diff --git a/.codecov.yml b/.codecov.yml index 6b88f948fe1..3395aa4b358 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,19 +1,25 @@ +codecov: + notify: + # GHA: 5, Jenkins: 11 + # Accurate as of April 1, 2025 + # Potential to change when Python versions change. We will allow + # codecov to start reporting when 80% of the reports are in. This + # will hopefully help avoid an issue where codecov is not tracking + # PR branches (see https://github.com/codecov/feedback/issues/692). + after_n_builds: 13 + wait_for_ci: false + require_ci_to_pass: false +comment: + after_n_builds: 13 coverage: - range: "50...100" + range: 70..98 status: + patch: + default: + # Force patches to be covered at the level of the codebase + threshold: 0.0 project: default: # Allow overall coverage to drop to avoid failures due to code # cleanup or CI unavailability/lag - threshold: 5% - patch: - default: - # Force patches to be covered at the level of the codebase - threshold: 0% -# ci: -# - !ci.appveyor.com -codecov: - notify: - # GHA: 4, Jenkins: 8 - after_n_builds: 12 # all - wait_for_ci: yes + threshold: 5.0 diff --git a/.coin-or/projDesc.xml b/.coin-or/projDesc.xml index 1ee247e100f..667b374abed 100644 --- a/.coin-or/projDesc.xml +++ b/.coin-or/projDesc.xml @@ -227,8 +227,8 @@ Carl D. Laird, Chair, Pyomo Management Committee, claird at andrew dot cmu dot e Use explicit overrides to disable use of automated version reporting. --> - 6.7.0 - 6.7.0 + 6.9.2 + 6.9.2 @@ -287,7 +287,7 @@ Carl D. Laird, Chair, Pyomo Management Committee, claird at andrew dot cmu dot e Any - Python 3.8, 3.9, 3.10, 3.11, 3.12 + Python 3.9, 3.10, 3.11, 3.12, 3.13 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 36b9898397f..ff10e62397b 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -45,4 +45,5 @@ ed13c8c65d6c3f56973887744be1c62a5d4756de 363a16a609f519b3edfdfcf40c66d6de7ac135af d024718991455519e09149ede53a38df1c067abe 017e21ee50d98d8b2f2083e6880f030025ed5378 +91972008d109322fc1a3b688333d67def2288625 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index a6b1df3cf9a..61a09df7258 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,6 +4,8 @@ about: Report a bug in Pyomo (command not working as expected, etc.) labels: "bug" --- + + ## Summary + ## Summary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4bd8e88bfed..5ab3eb16ed9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,6 +7,8 @@ + + ## Fixes # . ## Summary/Motivation: diff --git a/.github/workflows/release_wheel_creation.yml b/.github/workflows/release_wheel_creation.yml index 932b0d8eea6..419921c795d 100644 --- a/.github/workflows/release_wheel_creation.yml +++ b/.github/workflows/release_wheel_creation.yml @@ -4,6 +4,8 @@ on: push: tags: - '*' + schedule: + - cron: '0 0 3 * *' workflow_dispatch: inputs: git-ref: @@ -14,8 +16,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true -env: - PYOMO_SETUP_ARGS: "--with-cython --with-distributable-extensions" +defaults: + run: + shell: bash -l {0} jobs: native_wheels: @@ -24,36 +27,59 @@ jobs: strategy: fail-fast: true matrix: - os: [ubuntu-22.04, windows-latest, macos-latest] + os: [ubuntu-latest, windows-latest, macos-latest] arch: [all] - wheel-version: ['cp38*', 'cp39*', 'cp310*', 'cp311*', 'cp312*'] + wheel-version: ['cp39*', 'cp310*', 'cp311*', 'cp312*', 'cp313*'] include: - - wheel-version: 'cp38*' - TARGET: 'py38' - wheel-version: 'cp39*' TARGET: 'py39' + GLOBAL_OPTIONS: "--with-cython --with-distributable-extensions" - wheel-version: 'cp310*' TARGET: 'py310' + GLOBAL_OPTIONS: "--with-cython --with-distributable-extensions" - wheel-version: 'cp311*' TARGET: 'py311' + GLOBAL_OPTIONS: "--without-cython --with-distributable-extensions" - wheel-version: 'cp312*' TARGET: 'py312' + GLOBAL_OPTIONS: "--without-cython --with-distributable-extensions" + - wheel-version: 'cp313*' + TARGET: 'py313' + GLOBAL_OPTIONS: "--without-cython --with-distributable-extensions" + + # We use pure python for any Windows/python greater than 3.10 + exclude: + - wheel-version: 'cp311*' + os: windows-latest + - wheel-version: 'cp312*' + os: windows-latest + - wheel-version: 'cp313*' + os: windows-latest + steps: - uses: actions/checkout@v4 + - name: Create pyproject.toml + run: | + # Per the cibuildwheel documentation, you can technically use + # CIBW_BEFORE_BUILD to do these steps; however, as of the newest + # version (2.21.3) this feature does not work. This is a hack + # to make cibuildwheel recognize our pre-build requirements + echo -e '[build-system]\n\nrequires = [ "setuptools", "wheel", "cython", "pybind11" ]' > $GITHUB_WORKSPACE/pyproject.toml + cat $GITHUB_WORKSPACE/pyproject.toml + ls -la $GITHUB_WORKSPACE - name: Build wheels - uses: pypa/cibuildwheel@v2.16.5 + uses: pypa/cibuildwheel@v2.21.3 with: output-dir: dist env: CIBW_ARCHS_LINUX: "native" - CIBW_ARCHS_MACOS: "native arm64" - CIBW_ARCHS_WINDOWS: "native ARM64" - CIBW_SKIP: "*-musllinux*" + CIBW_ARCHS_MACOS: "x86_64 arm64" + CIBW_ARCHS_WINDOWS: "AMD64 ARM64" CIBW_BUILD: ${{ matrix.wheel-version }} + CIBW_SKIP: "*-musllinux*" CIBW_BUILD_VERBOSITY: 1 - CIBW_BEFORE_BUILD: pip install cython pybind11 - CIBW_CONFIG_SETTINGS: '--global-option="--with-cython --with-distributable-extensions"' + CIBW_ENVIRONMENT: PYOMO_SETUP_ARGS="${{ matrix.GLOBAL_OPTIONS }}" - uses: actions/upload-artifact@v4 with: name: native_wheels-${{ matrix.os }}-${{ matrix.TARGET }} @@ -65,21 +91,26 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-22.04] + os: [ubuntu-latest] arch: [all] - wheel-version: ['cp38*', 'cp39*', 'cp310*', 'cp311*', 'cp312*'] + wheel-version: ['cp39*', 'cp310*', 'cp311*', 'cp312*', 'cp313*'] include: - - wheel-version: 'cp38*' - TARGET: 'py38' - wheel-version: 'cp39*' TARGET: 'py39' + GLOBAL_OPTIONS: "--with-cython --with-distributable-extensions" - wheel-version: 'cp310*' TARGET: 'py310' + GLOBAL_OPTIONS: "--with-cython --with-distributable-extensions" - wheel-version: 'cp311*' TARGET: 'py311' + GLOBAL_OPTIONS: "--without-cython --with-distributable-extensions" - wheel-version: 'cp312*' TARGET: 'py312' + GLOBAL_OPTIONS: "--without-cython --with-distributable-extensions" + - wheel-version: 'cp313*' + TARGET: 'py313' + GLOBAL_OPTIONS: "--without-cython --with-distributable-extensions" steps: - uses: actions/checkout@v4 - name: Set up QEMU @@ -87,23 +118,57 @@ jobs: uses: docker/setup-qemu-action@v3 with: platforms: all + - name: Create pyproject.toml + run: | + # Per the cibuildwheel documentation, you can technically use + # CIBW_BEFORE_BUILD to do these steps; however, as of the newest + # version (2.21.3) this feature does not work. This is a hack + # to make cibuildwheel recognize our pre-build requirements + echo -e '[build-system]\n\nrequires = [ "setuptools", "wheel", "cython", "pybind11" ]' > $GITHUB_WORKSPACE/pyproject.toml + cat $GITHUB_WORKSPACE/pyproject.toml + ls -la $GITHUB_WORKSPACE - name: Build wheels - uses: pypa/cibuildwheel@v2.16.5 + uses: pypa/cibuildwheel@v2.21.3 with: output-dir: dist env: CIBW_ARCHS_LINUX: "aarch64" - CIBW_SKIP: "*-musllinux*" CIBW_BUILD: ${{ matrix.wheel-version }} + CIBW_SKIP: "*-musllinux*" CIBW_BUILD_VERBOSITY: 1 - CIBW_BEFORE_BUILD: pip install cython pybind11 - CIBW_CONFIG_SETTINGS: '--global-option="--with-cython --with-distributable-extensions"' + CIBW_ENVIRONMENT: PYOMO_SETUP_ARGS="${{ matrix.GLOBAL_OPTIONS }}" - uses: actions/upload-artifact@v4 with: name: alt_wheels-${{ matrix.os }}-${{ matrix.TARGET }} path: dist/*.whl overwrite: true + pure_python: + name: pure_python_wheel + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install twine wheel setuptools pybind11 + - name: Build pure python wheel + run: | + python setup.py --without-cython sdist --format=gztar bdist_wheel + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: purepythonwheel + path: dist/*.whl + overwrite: true + generictarball: name: ${{ matrix.TARGET }} runs-on: ${{ matrix.os }} @@ -114,7 +179,7 @@ jobs: include: - os: ubuntu-latest TARGET: generic_tarball - python-version: [3.8] + python-version: [3.9] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml index 77f47b505ff..47a36ef5917 100644 --- a/.github/workflows/test_branches.yml +++ b/.github/workflows/test_branches.yml @@ -21,15 +21,19 @@ defaults: env: PYTHONWARNINGS: ignore::UserWarning PYTHON_CORE_PKGS: wheel - PYPI_ONLY: z3-solver - PYPY_EXCLUDE: scipy numdifftools seaborn statsmodels + PYPI_ONLY: z3-solver linear-tree + PYPY_EXCLUDE: scipy numdifftools seaborn statsmodels linear-tree CACHE_VER: v221013.1 NEOS_EMAIL: tests@pyomo.org SRC_REF: ${{ github.head_ref || github.ref }} + PYOMO_WORKFLOW: branch jobs: lint: name: lint/style-and-typos + if: | + ${{ ! ( contains(github.event.pull_request.title, '[WIP]') || + github.event.pull_request.draft ) }} runs-on: ubuntu-latest steps: - name: Checkout Pyomo source @@ -40,12 +44,28 @@ jobs: python-version: '3.10' - name: Black Formatting Check run: | - pip install black + # Note v24.4.1 fails due to a bug in the parser + pip install 'black!=24.4.1' black . -S -C --check --diff --exclude examples/pyomobook/python-ch/BadIndent.py - name: Spell Check uses: crate-ci/typos@master with: config: ./.github/workflows/typos.toml + - name: URL Checker + if: env.PYOMO_WORKFLOW == 'branch' + uses: urlstechie/urlchecker-action@0.0.34 + with: + # A comma-separated list of file types to cover in the URL checks + file_types: .md,.rst,.py + # Choose whether to include file with no URLs in the prints. + print_all: false + # More verbose summary at the end of a run + verbose: true + # How many times to retry a failed request (defaults to 1) + retry_count: 3 + # Exclude Jenkins because it's behind a firewall; ignore RTD because + # a magically-generated string is triggering a failure + exclude_urls: https://pyomo-jenkins.sandia.gov/,https://pyomo.readthedocs.io/en/%s/errors.html build: @@ -56,57 +76,56 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python: ['3.12'] + python: [3.13] other: [""] category: [""] include: - os: ubuntu-latest - python: '3.12' + python: 3.13 + test_docs: 1 TARGET: linux PYENV: pip - os: macos-latest - python: '3.10' + python: 3.12 + test_docs: 1 TARGET: osx PYENV: pip - os: windows-latest - python: 3.9 + python: 3.11 + test_docs: 1 TARGET: win PYENV: conda PACKAGES: glpk pytest-qt filelock - os: ubuntu-latest - python: '3.11' + python: 3.11 other: /conda - skip_doctest: 1 TARGET: linux PYENV: conda PACKAGES: pytest-qt - os: ubuntu-latest - python: 3.9 + python: '3.10' other: /mpi mpi: 3 - skip_doctest: 1 TARGET: linux PYENV: conda - PACKAGES: mpi4py + PACKAGES: openmpi mpi4py - os: ubuntu-latest - python: '3.10' + python: 3.12 other: /cython setup_options: --with-cython - skip_doctest: 1 TARGET: linux PYENV: pip PACKAGES: cython - os: windows-latest - python: 3.8 + python: 3.9 other: /pip - skip_doctest: 1 TARGET: win PYENV: pip @@ -180,7 +199,7 @@ jobs: # Notes: # - install glpk # - pyodbc needs: gcc pkg-config unixodbc freetds - for pkg in bash pkg-config unixodbc freetds glpk; do + for pkg in bash pkg-config unixodbc freetds glpk ginac; do brew list $pkg || brew install $pkg done @@ -192,13 +211,15 @@ jobs: # - install glpk # - ipopt needs: libopenblas-dev gfortran liblapack-dev sudo apt-get -o Dir::Cache=${GITHUB_WORKSPACE}/cache/os \ - install libopenblas-dev gfortran liblapack-dev glpk-utils + install libopenblas-dev gfortran liblapack-dev glpk-utils \ + libginac-dev sudo chmod -R 777 ${GITHUB_WORKSPACE}/cache/os - name: Update Windows if: matrix.TARGET == 'win' run: | echo "SETUPTOOLS_USE_DISTUTILS=local" >> $GITHUB_ENV + choco install pkgconfiglite - name: Set up Python ${{ matrix.python }} if: matrix.PYENV == 'pip' @@ -217,7 +238,7 @@ jobs: # have support for OSX. - name: Set up UI testing infrastructure if: ${{ matrix.TARGET != 'osx' }} - uses: pyvista/setup-headless-display-action@v2 + uses: pyvista/setup-headless-display-action@v3 with: qt: true pyvista: false @@ -263,11 +284,12 @@ jobs: if test -z "${{matrix.slim}}"; then python -m pip install --cache-dir cache/pip cplex docplex \ || echo "WARNING: CPLEX Community Edition is not available" - python -m pip install --cache-dir cache/pip \ - -i https://pypi.gurobi.com gurobipy==10.0.3 \ + python -m pip install --cache-dir cache/pip gurobipy \ || echo "WARNING: Gurobi is not available" python -m pip install --cache-dir cache/pip xpress \ || echo "WARNING: Xpress Community Edition is not available" + python -m pip install --cache-dir cache/pip maingopy \ + || echo "WARNING: MAiNGO is not available" if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -287,15 +309,21 @@ jobs: # Set up environment conda config --set always_yes yes conda config --set auto_update_conda false + conda config --set channel_priority strict conda config --remove channels defaults conda config --append channels nodefaults conda config --append channels conda-forge # Try to install mamba - conda install --update-deps -q -y -n base conda-libmamba-solver \ - || MAMBA_FAILED=1 - if test -z "$MAMBA_FAILED"; then - echo "*** Activating the mamba environment solver ***" - conda config --set solver libmamba + CONDA_VER=$(conda --version | cut -d\ -f2) + if test 23.10 = "`echo -e "23.10\n$CONDA_VER" | sort -V | tail -1`"; then + # Note: removed '--update-deps' on 2025-02-28 to work around + # broken libffi(?) + conda install -q -y -n base conda-libmamba-solver \ + || MAMBA_FAILED=1 + if test -z "$MAMBA_FAILED"; then + echo "*** Activating the mamba environment solver ***" + conda config --set solver libmamba + fi fi # Add the rest of the channels conda config --append channels gurobi @@ -320,6 +348,10 @@ jobs: if test "${{matrix.TARGET}}" == linux; then EXCLUDE="casadi numdifftools $EXCLUDE" fi + if [[ "${{matrix.TARGET}}" == win && "${{matrix.python}}" == "3.13" ]]; then + # As of Nov 7, 2024, qtconsole is not compatible with python 3.13 on win + EXCLUDE="qtconsole $EXCLUDE" + fi EXCLUDE=`echo "$EXCLUDE" | xargs` if test -n "$EXCLUDE"; then for WORD in $EXCLUDE; do @@ -333,16 +365,35 @@ jobs: CONDA_DEPENDENCIES="$CONDA_DEPENDENCIES $PKG" fi done + echo "" echo "*** Install Pyomo dependencies ***" + # For windows, cannot use newer setuptools because of APPSI compilation issues + if test "${{matrix.TARGET}}" == 'win'; then + CONDA_DEPENDENCIES="$CONDA_DEPENDENCIES setuptools<74.0.0" + fi # Note: this will fail the build if any installation fails (or # possibly if it outputs messages to stderr) conda install --update-deps -q -y $CONDA_DEPENDENCIES if test -z "${{matrix.slim}}"; then + # xpress.init() (xpress 9.5.1 from conda) hangs indefinitely + # on GHA/Windows under Python 3.10 and 3.11. Exclude that + # release on that platform. + if [[ ${{matrix.TARGET}} == win && ${{matrix.python}} =~ 3.1[01] ]]; then + # We would like to just use something like: + # - "!=9.5.1" (conda errors) + # - "<9.5.1|>9.5.1" (conda installs 9.1.2, which also hangs) + # - "<=9.5.0|>9.5.1" (conda seg faults) + XPRESS='xpress>=9.5.2' + else + XPRESS='xpress' + fi + TIMEOUT_MSG="TIMEOUT: killing conda install process" PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g') echo "Installing for $PYVER" - for PKG in 'cplex>=12.10' docplex 'gurobi=10.0.3' xpress cyipopt pymumps scip; do + for PKG in 'cplex>=12.10' docplex gurobi "$XPRESS" cyipopt pymumps scip; do echo "" echo "*** Install $PKG ***" + echo "" # conda can literally take an hour to determine that a # package is not available. Perform a quick search to see # if the package is available for this interpreter before @@ -361,15 +412,37 @@ jobs: if test -z "$_ISPY" -o -n "$_PYOK"; then echo "" echo "... INSTALLING $PKG" - conda install -y "$PKG" || _BUILDS="" + # Because conda can occasionally hang + # indefinitely (lately while installing SCIP), + # we will run "conda install" in a subshell and + # kill it after 5-minutes. + (conda install -q -y $PKG |& tee conda.log) & + conda_subshell_pid=$! + sleep 1 + conda_pid=$(ps | grep conda | sed 's/^ *//' | sed 's/ .*//') + # Only timeout the conda process if it's not to + # the installation phase yet. Signalling conda + # after it has started the installation process + # can irrecoverably corrupt the environment. + (sleep 300 \ + && if test `grep 'Package Plan' conda.log | wc -l` -eq 0; \ + then echo "$TIMEOUT_MSG $conda_pid (TERM)" \ + && kill -TERM $conda_pid 2>/dev/null ; fi) & + timeout_pid=$! + wait $conda_subshell_pid || _BUILDS="" + kill $timeout_pid 2>/dev/null \ + || echo "TIMEOUT: time limit expired" fi fi + echo "" if test -z "$_BUILDS"; then echo "WARNING: $PKG is not available" fi done fi # Re-try Pyomo (optional) dependencies with pip + echo "" + echo "Installing packages only available on PyPI" if test -n "$PYPI_DEPENDENCIES"; then python -m pip install --cache-dir cache/pip $PYPI_DEPENDENCIES fi @@ -401,6 +474,9 @@ jobs: mkdir -p "$DOWNLOAD_DIR" echo "TPL_DIR=$TPL_DIR" >> $GITHUB_ENV echo "DOWNLOAD_DIR=$DOWNLOAD_DIR" >> $GITHUB_ENV + # Create a new PYOMO_PATH variable so we can ensure that we are actually + # getting the right PATH at the end + echo "PYOMO_PATH=$PATH" >> $GITHUB_ENV - name: Install Ipopt if: ${{ ! matrix.slim }} @@ -408,14 +484,13 @@ jobs: IPOPT_DIR=$TPL_DIR/ipopt echo "$IPOPT_DIR" >> $GITHUB_PATH echo "LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$IPOPT_DIR" >> $GITHUB_ENV + echo "DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:$IPOPT_DIR" >> $GITHUB_ENV + NEW_PYOMO_PATH="$IPOPT_DIR:$PYOMO_PATH" + echo "PYOMO_PATH=$NEW_PYOMO_PATH" >> $GITHUB_ENV mkdir -p $IPOPT_DIR IPOPT_TAR=${DOWNLOAD_DIR}/ipopt.tar.gz if test ! -e $IPOPT_TAR; then echo "...downloading Ipopt" - if test "${{matrix.TARGET}}" == osx; then - echo "IDAES Ipopt not available on OSX" - exit 0 - fi URL=https://github.com/IDAES/idaes-ext RELEASE=$(curl --max-time 150 --retry 8 \ -L -s -H 'Accept: application/json' ${URL}/releases/latest) @@ -423,8 +498,12 @@ jobs: URL=${URL}/releases/download/$VER if test "${{matrix.TARGET}}" == linux; then curl --max-time 150 --retry 8 \ - -L $URL/idaes-solvers-ubuntu2004-x86_64.tar.gz \ - > $IPOPT_TAR + -L $URL/idaes-solvers-ubuntu2204-x86_64.tar.gz \ + -o $IPOPT_TAR + elif test "${{matrix.TARGET}}" == osx; then + curl --max-time 150 --retry 8 \ + -L $URL/idaes-solvers-darwin-aarch64.tar.gz \ + -o $IPOPT_TAR else curl --max-time 150 --retry 8 \ -L $URL/idaes-solvers-windows-x86_64.tar.gz \ @@ -432,7 +511,11 @@ jobs: fi fi cd $IPOPT_DIR - tar -xzi < $IPOPT_TAR + if test "${{matrix.TARGET}}" == win; then + tar -xzi < $IPOPT_TAR + else + tar -xzf $IPOPT_TAR + fi echo "" echo "$IPOPT_DIR" ls -l $IPOPT_DIR @@ -484,7 +567,9 @@ jobs: - name: Install GAMS Python bindings if: ${{ ! matrix.slim }} run: | - GAMS_DIR="${env:TPL_DIR}/gams" + GAMS_DIR="$TPL_DIR/gams" + NEW_PYOMO_PATH="$GAMS_DIR:$PYOMO_PATH" + echo "PYOMO_PATH=$NEW_PYOMO_PATH" >> $GITHUB_ENV py_ver=$($PYTHON_EXE -c 'import sys;v="_%s%s" % sys.version_info[:2] \ ;print(v if v != "_27" else "")') if test -e $GAMS_DIR/apifiles/Python/api$py_ver; then @@ -501,7 +586,18 @@ jobs: $BARON_DIR = "${env:TPL_DIR}/baron" echo "$BARON_DIR" | ` Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - $URL = "https://www.minlp.com/downloads/xecs/baron/current/" + $CURRENT_PYOMO_PATH="${env:PYOMO_PATH}" + # Prepend BARON_DIR with appropriate path separator + if ( "${{matrix.TARGET}}" -eq "win" ) { + $PATH_SEPARATOR = ";" + } else { + $PATH_SEPARATOR = ":" + } + $NEW_PYOMO_PATH = "$BARON_DIR$PATH_SEPARATOR$CURRENT_PYOMO_PATH" + echo "New PYOMO_PATH: $NEW_PYOMO_PATH" + echo "PYOMO_PATH=$NEW_PYOMO_PATH" | ` + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $URL = "https://minlp-downloads.nyc3.cdn.digitaloceanspaces.com/xecs/baron/current/" if ( "${{matrix.TARGET}}" -eq "win" ) { $INSTALLER = "${env:DOWNLOAD_DIR}/baron_install.exe" $URL += "baron-win64.exe" @@ -535,6 +631,8 @@ jobs: run: | GJH_DIR="$TPL_DIR/gjh" echo "${GJH_DIR}" >> $GITHUB_PATH + NEW_PYOMO_PATH="$GJH_DIR:$PYOMO_PATH" + echo "PYOMO_PATH=$NEW_PYOMO_PATH" >> $GITHUB_ENV INSTALL_DIR="${DOWNLOAD_DIR}/gjh" if test ! -e "$INSTALL_DIR/bin"; then mkdir -p "$INSTALL_DIR" @@ -597,7 +695,7 @@ jobs: > ${SITE_PACKAGES}/run_coverage_at_startup.pth - name: Download and install extensions - if: ${{ ! matrix.slim }} + if: ${{ ! matrix.slim && matrix.category != 'notests' }} run: | echo "" echo "Pyomo download-extensions" @@ -610,34 +708,48 @@ jobs: - name: Report pyomo plugin information run: | + # MRM / Jan 9, 2025: We update the PATH manually to make sure we + # capture all of our changes. This is necessary because of an + # issue with how the PATH rearranges on Windows. + # Issue: https://github.com/actions/runner-images/issues/11328 + export PATH=$PYOMO_PATH echo "$PATH" pyomo help --solvers || exit 1 pyomo help --transformations || exit 1 pyomo help --writers || exit 1 - name: Run Pyomo tests - if: matrix.mpi == 0 + if: ${{ matrix.mpi == 0 && matrix.category != 'notests' }} run: | + export PATH=$PYOMO_PATH $PYTHON_EXE -m pytest -v \ -W ignore::Warning ${{matrix.category}} \ pyomo `pwd`/pyomo-model-libraries \ `pwd`/examples `pwd`/doc --junitxml="TEST-pyomo.xml" - name: Run Pyomo MPI tests - if: matrix.mpi != 0 + if: ${{ matrix.mpi != 0 && matrix.category != 'notests' }} run: | # Manually invoke the DAT parser so that parse_table_datacmds.py # is fully generated by a single process before invoking MPI $PYTHON_EXE -c "from pyomo.dataportal.parse_datacmds import \ parse_data_commands; parse_data_commands(data='')" # Note: if we are testing with openmpi, add '--oversubscribe' - mpirun -np ${{matrix.mpi}} pytest -v \ + mpirun -np ${{matrix.mpi}} -oversubscribe pytest -v \ --junit-xml=TEST-pyomo-mpi.xml \ -m "mpi" -W ignore::Warning \ pyomo `pwd`/pyomo-model-libraries + - name: Build documentation + if: matrix.build_docs != 0 + run: | + sudo apt-get -y install graphviz latexmk texlive texlive-latex-extra \ + texlive-pictures + make -C doc/OnlineDocs html + make -C doc/OnlineDocs latexpdf + - name: Run documentation tests - if: matrix.skip_doctest == 0 + if: matrix.test_docs != 0 run: | make -C doc/OnlineDocs doctest -d @@ -651,6 +763,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{github.job}}_${{env.GHA_JOBGROUP}}-${{env.GHA_JOBNAME}} + include-hidden-files: true path: | .coverage coverage.xml @@ -660,17 +773,17 @@ jobs: bare-python-env: - name: linux/3.8/bare-env + name: linux/3.9/bare-env runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout Pyomo source uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install Pyomo run: | @@ -702,18 +815,19 @@ jobs: cover: name: process-coverage-${{ matrix.TARGET }} needs: build - if: ${{ false }} # turn off for branches + # run even if a build job fails, but not if canceled (except for branches) + if: ${{ false }} runs-on: ${{ matrix.os }} timeout-minutes: 10 strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-13, windows-latest] include: - os: ubuntu-latest TARGET: linux - - os: macos-latest + - os: macos-13 TARGET: osx - os: windows-latest TARGET: win @@ -728,17 +842,17 @@ jobs: # id: pip-cache # with: # path: cache/pip - # key: pip-${{env.CACHE_VER}}.0-${{runner.os}}-3.8 + # key: pip-${{env.CACHE_VER}}.0-${{runner.os}}-3.9 - name: Download build artifacts uses: actions/download-artifact@v4 with: path: artifacts - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install Python Packages (pip) shell: bash # DO NOT REMOVE: see note above @@ -773,26 +887,6 @@ jobs: $PYTHON_EXE -c "from pyomo.dataportal.parse_datacmds import \ parse_data_commands; parse_data_commands(data='')" - - name: Update codecov uploader - run: | - set +e - CODECOV="${GITHUB_WORKSPACE}/codecov.sh" - echo "CODECOV=$CODECOV" >> $GITHUB_ENV - for i in `seq 3`; do - echo "Downloading current codecov script (attempt ${i})" - curl -L https://codecov.io/bash -o $CODECOV - if test $? == 0; then - break - fi - DELAY=$(( RANDOM % 30 + 30)) - echo "Pausing $DELAY seconds before re-attempting download" - sleep $DELAY - done - if test ! -e $CODECOV; then - echo "Failed to download codecov.sh" - exit 1 - fi - - name: Combine coverage reports if: github.repository_owner == 'Pyomo' || github.ref != 'refs/heads/main' run: | @@ -831,7 +925,7 @@ jobs: - name: Upload codecov reports if: github.repository_owner == 'Pyomo' || github.ref != 'refs/heads/main' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage.xml token: ${{ secrets.PYOMO_CODECOV_TOKEN }} @@ -843,10 +937,10 @@ jobs: if: | hashFiles('coverage-other.xml') != '' && (github.repository_owner == 'Pyomo' || github.ref != 'refs/heads/main') - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage-other.xml token: ${{ secrets.PYOMO_CODECOV_TOKEN }} name: ${{ matrix.TARGET }}/other - flags: ${{ matrix.TARGET }},other + flags: ${{ matrix.TARGET }}_other fail_ci_if_error: true diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 87d6aa4d7a8..2af9b4469a8 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -7,6 +7,11 @@ on: pull_request: branches: - main + types: + - opened + - reopened + - synchronize + - ready_for_review workflow_dispatch: inputs: git-ref: @@ -24,15 +29,22 @@ defaults: env: PYTHONWARNINGS: ignore::UserWarning PYTHON_CORE_PKGS: wheel - PYPI_ONLY: z3-solver - PYPY_EXCLUDE: scipy numdifftools seaborn statsmodels + PYPI_ONLY: z3-solver linear-tree + PYPY_EXCLUDE: scipy numdifftools seaborn statsmodels linear-tree CACHE_VER: v221013.1 NEOS_EMAIL: tests@pyomo.org SRC_REF: ${{ github.head_ref || github.ref }} + PYOMO_WORKFLOW: | + ${{ ( contains(github.event.pull_request.title, '[WIP]') || + github.event.pull_request.draft ) && 'draft_pr' + || github.event.pull_request.number && 'pr' || 'main' }} jobs: lint: name: lint/style-and-typos + if: | + ${{ ! ( contains(github.event.pull_request.title, '[WIP]') || + github.event.pull_request.draft ) }} runs-on: ubuntu-latest steps: - name: Checkout Pyomo source @@ -43,12 +55,28 @@ jobs: python-version: '3.10' - name: Black Formatting Check run: | - pip install black + # Note v24.4.1 fails due to a bug in the parser + pip install 'black!=24.4.1' black . -S -C --check --diff --exclude examples/pyomobook/python-ch/BadIndent.py - name: Spell Check uses: crate-ci/typos@master with: config: ./.github/workflows/typos.toml + - name: URL Checker + if: env.PYOMO_WORKFLOW == 'branch' + uses: urlstechie/urlchecker-action@0.0.34 + with: + # A comma-separated list of file types to cover in the URL checks + file_types: .md,.rst,.py + # Choose whether to include file with no URLs in the prints. + print_all: false + # More verbose summary at the end of a run + verbose: true + # How many times to retry a failed request (defaults to 1) + retry_count: 3 + # Exclude Jenkins because it's behind a firewall; ignore RTD because + # a magically-generated string is triggering a failure + exclude_urls: https://pyomo-jenkins.sandia.gov/,https://pyomo.readthedocs.io/en/%s/errors.html build: @@ -60,86 +88,99 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: [ 3.8, 3.9, '3.10', '3.11', '3.12' ] + python: [ 3.9, '3.10', 3.11, 3.12, 3.13 ] other: [""] category: [""] include: - os: ubuntu-latest + test_docs: 1 TARGET: linux PYENV: pip - os: macos-latest + test_docs: 1 TARGET: osx PYENV: pip - os: windows-latest + test_docs: 1 TARGET: win PYENV: conda PACKAGES: glpk pytest-qt filelock - os: ubuntu-latest - python: '3.11' + python: 3.11 other: /conda - skip_doctest: 1 TARGET: linux PYENV: conda PACKAGES: pytest-qt - os: ubuntu-latest - python: 3.9 + python: '3.10' other: /mpi mpi: 3 - skip_doctest: 1 TARGET: linux PYENV: conda - PACKAGES: mpi4py + PACKAGES: openmpi mpi4py - os: ubuntu-latest - python: '3.11' - other: /singletest - category: "-m 'neos or importtest'" - skip_doctest: 1 - TARGET: linux - PYENV: pip - - - os: ubuntu-latest - python: '3.10' + python: 3.12 other: /cython setup_options: --with-cython - skip_doctest: 1 TARGET: linux PYENV: pip PACKAGES: cython - os: windows-latest - python: 3.8 + python: 3.9 other: /pip - skip_doctest: 1 TARGET: win PYENV: pip - os: ubuntu-latest - python: 3.8 + python: 3.11 + other: /singletest + category: "-m 'neos or importtest'" + TARGET: linux + PYENV: pip + + - os: ubuntu-latest + python: 3.9 other: /slim slim: 1 - skip_doctest: 1 TARGET: linux PYENV: pip - os: ubuntu-latest - python: 3.9 + python: 3.12 + other: /numpy2 + slim: 1 + TARGET: linux + PYENV: pip + PACKAGES: "gurobipy dill numpy>2.0 scipy networkx" + + - os: ubuntu-latest + python: '3.10' other: /pyutilib TARGET: linux PYENV: pip PACKAGES: pyutilib - os: ubuntu-latest - python: pypy-3.9 - skip_doctest: 1 + python: 'pypy-3.10' TARGET: linux PYENV: pip + - os: ubuntu-latest + python: 3.11 + other: /rtd + category: notests + build_docs: 1 + TARGET: linux + PYENV: pip + + steps: - name: Checkout Pyomo source uses: actions/checkout@v4 @@ -210,7 +251,7 @@ jobs: # Notes: # - install glpk # - pyodbc needs: gcc pkg-config unixodbc freetds - for pkg in bash pkg-config unixodbc freetds glpk; do + for pkg in bash pkg-config unixodbc freetds glpk ginac; do brew list $pkg || brew install $pkg done @@ -222,13 +263,15 @@ jobs: # - install glpk # - ipopt needs: libopenblas-dev gfortran liblapack-dev sudo apt-get -o Dir::Cache=${GITHUB_WORKSPACE}/cache/os \ - install libopenblas-dev gfortran liblapack-dev glpk-utils + install libopenblas-dev gfortran liblapack-dev glpk-utils \ + libginac-dev sudo chmod -R 777 ${GITHUB_WORKSPACE}/cache/os - name: Update Windows if: matrix.TARGET == 'win' run: | echo "SETUPTOOLS_USE_DISTUTILS=local" >> $GITHUB_ENV + choco install pkgconfiglite - name: Set up Python ${{ matrix.python }} if: matrix.PYENV == 'pip' @@ -247,7 +290,7 @@ jobs: # have support for OSX. - name: Set up UI testing infrastructure if: ${{ matrix.TARGET != 'osx' }} - uses: pyvista/setup-headless-display-action@v2 + uses: pyvista/setup-headless-display-action@v3 with: qt: true pyvista: false @@ -293,11 +336,12 @@ jobs: if test -z "${{matrix.slim}}"; then python -m pip install --cache-dir cache/pip cplex docplex \ || echo "WARNING: CPLEX Community Edition is not available" - python -m pip install --cache-dir cache/pip \ - -i https://pypi.gurobi.com gurobipy==10.0.3 \ + python -m pip install --cache-dir cache/pip gurobipy \ || echo "WARNING: Gurobi is not available" python -m pip install --cache-dir cache/pip xpress \ || echo "WARNING: Xpress Community Edition is not available" + python -m pip install --cache-dir cache/pip maingopy \ + || echo "WARNING: MAiNGO is not available" if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -317,15 +361,21 @@ jobs: # Set up environment conda config --set always_yes yes conda config --set auto_update_conda false + conda config --set channel_priority strict conda config --remove channels defaults conda config --append channels nodefaults conda config --append channels conda-forge # Try to install mamba - conda install --update-deps -q -y -n base conda-libmamba-solver \ - || MAMBA_FAILED=1 - if test -z "$MAMBA_FAILED"; then - echo "*** Activating the mamba environment solver ***" - conda config --set solver libmamba + CONDA_VER=$(conda --version | cut -d\ -f2) + if test 23.10 = "`echo -e "23.10\n$CONDA_VER" | sort -V | tail -1`"; then + # Note: removed '--update-deps' on 2025-02-28 to work around + # broken libffi(?) + conda install -q -y -n base conda-libmamba-solver \ + || MAMBA_FAILED=1 + if test -z "$MAMBA_FAILED"; then + echo "*** Activating the mamba environment solver ***" + conda config --set solver libmamba + fi fi # Add the rest of the channels conda config --append channels gurobi @@ -350,6 +400,10 @@ jobs: if test "${{matrix.TARGET}}" == linux; then EXCLUDE="casadi numdifftools $EXCLUDE" fi + if [[ "${{matrix.TARGET}}" == win && "${{matrix.python}}" == "3.13" ]]; then + # As of Nov 7, 2024, qtconsole is not compatible with python 3.13 on win + EXCLUDE="qtconsole $EXCLUDE" + fi EXCLUDE=`echo "$EXCLUDE" | xargs` if test -n "$EXCLUDE"; then for WORD in $EXCLUDE; do @@ -363,16 +417,35 @@ jobs: CONDA_DEPENDENCIES="$CONDA_DEPENDENCIES $PKG" fi done + echo "" echo "*** Install Pyomo dependencies ***" + # For windows, cannot use newer setuptools because of APPSI compilation issues + if test "${{matrix.TARGET}}" == 'win'; then + CONDA_DEPENDENCIES="$CONDA_DEPENDENCIES setuptools<74.0.0" + fi # Note: this will fail the build if any installation fails (or # possibly if it outputs messages to stderr) conda install --update-deps -q -y $CONDA_DEPENDENCIES if test -z "${{matrix.slim}}"; then + # xpress.init() (xpress 9.5.1 from conda) hangs indefinitely + # on GHA/Windows under Python 3.10 and 3.11. Exclude that + # release on that platform. + if [[ ${{matrix.TARGET}} == win && ${{matrix.python}} =~ 3.1[01] ]]; then + # We would like to just use something like: + # - "!=9.5.1" (conda errors) + # - "<9.5.1|>9.5.1" (conda installs 9.1.2, which also hangs) + # - "<=9.5.0|>9.5.1" (conda seg faults) + XPRESS='xpress>=9.5.2' + else + XPRESS='xpress' + fi + TIMEOUT_MSG="TIMEOUT: killing conda install process" PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g') echo "Installing for $PYVER" - for PKG in 'cplex>=12.10' docplex 'gurobi=10.0.3' xpress cyipopt pymumps scip; do + for PKG in 'cplex>=12.10' docplex gurobi "$XPRESS" cyipopt pymumps scip; do echo "" echo "*** Install $PKG ***" + echo "" # conda can literally take an hour to determine that a # package is not available. Perform a quick search to see # if the package is available for this interpreter before @@ -391,15 +464,37 @@ jobs: if test -z "$_ISPY" -o -n "$_PYOK"; then echo "" echo "... INSTALLING $PKG" - conda install -y "$PKG" || _BUILDS="" + # Because conda can occasionally hang + # indefinitely (lately while installing SCIP), + # we will run "conda install" in a subshell and + # kill it after 5-minutes. + (conda install -q -y $PKG |& tee conda.log) & + conda_subshell_pid=$! + sleep 1 + conda_pid=$(ps | grep conda | sed 's/^ *//' | sed 's/ .*//') + # Only timeout the conda process if it's not to + # the installation phase yet. Signalling conda + # after it has started the installation process + # can irrecoverably corrupt the environment. + (sleep 300 \ + && if test `grep 'Package Plan' conda.log | wc -l` -eq 0; \ + then echo "$TIMEOUT_MSG $conda_pid (TERM)" \ + && kill -TERM $conda_pid 2>/dev/null ; fi) & + timeout_pid=$! + wait $conda_subshell_pid || _BUILDS="" + kill $timeout_pid 2>/dev/null \ + || echo "TIMEOUT: time limit expired" fi fi + echo "" if test -z "$_BUILDS"; then echo "WARNING: $PKG is not available" fi done fi # Re-try Pyomo (optional) dependencies with pip + echo "" + echo "Installing packages only available on PyPI" if test -n "$PYPI_DEPENDENCIES"; then python -m pip install --cache-dir cache/pip $PYPI_DEPENDENCIES fi @@ -431,6 +526,9 @@ jobs: mkdir -p "$DOWNLOAD_DIR" echo "TPL_DIR=$TPL_DIR" >> $GITHUB_ENV echo "DOWNLOAD_DIR=$DOWNLOAD_DIR" >> $GITHUB_ENV + # Create a new PYOMO_PATH variable so we can ensure that we are actually + # getting the right PATH at the end + echo "PYOMO_PATH=$PATH" >> $GITHUB_ENV - name: Install Ipopt if: ${{ ! matrix.slim }} @@ -438,14 +536,13 @@ jobs: IPOPT_DIR=$TPL_DIR/ipopt echo "$IPOPT_DIR" >> $GITHUB_PATH echo "LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$IPOPT_DIR" >> $GITHUB_ENV + echo "DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:$IPOPT_DIR" >> $GITHUB_ENV + NEW_PYOMO_PATH="$IPOPT_DIR:$PYOMO_PATH" + echo "PYOMO_PATH=$NEW_PYOMO_PATH" >> $GITHUB_ENV mkdir -p $IPOPT_DIR IPOPT_TAR=${DOWNLOAD_DIR}/ipopt.tar.gz if test ! -e $IPOPT_TAR; then echo "...downloading Ipopt" - if test "${{matrix.TARGET}}" == osx; then - echo "IDAES Ipopt not available on OSX" - exit 0 - fi URL=https://github.com/IDAES/idaes-ext RELEASE=$(curl --max-time 150 --retry 8 \ -L -s -H 'Accept: application/json' ${URL}/releases/latest) @@ -453,8 +550,12 @@ jobs: URL=${URL}/releases/download/$VER if test "${{matrix.TARGET}}" == linux; then curl --max-time 150 --retry 8 \ - -L $URL/idaes-solvers-ubuntu2004-x86_64.tar.gz \ - > $IPOPT_TAR + -L $URL/idaes-solvers-ubuntu2204-x86_64.tar.gz \ + -o $IPOPT_TAR + elif test "${{matrix.TARGET}}" == osx; then + curl --max-time 150 --retry 8 \ + -L $URL/idaes-solvers-darwin-aarch64.tar.gz \ + -o $IPOPT_TAR else curl --max-time 150 --retry 8 \ -L $URL/idaes-solvers-windows-x86_64.tar.gz \ @@ -462,7 +563,11 @@ jobs: fi fi cd $IPOPT_DIR - tar -xzi < $IPOPT_TAR + if test "${{matrix.TARGET}}" == win; then + tar -xzi < $IPOPT_TAR + else + tar -xzf $IPOPT_TAR + fi echo "" echo "$IPOPT_DIR" ls -l $IPOPT_DIR @@ -514,7 +619,9 @@ jobs: - name: Install GAMS Python bindings if: ${{ ! matrix.slim }} run: | - GAMS_DIR="${env:TPL_DIR}/gams" + GAMS_DIR="$TPL_DIR/gams" + NEW_PYOMO_PATH="$GAMS_DIR:$PYOMO_PATH" + echo "PYOMO_PATH=$NEW_PYOMO_PATH" >> $GITHUB_ENV py_ver=$($PYTHON_EXE -c 'import sys;v="_%s%s" % sys.version_info[:2] \ ;print(v if v != "_27" else "")') if test -e $GAMS_DIR/apifiles/Python/api$py_ver; then @@ -531,7 +638,18 @@ jobs: $BARON_DIR = "${env:TPL_DIR}/baron" echo "$BARON_DIR" | ` Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - $URL = "https://www.minlp.com/downloads/xecs/baron/current/" + $CURRENT_PYOMO_PATH="${env:PYOMO_PATH}" + # Prepend BARON_DIR with appropriate path separator + if ( "${{matrix.TARGET}}" -eq "win" ) { + $PATH_SEPARATOR = ";" + } else { + $PATH_SEPARATOR = ":" + } + $NEW_PYOMO_PATH = "$BARON_DIR$PATH_SEPARATOR$CURRENT_PYOMO_PATH" + echo "New PYOMO_PATH: $NEW_PYOMO_PATH" + echo "PYOMO_PATH=$NEW_PYOMO_PATH" | ` + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $URL = "https://minlp-downloads.nyc3.cdn.digitaloceanspaces.com/xecs/baron/current/" if ( "${{matrix.TARGET}}" -eq "win" ) { $INSTALLER = "${env:DOWNLOAD_DIR}/baron_install.exe" $URL += "baron-win64.exe" @@ -565,6 +683,8 @@ jobs: run: | GJH_DIR="$TPL_DIR/gjh" echo "${GJH_DIR}" >> $GITHUB_PATH + NEW_PYOMO_PATH="$GJH_DIR:$PYOMO_PATH" + echo "PYOMO_PATH=$NEW_PYOMO_PATH" >> $GITHUB_ENV INSTALL_DIR="${DOWNLOAD_DIR}/gjh" if test ! -e "$INSTALL_DIR/bin"; then mkdir -p "$INSTALL_DIR" @@ -627,7 +747,7 @@ jobs: > ${SITE_PACKAGES}/run_coverage_at_startup.pth - name: Download and install extensions - if: ${{ ! matrix.slim }} + if: ${{ ! matrix.slim && matrix.category != 'notests' }} run: | echo "" echo "Pyomo download-extensions" @@ -640,34 +760,48 @@ jobs: - name: Report pyomo plugin information run: | + # MRM / Jan 9, 2025: We update the PATH manually to make sure we + # capture all of our changes. This is necessary because of an + # issue with how the PATH rearranges on Windows. + # Issue: https://github.com/actions/runner-images/issues/11328 + export PATH=$PYOMO_PATH echo "$PATH" pyomo help --solvers || exit 1 pyomo help --transformations || exit 1 pyomo help --writers || exit 1 - name: Run Pyomo tests - if: matrix.mpi == 0 + if: ${{ matrix.mpi == 0 && matrix.category != 'notests' }} run: | + export PATH=$PYOMO_PATH $PYTHON_EXE -m pytest -v \ -W ignore::Warning ${{matrix.category}} \ pyomo `pwd`/pyomo-model-libraries \ `pwd`/examples `pwd`/doc --junitxml="TEST-pyomo.xml" - name: Run Pyomo MPI tests - if: matrix.mpi != 0 + if: ${{ matrix.mpi != 0 && matrix.category != 'notests' }} run: | # Manually invoke the DAT parser so that parse_table_datacmds.py # is fully generated by a single process before invoking MPI $PYTHON_EXE -c "from pyomo.dataportal.parse_datacmds import \ parse_data_commands; parse_data_commands(data='')" # Note: if we are testing with openmpi, add '--oversubscribe' - mpirun -np ${{matrix.mpi}} pytest -v \ + mpirun -np ${{matrix.mpi}} -oversubscribe pytest -v \ --junit-xml=TEST-pyomo-mpi.xml \ -m "mpi" -W ignore::Warning \ pyomo `pwd`/pyomo-model-libraries + - name: Build documentation + if: matrix.build_docs != 0 + run: | + sudo apt-get -y install graphviz latexmk texlive texlive-latex-extra \ + texlive-pictures + make -C doc/OnlineDocs html + make -C doc/OnlineDocs latexpdf + - name: Run documentation tests - if: matrix.skip_doctest == 0 + if: matrix.test_docs != 0 run: | make -C doc/OnlineDocs doctest -d @@ -681,6 +815,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{github.job}}_${{env.GHA_JOBGROUP}}-${{env.GHA_JOBNAME}} + include-hidden-files: true path: | .coverage coverage.xml @@ -690,7 +825,7 @@ jobs: bare-python-env: - name: linux/3.8/bare-env + name: linux/3.9/bare-env needs: lint # the linter job is a prerequisite for PRs runs-on: ubuntu-latest timeout-minutes: 10 @@ -698,10 +833,10 @@ jobs: - name: Checkout Pyomo source uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install Pyomo run: | @@ -733,18 +868,19 @@ jobs: cover: name: process-coverage-${{ matrix.TARGET }} needs: build - if: always() # run even if a build job fails + # run even if a build job fails, but not if canceled (except for branches) + if: success() || failure() runs-on: ${{ matrix.os }} timeout-minutes: 10 strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-13, windows-latest] include: - os: ubuntu-latest TARGET: linux - - os: macos-latest + - os: macos-13 TARGET: osx - os: windows-latest TARGET: win @@ -759,17 +895,17 @@ jobs: # id: pip-cache # with: # path: cache/pip - # key: pip-${{env.CACHE_VER}}.0-${{runner.os}}-3.8 + # key: pip-${{env.CACHE_VER}}.0-${{runner.os}}-3.9 - name: Download build artifacts uses: actions/download-artifact@v4 with: path: artifacts - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install Python Packages (pip) shell: bash # DO NOT REMOVE: see note above @@ -804,26 +940,6 @@ jobs: $PYTHON_EXE -c "from pyomo.dataportal.parse_datacmds import \ parse_data_commands; parse_data_commands(data='')" - - name: Update codecov uploader - run: | - set +e - CODECOV="${GITHUB_WORKSPACE}/codecov.sh" - echo "CODECOV=$CODECOV" >> $GITHUB_ENV - for i in `seq 3`; do - echo "Downloading current codecov script (attempt ${i})" - curl -L https://codecov.io/bash -o $CODECOV - if test $? == 0; then - break - fi - DELAY=$(( RANDOM % 30 + 30)) - echo "Pausing $DELAY seconds before re-attempting download" - sleep $DELAY - done - if test ! -e $CODECOV; then - echo "Failed to download codecov.sh" - exit 1 - fi - - name: Combine coverage reports if: github.repository_owner == 'Pyomo' || github.ref != 'refs/heads/main' run: | @@ -862,7 +978,7 @@ jobs: - name: Upload codecov reports if: github.repository_owner == 'Pyomo' || github.ref != 'refs/heads/main' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage.xml token: ${{ secrets.PYOMO_CODECOV_TOKEN }} @@ -874,10 +990,10 @@ jobs: if: | hashFiles('coverage-other.xml') != '' && (github.repository_owner == 'Pyomo' || github.ref != 'refs/heads/main') - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage-other.xml token: ${{ secrets.PYOMO_CODECOV_TOKEN }} name: ${{ matrix.TARGET }}/other - flags: ${{ matrix.TARGET }},other + flags: ${{ matrix.TARGET }}_other fail_ci_if_error: true diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 23f94fc8afd..80d50477ca4 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -38,6 +38,37 @@ caf = "caf" WRONLY = "WRONLY" # Ignore the name Hax Hax = "Hax" +# Ignore dout (short for dual output in SAS solvers) +dout = "dout" # Big Sur Sur = "Sur" +# contrib package named mis and the acronym whence the name comes +mis = "mis" +MIS = "MIS" +# Ignore the shorthand ans for answer +ans = "ans" +# Ignore the keyword arange +arange = "arange" +# Ignore IIS +IIS = "IIS" +iis = "iis" +# Ignore PN +PN = "PN" +# Ignore hd +hd = "hd" +# Ignore opf +opf = "opf" +# Ignore FRE +FRE = "FRE" +# Ignore MCH +MCH = "MCH" +# Ignore RO +ro = "ro" +RO = "RO" +# Ignore EOF - end of file +EOF = "EOF" +# Ignore lst as shorthand for list +lst = "lst" +# Abbreviation of gamma (used in stochpdegas1_automatic.py) +gam = "gam" # AS NEEDED: Add More Words Below diff --git a/.github/workflows/url_check.yml b/.github/workflows/url_check.yml new file mode 100644 index 00000000000..797574574b4 --- /dev/null +++ b/.github/workflows/url_check.yml @@ -0,0 +1,32 @@ +name: URL Validation + +on: + schedule: + - cron: '0 3 * * 0' + workflow_dispatch: + inputs: + git-ref: + description: Git Hash (Optional) + required: false + +jobs: + url_check: + runs-on: ubuntu-latest + steps: + - name: Checkout Pyomo source + uses: actions/checkout@v4 + - name: URL Checker + uses: urlstechie/urlchecker-action@0.0.34 + with: + # A comma-separated list of file types to cover in the URL checks + file_types: .md,.rst,.py + # Choose whether to include file with no URLs in the prints. + print_all: false + # More verbose summary at the end of a run + verbose: true + # How many times to retry a failed request (defaults to 1) + retry_count: 3 + # Exclude: + # - Jenkins because it's behind a firewall + # - RTD because a magically-generated string triggers failures + exclude_urls: https://pyomo-jenkins.sandia.gov/,https://pyomo.readthedocs.io/en/%s/errors.html diff --git a/.gitignore b/.gitignore index 638dc70d13e..9550b0773f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,19 @@ +# temporary editor files +*~ +.#* +\#*# + # IDE configuration files .idea .spyder* .ropeproject .vscode + # Python generates numerous files when byte compiling / installing packages +__pycache__/ *.pyx -*.pyc -*.pyo -*.egg-info +*.py[cod] +*.egg-info/ # Documentation builds doc/OnlineDocs/_build @@ -17,6 +23,7 @@ doc/OnlineDocs/**/*.spy *.out pyomo/dataportal/parse_table_datacmds.py gurobi.log +cplex.log # Results from pytest --with-coverage .coverage @@ -24,7 +31,6 @@ gurobi.log # Jupyterhub/Jupyterlab checkpoints .ipynb_checkpoints -cplex.log # Mac tracking files *.DS_Store* diff --git a/.jenkins.sh b/.jenkins.sh index 37be6113ed9..45c401d2722 100644 --- a/.jenkins.sh +++ b/.jenkins.sh @@ -20,8 +20,11 @@ # # CODECOV_TOKEN: the token to use when uploading results to codecov.io # -# CODECOV_ARGS: additional arguments to pass to the codecov uploader -# (e.g., to support SSL certificates) +# CODECOV_SOURCE_BRANCH: passed to the 'codecov-cli' command; branch of Pyomo +# (e.g., to enable correct codecov uploads) +# +# CODECOV_REPO_OWNER: passed to the 'codecov-cli' command; owner of repo +# (e.g., to enable correct codecov uploads) # # DISABLE_COVERAGE: if nonempty, then coverage analysis is disabled # @@ -43,9 +46,6 @@ fi if test -z "$SLIM"; then export VENV_SYSTEM_PACKAGES='--system-site-packages' fi -if test ! -z "$CATEGORY"; then - export PY_CAT="-m $CATEGORY" -fi if test "$WORKSPACE" != "`pwd`"; then echo "ERROR: pwd is not WORKSPACE" @@ -122,10 +122,23 @@ if test -z "$MODE" -o "$MODE" == setup; then echo "PYOMO_CONFIG_DIR=$PYOMO_CONFIG_DIR" echo "" + # Call Pyomo build scripts to build TPLs that would normally be + # skipped by the pyomo download-extensions / build-extensions + # actions below + if [[ " $CATEGORY " == *" builders "* ]]; then + echo "" + echo "Running local build scripts..." + echo "" + set -x + python pyomo/contrib/simplification/build.py --build-deps || exit 1 + set +x + fi + # Use Pyomo to download & compile binary extensions i=0 while /bin/true; do i=$[$i+1] + echo "" echo "Downloading pyomo extensions (attempt $i)" pyomo download-extensions $PYOMO_DOWNLOAD_ARGS if test $? == 0; then @@ -178,7 +191,7 @@ if test -z "$MODE" -o "$MODE" == test; then python -m pytest -v \ -W ignore::Warning \ --junitxml="TEST-pyomo.xml" \ - $PY_CAT $TEST_SUITES $PYTEST_EXTRA_ARGS + -m "$CATEGORY" $TEST_SUITES $PYTEST_EXTRA_ARGS # Combine the coverage results and upload if test -z "$DISABLE_COVERAGE"; then @@ -192,22 +205,51 @@ if test -z "$MODE" -o "$MODE" == test; then # Note, that the PWD should still be $WORKSPACE/pyomo # coverage combine || exit 1 - coverage report -i + coverage report -i || exit 1 + coverage xml -i || exit 1 export OS=`uname` - if test -z "$CODECOV_TOKEN"; then - coverage xml - else - CODECOV_JOB_NAME=`echo ${JOB_NAME} | sed -r 's/^(.*autotest_)?Pyomo_([^\/]+).*/\2/'`.$BUILD_NUMBER.$python + if test -z "$PYOMO_SOURCE_SHA"; then + PYOMO_SOURCE_SHA=$GIT_COMMIT + fi + if test -n "$CODECOV_TOKEN" -a -n "$PYOMO_SOURCE_SHA"; then + _NAME=$(echo $JOB_NAME | sed -r 's/^(.*(autotest|Build)_)?([^\/]+).*/\3/') + _MATRIX=$(echo $JOB_NAME | sed -r "s/,/\n/g" | grep -v host \ + | sed -r 's/.*=//' | tr '\n' , | sed -r 's/,+$//') + CODECOV_JOB_NAME=${_NAME}/${_MATRIX}.${BUILD_NUMBER} + CODECOV_FLAG=$(echo $JOB_NAME | sed -r "s/,/\n/g" | grep CATEGORY \ + | sed -r 's/.*=//') + if test -z "$CODECOV_FLAG"; then + CODECOV_FLAG=linux + fi + if test -z "$CODECOV_REPO_OWNER"; then + if test -n "$PYOMO_SOURCE_REPO"; then + CODECOV_REPO_OWNER=$(echo "$PYOMO_SOURCE_REPO" | cut -d '/' -f 4) + elif test -n "$GIT_URL"; then + CODECOV_REPO_OWNER=$(echo "$GIT_URL" | cut -d '/' -f 4) + else + CODECOV_REPO_OWNER="" + fi + fi + if test -z "$CODECOV_SOURCE_BRANCH"; then + CODECOV_SOURCE_BRANCH=$(git branch -av --contains "$PYOMO_SOURCE_SHA" \ + | grep "${PYOMO_SOURCE_SHA:0:7}" | grep "/origin/" \ + | cut -d '/' -f 3 | cut -d' ' -f 1) + if test -z "$CODECOV_SOURCE_BRANCH"; then + CODECOV_SOURCE_BRANCH=main + fi + fi i=0 while /bin/true; do i=$[$i+1] echo "Uploading coverage to codecov (attempt $i)" - codecov -X gcovcodecov -X gcov -X s3 --no-color \ - -t $CODECOV_TOKEN --root `pwd` -e OS,python \ - --name $CODECOV_JOB_NAME $CODECOV_ARGS \ - | tee .cover.upload - if test $? == 0 -a `grep -i error .cover.upload \ - | grep -v branch= | wc -l` -eq 0; then + codecovcli -v upload-process --sha $PYOMO_SOURCE_SHA \ + --fail-on-error --git-service github --token $CODECOV_TOKEN \ + --slug pyomo/pyomo --file coverage.xml --disable-search \ + --flag $CODECOV_FLAG \ + --name $CODECOV_JOB_NAME \ + --branch $CODECOV_REPO_OWNER:$CODECOV_SOURCE_BRANCH \ + --env OS,python --network-root-folder `pwd` --plugin noop + if test $? == 0; then break elif test $i -ge 4; then exit 1 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index dc77164f866..4046cecebb7 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,12 +8,27 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.11" + apt_packages: + - graphviz + - latexmk + - texlive + - texlive-latex-extra + - texlive-pictures + jobs: + post_build: + - | + for pdf in $READTHEDOCS_OUTPUT/pdf/*.pdf; do + if test [[ `basename $pdf` == graphviz* ]]; then + rm -v $pdf; + fi; + done sphinx: configuration: doc/OnlineDocs/conf.py -formats: all +formats: + - pdf # Set the version of Python and requirements required to build the docs python: diff --git a/CHANGELOG.md b/CHANGELOG.md index 553a4f1c3bd..09418d4d627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,415 @@ Pyomo CHANGELOG =============== +------------------------------------------------------------------------------- +Pyomo 6.9.2 (16 Apr 2025) +------------------------------------------------------------------------------- + +- General + - Update Copyright to 2025 (#3515) + - `capture_output`: do not output to captured file descriptors (#3537, #3560) + - `calc_variable_from_constraint`: guard against expression overflow (#3541) + - Standardize the usage of pyomo.environ imports (#3545) + - Update ASL CMake builders (#3548) + - Support passing LoggerAdapter objects to LogStream (#3547) +- Core + - Support multi-column DataFrames in Initializer (#3552) + - Multiple dispatch for relational expression generation (#3483) + - Type check `exception` args in component `__call__` implementations (#3538) +- Documentation + - Move RTD ads to the sidebar (#3536) + - Standardize Example Imports (#3543) + - LD-SDA Documentation (#3539) + - Fix broken URLs in documentation (#3529, #3544) + - Update future solver interface documentation (#3526) + - Document `BlockData.clone` (#3542) +- Solver Interfaces + - Fix bug in `XpressPersistent.update_var` (#3566) + - Avoid duplicate `AMPLFUNC` entries in `ipopt_v2` (#3554) + - Update deprecation warnings in `contrib.solver` (#3553, #3555) + - Support for upcoming knitro python package (#3478) + - Future solver interface reorganization and updates (#3476) + - Update HiGHS interface to allow keyboard interrupts (#3509, #3511) + - Fix/extend MOSEK option handling (#3488) +- Testing + - Defer `pathlib` import, remove test output file (#3563) + - Add IDAES Solvers to Mac; Update to Ubuntu 22.04 (#3556) + - Clean up numeric_expr dispatcher test driver (#3551) + - Testing and coverage fixes (#3549) + - Resolve intermittent test failure (increase sleep time) (#3517) + - Be more careful when/how we timeout conda install (#3514) +- Contributed Packages + - cspline_external: Add a function for cubic spline interpolation (#3216) + - DoE: Correct initialization when using lower diagonal of FIM (#3532) + - FBBT: Resolve bug registering native type handlers (#3567) + - parmest/DoE: Ordering and param bug fixes (#3512) + - PyNumero: Add support for greybox models (#3364) + - PyROS: Modify Decision Rule Order Efficiency (#3562) + - PyROS: Revise second-stage equality reformulation under discrete + (scenario-based) uncertainty (#3533) + - PyROS: Add Uncertain Parameter Reduction (#3503) + +------------------------------------------------------------------------------- +Pyomo 6.9.1 (5 Mar 2025) +------------------------------------------------------------------------------- + +- General + - Remove URL and replace with full citation (#3490) + - Rename project "pyomo" in setup.py [PEP 625] (#3430) + - Resolve errors in `TeeStream` and `capture_output` (#3497) +- Documentation + - Update autosummary for Sphinx 8.2.1 (#3492) + - Resolve errors building documentation PDF (#3495) +- Testing + - conda: disable '--update-deps' to work around buggy libffi(?) (#3499) + - Update for March 2024 typos release (#3500) + - Add timeout when installing optional packages (solvers) in conda (#3501) + - Updates to CodeCov configuration (#3491) +- Contributed Packages + - APPSI: Add warmstart for appsi_highs (issue #3450) (#3494) + +------------------------------------------------------------------------------- +Pyomo 6.9.0 (21 Feb 2025) +------------------------------------------------------------------------------- + +- General + - Big Wheel of Misfortune - A Practice in Reviewing Old Issues (#3460, #3473) + - Resolve buffering issues in `TeeStream` and `capture_output` (#3449) + - Update GHA badges on README.md (#3457) + - Fix incompatibility with `imp` and `DeferredImportCallbackFinder` (#3444) + - Remove Python 3.8 Support (#3438) +- Core + - Resolve error parenthesizing pow mantissa (#3472) + - Resolve OSX error accessing closed filehandle (#3467) + - Allow construction of CUID from another CUID (#3464) + - Clean up `identify_variables` / `identify_mutable_parameters`; + deprecate `SimpleExpressionVisitor` (#3436) +- Solver Interfaces + - Solver refactor: BUGFIX, f-string in contrib/solver (#3481) + - Update Gurobi NL interface for Gurobi 12.x (#3470) + - Defer starting the SAS session until needed (#3448) + - Solver refactor: Validator for `tee` in `contrib.solver.config` (#3482) +- Testing + - Pin Sphinx to not 8.2.0 (#3479) + - Typos corrections (#3437, #3474) + - Address intermittent TeeStream test failure (#3465) + - 2025 Testing update: Black 25.1.0, testing Qt without X11 (#3463) + - Testing: exclude Xpress 9.5.1 on Windows/GHA/Python3.{0,1} (#3455) + - CY25 CI Infrastructure and typo fixes (#3453) + - Verify we are testing all NEOS solvers (#3433) +- Contributed Packages + - GDPOpt: Add Logic-Based Discrete-Steepest Descent Algorithm (#3331) + - PyROS: Fix/Tweak Documentation and Solver Output Logging (#3475) + - PyROS: Fix Treatment of Fixed Model Variables and Initialization of + Auxiliary Uncertain Parameters (#3461) + - PyROS: Improve handling of separation problem sub-solver errors (#3441) + - PyROS: Extend valid types for solver argument `uncertain_params` (#3439) + - PyROS: Add Gaussian confidence level attribute to `EllipsoidalSet` (#3434) + +------------------------------------------------------------------------------- +Pyomo 6.8.2 (18 Nov 2024) +------------------------------------------------------------------------------- + +- Core + - Resolve errors in mapping ScalarVar to numpy ndarray (#3423) +- Documentation + - Update Documentation URLs (#3425) +- Solver Interfaces + - Resolve error in xpress_direct interface retrieving reduced costs (#3422) +- Testing + - Remove (unused) legacy test drivers (#3427) + +------------------------------------------------------------------------------- +Pyomo 6.8.1 (15 Nov 2024) +------------------------------------------------------------------------------- + +"Annie" + +SIGNIFICANT CHANGE NOTICE + +- This will be the last release to support Python 3.8 +- This is the first release to be tested against Python 3.13 +- Complete reorganization of our online documentation (#3382, #3378) + +CHANGELOG + +- General + - Add a 'Do not delete' Disclaimer to Issues/PR Templates (#3361) + - Add URL Status Badge to README (#3373) + - Resolve change in `InvalidNumber` handling in writers (#3390) + - Update `common.timing` to make tests more deterministic (#3397) + - Defer processing `ConfigValue`/`ConfigList` default until first use (#3394) + - Improved support for moved/renamed/deprecated modules (#3385) + - Fix `ConfigValue` initialization in multithreaded environments (#3405) + - `ConfigDict`: prevent recursion on partially-constructed objects (#3409) + - Fix bug in `AutoSlots` deepcopy (#3412, #3413) + - Update `mpi4py_available` to work around `conda-forge/openmpi` (#3416) +- Core + - Resolve bugs in `create_node_with_local_data` (#3376) + - Resolve issue in filter/validate deprecation path (#3368) + - Support `Param.pprint()` for non-finite Params (#3387) + - Add (parameterized) linear programming dual transformation (#3402) +- Documentation + - Autogenerate API documentation (#3378) + - Add Alternative Solutions documentation (#3370) + - Reorganize online documentation (#3382) + - Fix broken doc URLs (#3398) + - Improved autoenum documentation (#3389) + - Reduce the number of formats built on readthedocs to avoid timeout (#3404) +- Solver Interfaces + - Remove deprecated `gurobipy` `addConstr` call (#3350) + - Update Xpress interfaces to support 9.5 (#3392) + - Add support for templatized models in `gurobi_direct_v2` (#3362) + - Update test for GAMS mapping 'infeasible or unbounded' to infeasible (#3396) + - `XpressDirect.available()`: check there is a valid license (#3400) + - Move away from dependence on gurobi.sh (#3384) + - Fix error when xpress is imported before `xpress_direct` (#3410) +- Testing + - Move URL Checker to Weekly Job (#3360) + - Correct newly discovered typos (#3365, #3399) + - Remove Octeract from NEOS solvers list (and other testing fixes) (#3374) + - Guard tests against broken Gurobi licenses (#3383) + - Remove pin to Gurobi 10.0.3 (#3393) + - Add Python 3.13 to Testing Infrastructure (#3401, #3419) + - Resolve `timeout()` failures on Windows/py3.13 (#3415) +- GDP + - Fix performance degradation in hull transformation (#3366) +- Contributed Packages + - DoE: Fix bug from using hardcoded value (#3358) + - iis: Catch catastrophic solver failure when building MIS (#3403) + - PyNumero: Reverse `BlockVector`/`MPIBlockVector` base class order (#3380) + - PyNumero: Resolve incompatibilities with NumPy2 (#3408) + - PyROS: Overhaul preprocessor subroutine and subproblem objects (#3341) + +------------------------------------------------------------------------------- +Pyomo 6.8.0 (20 Aug 2024) +------------------------------------------------------------------------------- + +SIGNIFICANT CHANGE NOTICE + +- Internal data storage for Constraint objects (see #3293) +- No longer release cythonized wheel for Python 3.11+ (see #3355) + +CHANGELOG + +- General + - Add ParameterizedQuadraticRepn and corresponding walker (#3324) + - Update Pyomo for NumPy 2.0 compatibility (#3292, #3353) + - Add ParameterizedLinearRepn and corresponding walker (#3268) + - Update Release Process Workflow for changes in `pip` (#3355) +- Core + - Handle uninitialized variable in `propagate_solution` of scaling + transformation (#3275) + - Add `context` option to `SuffixFinder` (#3348) + - Remove the `_suppress_ctypes` attribute from Block (#3347) + - Improve `Set` initialization performance (#3302) + - Update Constraint to only store the original expression (not + lower/body/upper) (#3293) + - Kernel: fix bug in conic geomean (#3310) + - Fix bug with IndexedSet objects and the within argument (#3288) + - Support validate/filter for IndexedSet components using index (#3338) +- Solver Interfaces + - Resolve NLv2 incompatibility with multithreading (#3332) + - Resolve writer performance degradation (#3343) + - Fix bug with inconsistent use of `result` and `results` (#3337) + - LegacySolverWrapper: restore 'options' attribute (#3334) + - Fix bug in XpressDirect._load_slacks (#3318) + - NLv2: support expressions with nested external functions (#3319) + - Ignore errors on ASL solver version check (#3298) + - Add SAS solver interface (#2886, #3309) +- Testing + - Omnibus testing / platform portability fixes (#3335) + - Change BARON download URL (#3328) + - Disable interface/testing for NEOS/octeract (#3322) + - Fix typo in Jenkins driver (#3312) + - Jenkins: update logic for recording variables (#3311) + - Unpin Codecov / Update coverage (#3303) +- GDP + - Don't transform known-to-be infeasible Disjuncts in multiple BigM (#3314) +- Contributed Packages + - alternative_solutions: Add a new contrib package for generating + alternative solutions (#3270) + - APPSI: Allow maingo_solvermodel to be imported without maingopy (#3330) + - APPSI: Sort indices while removing constraints to fix bug in HiGHs + interface (#3281) + - CP: Add beforeChild handling for bools in logical expressions (#3315) + - DoE: Refactor to improve API and maintainability (#3317) + - incidence_analysis: Raise error in `generate_strongly_connected_components` + instead of asserting (#3305) + - parmest: Add missing main call for example file (#3349) + - piecewise: Add incremental PW linear to MIP transformation (#3287) + - piecewise: Add nonlinear-to-piecewise-linear transformation (#3333) + - PyNumero: Support user-provided CyIpopt callbacks with 13 arguments (#3289) + - PyNumero: Support PyomoNLP scaling factors on sub-blocks (#3295) + - PyROS: Temporarily Adjust NL Writer Feasibility Tolerance (#3280) + - viewer: Add option to specify the model by variable name (#3271) + +------------------------------------------------------------------------------- +Pyomo 6.7.3 (29 May 2024) +------------------------------------------------------------------------------- + +- Core + - Deprecate `pyomo.core.plugins.transform.model.to_standard_form()` (#3265) + - Reorder definitions to avoid `NameError` in some situations (#3264) +- Solver Interfaces + - NLv2: Fix linear presolver with constant defined vars/external fcns (#3276) +- Testing + - Add URL checking to GHA linting job (#3259, #3261) + - Skip Windows Python 3.8 conda GHA job (#3269) +- Contributed Packages + - DoE: Bug fixes for workshop (#3267) + - viewer: Update guard for pint import (#3277) + +------------------------------------------------------------------------------- +Pyomo 6.7.2 (9 May 2024) +------------------------------------------------------------------------------- + +- General + - Support config domains with either method or attribute domain_name (#3159) + - Automate TPL callback registrations (#3167) + - Fix type registrations for ExternalFunction arguments (#3168) + - Only modify module path and spec for deferred import modules (#3176) + - Add "mixed" standard form representation (#3201) + - Support "default" dispatchers in `ExitNodeDispatcher` (#3194) + - Redefine objective sense as a proper `IntEnum` (#3224) + - Fix division-by-0 bug in linear walker (#3246) +- Core + - Allow `Var` objects in `LinearExpression.args` (#3189) + - Add type hints to components (#3173) + - Simplify expressions generated by `TemplateSumExpression` (#3196) + - Make component data public classes (#3221, #3253) + - Exploit repeated named expressions in `identify_variables` (#3190) +- Documentation + - NFC: Add link to the HOMOWP companion notebooks (#3195) + - Update installation documentation to include Cython instructions (#3208) + - Add links to the Pyomo Book Springer page (#3211) +- Solver Interfaces + - Fix division by zero error in linear presolve (#3161) + - Subprocess timeout update (#3183) + - Solver Refactor - Bug fixes for various components (#3181, #3214, #3228) + - NLv2: handle presolved independent linear subsystems (#3193) + - Update `LegacySolverWrapper` compatibility with the `pyomo` script (#3202) + - Fix mosek_direct to use putqconk instead of putqcon (#3199) + - Check _skip_trivial_constraints before the constraint body (#3226) + - Fix AMPL solver duplicate funcadd (#3206) + - Disable the use of universal newlines in the ipopt_v2 NL file (#3231) + - NLv2: fix reporting numbers of nonlinear discrete variables (#3238) + - Fix: Get SCIP solving time considering float number with some text (#3234) + - Solver Refactor - Add `gurobi_direct` implementation (#3225) +- Testing + - Update TPL package list due to `contrib.solver` (#3164) + - Set maxDiff=None on the base TestCase class (#3171) + - Testing infrastructure updates (#3175) + - Typos update for March 2024 (#3219) + - Add openmpi to testing environment to resolve issue in mpi4py (#3236, #3239) + - Skip black 24.4.1 due to a bug in the parser (#3247) + - Skip tests on draft and WIP pull requests (#3223) + - Update GHA to grab gurobipy from PyPI (#3254) +- GDP + - Use private_data for all original / transformed component mappings (#3166) + - Fix a bug in gdp.bigm transformation for nested GDPs (#3213) +- Contributed Packages + - APPSI: cmodel: handle non-mutable params in var / constraint bounds (#3182) + - APPSI: Allow APPSI FBBT to handle nested named Expressions (#3185) + - APPSI: Add MAiNGO solver interface (#3165) + - CP: Add SequenceVar and other logical expressions for scheduling (#3227) + - DoE: Bug fixes (#3245) + - iis: Add minimal intractable system infeasibility diagnostics (#3172) + - incidence_analysis: Improve `solve_strongly_connected_components` + performance for models with named expressions (#3186) + - incidence_analysis: Add function to plot incidence graph in + Dulmage-Mendelsohn order (#3207) + - incidence_analysis: Require variables and constraints to be specified + separately in `IncidenceGraphInterface.remove_nodes` (#3212) + - latex_printer: bugfix for set operations / multidimensional sets (#3177) + - MindtPy: Add HiGHS support (#2971) + - MindtPy: Add call_before_subproblem_solve callback (#3251) + - Parmest: New UI using experiment lists (#3160) + - piecewise: Add piecewise linear transformations (#3036) + - preprocessing: bugfix: intersect domains in variable aggregator (#3241) + - PyNumero: Allow CyIpopt to solve problems without objectives (#3163) + - PyNumero: Work around bug in CyIpopt 1.4.0 (#3222) + - PyNumero: Include "inventory" in readme (#3248) + - PyROS: Simplify custom domain validators (#3169) + - PyROS: Fix iteration logging for edge case involving discrete sets (#3170) + - PyROS: Update solver timing system (#3198) + - simplification: expression simplification using GiNaC or SymPy (#3088) + +------------------------------------------------------------------------------- +Pyomo 6.7.1 (21 Feb 2024) +------------------------------------------------------------------------------- + +- General + - Add support for tuples in `ComponentMap`; add `DefaultComponentMap` (#3150) + - Update `Path`, `PathList`, and `IsInstance` Domain Validators (#3144) + - Remove usage of `__all__` (#3142) + - Extend Path and Type Checking Validators of `common.config` (#3140) + - Update Copyright Statements (#3139) + - Update `ExitNodeDispatcher` to better support extensibility (#3125) + - Create contributors data gathering script (#3117) + - Prevent duplicate entries in ConfigDict declaration order (#3116) + - Remove unnecessary `__future__` imports (#3109) + - Import pandas through pyomo.common.dependencies (#3102) + - Update links to workshop slides (#3079) + - Remove incorrect use of identity (is) comparisons (#3061) +- Core + - Add `Block.register_private_data_initializer()` (#3153) + - Generalize the simple_constraint_rule decorator (#3152) + - Fix edge case assigning new numeric types to Var/Param with units (#3151) + - Add private_data to `_BlockData` (#3138) + - IndexComponent create implicit sets as "anonymous" sets (#3075) + - Add `all_different` and `count_if` to the logical expression system (#3058) + - Fix RangeSet.__len__ when defined by floats (#3119) + - Overhaul the `Suffix` component (#3072) + - Enforce expression immutability in `expr.args` (#3099) + - Improve NumPy registration when assigning numpy to Param (#3093) + - Track changes in PyPy behavior introduced in 7.3.14 (#3087) + - Remove automatic numpy import (#3077) + - Fix `range_difference` for Sets with nonzero anchor points (#3063) + - Clarify errors raised by accessing Sets by positional index (#3062) +- Documentation + - Update intersphinx links, remove docs for nonfunctional code (#3155) + - Update MPC documentation and citation (#3148) + - Fix an error in the documentation for LinearExpression (#3090) + - Fix Pyomo.DoE documentation (#3070) + - Fix latex_printer documentation (#3066) +- Solver Interfaces + - Preview release of new solver interfaces as pyomo.contrib.solver + (#3137, #3156) + - Make error msg more explicit wrt different interfaces (#3141) + - NLv2: only raise exception for empty models in the legacy API (#3135) + - Add `to_expr()` to AMPLRepn, fix NLWriterInfo return type (#3095) +- Testing + - Update Release Wheel Builder Action (#3149) + - Actions Version Update: Address node.js deprecations (#3118) + - New Black Major Release (24.1.0) (#3108) + - Use scip for PyROS tests (#3104) + - Add missing solver dependency flags for OnlineDocs tests (#3094) + - Re-enable `contrib.viewer.tests.test_qt.py` (#3085) + - Add automated testing of OnlineDocs examples (#3080) + - Silence deprecation warnings emitted by Pyomo tests (#3076) + - Fix Python 3.12 tests (manage `pyutilib`, `distutils` dependencies) (#3065) +- DAE + - Replace deprecated `numpy.math` alias with standard `math` module (#3074) +- GDP + - Handle nested GDPs correctly in all the transformations (#3145) + - Fix bugs in nested models in gdp.hull transformation (#3143) + - Various bug fixes in gdp.mbigm transformation (#3073) + - Add GDP => MINLP Transformation (#3082) +- Contributed Packages + - GDPopt: Fix lbb solve_data bug (#3133) + - GDPopt: Adding missing import for gdpopt.enumerate (#3105) + - FBBT: Extend `fbbt.ExpressionBoundsVisitor` to handle relational + expressions and Expr_if (#3129) + - incidence_analysis: Method to add an edge in IncidenceGraphInterface (#3120) + - incidence_analysis: Add subgraph method to IncidencegraphInterface (#3122) + - incidence_analysis: Add `ampl_repn` option (#3069) + - incidence_analysis: Update documentation (#3067) + - interior_point: Resolve test failure due to Mumps update (#3114) + - MindtPy: Various bug fixes (#3034) + - PyROS: Update Solver Argument Resolution and Validation Routines (#3126) + - PyROS: Update Subproblem Initialization Routines (#3071) + - PyROS: Fix DR polishing under nominal objective focus (#3060) ------------------------------------------------------------------------------- Pyomo 6.7.0 (29 Nov 2023) diff --git a/LICENSE.md b/LICENSE.md index 9fd5d9b810c..2350f7c8d99 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,7 @@ LICENSE ======= -Copyright (c) 2008-2024 National Technology and Engineering Solutions of +Copyright (c) 2008-2025 National Technology and Engineering Solutions of Sandia, LLC . Under the terms of Contract DE-NA0003525 with National Technology and Engineering Solutions of Sandia, LLC , the U.S. Government retains certain rights in this software. diff --git a/README.md b/README.md index 2f8a25403c2..384d7234533 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Github Actions Status](https://github.com/Pyomo/pyomo/workflows/GitHub%20CI/badge.svg?event=push)](https://github.com/Pyomo/pyomo/actions?query=event%3Apush+workflow%3A%22GitHub+CI%22) +[![GitHub Actions Status](https://github.com/Pyomo/pyomo/actions/workflows/test_pr_and_main.yml/badge.svg?branch=main&event=push)](https://github.com/Pyomo/pyomo/actions/workflows/test_pr_and_main.yml?query=branch%3Amain+event%3Apush) [![Jenkins Status](https://github.com/Pyomo/jenkins-status/blob/main/pyomo_main.svg)](https://pyomo-jenkins.sandia.gov/) [![codecov](https://codecov.io/gh/Pyomo/pyomo/branch/main/graph/badge.svg)](https://codecov.io/gh/Pyomo/pyomo) [![Documentation Status](https://readthedocs.org/projects/pyomo/badge/?version=latest)](http://pyomo.readthedocs.org/en/latest/) @@ -51,8 +51,8 @@ Pyomo is available under the BSD License - see the Pyomo is currently tested with the following Python implementations: -* CPython: 3.8, 3.9, 3.10, 3.11, 3.12 -* PyPy: 3.9 +* CPython: 3.9, 3.10, 3.11, 3.12, 3.13 +* PyPy: 3.10 _Testing and support policy_: @@ -71,8 +71,11 @@ version, we will remove testing for that Python version. ### Tutorials and Examples +* [Pyomo — Optimization Modeling in Python](https://link.springer.com/book/10.1007/978-3-030-68928-5) * [Pyomo Workshop Slides](https://github.com/Pyomo/pyomo-tutorials/blob/main/Pyomo-Workshop-December-2023.pdf) * [Prof. Jeffrey Kantor's Pyomo Cookbook](https://jckantor.github.io/ND-Pyomo-Cookbook/) +* The [companion notebooks](https://mobook.github.io/MO-book/intro.html) + for *Hands-On Mathematical Optimization with Python* * [Pyomo Gallery](https://github.com/Pyomo/PyomoGallery) ### Getting Help @@ -83,7 +86,7 @@ To get help from the Pyomo community ask a question on one of the following: ### Developers -Pyomo development moved to this repository in June, 2016 from +Pyomo development moved to this repository in June 2016 from Sandia National Laboratories. Developer discussions are hosted by [Google Groups](https://groups.google.com/forum/#!forum/pyomo-developers). diff --git a/RELEASE.md b/RELEASE.md index 03baa803ac9..f5506f93848 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,17 +1,18 @@ -We are pleased to announce the release of Pyomo 6.7.0. +We are pleased to announce the release of Pyomo 6.9.2. Pyomo is a collection of Python software packages that supports a diverse set of optimization capabilities for formulating and analyzing optimization models. -The following are highlights of the 6.7 release series: +The following are highlights of the 6.9 release series: - - Added support for Python 3.12 - - Removed support for Python 3.7 - - New writer for converting linear models to matrix form - - New packages: - - latex_printer (print Pyomo models to a LaTeX compatible format) - - ...and of course numerous minor bug fixes and performance enhancements +- Removed support for Python 3.8 +- New Logic-Based Discrete-Steepest Descent Algorithm in GDPOpt +- New External Function for Cubic Spline Interpolation +- New solver interfaces: + - Support for upcoming knitro Python package + - v2: Ongoing solver interface refactor +- ...and of course numerous minor bug fixes and performance enhancements A full list of updates and changes is available in the [`CHANGELOG.md`](https://github.com/Pyomo/pyomo/blob/main/CHANGELOG.md). diff --git a/conftest.py b/conftest.py index 7faad6fc89b..e73871b8b40 100644 --- a/conftest.py +++ b/conftest.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,6 +11,22 @@ import pytest +_implicit_markers = {'default'} +_extended_implicit_markers = _implicit_markers.union({'solver'}) + + +def pytest_collection_modifyitems(items): + """ + This method will mark any unmarked tests with the implicit marker ('default') + + """ + for item in items: + try: + next(item.iter_markers()) + except StopIteration: + for marker in _implicit_markers: + item.add_marker(getattr(pytest.mark, marker)) + def pytest_runtest_setup(item): """ @@ -32,13 +48,10 @@ def pytest_runtest_setup(item): the default mode; but if solver tests are also marked with an explicit category (e.g., "expensive"), we will skip them. """ - marker = item.iter_markers() solvernames = [mark.args[0] for mark in item.iter_markers(name="solver")] solveroption = item.config.getoption("--solver") markeroption = item.config.getoption("-m") - implicit_markers = ['default'] - extended_implicit_markers = implicit_markers + ['solver'] - item_markers = set(mark.name for mark in marker) + item_markers = set(mark.name for mark in item.iter_markers()) if solveroption: if solveroption not in solvernames: pytest.skip("SKIPPED: Test not marked {!r}".format(solveroption)) @@ -46,9 +59,9 @@ def pytest_runtest_setup(item): elif markeroption: return elif item_markers: - if not set(implicit_markers).issubset( - item_markers - ) and not item_markers.issubset(set(extended_implicit_markers)): + if not _implicit_markers.issubset(item_markers) and not item_markers.issubset( + _extended_implicit_markers + ): pytest.skip('SKIPPED: Only running default, solver, and unmarked tests.') diff --git a/doc/OnlineDocs/Makefile b/doc/OnlineDocs/Makefile index 3625325ef73..dfb9df8e7db 100644 --- a/doc/OnlineDocs/Makefile +++ b/doc/OnlineDocs/Makefile @@ -7,6 +7,7 @@ SPHINXBUILD = sphinx-build SPHINXPROJ = Pyomo SOURCEDIR = . BUILDDIR = _build +APIDIR = api # Put it first so that "make" without argument is like "make help". help: @@ -19,8 +20,17 @@ help: %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -clean clean_tests: +clean: @$(SPHINXBUILD) -M clean "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - @echo "Removing *.spy, *.out" + @echo "Removing *.spy" @find . -name \*.spy -delete - @find src -name \*.out -delete + @for D in $(BUILDDIR) $(SOURCEDIR)/$(APIDIR); do \ + if test -d "$$D"; then echo "Removing $$D"; rm -r "$$D"; fi \ + done + @for D in ext src; do \ + if test -d "$$D/__pycache__"; then \ + echo "Removing $$D/__pycache__"; rm -r "$$D/__pycache__" ; \ + fi \ + done + +rebuild: clean html diff --git a/doc/OnlineDocs/README.md b/doc/OnlineDocs/README.md index a2d4e5997dc..53587d2d97d 100644 --- a/doc/OnlineDocs/README.md +++ b/doc/OnlineDocs/README.md @@ -1,27 +1,70 @@ +Pyomo leverages ``make`` to generate documentation. The following two +sections describe how to build and test the online documentation +locally. + +> **NOTE**: All commands assume you are running from the *root Pyomo source directory*. + + Preview Changes Locally ------------------------- +----------------------- -1. Install Sphinx +1. Install documentation dependencies (e.g., Sphinx, etc): ```bash - $ pip install sphinx sphinx_rtd_theme sphinx_copybutton + $ pip install -e .[docs] ``` - **NOTE**: You may get a warning about the `dot` command if you do not have - `graphviz` installed. + > **NOTE**: You may get a warning about the `dot` command if you do + > not have `graphviz` installed. -1. Build the documentation + +2. Build the documentation. Sphinx (and Pyomo) support multiple + documentation *targets*. These instructions describe building the + `html` target, but the same process applies for other targets. ```bash - $ make html # Option 1 - $ make latexpdf # Option 2 + $ make -C doc/OnlineDocs html ``` -1. View `_build/html/index.html` in your browser +3. View ``doc/OnlineDocs/_build/html/index.html`` in your browser Test Changes Locally -------------------- ```bash - $ make -C doc/OnlineDocs doctest -d # from the pyomo root folder + $ make -C doc/OnlineDocs doctest + ``` + +Rebuilding the documentation +---------------------------- + +Sphinx caches significant amounts of work at the end of a documentation +build. However, if you are in the process of editing the documentation, +it may not correctly invalidate the cache. You can purge the entire +cache with + + ```bash + $ make -C doc/OnlineDocs clean ``` + +Combining steps +--------------- + +These steps can, of course, be combined into a single command: + + ```bash + $ make -c doc/OnlineDocs clean html doctest + ``` + +Documentation history +--------------------- + +The Pyomo online documentation went through a significant overhaul in +2024. If you need to go back and look at the old documentation, the +following git hashes might be relevant: + + - [23fb726ce](https://github.com/Pyomo/pyomo/commit/23fb726ce0e092412081bd70e8a0370af46f6d0f): + main (close to just) before the reorg was merged + + - [c157587fc](https://github.com/Pyomo/pyomo/commit/c157587fc9a03300b53879b99c1f350a26a9519f): + reorg branch just before the `Archive` subdirectory was removed diff --git a/doc/OnlineDocs/_static/theme_overrides.css b/doc/OnlineDocs/_static/theme_overrides.css index 43d48693e03..936b3f95b13 100644 --- a/doc/OnlineDocs/_static/theme_overrides.css +++ b/doc/OnlineDocs/_static/theme_overrides.css @@ -31,6 +31,27 @@ dl.py.method dt em span.n { } } +.rst-content table.diataxis td { + vertical-align: top; +} + +.rst-content table.diataxis li.toctree-l1 { + list-style-type: none; + font-weight: bold; + font-size: x-large; +} + +.rst-content table.diataxis li ul li { + list-style-type: none; + font-weight: normal; + font-size: medium; +} + +.rst-content table.diataxis li ul li ul li { + list-style-type: "- "; +} + + /* Remove space after tables in definition lists (e.g., for function "Parameters" lists*/ .rst-content dl div.wy-table-responsive { diff --git a/doc/OnlineDocs/_templates/layout.html b/doc/OnlineDocs/_templates/layout.html new file mode 100644 index 00000000000..777a884bfe5 --- /dev/null +++ b/doc/OnlineDocs/_templates/layout.html @@ -0,0 +1,15 @@ +{% extends "!layout.html" %} +{# Explicitly put RTD's ads in the sidebar. If we don't specify a + location, RTD puts them at the bottom of the page content in a + style that is easily visually confused with the documentation + content. +#} +{%- block navigation %} +{{ super() }} +
+
+
+
+
+{% endblock %} diff --git a/doc/OnlineDocs/_templates/recursive-base.rst b/doc/OnlineDocs/_templates/recursive-base.rst new file mode 100644 index 00000000000..6a4e827fbe2 --- /dev/null +++ b/doc/OnlineDocs/_templates/recursive-base.rst @@ -0,0 +1,13 @@ +{{ name | escape | underline}} + +({{ objtype }} from :py:mod:`{{ module }}`) + +.. testsetup:: * + + # import everything from the module containing this class so that + # doctests for the class docstrings see the correct environment + from {{ module }} import * + +.. currentmodule:: {{ module }} + +.. auto{{ objtype }}:: {{ objname }} diff --git a/doc/OnlineDocs/_templates/recursive-class.rst b/doc/OnlineDocs/_templates/recursive-class.rst new file mode 100644 index 00000000000..0691af291aa --- /dev/null +++ b/doc/OnlineDocs/_templates/recursive-class.rst @@ -0,0 +1,49 @@ +{{ name | escape | underline}} + +(class from :py:mod:`{{ module }}`) + +.. testsetup:: * + + # import everything from the module containing this class so that + # doctests for the class docstrings see the correct environment + from {{ module }} import * + +.. currentmodule:: {{ module }} + +{# Note that numpy.ndarray examples fail doctest; disable documentation + of inherited members for classes derived from ndarray #} + +.. autoclass:: {{ module }}::{{ objname }} + :members: + :show-inheritance: + {{ '' if (module + '.' + name) in ( + 'pyomo.contrib.pynumero.sparse.block_vector.BlockVector', + 'pyomo.contrib.pynumero.sparse.mpi_block_vector.MPIBlockVector', + 'pyomo.core.expr.ndarray.NumericNDArray', + ) else ':inherited-members:' }} + + {% block methods %} + .. automethod:: __init__ + + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + .. rubric:: Member Documentation diff --git a/doc/OnlineDocs/_templates/recursive-enum.rst b/doc/OnlineDocs/_templates/recursive-enum.rst new file mode 100644 index 00000000000..966cb178b78 --- /dev/null +++ b/doc/OnlineDocs/_templates/recursive-enum.rst @@ -0,0 +1,54 @@ +{{ name | escape | underline}} + +(enum from :py:mod:`{{ module }}`) + +.. testsetup:: * + + # import everything from the module containing this class so that + # doctests for the class docstrings see the correct environment + from {{ module }} import * + +.. currentmodule:: {{ module }} + +.. autoenum:: {{ module }}::{{ objname }} + :members: + :inherited-members: + :undoc-members: + :show-inheritance: + + {% block enum_members %} + {% if enum_members %} + .. rubric:: {{ _('Enum Members') }} + + {{ member_type }} + + .. autosummary:: + {% for item in enum_members %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block methods %} + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + .. rubric:: Member Documentation diff --git a/doc/OnlineDocs/_templates/recursive-module.rst b/doc/OnlineDocs/_templates/recursive-module.rst new file mode 100644 index 00000000000..dc69e33731d --- /dev/null +++ b/doc/OnlineDocs/_templates/recursive-module.rst @@ -0,0 +1,92 @@ +{% if fullname == 'pyomo' %} +Library Reference +================= +{% else %} +{{ name | escape | underline}} +{% endif %} + +.. automodule:: {{ fullname }} + :undoc-members: + + {% block attributes %} + {%- if attributes %} + .. rubric:: {{ _('Module Attributes') }} + + .. autosummary:: + :toctree: + :template: recursive-base.rst + {% for item in attributes %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {% block enums %} + {%- if enums %} + .. rubric:: {{ _('Enums') }} + + .. autosummary:: + :toctree: + :template: recursive-enum.rst + {% for item in enums %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {%- block classes %} + {%- if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :toctree: + :template: recursive-class.rst + {% for item in classes %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {%- block exceptions %} + {%- if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :toctree: + :template: recursive-class.rst + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {%- block functions %} + {%- if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :toctree: + :template: recursive-base.rst + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + +{%- block modules %} +{%- if modules %} +.. rubric:: Modules + +.. autosummary:: + :toctree: + :template: recursive-module.rst + :recursive: +{% for item in modules %} +{# Need item != tests for Sphinx >= 8.0; !endswith(.tests) for < 8.0 #} +{% if item != 'tests' and not item.endswith('.tests') + and item != 'examples' and not item.endswith('.examples') %} + {{ item }} +{% endif %} +{%- endfor %} +{% endif %} +{%- endblock %} diff --git a/doc/OnlineDocs/advanced_topics/index.rst b/doc/OnlineDocs/advanced_topics/index.rst deleted file mode 100644 index d5293bfa40c..00000000000 --- a/doc/OnlineDocs/advanced_topics/index.rst +++ /dev/null @@ -1,11 +0,0 @@ -Advanced Topics -=============== - -.. toctree:: - :maxdepth: 1 - - persistent_solvers.rst - units_container.rst - linearexpression.rst - flattener/index.rst - sos_constraints.rst diff --git a/doc/OnlineDocs/advanced_topics/linearexpression.rst b/doc/OnlineDocs/advanced_topics/linearexpression.rst deleted file mode 100644 index 8b43c3fa03a..00000000000 --- a/doc/OnlineDocs/advanced_topics/linearexpression.rst +++ /dev/null @@ -1,44 +0,0 @@ -LinearExpression -================ - -Significant speed -improvements can sometimes be obtained using the ``LinearExpression`` object -when there are long, dense, linear expressions. The arguments are - -:: - - constant, linear_coeffs, linear_vars - -where the second and third arguments are lists that must be of the -same length. Here is a simple example that illustrates the -syntax. This example creates two constraints that are the same; in this -particular case the LinearExpression component would offer very little improvement -because Pyomo would be able to detect that `campe2` is a linear expression: - -.. doctest:: - - >>> import pyomo.environ as pyo - >>> from pyomo.core.expr.numeric_expr import LinearExpression - >>> model = pyo.ConcreteModel() - >>> model.nVars = pyo.Param(initialize=4) - >>> model.N = pyo.RangeSet(model.nVars) - >>> model.x = pyo.Var(model.N, within=pyo.Binary) - >>> - >>> model.coefs = [1, 1, 3, 4] - >>> - >>> model.linexp = LinearExpression(constant=0, - ... linear_coefs=model.coefs, - ... linear_vars=[model.x[i] for i in model.N]) - >>> def caprule(m): - ... return m.linexp <= 6 - >>> model.capme = pyo.Constraint(rule=caprule) - >>> - >>> def caprule2(m): - ... return sum(model.coefs[i-1]*model.x[i] for i in model.N) <= 6 - >>> model.capme2 = pyo.Constraint(rule=caprule2) - - -.. warning:: - - The lists that are passed to ``LinearExpression`` are not copied, so caution must - be exercised if they are modified after the component is constructed. diff --git a/doc/OnlineDocs/advanced_topics/units_container.rst b/doc/OnlineDocs/advanced_topics/units_container.rst deleted file mode 100644 index f09a3361b6b..00000000000 --- a/doc/OnlineDocs/advanced_topics/units_container.rst +++ /dev/null @@ -1,13 +0,0 @@ -Units Handling in Pyomo -======================= - -.. automodule:: pyomo.core.base.units_container - -.. autoclass:: PyomoUnitsContainer - :show-inheritance: - :members: - -.. autoclass:: UnitsError - -.. autoclass:: InconsistentUnitsError - diff --git a/doc/OnlineDocs/bibliography.rst b/doc/OnlineDocs/bibliography.rst deleted file mode 100644 index 6cbb96d3bfb..00000000000 --- a/doc/OnlineDocs/bibliography.rst +++ /dev/null @@ -1,66 +0,0 @@ -Bibliography -============ - -.. [AMPL] R. Fourer, D. M. Gay, and B. W. Kernighan. AMPL: A Modeling - Language for Mathematical Programming, 2nd Edition. Duxbury - Press, 2002. - -.. [AIMMS] http://www.aimms.com/ - -.. [GAMS] http://www.gams.com - -.. [Isenberg_et_al] Isenberg, NM, Akula, P, Eslick, JC, Bhattacharyya, D, - Miller, DC, Gounaris, CE. A generalized cutting‐set approach for - nonlinear robust optimization in process systems - engineering. AIChE J. 2021; 67:e17175. DOI `10.1002/aic.17175 - `_ - -.. [mpisppy] Bernard Knueven, David Mildebrath, Christopher Muir, - John D Siirola, Jean-Paul Watson, and David L Woodruff, A Parallel - Hub-and-Spoke System for Large-Scale Scenario-Based Optimization - Under Uncertainty, pre-print, 2020 - -.. [ParmestPaper] Katherine A. Klise, Bethany L. Nicholson, Andrea - Staid, David L.Woodruff. Parmest: Parameter Estimation Via Pyomo. - Computer Aided Chemical Engineering, 47 (2019): 41-46. - -.. [PyomoBookI] William E. Hart, Carl D. Laird, Jean-Paul Watson, - David L. Woodruff. Pyomo – Optimization Modeling in - Python, Springer, 2012. - -.. [PyomoBookII] W. E. Hart, C. D. Laird, - J.-P. Watson, D. L. Woodruff, G. A. Hackebeil, B. L. Nicholson, - J. D. Siirola. Pyomo - Optimization Modeling in Python, - 2nd Edition. Springer Optimization and Its - Applications, Vol 67. Springer, 2017. - -.. [PyomoBookIII] Bynum, Michael L., Gabriel A. Hackebeil, - William E. Hart, Carl D. Laird, Bethany L. Nicholson, - John D. Siirola, Jean-Paul Watson, and David L. Woodruff. - Pyomo - Optimization Modeling in Python, 3rd Edition. - Vol. 67. Springer, 2021. - -.. [PyomoJournal] William E. Hart, Jean-Paul Watson, David L. Woodruff. - "Pyomo: modeling and solving mathematical programs in - Python," Mathematical Programming Computation, Volume - 3, Number 3, August 2011 - -.. [PyomoDAE] Bethany Nicholson, John D. Siirola, Jean-Paul Watson, - Victor M. Zavala, and Lorenz T. Biegler. "pyomo.dae: a - modeling and automatic discretization framework for - optimization with differential and algebraic equations." - Mathematical Programming Computation 10(2) (2018): - 187-223. - -.. [RooneyBiegler] W.C. Rooney, L.T. Biegler, "Design for model parameter - uncertainty using nonlinear confidence regions", AIChE - Journal, 47(8), 2001 - -.. [SemiBatch] O. Abel, W. Marquardt, "Scenario-integrated modeling and - optimization of dynamic systems", AIChE Journal, 46(4), 2000 - -.. [Vielma_et_al] J. P. Vielma, S. Ahmed, G. Nemhauser. "Mixed-Integer - Models for Non-separable Piecewise Linear - Optimization: Unifying framework and Extensions", - Operations Research 58, 2010. pp. 303-315. - diff --git a/doc/OnlineDocs/citing_pyomo.rst b/doc/OnlineDocs/citing_pyomo.rst deleted file mode 100644 index 458a1fe6ab7..00000000000 --- a/doc/OnlineDocs/citing_pyomo.rst +++ /dev/null @@ -1,16 +0,0 @@ -Citing Pyomo -============ - -Pyomo ------ - -Bynum, Michael L., Gabriel A. Hackebeil, William E. Hart, Carl D. Laird, Bethany L. Nicholson, John D. Siirola, Jean-Paul Watson, and David L. Woodruff. Pyomo - Optimization Modeling in Python, 3rd Edition. Springer, 2021. - -Hart, William E., Jean-Paul Watson, and David L. Woodruff. "Pyomo: modeling and solving mathematical programs in Python." Mathematical Programming Computation 3, no. 3 (2011): 219-260. - - -PySP ----- - -Watson, Jean-Paul, David L. Woodruff, and William E. Hart. "PySP: modeling and solving stochastic programs in Python." Mathematical Programming Computation 4, no. 2 (2012): 109-149. - diff --git a/doc/OnlineDocs/code.rst b/doc/OnlineDocs/code.rst new file mode 100644 index 00000000000..e03e2cfb4d9 --- /dev/null +++ b/doc/OnlineDocs/code.rst @@ -0,0 +1,16 @@ +:orphan: + +This document is a reference containing all Pyomo module, class, and +function documentation. + +.. autosummary:: + :toctree: api + :caption: Library Reference + :template: recursive-module.rst + :recursive: + + pyomo + + +* :ref:`genindex` +* :ref:`modindex` diff --git a/doc/OnlineDocs/conf.py b/doc/OnlineDocs/conf.py index 04fe458407b..d4deca77508 100644 --- a/doc/OnlineDocs/conf.py +++ b/doc/OnlineDocs/conf.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -35,6 +35,8 @@ sys.path.insert(0, os.path.abspath('../../../pyutilib')) # top-level pyomo source directory sys.path.insert(0, os.path.abspath('../..')) +# our sphinx extensions +sys.path.insert(0, os.path.abspath('ext')) # -- Rebuild SPY files ---------------------------------------------------- sys.path.insert(0, os.path.abspath('src')) @@ -44,7 +46,7 @@ generate_spy_files(os.path.abspath('src')) generate_spy_files( - os.path.abspath(os.path.join('library_reference', 'kernel', 'examples')) + os.path.abspath(os.path.join('explanation', 'experimental', 'kernel')) ) finally: sys.path.pop(0) @@ -58,7 +60,6 @@ 'pandas': ('https://pandas.pydata.org/docs/', None), 'scikit-learn': ('https://scikit-learn.org/stable/', None), 'scipy': ('https://docs.scipy.org/doc/scipy/', None), - 'Sphinx': ('https://www.sphinx-doc.org/en/master/', None), } # -- General configuration ------------------------------------------------ @@ -72,19 +73,18 @@ # ones. extensions = [ 'sphinx.ext.intersphinx', - 'sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.mathjax', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', - 'sphinx.ext.ifconfig', 'sphinx.ext.inheritance_diagram', - 'sphinx.ext.autosummary', 'sphinx.ext.doctest', 'sphinx.ext.todo', 'sphinx_copybutton', - 'enum_tools.autoenum', - #'sphinx.ext.githubpages', + # Our version of 'autoenum', designed to work with autosummary. + # This adds 'sphinx.ext.autosummary', and 'sphinx.ext.autodoc': + 'pyomo_autosummary_autoenum', + 'pyomo_tocref', ] viewcode_follow_imported_members = True @@ -107,8 +107,8 @@ # General information about the project. project = u'Pyomo' -copyright = u'2008-2023, Sandia National Laboratories' -author = u'Pyomo Developers' +copyright = u'2008-2025, Sandia National Laboratories' +author = u'Pyomo Development Team' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -130,8 +130,22 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +# These patterns also effect to html_static_path and html_extra_path +# Notes: +# - _build : this is the Sphinx build (output) dir +# +# - api/*.tests.* : this matches autosummary RST files generated for +# test modules. Note that the _templates/recursive-modules.rst +# should prevent these file from being generated, so this is not +# strictly necessary, but including it makes Sphinx throw warnings if +# the filter in the template ever "breaks" +# +# - **/tests/** : this matches source files in any tests directory +# [JDS: I *believe* this is necessary, but am not 100% certain] +# +# - 'Thumbs.db', '.DS_Store' : these have been included from the +# beginning. Unclear if they are still necessary +exclude_patterns = ['_build', 'api/*.tests.*', '**/tests/**', 'Thumbs.db', '.DS_Store'] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' @@ -167,7 +181,7 @@ # further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +html_theme_options = {'navigation_depth': 6, 'titles_only': True} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -195,23 +209,48 @@ # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # - # 'preamble': '', + 'preamble': r''' +\usepackage{enumitem} +\setlistdepth{99} +\DeclareUnicodeCharacter{2227}{$\wedge$} +\DeclareUnicodeCharacter{2228}{$\vee$} +\DeclareUnicodeCharacter{22BB}{$\veebar$} +''', # Latex figure (float) alignment # # 'figure_align': 'htbp', + # necessary for unicode charactacters in pdf output + 'inputenc': '', + 'utf8extra': '', + # remove blank pages (e.g., between chapters) + 'classoptions': ',openany,oneside', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). -latex_documents = [(master_doc, 'pyomo.tex', 'Pyomo Documentation', 'Pyomo', 'manual')] +latex_documents = [(master_doc, 'pyomo.tex', 'Pyomo Documentation', author, 'manual')] +if not on_rtd: + latex_documents.append( + ('code', 'pyomo_reference.tex', 'Pyomo Code Reference', author, 'manual') + ) +# The name of an image file (relative to this directory) to place at the top of +# the title page. +latex_logo = '../logos/pyomo/PyomoNewBlue.jpg' + +# Disable the domain indices (i.e., the module index) for LaTeX targets: +# because we are splitting the documentation, the module index in the +# main document would be completely broken, and having one in the +# reference document seems redundant (JDS: and I haven't figured out how +# to have it in only one of the documents) +latex_domain_indices = False # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(master_doc, 'pyomo', 'Pyomo Documentation', [author], 1)] +man_pages = [] # -- Options for Texinfo output ------------------------------------------- @@ -219,20 +258,13 @@ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - 'pyomo', - 'Pyomo Documentation', - author, - 'Pyomo', - 'One line description of project.', - 'Miscellaneous', - ) -] +texinfo_documents = [] # autodoc_member_order = 'bysource' -# autodoc_member_order = 'groupwise' +autodoc_member_order = 'groupwise' + +autosummary_generate = True +autosummary_ignore_module_all = True # -- Check which conditional dependencies are available ------------------ # Used for skipping certain doctests @@ -266,20 +298,32 @@ def check_output(self, want, got, optionflags): platform.python_implementation() ) +# Mark that we are testing code (in this case, testing the documentation) +from pyomo.common.flags import in_testing_environment +in_testing_environment(True) + +# We need multiprocessing because some doctests must be skipped if the +# start method is not "fork" +import multiprocessing + +# (register plugins, make environ available to tests) +import pyomo.environ as pyo + from pyomo.common.dependencies import ( attempt_import, numpy_available, scipy_available, pandas_available, yaml_available, networkx_available, matplotlib_available, - pympler_available, dill_available, + pympler_available, dill_available, pint_available, + numpy as np, ) -pint_available = attempt_import('pint', defer_check=False)[1] from pyomo.contrib.parmest.parmest import parmest_available -import pyomo.environ as _pe # (trigger all plugin registrations) -import pyomo.opt as _opt +# Ensure that the matplotlib import has been resolved (and the backend changed) +bool(matplotlib_available) # Not using SolverFactory to check solver availability because # as of June 2020 there is no way to suppress warnings when # solvers are not available +import pyomo.opt as _opt ipopt_available = bool(_opt.check_available_solvers('ipopt')) sipopt_available = bool(_opt.check_available_solvers('ipopt_sens')) k_aug_available = bool(_opt.check_available_solvers('k_aug')) @@ -290,6 +334,11 @@ def check_output(self, want, got, optionflags): baron = _opt.SolverFactory('baron') +if numpy_available: + # Recent changes on GHA seem to have dropped the default precision + # from 8 to 4; restore the default. + np.set_printoptions(precision=8) + if numpy_available and scipy_available: import pyomo.contrib.pynumero.asl as _asl asl_available = _asl.AmplInterface.available() @@ -300,4 +349,8 @@ def check_output(self, want, got, optionflags): asl_available = False ma27_available = False mumps_available = False + +# Prevent any Pyomo logs from propagating up to the doctest logger +import logging +logging.getLogger('pyomo').propagate = False ''' diff --git a/doc/OnlineDocs/contributed_packages/iis.rst b/doc/OnlineDocs/contributed_packages/iis.rst deleted file mode 100644 index 98cb9e30771..00000000000 --- a/doc/OnlineDocs/contributed_packages/iis.rst +++ /dev/null @@ -1,6 +0,0 @@ -Infeasible Irreducible System (IIS) Tool -======================================== - -.. automodule:: pyomo.contrib.iis.iis - -.. autofunction:: pyomo.contrib.iis.write_iis diff --git a/doc/OnlineDocs/contributed_packages/mpc/data.rst b/doc/OnlineDocs/contributed_packages/mpc/data.rst deleted file mode 100644 index 73cb6543b1e..00000000000 --- a/doc/OnlineDocs/contributed_packages/mpc/data.rst +++ /dev/null @@ -1,17 +0,0 @@ -Data Structures -=============== - -.. automodule:: pyomo.contrib.mpc.data.get_cuid - :members: - -.. automodule:: pyomo.contrib.mpc.data.dynamic_data_base - :members: - -.. automodule:: pyomo.contrib.mpc.data.scalar_data - :members: - -.. automodule:: pyomo.contrib.mpc.data.series_data - :members: - -.. automodule:: pyomo.contrib.mpc.data.interval_data - :members: diff --git a/doc/OnlineDocs/contributed_packages/parmest/datarec.rst b/doc/OnlineDocs/contributed_packages/parmest/datarec.rst deleted file mode 100644 index 6b721377e46..00000000000 --- a/doc/OnlineDocs/contributed_packages/parmest/datarec.rst +++ /dev/null @@ -1,58 +0,0 @@ -.. _datarecsection: - -Data Reconciliation -==================== - -The method :class:`~pyomo.contrib.parmest.parmest.Estimator.theta_est` -can optionally return model values. This feature can be used to return -reconciled data using a user specified objective. In this case, the list -of variable names the user wants to estimate (theta_names) is set to an -empty list and the objective function is defined to minimize -measurement to model error. Note that the model used for data -reconciliation may differ from the model used for parameter estimation. - -The following example illustrates the use of parmest for data -reconciliation. The functions -:class:`~pyomo.contrib.parmest.graphics.grouped_boxplot` or -:class:`~pyomo.contrib.parmest.graphics.grouped_violinplot` can be used -to visually compare the original and reconciled data. - -Here's a stylized code snippet showing how box plots might be created: - -.. doctest:: - :skipif: True - - >>> import pyomo.contrib.parmest.parmest as parmest - >>> pest = parmest.Estimator(model_function, data, [], objective_function) - >>> obj, theta, data_rec = pest.theta_est(return_values=['A', 'B']) - >>> parmest.graphics.grouped_boxplot(data, data_rec) - -Returned Values -^^^^^^^^^^^^^^^ - -Here's a full program that can be run to see returned values (in this case it -is the response function that is defined in the model file): - -.. doctest:: - :skipif: not ipopt_available or not parmest_available - - >>> import pandas as pd - >>> import pyomo.contrib.parmest.parmest as parmest - >>> from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import rooney_biegler_model - - >>> theta_names = ['asymptote', 'rate_constant'] - - >>> data = pd.DataFrame(data=[[1,8.3],[2,10.3],[3,19.0], - ... [4,16.0],[5,15.6],[7,19.8]], - ... columns=['hour', 'y']) - - >>> def SSE(model, data): - ... expr = sum((data.y[i]\ - ... - model.response_function[data.hour[i]])**2 for i in data.index) - ... return expr - - >>> pest = parmest.Estimator(rooney_biegler_model, data, theta_names, SSE, - ... solver_options=None) - >>> obj, theta, var_values = pest.theta_est(return_values=['response_function']) - >>> #print(var_values) - diff --git a/doc/OnlineDocs/contributed_packages/pynumero/api.rst b/doc/OnlineDocs/contributed_packages/pynumero/api.rst deleted file mode 100644 index 3d1ac8a189e..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/api.rst +++ /dev/null @@ -1,14 +0,0 @@ -.. _pynumero_api: - -PyNumero API -============ - -.. automodule:: pyomo.contrib.pynumero - :members: - :undoc-members: - -.. toctree:: - - pynumero.sparse - pynumero.interfaces - pynumero.linalg diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.ampl_nlp.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.ampl_nlp.rst deleted file mode 100644 index 37dd5852351..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.ampl_nlp.rst +++ /dev/null @@ -1,8 +0,0 @@ -AMPL NLP Interface -================== - -.. autoclass:: pyomo.contrib.pynumero.interfaces.ampl_nlp.AmplNLP - :members: - :undoc-members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.asl_nlp.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.asl_nlp.rst deleted file mode 100644 index 2537bd52fdb..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.asl_nlp.rst +++ /dev/null @@ -1,8 +0,0 @@ -ASL NLP Interface -================= - -.. autoclass:: pyomo.contrib.pynumero.interfaces.ampl_nlp.AslNLP - :members: - :undoc-members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.extended_nlp.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.extended_nlp.rst deleted file mode 100644 index 75528ac4b45..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.extended_nlp.rst +++ /dev/null @@ -1,8 +0,0 @@ -Extended NLP Interface -====================== - -.. autoclass:: pyomo.contrib.pynumero.interfaces.nlp.ExtendedNLP - :members: - :undoc-members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.external_grey_box_model.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.external_grey_box_model.rst deleted file mode 100644 index 10187b4156e..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.external_grey_box_model.rst +++ /dev/null @@ -1,8 +0,0 @@ -External Grey Box Model -======================= - -.. autoclass:: pyomo.contrib.pynumero.interfaces.external_grey_box.ExternalGreyBoxModel - :members: - :undoc-members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.nlp.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.nlp.rst deleted file mode 100644 index d8532873c22..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.nlp.rst +++ /dev/null @@ -1,8 +0,0 @@ -NLP Interface -============= - -.. autoclass:: pyomo.contrib.pynumero.interfaces.nlp.NLP - :members: - :undoc-members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.projected_nlp.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.projected_nlp.rst deleted file mode 100644 index b9c6941bd93..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.projected_nlp.rst +++ /dev/null @@ -1,8 +0,0 @@ -Projected NLP Interface -======================= - -.. autoclass:: pyomo.contrib.pynumero.interfaces.nlp_projections.ProjectedNLP - :members: - :undoc-members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.pyomo_grey_box_nlp.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.pyomo_grey_box_nlp.rst deleted file mode 100644 index c7200038f5e..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.pyomo_grey_box_nlp.rst +++ /dev/null @@ -1,8 +0,0 @@ -Pyomo Grey Box NLP Interface -============================ - -.. autoclass:: pyomo.contrib.pynumero.interfaces.pyomo_nlp.PyomoGreyBoxNLP - :members: - :undoc-members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.pyomo_nlp.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.pyomo_nlp.rst deleted file mode 100644 index e52ce33c2d9..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.pyomo_nlp.rst +++ /dev/null @@ -1,8 +0,0 @@ -Pyomo NLP Interface -=================== - -.. autoclass:: pyomo.contrib.pynumero.interfaces.pyomo_nlp.PyomoNLP - :members: - :undoc-members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.rst deleted file mode 100644 index ec0b94960f6..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.interfaces.rst +++ /dev/null @@ -1,16 +0,0 @@ -PyNumero NLP Interfaces -======================= - -.. automodule:: pyomo.contrib.pynumero.interfaces - :members: - -.. toctree:: - - pynumero.interfaces.nlp - pynumero.interfaces.extended_nlp - pynumero.interfaces.asl_nlp - pynumero.interfaces.ampl_nlp - pynumero.interfaces.pyomo_nlp - pynumero.interfaces.projected_nlp - pynumero.interfaces.external_grey_box_model - pynumero.interfaces.pyomo_grey_box_nlp diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.base.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.base.rst deleted file mode 100644 index 0a94f87c6be..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.base.rst +++ /dev/null @@ -1,26 +0,0 @@ -Linear Solver Base Classes -========================== - -.. autoclass:: pyomo.contrib.pynumero.linalg.base.LinearSolverStatus - :members: - :inherited-members: - :show-inheritance: - :undoc-members: - -.. autoclass:: pyomo.contrib.pynumero.linalg.base.LinearSolverResults - :members: - :inherited-members: - :show-inheritance: - :undoc-members: - -.. autoclass:: pyomo.contrib.pynumero.linalg.base.LinearSolverInterface - :members: - :inherited-members: - :show-inheritance: - :undoc-members: - -.. autoclass:: pyomo.contrib.pynumero.linalg.base.DirectLinearSolverInterface - :members: - :inherited-members: - :show-inheritance: - :undoc-members: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.ma27.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.ma27.rst deleted file mode 100644 index f1d2eed3ed0..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.ma27.rst +++ /dev/null @@ -1,8 +0,0 @@ -HSL MA27 -======== - -.. autoclass:: pyomo.contrib.pynumero.linalg.ma27_interface.MA27 - :members: - :inherited-members: - :show-inheritance: - :undoc-members: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.ma57.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.ma57.rst deleted file mode 100644 index c97f193b5f8..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.ma57.rst +++ /dev/null @@ -1,8 +0,0 @@ -HSL MA57 -======== - -.. autoclass:: pyomo.contrib.pynumero.linalg.ma57_interface.MA57 - :members: - :inherited-members: - :show-inheritance: - :undoc-members: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.mumps.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.mumps.rst deleted file mode 100644 index 1fd5998dd4d..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.mumps.rst +++ /dev/null @@ -1,8 +0,0 @@ -MUMPS -===== - -.. autoclass:: pyomo.contrib.pynumero.linalg.mumps_interface.MumpsCentralizedAssembledLinearSolver - :members: - :inherited-members: - :show-inheritance: - :undoc-members: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.rst deleted file mode 100644 index 70b091becbd..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.rst +++ /dev/null @@ -1,14 +0,0 @@ -PyNumero Linear Solver Interfaces -================================= - -.. automodule:: pyomo.contrib.pynumero.linalg - :members: - -.. toctree:: - - pynumero.linalg.base - pynumero.linalg.ma27 - pynumero.linalg.ma57 - pynumero.linalg.mumps - pynumero.linalg.scipy - diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.scipy.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.scipy.rst deleted file mode 100644 index 7e0a1d0b865..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.linalg.scipy.rst +++ /dev/null @@ -1,14 +0,0 @@ -Scipy -===== - -.. autoclass:: pyomo.contrib.pynumero.linalg.scipy_interface.ScipyLU - :members: - :inherited-members: - :show-inheritance: - :undoc-members: - -.. autoclass:: pyomo.contrib.pynumero.linalg.scipy_interface.ScipyIterative - :members: - :inherited-members: - :show-inheritance: - :undoc-members: diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.sparse.block_vector.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.sparse.block_vector.rst deleted file mode 100644 index 6e1dc1f20e5..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.sparse.block_vector.rst +++ /dev/null @@ -1,154 +0,0 @@ -BlockVector -=========== - -Methods specific to :py:class:`pyomo.contrib.pynumero.sparse.block_vector.BlockVector`: - - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.set_block` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.get_block` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.block_sizes` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.get_block_size` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.is_block_defined` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.copyfrom` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.copyto` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.copy_structure` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.set_blocks` - * :py:meth:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.pprint` - -Attributes specific to :py:class:`pyomo.contrib.pynumero.sparse.block_vector.BlockVector`: - - * :py:attr:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.nblocks` - * :py:attr:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.bshape` - * :py:attr:`~pyomo.contrib.pynumero.sparse.block_vector.BlockVector.has_none` - - -NumPy compatible methods: - - * `numpy.ndarray.dot() `_ - * `numpy.ndarray.sum() `_ - * `numpy.ndarray.all() `_ - * `numpy.ndarray.any() `_ - * `numpy.ndarray.max() `_ - * `numpy.ndarray.astype() `_ - * `numpy.ndarray.clip() `_ - * `numpy.ndarray.compress() `_ - * `numpy.ndarray.conj() `_ - * `numpy.ndarray.conjugate() `_ - * `numpy.ndarray.nonzero() `_ - * `numpy.ndarray.ptp() `_ - * `numpy.ndarray.round() `_ - * `numpy.ndarray.std() `_ - * `numpy.ndarray.var() `_ - * `numpy.ndarray.tofile() `_ - * `numpy.ndarray.min() `_ - * `numpy.ndarray.mean() `_ - * `numpy.ndarray.prod() `_ - * `numpy.ndarray.fill() `_ - * `numpy.ndarray.tolist() `_ - * `numpy.ndarray.flatten() `_ - * `numpy.ndarray.ravel() `_ - * `numpy.ndarray.argmax() `_ - * `numpy.ndarray.argmin() `_ - * `numpy.ndarray.cumprod() `_ - * `numpy.ndarray.cumsum() `_ - * `numpy.ndarray.copy() `_ - -For example, - -.. code-block:: python - - >>> import numpy as np - >>> from pyomo.contrib.pynumero.sparse import BlockVector - >>> v = BlockVector(2) - >>> v.set_block(0, np.random.normal(size=100)) - >>> v.set_block(1, np.random.normal(size=30)) - >>> avg = v.mean() - -NumPy compatible functions: - - * `numpy.log10() `_ - * `numpy.sin() `_ - * `numpy.cos() `_ - * `numpy.exp() `_ - * `numpy.ceil() `_ - * `numpy.floor() `_ - * `numpy.tan() `_ - * `numpy.arctan() `_ - * `numpy.arcsin() `_ - * `numpy.arccos() `_ - * `numpy.sinh() `_ - * `numpy.cosh() `_ - * `numpy.abs() `_ - * `numpy.tanh() `_ - * `numpy.arccosh() `_ - * `numpy.arcsinh() `_ - * `numpy.arctanh() `_ - * `numpy.fabs() `_ - * `numpy.sqrt() `_ - * `numpy.log() `_ - * `numpy.log2() `_ - * `numpy.absolute() `_ - * `numpy.isfinite() `_ - * `numpy.isinf() `_ - * `numpy.isnan() `_ - * `numpy.log1p() `_ - * `numpy.logical_not() `_ - * `numpy.expm1() `_ - * `numpy.exp2() `_ - * `numpy.sign() `_ - * `numpy.rint() `_ - * `numpy.square() `_ - * `numpy.positive() `_ - * `numpy.negative() `_ - * `numpy.rad2deg() `_ - * `numpy.deg2rad() `_ - * `numpy.conjugate() `_ - * `numpy.reciprocal() `_ - * `numpy.signbit() `_ - * `numpy.add() `_ - * `numpy.multiply() `_ - * `numpy.divide() `_ - * `numpy.subtract() `_ - * `numpy.greater() `_ - * `numpy.greater_equal() `_ - * `numpy.less() `_ - * `numpy.less_equal() `_ - * `numpy.not_equal() `_ - * `numpy.maximum() `_ - * `numpy.minimum() `_ - * `numpy.fmax() `_ - * `numpy.fmin() `_ - * `numpy.equal() `_ - * `numpy.logical_and() `_ - * `numpy.logical_or() `_ - * `numpy.logical_xor() `_ - * `numpy.logaddexp() `_ - * `numpy.logaddexp2() `_ - * `numpy.remainder() `_ - * `numpy.heaviside() `_ - * `numpy.hypot() `_ - -For example, - -.. code-block:: python - - >>> import numpy as np - >>> from pyomo.contrib.pynumero.sparse import BlockVector - >>> v = BlockVector(2) - >>> v.set_block(0, np.random.normal(size=100)) - >>> v.set_block(1, np.random.normal(size=30)) - >>> inf_norm = np.max(np.abs(v)) - -.. autoclass:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.set_block -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.get_block -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.block_sizes -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.get_block_size -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.is_block_defined -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.copyfrom -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.copyto -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.copy_structure -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.set_blocks -.. automethod:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.pprint -.. autoproperty:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.nblocks -.. autoproperty:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.bshape -.. autoproperty:: pyomo.contrib.pynumero.sparse.block_vector.BlockVector.has_none diff --git a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.sparse.rst b/doc/OnlineDocs/contributed_packages/pynumero/pynumero.sparse.rst deleted file mode 100644 index 6d903abb5a4..00000000000 --- a/doc/OnlineDocs/contributed_packages/pynumero/pynumero.sparse.rst +++ /dev/null @@ -1,9 +0,0 @@ -PyNumero Block Linear Algebra -============================= - -.. automodule:: pyomo.contrib.pynumero.sparse - :members: - -.. toctree:: - - pynumero.sparse.block_vector diff --git a/doc/OnlineDocs/contribution_guide.rst b/doc/OnlineDocs/contribution_guide.rst index 10670627546..ba7a569d6bd 100644 --- a/doc/OnlineDocs/contribution_guide.rst +++ b/doc/OnlineDocs/contribution_guide.rst @@ -71,6 +71,10 @@ at least 70% coverage of the lines modified in the PR and prefer coverage closer to 90%. We also require that all tests pass before a PR will be merged. +.. note:: + If you are having issues getting tests to pass on your Pull Request, + please tag any of the core developers to ask for help. + The Pyomo main branch provides a Github Actions workflow (configured in the ``.github/`` directory) that will test any changes pushed to a branch with a subset of the complete test harness that includes @@ -82,13 +86,16 @@ This will enable the tests to run automatically with each push to your fork. At any point in the development cycle, a "work in progress" pull request may be opened by including '[WIP]' at the beginning of the PR -title. This allows your code changes to be tested by the full suite of -Pyomo's automatic -testing infrastructure. Any pull requests marked '[WIP]' will not be +title. Any pull requests marked '[WIP]' or draft will not be reviewed or merged by the core development team. However, any '[WIP]' pull request left open for an extended period of time without active development may be marked 'stale' and closed. +.. note:: + Draft and WIP Pull Requests will **NOT** trigger tests. This is an effort to + reduce our CI backlog. Please make use of the provided + branch test suite for evaluating / testing draft functionality. + Python Version Support ++++++++++++++++++++++ @@ -397,56 +404,16 @@ Contrib packages will be tested along with Pyomo. If test failures arise, then these packages will be disabled and an issue will be created to resolve these test failures. -The following two examples illustrate the two ways -that ``pyomo.contrib`` can be used to integrate third-party -contributions. - -Including External Packages -+++++++++++++++++++++++++++ - -The `pyomocontrib_simplemodel -`_ package -is derived from Pyomo, and it defines the class SimpleModel that -illustrates how Pyomo can be used in a simple, less object-oriented -manner. Specifically, this class mimics the modeling style supported -by `PuLP `_. - -While ``pyomocontrib_simplemodel`` can be installed and used separate -from Pyomo, this package is included in ``pyomo/contrib/simplemodel``. -This allows this package to be referenced as if were defined as a -subpackage of ``pyomo.contrib``. For example:: - - from pyomo.contrib.simplemodel import * - from math import pi - - m = SimpleModel() - - r = m.var('r', bounds=(0,None)) - h = m.var('h', bounds=(0,None)) - - m += 2*pi*r*(r + h) - m += pi*h*r**2 == 355 - - status = m.solve("ipopt") - -This example illustrates that a package can be distributed separate -from Pyomo while appearing to be included in the ``pyomo.contrib`` -subpackage. Pyomo requires a separate directory be defined under -``pyomo/contrib`` for each such package, and the Pyomo developer -team will approve the inclusion of third-party packages in this -manner. - - Contrib Packages within Pyomo +++++++++++++++++++++++++++++ -Third-party contributions can also be included directly within the +Third-party contributions can be included directly within the ``pyomo.contrib`` package. The ``pyomo/contrib/example`` package provides an example of how this can be done, including a directory for plugins and package tests. For example, this package can be imported as a subpackage of ``pyomo.contrib``:: - from pyomo.environ import * + import pyomo.environ as pyo from pyomo.contrib.example import a # Print the value of 'a' defined by this package @@ -458,7 +425,7 @@ import this package, but if an import failure occurs, Pyomo will silently ignore it. Otherwise, this pyomo package will be treated like any other. Specifically: -* Plugin classes defined in this package are loaded when `pyomo.environ` is loaded. +* Plugin classes defined in this package are loaded when ``pyomo.environ`` is loaded. * Tests in this package are run with other Pyomo tests. diff --git a/doc/OnlineDocs/developer_reference/future.rst b/doc/OnlineDocs/developer_reference/future.rst deleted file mode 100644 index 531c0fdb5c6..00000000000 --- a/doc/OnlineDocs/developer_reference/future.rst +++ /dev/null @@ -1,3 +0,0 @@ - -.. automodule:: pyomo.__future__ - :noindex: diff --git a/doc/OnlineDocs/developer_reference/index.rst b/doc/OnlineDocs/developer_reference/index.rst deleted file mode 100644 index 0feb33cdab9..00000000000 --- a/doc/OnlineDocs/developer_reference/index.rst +++ /dev/null @@ -1,16 +0,0 @@ -Developer Reference -=================== - -This section provides documentation about fundamental capabilities -in Pyomo. This documentation serves as a reference for both (1) -Pyomo developers and (2) advanced users who are developing Python -scripts using Pyomo. - -.. toctree:: - :maxdepth: 1 - - config.rst - deprecation.rst - expressions/index.rst - future.rst - solvers.rst diff --git a/doc/OnlineDocs/developer_reference/solvers.rst b/doc/OnlineDocs/developer_reference/solvers.rst deleted file mode 100644 index 45945c18b12..00000000000 --- a/doc/OnlineDocs/developer_reference/solvers.rst +++ /dev/null @@ -1,254 +0,0 @@ -Future Solver Interface Changes -=============================== - -Pyomo offers interfaces into multiple solvers, both commercial and open source. -To support better capabilities for solver interfaces, the Pyomo team is actively -redesigning the existing interfaces to make them more maintainable and intuitive -for use. Redesigned interfaces can be found in ``pyomo.contrib.solver``. - -.. currentmodule:: pyomo.contrib.solver - - -New Interface Usage -------------------- - -The new interfaces have two modes: backwards compatible and future capability. -The future capability mode can be accessed directly or by switching the default -``SolverFactory`` version (see :doc:`future`). Currently, the new versions -available are: - -.. list-table:: Available Redesigned Solvers - :widths: 25 25 25 - :header-rows: 1 - - * - Solver - - ``SolverFactory`` (v1) Name - - ``SolverFactory`` (v3) Name - * - ipopt - - ``ipopt_v2`` - - ``ipopt`` - * - Gurobi - - ``gurobi_v2`` - - ``gurobi`` - -Backwards Compatible Mode -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. testcode:: - :skipif: not ipopt_available - - import pyomo.environ as pyo - from pyomo.contrib.solver.util import assert_optimal_termination - - model = pyo.ConcreteModel() - model.x = pyo.Var(initialize=1.5) - model.y = pyo.Var(initialize=1.5) - - def rosenbrock(model): - return (1.0 - model.x) ** 2 + 100.0 * (model.y - model.x**2) ** 2 - - model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) - - status = pyo.SolverFactory('ipopt_v2').solve(model) - assert_optimal_termination(status) - model.pprint() - -.. testoutput:: - :skipif: not ipopt_available - :hide: - - 2 Var Declarations - ... - 3 Declarations: x y obj - -Future Capability Mode -^^^^^^^^^^^^^^^^^^^^^^ - -There are multiple ways to utilize the future capability mode: direct import -or changed ``SolverFactory`` version. - -.. testcode:: - :skipif: not ipopt_available - - # Direct import - import pyomo.environ as pyo - from pyomo.contrib.solver.util import assert_optimal_termination - from pyomo.contrib.solver.ipopt import Ipopt - - model = pyo.ConcreteModel() - model.x = pyo.Var(initialize=1.5) - model.y = pyo.Var(initialize=1.5) - - def rosenbrock(model): - return (1.0 - model.x) ** 2 + 100.0 * (model.y - model.x**2) ** 2 - - model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) - - opt = Ipopt() - status = opt.solve(model) - assert_optimal_termination(status) - # Displays important results information; only available in future capability mode - status.display() - model.pprint() - -.. testoutput:: - :skipif: not ipopt_available - :hide: - - solution_loader: ... - ... - 3 Declarations: x y obj - -Changing the ``SolverFactory`` version: - -.. testcode:: - :skipif: not ipopt_available - - # Change SolverFactory version - import pyomo.environ as pyo - from pyomo.contrib.solver.util import assert_optimal_termination - from pyomo.__future__ import solver_factory_v3 - - model = pyo.ConcreteModel() - model.x = pyo.Var(initialize=1.5) - model.y = pyo.Var(initialize=1.5) - - def rosenbrock(model): - return (1.0 - model.x) ** 2 + 100.0 * (model.y - model.x**2) ** 2 - - model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) - - status = pyo.SolverFactory('ipopt').solve(model) - assert_optimal_termination(status) - # Displays important results information; only available in future capability mode - status.display() - model.pprint() - -.. testoutput:: - :skipif: not ipopt_available - :hide: - - solution_loader: ... - ... - 3 Declarations: x y obj - -.. testcode:: - :skipif: not ipopt_available - :hide: - - from pyomo.__future__ import solver_factory_v1 - -Linear Presolve and Scaling -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The new interface will allow for direct manipulation of linear presolve and scaling -options for certain solvers. Currently, these options are only available for -``ipopt``. - -.. autoclass:: pyomo.contrib.solver.ipopt.Ipopt - :members: solve - -The ``writer_config`` configuration option can be used to manipulate presolve -and scaling options: - -.. testcode:: - - from pyomo.contrib.solver.ipopt import Ipopt - opt = Ipopt() - opt.config.writer_config.display() - -.. testoutput:: - - show_section_timing: false - skip_trivial_constraints: true - file_determinism: FileDeterminism.ORDERED - symbolic_solver_labels: false - scale_model: true - export_nonlinear_variables: None - row_order: None - column_order: None - export_defined_variables: true - linear_presolve: true - -Note that, by default, both ``linear_presolve`` and ``scale_model`` are enabled. -Users can manipulate ``linear_presolve`` and ``scale_model`` to their preferred -states by changing their values. - -.. code-block:: python - - >>> opt.config.writer_config.linear_presolve = False - - -Interface Implementation ------------------------- - -All new interfaces should be built upon one of two classes (currently): -:class:`SolverBase` or -:class:`PersistentSolverBase`. - -All solvers should have the following: - -.. autoclass:: pyomo.contrib.solver.base.SolverBase - :members: - -Persistent solvers include additional members as well as other configuration options: - -.. autoclass:: pyomo.contrib.solver.base.PersistentSolverBase - :show-inheritance: - :members: - -Results -------- - -Every solver, at the end of a -:meth:`solve` call, will -return a :class:`Results` -object. This object is a :py:class:`pyomo.common.config.ConfigDict`, -which can be manipulated similar to a standard ``dict`` in Python. - -.. autoclass:: pyomo.contrib.solver.results.Results - :show-inheritance: - :members: - :undoc-members: - - -Termination Conditions -^^^^^^^^^^^^^^^^^^^^^^ - -Pyomo offers a standard set of termination conditions to map to solver -returns. The intent of -:class:`TerminationCondition` -is to notify the user of why the solver exited. The user is expected -to inspect the :class:`Results` -object or any returned solver messages or logs for more information. - -.. autoclass:: pyomo.contrib.solver.results.TerminationCondition - :show-inheritance: - - -Solution Status -^^^^^^^^^^^^^^^ - -Pyomo offers a standard set of solution statuses to map to solver -output. The intent of -:class:`SolutionStatus` -is to notify the user of what the solver returned at a high level. The -user is expected to inspect the -:class:`Results` object or any -returned solver messages or logs for more information. - -.. autoclass:: pyomo.contrib.solver.results.SolutionStatus - :show-inheritance: - - -Solution --------- - -Solutions can be loaded back into a model using a ``SolutionLoader``. A specific -loader should be written for each unique case. Several have already been -implemented. For example, for ``ipopt``: - -.. autoclass:: pyomo.contrib.solver.ipopt.IpoptSolutionLoader - :show-inheritance: - :members: - :inherited-members: diff --git a/doc/OnlineDocs/explanation/analysis/alternative_solutions.rst b/doc/OnlineDocs/explanation/analysis/alternative_solutions.rst new file mode 100644 index 00000000000..899db8e8757 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/alternative_solutions.rst @@ -0,0 +1,234 @@ +############################################### +Generating Alternative (Near-)Optimal Solutions +############################################### + +Optimization solvers are generally designed to return a feasible solution +to the user. However, there are many applications where a user needs +more context than this result. For example, + +* alternative solutions can support an assessment of trade-offs between + competing objectives; + +* if the optimization formulation may be inaccurate or untrustworthy, + then comparisons amongst alternative solutions provide additional + insights into the reliability of these model predictions; or + +* the user may have unexpressed objectives or constraints, which only + are realized in later stages of model analysis. + +The *alternative-solutions library* provides a variety of functions that +can be used to generate optimal or near-optimal solutions for a pyomo +model. Conceptually, these functions are like pyomo solvers. They can +be configured with solver names and options, and they return a list of +solutions for the pyomo model. However, these functions are independent +of pyomo's solver interface because they return a custom solution object. + +The following functions are defined in the alternative-solutions library: + +* ``enumerate_binary_solutions`` + + * Finds alternative optimal solutions for a binary problem using no-good cuts. + +* ``enumerate_linear_solutions`` + + * Finds alternative optimal solutions for a (mixed-integer) linear program. + +* ``enumerate_linear_solutions_soln_pool`` + + * Finds alternative optimal solutions for a (mixed-binary) linear + program using Gurobi's solution pool feature. + +* ``gurobi_generate_solutions`` + + * Finds alternative optimal solutions for discrete variables using + Gurobi's built-in solution pool capability. + +* ``obbt_analysis_bounds_and_solutions`` + + * Calculates the bounds on each variable by solving a series of min + and max optimization problems where each variable is used as the + objective function. This can be applied to any class of problem + supported by the selected solver. + + +Basic Usage Example +------------------- + +Many of the functions in the alternative-solutions library have similar +options, so we simply illustrate the ``enumerate_binary_solutions`` +function. We define a simple knapsack example whose alternative +solutions have integer objective values ranging from 0 to 90. + +.. doctest:: + + >>> import pyomo.environ as pyo + + >>> values = [10, 40, 30, 50] + >>> weights = [5, 4, 6, 3] + >>> capacity = 10 + + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var(range(4), within=pyo.Binary) + >>> m.o = pyo.Objective(expr=sum(values[i] * m.x[i] for i in range(4)), sense=pyo.maximize) + >>> m.c = pyo.Constraint(expr=sum(weights[i] * m.x[i] for i in range(4)) <= capacity) + +We can execute the ``enumerate_binary_solutions`` function to generate a +list of ``Solution`` objects that represent alternative optimal +solutions: + +.. doctest:: + :skipif: not glpk_available + + >>> import pyomo.contrib.alternative_solutions as aos + >>> solns = aos.enumerate_binary_solutions(m, num_solutions=100, solver="glpk") + >>> assert len(solns) == 10 + +Each ``Solution`` object contains information about the objective and +variables, and it includes various methods to access this information. +For example: + +.. doctest:: + :skipif: not glpk_available + + >>> print(solns[0]) + { + "fixed_variables": [], + "objective": "o", + "objective_value": 90.0, + "solution": { + "x[0]": 0, + "x[1]": 1, + "x[2]": 0, + "x[3]": 1 + } + } + + +Gap Usage Example +----------------- + +When we only want some of the solutions based off a tolerance away from +optimal, this can be done using the ``abs_opt_gap`` parameter. This is +shown in the following simple knapsack examples where the weights and +values are the same. + +.. doctest:: + :skipif: not glpk_available + + >>> import pyomo.environ as pyo + >>> import pyomo.contrib.alternative_solutions as aos + + >>> values = [10,9,2,1,1] + >>> weights = [10,9,2,1,1] + + >>> K = len(values) + >>> capacity = 12 + + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var(range(K), within=pyo.Binary) + >>> m.o = pyo.Objective(expr=sum(values[i] * m.x[i] for i in range(K)), sense=pyo.maximize) + >>> m.c = pyo.Constraint(expr=sum(weights[i] * m.x[i] for i in range(K)) <= capacity) + + >>> solns = aos.enumerate_binary_solutions(m, num_solutions=10, solver="glpk", abs_opt_gap = 0.0) + >>> assert(len(solns) == 4) + +In this example, we only get the four ``Solution`` objects that have an +``objective_value`` of 12. Note that while we wanted only those four +solutions with no optimality gap, using a gap of half the smallest value +(in this case .5) will return the same solutions and avoids any machine +precision issues. + +.. doctest:: + :skipif: not glpk_available + + >>> import pyomo.environ as pyo + >>> import pyomo.contrib.alternative_solutions as aos + + >>> values = [10,9,2,1,1] + >>> weights = [10,9,2,1,1] + + >>> K = len(values) + >>> capacity = 12 + + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var(range(K), within=pyo.Binary) + >>> m.o = pyo.Objective(expr=sum(values[i] * m.x[i] for i in range(K)), sense=pyo.maximize) + >>> m.c = pyo.Constraint(expr=sum(weights[i] * m.x[i] for i in range(K)) <= capacity) + + >>> solns = aos.enumerate_binary_solutions(m, num_solutions=10, solver="glpk", abs_opt_gap = 0.5) + >>> assert(len(solns) == 4) + >>> for soln in sorted(solns, key=lambda s: str(s.get_variable_name_values())): + ... print(soln) + { + "fixed_variables": [], + "objective": "o", + "objective_value": 12.0, + "solution": { + "x[0]": 0, + "x[1]": 1, + "x[2]": 1, + "x[3]": 0, + "x[4]": 1 + } + } + { + "fixed_variables": [], + "objective": "o", + "objective_value": 12.0, + "solution": { + "x[0]": 0, + "x[1]": 1, + "x[2]": 1, + "x[3]": 1, + "x[4]": 0 + } + } + { + "fixed_variables": [], + "objective": "o", + "objective_value": 12.0, + "solution": { + "x[0]": 1, + "x[1]": 0, + "x[2]": 0, + "x[3]": 1, + "x[4]": 1 + } + } + { + "fixed_variables": [], + "objective": "o", + "objective_value": 12.0, + "solution": { + "x[0]": 1, + "x[1]": 0, + "x[2]": 1, + "x[3]": 0, + "x[4]": 0 + } + } + + +Interface Documentation +----------------------- + +.. currentmodule:: pyomo.contrib.alternative_solutions + +.. autofunction:: enumerate_binary_solutions + :noindex: + +.. autofunction:: enumerate_linear_solutions + :noindex: + +.. autofunction:: pyomo.contrib.alternative_solutions.lp_enum_solnpool.enumerate_linear_solutions_soln_pool + :noindex: + +.. autofunction:: gurobi_generate_solutions + :noindex: + +.. autofunction:: obbt_analysis_bounds_and_solutions + :noindex: + +.. autoclass:: Solution + :noindex: + diff --git a/doc/OnlineDocs/contributed_packages/communities_8pp.png b/doc/OnlineDocs/explanation/analysis/communities_8pp.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/communities_8pp.png rename to doc/OnlineDocs/explanation/analysis/communities_8pp.png diff --git a/doc/OnlineDocs/contributed_packages/communities_decode_1.png b/doc/OnlineDocs/explanation/analysis/communities_decode_1.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/communities_decode_1.png rename to doc/OnlineDocs/explanation/analysis/communities_decode_1.png diff --git a/doc/OnlineDocs/contributed_packages/community.rst b/doc/OnlineDocs/explanation/analysis/community.rst similarity index 98% rename from doc/OnlineDocs/contributed_packages/community.rst rename to doc/OnlineDocs/explanation/analysis/community.rst index b110107e604..f95e1d16261 100644 --- a/doc/OnlineDocs/contributed_packages/community.rst +++ b/doc/OnlineDocs/explanation/analysis/community.rst @@ -24,7 +24,7 @@ detection. Thus, this package provides the user with a lot of control over the c function we use for this community detection is shown below: .. autofunction:: pyomo.contrib.community_detection.detection.detect_communities - :noindex: + :noindex: As stated above, the characteristics of the NetworkX graph of the Pyomo model are very important to the community detection. The main graph features the user can specify are the type of community map, @@ -278,9 +278,11 @@ community_map attribute or the `repr()` function can be used: Generate a matplotlib figure (left_figure) - a constraint graph of the community map >>> left_figure, _ = cmo.visualize_model_graph(type_of_graph='constraint') + >>> left_figure.show() # doctest: +SKIP Now, we will generate the figure on the right (a bipartite graph of the community map) >>> right_figure, _ = cmo.visualize_model_graph(type_of_graph='bipartite') + >>> right_figure.show() # doctest: +SKIP An example of the two separate graphs created for these two function calls is shown below: .. image:: communities_decode_1.png @@ -312,13 +314,16 @@ An example of the two separate graphs created for these two function calls is sh Now, we follow steps similar to the example above (see above for explanations) >>> community_map_object = cmo = detect_communities(model, type_of_community_map='constraint', random_seed=seed) >>> left_fig, pos = cmo.visualize_model_graph(type_of_graph='variable') + >>> left_fig.show() # doctest: +SKIP As we did before, we will use the returned 'pos' to create a consistent graph layout >>> community_map_object = cmo = detect_communities(model, type_of_community_map='bipartite') >>> middle_fig, _ = cmo.visualize_model_graph(type_of_graph='variable', pos=pos) + >>> middle_fig.show() # doctest: +SKIP >>> community_map_object = cmo = detect_communities(model, type_of_community_map='variable') >>> right_fig, _ = cmo.visualize_model_graph(type_of_graph='variable', pos=pos) + >>> right_fig.show() # doctest: +SKIP We can see an example for the three separate graphs created by these three function calls below: .. image:: communities_8pp.png @@ -383,7 +388,9 @@ We can see an example for the three separate graphs created by these three funct Functions in this Package ------------------------- .. automodule:: pyomo.contrib.community_detection.detection + :noindex: :members: .. automodule:: pyomo.contrib.community_detection.community_graph + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/doe/CCSI-license.txt b/doc/OnlineDocs/explanation/analysis/doe/CCSI-license.txt similarity index 100% rename from doc/OnlineDocs/contributed_packages/doe/CCSI-license.txt rename to doc/OnlineDocs/explanation/analysis/doe/CCSI-license.txt diff --git a/doc/OnlineDocs/explanation/analysis/doe/FIM_sensitivity.png b/doc/OnlineDocs/explanation/analysis/doe/FIM_sensitivity.png new file mode 100644 index 00000000000..af6b75cbbea Binary files /dev/null and b/doc/OnlineDocs/explanation/analysis/doe/FIM_sensitivity.png differ diff --git a/doc/OnlineDocs/contributed_packages/doe/doe.rst b/doc/OnlineDocs/explanation/analysis/doe/doe.rst similarity index 56% rename from doc/OnlineDocs/contributed_packages/doe/doe.rst rename to doc/OnlineDocs/explanation/analysis/doe/doe.rst index 8c22ff7370d..6e77e55dfd6 100644 --- a/doc/OnlineDocs/contributed_packages/doe/doe.rst +++ b/doc/OnlineDocs/explanation/analysis/doe/doe.rst @@ -3,13 +3,13 @@ Pyomo.DoE **Pyomo.DoE** (Pyomo Design of Experiments) is a Python library for model-based design of experiments using science-based models. -Pyomo.DoE was developed by **Jialu Wang** and **Alexander W. Dowling** at the University of Notre Dame as part of the `Carbon Capture Simulation for Industry Impact (CCSI2) `_. +Pyomo.DoE was developed by **Jialu Wang** and **Alexander W. Dowling** at the University of Notre Dame as part of the `Carbon Capture Simulation for Industry Impact (CCSI2) `_. project, funded through the U.S. Department Of Energy Office of Fossil Energy. If you use Pyomo.DoE, please cite: -[Wang and Dowling, 2022] Wang, Jialu, and Alexander W. Dowling. -"Pyomo.DOE: An open‐source package for model‐based design of experiments in Python." +[Wang and Dowling, 2022] Wang, Jialu, and Alexander W. Dowling. +"Pyomo.DOE: An open‐source package for model‐based design of experiments in Python." AIChE Journal 68.12 (2022): e17813. `https://doi.org/10.1002/aic.17813` Methodology Overview @@ -26,7 +26,7 @@ Pyomo.DoE provides the exploratory analysis and MBDoE capabilities to the Pyomo the allowable design spaces for design variables, and the assumed observation error model. During exploratory analysis, Pyomo.DoE checks if the model parameters can be inferred from the postulated measurements or preliminary data. MBDoE then recommends optimized experimental conditions for collecting more data. -Parameter estimation packages such as `Parmest `_ can perform parameter estimation using the available data to infer values for parameters, +Parameter estimation packages such as :ref:`Parmest ` can perform parameter estimation using the available data to infer values for parameters, and facilitate an uncertainty analysis to approximate the parameter covariance matrix. If the parameter uncertainties are sufficiently small, the workflow terminates and returns the final model with quantified parametric uncertainty. If not, MBDoE recommends optimized experimental conditions to generate new data. @@ -41,14 +41,16 @@ Below is an overview of the type of optimization models Pyomo.DoE can accommodat The general form of a DAE problem that can be passed into Pyomo.DoE is shown below: .. math:: - \begin{align*} - & \dot{\mathbf{x}}(t) = \mathbf{f}(\mathbf{x}(t), \mathbf{z}(t), \mathbf{y}(t), \mathbf{u}(t), \overline{\mathbf{w}}, \boldsymbol{\theta}) \\ - & \mathbf{g}(\mathbf{x}(t), \mathbf{z}(t), \mathbf{y}(t), \mathbf{u}(t), \overline{\mathbf{w}},\boldsymbol{\theta})=\mathbf{0} \\ - & \mathbf{y} =\mathbf{h}(\mathbf{x}(t), \mathbf{z}(t), \mathbf{u}(t), \overline{\mathbf{w}},\boldsymbol{\theta}) \\ - & \mathbf{f}^{\mathbf{0}}\left(\dot{\mathbf{x}}\left(t_{0}\right), \mathbf{x}\left(t_{0}\right), \mathbf{z}(t_0), \mathbf{y}(t_0), \mathbf{u}\left(t_{0}\right), \overline{\mathbf{w}}, \boldsymbol{\theta})\right)=\mathbf{0} \\ - & \mathbf{g}^{\mathbf{0}}\left( \mathbf{x}\left(t_{0}\right),\mathbf{z}(t_0), \mathbf{y}(t_0), \mathbf{u}\left(t_{0}\right), \overline{\mathbf{w}}, \boldsymbol{\theta}\right)=\mathbf{0}\\ - &\mathbf{y}^{\mathbf{0}}\left(t_{0}\right)=\mathbf{h}\left(\mathbf{x}\left(t_{0}\right),\mathbf{z}(t_0), \mathbf{u}\left(t_{0}\right), \overline{\mathbf{w}}, \boldsymbol{\theta}\right) - \end{align*} + :nowrap: + + \[\begin{array}{l} + \dot{\mathbf{x}}(t) = \mathbf{f}(\mathbf{x}(t), \mathbf{z}(t), \mathbf{y}(t), \mathbf{u}(t), \overline{\mathbf{w}}, \boldsymbol{\theta}) \\ + \mathbf{g}(\mathbf{x}(t), \mathbf{z}(t), \mathbf{y}(t), \mathbf{u}(t), \overline{\mathbf{w}},\boldsymbol{\theta})=\mathbf{0} \\ + \mathbf{y} =\mathbf{h}(\mathbf{x}(t), \mathbf{z}(t), \mathbf{u}(t), \overline{\mathbf{w}},\boldsymbol{\theta}) \\ + \mathbf{f}^{\mathbf{0}}\left(\dot{\mathbf{x}}\left(t_{0}\right), \mathbf{x}\left(t_{0}\right), \mathbf{z}(t_0), \mathbf{y}(t_0), \mathbf{u}\left(t_{0}\right), \overline{\mathbf{w}}, \boldsymbol{\theta})\right)=\mathbf{0} \\ + \mathbf{g}^{\mathbf{0}}\left( \mathbf{x}\left(t_{0}\right),\mathbf{z}(t_0), \mathbf{y}(t_0), \mathbf{u}\left(t_{0}\right), \overline{\mathbf{w}}, \boldsymbol{\theta}\right)=\mathbf{0}\\ + \mathbf{y}^{\mathbf{0}}\left(t_{0}\right)=\mathbf{h}\left(\mathbf{x}\left(t_{0}\right),\mathbf{z}(t_0), \mathbf{u}\left(t_{0}\right), \overline{\mathbf{w}}, \boldsymbol{\theta}\right) + \end{array}\] where: @@ -69,6 +71,8 @@ where: Based on the above notation, the form of the MBDoE problem addressed in Pyomo.DoE is shown below: .. math:: + :nowrap: + \begin{equation} \begin{aligned} \underset{\boldsymbol{\varphi}}{\max} \quad & \Psi (\mathbf{M}(\mathbf{\hat{y}}, \boldsymbol{\varphi})) \\ @@ -116,64 +120,14 @@ In order to solve problems of the above, Pyomo.DoE implements the 2-stage stocha Pyomo.DoE Required Inputs -------------------------------- -The required inputs to the Pyomo.DoE solver are the following: - -* A function that creates the process model -* Dictionary of parameters and their nominal value -* A measurement object -* A design variables object -* A Numpy ``array`` containing the Prior FIM -* Optimization solver - -Below is a list of arguments that Pyomo.DoE expects the user to provide. - -parameter_dict : ``dictionary`` - A ``dictionary`` of parameter names and values. If they are an indexed variable, put the variable name and index in a nested ``Dictionary``. - -design_variables: ``DesignVariables`` - A ``DesignVariables`` of design variables, provided by the DesignVariables class. - If this design var is independent of time (constant), set the time to [0] - -measurement_variables : ``MeasurementVariables`` - A ``MeasurementVariables`` of the measurements, provided by the MeasurementVariables class. - -create_model : ``function`` - A ``function`` returning a deterministic process model. - -prior_FIM : ``array`` - An ``array`` defining the Fisher information matrix (FIM) for prior experiments, default is a zero matrix. - -Pyomo.DoE Solver Interface ---------------------------- +The required input to the Pyomo.DoE solver is an ``Experiment`` object. The experiment object must have a ``get_labeled_model`` function which returns a Pyomo model with four ``Suffix`` components identifying the parts of the model used in MBDoE analysis. This is in line with the convention used in the parameter estimation tool, :ref:`Parmest `. The four ``Suffix`` components are: -.. figure:: uml.png - :scale: 25 % - - -.. autoclass:: pyomo.contrib.doe.doe.DesignOfExperiments - :members: __init__, stochastic_program, compute_FIM, run_grid_search - -.. Note:: - ``stochastic_program()`` includes the following steps: - #. Build two-stage stochastic programming optimization model where scenarios correspond to finite difference approximations for the Jacobian of the response variables with respect to calibrated model parameters - #. Fix the experiment design decisions and solve a square (i.e., zero degrees of freedom) instance of the two-stage DOE problem. This step is for initialization. - #. Unfix the experiment design decisions and solve the two-stage DOE problem. - -.. autoclass:: pyomo.contrib.doe.measurements.MeasurementVariables - :members: __init__, add_variables - -.. autoclass:: pyomo.contrib.doe.measurements.DesignVariables - :members: __init__, add_variables - -.. autoclass:: pyomo.contrib.doe.scenario.ScenarioGenerator - :special-members: __init__ - -.. autoclass:: pyomo.contrib.doe.result.FisherResults - :members: __init__, result_analysis - -.. autoclass:: pyomo.contrib.doe.result.GridSearchResult - :special-members: __init__ +* ``experiment_inputs`` - The experimental design decisions +* ``experiment_outputs`` - The values measured during the experiment +* ``measurement_error`` - The error associated with individual values measured during the experiment +* ``unknown_parameters`` - Those parameters in the model that are estimated using the measured values during the experiment +An example ``Experiment`` object that builds and labels the model is shown in the next few sections. Pyomo.DoE Usage Example ----------------------- @@ -183,6 +137,8 @@ The Arrhenius equations model the temperature dependence of the reaction rate co .. math:: + :nowrap: + \begin{equation} \begin{aligned} k_1 & = A_1 e^{-\frac{E_1}{RT}} \\ @@ -203,89 +159,87 @@ The goal of MBDoE is to optimize the experiment design variables :math:`\boldsym The observation errors are assumed to be independent both in time and across measurements with a constant standard deviation of 1 M for each species. -Step 0: Import Pyomo and the Pyomo.DoE module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Step 0: Import Pyomo and the Pyomo.DoE module and create an ``Experiment`` class +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. doctest:: >>> # === Required import === >>> import pyomo.environ as pyo - >>> from pyomo.dae import ContinuousSet, DerivativeVar - >>> from pyomo.contrib.doe import DesignOfExperiments, MeasurementVariables, DesignVariables + >>> from pyomo.contrib.doe import DesignOfExperiments >>> import numpy as np +.. literalinclude:: /../../pyomo/contrib/doe/examples/reactor_experiment.py + :start-after: ======================== + :end-before: End constructor definition + Step 1: Define the Pyomo process model ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The process model for the reaction kinetics problem is shown below. +The process model for the reaction kinetics problem is shown below. We build the model without any data or discretization. -.. literalinclude:: ../../../../pyomo/contrib/doe/examples/reactor_kinetics.py - :language: python - :pyobject: create_model +.. literalinclude:: /../../pyomo/contrib/doe/examples/reactor_experiment.py + :start-after: Create flexible model without data + :end-before: End equation definition -.. literalinclude:: ../../../../pyomo/contrib/doe/examples/reactor_kinetics.py - :language: python - :pyobject: disc_for_measure +Step 2: Finalize the Pyomo process model +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. note:: - The model requires at least two options: "block" and "global". Both options requires the pass of a created empty Pyomo model. - With "global" option, only design variables and their time sets need to be defined; - With "block" option, a full model needs to be defined. +Here we add data to the model and finalize the discretization. This step is required before the model can be labeled. +.. literalinclude:: /../../pyomo/contrib/doe/examples/reactor_experiment.py + :start-after: End equation definition + :end-before: End model finalization -Step 2: Define the inputs for Pyomo.DoE -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. literalinclude:: ../../../../pyomo/contrib/doe/examples/reactor_compute_FIM.py - :language: python - :start-at: # Control time set - :end-before: ### Compute +Step 3: Label the information needed for DoE analysis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +We label the four important groups as defined before. -Step 3: Compute the FIM of a square MBDoE problem -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. literalinclude:: /../../pyomo/contrib/doe/examples/reactor_experiment.py + :start-after: End model finalization + :end-before: End model labeling -This method computes an MBDoE optimization problem with no degree of freedom. +Step 4: Implement the ``get_labeled_model`` method +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -This method can be accomplished by two modes, ``direct_kaug`` and ``sequential_finite``. -``direct_kaug`` mode requires the installation of the solver `k_aug `_. +This method utilizes the previous 3 steps and is used by `Pyomo.DoE` to build the model to perform optimal experimental design. -.. literalinclude:: ../../../../pyomo/contrib/doe/examples/reactor_compute_FIM.py - :language: python - :start-after: ### Compute the FIM - :end-before: # test result +.. literalinclude:: /../../pyomo/contrib/doe/examples/reactor_experiment.py + :start-after: End constructor definition + :end-before: Create flexible model without data -Step 4: Exploratory analysis (Enumeration) +Step 5: Exploratory analysis (Enumeration) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Exploratory analysis is suggested to enumerate the design space to check if the problem is identifiable, +Exploratory analysis is suggested to enumerate the design space to check if the problem is identifiable, i.e., ensure that D-, E-optimality metrics are not small numbers near zero, and Modified E-optimality is not a big number. -Pyomo.DoE accomplishes the exploratory analysis with the ``run_grid_search`` function. -It allows users to define any number of design decisions. Heatmaps can be drawn by two design variables, fixing other design variables. -1D curve can be drawn by one design variable, fixing all other variables. -The function ``run_grid_search`` enumerates over the design space, each MBDoE problem accomplished by ``compute_FIM`` method. -Therefore, ``run_grid_search`` supports only two modes: ``sequential_finite`` and ``direct_kaug``. +Pyomo.DoE can perform exploratory sensitivity analysis with the ``compute_FIM_full_factorial`` function. +The ``compute_FIM_full_factorial`` function generates a grid over the design space as specified by the user. Each grid point represents an MBDoE problem solved using ``compute_FIM`` method. In this way, sensitivity of the FIM over the design space can be evaluated. -.. literalinclude:: ../../../../pyomo/contrib/doe/examples/reactor_grid_search.py - :language: python - :pyobject: main +The following code executes the above problem description: -Successful run of the above code shows the following figure: +.. literalinclude:: /../../pyomo/contrib/doe/examples/reactor_example.py + :start-after: Read in file + :end-before: End sensitivity analysis -.. figure:: grid-1.png - :scale: 35 % +An example output of the code above, a design exploration for the initial concentration and temperature as experimental design variables with 9 values, produces the four figures summarized below: -A heatmap shows the change of the objective function, a.k.a. the experimental information content, in the design region. Horizontal and vertical axes are two design variables, while the color of each grid shows the experimental information content. Taking the Fig. Reactor case - A optimality as example, A-optimality shows that the most informative region is around $C_{A0}=5.0$ M, $T=300.0$ K, while the least informative region is around $C_{A0}=1.0$ M, $T=700.0$ K. +.. figure:: FIM_sensitivity.png + :scale: 50 % -Step 5: Gradient-based optimization -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +A heatmap shows the change of the objective function, a.k.a. the experimental information content, in the design space. Horizontal and vertical axes are the two experimental design variables, while the color of each grid shows the experimental information content. For A optimality (top left subfigure), the figure shows that the most informative region is around :math:`C_{A0}=5.0` M, :math:`T=300.0` K, while the least informative region is around :math:`C_{A0}=1.0` M, :math:`T=700.0` K. + +Step 6: Performing an optimal experimental design +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Pyomo.DoE accomplishes gradient-based optimization with the ``stochastic_program`` function for A- and D-optimality design. +In step 5, the DoE object was constructed to perform an exploratory sensitivity analysis. The same object can be used to design an optimal experiment with a single line of code. -This function solves twice: It solves the square version of the MBDoE problem first, and then unfixes the design variables as degree of freedoms and solves again. In this way the optimization problem can be well initialized. +.. literalinclude:: /../../pyomo/contrib/doe/examples/reactor_example.py + :start-after: Begin optimal DoE + :end-before: Print out a results summary -.. literalinclude:: ../../../../pyomo/contrib/doe/examples/reactor_optimize_doe.py - :language: python - :pyobject: main +When run, the optimal design is an initial concentration of 5.0 mol/L and an initial temperature of 494 K with all other temperatures being 300 K. The corresponding log-10 determinant of the FIM is 13.75 diff --git a/doc/OnlineDocs/contributed_packages/doe/flowchart.png b/doc/OnlineDocs/explanation/analysis/doe/flowchart.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/doe/flowchart.png rename to doc/OnlineDocs/explanation/analysis/doe/flowchart.png diff --git a/doc/OnlineDocs/contributed_packages/doe/grid-1.png b/doc/OnlineDocs/explanation/analysis/doe/grid-1.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/doe/grid-1.png rename to doc/OnlineDocs/explanation/analysis/doe/grid-1.png diff --git a/doc/OnlineDocs/contributed_packages/doe/reactor.png b/doc/OnlineDocs/explanation/analysis/doe/reactor.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/doe/reactor.png rename to doc/OnlineDocs/explanation/analysis/doe/reactor.png diff --git a/doc/OnlineDocs/contributed_packages/doe/uml.png b/doc/OnlineDocs/explanation/analysis/doe/uml.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/doe/uml.png rename to doc/OnlineDocs/explanation/analysis/doe/uml.png diff --git a/doc/OnlineDocs/explanation/analysis/iis.rst b/doc/OnlineDocs/explanation/analysis/iis.rst new file mode 100644 index 00000000000..773560c4e28 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/iis.rst @@ -0,0 +1,141 @@ +Infeasibility Diagnostics +!!!!!!!!!!!!!!!!!!!!!!!!! + +There are two closely related tools for infeasibility diagnosis: + + - :ref:`iis` + - :ref:`mis` + +The first simply provides a conduit for solvers that compute an +infeasible irreducible system (e.g., Cplex, Gurobi, or Xpress). The +second provides similar functionality, but uses the ``mis`` package +contributed to Pyomo. + + +.. _iis: + +Infeasible Irreducible System (IIS) Tool +======================================== + +.. automodule:: pyomo.contrib.iis.iis + :noindex: + +.. autofunction:: pyomo.contrib.iis.write_iis + :noindex: + +.. _mis: + +Minimal Intractable System finder (MIS) Tool +============================================ + +The file ``mis.py`` finds sets of actions that each, independently, +would result in feasibility. The zero-tolerance is whatever the +solver uses, so users may want to post-process output if it is going +to be used for analysis. It also computes a minimal intractable system +(which is not guaranteed to be unique). It was written by Ben Knueven +as part of the watertap project (https://github.com/watertap-org/watertap) +and is therefore governed by a license shown +at the top of ``mis.py``. + +The algorithms come from John Chinneck's slides, see: https://www.sce.carleton.ca/faculty/chinneck/docs/CPAIOR07InfeasibilityTutorial.pdf + +Solver +------ + +At the time of this writing, you need to use IPopt even for LPs. + +Quick Start +----------- + +The file ``trivial_mis.py`` is a tiny example listed at the bottom of +this help file, which references a Pyomo model with the Python variable +`m` and has these lines: + +.. code-block:: python + + from pyomo.contrib.mis import compute_infeasibility_explanation + ipopt = pyo.SolverFactory("ipopt") + compute_infeasibility_explanation(m, solver=ipopt) + +.. Note:: + This is done instead of solving the problem. + +.. Note:: + IDAES users can pass ``get_solver()`` imported from ``ideas.core.solvers`` + as the solver. + +Interpreting the Output +----------------------- + +Assuming the dependencies are installed, running ``trivial_mis.py`` +(shown below) will +produce a lot of warnings from IPopt and then meaningful output (using a logger). + +Repair Options +^^^^^^^^^^^^^^ + +This output for the trivial example shows three independent ways that the model could be rendered feasible: + + +.. code-block:: text + + Model Trivial Quad may be infeasible. A feasible solution was found with only the following variable bounds relaxed: + ub of var x[1] by 4.464126126706818e-05 + lb of var x[2] by 0.9999553410114216 + Another feasible solution was found with only the following variable bounds relaxed: + lb of var x[1] by 0.7071067726864677 + ub of var x[2] by 0.41421355687130673 + ub of var y by 0.7071067651855212 + Another feasible solution was found with only the following inequality constraints, equality constraints, and/or variable bounds relaxed: + constraint: c by 0.9999999861866736 + + +Minimal Intractable System (MIS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This output shows a minimal intractable system: + + +.. code-block:: text + + Computed Minimal Intractable System (MIS)! + Constraints / bounds in MIS: + lb of var x[2] + lb of var x[1] + constraint: c + +Constraints / bounds in guards for stability +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This part of the report is for nonlinear programs (NLPs). + +When we’re trying to reduce the constraint set, for an NLP there may be constraints that when missing cause the solver +to fail in some catastrophic fashion. In this implementation this is interpreted as failing to get a `results` +object back from the call to `solve`. In these cases we keep the constraint in the problem but it’s in the +set of “guard” constraints – we can’t really be sure they’re a source of infeasibility or not, +just that “bad things” happen when they’re not included. + +Perhaps ideally we would put a constraint in the “guard” set if IPopt failed to converge, and only put it in the +MIS if IPopt converged to a point of local infeasibility. However, right now the code generally makes the +assumption that if IPopt fails to converge the subproblem is infeasible, though obviously that is far from the truth. +Hence for difficult NLPs even the “Phase 1” may “fail” – in that when finished the subproblem containing just the +constraints in the elastic filter may be feasible -- because IPopt failed to converge and we assumed that meant the +subproblem was not feasible. + +Dealing with NLPs is far from clean, but that doesn’t mean the tool can’t return useful results even when its assumptions are not satisfied. + +trivial_mis.py +-------------- + +.. code-block:: python + + import pyomo.environ as pyo + m = pyo.ConcreteModel("Trivial Quad") + m.x = pyo.Var([1,2], bounds=(0,1)) + m.y = pyo.Var(bounds=(0, 1)) + m.c = pyo.Constraint(expr=m.x[1] * m.x[2] == -1) + m.d = pyo.Constraint(expr=m.x[1] + m.y >= 1) + + from pyomo.contrib.mis import compute_infeasibility_explanation + ipopt = pyo.SolverFactory("ipopt") + compute_infeasibility_explanation(m, solver=ipopt) diff --git a/doc/OnlineDocs/contributed_packages/incidence/api.rst b/doc/OnlineDocs/explanation/analysis/incidence/api.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/incidence/api.rst rename to doc/OnlineDocs/explanation/analysis/incidence/api.rst diff --git a/doc/OnlineDocs/contributed_packages/incidence/config.rst b/doc/OnlineDocs/explanation/analysis/incidence/config.rst similarity index 89% rename from doc/OnlineDocs/contributed_packages/incidence/config.rst rename to doc/OnlineDocs/explanation/analysis/incidence/config.rst index 06e4f5c5626..5260d3de256 100644 --- a/doc/OnlineDocs/contributed_packages/incidence/config.rst +++ b/doc/OnlineDocs/explanation/analysis/incidence/config.rst @@ -2,4 +2,5 @@ Incidence Options ================= .. automodule:: pyomo.contrib.incidence_analysis.config + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/incidence/connected.rst b/doc/OnlineDocs/explanation/analysis/incidence/connected.rst similarity index 90% rename from doc/OnlineDocs/contributed_packages/incidence/connected.rst rename to doc/OnlineDocs/explanation/analysis/incidence/connected.rst index 4cf60f62eba..301d78f8a95 100644 --- a/doc/OnlineDocs/contributed_packages/incidence/connected.rst +++ b/doc/OnlineDocs/explanation/analysis/incidence/connected.rst @@ -2,4 +2,5 @@ Weakly Connected Components =========================== .. automodule:: pyomo.contrib.incidence_analysis.connected + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/incidence/dulmage_mendelsohn.rst b/doc/OnlineDocs/explanation/analysis/incidence/dulmage_mendelsohn.rst similarity index 91% rename from doc/OnlineDocs/contributed_packages/incidence/dulmage_mendelsohn.rst rename to doc/OnlineDocs/explanation/analysis/incidence/dulmage_mendelsohn.rst index 6fe2bd59324..dfcd3ea1a33 100644 --- a/doc/OnlineDocs/contributed_packages/incidence/dulmage_mendelsohn.rst +++ b/doc/OnlineDocs/explanation/analysis/incidence/dulmage_mendelsohn.rst @@ -2,4 +2,5 @@ Dulmage-Mendelsohn Partition ============================ .. automodule:: pyomo.contrib.incidence_analysis.dulmage_mendelsohn + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/incidence/incidence.rst b/doc/OnlineDocs/explanation/analysis/incidence/incidence.rst similarity index 89% rename from doc/OnlineDocs/contributed_packages/incidence/incidence.rst rename to doc/OnlineDocs/explanation/analysis/incidence/incidence.rst index ebf481c00a7..d8bbab089ba 100644 --- a/doc/OnlineDocs/contributed_packages/incidence/incidence.rst +++ b/doc/OnlineDocs/explanation/analysis/incidence/incidence.rst @@ -2,4 +2,5 @@ Incident Variables ================== .. automodule:: pyomo.contrib.incidence_analysis.incidence + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/incidence/index.rst b/doc/OnlineDocs/explanation/analysis/incidence/index.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/incidence/index.rst rename to doc/OnlineDocs/explanation/analysis/incidence/index.rst diff --git a/doc/OnlineDocs/contributed_packages/incidence/interface.rst b/doc/OnlineDocs/explanation/analysis/incidence/interface.rst similarity index 89% rename from doc/OnlineDocs/contributed_packages/incidence/interface.rst rename to doc/OnlineDocs/explanation/analysis/incidence/interface.rst index 29c92d8193c..1f6cd20bec3 100644 --- a/doc/OnlineDocs/contributed_packages/incidence/interface.rst +++ b/doc/OnlineDocs/explanation/analysis/incidence/interface.rst @@ -2,4 +2,5 @@ Pyomo Interfaces ================ .. automodule:: pyomo.contrib.incidence_analysis.interface + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/incidence/matching.rst b/doc/OnlineDocs/explanation/analysis/incidence/matching.rst similarity index 89% rename from doc/OnlineDocs/contributed_packages/incidence/matching.rst rename to doc/OnlineDocs/explanation/analysis/incidence/matching.rst index 1941c7116cd..83aeb06a7fa 100644 --- a/doc/OnlineDocs/contributed_packages/incidence/matching.rst +++ b/doc/OnlineDocs/explanation/analysis/incidence/matching.rst @@ -2,4 +2,5 @@ Maximum Matching ================ .. automodule:: pyomo.contrib.incidence_analysis.matching + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/incidence/overview.rst b/doc/OnlineDocs/explanation/analysis/incidence/overview.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/incidence/overview.rst rename to doc/OnlineDocs/explanation/analysis/incidence/overview.rst diff --git a/doc/OnlineDocs/contributed_packages/incidence/scc_solver.rst b/doc/OnlineDocs/explanation/analysis/incidence/scc_solver.rst similarity index 92% rename from doc/OnlineDocs/contributed_packages/incidence/scc_solver.rst rename to doc/OnlineDocs/explanation/analysis/incidence/scc_solver.rst index 35f494af1a1..5f20a96191d 100644 --- a/doc/OnlineDocs/contributed_packages/incidence/scc_solver.rst +++ b/doc/OnlineDocs/explanation/analysis/incidence/scc_solver.rst @@ -2,4 +2,5 @@ Block Triangular Decomposition Solver ===================================== .. automodule:: pyomo.contrib.incidence_analysis.scc_solver + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/incidence/triangularize.rst b/doc/OnlineDocs/explanation/analysis/incidence/triangularize.rst similarity index 90% rename from doc/OnlineDocs/contributed_packages/incidence/triangularize.rst rename to doc/OnlineDocs/explanation/analysis/incidence/triangularize.rst index a051086a859..e1e60a39677 100644 --- a/doc/OnlineDocs/contributed_packages/incidence/triangularize.rst +++ b/doc/OnlineDocs/explanation/analysis/incidence/triangularize.rst @@ -2,4 +2,5 @@ Block Triangularization ======================= .. automodule:: pyomo.contrib.incidence_analysis.triangularize + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/incidence/tutorial.bt.rst b/doc/OnlineDocs/explanation/analysis/incidence/tutorial.bt.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/incidence/tutorial.bt.rst rename to doc/OnlineDocs/explanation/analysis/incidence/tutorial.bt.rst diff --git a/doc/OnlineDocs/contributed_packages/incidence/tutorial.btsolve.rst b/doc/OnlineDocs/explanation/analysis/incidence/tutorial.btsolve.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/incidence/tutorial.btsolve.rst rename to doc/OnlineDocs/explanation/analysis/incidence/tutorial.btsolve.rst diff --git a/doc/OnlineDocs/contributed_packages/incidence/tutorial.dm.rst b/doc/OnlineDocs/explanation/analysis/incidence/tutorial.dm.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/incidence/tutorial.dm.rst rename to doc/OnlineDocs/explanation/analysis/incidence/tutorial.dm.rst diff --git a/doc/OnlineDocs/contributed_packages/incidence/tutorial.rst b/doc/OnlineDocs/explanation/analysis/incidence/tutorial.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/incidence/tutorial.rst rename to doc/OnlineDocs/explanation/analysis/incidence/tutorial.rst diff --git a/doc/OnlineDocs/explanation/analysis/index.rst b/doc/OnlineDocs/explanation/analysis/index.rst new file mode 100644 index 00000000000..0a8e3c3b416 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/index.rst @@ -0,0 +1,20 @@ +Analysis in Pyomo +================= + +.. toctree:: + :maxdepth: 2 + + alternative_solutions + community + doe/doe + iis + incidence/index + mpc/index + parmest/index + sensitivity_toolbox + +.. + Reorganization notes: + + Analysis in Pyomo + `FBBT` diff --git a/doc/OnlineDocs/contributed_packages/mpc/api.rst b/doc/OnlineDocs/explanation/analysis/mpc/api.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/mpc/api.rst rename to doc/OnlineDocs/explanation/analysis/mpc/api.rst diff --git a/doc/OnlineDocs/contributed_packages/mpc/conversion.rst b/doc/OnlineDocs/explanation/analysis/mpc/conversion.rst similarity index 87% rename from doc/OnlineDocs/contributed_packages/mpc/conversion.rst rename to doc/OnlineDocs/explanation/analysis/mpc/conversion.rst index 9d9406edb75..e78a1d69e0b 100644 --- a/doc/OnlineDocs/contributed_packages/mpc/conversion.rst +++ b/doc/OnlineDocs/explanation/analysis/mpc/conversion.rst @@ -2,4 +2,5 @@ Data Conversion =============== .. automodule:: pyomo.contrib.mpc.data.convert + :noindex: :members: diff --git a/doc/OnlineDocs/explanation/analysis/mpc/data.rst b/doc/OnlineDocs/explanation/analysis/mpc/data.rst new file mode 100644 index 00000000000..da65bf40814 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/mpc/data.rst @@ -0,0 +1,22 @@ +Data Structures +=============== + +.. automodule:: pyomo.contrib.mpc.data.get_cuid + :members: + :noindex: + + automodule:: pyomo.contrib.mpc.data.dynamic_data_base + :members: + :noindex: + + automodule:: pyomo.contrib.mpc.data.scalar_data + :members: + :noindex: + + automodule:: pyomo.contrib.mpc.data.series_data + :members: + :noindex: + + automodule:: pyomo.contrib.mpc.data.interval_data + :members: + :noindex: diff --git a/doc/OnlineDocs/contributed_packages/mpc/examples.rst b/doc/OnlineDocs/explanation/analysis/mpc/examples.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/mpc/examples.rst rename to doc/OnlineDocs/explanation/analysis/mpc/examples.rst diff --git a/doc/OnlineDocs/contributed_packages/mpc/faq.rst b/doc/OnlineDocs/explanation/analysis/mpc/faq.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/mpc/faq.rst rename to doc/OnlineDocs/explanation/analysis/mpc/faq.rst diff --git a/doc/OnlineDocs/contributed_packages/mpc/index.rst b/doc/OnlineDocs/explanation/analysis/mpc/index.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/mpc/index.rst rename to doc/OnlineDocs/explanation/analysis/mpc/index.rst diff --git a/doc/OnlineDocs/contributed_packages/mpc/interface.rst b/doc/OnlineDocs/explanation/analysis/mpc/interface.rst similarity index 86% rename from doc/OnlineDocs/contributed_packages/mpc/interface.rst rename to doc/OnlineDocs/explanation/analysis/mpc/interface.rst index eb5bac548fd..13a5bf24360 100644 --- a/doc/OnlineDocs/contributed_packages/mpc/interface.rst +++ b/doc/OnlineDocs/explanation/analysis/mpc/interface.rst @@ -2,7 +2,9 @@ Interfaces ========== .. automodule:: pyomo.contrib.mpc.interfaces.model_interface + :noindex: :members: .. automodule:: pyomo.contrib.mpc.interfaces.var_linker + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/mpc/modeling.rst b/doc/OnlineDocs/explanation/analysis/mpc/modeling.rst similarity index 86% rename from doc/OnlineDocs/contributed_packages/mpc/modeling.rst rename to doc/OnlineDocs/explanation/analysis/mpc/modeling.rst index cbae03161b1..2bc213f1702 100644 --- a/doc/OnlineDocs/contributed_packages/mpc/modeling.rst +++ b/doc/OnlineDocs/explanation/analysis/mpc/modeling.rst @@ -2,10 +2,13 @@ Modeling Components =================== .. automodule:: pyomo.contrib.mpc.modeling.constraints + :noindex: :members: .. automodule:: pyomo.contrib.mpc.modeling.cost_expressions + :noindex: :members: .. automodule:: pyomo.contrib.mpc.modeling.terminal + :noindex: :members: diff --git a/doc/OnlineDocs/contributed_packages/mpc/overview.rst b/doc/OnlineDocs/explanation/analysis/mpc/overview.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/mpc/overview.rst rename to doc/OnlineDocs/explanation/analysis/mpc/overview.rst diff --git a/doc/OnlineDocs/contributed_packages/parmest/api.rst b/doc/OnlineDocs/explanation/analysis/parmest/api.rst similarity index 91% rename from doc/OnlineDocs/contributed_packages/parmest/api.rst rename to doc/OnlineDocs/explanation/analysis/parmest/api.rst index 4d6896a8582..a1456361260 100644 --- a/doc/OnlineDocs/contributed_packages/parmest/api.rst +++ b/doc/OnlineDocs/explanation/analysis/parmest/api.rst @@ -6,6 +6,7 @@ API parmest --------- .. automodule:: pyomo.contrib.parmest.parmest + :noindex: :members: :undoc-members: :show-inheritance: @@ -13,6 +14,7 @@ parmest scenariocreator ------------------ .. automodule:: pyomo.contrib.parmest.scenariocreator + :noindex: :members: :undoc-members: :show-inheritance: @@ -20,6 +22,7 @@ scenariocreator graphics --------- .. automodule:: pyomo.contrib.parmest.graphics + :noindex: :members: :undoc-members: :show-inheritance: diff --git a/doc/OnlineDocs/contributed_packages/parmest/boxplot.png b/doc/OnlineDocs/explanation/analysis/parmest/boxplot.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/parmest/boxplot.png rename to doc/OnlineDocs/explanation/analysis/parmest/boxplot.png diff --git a/doc/OnlineDocs/contributed_packages/parmest/covariance.rst b/doc/OnlineDocs/explanation/analysis/parmest/covariance.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/parmest/covariance.rst rename to doc/OnlineDocs/explanation/analysis/parmest/covariance.rst diff --git a/doc/OnlineDocs/explanation/analysis/parmest/datarec.rst b/doc/OnlineDocs/explanation/analysis/parmest/datarec.rst new file mode 100644 index 00000000000..3c6e12196f7 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/parmest/datarec.rst @@ -0,0 +1,54 @@ +.. _datarecsection: + +Data Reconciliation +==================== + +The optional argument ``return_values`` in :class:`~pyomo.contrib.parmest.parmest.Estimator.theta_est` +can be used for data reconciliation or to return model values based on the specified objective. + +For data reconciliation, the ``m.unknown_parameters`` is empty +and the objective function is defined to minimize +measurement to model error. Note that the model used for data +reconciliation may differ from the model used for parameter estimation. + +The functions +:class:`~pyomo.contrib.parmest.graphics.grouped_boxplot` or +:class:`~pyomo.contrib.parmest.graphics.grouped_violinplot` can be used +to visually compare the original and reconciled data. + +The following example from the reactor design subdirectory returns reconciled values for experiment outputs +(`ca`, `cb`, `cc`, and `cd`) and then uses those values in +parameter estimation (`k1`, `k2`, and `k3`). + +.. literalinclude:: /../../pyomo/contrib/parmest/examples/reactor_design/datarec_example.py + :language: python + +The following example returns model values from a Pyomo Expression. + +.. doctest:: + :skipif: not ipopt_available or not parmest_available + + >>> import pandas as pd + >>> import pyomo.contrib.parmest.parmest as parmest + >>> from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import RooneyBieglerExperiment + + >>> # Generate data + >>> data = pd.DataFrame(data=[[1,8.3],[2,10.3],[3,19.0], + ... [4,16.0],[5,15.6],[7,19.8]], + ... columns=['hour', 'y']) + + >>> # Create an experiment list + >>> exp_list = [] + >>> for i in range(data.shape[0]): + ... exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + >>> # Define objective + >>> def SSE(model): + ... expr = (model.experiment_outputs[model.y] + ... - model.response_function[model.experiment_outputs[model.hour]] + ... ) ** 2 + ... return expr + + >>> pest = parmest.Estimator(exp_list, obj_function=SSE, solver_options=None) + >>> obj, theta, var_values = pest.theta_est(return_values=['response_function']) + >>> #print(var_values) diff --git a/doc/OnlineDocs/contributed_packages/parmest/driver.rst b/doc/OnlineDocs/explanation/analysis/parmest/driver.rst similarity index 53% rename from doc/OnlineDocs/contributed_packages/parmest/driver.rst rename to doc/OnlineDocs/explanation/analysis/parmest/driver.rst index 28238928b83..b3f212008ca 100644 --- a/doc/OnlineDocs/contributed_packages/parmest/driver.rst +++ b/doc/OnlineDocs/explanation/analysis/parmest/driver.rst @@ -4,9 +4,9 @@ Parameter Estimation ================================== Parameter Estimation using parmest requires a Pyomo model, experimental -data which defines multiple scenarios, and a list of parameter names -(thetas) to estimate. parmest uses Pyomo [PyomoBookII]_ and (optionally) -mpi-sppy [mpisppy]_ to solve a +data which defines multiple scenarios, and parameters +(thetas) to estimate. parmest uses Pyomo [PyomoBookIII]_ and (optionally) +mpi-sppy [KMM+23]_ to solve a two-stage stochastic programming problem, where the experimental data is used to create a scenario tree. The objective function needs to be written with the Pyomo Expression for first stage cost @@ -36,13 +36,12 @@ which includes the following methods: ~pyomo.contrib.parmest.parmest.Estimator.likelihood_ratio_test ~pyomo.contrib.parmest.parmest.Estimator.leaveNout_bootstrap_test -Additional functions are available in parmest to group data, plot -results, and fit distributions to theta values. +Additional functions are available in parmest to plot +results and fit distributions to theta values. .. autosummary:: :nosignatures: - ~pyomo.contrib.parmest.parmest.group_data ~pyomo.contrib.parmest.graphics.pairwise_plot ~pyomo.contrib.parmest.graphics.grouped_boxplot ~pyomo.contrib.parmest.graphics.grouped_violinplot @@ -58,21 +57,33 @@ Section. .. testsetup:: * :skipif: not __import__('pyomo.contrib.parmest.parmest').contrib.parmest.parmest.parmest_available + # Data import pandas as pd - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import rooney_biegler_model as model_function - data = pd.DataFrame(data=[[1,8.3],[2,10.3],[3,19.0], - [4,16.0],[5,15.6],[6,19.8]], - columns=['hour', 'y']) - theta_names = ['asymptote', 'rate_constant'] - def objective_function(model, data): - expr = sum((data.y[i] - model.response_function[data.hour[i]])**2 for i in data.index) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], + [4, 16.0], [5, 15.6], [7, 19.8]], + columns=['hour', 'y'], + ) + + # Sum of squared error function + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 return expr + # Create an experiment list + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import RooneyBieglerExperiment + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + .. doctest:: :skipif: not __import__('pyomo.contrib.parmest.parmest').contrib.parmest.parmest.parmest_available >>> import pyomo.contrib.parmest.parmest as parmest - >>> pest = parmest.Estimator(model_function, data, theta_names, objective_function) + >>> pest = parmest.Estimator(exp_list, obj_function=SSE) Optionally, solver options can be supplied, e.g., @@ -80,66 +91,44 @@ Optionally, solver options can be supplied, e.g., :skipif: not __import__('pyomo.contrib.parmest.parmest').contrib.parmest.parmest.parmest_available >>> solver_options = {"max_iter": 6000} - >>> pest = parmest.Estimator(model_function, data, theta_names, objective_function, solver_options) - - - -Model function --------------- - -The first argument is a function which uses data for a single scenario -to return a populated and initialized Pyomo model for that scenario. - -Parameters that the user would like to estimate can be defined as -**mutable parameters (Pyomo `Param`) or variables (Pyomo `Var`)**. -Within parmest, any parameters that are to be estimated are converted to unfixed variables. -Variables that are to be estimated are also unfixed. - -The model does not have to be specifically written as a -two-stage stochastic programming problem for parmest. -That is, parmest can modify the -objective, see :ref:`ObjFunction` below. - -Data ----- - -The second argument is the data which will be used to populate the Pyomo -model. Supported data formats include: - -* **Pandas Dataframe** where each row is a separate scenario and column - names refer to observed quantities. Pandas DataFrames are easily - stored and read in from csv, excel, or databases, or created directly - in Python. -* **List of Pandas Dataframe** where each entry in the list is a separate scenario. - Dataframes store observed quantities, referenced by index and column. -* **List of dictionaries** where each entry in the list is a separate - scenario and the keys (or nested keys) refer to observed quantities. - Dictionaries are often preferred over DataFrames when using static and - time series data. Dictionaries are easily stored and read in from - json or yaml files, or created directly in Python. -* **List of json file names** where each entry in the list contains a - json file name for a separate scenario. This format is recommended - when using large datasets in parallel computing. - -The data must be compatible with the model function that returns a -populated and initialized Pyomo model for a single scenario. Data can -include multiple entries per variable (time series and/or duplicate -sensors). This information can be included in custom objective -functions, see :ref:`ObjFunction` below. - -Theta names ------------ - -The third argument is a list of parameters or variable names that the user wants to -estimate. The list contains strings with `Param` and/or `Var` names from the Pyomo -model. + >>> pest = parmest.Estimator(exp_list, obj_function=SSE, solver_options=solver_options) + + +List of experiment objects +-------------------------- + +The first argument is a list of experiment objects which is used to +create one labeled model for each expeirment. +The template :class:`~pyomo.contrib.parmest.experiment.Experiment` +can be used to generate a list of experiment objects. + +A labeled Pyomo model ``m`` has the following additional suffixes (Pyomo `Suffix`): + +* ``m.experiment_outputs`` which defines experiment output (Pyomo `Param`, `Var`, or `Expression`) + and their associated data values (float, int). +* ``m.unknown_parameters`` which defines the mutable parameters or variables (Pyomo `Param` or `Var`) + to estimate along with their component unique identifier (Pyomo `ComponentUID`). + Within parmest, any parameters that are to be estimated are converted to unfixed variables. + Variables that are to be estimated are also unfixed. + +The experiment class has one required method: + +* :class:`~pyomo.contrib.parmest.experiment.Experiment.get_labeled_model` which returns the labeled Pyomo model. + Note that the model does not have to be specifically written as a + two-stage stochastic programming problem for parmest. + That is, parmest can modify the + objective, see :ref:`ObjFunction` below. + +Parmest comes with several :ref:`examplesection` that illustrates how to set up the list of experiment objects. +The examples commonly include additional :class:`~pyomo.contrib.parmest.experiment.Experiment` class methods to +create the model, finalize the model, and label the model. The user can customize methods to suit their needs. .. _ObjFunction: Objective function ------------------ -The fourth argument is an optional argument which defines the +The second argument is an optional argument which defines the optimization objective function to use in parameter estimation. If no objective function is specified, the Pyomo model is used "as is" and @@ -150,20 +139,27 @@ stochastic programming problem. If the Pyomo model is not written as a two-stage stochastic programming problem in this format, and/or if the user wants to use an objective that is different than the original model, a custom objective function can be -defined for parameter estimation. The objective function arguments -include `model` and `data` and the objective function returns a Pyomo +defined for parameter estimation. The objective function has a single argument, +which is the model from a single experiment. +The objective function returns a Pyomo expression which is used to define "SecondStageCost". The objective function can be used to customize data points and weights that are used in parameter estimation. +Parmest includes one built in objective function to compute the sum of squared errors ("SSE") between the +``m.experiment_outputs`` model values and data values. + Suggested initialization procedure for parameter estimation problems -------------------------------------------------------------------- To check the quality of initial guess values provided for the fitted parameters, we suggest solving a square instance of the problem prior to solving the parameter estimation problem using the following steps: -1. Create :class:`~pyomo.contrib.parmest.parmest.Estimator` object. To initialize the parameter estimation solve from the square problem solution, set optional argument ``solver_options = {bound_push: 1e-8}``. +1. Create :class:`~pyomo.contrib.parmest.parmest.Estimator` object. To initialize the parameter +estimation solve from the square problem solution, set optional argument ``solver_options = {bound_push: 1e-8}``. -2. Call :class:`~pyomo.contrib.parmest.parmest.Estimator.objective_at_theta` with optional argument ``(initialize_parmest_model=True)``. Different initial guess values for the fitted parameters can be provided using optional argument `theta_values` (**Pandas Dataframe**) +2. Call :class:`~pyomo.contrib.parmest.parmest.Estimator.objective_at_theta` with optional +argument ``(initialize_parmest_model=True)``. Different initial guess values for the fitted +parameters can be provided using optional argument `theta_values` (**Pandas Dataframe**) 3. Solve parameter estimation problem by calling :class:`~pyomo.contrib.parmest.parmest.Estimator.theta_est` diff --git a/doc/OnlineDocs/contributed_packages/parmest/examples.rst b/doc/OnlineDocs/explanation/analysis/parmest/examples.rst similarity index 76% rename from doc/OnlineDocs/contributed_packages/parmest/examples.rst rename to doc/OnlineDocs/explanation/analysis/parmest/examples.rst index 793ff3d0c8d..275e2177503 100644 --- a/doc/OnlineDocs/contributed_packages/parmest/examples.rst +++ b/doc/OnlineDocs/explanation/analysis/parmest/examples.rst @@ -5,9 +5,9 @@ Examples Examples can be found in `pyomo/contrib/parmest/examples` and include: -* Reactor design example [PyomoBookII]_ -* Semibatch example [SemiBatch]_ -* Rooney Biegler example [RooneyBiegler]_ +* Reactor design example [PyomoBookIII]_ +* Semibatch example [AM00]_ +* Rooney Biegler example [RB01]_ Each example includes a Python file that contains the Pyomo model and a Python file to run parameter estimation. @@ -20,13 +20,13 @@ Additional use cases include: * Parameter estimation using mpi4py, the example saves results to a file for later analysis/graphics (semibatch example) -The description below uses the reactor design example. The file +The example below uses the reactor design example. The file **reactor_design.py** includes a function which returns an populated instance of the Pyomo model. Note that the model is defined to maximize `cb` and that `k1`, `k2`, and `k3` are fixed. The _main_ program is included for easy testing of the model declaration. -.. literalinclude:: ../../../../pyomo/contrib/parmest/examples/reactor_design/reactor_design.py +.. literalinclude:: /../../pyomo/contrib/parmest/examples/reactor_design/reactor_design.py :language: python The file **parameter_estimation_example.py** uses parmest to estimate values of `k1`, @@ -35,7 +35,7 @@ observed values of `ca`, `cb`, `cc`, and `cd`. Additional example files use parmest to run parameter estimation with bootstrap resampling and perform a likelihood ratio test over a range of theta values. -.. literalinclude:: ../../../../pyomo/contrib/parmest/examples/reactor_design/parameter_estimation_example.py +.. literalinclude:: /../../pyomo/contrib/parmest/examples/reactor_design/parameter_estimation_example.py :language: python The semibatch and Rooney Biegler examples are defined in a similar diff --git a/doc/OnlineDocs/contributed_packages/parmest/graphics.rst b/doc/OnlineDocs/explanation/analysis/parmest/graphics.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/parmest/graphics.rst rename to doc/OnlineDocs/explanation/analysis/parmest/graphics.rst diff --git a/doc/OnlineDocs/contributed_packages/parmest/index.rst b/doc/OnlineDocs/explanation/analysis/parmest/index.rst similarity index 64% rename from doc/OnlineDocs/contributed_packages/parmest/index.rst rename to doc/OnlineDocs/explanation/analysis/parmest/index.rst index 2bf4942e632..4616a2134d4 100644 --- a/doc/OnlineDocs/contributed_packages/parmest/index.rst +++ b/doc/OnlineDocs/explanation/analysis/parmest/index.rst @@ -1,14 +1,16 @@ -Parameter Estimation with ``parmest`` -===================================== +.. _parmest: + +Parameter Estimation +==================== ``parmest`` is a Python package built on the Pyomo optimization modeling -language ([PyomoJournal]_, [PyomoBookII]_) to support parameter estimation using experimental data along with +language ([Pyomo-paper]_, [PyomoBookIII]_) to support parameter estimation using experimental data along with confidence regions and subsequent creation of scenarios for stochastic programming. Citation for parmest ^^^^^^^^^^^^^^^^^^^^ -If you use parmest, please cite [ParmestPaper]_ +If you use parmest, please cite [Parmest-paper]_ Index of parmest documentation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -26,10 +28,3 @@ Index of parmest documentation examples.rst parallel.rst api.rst - -Indices and Tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/doc/OnlineDocs/contributed_packages/parmest/installation.rst b/doc/OnlineDocs/explanation/analysis/parmest/installation.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/parmest/installation.rst rename to doc/OnlineDocs/explanation/analysis/parmest/installation.rst diff --git a/doc/OnlineDocs/contributed_packages/parmest/overview.rst b/doc/OnlineDocs/explanation/analysis/parmest/overview.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/parmest/overview.rst rename to doc/OnlineDocs/explanation/analysis/parmest/overview.rst diff --git a/doc/OnlineDocs/contributed_packages/parmest/pairwise_plot_CI.png b/doc/OnlineDocs/explanation/analysis/parmest/pairwise_plot_CI.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/parmest/pairwise_plot_CI.png rename to doc/OnlineDocs/explanation/analysis/parmest/pairwise_plot_CI.png diff --git a/doc/OnlineDocs/contributed_packages/parmest/pairwise_plot_LR.png b/doc/OnlineDocs/explanation/analysis/parmest/pairwise_plot_LR.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/parmest/pairwise_plot_LR.png rename to doc/OnlineDocs/explanation/analysis/parmest/pairwise_plot_LR.png diff --git a/doc/OnlineDocs/contributed_packages/parmest/parallel.rst b/doc/OnlineDocs/explanation/analysis/parmest/parallel.rst similarity index 93% rename from doc/OnlineDocs/contributed_packages/parmest/parallel.rst rename to doc/OnlineDocs/explanation/analysis/parmest/parallel.rst index 3b60c5777de..e1d6548b105 100644 --- a/doc/OnlineDocs/contributed_packages/parmest/parallel.rst +++ b/doc/OnlineDocs/explanation/analysis/parmest/parallel.rst @@ -16,7 +16,7 @@ model in parallel:: The file **parallel_example.py** is shown below. Results are saved to file for later analysis. -.. literalinclude:: ../../../../pyomo/contrib/parmest/examples/semibatch/parallel_example.py +.. literalinclude:: /../../pyomo/contrib/parmest/examples/semibatch/parallel_example.py :language: python Installation diff --git a/doc/OnlineDocs/contributed_packages/parmest/scencreate.rst b/doc/OnlineDocs/explanation/analysis/parmest/scencreate.rst similarity index 82% rename from doc/OnlineDocs/contributed_packages/parmest/scencreate.rst rename to doc/OnlineDocs/explanation/analysis/parmest/scencreate.rst index 66d41d4c606..79dfc31fbbb 100644 --- a/doc/OnlineDocs/contributed_packages/parmest/scencreate.rst +++ b/doc/OnlineDocs/explanation/analysis/parmest/scencreate.rst @@ -14,9 +14,9 @@ correspond one-to-one with the experiments used as input data. It also creates a few scenarios using the bootstrap methods and outputs prints the scenarios to the screen, accessing them via the ``ScensItator`` a ``print`` -.. literalinclude:: ../../../../pyomo/contrib/parmest/examples/semibatch/scenario_example.py +.. literalinclude:: /../../pyomo/contrib/parmest/examples/semibatch/scenario_example.py :language: python .. note:: - This example may produce an error message your version of Ipopt is not based + This example may produce an error message if your version of Ipopt is not based on a good linear solver. diff --git a/doc/OnlineDocs/contributed_packages/sensitivity_toolbox.rst b/doc/OnlineDocs/explanation/analysis/sensitivity_toolbox.rst similarity index 78% rename from doc/OnlineDocs/contributed_packages/sensitivity_toolbox.rst rename to doc/OnlineDocs/explanation/analysis/sensitivity_toolbox.rst index 2a2ccff4b09..f0069565ff6 100644 --- a/doc/OnlineDocs/contributed_packages/sensitivity_toolbox.rst +++ b/doc/OnlineDocs/explanation/analysis/sensitivity_toolbox.rst @@ -1,7 +1,7 @@ Sensitivity Toolbox =================== -The sensitivity toolbox provides a Pyomo interface to sIPOPT and k_aug to very quickly compute approximate solutions to nonlinear programs with a small perturbation in model parameters. +The sensitivity toolbox provides a Pyomo interface to sIPOPT and k_aug to very quickly compute approximate solutions to nonlinear programs with a small perturbation in model parameters. See the `sIPOPT documentation `_ or the `following paper `_ for additional details: @@ -17,37 +17,39 @@ Using the Sensitivity Toolbox We will start with a motivating example: .. math:: - \begin{align*} - \min_{x_1,x_2,x_3} \quad & x_1^2 + x_2^2 + x_3^2 \\ - \mathrm{s.t.} \qquad & 6 x_1 + 3 x_2 + 2 x_3 - p_1 = 0 \\ - & p_2 x_1 + x_2 - x_3 - 1 = 0 \\ - & x_1, x_2, x_3 \geq 0 - \end{align*} + :nowrap: + + \[\begin{array}{ll} + \min_{x_1,x_2,x_3} & x_1^2 + x_2^2 + x_3^2 \\ + \mathrm{s.t.} & 6 x_1 + 3 x_2 + 2 x_3 - p_1 = 0 \\ + & p_2 x_1 + x_2 - x_3 - 1 = 0 \\ + & x_1, x_2, x_3 \geq 0 + \end{array}\] Here :math:`x_1`, :math:`x_2`, and :math:`x_3` are the decision variables while :math:`p_1` and :math:`p_2` are parameters. At first, let's consider :math:`p_1 = 4.5` and :math:`p_2 = 1.0`. Below is the model implemented in Pyomo. .. doctest:: python # Import Pyomo and the sensitivity toolbox - >>> from pyomo.environ import * + >>> import pyomo.environ as pyo >>> from pyomo.contrib.sensitivity_toolbox.sens import sensitivity_calculation # Create a concrete model - >>> m = ConcreteModel() + >>> m = pyo.ConcreteModel() # Define the variables with bounds and initial values - >>> m.x1 = Var(initialize = 0.15, within=NonNegativeReals) - >>> m.x2 = Var(initialize = 0.15, within=NonNegativeReals) - >>> m.x3 = Var(initialize = 0.0, within=NonNegativeReals) + >>> m.x1 = pyo.Var(initialize = 0.15, within=pyo.NonNegativeReals) + >>> m.x2 = pyo.Var(initialize = 0.15, within=pyo.NonNegativeReals) + >>> m.x3 = pyo.Var(initialize = 0.0, within=pyo.NonNegativeReals) # Define the parameters - >>> m.eta1 = Param(initialize=4.5,mutable=True) - >>> m.eta2 = Param(initialize=1.0,mutable=True) + >>> m.eta1 = pyo.Param(initialize=4.5,mutable=True) + >>> m.eta2 = pyo.Param(initialize=1.0,mutable=True) # Define the constraints and objective - >>> m.const1 = Constraint(expr=6*m.x1+3*m.x2+2*m.x3-m.eta1 ==0) - >>> m.const2 = Constraint(expr=m.eta2*m.x1+m.x2-m.x3-1 ==0) - >>> m.cost = Objective(expr=m.x1**2+m.x2**2+m.x3**2) + >>> m.const1 = pyo.Constraint(expr=6*m.x1+3*m.x2+2*m.x3-m.eta1 ==0) + >>> m.const2 = pyo.Constraint(expr=m.eta2*m.x1+m.x2-m.x3-1 ==0) + >>> m.cost = pyo.Objective(expr=m.x1**2+m.x2**2+m.x3**2) The solution of this optimization problem is :math:`x_1^* = 0.5`, :math:`x_2^* = 0.5`, and :math:`x_3^* = 0.0`. But what if we change the parameter values to :math:`\hat{p}_1 = 4.0` and :math:`\hat{p}_2 = 1.0`? Is there a quick way to approximate the new solution :math:`\hat{x}_1^*`, :math:`\hat{x}_2^*`, and :math:`\hat{x}_3^*`? Yes! This is the main functionality of sIPOPT and k_aug. @@ -56,8 +58,8 @@ Next we define the perturbed parameter values :math:`\hat{p}_1` and :math:`\hat{ .. doctest:: python - >>> m.perturbed_eta1 = Param(initialize = 4.0) - >>> m.perturbed_eta2 = Param(initialize = 1.0) + >>> m.perturbed_eta1 = pyo.Param(initialize = 4.0) + >>> m.perturbed_eta2 = pyo.Param(initialize = 1.0) And finally we call sIPOPT or k_aug: @@ -67,11 +69,23 @@ And finally we call sIPOPT or k_aug: >>> m_sipopt = sensitivity_calculation('sipopt', m, [m.eta1, m.eta2], [m.perturbed_eta1, m.perturbed_eta2], tee=False) >>> m_kaug_dsdp = sensitivity_calculation('k_aug', m, [m.eta1, m.eta2], [m.perturbed_eta1, m.perturbed_eta2], tee=False) -The first argument specifies the method, either 'sipopt' or 'k_aug'. The second argument is the Pyomo model. The third argument is a list of the original parameters. The fourth argument is a list of the perturbed parameters. It's important that these two lists are the same length and in the same order. +.. testcode:: python + :skipif: not sipopt_available or not k_aug_available or not dot_sens_available + :hide: + + # The x3 result can come back -0.000 depending on the platform or + # solver version; map it so that tests don't fail. + for _m in (m, m_sipopt, m_kaug_dsdp): + if f'{_m.x3():.3f}' == '-0.000': + _m.x3 = 0. + if f'{m_sipopt.sens_sol_state_1[m_sipopt.x3]:.3f}' == '-0.000': + m_sipopt.sens_sol_state_1[m_sipopt.x3] = 0. + +The first argument specifies the method, either 'sipopt' or 'k_aug'. The second argument is the Pyomo model. The third argument is a list of the original parameters. The fourth argument is a list of the perturbed parameters. It's important that these two lists are the same length and in the same order. First, we can inspect the initial point: -.. doctest:: python +.. doctest:: python :skipif: not sipopt_available or not k_aug_available or not dot_sens_available >>> print("eta1 = %0.3f" % m.eta1()) @@ -115,7 +129,7 @@ Note that k_aug does not save the solution with the original parameter values. F .. doctest:: python :skipif: not sipopt_available or not k_aug_available or not dot_sens_available - + # *sIPOPT* # New parameter values: >>> print("eta1 = %0.3f" %m_sipopt.perturbed_eta1()) @@ -138,7 +152,7 @@ Note that k_aug does not save the solution with the original parameter values. F x2 = 0.667 >>> print("x3 = %0.3f" % x3) - x3 = -0.000 + x3 = 0.000 # *k_aug* # New parameter values: @@ -162,7 +176,7 @@ Note that k_aug does not save the solution with the original parameter values. F x2 = 0.667 >>> print("x3 = %0.3f" % x3) - x3 = -0.000 + x3 = 0.000 Installing sIPOPT and k_aug @@ -175,7 +189,7 @@ The sensitivity toolbox requires either sIPOPT or k_aug to be installed and avai * https://coin-or.github.io/coinbrew/ * https://github.com/dthierry/k_aug -.. note:: +.. note:: If you get an error that ``ipopt_sens`` or ``k_aug`` and ``dot_sens`` cannot be found, double check your installation and make sure the build directories containing the executables were added to your system PATH. @@ -183,3 +197,4 @@ Sensitivity Toolbox Interface ----------------------------- .. autofunction:: pyomo.contrib.sensitivity_toolbox.sens.sensitivity_calculation + :noindex: diff --git a/doc/OnlineDocs/contributed_packages/index.rst b/doc/OnlineDocs/explanation/contrib_index.txt similarity index 97% rename from doc/OnlineDocs/contributed_packages/index.rst rename to doc/OnlineDocs/explanation/contrib_index.txt index b1d9cbbad3b..65c14a721df 100644 --- a/doc/OnlineDocs/contributed_packages/index.rst +++ b/doc/OnlineDocs/explanation/contrib_index.txt @@ -15,6 +15,7 @@ Contributed packages distributed with Pyomo: .. toctree:: :maxdepth: 1 + alternative_solutions.rst community.rst doe/doe.rst gdpopt.rst diff --git a/doc/OnlineDocs/developer_reference/config.rst b/doc/OnlineDocs/explanation/developer_utils/config.rst similarity index 100% rename from doc/OnlineDocs/developer_reference/config.rst rename to doc/OnlineDocs/explanation/developer_utils/config.rst diff --git a/doc/OnlineDocs/developer_reference/deprecation.rst b/doc/OnlineDocs/explanation/developer_utils/deprecation.rst similarity index 100% rename from doc/OnlineDocs/developer_reference/deprecation.rst rename to doc/OnlineDocs/explanation/developer_utils/deprecation.rst diff --git a/doc/OnlineDocs/explanation/developer_utils/index.rst b/doc/OnlineDocs/explanation/developer_utils/index.rst new file mode 100644 index 00000000000..7b61e9a3ec1 --- /dev/null +++ b/doc/OnlineDocs/explanation/developer_utils/index.rst @@ -0,0 +1,8 @@ +Developer Utilities +=================== + +.. toctree:: + :maxdepth: 2 + + config + deprecation diff --git a/doc/OnlineDocs/explanation/experimental/index.rst b/doc/OnlineDocs/explanation/experimental/index.rst new file mode 100644 index 00000000000..0fe881d5011 --- /dev/null +++ b/doc/OnlineDocs/explanation/experimental/index.rst @@ -0,0 +1,8 @@ +Experimental features +===================== + +.. toctree:: + :maxdepth: 2 + + kernel/index + solvers diff --git a/doc/OnlineDocs/library_reference/kernel/index.rst b/doc/OnlineDocs/explanation/experimental/kernel/index.rst similarity index 89% rename from doc/OnlineDocs/library_reference/kernel/index.rst rename to doc/OnlineDocs/explanation/experimental/kernel/index.rst index 70c3cc715a9..bf48e865d02 100644 --- a/doc/OnlineDocs/library_reference/kernel/index.rst +++ b/doc/OnlineDocs/explanation/experimental/kernel/index.rst @@ -21,7 +21,7 @@ The :python:`pyomo.kernel` library is an experimental modeling interface designe Models built from :python:`pyomo.kernel` components are fully compatible with the standard solver interfaces included with Pyomo. A minimal example script that defines and solves a model is shown below. -.. literalinclude:: examples/kernel_solving.py +.. literalinclude:: /src/kernel/examples/kernel_solving.py :language: python Notable Improvements @@ -32,7 +32,7 @@ More Control of Model Structure Containers in :python:`pyomo.kernel` are analogous to indexed components in :python:`pyomo.environ`. However, :python:`pyomo.kernel` containers allow for additional layers of structure as they can be nested within each other as long as they have compatible categories. The following example shows this using :python:`pyomo.kernel.variable` containers. -.. literalinclude:: examples/kernel_containers_all.spy +.. literalinclude:: /src/kernel/examples/kernel_containers_all.spy :language: python As the next section will show, the standard modeling component containers are also compatible with user-defined classes that derive from the existing modeling components. @@ -58,21 +58,21 @@ The next series of examples goes into more detail on how to implement derived co The following code block shows a class definition for a non-negative variable, starting from :python:`pyomo.kernel.variable` as a base class. -.. literalinclude:: examples/kernel_subclassing_Nonnegative.spy +.. literalinclude:: /src/kernel/examples/kernel_subclassing_Nonnegative.spy :language: python The :python:`NonNegativeVariable` class prevents negative values from being stored into its lower bound during initialization or later on through assignment statements (e.g, :python:`x.lb = -1` fails). Note that the :python:`__slots__ == ()` line at the beginning of the class definition is optional, but it is recommended if no additional data members are necessary as it reduces the memory requirement of the new variable type. The next code block defines a custom variable container called :python:`Point` that represents a 3-dimensional point in Cartesian space. The new type derives from the :python:`pyomo.kernel.variable_tuple` container and uses the :python:`NonNegativeVariable` type we defined previously in the `z` coordinate. -.. literalinclude:: examples/kernel_subclassing_Point.spy +.. literalinclude:: /src/kernel/examples/kernel_subclassing_Point.spy :language: python The :python:`Point` class can be treated like a tuple storing three variables, and it can be placed inside of other variable containers or added as attributes to blocks. The property methods included in the class definition provide an additional syntax for accessing the three variables it stores, as the next code example will show. The following code defines a class for building a convex second-order cone constraint from a :python:`Point` object. It derives from the :python:`pyomo.kernel.constraint` class, overriding the constructor to build the constraint expression and utilizing the property methods on the point class to increase readability. -.. literalinclude:: examples/kernel_subclassing_SOC.spy +.. literalinclude:: /src/kernel/examples/kernel_subclassing_SOC.spy :language: python @@ -81,12 +81,12 @@ Reduced Memory Usage The :python:`pyomo.kernel` library offers significant opportunities to reduce memory requirements for highly structured models. The situation where this is most apparent is when expressing a model in terms of many small blocks consisting of singleton components. As an example, consider expressing a model consisting of a large number of voltage transformers. One option for doing so might be to define a `Transformer` component as a subclass of :python:`pyomo.kernel.block`. The example below defines such a component, including some helper methods for connecting input and output voltage variables and updating the transformer ratio. -.. literalinclude:: examples/transformer_kernel.spy +.. literalinclude:: /src/kernel/examples/transformer_kernel.spy :language: python A simplified version of this using :python:`pyomo.environ` components might look like what is below. -.. literalinclude:: examples/transformer_aml.spy +.. literalinclude:: /src/kernel/examples/transformer_aml.spy :language: python The transformer expressed using :python:`pyomo.kernel` components requires roughly 2 KB of memory, whereas the :python:`pyomo.environ` version requires roughly 8.4 KB of memory (an increase of more than 4x). Additionally, the :python:`pyomo.kernel` transformer is fully compatible with all existing :python:`pyomo.kernel` block containers. @@ -142,7 +142,7 @@ instantiation. The first method is to directly instantiate a conic constraint object, providing all necessary input variables: -.. literalinclude:: examples/conic_Class.spy +.. literalinclude:: /src/kernel/examples/conic_Class.spy :language: python This method may be limiting if utilizing the Mosek solver as @@ -164,47 +164,5 @@ constraint, as well as auxiliary constraints that link the inputs (that are not :python:`None`) to the auxiliary variables. Example: -.. literalinclude:: examples/conic_Domain.spy +.. literalinclude:: /src/kernel/examples/conic_Domain.spy :language: python - -Reference ---------- - -.. _kernel_modeling_components: - -Modeling Components: -^^^^^^^^^^^^^^^^^^^^ - -.. toctree:: - :maxdepth: 1 - - block.rst - variable.rst - constraint.rst - parameter.rst - objective.rst - expression.rst - sos.rst - suffix.rst - piecewise/index.rst - conic.rst - -Base API: -^^^^^^^^^ - -.. toctree:: - :maxdepth: 1 - - base.rst - homogeneous_container.rst - heterogeneous_container.rst - -Containers: -^^^^^^^^^^^ - -.. toctree:: - :maxdepth: 1 - - tuple_container.rst - list_container.rst - dict_container.rst diff --git a/doc/OnlineDocs/explanation/experimental/kernel/syntax_comparison.rst b/doc/OnlineDocs/explanation/experimental/kernel/syntax_comparison.rst new file mode 100644 index 00000000000..c0d60ad86b1 --- /dev/null +++ b/doc/OnlineDocs/explanation/experimental/kernel/syntax_comparison.rst @@ -0,0 +1,133 @@ +.. _kernel_syntax_comparison: + +Syntax Comparison Table (pyomo.kernel vs pyomo.environ) +======================================================= + +.. list-table:: + :header-rows: 1 + :align: center + + * - + - **pyomo.kernel** + - **pyomo.environ** + + * - **Import** + - .. literalinclude:: /src/kernel/examples/kernel_example_Import_Syntax.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Import_Syntax.spy + :language: python + * - **Model** [#models_fn]_ + - .. literalinclude:: /src/kernel/examples/kernel_example_AbstractModels.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_ConcreteModels.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_AbstractModels.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_ConcreteModels.spy + :language: python + * - **Set** [#sets_fn]_ + - .. literalinclude:: /src/kernel/examples/kernel_example_Sets_1.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Sets_2.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Sets_1.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Sets_2.spy + :language: python + * - **Parameter** [#parameters_fn]_ + - .. literalinclude:: /src/kernel/examples/kernel_example_Parameters_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Parameters_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Parameters_list.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Parameters_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Parameters_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Parameters_list.spy + :language: python + * - **Variable** + - .. literalinclude:: /src/kernel/examples/kernel_example_Variables_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Variables_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Variables_list.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Variables_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Variables_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Variables_list.spy + :language: python + * - **Constraint** + - .. literalinclude:: /src/kernel/examples/kernel_example_Constraints_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Constraints_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Constraints_list.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Constraints_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Constraints_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Constraints_list.spy + :language: python + * - **Expression** + - .. literalinclude:: /src/kernel/examples/kernel_example_Expressions_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Expressions_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Expressions_list.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Expressions_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Expressions_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Expressions_list.spy + :language: python + * - **Objective** + - .. literalinclude:: /src/kernel/examples/kernel_example_Objectives_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Objectives_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Objectives_list.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Objectives_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Objectives_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Objectives_list.spy + :language: python + * - **SOS** [#sos_fn]_ + - .. literalinclude:: /src/kernel/examples/kernel_example_SOS_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_SOS_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_SOS_list.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_SOS_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_SOS_dict.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_SOS_list.spy + :language: python + * - **Suffix** + - .. literalinclude:: /src/kernel/examples/kernel_example_Suffix_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/kernel_example_Suffix_dict.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Suffix_single.spy + :language: python + .. literalinclude:: /src/kernel/examples/aml_example_Suffix_dict.spy + :language: python + * - **Piecewise** [#pw_fn]_ + - .. literalinclude:: /src/kernel/examples/kernel_example_Piecewise_1d.spy + :language: python + - .. literalinclude:: /src/kernel/examples/aml_example_Piecewise_1d.spy + :language: python +.. [#models_fn] :python:`pyomo.kernel` does not include an alternative to the :python:`AbstractModel` component from :python:`pyomo.environ`. All data necessary to build a model must be imported by the user. +.. [#sets_fn] :python:`pyomo.kernel` does not include an alternative to the Pyomo :python:`Set` component from :python:`pyomo.environ`. +.. [#parameters_fn] :python:`pyomo.kernel.parameter` objects are always mutable. +.. [#sos_fn] Special Ordered Sets +.. [#pw_fn] Both :python:`pyomo.kernel.piecewise` and :python:`pyomo.kernel.piecewise_nd` create objects that are sub-classes of :python:`pyomo.kernel.block`. Thus, these objects can be stored in containers such as :python:`pyomo.kernel.block_dict` and :python:`pyomo.kernel.block_list`. diff --git a/doc/OnlineDocs/explanation/experimental/solvers.rst b/doc/OnlineDocs/explanation/experimental/solvers.rst new file mode 100644 index 00000000000..478749351eb --- /dev/null +++ b/doc/OnlineDocs/explanation/experimental/solvers.rst @@ -0,0 +1,494 @@ +Future Solver Interface Changes +=============================== + +.. note:: + + The new solver interfaces are still under active development. They + are included in the releases as development previews. Please be + aware that APIs and functionality may change with no notice. + + We welcome any feedback and ideas as we develop this capability. + Please post feedback on + `Issue 1030 `_. + +Pyomo offers interfaces into multiple solvers, both commercial and open +source. To support better capabilities for solver interfaces, the Pyomo +team is actively redesigning the existing interfaces to make them more +maintainable and intuitive for use. A preview of the redesigned +interfaces can be found in ``pyomo.contrib.solver``. + +.. currentmodule:: pyomo.contrib.solver + + +New Interface Usage +------------------- + +The new interfaces are not completely backwards compatible with the +existing Pyomo solver interfaces. However, to aid in testing and +evaluation, we are distributing versions of the new solver interfaces +that are compatible with the existing ("legacy") solver interface. +These "legacy" interfaces are registered with the current +``SolverFactory`` using slightly different names (to avoid conflicts +with existing interfaces). + +.. |br| raw:: html + +
+ +.. list-table:: Available Redesigned Solvers and Names Registered + in the SolverFactories + :header-rows: 1 + + * - Solver + - Name registered in the |br| ``pyomo.contrib.solver.common.factory.SolverFactory`` + - Name registered in the |br| ``pyomo.opt.base.solvers.LegacySolverFactory`` + * - Ipopt + - ``ipopt`` + - ``ipopt_v2`` + * - Gurobi (persistent) + - ``gurobi_persistent`` + - ``gurobi_persistent_v2`` + * - Gurobi (direct) + - ``gurobi_direct`` + - ``gurobi_direct_v2`` + * - HiGHS + - ``highs`` + - ``highs`` + +Using the new interfaces through the legacy interface +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Here we use the new interface as exposed through the existing (legacy) +solver factory and solver interface wrapper. This provides an API that +is compatible with the existing (legacy) Pyomo solver interface and can +be used with other Pyomo tools / capabilities. + +.. testcode:: + :skipif: not ipopt_available + + import pyomo.environ as pyo + + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5) + model.y = pyo.Var(initialize=1.5) + + def rosenbrock(model): + return (1.0 - model.x) ** 2 + 100.0 * (model.y - model.x**2) ** 2 + + model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) + + status = pyo.SolverFactory('ipopt_v2').solve(model) + pyo.assert_optimal_termination(status) + model.pprint() + +.. testoutput:: + :skipif: not ipopt_available + :hide: + + 2 Var Declarations + ... + 3 Declarations: x y obj + +In keeping with our commitment to backwards compatibility, both the legacy and +future methods of specifying solver options are supported: + +.. testcode:: + :skipif: not ipopt_available + + import pyomo.environ as pyo + + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5) + model.y = pyo.Var(initialize=1.5) + + def rosenbrock(model): + return (1.0 - model.x) ** 2 + 100.0 * (model.y - model.x**2) ** 2 + + model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) + + # Backwards compatible + status = pyo.SolverFactory('ipopt_v2').solve(model, options={'max_iter' : 6}) + # Forwards compatible + status = pyo.SolverFactory('ipopt_v2').solve(model, solver_options={'max_iter' : 6}) + model.pprint() + +.. testoutput:: + :skipif: not ipopt_available + :hide: + + 2 Var Declarations + ... + 3 Declarations: x y obj + +Using the new interfaces directly +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Here we use the new interface by importing it directly: + +.. testcode:: + :skipif: not ipopt_available + + # Direct import + import pyomo.environ as pyo + from pyomo.contrib.solver.solvers.ipopt import Ipopt + + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5) + model.y = pyo.Var(initialize=1.5) + + def rosenbrock(model): + return (1.0 - model.x) ** 2 + 100.0 * (model.y - model.x**2) ** 2 + + model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) + + opt = Ipopt() + status = opt.solve(model) + pyo.assert_optimal_termination(status) + # Displays important results information; only available through the new interfaces + status.display() + model.pprint() + +.. testoutput:: + :skipif: not ipopt_available + :hide: + + termination_condition: ... + ... + 3 Declarations: x y obj + +Using the new interfaces through the "new" SolverFactory +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Here we use the new interface by retrieving it from the new ``SolverFactory``: + +.. testcode:: + :skipif: not ipopt_available + + # Import through new SolverFactory + import pyomo.environ as pyo + from pyomo.contrib.solver.common.factory import SolverFactory + + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5) + model.y = pyo.Var(initialize=1.5) + + def rosenbrock(model): + return (1.0 - model.x) ** 2 + 100.0 * (model.y - model.x**2) ** 2 + + model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) + + opt = SolverFactory('ipopt') + status = opt.solve(model) + pyo.assert_optimal_termination(status) + # Displays important results information; only available through the new interfaces + status.display() + model.pprint() + +.. testoutput:: + :skipif: not ipopt_available + :hide: + + termination_condition: ... + ... + 3 Declarations: x y obj + +Switching all of Pyomo to use the new interfaces +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We also provide a mechanism to get a "preview" of the future where we +replace the existing (legacy) SolverFactory and utilities with the new +(development) version (see :doc:`/reference/future`): + +.. testcode:: + :skipif: not ipopt_available + + # Change default SolverFactory version + import pyomo.environ as pyo + from pyomo.__future__ import solver_factory_v3 + + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5) + model.y = pyo.Var(initialize=1.5) + + def rosenbrock(model): + return (1.0 - model.x) ** 2 + 100.0 * (model.y - model.x**2) ** 2 + + model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) + + status = pyo.SolverFactory('ipopt').solve(model) + pyo.assert_optimal_termination(status) + # Displays important results information; only available through the new interfaces + status.display() + model.pprint() + +.. testoutput:: + :skipif: not ipopt_available + :hide: + + termination_condition: ... + ... + 3 Declarations: x y obj + +.. testcode:: + :skipif: not ipopt_available + :hide: + + from pyomo.__future__ import solver_factory_v1 + +Linear Presolve and Scaling +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The new interface allows access to new capabilities in the various +problem writers, including the linear presolve and scaling options +recently incorporated into the redesigned NL writer. For example, you +can control the NL writer in the new ``ipopt`` interface through the +solver's ``writer_config`` configuration option: + +.. autoclass:: pyomo.contrib.solver.solvers.ipopt.Ipopt + :noindex: + :members: solve + +.. testcode:: + + from pyomo.contrib.solver.solvers.ipopt import Ipopt + opt = Ipopt() + opt.config.writer_config.display() + +.. testoutput:: + + show_section_timing: false + skip_trivial_constraints: true + file_determinism: FileDeterminism.ORDERED + symbolic_solver_labels: false + scale_model: true + export_nonlinear_variables: None + row_order: None + column_order: None + export_defined_variables: true + linear_presolve: true + +Note that, by default, both ``linear_presolve`` and ``scale_model`` are enabled. +Users can manipulate ``linear_presolve`` and ``scale_model`` to their preferred +states by changing their values. + +.. code-block:: python + + >>> opt.config.writer_config.linear_presolve = False + + +Interface Implementation +------------------------ + +All new interfaces should be built upon one of two classes (currently): +:class:`SolverBase` or +:class:`PersistentSolverBase`. + +All solvers should have the following: + +.. autoclass:: pyomo.contrib.solver.common.base.SolverBase + :noindex: + :members: + +Persistent solvers include additional members as well as other configuration options: + +.. autoclass:: pyomo.contrib.solver.common.base.PersistentSolverBase + :noindex: + :show-inheritance: + :members: + +Results +------- + +Every solver, at the end of a +:meth:`solve` call, will +return a :class:`Results` +object. This object is a :py:class:`pyomo.common.config.ConfigDict`, +which can be manipulated similar to a standard ``dict`` in Python. + +.. autoclass:: pyomo.contrib.solver.common.results.Results + :noindex: + :show-inheritance: + :members: + :undoc-members: + +The new interface has condensed :py:class:`~pyomo.opt.results.solver.SolverStatus`, +:py:class:`~pyomo.opt.results.solver.TerminationCondition`, +and :py:class:`~pyomo.opt.results.solution.SolutionStatus` into +:py:class:`~pyomo.contrib.solver.common.results.TerminationCondition` +and :py:class:`~pyomo.contrib.solver.common.results.SolutionStatus` to +reduce complexity. As a result, several legacy +:py:class:`~pyomo.opt.results.solver.SolutionStatus` values are +no longer achievable. These are detailed in the table below. + +.. list-table:: Mapping from unachievable :py:class:`~pyomo.opt.results.solver.SolutionStatus` + to future statuses + :header-rows: 1 + + * - Legacy :py:class:`~pyomo.opt.results.solver.SolutionStatus` + - :py:class:`~pyomo.contrib.solver.common.results.TerminationCondition` + - :py:class:`~pyomo.contrib.solver.common.results.SolutionStatus` + * - other + - unknown + - noSolution + * - unsure + - unknown + - noSolution + * - locallyOptimal + - convergenceCriteriaSatisfied + - optimal + * - globallyOptimal + - convergenceCriteriaSatisfied + - optimal + * - bestSoFar + - convergenceCriteriaSatisfied + - feasible + +Termination Conditions +^^^^^^^^^^^^^^^^^^^^^^ + +Pyomo offers a standard set of termination conditions to map to solver +returns. The intent of +:class:`TerminationCondition` +is to notify the user of why the solver exited. The user is expected +to inspect the :class:`Results` +object or any returned solver messages or logs for more information. + +.. autoclass:: pyomo.contrib.solver.common.results.TerminationCondition + :noindex: + :show-inheritance: + + +Solution Status +^^^^^^^^^^^^^^^ + +Pyomo offers a standard set of solution statuses to map to solver +output. The intent of +:class:`SolutionStatus` +is to notify the user of what the solver returned at a high level. The +user is expected to inspect the +:class:`Results` object or any +returned solver messages or logs for more information. + +.. autoclass:: pyomo.contrib.solver.common.results.SolutionStatus + :noindex: + :show-inheritance: + + +Solution +-------- + +Solutions can be loaded back into a model using a ``SolutionLoader``. A specific +loader should be written for each unique case. Several have already been +implemented. For example, for ``ipopt``: + +.. autoclass:: pyomo.contrib.solver.solvers.ipopt.IpoptSolutionLoader + :noindex: + :members: + :show-inheritance: + :inherited-members: + + +Dual Sign Convention +-------------------- +For all future solver interfaces, Pyomo adopts the following sign convention. Given the problem + +.. math:: + + \begin{aligned} + \min\quad & f(x) \\ + \text{s.t.}\quad & c_i(x) = 0 \quad \forall i \in \mathcal{E} \\ + & g_i(x) \le 0 \quad \forall i \in \mathcal{U} \\ + & h_i(x) \ge 0 \quad \forall i \in \mathcal{L} + \end{aligned} + +We define the Lagrangian as + +.. math:: + + \begin{aligned} + L(x, \lambda, \nu, \delta) + &= f(x) + - \sum_{i \in \mathcal{E}} \lambda_i\,c_i(x) + - \sum_{i \in \mathcal{U}} \nu_i\,g_i(x) + - \sum_{i \in \mathcal{L}} \delta_i\,h_i(x) + \end{aligned} + +Then, the KKT conditions are [NW99]_ + +.. math:: + + \begin{aligned} + \nabla_x L(x, \lambda, \nu, \delta) &= 0 \\ + c(x) &= 0 \\ + g(x) &\le 0 \\ + h(x) &\ge 0 \\ + \nu &\le 0 \\ + \delta &\ge 0 \\ + \nu_i\,g_i(x) &= 0 \\ + \delta_i\,h_i(x) &= 0 + \end{aligned} + +Note that this sign convention is based on the ``(lower, body, upper)`` +representation of constraints rather than the expression provided by a +user. Users can specify constraints with variables on both the left- and +right-hand sides of equalities and inequalities. However, the +``(lower, body, upper)`` representation ensures that all variables +appear in the ``body``, matching the form of the problem above. + +For maximization problems of the form + +.. math:: + + \begin{aligned} + \max\quad & f(x) \\ + \text{s.t.}\quad & c_i(x) = 0 \quad \forall i \in \mathcal{E} \\ + & g_i(x) \le 0 \quad \forall i \in \mathcal{U} \\ + & h_i(x) \ge 0 \quad \forall i \in \mathcal{L} + \end{aligned} + +we define the Lagrangian to be the same as above: + +.. math:: + + \begin{aligned} + L(x, \lambda, \nu, \delta) + &= f(x) + - \sum_{i \in \mathcal{E}} \lambda_i\,c_i(x) + - \sum_{i \in \mathcal{U}} \nu_i\,g_i(x) + - \sum_{i \in \mathcal{L}} \delta_i\,h_i(x) + \end{aligned} + +As a result, the signs of the duals change. The KKT conditions are + +.. math:: + + \begin{aligned} + \nabla_x L(x, \lambda, \nu, \delta) &= 0 \\ + c(x) &= 0 \\ + g(x) &\le 0 \\ + h(x) &\ge 0 \\ + \nu &\ge 0 \\ + \delta &\le 0 \\ + \nu_i\,g_i(x) &= 0 \\ + \delta_i\,h_i(x) &= 0 + \end{aligned} + + +Pyomo also supports "range constraints" which are inequalities with both upper +and lower bounds, where the bounds are not equal. For example, + +.. math:: + + -1 \leq x + y \leq 1 + +These are handled very similarly to variable bounds in terms of dual sign +conventions. For these, at most one "side" of the inequality can be active +at a time. If neither side is active, then the dual will be zero. If the dual +is nonzero, then the dual corresponds to the side of the constraint that is +active. The dual for the other side will be implicitly zero. When accessing +duals, the keys are the constraints. As a result, there is only one key for +a range constraint, even though it is really two constraints. Therefore, the +dual for the inactive side will not be reported explicitly. Again, the sign +convention is based on the ``(lower, body, upper)`` representation of the +constraint. Therefore, the left side of this inequality belongs to +:math:`\mathcal{L}` and the right side belongs to :math:`\mathcal{U}`. diff --git a/doc/OnlineDocs/explanation/index.rst b/doc/OnlineDocs/explanation/index.rst new file mode 100644 index 00000000000..0121debcd32 --- /dev/null +++ b/doc/OnlineDocs/explanation/index.rst @@ -0,0 +1,55 @@ +Explanations +============ + +.. toctree:: + :maxdepth: 3 + + philosophy/index + modeling/index + solvers/index + analysis/index + modeling_utils/index + developer_utils/index + experimental/index + + + +.. + Reorganization notes: + + `Pyomo Philosophy` + `Concrete and Abstract Models` + `Component Hierarchy` + `Expression System` + `Transformations` + `Modeling in Pyomo` + `Math Programming` + `GDP` + `DAE` + `Network` + `Piecewise Linear` + `Constraint Programming` + `Units of Measure` + `Solvers` + `PyROS` + `MindtPy` + `Trust Region` + `Pynumero` + `Analysis in Pyomo` + `IIS` + `FBBT` + `Incidence Analysis` + `Parameter Estimation` + `Design of Experiments` + `MPC` + `AOS` + `Modeling Utilities` + `Latex Printer` + `FME` + `Model Viewer` + `Model Flattening` + `Developer Utilities` + `Configuration System` + `Deprecation System` + `Experimental` + `Kernel` diff --git a/doc/OnlineDocs/modeling_extensions/dae.rst b/doc/OnlineDocs/explanation/modeling/dae.rst similarity index 86% rename from doc/OnlineDocs/modeling_extensions/dae.rst rename to doc/OnlineDocs/explanation/modeling/dae.rst index 703e83f4f14..ac43ed35005 100644 --- a/doc/OnlineDocs/modeling_extensions/dae.rst +++ b/doc/OnlineDocs/explanation/modeling/dae.rst @@ -5,7 +5,8 @@ Dynamic Optimization with pyomo.DAE :scale: 35% :align: right -The pyomo.DAE modeling extension [PyomoDAE]_ allows users to incorporate systems of +The pyomo.DAE modeling extension [PyomoDAE-paper]_ allows users to +incorporate systems of differential algebraic equations (DAE)s in a Pyomo model. The modeling components in this extension are able to represent ordinary or partial differential equations. The differential equations do not have to be @@ -34,19 +35,19 @@ Pyomo.DAE introduces three new modeling components to Pyomo: As will be shown later, differential equations can be declared using using these new modeling components along with the standard Pyomo -:py:class:`Var ` and -:py:class:`Constraint ` components. +:py:class:`.Var` and +:py:class:`.Constraint` components. ContinuousSet ************* This component is used to define continuous bounded domains (for example 'spatial' or 'time' domains). It is similar to a Pyomo -:py:class:`Set ` component and can be used to index things +:py:class:`.Set` component and can be used to index things like variables and constraints. Any number of :py:class:`ContinuousSets ` can be used to index a component and components can be indexed by both -:py:class:`Sets ` and +:py:class:`Sets <.Set>` and :py:class:`ContinuousSets ` in arbitrary order. In the current implementation, models with @@ -58,7 +59,8 @@ bounds of the continuous domain. A user may also specify additional points in the domain to be used as finite element points in the discretization. .. autoclass:: pyomo.dae.ContinuousSet - :members: + :noindex: + :members: The following code snippet shows examples of declaring a :py:class:`ContinuousSet ` component on a @@ -67,10 +69,10 @@ concrete Pyomo model: .. doctest:: Required imports - >>> from pyomo.environ import * - >>> from pyomo.dae import * + >>> import pyomo.environ as pyo + >>> from pyomo.dae import ContinuousSet - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() Declaration by providing bounds >>> model.t = ContinuousSet(bounds=(0,5)) @@ -94,10 +96,10 @@ abstract Pyomo model using the example data file. .. doctest:: Required imports - >>> from pyomo.environ import * - >>> from pyomo.dae import * + >>> import pyomo.environ as pyo + >>> from pyomo.dae import ContinuousSet - >>> model = AbstractModel() + >>> model = pyo.AbstractModel() The ContinuousSet below will be initialized using the points in the data file when a model instance is created. @@ -110,9 +112,9 @@ abstract Pyomo model using the example data file. .. note:: Most valid ways to declare and initialize a - :py:class:`Set ` can be used to + :py:class:`.Set` can be used to declare and initialize a :py:class:`ContinuousSet`. - See the documentation for :py:class:`Set ` for additional + See the documentation for :py:class:`.Set` for additional options. .. warning:: @@ -127,7 +129,7 @@ abstract Pyomo model using the example data file. .. note:: :py:class:`ContinuousSet ` components are always ordered (sorted) therefore the ``first()`` and ``last()`` - :py:class:`Set ` methods can be used to access the lower + :py:class:`.Set` methods can be used to access the lower and upper boundaries of the :py:class:`ContinuousSet ` respectively @@ -135,7 +137,8 @@ DerivativeVar ************* .. autoclass:: pyomo.dae.DerivativeVar - :members: + :noindex: + :members: The code snippet below shows examples of declaring :py:class:`DerivativeVar ` components on a @@ -143,22 +146,22 @@ Pyomo model. In each case, the variable being differentiated is supplied as the only positional argument and the type of derivative is specified using the 'wrt' (or the more verbose 'withrespectto') keyword argument. Any keyword argument that is valid for a Pyomo -:py:class:`Var ` component may also be specified. +:py:class:`.Var` component may also be specified. .. doctest:: Required imports - >>> from pyomo.environ import * - >>> from pyomo.dae import * + >>> import pyomo.environ as pyo + >>> from pyomo.dae import ContinuousSet, DerivativeVar - >>> model = ConcreteModel() - >>> model.s = Set(initialize=['a','b']) + >>> model = pyo.ConcreteModel() + >>> model.s = pyo.Set(initialize=['a','b']) >>> model.t = ContinuousSet(bounds=(0,5)) >>> model.l = ContinuousSet(bounds=(-10,10)) - >>> model.x = Var(model.t) - >>> model.y = Var(model.s,model.t) - >>> model.z = Var(model.t,model.l) + >>> model.x = pyo.Var(model.t) + >>> model.y = pyo.Var(model.s,model.t) + >>> model.z = pyo.Var(model.t,model.l) Declare the first derivative of model.x with respect to model.t >>> model.dxdt = DerivativeVar(model.x, withrespectto=model.t) @@ -179,32 +182,32 @@ argument. Any keyword argument that is valid for a Pyomo The 'initialize' keyword argument will initialize the value of a derivative and is **not** the same as specifying an initial condition. Initial or boundary conditions should be specified using a - :py:class:`Constraint` or - :py:class:`ConstraintList` or - by fixing the value of a :py:class:`Var` at a boundary + :py:class:`.Constraint` or + :py:class:`.ConstraintList` or + by fixing the value of a :py:class:`.Var` at a boundary point. Declaring Differential Equations -------------------------------- A differential equations is declared as a standard Pyomo -:py:class:`Constraint` and is not required to have +:py:class:`.Constraint` and is not required to have any particular form. The following code snippet shows how one might declare an ordinary or partial differential equation. .. doctest:: Required imports - >>> from pyomo.environ import * - >>> from pyomo.dae import * + >>> import pyomo.environ as pyo + >>> from pyomo.dae import ContinuousSet, DerivativeVar, Integral - >>> model = ConcreteModel() - >>> model.s = Set(initialize=['a', 'b']) + >>> model = pyo.ConcreteModel() + >>> model.s = pyo.Set(initialize=['a', 'b']) >>> model.t = ContinuousSet(bounds=(0, 5)) >>> model.l = ContinuousSet(bounds=(-10, 10)) - >>> model.x = Var(model.s, model.t) - >>> model.y = Var(model.t, model.l) + >>> model.x = pyo.Var(model.s, model.t) + >>> model.y = pyo.Var(model.t, model.l) >>> model.dxdt = DerivativeVar(model.x, wrt=model.t) >>> model.dydt = DerivativeVar(model.y, wrt=model.t) >>> model.dydl2 = DerivativeVar(model.y, wrt=(model.l, model.l)) @@ -212,23 +215,23 @@ an ordinary or partial differential equation. An ordinary differential equation >>> def _ode_rule(m, s, t): ... if t == 0: - ... return Constraint.Skip + ... return pyo.Constraint.Skip ... return m.dxdt[s, t] == m.x[s, t]**2 - >>> model.ode = Constraint(model.s, model.t, rule=_ode_rule) + >>> model.ode = pyo.Constraint(model.s, model.t, rule=_ode_rule) A partial differential equation >>> def _pde_rule(m, t, l): ... if t == 0 or l == m.l.first() or l == m.l.last(): - ... return Constraint.Skip + ... return pyo.Constraint.Skip ... return m.dydt[t, l] == m.dydl2[t, l] - >>> model.pde = Constraint(model.t, model.l, rule=_pde_rule) + >>> model.pde = pyo.Constraint(model.t, model.l, rule=_pde_rule) -By default, a :py:class:`Constraint` declared over a +By default, a :py:class:`.Constraint` declared over a :py:class:`ContinuousSet` will be applied at every discretization point contained in the set. Often a modeler does not want to enforce a differential equation at one or both boundaries of a continuous domain. This may be addressed explicitly in the -:py:class:`Constraint` declaration using +:py:class:`.Constraint` declaration using ``Constraint.Skip`` as shown above. Alternatively, the desired constraints can be deactivated just before the model is sent to a solver as shown below. @@ -244,11 +247,11 @@ be deactivated just before the model is sent to a solver as shown below. >>> def _ode_rule(m, s, t): ... return m.dxdt[s, t] == m.x[s, t]**2 - >>> model.ode = Constraint(model.s, model.t, rule=_ode_rule) + >>> model.ode = pyo.Constraint(model.s, model.t, rule=_ode_rule) >>> def _pde_rule(m, t, l): ... return m.dydt[t, l] == m.dydl2[t, l] - >>> model.pde = Constraint(model.t, model.l, rule=_pde_rule) + >>> model.pde = pyo.Constraint(model.t, model.l, rule=_pde_rule) Declare other model components and apply a discretization transformation ... @@ -287,18 +290,19 @@ Declaring Integrals equations. .. autoclass:: pyomo.dae.Integral + :noindex: :members: Declaring an :py:class:`Integral` component is similar to -declaring an :py:class:`Expression` component. A +declaring an :py:class:`.Expression` component. A simple example is shown below: .. doctest:: - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() >>> model.time = ContinuousSet(bounds=(0,10)) - >>> model.X = Var(model.time) - >>> model.scale = Param(initialize=1E-3) + >>> model.X = pyo.Var(model.time) + >>> model.scale = pyo.Param(initialize=1E-3) >>> def _intX(m,t): ... return m.X[t] @@ -306,7 +310,7 @@ simple example is shown below: >>> def _obj(m): ... return m.scale*m.intX - >>> model.obj = Objective(rule=_obj) + >>> model.obj = pyo.Objective(rule=_obj) Notice that the positional arguments supplied to the :py:class:`Integral` declaration must include all indices @@ -322,7 +326,7 @@ is being evaluated over. This is done using the 'wrt' keyword argument. ensure consistency in the ordering and dimension of the indexing sets After an :py:class:`Integral` has been declared, it can be -used just like a Pyomo :py:class:`Expression` +used just like a Pyomo :py:class:`.Expression` component and can be included in constraints or the objective function as shown above. @@ -338,20 +342,20 @@ positional argument. This should become more clear with the following example showing a double integral over the :py:class:`ContinuousSet` components ``model.t1`` and ``model.t2``. In addition, the expression is also indexed by the -:py:class:`Set` ``model.s``. The mathematical representation +:py:class:`.Set` ``model.s``. The mathematical representation and implementation in Pyomo are shown below: .. math:: - \sum_{s} \int_{t_2} \int_{t_1} \! X(t_1, t_2, s) \, dt_1 \, dt_2 + \sum_{s} \int_{t_2} \int_{t_1} \! X(t_1, t_2, s) \, dt_1 \, dt_2 .. doctest:: - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() >>> model.t1 = ContinuousSet(bounds=(0, 10)) >>> model.t2 = ContinuousSet(bounds=(-1, 1)) - >>> model.s = Set(initialize=['A', 'B', 'C']) + >>> model.s = pyo.Set(initialize=['A', 'B', 'C']) - >>> model.X = Var(model.t1, model.t2, model.s) + >>> model.X = pyo.Var(model.t1, model.t2, model.s) >>> def _intX1(m, t1, t2, s): ... return m.X[t1, t2, s] @@ -364,7 +368,7 @@ and implementation in Pyomo are shown below: >>> def _obj(m): ... return sum(m.intX2[k] for k in m.s) - >>> model.obj = Objective(rule=_obj) + >>> model.obj = pyo.Objective(rule=_obj) Discretization Transformations ------------------------------ @@ -399,18 +403,20 @@ called Implicit or Backward Euler) has been implemented. The discretization equations for this method are shown below: .. math:: - \begin{array}{l} + :nowrap: + + \[\begin{array}{l} \mathrm{Given: } \\ \frac{dx}{dt} = f(t, x) , \quad x(t_0) = x_{0} \\ \text{discretize $t$ and $x$ such that } \\ x(t_0 + kh) = x_{k} \\ x_{k + 1} = x_{k} + h * f(t_{k + 1}, x_{k + 1}) \\ t_{k + 1} = t_{k} + h - \end{array} + \end{array}\] where :math:`h` is the step size between discretization points or the size of each finite element. These equations are generated automatically as -:py:class:`Constraints` when the backward +:py:class:`Constraints<.Constraint>` when the backward difference method is applied to a Pyomo model. There are several discretization options available to a @@ -454,25 +460,25 @@ method. The code also shows how to add a constraint to a discretized model. .. doctest:: :hide: - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() >>> model.time = ContinuousSet(bounds=(0, 10)) - >>> model.x1 = Var(model.time, bounds=(-10, 10)) + >>> model.x1 = pyo.Var(model.time, bounds=(-10, 10)) >>> model.dx1 = DerivativeVar(model.x1) .. doctest:: :skipif: not ipopt_available Discretize model using Backward Difference method - >>> discretizer = TransformationFactory('dae.finite_difference') + >>> discretizer = pyo.TransformationFactory('dae.finite_difference') >>> discretizer.apply_to(model,nfe=20,wrt=model.time,scheme='BACKWARD') Add another constraint to discretized model >>> def _sum_limit(m): ... return sum(m.x1[i] for i in m.time) <= 50 - >>> model.con_sum_limit = Constraint(rule=_sum_limit) + >>> model.con_sum_limit = pyo.Constraint(rule=_sum_limit) Solve discretized model - >>> solver = SolverFactory('ipopt') + >>> solver = pyo.SolverFactory('ipopt') >>> results = solver.solve(model) Collocation Transformation @@ -522,26 +528,26 @@ function to a discretized model. .. doctest:: :hide: - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() >>> model.time = ContinuousSet(bounds=(0, 10)) - >>> model.x = Var(model.time, bounds=(-10, 10)) + >>> model.x = pyo.Var(model.time, bounds=(-10, 10)) >>> model.dx = DerivativeVar(model.x) - >>> model.x_ref = Param(initialize=5) + >>> model.x_ref = pyo.Param(initialize=5) .. doctest:: :skipif: not ipopt_available Discretize model using Radau Collocation - >>> discretizer = TransformationFactory('dae.collocation') + >>> discretizer = pyo.TransformationFactory('dae.collocation') >>> discretizer.apply_to(model,nfe=20,ncp=6,scheme='LAGRANGE-RADAU') Add objective function after model has been discretized >>> def obj_rule(m): ... return sum((m.x[i]-m.x_ref)**2 for i in m.time) - >>> model.obj = Objective(rule=obj_rule) + >>> model.obj = pyo.Objective(rule=obj_rule) Solve discretized model - >>> solver = SolverFactory('ipopt') + >>> solver = pyo.SolverFactory('ipopt') >>> results = solver.solve(model) Restricting Optimal Control Profiles @@ -556,23 +562,24 @@ transformation to reduce the number of free collocation points within a finite element for a particular variable. .. autoclass:: pyomo.dae.plugins.colloc.Collocation_Discretization_Transformation - :members: reduce_collocation_points + :noindex: + :members: reduce_collocation_points An example of using this function is shown below: .. doctest:: :hide: - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() >>> model.time = ContinuousSet(bounds=(0, 10)) - >>> model.x = Var(model.time, bounds=(-10, 10)) + >>> model.x = pyo.Var(model.time, bounds=(-10, 10)) >>> model.dx = DerivativeVar(model.x) - >>> model.x_ref = Param(initialize=5) - >>> model.u = Var(model.time) + >>> model.x_ref = pyo.Param(initialize=5) + >>> model.u = pyo.Var(model.time) .. doctest:: - >>> discretizer = TransformationFactory('dae.collocation') + >>> discretizer = pyo.TransformationFactory('dae.collocation') >>> discretizer.apply_to(model, nfe=10, ncp=6) >>> model = discretizer.reduce_collocation_points(model, ... var=model.u, @@ -607,13 +614,13 @@ numerical method can be applied with different resolutions: .. doctest:: :hide: - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() >>> model.t1 = ContinuousSet(bounds=(0, 10)) >>> model.t2 = ContinuousSet(bounds=(-2, 2)) .. doctest:: - >>> discretizer = TransformationFactory('dae.finite_difference') + >>> discretizer = pyo.TransformationFactory('dae.finite_difference') >>> discretizer.apply_to(model,wrt=model.t1,nfe=10) >>> discretizer.apply_to(model,wrt=model.t2,nfe=100) @@ -626,13 +633,13 @@ difference method to another .. doctest:: :hide: - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() >>> model.t1 = ContinuousSet(bounds=(0, 10)) >>> model.t2 = ContinuousSet(bounds=(-2, 2)) .. doctest:: - >>> discretizer = TransformationFactory('dae.finite_difference') + >>> discretizer = pyo.TransformationFactory('dae.finite_difference') >>> discretizer.apply_to(model,wrt=model.t1,scheme='FORWARD') >>> discretizer.apply_to(model,wrt=model.t2,scheme='CENTRAL') @@ -642,15 +649,15 @@ discretizations. For example: .. doctest:: :hide: - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() >>> model.t1 = ContinuousSet(bounds=(0, 10)) >>> model.t2 = ContinuousSet(bounds=(-2, 2)) .. doctest:: - >>> disc_fe = TransformationFactory('dae.finite_difference') + >>> disc_fe = pyo.TransformationFactory('dae.finite_difference') >>> disc_fe.apply_to(model,wrt=model.t1,nfe=10) - >>> disc_col = TransformationFactory('dae.collocation') + >>> disc_col = pyo.TransformationFactory('dae.collocation') >>> disc_col.apply_to(model,wrt=model.t2,nfe=10,ncp=5) If the user would like to apply the same discretization to all @@ -722,7 +729,8 @@ packages. order to use this class. .. autoclass:: pyomo.dae.Simulator - :members: + :noindex: + :members: .. note:: Any keyword options supported by the integrator may be specified as @@ -738,7 +746,7 @@ supported by CasADi. A list of available integrators for each package is given below. Please refer to the `SciPy `_ and `CasADi -`_ documentation directly for the most up-to-date information about +`_ documentation directly for the most up-to-date information about these packages and for more information about the various integrators and options. @@ -766,24 +774,26 @@ Using the Simulator We now show how to use the Simulator to simulate the following system of ODEs: .. math:: - \begin{array}{l} + :nowrap: + + \[\begin{array}{l} \frac{d\theta}{dt} = \omega \\ \frac{d\omega}{dt} = -b*\omega -c*sin(\theta) - \end{array} + \end{array}\] We begin by formulating the model using pyomo.DAE .. doctest:: - >>> m = ConcreteModel() + >>> m = pyo.ConcreteModel() >>> m.t = ContinuousSet(bounds=(0.0, 10.0)) - >>> m.b = Param(initialize=0.25) - >>> m.c = Param(initialize=5.0) + >>> m.b = pyo.Param(initialize=0.25) + >>> m.c = pyo.Param(initialize=5.0) - >>> m.omega = Var(m.t) - >>> m.theta = Var(m.t) + >>> m.omega = pyo.Var(m.t) + >>> m.theta = pyo.Var(m.t) >>> m.domegadt = DerivativeVar(m.omega, wrt=m.t) >>> m.dthetadt = DerivativeVar(m.theta, wrt=m.t) @@ -793,12 +803,12 @@ We begin by formulating the model using pyomo.DAE >>> m.theta[0].fix(3.14 - 0.1) >>> def _diffeq1(m, t): - ... return m.domegadt[t] == -m.b * m.omega[t] - m.c * sin(m.theta[t]) - >>> m.diffeq1 = Constraint(m.t, rule=_diffeq1) + ... return m.domegadt[t] == -m.b * m.omega[t] - m.c * pyo.sin(m.theta[t]) + >>> m.diffeq1 = pyo.Constraint(m.t, rule=_diffeq1) >>> def _diffeq2(m, t): ... return m.dthetadt[t] == m.omega[t] - >>> m.diffeq2 = Constraint(m.t, rule=_diffeq2) + >>> m.diffeq2 = pyo.Constraint(m.t, rule=_diffeq2) Notice that the initial conditions are set by `fixing` the values of ``m.omega`` and ``m.theta`` at t=0 instead of being specified as extra @@ -856,16 +866,16 @@ example. .. doctest:: - >>> m = ConcreteModel() + >>> m = pyo.ConcreteModel() >>> m.t = ContinuousSet(bounds=(0.0, 20.0)) Time-varying inputs - >>> m.b = Var(m.t) - >>> m.c = Param(m.t, default=5.0) + >>> m.b = pyo.Var(m.t) + >>> m.c = pyo.Param(m.t, default=5.0) - >>> m.omega = Var(m.t) - >>> m.theta = Var(m.t) + >>> m.omega = pyo.Var(m.t) + >>> m.theta = pyo.Var(m.t) >>> m.domegadt = DerivativeVar(m.omega, wrt=m.t) >>> m.dthetadt = DerivativeVar(m.theta, wrt=m.t) @@ -876,19 +886,19 @@ example. >>> def _diffeq1(m, t): ... return m.domegadt[t] == -m.b[t] * m.omega[t] - \ - ... m.c[t] * sin(m.theta[t]) - >>> m.diffeq1 = Constraint(m.t, rule=_diffeq1) + ... m.c[t] * pyo.sin(m.theta[t]) + >>> m.diffeq1 = pyo.Constraint(m.t, rule=_diffeq1) >>> def _diffeq2(m, t): ... return m.dthetadt[t] == m.omega[t] - >>> m.diffeq2 = Constraint(m.t, rule=_diffeq2) + >>> m.diffeq2 = pyo.Constraint(m.t, rule=_diffeq2) Specifying the piecewise constant inputs >>> b_profile = {0: 0.25, 15: 0.025} >>> c_profile = {0: 5.0, 7: 50} Declaring a Pyomo Suffix to pass the time-varying inputs to the Simulator - >>> m.var_input = Suffix(direction=Suffix.LOCAL) + >>> m.var_input = pyo.Suffix(direction=pyo.Suffix.LOCAL) >>> m.var_input[m.b] = b_profile >>> m.var_input[m.c] = c_profile @@ -923,7 +933,7 @@ returned from the simulator. An example using this function is shown below ... varying_inputs=m.var_input) # doctest: +SKIP Discretize the model using Orthogonal Collocation - >>> discretizer = TransformationFactory('dae.collocation') + >>> discretizer = pyo.TransformationFactory('dae.collocation') >>> discretizer.apply_to(m, nfe=10, ncp=3) Initialize the discretized model using the simulator profiles diff --git a/doc/OnlineDocs/modeling_extensions/gdp/concepts.rst b/doc/OnlineDocs/explanation/modeling/gdp/concepts.rst similarity index 93% rename from doc/OnlineDocs/modeling_extensions/gdp/concepts.rst rename to doc/OnlineDocs/explanation/modeling/gdp/concepts.rst index 95629bc48fd..952be873767 100644 --- a/doc/OnlineDocs/modeling_extensions/gdp/concepts.rst +++ b/doc/OnlineDocs/explanation/modeling/gdp/concepts.rst @@ -11,21 +11,24 @@ Generalized Disjunctive Programming (GDP) provides a way to bridge high-level pr The GDP standard form from the :ref:`index page ` is repeated below. .. math:: + :nowrap: - \min\ obj = &\ f(x, z) \\ - \text{s.t.} \quad &\ Ax+Bz \leq d\\ - &\ g(x,z) \leq 0\\ - &\ \bigvee_{i\in D_k} \left[ + \[\begin{array}{ll} + \min & f(x, z) \\ + \mathrm{s.t.} \quad & Ax+Bz \leq d \\ + & g(x,z) \leq 0 \\ + & \bigvee_{i\in D_k} \left[ \begin{gathered} Y_{ik} \\ M_{ik} x + N_{ik} z \leq e_{ik} \\ - r_{ik}(x,z)\leq 0\\ + r_{ik}(x,z)\leq 0 \\ \end{gathered} - \right] \quad k \in K\\ - &\ \Omega(Y) = True \\ - &\ x \in X \subseteq \mathbb{R}^n\\ - &\ Y \in \{True, False\}^{p}\\ - &\ z \in Z \subseteq \mathbb{Z}^m + \right] \quad k \in K \\ + & \Omega(Y) = True \\ + & x \in X \subseteq \mathbb{R}^n \\ + & Y \in \{True, False\}^{p} \\ + & z \in Z \subseteq \mathbb{Z}^m + \end{array}\] Original support in Pyomo.GDP focused on the disjuncts and disjunctions, allowing the modelers to group relational expressions in disjuncts, with disjunctions describing logical-OR relationships between the groupings. As a result, we implemented the ``Disjunct`` and ``Disjunction`` objects before ``BooleanVar`` and the rest of the logical expression system. @@ -37,14 +40,16 @@ Disjuncts Disjuncts represent groupings of relational expressions (e.g. algebraic constraints) summarized by a Boolean indicator variable :math:`Y` through implication: .. math:: + :nowrap: + \[ \left. \begin{aligned} & Y_{ik} \Rightarrow & M_{ik} x + N_{ik} z &\leq e_{ik}\\ & Y_{ik} \Rightarrow & r_{ik}(x,z) &\leq 0 \end{aligned} \right.\qquad \forall i \in D_k, \forall k \in K - + \] Logically, this means that if :math:`Y_{ik} = True`, then the constraints :math:`M_{ik} x + N_{ik} z \leq e_{ik}` and :math:`r_{ik}(x,z) \leq 0` must be satisfied. However, if :math:`Y_{ik} = False`, then the corresponding constraints are ignored. @@ -59,7 +64,9 @@ Disjunctions describe a logical *OR* relationship between two or more Disjuncts. The simplest and most common case is a 2-term disjunction: .. math:: + :nowrap: + \[ \left[\begin{gathered} Y_1 \\ \exp(x_2) - 1 = x_1 \\ @@ -71,6 +78,7 @@ The simplest and most common case is a 2-term disjunction: x_1 = x_2 = 0 \end{gathered} \right] + \] The disjunction above describes the selection between two units in a process network. diff --git a/doc/OnlineDocs/explanation/modeling/gdp/index.rst b/doc/OnlineDocs/explanation/modeling/gdp/index.rst new file mode 100644 index 00000000000..ee69f99668e --- /dev/null +++ b/doc/OnlineDocs/explanation/modeling/gdp/index.rst @@ -0,0 +1,80 @@ +.. _gdp-main-page: + +*********************************** +Generalized Disjunctive Programming +*********************************** + +.. image:: /../logos/gdp/Pyomo-GDP-150.png + :scale: 35% + :align: right + :class: no-scaled-link + +The Pyomo.GDP modeling extension [PyomoGDP-proceedings]_ +[PyomoGDP-paper]_ provides support for Generalized Disjunctive +Programming (GDP) [RG94]_, an extension of Disjunctive Programming +[Bal85]_ from the operations research community to include nonlinear +relationships. The classic form for a GDP is given by: + +.. math:: + :nowrap: + + \[\begin{array}{ll} + \min & f(x, z) \\ + \mathrm{s.t.} \quad & Ax+Bz \leq d\\ + & g(x,z) \leq 0\\ + & \bigvee_{i\in D_k} \left[ + \begin{gathered} + Y_{ik} \\ + M_{ik} x + N_{ik} z \leq e_{ik} \\ + r_{ik}(x,z)\leq 0\\ + \end{gathered} + \right] \quad k \in K\\ + & \Omega(Y) = True \\ + & x \in X \subseteq \mathbb{R}^n\\ + & Y \in \{True, False\}^{p}\\ + & z \in Z \subseteq \mathbb{Z}^m + \end{array}\] + +Here, we have the minimization of an objective :math:`f(x, z)` subject to global linear constraints :math:`Ax+Bz \leq d` and nonlinear constraints :math:`g(x,z) \leq 0`, with conditional linear constraints :math:`M_{ik} x + N_{ik} z \leq e_{ik}` and nonlinear constraints :math:`r_{ik}(x,z)\leq 0`. +These conditional constraints are collected into disjuncts :math:`D_k`, organized into disjunctions :math:`K`. Finally, there are logical propositions :math:`\Omega(Y) = True`. +Decision/state variables can be continuous :math:`x`, Boolean :math:`Y`, and/or integer :math:`z`. + +GDP is useful to model discrete decisions that have implications on the +system behavior [GT13]_. For example, in process design, a +disjunction may model the choice between processes A and B. If A is +selected, then its associated equations and inequalities will apply; +otherwise, if B is selected, then its respective constraints should be +enforced. + +Modelers often ask to model if-then-else relationships. +These can be expressed as a disjunction as follows: + +.. math:: + :nowrap: + + \begin{gather*} + \left[\begin{gathered} + Y_1 \\ + \text{constraints} \\ + \text{for }\textit{then} + \end{gathered}\right] + \vee + \left[\begin{gathered} + Y_2 \\ + \text{constraints} \\ + \text{for }\textit{else} + \end{gathered}\right] \\ + Y_1 \veebar Y_2 + \end{gather*} + +Here, if the Boolean :math:`Y_1` is ``True``, then the constraints in the first disjunct are enforced; otherwise, the constraints in the second disjunct are enforced. +The following sections describe the key concepts, modeling, and solution approaches available for Generalized Disjunctive Programming. + +.. toctree:: + :caption: Pyomo.GDP Contents + :maxdepth: 2 + + concepts + modeling + solving + diff --git a/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst b/doc/OnlineDocs/explanation/modeling/gdp/modeling.rst similarity index 90% rename from doc/OnlineDocs/modeling_extensions/gdp/modeling.rst rename to doc/OnlineDocs/explanation/modeling/gdp/modeling.rst index 996ebcb0366..6abb456c93a 100644 --- a/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst +++ b/doc/OnlineDocs/explanation/modeling/gdp/modeling.rst @@ -17,11 +17,6 @@ Modeling in Pyomo.GDP from pyomo.gdp import Disjunct, Disjunction from pyomo.core.plugins.transform.logical_to_linear import update_boolean_vars_from_binary - # This is to make unicode comparison work in python 2.7. - import sys - if sys.version[0] == '2': - reload(sys) - sys.setdefaultencoding("utf-8") Disjunctions ============ @@ -220,13 +215,36 @@ logical propositions. Here, we demonstrate this capability with a toy example: .. math:: - - \min~&x\\ - \text{s.t.}~&\left[\begin{gathered}Y_1\\x \geq 2\end{gathered}\right] \vee \left[\begin{gathered}Y_2\\x \geq 3\end{gathered}\right]\\ - &\left[\begin{gathered}Y_3\\x \leq 8\end{gathered}\right] \vee \left[\begin{gathered}Y_4\\x = 2.5\end{gathered}\right] \\ - &Y_1 \veebar Y_2\\ - &Y_3 \veebar Y_4\\ - &Y_1 \Rightarrow Y_4 + :nowrap: + + \[\begin{array}{ll} + \min & x \\ + \text{s.t.} & \left[ + \begin{gathered} + Y_1\\ + x \geq 2 + \end{gathered} + \right] \vee \left[ + \begin{gathered} + Y_2 \\ + x \geq 3 + \end{gathered} + \right]\\ + & \left[ + \begin{gathered} + Y_3 \\ + x \leq 8 + \end{gathered} + \right] \vee \left[ + \begin{gathered} + Y_4 \\ + x = 2.5 + \end{gathered} + \right] \\ + & Y_1 \veebar Y_2 \\ + & Y_3 \veebar Y_4 \\ + & Y_1 \Rightarrow Y_4 + \end{array}\] .. doctest:: :skipif: not glpk_available @@ -302,9 +320,9 @@ Expressions within CP-type operators Here, augmented variables may be automatically added to the model as follows: .. math:: - \text{atleast}(3, &Y_1, Y_A, Y_B, Y_6)\\ - &Y_A \Leftrightarrow Y_2 \vee Y_3\\ - &Y_B \Leftrightarrow (Y_4 \Rightarrow Y_5) + \text{atleast}(3, Y_1, Y_A, Y_B, Y_6)\\ + Y_A \Leftrightarrow Y_2 \vee Y_3\\ + Y_B \Leftrightarrow (Y_4 \Rightarrow Y_5) .. code:: @@ -366,22 +384,22 @@ The following models all work and are equivalent for :math:`\left[x = 0\right] \ Option 1: Rule-based construction - >>> from pyomo.environ import * - >>> from pyomo.gdp import * - >>> model = ConcreteModel() + >>> import pyomo.environ as pyo + >>> from pyomo.gdp import Disjunct, Disjunction + >>> model = pyo.ConcreteModel() - >>> model.x = Var() - >>> model.y = Var() + >>> model.x = pyo.Var() + >>> model.y = pyo.Var() >>> # Two conditions >>> def _d(disjunct, flag): ... model = disjunct.model() ... if flag: ... # x == 0 - ... disjunct.c = Constraint(expr=model.x == 0) + ... disjunct.c = pyo.Constraint(expr=model.x == 0) ... else: ... # y == 0 - ... disjunct.c = Constraint(expr=model.y == 0) + ... disjunct.c = pyo.Constraint(expr=model.y == 0) >>> model.d = Disjunct([0,1], rule=_d) >>> # Define the disjunction @@ -391,29 +409,29 @@ The following models all work and are equivalent for :math:`\left[x = 0\right] \ Option 2: Explicit disjuncts - >>> from pyomo.environ import * - >>> from pyomo.gdp import * - >>> model = ConcreteModel() + >>> import pyomo.environ as pyo + >>> from pyomo.gdp import Disjunct, Disjunction + >>> model = pyo.ConcreteModel() - >>> model.x = Var() - >>> model.y = Var() + >>> model.x = pyo.Var() + >>> model.y = pyo.Var() >>> model.fix_x = Disjunct() - >>> model.fix_x.c = Constraint(expr=model.x == 0) + >>> model.fix_x.c = pyo.Constraint(expr=model.x == 0) >>> model.fix_y = Disjunct() - >>> model.fix_y.c = Constraint(expr=model.y == 0) + >>> model.fix_y.c = pyo.Constraint(expr=model.y == 0) >>> model.c = Disjunction(expr=[model.fix_x, model.fix_y]) Option 3: Implicit disjuncts (disjunction rule returns a list of expressions or a list of lists of expressions) - >>> from pyomo.environ import * - >>> from pyomo.gdp import * - >>> model = ConcreteModel() + >>> import pyomo.environ as pyo + >>> from pyomo.gdp import Disjunction + >>> model = pyo.ConcreteModel() - >>> model.x = Var() - >>> model.y = Var() + >>> model.x = pyo.Var() + >>> model.y = pyo.Var() >>> model.c = Disjunction(expr=[model.x == 0, model.y == 0]) diff --git a/doc/OnlineDocs/modeling_extensions/gdp/solving.rst b/doc/OnlineDocs/explanation/modeling/gdp/solving.rst similarity index 69% rename from doc/OnlineDocs/modeling_extensions/gdp/solving.rst rename to doc/OnlineDocs/explanation/modeling/gdp/solving.rst index 9fea90ebf5f..eb8ecf38eda 100644 --- a/doc/OnlineDocs/modeling_extensions/gdp/solving.rst +++ b/doc/OnlineDocs/explanation/modeling/gdp/solving.rst @@ -70,6 +70,7 @@ also be created, as described in :ref:`gdp-advanced-examples`. Following solution of the GDP model, values of the Boolean variables may be updated from their algebraic binary counterparts using the ``update_boolean_vars_from_binary()`` function. .. autofunction:: pyomo.core.plugins.transform.logical_to_linear.update_boolean_vars_from_binary + :noindex: Factorable Programming ^^^^^^^^^^^^^^^^^^^^^^ @@ -105,11 +106,16 @@ doing so are the (included) Big-M and Hull reformulations. Big-M (BM) Reformulation ^^^^^^^^^^^^^^^^^^^^^^^^ -The Big-M reformulation\ [#gdp-bm]_ results in a smaller transformed model, avoiding the need to add extra variables; however, it yields a looser continuous relaxation. -By default, the BM transformation will estimate reasonably tight M values for you if variables are bounded. -For nonlinear models where finite expression bounds may be inferred from variable bounds, the BM transformation may also be able to automatically compute M values for you. -For all other models, you will need to provide the M values through a "BigM" Suffix, or through the `bigM` argument to the transformation. -We will raise a ``GDP_Error`` for missing M values. +The Big-M reformulation\ [NW88]_ results in a smaller transformed +model, avoiding the need to add extra variables; however, it yields a +looser continuous relaxation. By default, the BM transformation will +estimate reasonably tight M values for you if variables are bounded. +For nonlinear models where finite expression bounds may be inferred from +variable bounds, the BM transformation may also be able to automatically +compute M values for you. For all other models, you will need to +provide the M values through a "BigM" Suffix, or through the `bigM` +argument to the transformation. We will raise a ``GDP_Error`` for +missing M values. To apply the BM reformulation within a python script, use: @@ -122,9 +128,12 @@ From the Pyomo command line, include the ``--transform pyomo.gdp.bigm`` option. Multiple Big-M (MBM) Reformulation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -We also implement the multiple-parameter Big-M (MBM) approach described in literature\ [#gdp-mbm]_. -By default, the MBM transformation will solve continuous subproblems in order to calculate M values. -This process can be time-consuming, so the transformation also provides a method to export the M values used as a dictionary and allows for M values to be provided through the `bigM` argument. +We also implement the multiple-parameter Big-M (MBM) approach described +in literature\ [TG15]_. By default, the MBM transformation will solve +continuous subproblems in order to calculate M values. This process can +be time-consuming, so the transformation also provides a method to +export the M values used as a dictionary and allows for M values to be +provided through the `bigM` argument. For example, to apply the transformation and store the M values, use: @@ -168,9 +177,12 @@ From the Pyomo command line, include the ``--transform pyomo.gdp.hull`` option. Hybrid BM/HR Reformulation ^^^^^^^^^^^^^^^^^^^^^^^^^^ -An experimental (for now) implementation of the cutting plane approach described in literature\ [#gdp-cuttingplanes]_ is provided for linear GDP models. -The transformation augments the BM reformulation by a set of cutting planes generated from the HR model by solving separation problems. -This gives a model that is not as large as the HR, but with a stronger continuous relaxation than the BM. +An experimental (for now) implementation of the cutting plane approach +described in literature\ [SG03]_ is provided for linear GDP models. +The transformation augments the BM reformulation by a set of cutting +planes generated from the HR model by solving separation problems. This +gives a model that is not as large as the HR, but with a stronger +continuous relaxation than the BM. This transformation is accessible via: @@ -185,17 +197,4 @@ Pyomo includes the contributed GDPopt solver, which can directly solve GDP models. Its usage is described within the :ref:`contributed packages documentation `. -References -========== -.. [#gdp-pse-paper] Chen, Q., Johnson, E. S., Siirola, J. D., & Grossmann, I. E. (2018). Pyomo.GDP: Disjunctive Models in Python. In M. R. Eden, M. G. Ierapetritou, & G. P. Towler (Eds.), *Proceedings of the 13th International Symposium on Process Systems Engineering* (pp. 889–894). San Diego: Elsevier B.V. https://doi.org/10.1016/B978-0-444-64241-7.50143-9 - -.. [#gdp-main-paper] Chen, Q., Johnson, E. S., Bernal, D. E., Valentin, R., Kale, S., Bates, J., Siirola, J. D. and Grossmann, I. E. (2021). Pyomo.GDP: an ecosystem for logic based modeling and optimization development, *Optimization and Engineering* (pp. 1-36).https://doi.org/10.1007/s11081-021-09601-7 - -.. [#gdp-review-2013] Grossmann, I. E., & Trespalacios, F. (2013). Systematic modeling of discrete-continuous optimization models through generalized disjunctive programming. *AIChE Journal*, 59(9), 3276–3295. https://doi.org/10.1002/aic.14088 - -.. [#gdp-mbm] Trespalacios, F., & Grossmann, I. E. (2015). Improved Big-M reformulation for generalized disjunctive programs. *Computers and Chemical Engineering*, 76, 98–103. https://doi.org/10.1016/j.compchemeng.2015.02.013 - -.. [#gdp-bm] Nemhauser, G. L., & Wolsey, L. A. (1988). *Integer and combinatorial optimization*. New York: Wiley. - -.. [#gdp-cuttingplanes] Sawaya, N. W., & Grossmann, I. E. (2003). A cutting plane method for solving linear generalized disjunctive programming problems. *Computer Aided Chemical Engineering*, 15(C), 1032–1037. https://doi.org/10.1016/S1570-7946(03)80444-3 diff --git a/doc/OnlineDocs/explanation/modeling/index.rst b/doc/OnlineDocs/explanation/modeling/index.rst new file mode 100644 index 00000000000..f23f5421322 --- /dev/null +++ b/doc/OnlineDocs/explanation/modeling/index.rst @@ -0,0 +1,25 @@ +Modeling in Pyomo +================= + +.. toctree:: + :maxdepth: 1 + + math_programming/index + dae + gdp/index + mpec + network + units + + +.. + Reorganization notes: + + `Modeling in Pyomo` + `Math Programming` + `GDP` + `DAE` + `Network` + `Piecewise Linear` + `Constraint Programming` + `Units of Measure` diff --git a/doc/OnlineDocs/pyomo_modeling_components/Constraints.rst b/doc/OnlineDocs/explanation/modeling/math_programming/constraints.rst similarity index 84% rename from doc/OnlineDocs/pyomo_modeling_components/Constraints.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/constraints.rst index 0cc42cb2abe..fdda70fa28d 100644 --- a/doc/OnlineDocs/pyomo_modeling_components/Constraints.rst +++ b/doc/OnlineDocs/explanation/modeling/math_programming/constraints.rst @@ -6,7 +6,7 @@ that are created using a rule, which is a Python function. For example, if the variable ``model.x`` has the indexes 'butter' and 'scones', then this constraint limits the sum over these indexes to be exactly three: -.. literalinclude:: ../src/scripting/spy4Constraints_Constraint_example.spy +.. literalinclude:: /src/scripting/spy4Constraints_Constraint_example.spy :language: python Instead of expressions involving equality (==) or inequalities (`<=` or @@ -16,7 +16,7 @@ lb `<=` expr `<=` ub. Variables can appear only in the middle expr. For example, the following two constraint declarations have the same meaning: -.. literalinclude:: ../src/scripting/spy4Constraints_Inequality_constraints_2expressions.spy +.. literalinclude:: /src/scripting/spy4Constraints_Inequality_constraints_2expressions.spy :language: python For this simple example, it would also be possible to declare @@ -30,7 +30,7 @@ interpreted as placing a budget of :math:`i` on the :math:`i^{\mbox{th}}` item to buy where the cost per item is given by the parameter ``model.a``: -.. literalinclude:: ../src/scripting/spy4Constraints_Passing_elements_crossproduct.spy +.. literalinclude:: /src/scripting/spy4Constraints_Passing_elements_crossproduct.spy :language: python .. note:: diff --git a/doc/OnlineDocs/pyomo_modeling_components/Expressions.rst b/doc/OnlineDocs/explanation/modeling/math_programming/expressions.rst similarity index 90% rename from doc/OnlineDocs/pyomo_modeling_components/Expressions.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/expressions.rst index 16c206e2fe8..f272607718d 100644 --- a/doc/OnlineDocs/pyomo_modeling_components/Expressions.rst +++ b/doc/OnlineDocs/explanation/modeling/math_programming/expressions.rst @@ -20,7 +20,7 @@ possible to build up expressions. The following example illustrates this, along with a reference to global Python data in the form of a Python variable called ``switch``: -.. literalinclude:: ../src/scripting/spy4Expressions_Buildup_expression_switch.spy +.. literalinclude:: /src/scripting/spy4Expressions_Buildup_expression_switch.spy :language: python In this example, the constraint that is generated depends on the value @@ -33,7 +33,7 @@ otherwise, the ``model.d`` term is not present. Because model elements result in expressions, not values, the following does not work as expected in an abstract model! - .. literalinclude:: ../src/scripting/spy4Expressions_Abstract_wrong_usage.spy + .. literalinclude:: /src/scripting/spy4Expressions_Abstract_wrong_usage.spy :language: python The trouble is that ``model.d >= 2`` results in an expression, not @@ -54,11 +54,11 @@ Pyomo has facilities to add piecewise constraints of the form y=f(x) for a variety of forms of the function f. The piecewise types other than SOS2, BIGM_SOS1, BIGM_BIN are implement -as described in the paper [Vielma_et_al]_. +as described in the paper [VAN10]_. There are two basic forms for the declaration of the constraint: -.. literalinclude:: ../src/scripting/spy4Expressions_Declare_piecewise_constraints.spy +.. literalinclude:: /src/scripting/spy4Expressions_Declare_piecewise_constraints.spy :language: python where ``pwconst`` can be replaced by a name appropriate for the @@ -124,7 +124,7 @@ Keywords: indexing set is used or when all indices use an identical piecewise function). Examples: - .. literalinclude:: ../src/scripting/spy4Expressions_f_rule_Function_examples.spy + .. literalinclude:: /src/scripting/spy4Expressions_f_rule_Function_examples.spy :language: python * **force_pw=True/False** @@ -163,7 +163,7 @@ Keywords: Here is an example of an assignment to a Python dictionary variable that has keywords for a picewise constraint: -.. literalinclude:: ../src/scripting/spy4Expressions_Keyword_assignment_example.spy +.. literalinclude:: /src/scripting/spy4Expressions_Keyword_assignment_example.spy :language: python Here is a simple example based on the example given earlier in @@ -175,10 +175,10 @@ whimsically just to make the example. The important thing to note is that variables that are going to appear as the independent variable in a piecewise constraint must have bounds. -.. literalinclude:: ../src/scripting/abstract2piece.py +.. literalinclude:: /src/scripting/abstract2piece.py :language: python -A more advanced example is provided in abstract2piecebuild.py in +A more advanced example is provided in ``abstract2piecebuild.py`` in :ref:`BuildAction`. ``Expression`` Objects @@ -193,13 +193,13 @@ variable x times the index. Later in the model file, just to illustrate how to do it, the expression is changed but just for the first index to be x squared. -.. literalinclude:: ../src/scripting/spy4Expressions_Expression_objects_illustration.spy +.. literalinclude:: /src/scripting/spy4Expressions_Expression_objects_illustration.spy :language: python An alternative is to create Python functions that, potentially, manipulate model objects. E.g., if you define a function -.. literalinclude:: ../src/scripting/spy4Expressions_Define_python_function.spy +.. literalinclude:: /src/scripting/spy4Expressions_Define_python_function.spy :language: python You can call this function with or without Pyomo modeling components as @@ -211,7 +211,7 @@ expression is used to generate another expression (e.g., f(model.x, 3) + 5), the initial expression is always cloned so that the new generated expression is independent of the old. For example: -.. literalinclude:: ../src/scripting/spy4Expressions_Generate_new_expression.spy +.. literalinclude:: /src/scripting/spy4Expressions_Generate_new_expression.spy :language: python If you want to create an expression that is shared between other diff --git a/doc/OnlineDocs/explanation/modeling/math_programming/index.rst b/doc/OnlineDocs/explanation/modeling/math_programming/index.rst new file mode 100644 index 00000000000..bc4e7c1803d --- /dev/null +++ b/doc/OnlineDocs/explanation/modeling/math_programming/index.rst @@ -0,0 +1,14 @@ +Math Programming +================ + +.. toctree:: + :maxdepth: 1 + + sets + parameters + variables + objectives + constraints + expressions + sos_constraints + suffixes diff --git a/doc/OnlineDocs/pyomo_modeling_components/Objectives.rst b/doc/OnlineDocs/explanation/modeling/math_programming/objectives.rst similarity index 100% rename from doc/OnlineDocs/pyomo_modeling_components/Objectives.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/objectives.rst diff --git a/doc/OnlineDocs/pyomo_modeling_components/Parameters.rst b/doc/OnlineDocs/explanation/modeling/math_programming/parameters.rst similarity index 100% rename from doc/OnlineDocs/pyomo_modeling_components/Parameters.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/parameters.rst diff --git a/doc/OnlineDocs/pyomo_modeling_components/Sets.rst b/doc/OnlineDocs/explanation/modeling/math_programming/sets.rst similarity index 97% rename from doc/OnlineDocs/pyomo_modeling_components/Sets.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/sets.rst index 73c3539d79d..3689ff8cc43 100644 --- a/doc/OnlineDocs/pyomo_modeling_components/Sets.rst +++ b/doc/OnlineDocs/explanation/modeling/math_programming/sets.rst @@ -277,12 +277,12 @@ Consider the following simple version of minimum cost flow problem: .. math:: :nowrap: - \begin{array}{lll} - \mbox{minimize} & \sum_{a \in \mathcal{A}} c_{a}x_{a} \\ - \mbox{subject to:} & S_{n} + \sum_{(i,n) \in \mathcal{A}}x_{(i,n)} & \\ - & -D_{n} - \sum_{(n,j) \in \mathcal{A}}x_{(n,j)} & n \in \mathcal{N} \\ - & x_{a} \geq 0, & a \in \mathcal{A} - \end{array} + \[\begin{array}{lll} + \mbox{minimize} & \sum_{a \in \mathcal{A}} c_{a}x_{a} \\ + \mbox{subject to:} & S_{n} + \sum_{(i,n) \in \mathcal{A}}x_{(i,n)} & \\ + & -D_{n} - \sum_{(n,j) \in \mathcal{A}}x_{(n,j)} & n \in \mathcal{N} \\ + & x_{a} \geq 0, & a \in \mathcal{A} + \end{array}\] where @@ -443,7 +443,7 @@ model is: for this model, a toy data file (in AMPL "``.dat``" format) would be: -.. literalinclude:: ../src/scripting/Isinglecomm.dat +.. literalinclude:: /src/scripting/Isinglecomm.dat :language: text .. doctest:: diff --git a/doc/OnlineDocs/advanced_topics/sos_constraints.rst b/doc/OnlineDocs/explanation/modeling/math_programming/sos_constraints.rst similarity index 100% rename from doc/OnlineDocs/advanced_topics/sos_constraints.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/sos_constraints.rst diff --git a/doc/OnlineDocs/pyomo_modeling_components/Suffixes.rst b/doc/OnlineDocs/explanation/modeling/math_programming/suffixes.rst similarity index 99% rename from doc/OnlineDocs/pyomo_modeling_components/Suffixes.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/suffixes.rst index e45fe2d74b7..e410374b274 100644 --- a/doc/OnlineDocs/pyomo_modeling_components/Suffixes.rst +++ b/doc/OnlineDocs/explanation/modeling/math_programming/suffixes.rst @@ -23,7 +23,7 @@ Suffix Notation and the Pyomo NL File Interface ----------------------------------------------- The Suffix component used in Pyomo has been adapted from the suffix -notation used in the modeling language AMPL [AMPL]_. Therefore, it +notation used in the modeling language AMPL [FGK02]_. Therefore, it follows naturally that AMPL style suffix functionality is fully available using Pyomo's NL file interface. For information on AMPL style suffixes the reader is referred to the AMPL website: diff --git a/doc/OnlineDocs/pyomo_modeling_components/Variables.rst b/doc/OnlineDocs/explanation/modeling/math_programming/variables.rst similarity index 88% rename from doc/OnlineDocs/pyomo_modeling_components/Variables.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/variables.rst index 7f7ee74af5f..58ccacc17da 100644 --- a/doc/OnlineDocs/pyomo_modeling_components/Variables.rst +++ b/doc/OnlineDocs/explanation/modeling/math_programming/variables.rst @@ -20,13 +20,13 @@ declaring a *singleton* (i.e. unindexed) variable named ``model.LumberJack`` that will take on real values between zero and 6 and it initialized to be 1.5: -.. literalinclude:: ../src/scripting/spy4Variables_Declare_singleton_variable.spy +.. literalinclude:: /src/scripting/spy4Variables_Declare_singleton_variable.spy :language: python Instead of the ``initialize`` option, initialization is sometimes done with a Python assignment statement as in -.. literalinclude:: ../src/scripting/spy4Variables_Assign_value.spy +.. literalinclude:: /src/scripting/spy4Variables_Assign_value.spy :language: python For indexed variables, bounds and initial values are often specified by @@ -36,7 +36,7 @@ followed by the indexes. This is illustrated in the following code snippet that makes use of Python dictionaries declared as lb and ub that are used by a function to provide bounds: -.. literalinclude:: ../src/scripting/spy4Variables_Declare_bounds.spy +.. literalinclude:: /src/scripting/spy4Variables_Declare_bounds.spy :language: python .. note:: diff --git a/doc/OnlineDocs/modeling_extensions/mpec.rst b/doc/OnlineDocs/explanation/modeling/mpec.rst similarity index 100% rename from doc/OnlineDocs/modeling_extensions/mpec.rst rename to doc/OnlineDocs/explanation/modeling/mpec.rst diff --git a/doc/OnlineDocs/modeling_extensions/network.rst b/doc/OnlineDocs/explanation/modeling/network.rst similarity index 91% rename from doc/OnlineDocs/modeling_extensions/network.rst rename to doc/OnlineDocs/explanation/modeling/network.rst index 3fce9448997..2c640eabe0a 100644 --- a/doc/OnlineDocs/modeling_extensions/network.rst +++ b/doc/OnlineDocs/explanation/modeling/network.rst @@ -29,10 +29,12 @@ Port **** .. autoclass:: pyomo.network.Port + :noindex: :members: :exclude-members: construct, display .. autoclass:: pyomo.network.port._PortData + :noindex: :members: :special-members: __getattr__ :exclude-members: set_value @@ -43,14 +45,14 @@ concrete Pyomo model: .. doctest:: - >>> from pyomo.environ import * - >>> from pyomo.network import * - >>> m = ConcreteModel() - >>> m.x = Var() - >>> m.y = Var(['a', 'b']) # can be indexed - >>> m.z = Var() + >>> import pyomo.environ as pyo + >>> from pyomo.network import Port + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var() + >>> m.y = pyo.Var(['a', 'b']) # can be indexed + >>> m.z = pyo.Var() >>> m.e = 5 * m.z # you can add Pyomo expressions too - >>> m.w = Var() + >>> m.w = pyo.Var() >>> m.p = Port() >>> m.p.add(m.x) # implicitly name the port member "x" @@ -62,10 +64,12 @@ Arc *** .. autoclass:: pyomo.network.Arc + :noindex: :members: :exclude-members: construct .. autoclass:: pyomo.network.arc._ArcData + :noindex: :members: :special-members: __getattr__ @@ -75,15 +79,15 @@ concrete Pyomo model: .. doctest:: - >>> from pyomo.environ import * - >>> from pyomo.network import * - >>> m = ConcreteModel() - >>> m.x = Var() - >>> m.y = Var(['a', 'b']) - >>> m.u = Var() - >>> m.v = Var(['a', 'b']) - >>> m.w = Var() - >>> m.z = Var(['a', 'b']) # indexes need to match + >>> import pyomo.environ as pyo + >>> from pyomo.network import Port, Arc + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var() + >>> m.y = pyo.Var(['a', 'b']) + >>> m.u = pyo.Var() + >>> m.v = pyo.Var(['a', 'b']) + >>> m.w = pyo.Var() + >>> m.z = pyo.Var(['a', 'b']) # indexes need to match >>> m.p = Port(initialize=[m.x, m.y]) >>> m.q = Port(initialize={"x": m.u, "y": m.v}) @@ -135,19 +139,19 @@ the arcs on a model: .. doctest:: - >>> from pyomo.environ import * - >>> from pyomo.network import * - >>> m = ConcreteModel() - >>> m.x = Var() - >>> m.y = Var(['a', 'b']) - >>> m.u = Var() - >>> m.v = Var(['a', 'b']) + >>> import pyomo.environ as pyo + >>> from pyomo.network import Port, Arc + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var() + >>> m.y = pyo.Var(['a', 'b']) + >>> m.u = pyo.Var() + >>> m.v = pyo.Var(['a', 'b']) >>> m.p = Port(initialize=[m.x, (m.y, Port.Extensive)]) # rules must match >>> m.q = Port(initialize={"x": m.u, "y": (m.v, Port.Extensive)}) >>> m.a = Arc(source=m.p, destination=m.q) - >>> TransformationFactory("network.expand_arcs").apply_to(m) + >>> pyo.TransformationFactory("network.expand_arcs").apply_to(m) Sequential Decomposition ------------------------ @@ -297,19 +301,19 @@ class: .. doctest:: :skipif: not __import__("pyomo.network").network.decomposition.imports_available - >>> from pyomo.environ import * - >>> from pyomo.network import * - >>> m = ConcreteModel() - >>> m.unit1 = Block() - >>> m.unit1.x = Var() - >>> m.unit1.y = Var(['a', 'b']) - >>> m.unit2 = Block() - >>> m.unit2.x = Var() - >>> m.unit2.y = Var(['a', 'b']) + >>> import pyomo.environ as pyo + >>> from pyomo.network import Port, Arc, SequentialDecomposition + >>> m = pyo.ConcreteModel() + >>> m.unit1 = pyo.Block() + >>> m.unit1.x = pyo.Var() + >>> m.unit1.y = pyo.Var(['a', 'b']) + >>> m.unit2 = pyo.Block() + >>> m.unit2.x = pyo.Var() + >>> m.unit2.y = pyo.Var(['a', 'b']) >>> m.unit1.port = Port(initialize=[m.unit1.x, (m.unit1.y, Port.Extensive)]) >>> m.unit2.port = Port(initialize=[m.unit2.x, (m.unit2.y, Port.Extensive)]) >>> m.a = Arc(source=m.unit1.port, destination=m.unit2.port) - >>> TransformationFactory("network.expand_arcs").apply_to(m) + >>> pyo.TransformationFactory("network.expand_arcs").apply_to(m) >>> m.unit1.x.fix(10) >>> m.unit1.y['a'].fix(15) @@ -326,6 +330,7 @@ class: >>> seq.run(m, initialize) .. autoclass:: pyomo.network.SequentialDecomposition + :noindex: :members: set_guesses_for, set_tear_set, tear_set_arcs, indexes_to_arcs, run, create_graph, select_tear_mip, select_tear_mip_model, select_tear_heuristic, calculation_order, tree_order diff --git a/doc/OnlineDocs/modeling_extensions/reduce_points_demo.png b/doc/OnlineDocs/explanation/modeling/reduce_points_demo.png similarity index 100% rename from doc/OnlineDocs/modeling_extensions/reduce_points_demo.png rename to doc/OnlineDocs/explanation/modeling/reduce_points_demo.png diff --git a/doc/OnlineDocs/explanation/modeling/units.rst b/doc/OnlineDocs/explanation/modeling/units.rst new file mode 100644 index 00000000000..6e4c1ae3f15 --- /dev/null +++ b/doc/OnlineDocs/explanation/modeling/units.rst @@ -0,0 +1,12 @@ +Units Handling in Pyomo +======================= + +.. automodule:: pyomo.core.base.units_container + :noindex: + +.. autosummary:: + + PyomoUnitsContainer + UnitsError + InconsistentUnitsError + diff --git a/doc/OnlineDocs/advanced_topics/flattener/index.rst b/doc/OnlineDocs/explanation/modeling_utils/flattener/index.rst similarity index 100% rename from doc/OnlineDocs/advanced_topics/flattener/index.rst rename to doc/OnlineDocs/explanation/modeling_utils/flattener/index.rst diff --git a/doc/OnlineDocs/advanced_topics/flattener/motivation.rst b/doc/OnlineDocs/explanation/modeling_utils/flattener/motivation.rst similarity index 100% rename from doc/OnlineDocs/advanced_topics/flattener/motivation.rst rename to doc/OnlineDocs/explanation/modeling_utils/flattener/motivation.rst diff --git a/doc/OnlineDocs/advanced_topics/flattener/reference.rst b/doc/OnlineDocs/explanation/modeling_utils/flattener/reference.rst similarity index 90% rename from doc/OnlineDocs/advanced_topics/flattener/reference.rst rename to doc/OnlineDocs/explanation/modeling_utils/flattener/reference.rst index 22c7b67e1f6..b30559ef1a6 100644 --- a/doc/OnlineDocs/advanced_topics/flattener/reference.rst +++ b/doc/OnlineDocs/explanation/modeling_utils/flattener/reference.rst @@ -8,7 +8,10 @@ API reference pyomo.dae.flatten.flatten_dae_components .. autofunction:: pyomo.dae.flatten.slice_component_along_sets + :noindex: .. autofunction:: pyomo.dae.flatten.flatten_components_along_sets + :noindex: .. autofunction:: pyomo.dae.flatten.flatten_dae_components + :noindex: diff --git a/doc/OnlineDocs/explanation/modeling_utils/index.rst b/doc/OnlineDocs/explanation/modeling_utils/index.rst new file mode 100644 index 00000000000..16560899ebd --- /dev/null +++ b/doc/OnlineDocs/explanation/modeling_utils/index.rst @@ -0,0 +1,24 @@ +Modeling Utilities +================== + +.. toctree:: + :maxdepth: 2 + + flattener/index + latex_printer + preprocessing + scaling + + + +.. + Reorganization notes: + + `Latex Printer` + `FME` + `Model Viewer` + `Model Flattening` + + Still missing: + - fme + - viewer diff --git a/doc/OnlineDocs/contributed_packages/latex_printer.rst b/doc/OnlineDocs/explanation/modeling_utils/latex_printer.rst similarity index 99% rename from doc/OnlineDocs/contributed_packages/latex_printer.rst rename to doc/OnlineDocs/explanation/modeling_utils/latex_printer.rst index ff3f628c0c8..c03eebe2f91 100644 --- a/doc/OnlineDocs/contributed_packages/latex_printer.rst +++ b/doc/OnlineDocs/explanation/modeling_utils/latex_printer.rst @@ -4,6 +4,7 @@ Latex Printing Pyomo models can be printed to a LaTeX compatible format using the ``pyomo.contrib.latex_printer.latex_printer`` function: .. autofunction:: pyomo.contrib.latex_printer.latex_printer.latex_printer + :noindex: .. note:: diff --git a/doc/OnlineDocs/contributed_packages/preprocessing.rst b/doc/OnlineDocs/explanation/modeling_utils/preprocessing.rst similarity index 78% rename from doc/OnlineDocs/contributed_packages/preprocessing.rst rename to doc/OnlineDocs/explanation/modeling_utils/preprocessing.rst index fd26f2bf6db..accc2bf935c 100644 --- a/doc/OnlineDocs/contributed_packages/preprocessing.rst +++ b/doc/OnlineDocs/explanation/modeling_utils/preprocessing.rst @@ -40,16 +40,16 @@ transformation on a concrete Pyomo model: .. doctest:: - >>> from pyomo.environ import * - >>> m = ConcreteModel() - >>> m.v1 = Var(initialize=1, bounds=(1, 8)) - >>> m.v2 = Var(initialize=2, bounds=(0, 3)) - >>> m.v3 = Var(initialize=3, bounds=(-7, 4)) - >>> m.v4 = Var(initialize=4, bounds=(2, 6)) - >>> m.c1 = Constraint(expr=m.v1 == m.v2) - >>> m.c2 = Constraint(expr=m.v2 == m.v3) - >>> m.c3 = Constraint(expr=m.v3 == m.v4) - >>> TransformationFactory('contrib.aggregate_vars').apply_to(m) + >>> import pyomo.environ as pyo + >>> m = pyo.ConcreteModel() + >>> m.v1 = pyo.Var(initialize=1, bounds=(1, 8)) + >>> m.v2 = pyo.Var(initialize=2, bounds=(0, 3)) + >>> m.v3 = pyo.Var(initialize=3, bounds=(-7, 4)) + >>> m.v4 = pyo.Var(initialize=4, bounds=(2, 6)) + >>> m.c1 = pyo.Constraint(expr=m.v1 == m.v2) + >>> m.c2 = pyo.Constraint(expr=m.v2 == m.v3) + >>> m.c3 = pyo.Constraint(expr=m.v3 == m.v4) + >>> pyo.TransformationFactory('contrib.aggregate_vars').apply_to(m) To see the results of the transformation, you could then use the command @@ -58,6 +58,7 @@ To see the results of the transformation, you could then use the command >>> m.pprint() .. autoclass:: pyomo.contrib.preprocessing.plugins.var_aggregator.VariableAggregator + :noindex: :members: apply_to, create_using, update_variables @@ -66,17 +67,18 @@ Explicit Constraints to Variable Bounds .. doctest:: - >>> from pyomo.environ import * - >>> m = ConcreteModel() - >>> m.v1 = Var(initialize=1) - >>> m.v2 = Var(initialize=2) - >>> m.v3 = Var(initialize=3) - >>> m.c1 = Constraint(expr=m.v1 == 2) - >>> m.c2 = Constraint(expr=m.v2 >= -2) - >>> m.c3 = Constraint(expr=m.v3 <= 5) - >>> TransformationFactory('contrib.constraints_to_var_bounds').apply_to(m) + >>> import pyomo.environ as pyo + >>> m = pyo.ConcreteModel() + >>> m.v1 = pyo.Var(initialize=1) + >>> m.v2 = pyo.Var(initialize=2) + >>> m.v3 = pyo.Var(initialize=3) + >>> m.c1 = pyo.Constraint(expr=m.v1 == 2) + >>> m.c2 = pyo.Constraint(expr=m.v2 >= -2) + >>> m.c3 = pyo.Constraint(expr=m.v3 <= 5) + >>> pyo.TransformationFactory('contrib.constraints_to_var_bounds').apply_to(m) .. autoclass:: pyomo.contrib.preprocessing.plugins.bounds_to_vars.ConstraintToVarBoundTransform + :noindex: :members: apply_to, create_using @@ -84,6 +86,7 @@ Induced Linearity Reformulation ------------------------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.induced_linearity.InducedLinearity + :noindex: :members: apply_to, create_using @@ -94,58 +97,68 @@ This transformation was developed by `Sunjeev Kale `_ at Carnegie Mellon University. .. autoclass:: pyomo.contrib.preprocessing.plugins.constraint_tightener.TightenConstraintFromVars + :noindex: :members: apply_to, create_using Trivial Constraint Deactivation ------------------------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.deactivate_trivial_constraints.TrivialConstraintDeactivator + :noindex: :members: apply_to, create_using, revert Fixed Variable Detection ------------------------ .. autoclass:: pyomo.contrib.preprocessing.plugins.detect_fixed_vars.FixedVarDetector + :noindex: :members: apply_to, create_using, revert Fixed Variable Equality Propagator ---------------------------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.equality_propagate.FixedVarPropagator + :noindex: :members: apply_to, create_using, revert Variable Bound Equality Propagator ---------------------------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.equality_propagate.VarBoundPropagator + :noindex: :members: apply_to, create_using, revert Variable Midpoint Initializer ----------------------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.init_vars.InitMidpoint + :noindex: :members: apply_to, create_using Variable Zero Initializer ------------------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.init_vars.InitZero + :noindex: :members: apply_to, create_using Zero Term Remover ----------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.remove_zero_terms.RemoveZeroTerms + :noindex: :members: apply_to, create_using Variable Bound Remover ---------------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.strip_bounds.VariableBoundStripper + :noindex: :members: apply_to, create_using, revert Zero Sum Propagator ------------------- .. autoclass:: pyomo.contrib.preprocessing.plugins.zero_sum_propagator.ZeroSumPropagator + :noindex: :members: apply_to, create_using diff --git a/doc/OnlineDocs/model_transformations/scaling.rst b/doc/OnlineDocs/explanation/modeling_utils/scaling.rst similarity index 97% rename from doc/OnlineDocs/model_transformations/scaling.rst rename to doc/OnlineDocs/explanation/modeling_utils/scaling.rst index 180f1e0205b..7761e275176 100644 --- a/doc/OnlineDocs/model_transformations/scaling.rst +++ b/doc/OnlineDocs/explanation/modeling_utils/scaling.rst @@ -3,8 +3,10 @@ Model Scaling Transformation Good scaling of models can greatly improve the numerical properties of a problem and thus increase reliability and convergence. The ``core.scale_model`` transformation allows users to separate scaling of a model from the declaration of the model variables and constraints which allows for models to be written in more natural forms and to be scaled and rescaled as required without having to rewrite the model code. -.. autoclass:: pyomo.core.plugins.transform.scaling.ScaleModel - :members: +.. autosummary:: + + pyomo.core.plugins.transform.scaling.ScaleModel + Setting Scaling Factors ----------------------- diff --git a/doc/OnlineDocs/explanation/philosophy/abstract_modeling.rst b/doc/OnlineDocs/explanation/philosophy/abstract_modeling.rst new file mode 100644 index 00000000000..6832cfe04ac --- /dev/null +++ b/doc/OnlineDocs/explanation/philosophy/abstract_modeling.rst @@ -0,0 +1,64 @@ +Abstract Models +--------------- + +.. note:: + + TODO: this is a copy of "Abstract vs Concrete" from Getting Started. + This should be expanded here. + + +A mathematical model can be defined using symbols that represent data +values. For example, the following equations represent a linear program +(LP) to find optimal values for the vector :math:`x` with parameters +:math:`n` and :math:`b`, and parameter vectors :math:`a` and :math:`c`: + +.. math:: + :nowrap: + + \[ + \begin{array}{lll} + \min & \sum_{j=1}^n c_j x_j &\\ + \mathrm{s.t.} & \sum_{j=1}^n a_{ij} x_j \geq b_i & \forall i = 1 \ldots m\\ + & x_j \geq 0 & \forall j = 1 \ldots n + \end{array} + \] + +.. note:: + + As a convenience, we use the symbol :math:`\forall` to mean "for all" + or "for each." + +We call this an *abstract* or *symbolic* mathematical model since it +relies on unspecified parameter values. Data values can be used to +specify a *model instance*. The ``AbstractModel`` class provides a +context for defining and initializing abstract optimization models in +Pyomo when the data values will be supplied at the time a solution is to +be obtained. + +In many contexts, a mathematical model can and should be directly +defined with the data values supplied at the time of the model +definition. We call these *concrete* mathematical models. For example, +the following LP model is a concrete instance of the previous abstract +model: + +.. math:: + :nowrap: + + \[ + \begin{array}{ll} + \min & 2 x_1 + 3 x_2\\ + \mathrm{s.t.} & 3 x_1 + 4 x_2 \geq 1\\ + & x_1, x_2 \geq 0 + \end{array} + \] + +The ``ConcreteModel`` class is used to define concrete optimization +models in Pyomo. + +.. note:: + + Python programmers will probably prefer to write concrete models, + while users of some other algebraic modeling languages may tend to + prefer to write abstract models. The choice is largely a matter of + taste; some applications may be a little more straightforward using + one or the other. diff --git a/doc/OnlineDocs/explanation/philosophy/component_design.rst b/doc/OnlineDocs/explanation/philosophy/component_design.rst new file mode 100644 index 00000000000..e4c526698d1 --- /dev/null +++ b/doc/OnlineDocs/explanation/philosophy/component_design.rst @@ -0,0 +1,4 @@ +Pyomo Component Design +====================== + +TODO diff --git a/doc/OnlineDocs/developer_reference/expressions/design.rst b/doc/OnlineDocs/explanation/philosophy/expressions/design.rst similarity index 98% rename from doc/OnlineDocs/developer_reference/expressions/design.rst rename to doc/OnlineDocs/explanation/philosophy/expressions/design.rst index ddecb39ad0c..d12fe745672 100644 --- a/doc/OnlineDocs/developer_reference/expressions/design.rst +++ b/doc/OnlineDocs/explanation/philosophy/expressions/design.rst @@ -73,7 +73,7 @@ Expression trees can be categorized in four different ways: These three categories are illustrated with the following example: -.. literalinclude:: ../../src/expr/design_categories.spy +.. literalinclude:: /src/expr/design_categories.spy The following table describes four different simple expressions that consist of a single model component, and it shows how they @@ -107,7 +107,7 @@ Named expressions allow for changes to an expression after it has been constructed. For example, consider the expression ``f`` defined with the :class:`Expression ` component: -.. literalinclude:: ../../src/expr/design_named_expression.spy +.. literalinclude:: /src/expr/design_named_expression.spy Although ``f`` is an immutable expression, whose definition is fixed, a sub-expressions is the named expression ``M.e``. Named @@ -227,7 +227,7 @@ The :data:`linear_expression ` object is a context manager that can be used to declare a linear sum. For example, consider the following two loops: -.. literalinclude:: ../../src/expr/design_cm1.spy +.. literalinclude:: /src/expr/design_cm1.spy The first apparent difference in these loops is that the value of ``s`` is explicitly initialized while ``e`` is initialized when the @@ -250,7 +250,7 @@ construct different expressions with different context declarations. Finally, note that these context managers can be passed into the :attr:`start` method for the :func:`quicksum ` function. For example: -.. literalinclude:: ../../src/expr/design_cm2.spy +.. literalinclude:: /src/expr/design_cm2.spy This sum contains terms for ``M.x[i]`` and ``M.y[i]``. The syntax in this example is not intuitive because the sum is being stored diff --git a/doc/OnlineDocs/developer_reference/expressions/index.rst b/doc/OnlineDocs/explanation/philosophy/expressions/index.rst similarity index 96% rename from doc/OnlineDocs/developer_reference/expressions/index.rst rename to doc/OnlineDocs/explanation/philosophy/expressions/index.rst index 685fde25173..e7dcf5831a1 100644 --- a/doc/OnlineDocs/developer_reference/expressions/index.rst +++ b/doc/OnlineDocs/explanation/philosophy/expressions/index.rst @@ -21,7 +21,7 @@ nodes contain operators. Pyomo relies on so-called magic methods to automate the construction of symbolic expressions. For example, consider an expression ``e`` declared as follows: -.. literalinclude:: ../../src/expr/index_simple.spy +.. literalinclude:: /src/expr/index_simple.spy Python determines that the magic method ``__mul__`` is called on the ``M.v`` object, with the argument ``2``. This method returns diff --git a/doc/OnlineDocs/developer_reference/expressions/managing.rst b/doc/OnlineDocs/explanation/philosophy/expressions/managing.rst similarity index 81% rename from doc/OnlineDocs/developer_reference/expressions/managing.rst rename to doc/OnlineDocs/explanation/philosophy/expressions/managing.rst index a4dd2a51436..ded3a5f8f9f 100644 --- a/doc/OnlineDocs/developer_reference/expressions/managing.rst +++ b/doc/OnlineDocs/explanation/philosophy/expressions/managing.rst @@ -23,7 +23,7 @@ mimics the Python operations used to construct an expression. The :data:`verbose` flag can be set to :const:`True` to generate a string representation that is a nested functional form. For example: -.. literalinclude:: ../../src/expr/managing_ex1.spy +.. literalinclude:: /src/expr/managing_ex1.spy Labeler and Symbol Map ~~~~~~~~~~~~~~~~~~~~~~ @@ -37,7 +37,7 @@ the :class:`NumericLabeler` defines a functor that can be used to sequentially generate simple labels with a prefix followed by the variable count: -.. literalinclude:: ../../src/expr/managing_ex2.spy +.. literalinclude:: /src/expr/managing_ex2.spy The :data:`smap` option is used to specify a symbol map object (:class:`SymbolMap `), which @@ -72,19 +72,19 @@ the expression have a value. The :func:`value ` function can be used to walk the expression tree and compute the value of an expression. For example: -.. literalinclude:: ../../src/expr/managing_ex5.spy +.. literalinclude:: /src/expr/managing_ex5.spy Additionally, expressions define the :func:`__call__` method, so the following is another way to compute the value of an expression: -.. literalinclude:: ../../src/expr/managing_ex6.spy +.. literalinclude:: /src/expr/managing_ex6.spy If a parameter or variable is undefined, then the :func:`value ` function and :func:`__call__` method will raise an exception. This exception can be suppressed using the :attr:`exception` option. For example: -.. literalinclude:: ../../src/expr/managing_ex7.spy +.. literalinclude:: /src/expr/managing_ex7.spy This option is useful in contexts where adding a try block is inconvenient in your modeling script. @@ -108,7 +108,7 @@ functions that support this functionality. First, the function is a generator function that walks the expression tree and yields all nodes whose type is in a specified set of node types. For example: -.. literalinclude:: ../../src/expr/managing_ex8.spy +.. literalinclude:: /src/expr/managing_ex8.spy The :func:`identify_variables ` function is a generator function that yields all nodes that are @@ -117,7 +117,7 @@ but this set of variable types does not need to be specified by the user. However, the :attr:`include_fixed` flag can be specified to omit fixed variables. For example: -.. literalinclude:: ../../src/expr/managing_ex9.spy +.. literalinclude:: /src/expr/managing_ex9.spy Walking an Expression Tree with a Visitor Class ----------------------------------------------- @@ -140,10 +140,6 @@ tree: seven event callbacks that users can hook into, providing very fine-grained control over the expression walker. -:class:`SimpleExpressionVisitor ` - A :func:`visitor` method is called for each node in the tree, - and the visitor class collects information about the tree. - :class:`ExpressionValueVisitor ` When the :func:`visitor` method is called on each node in the tree, the *values* of its children have been computed. The @@ -166,12 +162,6 @@ These classes define a variety of suitable tree search methods: * ``walk_expression``: depth-first traversal of the expression tree. -* :class:`SimpleExpressionVisitor ` - - * ``xbfs``: breadth-first search where leaf nodes are immediately visited - * ``xbfs_yield_leaves``: breadth-first search where leaf nodes are - immediately visited, and the visit method yields a value - * :class:`ExpressionValueVisitor ` * ``dfs_postorder_stack``: postorder depth-first search using a @@ -179,12 +169,11 @@ These classes define a variety of suitable tree search methods: To implement a visitor object, a user needs to provide specializations -for specific events. For legacy visitors based on the PyUtilib -visitor pattern (e.g., :class:`SimpleExpressionVisitor` and -:class:`ExpressionValueVisitor`), one must create a subclass of one of these -classes and override at least one of the following: +for specific events. For legacy visitors based on the PyUtilib visitor +pattern (e.g., :class:`ExpressionValueVisitor`), one must create a +subclass and override at least one of the following: -:func:`visitor` +:func:`visit` Defines the operation that is performed when a node is visited. In the :class:`ExpressionValueVisitor ` and @@ -196,10 +185,7 @@ classes and override at least one of the following: Checks if the search should terminate with this node. If no, then this method returns the tuple ``(False, None)``. If yes, then this method returns ``(False, value)``, where *value* is - computed by this method. This method is not used in the - :class:`SimpleExpressionVisitor - ` visitor - class. + computed by this method. :func:`finalize` This method defines the final value that is returned from the @@ -216,21 +202,22 @@ callbacks, which are documented in the class documentation. Detailed documentation of the APIs for these methods is provided with the class documentation for these visitors. -SimpleExpressionVisitor Example -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +StreamBasedExpressionVisitor Example +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In this example, we describe an visitor class that counts the number of nodes in an expression (including leaf nodes). Consider the following class: -.. literalinclude:: ../../src/expr/managing_visitor1.spy +.. literalinclude:: /src/expr/managing_visitor1.spy -The class constructor creates a counter, and the :func:`visit` method -increments this counter for every node that is visited. The :func:`finalize` -method returns the value of this counter after the tree has been walked. The -following function illustrates this use of this visitor class: +The :func:`initializeWalker` method creates a counter, and the +:func:`exitNode` method increments this counter for every node that is +visited. The :func:`finalizeResult` method returns the value of this +counter after the tree has been walked. The following function +illustrates this use of this visitor class: -.. literalinclude:: ../../src/expr/managing_visitor2.spy +.. literalinclude:: /src/expr/managing_visitor2.spy ExpressionValueVisitor Example @@ -240,14 +227,14 @@ In this example, we describe an visitor class that clones the expression tree (including leaf nodes). Consider the following class: -.. literalinclude:: ../../src/expr/managing_visitor3.spy +.. literalinclude:: /src/expr/managing_visitor3.spy The :func:`visit` method creates a new expression node with children specified by :attr:`values`. The :func:`visiting_potential_leaf` method performs a :func:`deepcopy` on leaf nodes, which are native Python types or non-expression objects. -.. literalinclude:: ../../src/expr/managing_visitor4.spy +.. literalinclude:: /src/expr/managing_visitor4.spy ExpressionReplacementVisitor Example @@ -258,15 +245,15 @@ variables with scaled variables, using a mutable parameter that can be modified later. the following class: -.. literalinclude:: ../../src/expr/managing_visitor5.spy +.. literalinclude:: /src/expr/managing_visitor5.spy No other method need to be defined. The :func:`beforeChild` method identifies variable nodes and returns a product expression that contains a mutable parameter. -.. literalinclude:: ../../src/expr/managing_visitor6.spy +.. literalinclude:: /src/expr/managing_visitor6.spy The :func:`scale_expression` function is called with an expression and a dictionary, :attr:`scale`, that maps variable ID to model parameter. For example: -.. literalinclude:: ../../src/expr/managing_visitor7.spy +.. literalinclude:: /src/expr/managing_visitor7.spy diff --git a/doc/OnlineDocs/developer_reference/expressions/overview.rst b/doc/OnlineDocs/explanation/philosophy/expressions/overview.rst similarity index 95% rename from doc/OnlineDocs/developer_reference/expressions/overview.rst rename to doc/OnlineDocs/explanation/philosophy/expressions/overview.rst index c1962edec22..c89e3e6b4b7 100644 --- a/doc/OnlineDocs/developer_reference/expressions/overview.rst +++ b/doc/OnlineDocs/explanation/philosophy/expressions/overview.rst @@ -50,13 +50,13 @@ are: example, the following two loops had dramatically different runtime: - .. literalinclude:: ../../src/expr/overview_example1.spy + .. literalinclude:: /src/expr/overview_example1.spy * Coopr3 eliminates side effects by automatically cloning sub-expressions. Unfortunately, this can easily lead to unexpected cloning in models, which can dramatically slow down Pyomo model generation. For example: - .. literalinclude:: ../../src/expr/overview_example2.spy + .. literalinclude:: /src/expr/overview_example2.spy * Coopr3 leverages recursion in many operations, including expression cloning. Even simple non-linear expressions can result in deep @@ -82,7 +82,7 @@ control for how expressions are managed in Python. For example: * Python variables can point to the same expression tree - .. literalinclude:: ../../src/expr/overview_tree1.spy + .. literalinclude:: /src/expr/overview_tree1.spy This is illustrated as follows: @@ -102,7 +102,7 @@ control for how expressions are managed in Python. For example: * A variable can point to a sub-tree that another variable points to - .. literalinclude:: ../../src/expr/overview_tree2.spy + .. literalinclude:: /src/expr/overview_tree2.spy This is illustrated as follows: @@ -124,7 +124,7 @@ control for how expressions are managed in Python. For example: * Two expression trees can point to the same sub-tree - .. literalinclude:: ../../src/expr/overview_tree3.spy + .. literalinclude:: /src/expr/overview_tree3.spy This is illustrated as follows: @@ -169,7 +169,7 @@ between expressions, we do not consider those expressions entangled. Expression entanglement is problematic because shared expressions complicate the expected behavior when sub-expressions are changed. Consider the following example: -.. literalinclude:: ../../src/expr/overview_tree4.spy +.. literalinclude:: /src/expr/overview_tree4.spy What is the value of ``e`` after ``M.w`` is added to it? What is the value of ``f``? The answers to these questions are not immediately @@ -244,7 +244,7 @@ There is one important exception to the entanglement property described above. The ``Expression`` component is treated as a mutable expression when shared between expressions. For example: -.. literalinclude:: ../../src/expr/overview_tree5.spy +.. literalinclude:: /src/expr/overview_tree5.spy Here, the expression ``M.e`` is a so-called *named expression* that the user has declared. Named expressions are explicitly intended diff --git a/doc/OnlineDocs/developer_reference/expressions/performance.rst b/doc/OnlineDocs/explanation/philosophy/expressions/performance.rst similarity index 91% rename from doc/OnlineDocs/developer_reference/expressions/performance.rst rename to doc/OnlineDocs/explanation/philosophy/expressions/performance.rst index 8e344e50982..c7b68377098 100644 --- a/doc/OnlineDocs/developer_reference/expressions/performance.rst +++ b/doc/OnlineDocs/explanation/philosophy/expressions/performance.rst @@ -11,14 +11,14 @@ Expression Generation Pyomo expressions can be constructed using native binary operators in Python. For example, a sum can be created in a simple loop: -.. literalinclude:: ../../src/expr/performance_loop1.spy +.. literalinclude:: /src/expr/performance_loop1.spy Additionally, Pyomo expressions can be constructed using functions that iteratively apply Python binary operators. For example, the Python :func:`sum` function can be used to replace the previous loop: -.. literalinclude:: ../../src/expr/performance_loop2.spy +.. literalinclude:: /src/expr/performance_loop2.spy The :func:`sum` function is both more compact and more efficient. Using :func:`sum` avoids the creation of temporary variables, and @@ -47,7 +47,7 @@ expressions. For example, consider the following quadratic polynomial: -.. literalinclude:: ../../src/expr/performance_loop3.spy +.. literalinclude:: /src/expr/performance_loop3.spy This quadratic polynomial is treated as a nonlinear expression unless the expression is explicitly processed to identify quadratic @@ -78,7 +78,7 @@ The :func:`prod ` function is analogous to the builtin argument list, :attr:`args`, which represents expressions that are multiplied together. For example: -.. literalinclude:: ../../src/expr/performance_prod.spy +.. literalinclude:: /src/expr/performance_prod.spy quicksum ~~~~~~~~ @@ -89,7 +89,7 @@ generates a more compact Pyomo expression. Its main argument is a variable length argument list, :attr:`args`, which represents expressions that are summed together. For example: -.. literalinclude:: ../../src/expr/performance_quicksum.spy +.. literalinclude:: /src/expr/performance_quicksum.spy The summation is customized based on the :attr:`start` and :attr:`linear` arguments. The :attr:`start` defines the initial @@ -111,13 +111,13 @@ more quickly. Consider the following example: -.. literalinclude:: ../../src/expr/quicksum_runtime.spy +.. literalinclude:: /src/expr/quicksum_runtime.spy The sum consists of linear terms because the exponents are one. The following output illustrates that quicksum can identify this linear structure to generate expressions more quickly: -.. literalinclude:: ../../src/expr/quicksum.log +.. literalinclude:: /src/expr/quicksum.log :language: none If :attr:`start` is not a numeric value, then the :func:`quicksum @@ -134,7 +134,7 @@ to be stored in an object that is passed into the function (e.g. the linear cont term in :attr:`args` is misleading. Consider the following example: - .. literalinclude:: ../../src/expr/performance_warning.spy + .. literalinclude:: /src/expr/performance_warning.spy The first term created by the generator is linear, but the subsequent terms are nonlinear. Pyomo gracefully transitions @@ -153,12 +153,12 @@ calling :func:`quicksum `. If two or more components provided, then the result is the summation of their terms multiplied together. For example: -.. literalinclude:: ../../src/expr/performance_sum_product1.spy +.. literalinclude:: /src/expr/performance_sum_product1.spy The :attr:`denom` argument specifies components whose terms are in the denominator. For example: -.. literalinclude:: ../../src/expr/performance_sum_product2.spy +.. literalinclude:: /src/expr/performance_sum_product2.spy The terms summed by this function are explicitly specified, so :func:`sum_product ` can identify diff --git a/doc/OnlineDocs/explanation/philosophy/index.rst b/doc/OnlineDocs/explanation/philosophy/index.rst new file mode 100644 index 00000000000..b45cf8806d0 --- /dev/null +++ b/doc/OnlineDocs/explanation/philosophy/index.rst @@ -0,0 +1,21 @@ +Pyomo Philosophy +================ + +.. toctree:: + :maxdepth: 2 + + abstract_modeling + component_design + expressions/index + transformations + + + +.. + Reorganization notes: + + `Pyomo Philosophy` + `Concrete and Abstract Models` + `Component Hierarchy` + `Expression System` + `Transformations` diff --git a/doc/OnlineDocs/model_transformations/index.rst b/doc/OnlineDocs/explanation/philosophy/transformations.rst similarity index 50% rename from doc/OnlineDocs/model_transformations/index.rst rename to doc/OnlineDocs/explanation/philosophy/transformations.rst index 462538128e7..363bcce73eb 100644 --- a/doc/OnlineDocs/model_transformations/index.rst +++ b/doc/OnlineDocs/explanation/philosophy/transformations.rst @@ -1,7 +1,4 @@ Model Transformations ===================== -.. toctree:: - :maxdepth: 1 - - scaling.rst +TODO diff --git a/doc/OnlineDocs/contributed_packages/gdpopt.rst b/doc/OnlineDocs/explanation/solvers/gdpopt.rst similarity index 55% rename from doc/OnlineDocs/contributed_packages/gdpopt.rst rename to doc/OnlineDocs/explanation/solvers/gdpopt.rst index d550b0ced76..98eabc4b01d 100644 --- a/doc/OnlineDocs/contributed_packages/gdpopt.rst +++ b/doc/OnlineDocs/explanation/solvers/gdpopt.rst @@ -12,15 +12,16 @@ The main advantage of these techniques is their ability to solve subproblems in a reduced space, including nonlinear constraints only for ``True`` logical blocks. As a result, GDPopt is most effective for nonlinear GDP models. -Three algorithms are available in GDPopt: +Four algorithms are available in GDPopt: 1. Logic-based outer approximation (LOA) [`Turkay & Grossmann, 1996`_] 2. Global logic-based outer approximation (GLOA) [`Lee & Grossmann, 2001`_] 3. Logic-based branch-and-bound (LBB) [`Lee & Grossmann, 2001`_] +4. Logic-based discrete steepest descent algorithm (LD-SDA) [`Ovalle et al., 2025`_] Usage and implementation details for GDPopt can be found in the PSE 2018 paper (`Chen et al., 2018`_), or via its -`preprint `_. +`preprint `_. Credit for prototyping and development can be found in the ``GDPopt`` class documentation, below. @@ -28,19 +29,20 @@ Credit for prototyping and development can be found in the ``GDPopt`` class docu .. _Lee & Grossmann, 2001: https://doi.org/10.1016/S0098-1354(01)00732-3 .. _Lee & Grossmann, 2000: https://doi.org/10.1016/S0098-1354(00)00581-0 .. _Chen et al., 2018: https://doi.org/10.1016/B978-0-444-64241-7.50143-9 +.. _Ovalle et al., 2025: https://doi.org/10.1016/j.compchemeng.2024.108993 GDPopt can be used to solve a Pyomo.GDP concrete model in two ways. The simplest is to instantiate the generic GDPopt solver and specify the desired algorithm as an argument to the ``solve`` method: .. code:: - >>> SolverFactory('gdpopt').solve(model, algorithm='LOA') + >>> pyo.SolverFactory('gdpopt').solve(model, algorithm='LOA') The alternative is to instantiate an algorithm-specific GDPopt solver: .. code:: - >>> SolverFactory('gdpopt.loa').solve(model) + >>> pyo.SolverFactory('gdpopt.loa').solve(model) In the above examples, GDPopt uses the GDPopt-LOA algorithm. Other algorithms may be used by specifying them in the ``algorithm`` argument when using the generic solver or by instantiating the algorithm-specific GDPopt solvers. All GDPopt options are listed below. @@ -63,27 +65,27 @@ An example that includes the modeling approach may be found below. :skipif: not glpk_available Required imports - >>> from pyomo.environ import * - >>> from pyomo.gdp import * + >>> import pyomo.environ as pyo + >>> from pyomo.gdp import Disjunct, Disjunction Create a simple model - >>> model = ConcreteModel(name='LOA example') + >>> model = pyo.ConcreteModel(name='LOA example') - >>> model.x = Var(bounds=(-1.2, 2)) - >>> model.y = Var(bounds=(-10,10)) - >>> model.c = Constraint(expr= model.x + model.y == 1) + >>> model.x = pyo.Var(bounds=(-1.2, 2)) + >>> model.y = pyo.Var(bounds=(-10,10)) + >>> model.c = pyo.Constraint(expr= model.x + model.y == 1) >>> model.fix_x = Disjunct() - >>> model.fix_x.c = Constraint(expr=model.x == 0) + >>> model.fix_x.c = pyo.Constraint(expr=model.x == 0) >>> model.fix_y = Disjunct() - >>> model.fix_y.c = Constraint(expr=model.y == 0) + >>> model.fix_y.c = pyo.Constraint(expr=model.y == 0) >>> model.d = Disjunction(expr=[model.fix_x, model.fix_y]) - >>> model.objective = Objective(expr=model.x + 0.1*model.y, sense=minimize) + >>> model.objective = pyo.Objective(expr=model.x + 0.1*model.y, sense=pyo.minimize) Solve the model using GDPopt - >>> results = SolverFactory('gdpopt.loa').solve( + >>> results = pyo.SolverFactory('gdpopt.loa').solve( ... model, mip_solver='glpk') # doctest: +IGNORE_RESULT Display the final solution @@ -93,10 +95,10 @@ An example that includes the modeling approach may be found below. Variables: x : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain - None : -1.2 : 0.0 : 2 : False : False : Reals + None : -1.2 : 0 : 2 : False : False : Reals y : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain - None : -10 : 1.0 : 10 : False : False : Reals + None : -10 : 1 : 10 : False : False : Reals Objectives: objective : Size=1, Index=None, Active=True @@ -106,7 +108,7 @@ An example that includes the modeling approach may be found below. Constraints: c : Size=1 Key : Lower : Body : Upper - None : 1.0 : 1.0 : 1.0 + None : 1.0 : 1 : 1.0 .. note:: @@ -115,7 +117,7 @@ An example that includes the modeling approach may be found below. .. code:: - >>> SolverFactory('gdpopt.loa').solve(model, tee=True) + >>> pyo.SolverFactory('gdpopt.loa').solve(model, tee=True) Global Logic-based Outer Approximation (GLOA) --------------------------------------------- @@ -124,7 +126,7 @@ The same algorithm can be used to solve GDPs involving nonconvex nonlinear const .. code:: - >>> SolverFactory('gdpopt.gloa').solve(model) + >>> pyo.SolverFactory('gdpopt.gloa').solve(model) .. warning:: @@ -137,7 +139,7 @@ Instead of outer approximation, GDPs can be solved using the same MILP relaxatio .. code:: - >>> SolverFactory('gdpopt.ric').solve(model) + >>> pyo.SolverFactory('gdpopt.ric').solve(model) Again, this is a global algorithm if the subproblems are solved globally, and is not otherwise. @@ -158,25 +160,25 @@ To use the GDPopt-LBB solver, define your Pyomo GDP model as usual: :skipif: not baron_available Required imports - >>> from pyomo.environ import * + >>> import pyomo.environ as pyo >>> from pyomo.gdp import Disjunct, Disjunction Create a simple model - >>> m = ConcreteModel() - >>> m.x1 = Var(bounds = (0,8)) - >>> m.x2 = Var(bounds = (0,8)) - >>> m.obj = Objective(expr=m.x1 + m.x2, sense=minimize) + >>> m = pyo.ConcreteModel() + >>> m.x1 = pyo.Var(bounds = (0,8)) + >>> m.x2 = pyo.Var(bounds = (0,8)) + >>> m.obj = pyo.Objective(expr=m.x1 + m.x2, sense=pyo.minimize) >>> m.y1 = Disjunct() >>> m.y2 = Disjunct() - >>> m.y1.c1 = Constraint(expr=m.x1 >= 2) - >>> m.y1.c2 = Constraint(expr=m.x2 >= 2) - >>> m.y2.c1 = Constraint(expr=m.x1 >= 3) - >>> m.y2.c2 = Constraint(expr=m.x2 >= 3) + >>> m.y1.c1 = pyo.Constraint(expr=m.x1 >= 2) + >>> m.y1.c2 = pyo.Constraint(expr=m.x2 >= 2) + >>> m.y2.c1 = pyo.Constraint(expr=m.x1 >= 3) + >>> m.y2.c2 = pyo.Constraint(expr=m.x2 >= 3) >>> m.djn = Disjunction(expr=[m.y1, m.y2]) Invoke the GDPopt-LBB solver - >>> results = SolverFactory('gdpopt.lbb').solve(m) + >>> results = pyo.SolverFactory('gdpopt.lbb').solve(m) WARNING: 09/06/22: The GDPopt LBB algorithm currently has known issues. Please use the results with caution and report any bugs! @@ -186,9 +188,70 @@ To use the GDPopt-LBB solver, define your Pyomo GDP model as usual: >>> print(results.solver.termination_condition) optimal - >>> print([value(m.y1.indicator_var), value(m.y2.indicator_var)]) + >>> print([pyo.value(m.y1.indicator_var), pyo.value(m.y2.indicator_var)]) [True, False] +Logic-based Discrete-Steepest Descent Algorithm (LD-SDA) +-------------------------------------------------------- + +The GDPopt-LDSDA solver exploits the ordered Boolean variables in the disjunctions to solve the GDP model. +It requires an **exclusive OR (XOR) logical constraint** to ensure that exactly one disjunct is active in each disjunction. +The solver also requires a **starting point** for the discrete variables and allows users to choose between two **direction norms**, `'L2'` and `'Linf'`, to guide the search process. + +.. note:: + + The current implementation of the GDPopt-LDSDA requires an explicit LogicalConstraint to enforce the exclusive OR condition for each disjunction. + +To use the GDPopt-LDSDA solver, define your Pyomo GDP model as usual: + +.. doctest:: + :skipif: not baron_available + + Required imports + >>> import pyomo.environ as pyo + >>> from pyomo.gdp import Disjunct, Disjunction + + Create a simple model + >>> m = pyo.ConcreteModel() + + Define sets + >>> I = [1, 2, 3, 4, 5] + >>> J = [1, 2, 3, 4, 5] + + Define variables + >>> m.a = pyo.Var(bounds=(-0.3, 0.2)) + >>> m.b = pyo.Var(bounds=(-0.9, -0.5)) + + Define disjuncts for Y1 + >>> m.Y1_disjuncts = Disjunct(I) + >>> for i in I: + ... m.Y1_disjuncts[i].y1_constraint = pyo.Constraint(expr=m.a == -0.3 + 0.1 * (i - 1)) + + Define disjuncts for Y2 + >>> m.Y2_disjuncts = Disjunct(J) + >>> for j in J: + ... m.Y2_disjuncts[j].y2_constraint = pyo.Constraint(expr=m.b == -0.9 + 0.1 * (j - 1)) + + Define disjunctions + >>> m.y1_disjunction = Disjunction(expr=[m.Y1_disjuncts[i] for i in I]) + >>> m.y2_disjunction = Disjunction(expr=[m.Y2_disjuncts[j] for j in J]) + + Logical constraints to enforce exactly one selection + >>> m.Y1_limit = pyo.LogicalConstraint(expr=pyo.exactly(1, [m.Y1_disjuncts[i].indicator_var for i in I])) + >>> m.Y2_limit = pyo.LogicalConstraint(expr=pyo.exactly(1, [m.Y2_disjuncts[j].indicator_var for j in J])) + + Define objective function + >>> m.obj = pyo.Objective( + ... expr=4 * m.a**2 - 2.1 * m.a**4 + (1 / 3) * m.a**6 + m.a * m.b - 4 * m.b**2 + 4 * m.b**4, + ... sense=pyo.minimize + ... ) + + Invoke the GDPopt-LDSDA solver + >>> results = pyo.SolverFactory('gdpopt.ldsda').solve(m, + ... starting_point=[1,1], + ... logical_constraint_list=[m.Y1_limit, m.Y2_limit], + ... direction_norm='Linf', + ... ) GDPopt implementation and optional arguments -------------------------------------------- @@ -197,17 +260,12 @@ GDPopt implementation and optional arguments GDPopt optional arguments should be considered beta code and are subject to change. -.. autoclass:: pyomo.contrib.gdpopt.GDPopt.GDPoptSolver - :members: - -.. autoclass:: pyomo.contrib.gdpopt.loa.GDP_LOA_Solver - :members: - -.. autoclass:: pyomo.contrib.gdpopt.gloa.GDP_GLOA_Solver - :members: +.. autosummary:: -.. autoclass:: pyomo.contrib.gdpopt.ric.GDP_RIC_Solver - :members: + ~pyomo.contrib.gdpopt.GDPopt.GDPoptSolver + ~pyomo.contrib.gdpopt.loa.GDP_LOA_Solver + ~pyomo.contrib.gdpopt.gloa.GDP_GLOA_Solver + ~pyomo.contrib.gdpopt.ric.GDP_RIC_Solver + ~pyomo.contrib.gdpopt.branch_and_bound.GDP_LBB_Solver + ~pyomo.contrib.gdpopt.ldsda.GDP_LDSDA_Solver -.. autoclass:: pyomo.contrib.gdpopt.branch_and_bound.GDP_LBB_Solver - :members: diff --git a/doc/OnlineDocs/contributed_packages/gdpopt_flowchart.png b/doc/OnlineDocs/explanation/solvers/gdpopt_flowchart.png similarity index 100% rename from doc/OnlineDocs/contributed_packages/gdpopt_flowchart.png rename to doc/OnlineDocs/explanation/solvers/gdpopt_flowchart.png diff --git a/doc/OnlineDocs/explanation/solvers/index.rst b/doc/OnlineDocs/explanation/solvers/index.rst new file mode 100644 index 00000000000..a50a604f93e --- /dev/null +++ b/doc/OnlineDocs/explanation/solvers/index.rst @@ -0,0 +1,15 @@ +Solvers +======= + +.. toctree:: + :maxdepth: 2 + + persistent + gdpopt + pyros + mindtpy + mcpp + multistart + trustregion + pynumero/index + z3_interface diff --git a/doc/OnlineDocs/contributed_packages/mcpp.rst b/doc/OnlineDocs/explanation/solvers/mcpp.rst similarity index 99% rename from doc/OnlineDocs/contributed_packages/mcpp.rst rename to doc/OnlineDocs/explanation/solvers/mcpp.rst index 18cea7f9b20..b25868e37ad 100644 --- a/doc/OnlineDocs/contributed_packages/mcpp.rst +++ b/doc/OnlineDocs/explanation/solvers/mcpp.rst @@ -1,3 +1,5 @@ +.. _MC++: + MC++ Interface ============== diff --git a/doc/OnlineDocs/contributed_packages/mindtpy.rst b/doc/OnlineDocs/explanation/solvers/mindtpy.rst similarity index 90% rename from doc/OnlineDocs/contributed_packages/mindtpy.rst rename to doc/OnlineDocs/explanation/solvers/mindtpy.rst index a850a42c740..60a3f0a816a 100644 --- a/doc/OnlineDocs/contributed_packages/mindtpy.rst +++ b/doc/OnlineDocs/explanation/solvers/mindtpy.rst @@ -11,22 +11,21 @@ The following algorithms are currently available in MindtPy: - **Outer-Approximation (OA)** [`Duran & Grossmann, 1986`_] - **LP/NLP based Branch-and-Bound (LP/NLP BB)** [`Quesada & Grossmann, 1992`_] - **Extended Cutting Plane (ECP)** [`Westerlund & Petterson, 1995`_] -- **Global Outer-Approximation (GOA)** [`Kesavan & Allgor, 2004`_, `MC++`_] +- **Global Outer-Approximation (GOA)** [`Kesavan & Allgor, 2004`_] - **Regularized Outer-Approximation (ROA)** [`Bernal & Peng, 2021`_, `Kronqvist & Bernal, 2018`_] - **Feasibility Pump (FP)** [`Bernal & Vigerske, 2019`_, `Bonami & Cornuéjols, 2009`_] Usage and early implementation details for MindtPy can be found in the PSE 2018 paper Bernal et al., (`ref `_, -`preprint `_). +`preprint `_). This solver implementation has been developed by `David Bernal `_ and `Zedong Peng `_ as part of research efforts at the `Bernal Research Group -`_ and the `Grossmann Research Group `_ +`_ and the `Grossmann Research Group `_ at Purdue University and Carnegie Mellon University. .. _Duran & Grossmann, 1986: https://dx.doi.org/10.1007/BF02592064 .. _Westerlund & Petterson, 1995: http://dx.doi.org/10.1016/0098-1354(95)87027-X .. _Kesavan & Allgor, 2004: https://link.springer.com/article/10.1007/s10107-004-0503-1 -.. _MC++: https://pyomo.readthedocs.io/en/stable/contributed_packages/mcpp.html .. _Bernal & Peng, 2021: http://www.optimization-online.org/DB_HTML/2021/06/8452.html .. _Kronqvist & Bernal, 2018: https://link.springer.com/article/10.1007%2Fs10107-018-1356-3 .. _Bonami & Cornuéjols, 2009: https://link.springer.com/article/10.1007/s10107-008-0212-2 @@ -66,28 +65,28 @@ Usage of MindtPy to solve a convex MINLP Pyomo model involves: .. code:: - >>> SolverFactory('mindtpy').solve(model) + >>> pyo.SolverFactory('mindtpy').solve(model) An example which includes the modeling approach may be found below. .. doctest:: Required imports - >>> from pyomo.environ import * + >>> import pyomo.environ as pyo Create a simple model - >>> model = ConcreteModel() + >>> model = pyo.ConcreteModel() - >>> model.x = Var(bounds=(1.0,10.0),initialize=5.0) - >>> model.y = Var(within=Binary) + >>> model.x = pyo.Var(bounds=(1.0,10.0),initialize=5.0) + >>> model.y = pyo.Var(within=pyo.Binary) - >>> model.c1 = Constraint(expr=(model.x-4.0)**2 - model.x <= 50.0*(1-model.y)) - >>> model.c2 = Constraint(expr=model.x*log(model.x)+5.0 <= 50.0*(model.y)) + >>> model.c1 = pyo.Constraint(expr=(model.x-4.0)**2 - model.x <= 50.0*(1-model.y)) + >>> model.c2 = pyo.Constraint(expr=model.x*pyo.log(model.x)+5.0 <= 50.0*(model.y)) - >>> model.objective = Objective(expr=model.x, sense=minimize) + >>> model.objective = pyo.Objective(expr=model.x, sense=pyo.minimize) Solve the model using MindtPy - >>> SolverFactory('mindtpy').solve(model, mip_solver='glpk', nlp_solver='ipopt') # doctest: +SKIP + >>> pyo.SolverFactory('mindtpy').solve(model, mip_solver='glpk', nlp_solver='ipopt') # doctest: +SKIP The solution may then be displayed by using the commands @@ -104,13 +103,13 @@ The solution may then be displayed by using the commands .. code:: - >>> SolverFactory('mindtpy').solve(model, mip_solver='glpk', nlp_solver='ipopt', tee=True) + >>> pyo.SolverFactory('mindtpy').solve(model, mip_solver='glpk', nlp_solver='ipopt', tee=True) MindtPy also supports setting options for mip solvers and nlp solvers. .. code:: - >>> SolverFactory('mindtpy').solve(model, + >>> pyo.SolverFactory('mindtpy').solve(model, strategy='OA', time_limit=3600, mip_solver='gams', @@ -130,9 +129,8 @@ The LP/NLP based branch-and-bound algorithm in MindtPy is implemented based on t .. note:: - In Pyomo, `persistent solvers`_ are necessary to set or register callback functions. The single tree implementation currently only works with CPLEX and GUROBI, more exactly ``cplex_persistent`` and ``gurobi_persistent``. To use the `LazyConstraintCallback`_ function of CPLEX from Pyomo, the `CPLEX Python API`_ is required. This means both IBM ILOG CPLEX Optimization Studio and the CPLEX-Python modules should be installed on your computer. To use the `cbLazy`_ function of GUROBI from pyomo, `gurobipy`_ is required. + In Pyomo, :ref:`persistent solvers ` are necessary to set or register callback functions. The single tree implementation currently only works with CPLEX and GUROBI, more exactly ``cplex_persistent`` and ``gurobi_persistent``. To use the `LazyConstraintCallback`_ function of CPLEX from Pyomo, the `CPLEX Python API`_ is required. This means both IBM ILOG CPLEX Optimization Studio and the CPLEX-Python modules should be installed on your computer. To use the `cbLazy`_ function of GUROBI from pyomo, `gurobipy`_ is required. -.. _`persistent solvers`: https://pyomo.readthedocs.io/en/stable/advanced_topics/persistent_solvers.html?highlight=persistent .. _CPLEX Python API: https://www.ibm.com/docs/en/icos/20.1.0?topic=cplex-setting-up-python-api .. _gurobipy: https://www.gurobi.com/documentation/9.1/quickstart_mac/cs_grbpy_the_gurobi_python.html .. _LazyConstraintCallback: https://www.ibm.com/docs/en/icos/20.1.0?topic=classes-cplexcallbackslazyconstraintcallback @@ -257,7 +255,7 @@ Augmented Penalty refers to the introduction of (non-negative) slack variables o Global Outer-Approximation ^^^^^^^^^^^^^^^^^^^^^^^^^^ -Apart from the decomposition methods for convex MINLP problems [`Kronqvist et al., 2019`_], MindtPy provides an implementation of Global Outer Approximation (GOA) as described in [`Kesavan & Allgor, 2004`_], to provide optimality guaranteed for nonconvex MINLP problems. Here, the validity of the Mixed-integer Linear Programming relaxation of the original problem is guaranteed via the usage of Generalized McCormick envelopes, computed using the package `MC++`_. The NLP subproblems, in this case, need to be solved to global optimality, which can be achieved through global NLP solvers such as `BARON`_ or `SCIP`_. +Apart from the decomposition methods for convex MINLP problems [`Kronqvist et al., 2019`_], MindtPy provides an implementation of Global Outer Approximation (GOA) as described in [`Kesavan & Allgor, 2004`_], to provide optimality guaranteed for nonconvex MINLP problems. Here, the validity of the Mixed-integer Linear Programming relaxation of the original problem is guaranteed via the usage of Generalized McCormick envelopes, computed using the :ref:`interface to the MC++ package `. The NLP subproblems, in this case, need to be solved to global optimality, which can be achieved through global NLP solvers such as `BARON`_ or `SCIP`_. .. _BARON: https://minlp.com/baron-solver .. _SCIP: https://www.scipopt.org/ @@ -301,6 +299,7 @@ MindtPy Implementation and Optional Arguments subject to change. .. autoclass:: pyomo.contrib.mindtpy.MindtPy.MindtPySolver + :noindex: :members: Get Help diff --git a/doc/OnlineDocs/contributed_packages/multistart.rst b/doc/OnlineDocs/explanation/solvers/multistart.rst similarity index 70% rename from doc/OnlineDocs/contributed_packages/multistart.rst rename to doc/OnlineDocs/explanation/solvers/multistart.rst index 069d770aa91..39406a3bb6f 100644 --- a/doc/OnlineDocs/contributed_packages/multistart.rst +++ b/doc/OnlineDocs/explanation/solvers/multistart.rst @@ -14,21 +14,22 @@ To use the multistart solver, define your Pyomo model as usual: .. doctest:: Required import - >>> from pyomo.environ import * + >>> import pyomo.environ as pyo Create a simple model - >>> m = ConcreteModel() - >>> m.x = Var() - >>> m.y = Var() - >>> m.obj = Objective(expr=m.x**2 + m.y**2) - >>> m.c = Constraint(expr=m.y >= -2*m.x + 5) + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var() + >>> m.y = pyo.Var() + >>> m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + >>> m.c = pyo.Constraint(expr=m.y >= -2*m.x + 5) Invoke the multistart solver - >>> SolverFactory('multistart').solve(m) # doctest: +SKIP + >>> pyo.SolverFactory('multistart').solve(m) # doctest: +SKIP Multistart wrapper implementation and optional arguments -------------------------------------------------------- .. autoclass:: pyomo.contrib.multistart.multi.MultiStart + :noindex: :members: diff --git a/doc/OnlineDocs/advanced_topics/persistent_solvers.rst b/doc/OnlineDocs/explanation/solvers/persistent.rst similarity index 73% rename from doc/OnlineDocs/advanced_topics/persistent_solvers.rst rename to doc/OnlineDocs/explanation/solvers/persistent.rst index aebb0545dd0..e9680c641bb 100644 --- a/doc/OnlineDocs/advanced_topics/persistent_solvers.rst +++ b/doc/OnlineDocs/explanation/solvers/persistent.rst @@ -1,3 +1,5 @@ +.. _persistent_solvers: + Persistent Solvers ================== @@ -6,7 +8,7 @@ notify the solver of incremental changes to a Pyomo model. The persistent solver interfaces create and store model instances from the Python API for the corresponding solver. For example, the :class:`GurobiPersistent` -class maintaints a pointer to a gurobipy Model object. Thus, we can +class maintains a pointer to a gurobipy Model object. Thus, we can make small changes to the model and notify the solver rather than recreating the entire model using the solver Python API (or rewriting an entire model file - e.g., an lp file) every time the model is @@ -22,16 +24,16 @@ Using Persistent Solvers The first step in using a persistent solver is to create a Pyomo model as usual. ->>> import pyomo.environ as pe ->>> m = pe.ConcreteModel() ->>> m.x = pe.Var() ->>> m.y = pe.Var() ->>> m.obj = pe.Objective(expr=m.x**2 + m.y**2) ->>> m.c = pe.Constraint(expr=m.y >= -2*m.x + 5) +>>> import pyomo.environ as pyo +>>> m = pyo.ConcreteModel() +>>> m.x = pyo.Var() +>>> m.y = pyo.Var() +>>> m.obj = pyo.Objective(expr=m.x**2 + m.y**2) +>>> m.c = pyo.Constraint(expr=m.y >= -2*m.x + 5) You can create an instance of a persistent solver through the SolverFactory. ->>> opt = pe.SolverFactory('gurobi_persistent') # doctest: +SKIP +>>> opt = pyo.SolverFactory('gurobi_persistent') # doctest: +SKIP This returns an instance of :py:class:`GurobiPersistent`. Now we need to tell the solver about our model. @@ -46,7 +48,7 @@ variables and constraints. We can now solve the model. We can also add or remove variables, constraints, blocks, and objectives. For example, ->>> m.c2 = pe.Constraint(expr=m.y >= m.x) # doctest: +SKIP +>>> m.c2 = pyo.Constraint(expr=m.y >= m.x) # doctest: +SKIP >>> opt.add_constraint(m.c2) # doctest: +SKIP This tells the solver to add one new constraint but otherwise leave @@ -67,29 +69,29 @@ code will run without error, but the solver will have an extra constraint. The solver will have both y >= -2*x + 5 and y <= x, which is not what was intended! ->>> m = pe.ConcreteModel() # doctest: +SKIP ->>> m.x = pe.Var() # doctest: +SKIP ->>> m.y = pe.Var() # doctest: +SKIP ->>> m.c = pe.Constraint(expr=m.y >= -2*m.x + 5) # doctest: +SKIP ->>> opt = pe.SolverFactory('gurobi_persistent') # doctest: +SKIP +>>> m = pyo.ConcreteModel() # doctest: +SKIP +>>> m.x = pyo.Var() # doctest: +SKIP +>>> m.y = pyo.Var() # doctest: +SKIP +>>> m.c = pyo.Constraint(expr=m.y >= -2*m.x + 5) # doctest: +SKIP +>>> opt = pyo.SolverFactory('gurobi_persistent') # doctest: +SKIP >>> opt.set_instance(m) # doctest: +SKIP >>> # WRONG: >>> del m.c # doctest: +SKIP ->>> m.c = pe.Constraint(expr=m.y <= m.x) # doctest: +SKIP +>>> m.c = pyo.Constraint(expr=m.y <= m.x) # doctest: +SKIP >>> opt.add_constraint(m.c) # doctest: +SKIP The correct way to do this is: ->>> m = pe.ConcreteModel() # doctest: +SKIP ->>> m.x = pe.Var() # doctest: +SKIP ->>> m.y = pe.Var() # doctest: +SKIP ->>> m.c = pe.Constraint(expr=m.y >= -2*m.x + 5) # doctest: +SKIP ->>> opt = pe.SolverFactory('gurobi_persistent') # doctest: +SKIP +>>> m = pyo.ConcreteModel() # doctest: +SKIP +>>> m.x = pyo.Var() # doctest: +SKIP +>>> m.y = pyo.Var() # doctest: +SKIP +>>> m.c = pyo.Constraint(expr=m.y >= -2*m.x + 5) # doctest: +SKIP +>>> opt = pyo.SolverFactory('gurobi_persistent') # doctest: +SKIP >>> opt.set_instance(m) # doctest: +SKIP >>> # Correct: >>> opt.remove_constraint(m.c) # doctest: +SKIP >>> del m.c # doctest: +SKIP ->>> m.c = pe.Constraint(expr=m.y <= m.x) # doctest: +SKIP +>>> m.c = pyo.Constraint(expr=m.y <= m.x) # doctest: +SKIP >>> opt.add_constraint(m.c) # doctest: +SKIP .. warning:: Components removed from a pyomo model must be removed @@ -98,14 +100,14 @@ The correct way to do this is: Additionally, unexpected behavior may result if a component is modified before being removed. ->>> m = pe.ConcreteModel() # doctest: +SKIP ->>> m.b = pe.Block() # doctest: +SKIP ->>> m.b.x = pe.Var() # doctest: +SKIP ->>> m.b.y = pe.Var() # doctest: +SKIP ->>> m.b.c = pe.Constraint(expr=m.b.y >= -2*m.b.x + 5) # doctest: +SKIP ->>> opt = pe.SolverFactory('gurobi_persistent') # doctest: +SKIP +>>> m = pyo.ConcreteModel() # doctest: +SKIP +>>> m.b = pyo.Block() # doctest: +SKIP +>>> m.b.x = pyo.Var() # doctest: +SKIP +>>> m.b.y = pyo.Var() # doctest: +SKIP +>>> m.b.c = pyo.Constraint(expr=m.b.y >= -2*m.b.x + 5) # doctest: +SKIP +>>> opt = pyo.SolverFactory('gurobi_persistent') # doctest: +SKIP >>> opt.set_instance(m) # doctest: +SKIP ->>> m.b.c2 = pe.Constraint(expr=m.b.y <= m.b.x) # doctest: +SKIP +>>> m.b.c2 = pyo.Constraint(expr=m.b.y <= m.b.x) # doctest: +SKIP >>> # ERROR: The constraint referenced by m.b.c2 does not >>> # exist in the solver model. >>> opt.remove_block(m.b) # doctest: +SKIP @@ -115,12 +117,12 @@ the solver instance, modify it with Pyomo, and then add it back to the solver instance. The only exception is with variables. Variables may be modified and then updated with with solver: ->>> m = pe.ConcreteModel() # doctest: +SKIP ->>> m.x = pe.Var() # doctest: +SKIP ->>> m.y = pe.Var() # doctest: +SKIP ->>> m.obj = pe.Objective(expr=m.x**2 + m.y**2) # doctest: +SKIP ->>> m.c = pe.Constraint(expr=m.y >= -2*m.x + 5) # doctest: +SKIP ->>> opt = pe.SolverFactory('gurobi_persistent') # doctest: +SKIP +>>> m = pyo.ConcreteModel() # doctest: +SKIP +>>> m.x = pyo.Var() # doctest: +SKIP +>>> m.y = pyo.Var() # doctest: +SKIP +>>> m.obj = pyo.Objective(expr=m.x**2 + m.y**2) # doctest: +SKIP +>>> m.c = pyo.Constraint(expr=m.y >= -2*m.x + 5) # doctest: +SKIP +>>> opt = pyo.SolverFactory('gurobi_persistent') # doctest: +SKIP >>> opt.set_instance(m) # doctest: +SKIP >>> m.x.setlb(1.0) # doctest: +SKIP >>> opt.update_var(m.x) # doctest: +SKIP @@ -158,13 +160,13 @@ Persistent Solver Performance In order to get the best performance out of the persistent solvers, use the "save_results" flag: ->>> import pyomo.environ as pe ->>> m = pe.ConcreteModel() ->>> m.x = pe.Var() ->>> m.y = pe.Var() ->>> m.obj = pe.Objective(expr=m.x**2 + m.y**2) ->>> m.c = pe.Constraint(expr=m.y >= -2*m.x + 5) ->>> opt = pe.SolverFactory('gurobi_persistent') # doctest: +SKIP +>>> import pyomo.environ as pyo +>>> m = pyo.ConcreteModel() +>>> m.x = pyo.Var() +>>> m.y = pyo.Var() +>>> m.obj = pyo.Objective(expr=m.x**2 + m.y**2) +>>> m.c = pyo.Constraint(expr=m.y >= -2*m.x + 5) +>>> opt = pyo.SolverFactory('gurobi_persistent') # doctest: +SKIP >>> opt.set_instance(m) # doctest: +SKIP >>> results = opt.solve(save_results=False) # doctest: +SKIP diff --git a/doc/OnlineDocs/explanation/solvers/pynumero/backward_compatibility.rst b/doc/OnlineDocs/explanation/solvers/pynumero/backward_compatibility.rst new file mode 100644 index 00000000000..036a00bee62 --- /dev/null +++ b/doc/OnlineDocs/explanation/solvers/pynumero/backward_compatibility.rst @@ -0,0 +1,14 @@ +Backward Compatibility +====================== + +While PyNumero is a third-party contribution to Pyomo, we intend to maintain +the stability of its core functionality. The core functionality of PyNumero +consists of: + +1. The ``NLP`` API and ``PyomoNLP`` implementation of this API +2. HSL and MUMPS linear solver interfaces +3. ``BlockVector`` and ``BlockMatrix`` classes +4. CyIpopt and SciPy solver interfaces + +Other parts of PyNumero, such as ``ExternalGreyBoxBlock`` and +``ImplicitFunctionSolver``, are experimental and subject to change without notice. diff --git a/doc/OnlineDocs/contributed_packages/pynumero/index.rst b/doc/OnlineDocs/explanation/solvers/pynumero/index.rst similarity index 82% rename from doc/OnlineDocs/contributed_packages/pynumero/index.rst rename to doc/OnlineDocs/explanation/solvers/pynumero/index.rst index 6ff8b29f812..c0507e01db8 100644 --- a/doc/OnlineDocs/contributed_packages/pynumero/index.rst +++ b/doc/OnlineDocs/explanation/solvers/pynumero/index.rst @@ -5,14 +5,20 @@ PyNumero PyNumero is a package for developing parallel algorithms for nonlinear programs (NLPs). This documentation provides a brief introduction to -PyNumero. For more details, see the API documentation (:ref:`pynumero_api`). +PyNumero. For more details, see the :mod:`API documentation `). .. toctree:: :maxdepth: 2 installation.rst tutorial.rst - api.rst + backward_compatibility.rst + + +PyNumero API +------------ + +:mod:`pyomo.contrib.pynumero` Developers @@ -40,11 +46,3 @@ Papers utilizing PyNumero * Rodriguez, J. S., Laird, C. D., & Zavala, V. M. (2020). Scalable preconditioning of block-structured linear algebra systems using ADMM. Computers & Chemical Engineering, 133, 106478. - - -Indices and Tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/doc/OnlineDocs/contributed_packages/pynumero/installation.rst b/doc/OnlineDocs/explanation/solvers/pynumero/installation.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/pynumero/installation.rst rename to doc/OnlineDocs/explanation/solvers/pynumero/installation.rst diff --git a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.block_vectors_and_matrices.rst b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.block_vectors_and_matrices.rst similarity index 89% rename from doc/OnlineDocs/contributed_packages/pynumero/tutorial.block_vectors_and_matrices.rst rename to doc/OnlineDocs/explanation/solvers/pynumero/tutorial.block_vectors_and_matrices.rst index 1ce98ce4a63..a8a66e81a46 100644 --- a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.block_vectors_and_matrices.rst +++ b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.block_vectors_and_matrices.rst @@ -65,7 +65,7 @@ Once the dimensions of a block have been set, they cannot be changed: Properties: .. doctest:: - :skipif: not numpy_available or not scipy_available + :skipif: not scipy_available or int(np.__version__[0]) >= 2 >>> v.shape (5,) @@ -82,10 +82,29 @@ Properties: >>> m.nnz 12 +.. doctest:: + :hide: + :skipif: not scipy_available or int(np.__version__[0]) < 2 + + >>> v.shape + (np.int64(5),) + >>> v.size + np.int64(5) + >>> v.nblocks + 3 + >>> v.bshape + (3,) + >>> m.shape + (np.int64(5), np.int64(5)) + >>> m.bshape + (3, 3) + >>> m.nnz + 12 + Much of the `BlockVector` API matches that of NumPy arrays: .. doctest:: - :skipif: not numpy_available or not scipy_available + :skipif: not scipy_available or int(np.__version__[0]) >= 2 >>> v.sum() 0.62846552 @@ -100,6 +119,23 @@ Much of the `BlockVector` API matches that of NumPy arrays: >>> v.dot(v) 4.781303326558476 +.. doctest:: + :hide: + :skipif: not scipy_available or int(np.__version__[0]) < 2 + + >>> v.sum() + np.float64(0.62846552) + >>> v.max() + np.float64(1.25) + >>> np.abs(v).flatten() + array([0.67025575, 1.2 , 0.1 , 1.14872127, 1.25 ]) + >>> (2*v).flatten() + array([-1.3405115 , -2.4 , 0.2 , 2.29744254, 2.5 ]) + >>> (v + v).flatten() + array([-1.3405115 , -2.4 , 0.2 , 2.29744254, 2.5 ]) + >>> v.dot(v) + np.float64(4.781303326558476) + Similarly, `BlockMatrix` behaves very similarly to SciPy sparse matrices: .. doctest:: @@ -269,4 +305,4 @@ Nested blocks: Nested `BlockMatrix` applications work similarly. -For more information, see the API documentation (:ref:`pynumero_api`). +For more information, see the :mod:`API documentation `. diff --git a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.linear_solver_interfaces.rst b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.linear_solver_interfaces.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/pynumero/tutorial.linear_solver_interfaces.rst rename to doc/OnlineDocs/explanation/solvers/pynumero/tutorial.linear_solver_interfaces.rst diff --git a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.mpi_blocks.rst b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.mpi_blocks.rst similarity index 91% rename from doc/OnlineDocs/contributed_packages/pynumero/tutorial.mpi_blocks.rst rename to doc/OnlineDocs/explanation/solvers/pynumero/tutorial.mpi_blocks.rst index b9cb1d5db7a..e65d4da9c96 100644 --- a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.mpi_blocks.rst +++ b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.mpi_blocks.rst @@ -12,7 +12,7 @@ or all processes/ranks. Consider the following example (in a file called "parallel_vector_ops.py"). -.. literalinclude:: ../../../../pyomo/contrib/pynumero/examples/parallel_vector_ops.py +.. literalinclude:: /../../pyomo/contrib/pynumero/examples/parallel_vector_ops.py This example can be run with @@ -46,7 +46,7 @@ except that the operations are now performed in parallel. `MPIBlockMatrix` construction is very similar. Consider the following example in a file called "parallel_matvec.py". -.. literalinclude:: ../../../../pyomo/contrib/pynumero/examples/parallel_matvec.py +.. literalinclude:: /../../pyomo/contrib/pynumero/examples/parallel_matvec.py Which can be run with diff --git a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.nlp_interfaces.rst b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.nlp_interfaces.rst similarity index 86% rename from doc/OnlineDocs/contributed_packages/pynumero/tutorial.nlp_interfaces.rst rename to doc/OnlineDocs/explanation/solvers/pynumero/tutorial.nlp_interfaces.rst index 28818709330..13dd5d0e366 100644 --- a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.nlp_interfaces.rst +++ b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.nlp_interfaces.rst @@ -2,15 +2,15 @@ NLP Interfaces ============== Below are examples of using PyNumero's interfaces to ASL for function -and derivative evaluation. More information can be found in the API -documentation (:ref:`pynumero_api`). +and derivative evaluation. More information can be found in the +:mod:`API documentation `. Relevant imports .. doctest:: :skipif: not numpy_available or not scipy_available or not asl_available - >>> import pyomo.environ as pe + >>> import pyomo.environ as pyo >>> from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP >>> import numpy as np @@ -19,12 +19,12 @@ Create a Pyomo model .. doctest:: :skipif: not numpy_available or not scipy_available or not asl_available - >>> m = pe.ConcreteModel() - >>> m.x = pe.Var(bounds=(-5, None)) - >>> m.y = pe.Var(initialize=2.5) - >>> m.obj = pe.Objective(expr=m.x**2 + m.y**2) - >>> m.c1 = pe.Constraint(expr=m.y == (m.x - 1)**2) - >>> m.c2 = pe.Constraint(expr=m.y >= pe.exp(m.x)) + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var(bounds=(-5, None)) + >>> m.y = pyo.Var(initialize=2.5) + >>> m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + >>> m.c1 = pyo.Constraint(expr=m.y == (m.x - 1)**2) + >>> m.c2 = pyo.Constraint(expr=m.y >= pyo.exp(m.x)) Create a :py:class:`pyomo.contrib.pynumero.interfaces.pyomo_nlp.PyomoNLP` instance diff --git a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.rst b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/pynumero/tutorial.rst rename to doc/OnlineDocs/explanation/solvers/pynumero/tutorial.rst diff --git a/doc/OnlineDocs/contributed_packages/pyros.rst b/doc/OnlineDocs/explanation/solvers/pyros.rst similarity index 82% rename from doc/OnlineDocs/contributed_packages/pyros.rst rename to doc/OnlineDocs/explanation/solvers/pyros.rst index aad37a9685a..bc5f59e9a1c 100644 --- a/doc/OnlineDocs/contributed_packages/pyros.rst +++ b/doc/OnlineDocs/explanation/solvers/pyros.rst @@ -14,45 +14,47 @@ The developers gratefully acknowledge support from the U.S. Department of Energy Methodology Overview ----------------------------- -Below is an overview of the type of optimization models PyROS can accommodate. +PyROS can accommodate optimization models with: +* **Continuous variables** only +* **Nonlinearities** (including **nonconvexities**) in both the + variables and uncertain parameters +* **First-stage degrees of freedom** and **second-stage degrees of freedom** +* **Equality constraints** defining state variables, + including implicitly defined state variables that cannot be + eliminated from the model via reformulation +* **Inequality constraints** in the degree-of-freedom and/or state variables -* PyROS is suitable for optimization models of **continuous variables** - that may feature non-linearities (including **non-convexities**) in - both the variables and uncertain parameters. -* PyROS can handle **equality constraints** defining state variables, - including implicit state variables that cannot be eliminated via - reformulation. -* PyROS allows for **two-stage** optimization problems that may - feature both first-stage and second-stage degrees of freedom. - -PyROS is designed to operate on deterministic models of the general form +Supported deterministic models can be written in the general form .. _deterministic-model: .. math:: - \begin{array}{clll} + :nowrap: + + \[\begin{array}{clll} \displaystyle \min_{\substack{x \in \mathcal{X}, \\ z \in \mathbb{R}^{n_z}, y\in\mathbb{R}^{n_y}}} & ~~ f_1\left(x\right) + f_2(x,z,y; q^{\text{nom}}) & \\ \displaystyle \text{s.t.} & ~~ g_i(x, z, y; q^{\text{nom}}) \leq 0 & \forall\,i \in \mathcal{I} \\ & ~~ h_j(x,z,y; q^{\text{nom}}) = 0 & \forall\,j \in \mathcal{J} \\ - \end{array} + \end{array}\] where: -* :math:`x \in \mathcal{X}` are the "design" variables - (i.e., first-stage degrees of freedom), - where :math:`\mathcal{X} \subseteq \mathbb{R}^{n_x}` is the feasible space defined by the model constraints - (including variable bounds specifications) referencing :math:`x` only. -* :math:`z \in \mathbb{R}^{n_z}` are the "control" variables - (i.e., second-stage degrees of freedom) +* :math:`x \in \mathcal{X}` are the first-stage degrees of freedom, + (or "design" variables,) + of which the feasible space :math:`\mathcal{X} \subseteq \mathbb{R}^{n_x}` + is defined by the model constraints + (including variable bounds specifications) referencing :math:`x` only +* :math:`z \in \mathbb{R}^{n_z}` are the second-stage degrees of freedom + (or "control" variables) * :math:`y \in \mathbb{R}^{n_y}` are the "state" variables * :math:`q \in \mathbb{R}^{n_q}` is the vector of model parameters considered uncertain, and :math:`q^{\text{nom}}` is the vector of nominal values - associated with those. -* :math:`f_1\left(x\right)` are the terms of the objective function that depend + associated with those +* :math:`f_1\left(x\right)` is the summand of the objective function that depends only on design variables -* :math:`f_2\left(x, z, y; q\right)` are the terms of the objective function - that depend on all variables and the uncertain parameters +* :math:`f_2\left(x, z, y; q\right)` is the summand of the objective function + that depends on all variables and the uncertain parameters * :math:`g_i\left(x, z, y; q\right)` is the :math:`i^\text{th}` inequality constraint function in set :math:`\mathcal{I}` (see :ref:`Note `) @@ -63,23 +65,11 @@ where: .. _var-bounds-to-ineqs: .. note:: - PyROS accepts models in which bounds are directly imposed on - ``Var`` objects representing components of the variables :math:`z` - and :math:`y`. These models are cast to - :ref:`the form above ` - by reformulating the bounds as inequality constraints. - -.. _unique-mapping: + PyROS accepts models in which there are: -.. note:: - A key requirement of PyROS is that each value of :math:`\left(x, z, q \right)` - maps to a unique value of :math:`y`, a property that is assumed to - be properly enforced by the system of equality constraints - :math:`\mathcal{J}`. - If the mapping is not unique, then the selection of 'state' - (i.e., not degree of freedom) variables :math:`y` is incorrect, - and one or more of the :math:`y` variables should be appropriately - redesignated to be part of either :math:`x` or :math:`z`. + 1. Bounds declared on the ``Var`` objects representing + components of the variable vectors + 2. Ranged inequality constraints In order to cast the robust optimization counterpart of the :ref:`deterministic model `, @@ -89,21 +79,80 @@ any realization in a compact uncertainty set the nominal value :math:`q^{\text{nom}}`. The set :math:`\mathcal{Q}` may be **either continuous or discrete**. -Based on the above notation, the form of the robust counterpart addressed by PyROS is +Based on the above notation, +the form of the robust counterpart addressed by PyROS is .. math:: - \begin{array}{ccclll} + :nowrap: + + \[\begin{array}{ccclll} \displaystyle \min_{x \in \mathcal{X}} & \displaystyle \max_{q \in \mathcal{Q}} & \displaystyle \min_{\substack{z \in \mathbb{R}^{n_z},\\y \in \mathbb{R}^{n_y}}} \ \ & \displaystyle ~~ f_1\left(x\right) + f_2\left(x, z, y, q\right) \\ & & \text{s.t.}~ & \displaystyle ~~ g_i\left(x, z, y, q\right) \leq 0 & & \forall\, i \in \mathcal{I}\\ & & & \displaystyle ~~ h_j\left(x, z, y, q\right) = 0 & & \forall\,j \in \mathcal{J} - \end{array} + \end{array}\] + +PyROS accepts a deterministic model and accompanying uncertainty set +and then, using the Generalized Robust Cutting-Set algorithm developed +in [IAE+21]_, seeks a solution to the robust counterpart. +When using PyROS, please consider citing [IAE+21]_. + +.. _unique-mapping: + +.. note:: + A key assumption of PyROS is that + for every + :math:`x \in \mathcal{X}`, + :math:`z \in \mathbb{R}^{n_z}`, + :math:`q \in \mathcal{Q}`, + there exists a unique :math:`y \in \mathbb{R}^{n_y}` + for which :math:`(x, z, y, q)` + satisfies the equality constraints + :math:`h_j(x, z, y, q) = 0\,\,\forall\, j \in \mathcal{J}`. + If this assumption is not met, + then the selection of 'state' + (i.e., not degree of freedom) variables :math:`y` is incorrect, + and one or more of the :math:`y` variables should be appropriately + redesignated to be part of either :math:`x` or :math:`z`. + +PyROS Installation +----------------------------- +PyROS can be installed as follows: + +1. :ref:`Install Pyomo `. + PyROS is included in the Pyomo software package, at pyomo/contrib/pyros. +2. Install NumPy and SciPy with your preferred package manager; + both NumPy and SciPy are required dependencies of PyROS. + You may install NumPy and SciPy with, for example, ``conda``: + + :: + + conda install numpy scipy + + or ``pip``: + + :: -PyROS solves problems of this form using the -Generalized Robust Cutting-Set algorithm developed in [Isenberg_et_al]_. + pip install numpy scipy +3. (*Optional*) Test your installation: + install ``pytest`` and ``parameterized`` + with your preferred package manager (as in the previous step): + + :: + + pip install pytest parameterized + + You may then run the PyROS tests as follows: + + :: + + python -c 'import os, pytest, pyomo.contrib.pyros as p; pytest.main([os.path.dirname(p.__file__)])' + + Some tests involving solvers may fail or be skipped, + depending on the solver distributions (e.g., Ipopt, BARON, SCIP) + that you have pre-installed and licensed on your system. -When using PyROS, please consider citing the above paper. PyROS Required Inputs ----------------------------- @@ -128,16 +177,20 @@ These are more elaborately presented in the PyROS Solver Interface ----------------------------- +The PyROS solver is invoked through the +:py:meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` method. + .. autoclass:: pyomo.contrib.pyros.PyROS :members: solve + :noindex: .. note:: Upon successful convergence of PyROS, the solution returned is certified to be robust optimal only if: - 1. master problems are solved to global optimality + 1. Master problems are solved to global optimality (by specifying ``solve_master_globally=True``) - 2. a worst-case objective focus is chosen + 2. A worst-case objective focus is chosen (by specifying ``objective_focus=ObjectiveType.worst_case``) Otherwise, the solution returned is certified to only be robust feasible. @@ -211,52 +264,25 @@ the various abstract and pre-implemented PyROS Uncertainty Set Classes ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.BoxSet - :show-inheritance: - :special-members: bounds, type, parameter_bounds, dim, point_in_set - -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.CardinalitySet - :show-inheritance: - :special-members: origin, positive_deviation, gamma, type, parameter_bounds, dim, point_in_set - -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.BudgetSet - :show-inheritance: - :special-members: coefficients_mat, rhs_vec, origin, budget_membership_mat, budget_rhs_vec, type, parameter_bounds, dim, point_in_set - -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.FactorModelSet - :show-inheritance: - :special-members: origin, number_of_factors, psi_mat, beta, type, parameter_bounds, dim, point_in_set - -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.PolyhedralSet - :show-inheritance: - :special-members: coefficients_mat, rhs_vec, type, parameter_bounds, dim, point_in_set - -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.AxisAlignedEllipsoidalSet - :show-inheritance: - :special-members: center, half_lengths, type, parameter_bounds, dim, point_in_set +.. autosummary:: -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.EllipsoidalSet - :show-inheritance: - :special-members: center, shape_matrix, scale, type, parameter_bounds, dim, point_in_set - -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.UncertaintySet - :show-inheritance: - :special-members: parameter_bounds, dim, point_in_set - -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.DiscreteScenarioSet - :show-inheritance: - :special-members: scenarios, type, parameter_bounds, dim, point_in_set - -.. autoclass:: pyomo.contrib.pyros.uncertainty_sets.IntersectionSet - :show-inheritance: - :special-members: all_sets, type, parameter_bounds, dim, point_in_set + ~pyomo.contrib.pyros.uncertainty_sets.UncertaintySet + ~pyomo.contrib.pyros.uncertainty_sets.BoxSet + ~pyomo.contrib.pyros.uncertainty_sets.CardinalitySet + ~pyomo.contrib.pyros.uncertainty_sets.BudgetSet + ~pyomo.contrib.pyros.uncertainty_sets.FactorModelSet + ~pyomo.contrib.pyros.uncertainty_sets.PolyhedralSet + ~pyomo.contrib.pyros.uncertainty_sets.AxisAlignedEllipsoidalSet + ~pyomo.contrib.pyros.uncertainty_sets.EllipsoidalSet + ~pyomo.contrib.pyros.uncertainty_sets.DiscreteScenarioSet + ~pyomo.contrib.pyros.uncertainty_sets.IntersectionSet PyROS Usage Example ----------------------------- In this section, we illustrate the usage of PyROS with a modeling example. -The deterministic problem of interest is called *hydro* +The deterministic problem of interest is called *hydro* (available `here `_), a QCQP taken from the `GAMS Model Library `_. @@ -305,23 +331,30 @@ The deterministic Pyomo model for *hydro* is shown below. .. note:: Primitive data (Python literals) that have been hard-coded within a - deterministic model cannot be later considered uncertain, - unless they are first converted to ``Param`` objects within - the ``ConcreteModel`` object. - Furthermore, any ``Param`` object that is to be later considered - uncertain must have the property ``mutable=True``. + deterministic model (:class:`~pyomo.core.base.PyomoModel.ConcreteModel`) + cannot be later considered uncertain, + unless they are first converted to Pyomo + :class:`~pyomo.core.base.param.Param` instances declared on the + :class:`~pyomo.core.base.PyomoModel.ConcreteModel` object. + Furthermore, any :class:`~pyomo.core.base.param.Param` + object that is to be later considered uncertain must be instantiated + with the argument ``mutable=True``. .. note:: - In case modifying the ``mutable`` property inside the deterministic - model object itself is not straightforward in your context, - you may consider adding the following statement **after** + If specifying/modifying the ``mutable`` argument in the + :class:`~pyomo.core.base.param.Param` declarations + of your deterministic model source code + is not straightforward in your context, then + you may consider adding **after** the line ``import pyomo.environ as pyo`` but **before** defining the model - object: ``pyo.Param.DefaultMutable = True``. - For all ``Param`` objects declared after this statement, - the attribute ``mutable`` is set to ``True`` by default. - Hence, non-mutable ``Param`` objects are now declared by - explicitly passing the argument ``mutable=False`` to the - ``Param`` constructor. + object the statement: ``pyo.Param.DefaultMutable = True``. + For all :class:`~pyomo.core.base.param.Param` + objects declared after this statement, + the attribute ``mutable`` is set to True by default. + Hence, non-mutable :class:`~pyomo.core.base.param.Param` + objects are now declared by explicitly passing the argument + ``mutable=False`` to the :class:`~pyomo.core.base.param.Param` + constructor. .. doctest:: @@ -404,22 +437,37 @@ The deterministic Pyomo model for *hydro* is shown below. Step 2: Define the Uncertainty ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -First, we need to collect into a list those ``Param`` objects of our model -that represent potentially uncertain parameters. -For the purposes of our example, we shall assume uncertainty in the model -parameters ``[m.p[0], m.p[1], m.p[2], m.p[3]]``, for which we can -conveniently utilize the object ``m.p`` (itself an indexed ``Param`` object). +We first collect the components of our model that represent the +uncertain parameters. +In this example, we assume uncertainty in +the parameter objects ``m.p[0]``, ``m.p[1]``, ``m.p[2]``, and ``m.p[3]``. +Since these objects comprise the mutable :class:`~pyomo.core.base.param.Param` +object ``m.p``, we can conveniently specify: .. doctest:: - >>> # === Specify which parameters are uncertain === - >>> # We can pass IndexedParams this way to PyROS, - >>> # or as an expanded list per index - >>> uncertain_parameters = [m.p] + >>> uncertain_params = m.p + +Equivalently, we may instead set ``uncertain_params`` to +either ``[m.p]``, ``[m.p[0], m.p[1], m.p[2], m.p[3]]``, +or ``list(m.p.values())``. + +.. note:: + Any :class:`~pyomo.core.base.param.Param` object that is + to be considered uncertain by PyROS must have the property + ``mutable=True``. .. note:: - Any ``Param`` object that is to be considered uncertain by PyROS - must have the property ``mutable=True``. + PyROS also allows uncertain parameters to be implemented as + :class:`~pyomo.core.base.var.Var` objects declared on the + deterministic model. + This may be convenient for users transitioning to PyROS from + parameter estimation and/or uncertainty quantification workflows, + in which the uncertain parameters are + often represented by :class:`~pyomo.core.base.var.Var` objects. + Prior to invoking PyROS, + all such :class:`~pyomo.core.base.var.Var` objects should be fixed. + PyROS will seek to identify solutions that remain feasible for any realization of these parameters included in an uncertainty set. @@ -466,7 +514,7 @@ global NLP solver: .. note:: Additional NLP optimizers can be automatically used in the event the primary subordinate local or global optimizer passed - to the PyROS :meth:`~pyomo.contrib.pyros.PyROS.solve` method + to the PyROS :meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` method does not successfully solve a subproblem to an appropriate termination condition. These alternative solvers are provided through the optional keyword arguments ``backup_local_solvers`` and ``backup_global_solvers``. @@ -531,7 +579,7 @@ correspond to first-stage degrees of freedom. ... model=m, ... first_stage_variables=first_stage_variables, ... second_stage_variables=second_stage_variables, - ... uncertain_params=uncertain_parameters, + ... uncertain_params=uncertain_params, ... uncertainty_set=box_uncertainty_set, ... local_solver=local_solver, ... global_solver=global_solver, @@ -575,7 +623,7 @@ The :ref:`preceding code snippet ` demonstrates how to retrieve this information. If we pass ``load_solution=True`` (the default setting) -to the :meth:`~pyomo.contrib.pyros.PyROS.solve` method, +to the :meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` method, then the solution at which PyROS terminates will be loaded to the variables of the original deterministic model. Note that in the :ref:`preceding code snippet `, @@ -605,7 +653,7 @@ freedom are in fact second-stage degrees of freedom. PyROS handles second-stage degrees of freedom via the use of polynomial decision rules, of which the degree is controlled through the optional keyword argument ``decision_rule_order`` to the PyROS -:meth:`~pyomo.contrib.pyros.PyROS.solve` method. +:meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` method. In this example, we select affine decision rules by setting ``decision_rule_order=1``: @@ -624,7 +672,7 @@ In this example, we select affine decision rules by setting ... model=m, ... first_stage_variables=first_stage_variables, ... second_stage_variables=second_stage_variables, - ... uncertain_params=uncertain_parameters, + ... uncertain_params=uncertain_params, ... uncertainty_set=box_uncertainty_set, ... local_solver=local_solver, ... global_solver=global_solver, @@ -662,10 +710,10 @@ to an affine decision rule. Specifying Arguments Indirectly Through ``options`` """"""""""""""""""""""""""""""""""""""""""""""""""" Like other Pyomo solver interface methods, -:meth:`~pyomo.contrib.pyros.PyROS.solve` +:meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` provides support for specifying options indirectly by passing a keyword argument ``options``, whose value must be a :class:`dict` -mapping names of arguments to :meth:`~pyomo.contrib.pyros.PyROS.solve` +mapping names of arguments to :meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` to their desired values. For example, the ``solve()`` statement in the :ref:`two-stage problem snippet ` @@ -678,7 +726,7 @@ could have been equivalently written as: ... model=m, ... first_stage_variables=first_stage_variables, ... second_stage_variables=second_stage_variables, - ... uncertain_params=uncertain_parameters, + ... uncertain_params=uncertain_params, ... uncertainty_set=box_uncertainty_set, ... local_solver=local_solver, ... global_solver=global_solver, @@ -689,7 +737,7 @@ could have been equivalently written as: ... }, ... ) ============================================================================== - PyROS: The Pyomo Robust Optimization Solver. + PyROS: The Pyomo Robust Optimization Solver... ... ------------------------------------------------------------------------------ Robust optimal solution identified. @@ -744,7 +792,7 @@ instance and invoking the PyROS solver: ... model=m, ... first_stage_variables=first_stage_variables, ... second_stage_variables=second_stage_variables, - ... uncertain_params=uncertain_parameters, + ... uncertain_params=uncertain_params, ... uncertainty_set= box_uncertainty_set, ... local_solver=local_solver, ... global_solver=global_solver, @@ -799,7 +847,7 @@ PyROS Solver Log Output The PyROS solver log output is controlled through the optional ``progress_logger`` argument, itself cast to a standard Python logger (:py:class:`logging.Logger`) object -at the outset of a :meth:`~pyomo.contrib.pyros.PyROS.solve` call. +at the outset of a :meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` call. The level of detail of the solver log output can be adjusted by adjusting the level of the logger object; see :ref:`the following table `. @@ -836,11 +884,12 @@ for a basic tutorial, see the :doc:`logging HOWTO `. * Iteration log table * Termination details: message, timing breakdown, summary of statistics * - :py:obj:`logging.DEBUG` - - * Termination outcomes and summary of statistics for + - * Progress through the various preprocessing subroutines + * Termination outcomes and summary of statistics for every master feasility, master, and DR polishing problem * Progress updates for the separation procedure * Separation subproblem initial point infeasibilities - * Summary of separation loop outcomes: performance constraints + * Summary of separation loop outcomes: second-stage inequality constraints violated, uncertain parameter scenario added to the master problem * Uncertain parameter scenarios added to the master problem @@ -861,12 +910,23 @@ Observe that the log contains the following information: * **Preprocessing information** (lines 39--41). Wall time required for preprocessing the deterministic model and associated components, - i.e. standardizing model components and adding the decision rule + i.e., standardizing model components and adding the decision rule variables and equations. * **Model component statistics** (lines 42--58). Breakdown of model component statistics. Includes components added by PyROS, such as the decision rule variables and equations. + The preprocessor may find that some second-stage variables + and state variables are mathematically + not adjustable to the uncertain parameters. + To this end, in the logs, the numbers of + adjustable second-stage variables and state variables + are included in parentheses, next to the total numbers + of second-stage variables and state variables, respectively; + note that "adjustable" has been abbreviated as "adj." + The number of truly uncertain parameters detected during preprocessing + is also noted in parentheses + (in which "eff." is an abbreviation for "effective"). * **Iteration log table** (lines 59--69). Summary information on the problem iterates and subproblem outcomes. The constituent columns are defined in detail in @@ -903,16 +963,16 @@ Observe that the log contains the following information: :linenos: ============================================================================== - PyROS: The Pyomo Robust Optimization Solver, v1.2.9. - Pyomo version: 6.7.0 - Commit hash: unknown - Invoked at UTC 2023-12-16T00:00:00.000000 + PyROS: The Pyomo Robust Optimization Solver, v1.3.6. + Pyomo version: 6.9.2 + Commit hash: 41cd797e0 + Invoked at UTC 2025-03-13T16:20:31.105320+00:00 Developed by: Natalie M. Isenberg (1), Jason A. F. Sherman (1), John D. Siirola (2), Chrysanthos E. Gounaris (1) (1) Carnegie Mellon University, Department of Chemical Engineering (2) Sandia National Laboratories, Center for Computing Research - + The developers gratefully acknowledge support from the U.S. Department of Energy's Institute for the Design of Advanced Energy Systems (IDAES). ============================================================================== @@ -926,6 +986,7 @@ Observe that the log contains the following information: keepfiles=False tee=False load_solution=True + symbolic_solver_labels=False objective_focus= nominal_uncertain_param_vals=[0.13248000000000001, 4.97, 4.97, 1800] decision_rule_order=1 @@ -942,55 +1003,56 @@ Observe that the log contains the following information: p_robustness={} ------------------------------------------------------------------------------ Preprocessing... - Done preprocessing; required wall time of 0.175s. + Done preprocessing; required wall time of 0.013s. ------------------------------------------------------------------------------ - Model statistics: + Model Statistics: Number of variables : 62 Epigraph variable : 1 First-stage variables : 7 - Second-stage variables : 6 - State variables : 18 + Second-stage variables : 6 (6 adj.) + State variables : 18 (7 adj.) Decision rule variables : 30 - Number of uncertain parameters : 4 - Number of constraints : 81 + Number of uncertain parameters : 4 (4 eff.) + Number of constraints : 52 Equality constraints : 24 Coefficient matching constraints : 0 + Other first-stage equations : 10 + Second-stage equations : 8 Decision rule equations : 6 - All other equality constraints : 18 - Inequality constraints : 57 - First-stage inequalities (incl. certain var bounds) : 10 - Performance constraints (incl. var bounds) : 47 + Inequality constraints : 28 + First-stage inequalities : 1 + Second-stage inequalities : 27 ------------------------------------------------------------------------------ Itn Objective 1-Stg Shift 2-Stg Shift #CViol Max Viol Wall Time (s) ------------------------------------------------------------------------------ - 0 3.5838e+07 - - 5 1.8832e+04 1.741 - 1 3.5838e+07 3.5184e-15 3.9404e-15 10 4.2516e+06 3.766 - 2 3.5993e+07 1.8105e-01 7.1406e-01 13 5.2004e+06 6.288 - 3 3.6285e+07 5.1968e-01 7.7753e-01 4 1.7892e+04 8.247 - 4 3.6285e+07 9.1166e-13 1.9702e-15 0 7.1157e-10g 11.456 + 0 3.5838e+07 - - 5 1.8832e+04 0.693 + 1 3.5838e+07 1.2289e-09 1.5876e-12 5 3.7762e+04 1.514 + 2 3.6129e+07 2.7244e-01 3.6878e-01 3 1.1093e+02 2.486 + 3 3.6269e+07 3.7352e-01 4.3227e-01 1 2.7711e+01 3.667 + 4 3.6285e+07 7.6526e-01 2.8426e-11 0 4.3364e-05g 6.291 ------------------------------------------------------------------------------ Robust optimal solution identified. ------------------------------------------------------------------------------ Timing breakdown: - + Identifier ncalls cumtime percall % ----------------------------------------------------------- - main 1 11.457 11.457 100.0 + main 1 6.291 6.291 100.0 ------------------------------------------------------ - dr_polishing 4 0.682 0.171 6.0 - global_separation 47 1.109 0.024 9.7 - local_separation 235 5.810 0.025 50.7 - master 5 1.353 0.271 11.8 - master_feasibility 4 0.247 0.062 2.2 - preprocessing 1 0.429 0.429 3.7 - other n/a 1.828 n/a 16.0 + dr_polishing 4 0.334 0.083 5.3 + global_separation 27 0.954 0.035 15.2 + local_separation 135 3.046 0.023 48.4 + master 5 1.027 0.205 16.3 + master_feasibility 4 0.133 0.033 2.1 + preprocessing 1 0.013 0.013 0.2 + other n/a 0.785 n/a 12.5 ====================================================== =========================================================== - + ------------------------------------------------------------------------------ Termination stats: Iterations : 5 - Solve time (wall s) : 11.457 + Solve time (wall s) : 6.291 Final objective value : 3.6285e+07 Termination condition : pyrosTerminationCondition.robust_optimal ------------------------------------------------------------------------------ @@ -1048,10 +1110,10 @@ The constituent columns are defined in the there are no second-stage variables, or the master problem of the current iteration is not solved successfully. * - #CViol - - Number of performance constraints found to be violated during + - Number of second-stage inequality constraints found to be violated during the separation step of the current iteration. - Unless a custom prioritization of the model's performance constraints - is specified (through the ``separation_priority_order`` argument), + Unless a custom prioritization of the model's second-stage inequality + constraints is specified (through the ``separation_priority_order`` argument), expect this number to trend downward as the iteration number increases. A "+" is appended if not all of the separation problems were solved successfully, either due to custom prioritization, a time out, @@ -1059,13 +1121,13 @@ The constituent columns are defined in the A dash ("-") is produced in lieu of a value if the separation routine is not invoked during the current iteration. * - Max Viol - - Maximum scaled performance constraint violation. + - Maximum scaled second-stage inequality constraint violation. Expect this value to trend downward as the iteration number increases. A 'g' is appended to the value if the separation problems were solved globally during the current iteration. A dash ("-") is produced in lieu of a value if the separation routine is not invoked during the current iteration, or if there are - no performance constraints. + no second-stage inequality constraints. * - Wall time (s) - Total time elapsed by the solver, in seconds, up to the end of the current iteration. diff --git a/doc/OnlineDocs/contributed_packages/trustregion.rst b/doc/OnlineDocs/explanation/solvers/trustregion.rst similarity index 91% rename from doc/OnlineDocs/contributed_packages/trustregion.rst rename to doc/OnlineDocs/explanation/solvers/trustregion.rst index f477c905e33..234d05f2a54 100644 --- a/doc/OnlineDocs/contributed_packages/trustregion.rst +++ b/doc/OnlineDocs/explanation/solvers/trustregion.rst @@ -31,11 +31,13 @@ Methodology Overview The formulation of the original hybrid problem is: .. math:: - \begin{align*} - \displaystyle \min_{} & ~~ f\left(z, w, d\left(w\right)\right) & \\ - \displaystyle \text{s.t.} \quad \: & ~~ h\left(z, w, d\left(w\right)\right) = 0 \\ - \displaystyle & ~~ g\left(z, w, d\left(w\right)\right) \leq 0 - \end{align*} + :nowrap: + + \[\begin{array}{ll} + \min_{} & f\left(z, w, d\left(w\right)\right) \\ + \text{s.t.} & h\left(z, w, d\left(w\right)\right) = 0 \\ + & g\left(z, w, d\left(w\right)\right) \leq 0 + \end{array}\] where: @@ -48,12 +50,14 @@ This formulation is reworked to separate all external function information as follows to enable the usage of the trust region method: .. math:: - \begin{align*} - \displaystyle \min_{x} & ~~ f\left(x\right) & \\ - \displaystyle \text{s.t.} \quad \: & ~~ h\left(x\right) = 0 \\ - \displaystyle & ~~ g\left(x\right) \leq 0 \\ - \displaystyle & ~~ y = d\left(w\right) - \end{align*} + :nowrap: + + \[\begin{array}{ll} + \min_{x} & f\left(x\right) \\ + \text{s.t.} & h\left(x\right) = 0 \\ + & g\left(x\right) \leq 0 \\ + & y = d\left(w\right) + \end{array}\] where: @@ -65,9 +69,7 @@ Using this formulation and a user-supplied low-fidelity/ideal model basis functi the surrogate model: .. math:: - \begin{align*} r_k\left(w\right) = b\left(w\right) + \left( d\left(w_k\right) - b\left(w_k\right) \right) + \left( \nabla d\left(w_k\right) - \nabla b\left(w_k\right) \right)^T \left( w - w_k \right) - \end{align*} This acts similarly to Newton's method in that small, incremental steps are taken towards an optimal solution. At each iteration, the current solution of the @@ -102,11 +104,12 @@ TRF Solver Interface The keyword arguments can be updated at solver instantiation or later when the ``solve`` method is called. .. autoclass:: pyomo.contrib.trustregion.TRF.TrustRegionSolver + :noindex: :members: solve TRF Usage Example ------------------ -Two examples can be found in the examples_ subdirectory. One of them is +Two examples can be found in the examples_ subdirectory. One of them is implemented below. .. _examples: https://github.com/Pyomo/pyomo/tree/main/pyomo/contrib/trustregion/examples @@ -143,14 +146,14 @@ Step 2: Create the model ... m.z = pyo.Var(range(3), domain=pyo.Reals, initialize=2.) ... m.x = pyo.Var(range(2), initialize=2.) ... m.x[1] = 1.0 - ... + ... ... m.ext_fcn = pyo.ExternalFunction(ext_fcn, grad_ext_fcn) - ... + ... ... m.obj = pyo.Objective( ... expr=(m.z[0]-1.0)**2 + (m.z[0]-m.z[1])**2 + (m.z[2]-1.0)**2 \ ... + (m.x[0]-1.0)**4 + (m.x[1]-1.0)**6 ... ) - ... + ... ... m.c1 = pyo.Constraint( ... expr=m.x[0] * m.z[0]**2 + m.ext_fcn(m.x[0], m.x[1]) == 2*pyo.sqrt(2.0) ... ) diff --git a/doc/OnlineDocs/contributed_packages/satsolver.rst b/doc/OnlineDocs/explanation/solvers/z3_interface.rst similarity index 77% rename from doc/OnlineDocs/contributed_packages/satsolver.rst rename to doc/OnlineDocs/explanation/solvers/z3_interface.rst index 4cac9170b55..5bd2cb3e7b5 100644 --- a/doc/OnlineDocs/contributed_packages/satsolver.rst +++ b/doc/OnlineDocs/explanation/solvers/z3_interface.rst @@ -19,15 +19,15 @@ To use the sat solver define your pyomo model as usual: .. doctest:: Required import - >>> from pyomo.environ import * + >>> import pyomo.environ as pyo >>> from pyomo.contrib.satsolver.satsolver import SMTSatSolver Create a simple model - >>> m = ConcreteModel() - >>> m.x = Var() - >>> m.y = Var() - >>> m.obj = Objective(expr=m.x**2 + m.y**2) - >>> m.c = Constraint(expr=m.y >= -2*m.x + 5) + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var() + >>> m.y = pyo.Var() + >>> m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + >>> m.c = pyo.Constraint(expr=m.y >= -2*m.x + 5) Invoke the sat solver using optional argument model to automatically process pyomo model diff --git a/doc/OnlineDocs/ext/pyomo_autosummary_autoenum.py b/doc/OnlineDocs/ext/pyomo_autosummary_autoenum.py new file mode 100644 index 00000000000..084e47e977a --- /dev/null +++ b/doc/OnlineDocs/ext/pyomo_autosummary_autoenum.py @@ -0,0 +1,244 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +"""Custom Sphinx autodoc plugin for improved rendering of Enum types +that plays nicely with sphinx.ext.autosummary. + +""" +import enum +import inspect +import re +import sphinx.locale + +from sphinx.application import Sphinx +from sphinx.domains import ObjType +from sphinx.domains.python import PyClasslike, PyAttribute, PyXRefRole +from sphinx.ext import autodoc, autosummary +from sphinx.ext.autosummary import mangle_signature as _msig +from sphinx.ext.autosummary.generate import generate_autosummary_content as _gac +from sphinx.util.inspect import object_description +from sphinx_toolbox.more_autodoc.typehints import format_annotation +from typing import Type, Any, Dict, List, Tuple, Union + +_pre_re = re.compile(r'^( = )(.*)') + + +def _mangle_signature(sig: str, max_chars: int = 30) -> str: + """Override sphinx.ext.autosummary.mangle_signature() so we can exploit + it to emit the enum member value using the sig field. We overwrite + mangle_signature to not return '({sig})' when the sig starts with + ' = ' + + """ + m = _pre_re.match(sig) + if m: + # Keep any initial whitespace; remove the parent that + # mangle_signature adds + return m.group(1) + _msig(m.group(2), max_chars)[1:-1] + return _msig(sig, max_chars) + + +def _generate_autosummary_content( + name: str, + obj: Any, + parent: Any, + template: autosummary.generate.AutosummaryRenderer, + *gac_args, + **gac_kwargs, +) -> str: + """Override sphinx.ext.autosummary.generate.generate_autosummary_content() + to provide additional fields to the namespace dictionary. + + This allows us to create templates that itemize Enums separately + from attributes. Because we want to insert ourselves into the + *middle* of the original function (between the point where the + template namespace (``ns``) is set up and when it is rendered, we + will actually overload the template.render() method and pass the + modified template into the original generate_autosummary_content + function. + + """ + if template.__class__.__name__ != '_pyomo_template_wrapper': + + class _pyomo_template_wrapper(template.__class__): + """Wrap the provided template object so we can add fields to ns before + calling the original render method. + + """ + + def render(self, name, ns): + # Overload render() so that we can intercept calls to it + # and add additional fields to the NS. Note that we + # need variables from the generate_autosummary_content + # context ... but we know that context is the calling + # frame. Seems like cheating, but it works. + if ns['objtype'] not in ('module', 'enum'): + return super().render(name, ns) + + caller = inspect.currentframe().f_back + l = caller.f_locals + doc = l['doc'] + obj = l['obj'] + args = {'obj': obj} + if '_get_members' in caller.f_globals: + _get_members = caller.f_globals['_get_members'] + if 'config' in l: + # Sphinx >= 8.2.1 + for field in ('config', 'doc', 'events', 'registry'): + args[field] = l[field] + else: + # Sphinx >= 7.2 + args.update({'doc': doc, 'app': l['app']}) + else: + # Sphinx < 7.2 + _get_members = caller.f_locals['get_members'] + + if ns['objtype'] == 'module': + ns['enums'], ns['all_enums'] = _get_members( + types={'enum'}, imported=l['imported_members'], **args + ) + elif ns['objtype'] == 'enum': + ns['members'] = dir(obj) + ns['inherited_members'] = set(dir(obj)) - set(obj.__dict__.keys()) + try: + # We need _get_members to eventually call + # _get_class_members, so we will (temporarily) + # set the doc.objtype back to "class" + doc.objtype = 'class' + ns['methods'], ns['all_methods'] = _get_members( + types={'method'}, include_public={'__init__'}, **args + ) + ns['attributes'], ns['all_attributes'] = _get_members( + types={'attribute', 'property'}, **args + ) + ns['enum_members'], ns['all_enum_members'] = _get_members( + types={'enum_member'}, **args + ) + finally: + doc.objtype = 'enum' + + mro = obj.__mro__ + for _base in mro[: mro.index(enum.Enum)]: + if not isinstance(_base, enum.EnumMeta): + ns['member_type'] = ( + f"Member type: {format_annotation(_base)}" + ) + break + return super().render(name, ns) + + template.__class__ = _pyomo_template_wrapper + + return _gac(name, obj, parent, template, *gac_args, **gac_kwargs) + + +class EnumDocumenter(autodoc.ClassDocumenter): + objtype = "enum" + + # More than Class; less than Exception + priority = autodoc.ClassDocumenter.priority + 1 + + member_order = 15 + + @classmethod + def can_document_member(cls, member, membername, isattr, parent): + return isinstance(member, enum.EnumMeta) + + def format_signature(self, **kwargs: Any) -> str: + # hard-code the enum signature. This is mostly to preserve the + # behavior from enum-tools.autoenum. We might want to revisit + # this decision later. + return "(value)" + + def sort_members( + self, documenters: List[Tuple[autodoc.Documenter, bool]], order: str + ) -> List[Tuple[autodoc.Documenter, bool]]: + if order != 'groupwise': + return super().sort_members(documenters, order) + # If we are grouping the members, then we want the groups + # alphabetical, *except* for the Enum members, which we want in + # declaration order: + mo = EnumDocumenter.member_order + tmp = [ + (e, (e[0].member_order, (i if e[0].member_order == mo else e[0].name))) + for i, e in enumerate(documenters) + ] + tmp.sort(key=lambda x: x[1]) + return [x[0] for x in tmp] + + +class EnumMemberDocumenter(autodoc.AttributeDocumenter): + """Custom documenter for Enum members""" + + # Note that we want to flag these attributes as "special" (i.e., not + # generic attributes, but we still want to emit regular py:attribute + # directives (so that we still refer to them with :py:attr: + # references). + objtype = "enum_member" + directivetype = autodoc.AttributeDocumenter.objtype + + # More than AttributeDocumenter + priority = autodoc.AttributeDocumenter.priority + 3 + + member_order = 15 + + @classmethod + def can_document_member(cls, member, membername, isattr, parent): + return isinstance(member, enum.Enum) + + def format_signature(self, **kwargs: Any) -> str: + """Custom format_signature() to return the value as the signature + + This has the effect of putting the value in the autosummary documentation. + """ + return " = " + object_description(self.object) + + def add_directive_header(self, sig): + """Custom add_directive_header to remove the enum value + + This undoes the effect of format_signature so that the entry + renders correctly (and ``:py:enum:`` links will be generated and + resolved correctly). + + """ + super().add_directive_header(sig.split(" = ", 1)[0].strip()) + + +def setup(app: Sphinx) -> Dict[str, Any]: + app.setup_extension('sphinx.ext.autodoc') + app.setup_extension('sphinx.ext.autosummary') + # Overwrite key parts of autosummary so that our version of autoenum + # plays nicely with it. We have tested this with Sphinx>7.2. + # Notably, 7.1.2 does NOT work (and cannot be easily made to work) + if 'generate_autosummary_content' not in dir(autosummary.generate): + raise RuntimeError( + "pyomo_autosummary_autoenum: Could not locate " + "autosummary.generate.generate_autosummary_content() " + "(possible incompatible Sphinx version)." + ) + autosummary.generate.generate_autosummary_content = _generate_autosummary_content + if 'mangle_signature' not in dir(autosummary): + raise RuntimeError( + "pyomo_autosummary_autoenum: Could not locate " + "autosummary.mangle_signature() " + "(possible incompatible Sphinx version)." + ) + autosummary.mangle_signature = _mangle_signature + + app.add_autodocumenter(EnumMemberDocumenter) + app.add_autodocumenter(EnumDocumenter) + + app.add_directive_to_domain("py", "enum", PyClasslike) + app.add_role_to_domain("py", "enum", PyXRefRole()) + app.registry.domains["py"].object_types["enum"] = ObjType( + sphinx.locale._("enum"), "enum", "class", "obj" + ) + + return {"version": '0.0.0', "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/doc/OnlineDocs/ext/pyomo_tocref.py b/doc/OnlineDocs/ext/pyomo_tocref.py new file mode 100644 index 00000000000..1f370578520 --- /dev/null +++ b/doc/OnlineDocs/ext/pyomo_tocref.py @@ -0,0 +1,58 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +"""Custom Sphinx autodoc plugin to generate TOC list-of-lists without +creating duplicate TOC entries + +""" + +from docutils import nodes +from sphinx.directives.other import TocTree +from sphinx.environment.adapters.toctree import TocTree as TocTree_Adapter + + +def setup(app): + app.connect('doctree-resolved', expand_tocrefs) + app.add_directive('tocref', TocRef) + return {'version': '0.0.0'} + + +class tocref(nodes.Element): + pass + + +class TocRef(TocTree): + tagname = 'tocref' + + def run(self): + wrapped_toc = super().run()[0] + assert len(wrapped_toc.children) == 1 + toc = wrapped_toc.children[0] + node = tocref() + node.attributes = toc.attributes + node.children = toc.children + self.set_source_info(node) + self.add_name(node) + return [node] + + +def expand_tocrefs(app, doctree, docname): + toctree_adapter = TocTree_Adapter(app.env) + for node in doctree.findall(tocref): + node.replace_self( + toctree_adapter.resolve( + docname, + app.builder, + node, + maxdepth=node['maxdepth'], + titles_only=node['titlesonly'], + ) + ) diff --git a/doc/OnlineDocs/getting_started/index.rst b/doc/OnlineDocs/getting_started/index.rst new file mode 100644 index 00000000000..45b8bec019c --- /dev/null +++ b/doc/OnlineDocs/getting_started/index.rst @@ -0,0 +1,8 @@ +Getting Started +=============== + +.. toctree:: + :maxdepth: 2 + + installation.rst + pyomo_overview/index.rst diff --git a/doc/OnlineDocs/installation.rst b/doc/OnlineDocs/getting_started/installation.rst similarity index 65% rename from doc/OnlineDocs/installation.rst rename to doc/OnlineDocs/getting_started/installation.rst index ecba05e13fb..5d2abe3c191 100644 --- a/doc/OnlineDocs/installation.rst +++ b/doc/OnlineDocs/getting_started/installation.rst @@ -1,9 +1,11 @@ +.. _pyomo_installation: + Installation ------------ Pyomo currently supports the following versions of Python: -* CPython: 3.8, 3.9, 3.10, 3.11, 3.12 +* CPython: 3.9, 3.10, 3.11, 3.12, 3.13 * PyPy: 3 At the time of the first Pyomo release after the end-of-life of a minor Python @@ -12,7 +14,7 @@ version, Pyomo will remove testing for that Python version. Using CONDA ~~~~~~~~~~~ -We recommend installation with *conda*, which is included with the +We recommend installation with ``conda``, which is included with the Anaconda distribution of Python. You can install Pyomo in your system Python installation by executing the following in a shell: @@ -21,7 +23,7 @@ Python installation by executing the following in a shell: conda install -c conda-forge pyomo Optimization solvers are not installed with Pyomo, but some open source -optimization solvers can be installed with conda as well: +optimization solvers can be installed with ``conda`` as well: :: @@ -31,7 +33,7 @@ optimization solvers can be installed with conda as well: Using PIP ~~~~~~~~~ -The standard utility for installing Python packages is *pip*. You +The standard utility for installing Python packages is ``pip``. You can install Pyomo in your system Python installation by executing the following in a shell: @@ -43,14 +45,14 @@ the following in a shell: Conditional Dependencies ~~~~~~~~~~~~~~~~~~~~~~~~ -Extensions to Pyomo, and many of the contributions in `pyomo.contrib`, +Extensions to Pyomo, and many of the contributions in ``pyomo.contrib``, often have conditional dependencies on a variety of third-party Python packages including but not limited to: matplotlib, networkx, numpy, openpyxl, pandas, pint, pymysql, pyodbc, pyro4, scipy, sympy, and xlrd. A full list of conditional dependencies can be found in Pyomo's -`setup.py` and displayed using: +``setup.py`` and displayed using: :: @@ -72,3 +74,28 @@ with the standard Anaconda installation. You can check which Python packages you have installed using the command ``conda list`` or ``pip list``. Additional Python packages may be installed as needed. + + +Installation with Cython +~~~~~~~~~~~~~~~~~~~~~~~~ + +Users can opt to install Pyomo with +`cython `_ +initialized. + +.. note:: + This can only be done via ``pip`` or from source. + +Via ``pip``: + +:: + + pip install pyomo --global-option="--with-cython" + +From source (recommended for advanced users only): + +:: + + git clone https://github.com/Pyomo/pyomo.git + cd pyomo + python setup.py install --with-cython diff --git a/doc/OnlineDocs/pyomo_overview/abstract_concrete.rst b/doc/OnlineDocs/getting_started/pyomo_overview/abstract_concrete.rst similarity index 86% rename from doc/OnlineDocs/pyomo_overview/abstract_concrete.rst rename to doc/OnlineDocs/getting_started/pyomo_overview/abstract_concrete.rst index 24f2f2b8187..da2962a0acd 100644 --- a/doc/OnlineDocs/pyomo_overview/abstract_concrete.rst +++ b/doc/OnlineDocs/getting_started/pyomo_overview/abstract_concrete.rst @@ -9,11 +9,13 @@ values. For example, the following equations represent a linear program .. math:: :nowrap: + \[ \begin{array}{lll} - \min & \sum_{j=1}^n c_j x_j &\\ - \mathrm{s.t.} & \sum_{j=1}^n a_{ij} x_j \geq b_i & \forall i = 1 \ldots m\\ - & x_j \geq 0 & \forall j = 1 \ldots n - \end{array} + \min & \sum_{j=1}^n c_j x_j &\\ + \mathrm{s.t.} & \sum_{j=1}^n a_{ij} x_j \geq b_i & \forall i = 1 \ldots m\\ + & x_j \geq 0 & \forall j = 1 \ldots n + \end{array} + \] .. note:: @@ -36,11 +38,13 @@ model: .. math:: :nowrap: + \[ \begin{array}{ll} - \min & 2 x_1 + 3 x_2\\ + \min & 2 x_1 + 3 x_2\\ \mathrm{s.t.} & 3 x_1 + 4 x_2 \geq 1\\ & x_1, x_2 \geq 0 \end{array} + \] The ``ConcreteModel`` class is used to define concrete optimization models in Pyomo. diff --git a/doc/OnlineDocs/pyomo_overview/index.rst b/doc/OnlineDocs/getting_started/pyomo_overview/index.rst similarity index 100% rename from doc/OnlineDocs/pyomo_overview/index.rst rename to doc/OnlineDocs/getting_started/pyomo_overview/index.rst diff --git a/doc/OnlineDocs/pyomo_overview/math_modeling.rst b/doc/OnlineDocs/getting_started/pyomo_overview/math_modeling.rst similarity index 97% rename from doc/OnlineDocs/pyomo_overview/math_modeling.rst rename to doc/OnlineDocs/getting_started/pyomo_overview/math_modeling.rst index ccacca8d58d..89a6e3af08b 100644 --- a/doc/OnlineDocs/pyomo_overview/math_modeling.rst +++ b/doc/OnlineDocs/getting_started/pyomo_overview/math_modeling.rst @@ -6,7 +6,7 @@ Modeling Objects. A more complete description is contained in the [PyomoBookIII]_ book. Pyomo supports the formulation and analysis of mathematical models for complex optimization applications. This capability is commonly associated with commercially available algebraic -modeling languages (AMLs) such as [AMPL]_, [AIMMS]_, and [GAMS]_. +modeling languages (AMLs) such as [FGK02]_, [AIMMS]_, and [GAMS]_. Pyomo's modeling objects are embedded within Python, a full-featured, high-level programming language that contains a rich set of supporting libraries. @@ -72,7 +72,7 @@ solvers to analyze a model introduces additional complexities. Pyomo is an AML that extends Python to include objects for mathematical -modeling. [PyomoBookI]_, [PyomoBookII]_, [PyomoBookIII]_, and [PyomoJournal]_ +modeling. [PyomoBookI]_, [PyomoBookII]_, [PyomoBookIII]_, and [Pyomo-paper]_ compare Pyomo with other AMLs. Although many good AMLs have been developed for optimization models, the following are motivating factors for the development of Pyomo: diff --git a/doc/OnlineDocs/pyomo_overview/overview_components.rst b/doc/OnlineDocs/getting_started/pyomo_overview/overview_components.rst similarity index 100% rename from doc/OnlineDocs/pyomo_overview/overview_components.rst rename to doc/OnlineDocs/getting_started/pyomo_overview/overview_components.rst diff --git a/doc/OnlineDocs/pyomo_overview/simple_examples.rst b/doc/OnlineDocs/getting_started/pyomo_overview/simple_examples.rst similarity index 97% rename from doc/OnlineDocs/pyomo_overview/simple_examples.rst rename to doc/OnlineDocs/getting_started/pyomo_overview/simple_examples.rst index 11305884c54..4ea6200184d 100644 --- a/doc/OnlineDocs/pyomo_overview/simple_examples.rst +++ b/doc/OnlineDocs/getting_started/pyomo_overview/simple_examples.rst @@ -15,11 +15,11 @@ Given the following model from the previous section: .. math:: :nowrap: - \begin{array}{ll} + \[\begin{array}{ll} \min & 2 x_1 + 3 x_2\\ \mathrm{s.t.} & 3 x_1 + 4 x_2 \geq 1\\ & x_1, x_2 \geq 0 - \end{array} + \end{array}\] This can be implemented as a concrete model as follows: @@ -48,11 +48,11 @@ We repeat the abstract model from the previous section: .. math:: :nowrap: - \begin{array}{lll} - \min & \sum_{j=1}^n c_j x_j &\\ + \[\begin{array}{lll} + \min & \sum_{j=1}^n c_j x_j &\\ \mathrm{s.t.} & \sum_{j=1}^n a_{ij} x_j \geq b_i & \forall i = 1 \ldots m\\ & x_j \geq 0 & \forall j = 1 \ldots n - \end{array} + \end{array}\] One way to implement this in Pyomo is as shown as follows: @@ -263,7 +263,7 @@ parameters. Here is one file that provides data (in AMPL "``.dat``" format). >>> # Create an instance to verify that the rules fire correctly >>> inst = model.create_instance('src/scripting/abstract1.dat') -.. literalinclude:: ../src/scripting/abstract1.dat +.. literalinclude:: /src/scripting/abstract1.dat :language: text There are multiple formats that can be used to provide data to a Pyomo @@ -327,18 +327,18 @@ the same model. To start with an illustration of general indexes, consider a slightly different Pyomo implementation of the model we just presented. -.. literalinclude:: ../src/scripting/abstract2.py +.. literalinclude:: /src/scripting/abstract2.py :language: python To get the same instantiated model, the following data file can be used. -.. literalinclude:: ../src/scripting/abstract2a.dat +.. literalinclude:: /src/scripting/abstract2a.dat :language: none However, this model can also be fed different data for problems of the same general form using meaningful indexes. -.. literalinclude:: ../src/scripting/abstract2.dat +.. literalinclude:: /src/scripting/abstract2.dat :language: none diff --git a/doc/OnlineDocs/working_abstractmodels/BuildAction.rst b/doc/OnlineDocs/howto/abstract_models/BuildAction.rst similarity index 85% rename from doc/OnlineDocs/working_abstractmodels/BuildAction.rst rename to doc/OnlineDocs/howto/abstract_models/BuildAction.rst index 6840e15a1d5..c87b56b92c0 100644 --- a/doc/OnlineDocs/working_abstractmodels/BuildAction.rst +++ b/doc/OnlineDocs/howto/abstract_models/BuildAction.rst @@ -13,7 +13,7 @@ trigger actions to be done as part of the model building process. The takes as arguments optional index sets and a function to perform the action. For example, -.. literalinclude:: ../src/scripting/abstract2piecebuild_BuildAction_example.spy +.. literalinclude:: /src/scripting/abstract2piecebuild_BuildAction_example.spy :language: python calls the function ``bpts_build`` for each member of ``model.J``. The @@ -21,14 +21,14 @@ function ``bpts_build`` should have the model and a variable for the members of ``model.J`` as formal arguments. In this example, the following would be a valid declaration for the function: -.. literalinclude:: ../src/scripting/abstract2piecebuild_Function_valid_declaration.spy +.. literalinclude:: /src/scripting/abstract2piecebuild_Function_valid_declaration.spy :language: python A full example, which extends the :ref:`abstract2.py` and :ref:`abstract2piece.py` examples, is -.. literalinclude:: ../src/scripting/abstract2piecebuild.spy +.. literalinclude:: /src/scripting/abstract2piecebuild.spy :language: python This example uses the build action to create a model component with @@ -51,13 +51,13 @@ clearer, to use a build action. The full model is: -.. literalinclude:: ../src/scripting/Isinglebuild.py +.. literalinclude:: /src/scripting/Isinglebuild.py :language: python -for this model, the same data file can be used as for Isinglecomm.py in +For this model, the same data file can be used as for Isinglecomm.py in :ref:`Isinglecomm.py` such as the toy data file: -.. literalinclude:: ../src/scripting/Isinglecomm.dat +.. literalinclude:: /src/scripting/Isinglecomm.dat Build actions can also be a way to implement data validation, particularly when multiple Sets or Parameters must be analyzed. However, diff --git a/doc/OnlineDocs/working_abstractmodels/data/ABCD.pdf b/doc/OnlineDocs/howto/abstract_models/data/ABCD.pdf similarity index 100% rename from doc/OnlineDocs/working_abstractmodels/data/ABCD.pdf rename to doc/OnlineDocs/howto/abstract_models/data/ABCD.pdf diff --git a/doc/OnlineDocs/working_abstractmodels/data/ABCD.png b/doc/OnlineDocs/howto/abstract_models/data/ABCD.png similarity index 100% rename from doc/OnlineDocs/working_abstractmodels/data/ABCD.png rename to doc/OnlineDocs/howto/abstract_models/data/ABCD.png diff --git a/doc/OnlineDocs/working_abstractmodels/data/PP.png b/doc/OnlineDocs/howto/abstract_models/data/PP.png similarity index 100% rename from doc/OnlineDocs/working_abstractmodels/data/PP.png rename to doc/OnlineDocs/howto/abstract_models/data/PP.png diff --git a/doc/OnlineDocs/working_abstractmodels/data/dataportals.rst b/doc/OnlineDocs/howto/abstract_models/data/dataportals.rst similarity index 88% rename from doc/OnlineDocs/working_abstractmodels/data/dataportals.rst rename to doc/OnlineDocs/howto/abstract_models/data/dataportals.rst index 5ce907fda2a..10d4a884cfb 100644 --- a/doc/OnlineDocs/working_abstractmodels/data/dataportals.rst +++ b/doc/OnlineDocs/howto/abstract_models/data/dataportals.rst @@ -62,14 +62,14 @@ can be used to initialize both concrete and abstract Pyomo models. Consider the file ``A.tab``, which defines a simple set with a tabular format: -.. literalinclude:: ../../src/dataportal/A.tab +.. literalinclude:: /src/dataportal/A.tab :language: none The ``load`` method is used to load data into a :class:`~pyomo.environ.DataPortal` object. Components in a concrete model can be explicitly initialized with data loaded by a :class:`~pyomo.environ.DataPortal` object: -.. literalinclude:: ../../src/dataportal/dataportal_tab_concrete1.spy +.. literalinclude:: /src/dataportal/dataportal_tab_concrete1.spy :language: python All data needed to initialize an abstract model *must* be provided by a @@ -77,7 +77,7 @@ All data needed to initialize an abstract model *must* be provided by a and the use of the :class:`~pyomo.environ.DataPortal` object to initialize components is automated for the user: -.. literalinclude:: ../../src/dataportal/dataportal_tab_load.spy +.. literalinclude:: /src/dataportal/dataportal_tab_load.spy :language: python Note the difference in the execution of the ``load`` method in these two @@ -126,7 +126,7 @@ that are loaded from different data sources. The ``[]`` operator is used to access set and parameter values. Consider the following example, which loads data and prints the value of the ``[]`` operator: -.. literalinclude:: ../../src/dataportal/dataportal_tab_getitem.spy +.. literalinclude:: /src/dataportal/dataportal_tab_getitem.spy :language: python The :class:`~pyomo.environ.DataPortal` @@ -162,12 +162,12 @@ with lists and dictionaries: For example, consider the following JSON file: -.. literalinclude:: ../../src/dataportal/T.json +.. literalinclude:: /src/dataportal/T.json :language: none The data in this file can be used to load the following model: -.. literalinclude:: ../../src/dataportal/dataportal_tab_json1.spy +.. literalinclude:: /src/dataportal/dataportal_tab_json1.spy :language: python Note that no ``set`` or ``param`` option needs to be specified when @@ -178,13 +178,13 @@ needed for model construction is used. The following YAML file has a similar structure: -.. literalinclude:: ../../src/dataportal/T.yaml +.. literalinclude:: /src/dataportal/T.yaml :language: none The data in this file can be used to load a Pyomo model with the same syntax as a JSON file: -.. literalinclude:: ../../src/dataportal/dataportal_tab_yaml1.spy +.. literalinclude:: /src/dataportal/dataportal_tab_yaml1.spy :language: python @@ -212,7 +212,7 @@ TAB files represent tabular data in an ascii file using whitespace as a delimiter. A TAB file consists of rows of values, where each row has the same length. For example, the file ``PP.tab`` has the format: -.. literalinclude:: ../../src/dataportal/PP.tab +.. literalinclude:: /src/dataportal/PP.tab :language: none CSV files represent tabular data in a format that is very similar to TAB @@ -220,7 +220,7 @@ files. Pyomo assumes that a CSV file consists of rows of values, where each row has the same length. For example, the file ``PP.csv`` has the format: -.. literalinclude:: ../../src/dataportal/PP.csv +.. literalinclude:: /src/dataportal/PP.csv :language: none Excel spreadsheets can express complex data relationships. A *range* is @@ -242,7 +242,7 @@ sub-element of a ``row`` element represents a different column, where each row has the same length. For example, the file ``PP.xml`` has the format: -.. literalinclude:: ../../src/dataportal/PP.xml +.. literalinclude:: /src/dataportal/PP.xml :language: none Loading Set Data @@ -256,13 +256,13 @@ Loading a Simple Set Consider the file ``A.tab``, which defines a simple set: -.. literalinclude:: ../../src/dataportal/A.tab +.. literalinclude:: /src/dataportal/A.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for a simple set ``A``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_set1.spy +.. literalinclude:: /src/dataportal/dataportal_tab_set1.spy :language: python Loading a Set of Tuples @@ -270,13 +270,13 @@ Loading a Set of Tuples Consider the file ``C.tab``: -.. literalinclude:: ../../src/dataportal/C.tab +.. literalinclude:: /src/dataportal/C.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for a two-dimensional set ``C``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_set2.spy +.. literalinclude:: /src/dataportal/dataportal_tab_set2.spy :language: python In this example, the column titles do not directly impact the process of @@ -289,13 +289,13 @@ Loading a Set Array Consider the file ``D.tab``, which defines an array representation of a two-dimensional set: -.. literalinclude:: ../../src/dataportal/D.tab +.. literalinclude:: /src/dataportal/D.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for a two-dimensional set ``D``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_set3.spy +.. literalinclude:: /src/dataportal/dataportal_tab_set3.spy :language: python The ``format`` option indicates that the set data is declared in a array @@ -313,13 +313,13 @@ Loading a Simple Parameter The simplest parameter is simply a singleton value. Consider the file ``Z.tab``: -.. literalinclude:: ../../src/dataportal/Z.tab +.. literalinclude:: /src/dataportal/Z.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for a simple parameter ``z``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_param1.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param1.spy :language: python Loading an Indexed Parameter @@ -328,13 +328,13 @@ Loading an Indexed Parameter An indexed parameter can be defined by a single column in a table. For example, consider the file ``Y.tab``: -.. literalinclude:: ../../src/dataportal/Y.tab +.. literalinclude:: /src/dataportal/Y.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for an indexed parameter ``y``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_param2.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param2.spy :language: python When column names are not used to specify the index and parameter data, @@ -351,19 +351,19 @@ The index set can be loaded with the parameter data using the ``index`` option. In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for set ``A`` and the indexed parameter ``y`` -.. literalinclude:: ../../src/dataportal/dataportal_tab_param3.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param3.spy :language: python An index set with multiple dimensions can also be loaded with an indexed parameter. Consider the file ``PP.tab``: -.. literalinclude:: ../../src/dataportal/PP.tab +.. literalinclude:: /src/dataportal/PP.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for a tuple set and an indexed parameter: -.. literalinclude:: ../../src/dataportal/dataportal_tab_param10.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param10.spy :language: python Loading a Parameter with Missing Values @@ -373,7 +373,7 @@ Missing parameter data can be expressed in two ways. First, parameter data can be defined with indices that are a subset of valid indices in the model. The following example loads the indexed parameter ``y``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_param9.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param9.spy :language: python The model defines an index set with four values, but only three @@ -382,13 +382,13 @@ parameter values are declared in the data file ``Y.tab``. Parameter data can also be declared with missing values using the period (``.``) symbol. For example, consider the file ``S.tab``: -.. literalinclude:: ../../src/dataportal/PP.tab +.. literalinclude:: /src/dataportal/PP.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for the index set ``A`` and indexed parameter ``y``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_param8.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param8.spy :language: python The period (``.``) symbol indicates a missing parameter value, but the @@ -400,13 +400,13 @@ Loading Multiple Parameters Multiple parameters can be initialized at once by specifying a list (or tuple) of component parameters. Consider the file ``XW.tab``: -.. literalinclude:: ../../src/dataportal/XW.tab +.. literalinclude:: /src/dataportal/XW.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for parameters ``x`` and ``w``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_param4.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param4.spy :language: python Selecting Parameter Columns @@ -421,7 +421,7 @@ component data. For example, consider the following load declaration: -.. literalinclude:: ../../src/dataportal/dataportal_tab_param5.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param5.spy :language: python The columns ``A`` and ``W`` are selected from the file ``XW.tab``, and a @@ -433,20 +433,20 @@ Loading a Parameter Array Consider the file ``U.tab``, which defines an array representation of a multiply-indexed parameter: -.. literalinclude:: ../../src/dataportal/U.tab +.. literalinclude:: /src/dataportal/U.tab :language: none In the following example, a :class:`~pyomo.environ.DataPortal` object loads data for a two-dimensional parameter ``u``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_param6.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param6.spy :language: python The ``format`` option indicates that the parameter data is declared in a array format. The ``format`` option can also indicate that the parameter data should be transposed. -.. literalinclude:: ../../src/dataportal/dataportal_tab_param7.spy +.. literalinclude:: /src/dataportal/dataportal_tab_param7.spy :language: python Note that the transposed parameter data changes the index set for the @@ -467,7 +467,7 @@ the following range of cells, which is named ``PPtable``: In the following example, a :class:`~pyomo.environ.DataPortal` object loads the named range ``PPtable`` from the file ``excel.xls``: -.. literalinclude:: ../../src/dataportal/dataportal_tab_excel1.spy +.. literalinclude:: /src/dataportal/dataportal_tab_excel1.spy :language: python Note that the ``range`` option is required to specify the table of cell @@ -477,14 +477,14 @@ There are a variety of ways that data can be loaded from a relational database. In the simplest case, a table can be specified within a database: -.. literalinclude:: ../../src/dataportal/dataportal_tab_db1.spy +.. literalinclude:: /src/dataportal/dataportal_tab_db1.spy :language: python In this example, the interface ``sqlite3`` is used to load data from an SQLite database in the file ``PP.sqlite``. More generally, an SQL query can be specified to dynamically generate a table. For example: -.. literalinclude:: ../../src/dataportal/dataportal_tab_db2.spy +.. literalinclude:: /src/dataportal/dataportal_tab_db2.spy :language: python Data Namespaces @@ -524,7 +524,7 @@ components. For example, the following script generates two model instances from an abstract model using data loaded into different namespaces: -.. literalinclude:: ../../src/dataportal/dataportal_tab_namespaces1.spy +.. literalinclude:: /src/dataportal/dataportal_tab_namespaces1.spy :language: python diff --git a/doc/OnlineDocs/working_abstractmodels/data/datfiles.rst b/doc/OnlineDocs/howto/abstract_models/data/datfiles.rst similarity index 89% rename from doc/OnlineDocs/working_abstractmodels/data/datfiles.rst rename to doc/OnlineDocs/howto/abstract_models/data/datfiles.rst index 4982ed2fff0..c0ce901628b 100644 --- a/doc/OnlineDocs/working_abstractmodels/data/datfiles.rst +++ b/doc/OnlineDocs/howto/abstract_models/data/datfiles.rst @@ -6,7 +6,7 @@ Data Command Files .. note:: The discussion and presentation below are adapted from Chapter 6 of - the "Pyomo Book" [PyomoBookII]_. The discussion of the + the second edition of the "Pyomo Book" [PyomoBookII]_. The discussion of the :class:`~pyomo.environ.DataPortal` class uses these same examples to illustrate how data can be loaded into Pyomo models within Python scripts (see the @@ -16,7 +16,7 @@ Model Data ---------- Pyomo's *data command files* employ a domain-specific language whose -syntax closely resembles the syntax of AMPL's data commands [AMPL]_. A +syntax closely resembles the syntax of AMPL's data commands [FGK02]_. A data command file consists of a sequence of commands that either (a) specify set and parameter data for a model, or (b) specify where such data is to be obtained from external sources (e.g. table files, CSV @@ -104,7 +104,7 @@ A set may be empty, and it may contain any combination of numeric and non-numeric string values. For example, the following are valid ``set`` commands: -.. literalinclude:: ../../src/data/set1.dat +.. literalinclude:: /src/data/set1.dat :language: python @@ -115,19 +115,19 @@ The ``set`` data command can also specify tuple data with the standard notation for tuples. For example, suppose that set ``A`` contains 3-tuples: -.. literalinclude:: ../../src/data/set2_decl.spy +.. literalinclude:: /src/data/set2_decl.spy :language: python The following ``set`` data command then specifies that ``A`` is the set containing the tuples ``(1,2,3)`` and ``(4,5,6)``: -.. literalinclude:: ../../src/data/set2a.dat +.. literalinclude:: /src/data/set2a.dat :language: none Alternatively, set data can simply be listed in the order that the tuple is represented: -.. literalinclude:: ../../src/data/set2.dat +.. literalinclude:: /src/data/set2.dat :language: none Obviously, the number of data elements specified using this syntax @@ -138,7 +138,7 @@ membership. For example, the following ``set`` data command declares 2-tuples in ``A`` using plus (``+``) to denote valid tuples and minus (``-``) to denote invalid tuples: -.. literalinclude:: ../../src/data/set4.dat +.. literalinclude:: /src/data/set4.dat :language: none This data command declares the following five 2-tuples: ``('A1',1)``, @@ -148,13 +148,13 @@ Finally, a set of tuple data can be concisely represented with tuple *templates* that represent a *slice* of tuple data. For example, suppose that the set ``A`` contains 4-tuples: -.. literalinclude:: ../../src/data/set5_decl.spy +.. literalinclude:: /src/data/set5_decl.spy :language: python The following ``set`` data command declares groups of tuples that are defined by a template and data to complete this template: -.. literalinclude:: ../../src/data/set5.dat +.. literalinclude:: /src/data/set5.dat :language: none A tuple template consists of a tuple that contains one or more asterisk @@ -163,7 +163,7 @@ tuple value is replaced by the values from the list of values that follows the tuple template. In this example, the following tuples are in set ``A``: -.. literalinclude:: ../../src/data/set5.txt +.. literalinclude:: /src/data/set5.txt :language: none Set Arrays @@ -183,12 +183,12 @@ list of string values. Suppose that a set ``A`` is used to index a set ``B`` as follows: -.. literalinclude:: ../../src/data/set3_decl.spy +.. literalinclude:: /src/data/set3_decl.spy :language: python Then set ``B`` is indexed using the values declared for set ``A``: -.. literalinclude:: ../../src/data/set3.dat +.. literalinclude:: /src/data/set3.dat :language: none The ``param`` Command @@ -197,7 +197,7 @@ The ``param`` Command Simple or non-indexed parameters are declared in an obvious way, as shown by these examples: -.. literalinclude:: ../../src/data/param1.dat +.. literalinclude:: /src/data/param1.dat :language: none Parameters can be defined with numeric data, simple strings and quoted @@ -213,33 +213,33 @@ parameter data. One-dimensional parameter data is indexed over a single set. Suppose that the parameter ``B`` is a parameter indexed by the set ``A``: -.. literalinclude:: ../../src/data/param2_decl.spy +.. literalinclude:: /src/data/param2_decl.spy :language: python A ``param`` data command can specify values for ``B`` with a list of index-value pairs: -.. literalinclude:: ../../src/data/param2.dat +.. literalinclude:: /src/data/param2.dat :language: none Because whitespace is ignored, this example data command file can be reorganized to specify the same data in a tabular format: -.. literalinclude:: ../../src/data/param2a.dat +.. literalinclude:: /src/data/param2a.dat :language: none Multiple parameters can be defined using a single ``param`` data command. For example, suppose that parameters ``B``, ``C``, and ``D`` are one-dimensional parameters all indexed by the set ``A``: -.. literalinclude:: ../../src/data/param3_decl.spy +.. literalinclude:: /src/data/param3_decl.spy :language: python Values for these parameters can be specified using a single ``param`` data command that declares these parameter names followed by a list of index and parameter values: -.. literalinclude:: ../../src/data/param3.dat +.. literalinclude:: /src/data/param3.dat :language: none The values in the ``param`` data command are interpreted as a list of @@ -249,7 +249,7 @@ corresponding numeric value. Note that parameter values do not need to be defined for all indices. For example, the following data command file is valid: -.. literalinclude:: ../../src/data/param3a.dat +.. literalinclude:: /src/data/param3a.dat :language: none The index ``g`` is omitted from the ``param`` command, and consequently @@ -259,7 +259,7 @@ More complex patterns of missing data can be specified using the period specifying multiple parameters that do not necessarily have the same index values: -.. literalinclude:: ../../src/data/param3b.dat +.. literalinclude:: /src/data/param3b.dat :language: none This example provides a concise representation of parameters that share @@ -270,13 +270,13 @@ Note that this data file specifies the data for set ``A`` twice: defined. An alternate syntax for ``param`` allows the user to concisely specify the definition of an index set along with associated parameters: -.. literalinclude:: ../../src/data/param3c.dat +.. literalinclude:: /src/data/param3c.dat :language: none Finally, we note that default values for missing data can also be specified using the ``default`` keyword: -.. literalinclude:: ../../src/data/param4.dat +.. literalinclude:: /src/data/param4.dat :language: none Note that default values can only be specified in ``param`` commands @@ -290,58 +290,58 @@ Multi-dimensional parameter data is indexed over either multiple sets or a single multi-dimensional set. Suppose that parameter ``B`` is a parameter indexed by set ``A`` that has dimension 2: -.. literalinclude:: ../../src/data/param5_decl.spy +.. literalinclude:: /src/data/param5_decl.spy :language: python The syntax of the ``param`` data command remains essentially the same when specifying values for ``B`` with a list of index and parameter values: -.. literalinclude:: ../../src/data/param5.dat +.. literalinclude:: /src/data/param5.dat :language: none Missing and default values are also handled in the same way with multi-dimensional index sets: -.. literalinclude:: ../../src/data/param5a.dat +.. literalinclude:: /src/data/param5a.dat :language: none Similarly, multiple parameters can defined with a single ``param`` data command. Suppose that parameters ``B``, ``C``, and ``D`` are parameters indexed over set ``A`` that has dimension 2: -.. literalinclude:: ../../src/data/param6_decl.spy +.. literalinclude:: /src/data/param6_decl.spy :language: python These parameters can be defined with a single ``param`` command that declares the parameter names followed by a list of index and parameter values: -.. literalinclude:: ../../src/data/param6.dat +.. literalinclude:: /src/data/param6.dat :language: none Similarly, the following ``param`` data command defines the index set along with the parameters: -.. literalinclude:: ../../src/data/param6a.dat +.. literalinclude:: /src/data/param6a.dat :language: none The ``param`` command also supports a matrix syntax for specifying the values in a parameter that has a 2-dimensional index. Suppose parameter ``B`` is indexed over set ``A`` that has dimension 2: -.. literalinclude:: ../../src/data/param7a_decl.spy +.. literalinclude:: /src/data/param7a_decl.spy :language: python The following ``param`` command defines a matrix of parameter values: -.. literalinclude:: ../../src/data/param7a.dat +.. literalinclude:: /src/data/param7a.dat :language: none Additionally, the following syntax can be used to specify a transposed matrix of parameter values: -.. literalinclude:: ../../src/data/param7b.dat +.. literalinclude:: /src/data/param7b.dat :language: none This functionality facilitates the presentation of parameter data in a @@ -355,13 +355,13 @@ be specified as a series of slices. Each slice is defined by a template followed by a list of index and parameter values. Suppose that parameter ``B`` is indexed over set ``A`` that has dimension 4: -.. literalinclude:: ../../src/data/param8a_decl.spy +.. literalinclude:: /src/data/param8a_decl.spy :language: python The following ``param`` command defines a matrix of parameter values with multiple templates: -.. literalinclude:: ../../src/data/param8a.dat +.. literalinclude:: /src/data/param8a.dat :language: none The ``B`` parameter consists of four values: ``B[a,1,a,1]=10``, @@ -376,7 +376,7 @@ data declaration than is possible with a ``param`` declaration. The following example illustrates a simple ``table`` command that declares data for a single parameter: -.. literalinclude:: ../../src/data/table0.dat +.. literalinclude:: /src/data/table0.dat :language: none The parameter ``M`` is indexed by column ``A``, which must be @@ -385,20 +385,20 @@ are provided after the colon and before the colon-equal (``:=``). Subsequently, the table data is provided. The syntax is not sensitive to whitespace, so the following is an equivalent ``table`` command: -.. literalinclude:: ../../src/data/table1.dat +.. literalinclude:: /src/data/table1.dat :language: none Multiple parameters can be declared by simply including additional parameter names. For example: -.. literalinclude:: ../../src/data/table2.dat +.. literalinclude:: /src/data/table2.dat :language: none This example declares data for the ``M`` and ``N`` parameters, which have different indexing columns. The indexing columns represent set data, which is specified separately. For example: -.. literalinclude:: ../../src/data/table3.dat +.. literalinclude:: /src/data/table3.dat :language: none This example declares data for the ``M`` and ``N`` parameters, along @@ -406,12 +406,12 @@ with the ``A`` and ``Z`` indexing sets. The correspondence between the index set ``Z`` and the indices of parameter ``N`` can be made more explicit by indexing ``N`` by ``Z``: -.. literalinclude:: ../../src/data/table4.dat +.. literalinclude:: /src/data/table4.dat :language: none Set data can also be specified independent of parameter data: -.. literalinclude:: ../../src/data/table5.dat +.. literalinclude:: /src/data/table5.dat :language: none .. warning:: @@ -423,13 +423,13 @@ Set data can also be specified independent of parameter data: that is initialized. For example, the ``table`` command initializes a set ``Z`` and a parameter ``M`` that are not related: - .. literalinclude:: ../../src/data/table7.dat + .. literalinclude:: /src/data/table7.dat :language: none Finally, simple parameter values can also be specified with a ``table`` command: -.. literalinclude:: ../../src/data/table6.dat +.. literalinclude:: /src/data/table6.dat :language: none The previous examples considered examples of the ``table`` command where @@ -437,7 +437,7 @@ column labels are provided. The ``table`` command can also be used without column labels. For example, the first example can be revised to omit column labels as follows: -.. literalinclude:: ../../src/data/table0.ul.dat +.. literalinclude:: /src/data/table0.ul.dat :language: none The ``columns=4`` is a keyword-value pair that defines the number of @@ -450,12 +450,12 @@ braces syntax declares the column where the ``M`` data is provided. Similarly, set data can be declared referencing the integer column labels: -.. literalinclude:: ../../src/data/table3.ul.dat +.. literalinclude:: /src/data/table3.ul.dat :language: none Declared set names can also be used to index parameters: -.. literalinclude:: ../../src/data/table4.ul.dat +.. literalinclude:: /src/data/table4.ul.dat :language: none Finally, we compare and contrast the ``table`` and ``param`` commands. @@ -521,13 +521,13 @@ Simple Load Examples The simplest illustration of the ``load`` command is specifying data for an indexed parameter. Consider the file ``Y.tab``: -.. literalinclude:: ../../src/data/Y.tab +.. literalinclude:: /src/data/Y.tab :language: none This file specifies the values of parameter ``Y`` which is indexed by set ``A``. The following ``load`` command loads the parameter data: -.. literalinclude:: ../../src/data/import1.tab.dat +.. literalinclude:: /src/data/import1.tab.dat :language: none The first argument is the filename. The options after the colon @@ -538,7 +538,7 @@ indicates the parameter that is initialized. Similarly, the following load command loads both the parameter data as well as the index set ``A``: -.. literalinclude:: ../../src/data/import2.tab.dat +.. literalinclude:: /src/data/import2.tab.dat :language: none The difference is the specification of the index set, ``A=[A]``, which @@ -548,24 +548,24 @@ ASCII table file. Set data can also be loaded from a ASCII table file that contains a single column of data: -.. literalinclude:: ../../src/data/A.tab +.. literalinclude:: /src/data/A.tab :language: none The ``format`` option must be specified to denote the fact that the relational data is being interpreted as a set: -.. literalinclude:: ../../src/data/import3.tab.dat +.. literalinclude:: /src/data/import3.tab.dat :language: none Note that this allows for specifying set data that contains tuples. Consider file ``C.tab``: -.. literalinclude:: ../../src/data/C.tab +.. literalinclude:: /src/data/C.tab :language: none A similar ``load`` syntax will load this data into set ``C``: -.. literalinclude:: ../../src/data/import4.tab.dat +.. literalinclude:: /src/data/import4.tab.dat :language: none Note that this example requires that ``C`` be declared with dimension @@ -609,7 +609,7 @@ describes different specifications and how they define how data is loaded into a model. Suppose file ``ABCD.tab`` defines the following relational table: -.. literalinclude:: ../../src/data/ABCD.tab +.. literalinclude:: /src/data/ABCD.tab :language: none There are many ways to interpret this relational table. It could @@ -621,7 +621,7 @@ for specifying how a table is interpreted. A simple specification is to interpret the relational table as a set: -.. literalinclude:: ../../src/data/ABCD1.dat +.. literalinclude:: /src/data/ABCD1.dat :language: none Note that ``Z`` is a set in the model that the data is being loaded @@ -631,7 +631,7 @@ data from this table. Another simple specification is to interpret the relational table as a parameter with indexed by 3-tuples: -.. literalinclude:: ../../src/data/ABCD2.dat +.. literalinclude:: /src/data/ABCD2.dat :language: none Again, this requires that ``D`` be a parameter in the model that the @@ -639,14 +639,14 @@ data is being loaded into. Additionally, the index set for ``D`` must contain the indices that are specified in the table. The ``load`` command also allows for the specification of the index set: -.. literalinclude:: ../../src/data/ABCD3.dat +.. literalinclude:: /src/data/ABCD3.dat :language: none This specifies that the index set is loaded into the ``Z`` set in the model. Similarly, data can be loaded into another parameter than what is specified in the relational table: -.. literalinclude:: ../../src/data/ABCD4.dat +.. literalinclude:: /src/data/ABCD4.dat :language: none This specifies that the index set is loaded into the ``Z`` set and that @@ -658,13 +658,13 @@ specification of data mappings from columns in a relational table into index sets and parameters. For example, suppose that a model is defined with set ``Z`` and parameters ``Y`` and ``W``: -.. literalinclude:: ../../src/data/ABCD5_decl.spy +.. literalinclude:: /src/data/ABCD5_decl.spy :language: python Then the following command defines how these data items are loaded using columns ``B``, ``C`` and ``D``: -.. literalinclude:: ../../src/data/ABCD5.dat +.. literalinclude:: /src/data/ABCD5.dat :language: none When the ``using`` option is omitted the data manager is inferred from @@ -672,13 +672,13 @@ the filename suffix. However, the filename suffix does not always reflect the format of the data it contains. For example, consider the relational table in the file ``ABCD.txt``: -.. literalinclude:: ../../src/data/ABCD.txt +.. literalinclude:: /src/data/ABCD.txt :language: none We can specify the ``using`` option to load from this file into parameter ``D`` and set ``Z``: -.. literalinclude:: ../../src/data/ABCD6.dat +.. literalinclude:: /src/data/ABCD6.dat :language: none .. note:: @@ -692,7 +692,7 @@ parameter ``D`` and set ``Z``: The following data managers are supported in Pyomo 5.1: - .. literalinclude:: ../../src/data/data_managers.txt + .. literalinclude:: /src/data/data_managers.txt :language: none Interpreting Tabular Data @@ -725,12 +725,12 @@ A table with a single value can be interpreted as a simple parameter using the ``param`` format value. Suppose that ``Z.tab`` contains the following table: -.. literalinclude:: ../../src/data/Z.tab +.. literalinclude:: /src/data/Z.tab :language: none The following load command then loads this value into parameter ``p``: -.. literalinclude:: ../../src/data/import6.tab.dat +.. literalinclude:: /src/data/import6.tab.dat :language: none Sets with 2-tuple data can be represented with a matrix format that @@ -739,12 +739,12 @@ relational table as a matrix that defines a set of 2-tuples where ``+`` denotes a valid tuple and ``-`` denotes an invalid tuple. Suppose that ``D.tab`` contains the following relational table: -.. literalinclude:: ../../src/data/D.tab +.. literalinclude:: /src/data/D.tab :language: none Then the following load command loads data into set ``B``: -.. literalinclude:: ../../src/data/import5.tab.dat +.. literalinclude:: /src/data/import5.tab.dat :language: none This command declares the following 2-tuples: ``('A1',1)``, @@ -754,19 +754,19 @@ Parameters with 2-tuple indices can be interpreted with a matrix format that where rows and columns are different indices. Suppose that ``U.tab`` contains the following table: -.. literalinclude:: ../../src/data/U.tab +.. literalinclude:: /src/data/U.tab :language: none Then the following load command loads this value into parameter ``U`` with a 2-dimensional index using the ``array`` format value.: -.. literalinclude:: ../../src/data/import7.tab.dat +.. literalinclude:: /src/data/import7.tab.dat :language: none The ``transpose_array`` format value also interprets the table as a matrix, but it loads the data in a transposed format: -.. literalinclude:: ../../src/data/import8.tab.dat +.. literalinclude:: /src/data/import8.tab.dat :language: none Note that these format values do not support the initialization of the @@ -789,7 +789,7 @@ in the following figure: The following command loads this data to initialize parameter ``D`` and index ``Z``: -.. literalinclude:: ../../src/data/ABCD7.dat +.. literalinclude:: /src/data/ABCD7.dat :language: none Thus, the syntax for loading data from spreadsheets only differs from @@ -809,7 +809,7 @@ command loads data from the Excel spreadsheet ``ABCD.xls`` using the ``pyodbc`` interface. The command loads this data to initialize parameter ``D`` and index ``Z``: -.. literalinclude:: ../../src/data/ABCD8.dat +.. literalinclude:: /src/data/ABCD8.dat :language: none The ``using`` option specifies that the ``pyodbc`` package will be @@ -818,7 +818,7 @@ specifies that the table ``ABCD`` is loaded from this spreadsheet. Similarly, the following command specifies a data connection string to specify the ODBC driver explicitly: -.. literalinclude:: ../../src/data/ABCD9.dat +.. literalinclude:: /src/data/ABCD9.dat :language: none ODBC drivers are generally tailored to the type of data source that @@ -836,7 +836,7 @@ task of minimizing the cost for a meal at a fast food restaurant -- they must purchase a sandwich, side, and a drink for the lowest cost. The following is a Pyomo model for this problem: -.. literalinclude:: ../../src/data/diet1.py +.. literalinclude:: /src/data/diet1.py :language: python Suppose that the file ``diet1.sqlite`` be a SQLite database file that @@ -884,7 +884,7 @@ We can solve the ``diet1`` model using the Python definition in ``diet.sqlite.dat`` specifies a ``load`` command that uses that ``sqlite3`` data manager and embeds a SQL query to retrieve the data: -.. literalinclude:: ../../src/data/diet.sqlite.dat +.. literalinclude:: /src/data/diet.sqlite.dat :language: none The PyODBC driver module will pass the SQL query through an Access ODBC @@ -904,7 +904,7 @@ The ``include`` command allows a data command file to execute data commands from another file. For example, the following command file executes data commands from ``ex1.dat`` and then ``ex2.dat``: -.. literalinclude:: ../../src/data/ex.dat +.. literalinclude:: /src/data/ex.dat :language: none Pyomo is sensitive to the order of execution of data commands, since @@ -921,7 +921,7 @@ to structure the specification of Pyomo's data commands. Specifically, a namespace declaration is used to group data commands and to provide a group label. Consider the following data command file: -.. literalinclude:: ../../src/data/namespace1.dat +.. literalinclude:: /src/data/namespace1.dat :language: none This data file defines two namespaces: ``ns1`` and ``ns2`` that diff --git a/doc/OnlineDocs/working_abstractmodels/data/index.rst b/doc/OnlineDocs/howto/abstract_models/data/index.rst similarity index 100% rename from doc/OnlineDocs/working_abstractmodels/data/index.rst rename to doc/OnlineDocs/howto/abstract_models/data/index.rst diff --git a/doc/OnlineDocs/working_abstractmodels/data/native.rst b/doc/OnlineDocs/howto/abstract_models/data/native.rst similarity index 70% rename from doc/OnlineDocs/working_abstractmodels/data/native.rst rename to doc/OnlineDocs/howto/abstract_models/data/native.rst index ed92d78d78e..f1a25183795 100644 --- a/doc/OnlineDocs/working_abstractmodels/data/native.rst +++ b/doc/OnlineDocs/howto/abstract_models/data/native.rst @@ -4,7 +4,8 @@ Using Standard Data Types Defining Constant Values ------------------------ -In many cases, Pyomo models can be constructed without :class:`~pyomo.environ.Set` and :class:`~pyomo.environ.Param` data components. Native Python data types +In many cases, Pyomo models can be constructed without :class:`Set` and +:class:`~Param` data components. Native Python data types class can be simply used to define constant values in Pyomo expressions. Consequently, Python sets, lists and dictionaries can be used to construct Pyomo models, as well as a wide range of other Python classes. @@ -34,51 +35,51 @@ can be initialized with: * list, set and tuple data: - .. literalinclude:: ../../src/dataportal/set_initialization_decl2.spy + .. literalinclude:: /src/dataportal/set_initialization_decl2.spy :language: python * generators: - .. literalinclude:: ../../src/dataportal/set_initialization_decl3.spy + .. literalinclude:: /src/dataportal/set_initialization_decl3.spy :language: python * numpy arrays: - .. literalinclude:: ../../src/dataportal/set_initialization_decl4.spy + .. literalinclude:: /src/dataportal/set_initialization_decl4.spy :language: python Sets can also be indirectly initialized with functions that return native Python data: -.. literalinclude:: ../../src/dataportal/set_initialization_decl5.spy +.. literalinclude:: /src/dataportal/set_initialization_decl5.spy :language: python Indexed sets can be initialized with dictionary data where the dictionary values are iterable data: -.. literalinclude:: ../../src/dataportal/set_initialization_decl6.spy +.. literalinclude:: /src/dataportal/set_initialization_decl6.spy :language: python Parameter Components ^^^^^^^^^^^^^^^^^^^^ -When a parameter is a single value, then a :class:`~pyomo.environ.Param` component can be simply initialized with a -value: +When a parameter is a single value, then a :class:`~pyomo.environ.Param` +component can be simply initialized with a value: -.. literalinclude:: ../../src/dataportal/param_initialization_decl1.spy +.. literalinclude:: /src/dataportal/param_initialization_decl1.spy :language: python More generally, :class:`~pyomo.environ.Param` components can be initialized with dictionary data where the dictionary values are single values: -.. literalinclude:: ../../src/dataportal/param_initialization_decl2.spy +.. literalinclude:: /src/dataportal/param_initialization_decl2.spy :language: python Parameters can also be indirectly initialized with functions that return native Python data: -.. literalinclude:: ../../src/dataportal/param_initialization_decl3.spy +.. literalinclude:: /src/dataportal/param_initialization_decl3.spy :language: python diff --git a/doc/OnlineDocs/working_abstractmodels/data/raw_dicts.rst b/doc/OnlineDocs/howto/abstract_models/data/raw_dicts.rst similarity index 88% rename from doc/OnlineDocs/working_abstractmodels/data/raw_dicts.rst rename to doc/OnlineDocs/howto/abstract_models/data/raw_dicts.rst index f78e349c28b..77ce78effd6 100644 --- a/doc/OnlineDocs/working_abstractmodels/data/raw_dicts.rst +++ b/doc/OnlineDocs/howto/abstract_models/data/raw_dicts.rst @@ -14,12 +14,12 @@ components, the required data dictionary maps the implicit index .. doctest:: - >>> from pyomo.environ import * - >>> m = AbstractModel() - >>> m.I = Set() - >>> m.p = Param() - >>> m.q = Param(m.I) - >>> m.r = Param(m.I, m.I, default=0) + >>> import pyomo.environ as pyo + >>> m = pyo.AbstractModel() + >>> m.I = pyo.Set() + >>> m.p = pyo.Param() + >>> m.q = pyo.Param(m.I) + >>> m.r = pyo.Param(m.I, m.I, default=0) >>> data = {None: { ... 'I': {None: [1,2,3]}, ... 'p': {None: 100}, diff --git a/doc/OnlineDocs/working_abstractmodels/data/storing_data.rst b/doc/OnlineDocs/howto/abstract_models/data/storing_data.rst similarity index 100% rename from doc/OnlineDocs/working_abstractmodels/data/storing_data.rst rename to doc/OnlineDocs/howto/abstract_models/data/storing_data.rst diff --git a/doc/OnlineDocs/working_abstractmodels/index.rst b/doc/OnlineDocs/howto/abstract_models/index.rst similarity index 100% rename from doc/OnlineDocs/working_abstractmodels/index.rst rename to doc/OnlineDocs/howto/abstract_models/index.rst diff --git a/doc/OnlineDocs/working_abstractmodels/instantiating_models.rst b/doc/OnlineDocs/howto/abstract_models/instantiating_models.rst similarity index 100% rename from doc/OnlineDocs/working_abstractmodels/instantiating_models.rst rename to doc/OnlineDocs/howto/abstract_models/instantiating_models.rst diff --git a/doc/OnlineDocs/working_abstractmodels/pyomo_command.rst b/doc/OnlineDocs/howto/abstract_models/pyomo_command.rst similarity index 97% rename from doc/OnlineDocs/working_abstractmodels/pyomo_command.rst rename to doc/OnlineDocs/howto/abstract_models/pyomo_command.rst index aabfc8667f7..13fac82cc71 100644 --- a/doc/OnlineDocs/working_abstractmodels/pyomo_command.rst +++ b/doc/OnlineDocs/howto/abstract_models/pyomo_command.rst @@ -90,7 +90,7 @@ When there seem to be troubles expressing the model, it is often useful to embed print commands in the model in places that will yield helpful information. Consider the following snippet: -.. literalinclude:: ../src/scripting/spy4PyomoCommand_Troubleshooting_printed_command.spy +.. literalinclude:: /src/scripting/spy4PyomoCommand_Troubleshooting_printed_command.spy :language: python The effect will be to output every member of the set ``model.I`` at the diff --git a/doc/OnlineDocs/howto/debugging.rst b/doc/OnlineDocs/howto/debugging.rst new file mode 100644 index 00000000000..f876dd39459 --- /dev/null +++ b/doc/OnlineDocs/howto/debugging.rst @@ -0,0 +1,4 @@ +Debugging Models +================ + +TODO diff --git a/doc/OnlineDocs/howto/index.rst b/doc/OnlineDocs/howto/index.rst new file mode 100644 index 00000000000..9f700bff4e8 --- /dev/null +++ b/doc/OnlineDocs/howto/index.rst @@ -0,0 +1,12 @@ +How-To Guides +============= + +.. toctree:: + :maxdepth: 2 + + interrogating + manipulating + solver_recipes + abstract_models/index.rst + debugging + ../contribution_guide diff --git a/doc/OnlineDocs/howto/interrogating.rst b/doc/OnlineDocs/howto/interrogating.rst new file mode 100644 index 00000000000..909b1bf9490 --- /dev/null +++ b/doc/OnlineDocs/howto/interrogating.rst @@ -0,0 +1,166 @@ +Interrogating Models +==================== + +.. _VarAccess: + +Accessing Variable Values +------------------------- + +Primal Variable Values +^^^^^^^^^^^^^^^^^^^^^^ + +Often, the point of optimization is to get optimal values of +variables. Some users may want to process the values in a script. We +will describe how to access a particular variable from a Python script +as well as how to access all variables from a Python script and from a +callback. This should enable the reader to understand how to get the +access that they desire. The Iterative example given above also +illustrates access to variable values. + +One Variable from a Python Script +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Assuming the model has been instantiated and solved and the results have +been loaded back into the instance object, then we can make use of the +fact that the variable is a member of the instance object and its value +can be accessed using its ``value`` member. For example, suppose the +model contains a variable named ``quant`` that is a singleton (has no +indexes) and suppose further that the name of the instance object is +``instance``. Then the value of this variable can be accessed using +``pyo.value(instance.quant)``. Variables with indexes can be referenced +by supplying the index. + +Consider the following very simple example, which is similar to the +iterative example. This is a concrete model. In this example, the value +of ``x[2]`` is accessed. + +.. literalinclude:: /src/scripting/noiteration1.py + :language: python + +.. note:: + + If this script is run without modification, Pyomo is likely to issue + a warning because there are no constraints. The warning is because + some solvers may fail if given a problem instance that does not have + any constraints. + +All Variables from a Python Script +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +As with one variable, we assume that the model has been instantiated +and solved. Assuming the instance object has the name ``instance``, +the following code snippet displays all variables and their values: + + >>> for v in instance.component_objects(pyo.Var, active=True): + ... print("Variable",v) # doctest: +SKIP + ... for index in v: + ... print (" ",index, pyo.value(v[index])) # doctest: +SKIP + + +Alternatively, + + >>> for v in instance.component_data_objects(pyo.Var, active=True): + ... print(v, pyo.value(v)) # doctest: +SKIP + +This code could be improved by checking to see if the variable is not +indexed (i.e., the only index value is ``None``), then the code could +print the value without the word ``None`` next to it. + +Assuming again that the model has been instantiated and solved and the +results have been loaded back into the instance object. Here is a code +snippet for fixing all integers at their current value: + + >>> for var in instance.component_data_objects(pyo.Var, active=True): + ... if not var.is_continuous(): + ... print ("fixing "+str(v)) # doctest: +SKIP + ... var.fixed = True # fix the current value + + +Another way to access all of the variables (particularly if there are +blocks) is as follows (this particular snippet assumes that instead of +`import pyomo.environ as pyo` `from pyo.environ import *` was used): + +.. literalinclude:: /src/scripting/block_iter_example_compprintloop.spy + :language: python + +.. _ParamAccess: + +Accessing Parameter Values +-------------------------- + +Accessing parameter values is completely analogous to accessing variable +values. For example, here is a code snippet to print the name and value +of every Parameter in a model: + + >>> for parmobject in instance.component_objects(pyo.Param, active=True): + ... nametoprint = str(str(parmobject.name)) + ... print ("Parameter ", nametoprint) # doctest: +SKIP + ... for index in parmobject: + ... vtoprint = pyo.value(parmobject[index]) + ... print (" ",index, vtoprint) # doctest: +SKIP + + +Accessing Duals +--------------- + +Access to dual values in scripts is similar to accessing primal variable +values, except that dual values are not captured by default so +additional directives are needed before optimization to signal that +duals are desired. + +To get duals without a script, use the ``pyomo`` option +``--solver-suffixes='dual'`` which will cause dual values to be included +in output. Note: In addition to duals (``dual``) , reduced costs +(``rc``) and slack values (``slack``) can be requested. All suffixes can +be requested using the ``pyomo`` option ``--solver-suffixes='.*'`` + +.. warning:: + + Some of the duals may have the value ``None``, rather than ``0``. + +Access Duals in a Python Script +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To signal that duals are desired, declare a Suffix component with the +name "dual" on the model or instance with an IMPORT or IMPORT_EXPORT +direction. + +.. literalinclude:: /src/scripting/driveabs2_Create_dual_suffix_component.spy + :language: python + +See the section on Suffixes :ref:`Suffixes` for more information on +Pyomo's Suffix component. After the results are obtained and loaded into +an instance, duals can be accessed in the following fashion. + +.. literalinclude:: /src/scripting/driveabs2_Access_all_dual.spy + :language: python + +The following snippet will only work, of course, if there is a +constraint with the name ``AxbConstraint`` that has and index, which is +the string ``Film``. + +.. literalinclude:: /src/scripting/driveabs2_Access_one_dual.spy + :language: python + +Here is a complete example that relies on the file ``abstract2.py`` to +provide the model and the file ``abstract2.dat`` to provide the +data. Note that the model in ``abstract2.py`` does contain a constraint +named ``AxbConstraint`` and ``abstract2.dat`` does specify an index for +it named ``Film``. + +.. literalinclude:: /src/scripting/driveabs2.spy + :language: python + +Concrete models are slightly different because the model is the +instance. Here is a complete example that relies on the file +``concrete1.py`` to provide the model and instantiate it. + +.. literalinclude:: /src/scripting/driveconc1.py + :language: python + +Accessing Slacks +---------------- + +The functions ``lslack()`` and ``uslack()`` return the upper and lower +slacks, respectively, for a constraint. + diff --git a/doc/OnlineDocs/working_models.rst b/doc/OnlineDocs/howto/manipulating.rst similarity index 52% rename from doc/OnlineDocs/working_models.rst rename to doc/OnlineDocs/howto/manipulating.rst index dbd7aa383e3..783fa4fe0e2 100644 --- a/doc/OnlineDocs/working_models.rst +++ b/doc/OnlineDocs/howto/manipulating.rst @@ -1,4 +1,4 @@ -Working with Pyomo Models +Manipulating Pyomo Models ========================= This section gives an overview of commonly used scripting commands when @@ -58,7 +58,7 @@ computer to solve the problem or even to iterate over solutions. This example is provided just to illustrate some elementary aspects of scripting. -.. literalinclude:: src/scripting/iterative1.spy +.. literalinclude:: /src/scripting/iterative1.spy :language: python Let us now analyze this script. The first line is a comment that happens @@ -66,7 +66,7 @@ to give the name of the file. This is followed by two lines that import symbols for Pyomo. The pyomo namespace is imported as ``pyo``. Therefore, ``pyo.`` must precede each use of a Pyomo name. -.. literalinclude:: src/scripting/iterative1_Import_symbols_for_pyomo.spy +.. literalinclude:: /src/scripting/iterative1_Import_symbols_for_pyomo.spy :language: python An object to perform optimization is created by calling @@ -74,7 +74,7 @@ An object to perform optimization is created by calling argument would be ``'gurobi'`` if, e.g., Gurobi was desired instead of glpk: -.. literalinclude:: src/scripting/iterative1_Call_SolverFactory_with_argument.spy +.. literalinclude:: /src/scripting/iterative1_Call_SolverFactory_with_argument.spy :language: python The next lines after a comment create a model. For our discussion here, @@ -86,13 +86,13 @@ to keep it simple. Constraints could be present in the base model. Even though it is an abstract model, the base model is fully specified by these commands because it requires no external data: -.. literalinclude:: src/scripting/iterative1_Create_base_model.spy +.. literalinclude:: /src/scripting/iterative1_Create_base_model.spy :language: python The next line is not part of the base model specification. It creates an empty constraint list that the script will use to add constraints. -.. literalinclude:: src/scripting/iterative1_Create_empty_constraint_list.spy +.. literalinclude:: /src/scripting/iterative1_Create_empty_constraint_list.spy :language: python The next non-comment line creates the instantiated model and refers to @@ -103,19 +103,19 @@ the ``create`` function is called without arguments because none are needed; however, the name of a file with data commands is given as an argument in many scripts. -.. literalinclude:: src/scripting/iterative1_Create_instantiated_model.spy +.. literalinclude:: /src/scripting/iterative1_Create_instantiated_model.spy :language: python The next line invokes the solver and refers to the object contain results with the Python variable ``results``. -.. literalinclude:: src/scripting/iterative1_Solve_and_refer_to_results.spy +.. literalinclude:: /src/scripting/iterative1_Solve_and_refer_to_results.spy :language: python The solve function loads the results into the instance, so the next line writes out the updated values. -.. literalinclude:: src/scripting/iterative1_Display_updated_value.spy +.. literalinclude:: /src/scripting/iterative1_Display_updated_value.spy :language: python The next non-comment line is a Python iteration command that will @@ -123,7 +123,7 @@ successively assign the integers from 0 to 4 to the Python variable ``i``, although that variable is not used in script. This loop is what causes the script to generate five more solutions: -.. literalinclude:: src/scripting/iterative1_Assign_integers.spy +.. literalinclude:: /src/scripting/iterative1_Assign_integers.spy :language: python An expression is built up in the Python variable named ``expr``. The @@ -135,7 +135,7 @@ zero and the expression in ``expr`` is augmented accordingly. Although Pyomo expression when it is assigned expressions involving Pyomo variable objects: -.. literalinclude:: src/scripting/iterative1_Iteratively_assign_and_test.spy +.. literalinclude:: /src/scripting/iterative1_Iteratively_assign_and_test.spy :language: python During the first iteration (when ``i`` is 0), we know that all values of @@ -159,15 +159,15 @@ function to get it. The next line adds to the constraint list called ``c`` the requirement that the expression be greater than or equal to one: -.. literalinclude:: src/scripting/iterative1_Add_expression_constraint.spy +.. literalinclude:: /src/scripting/iterative1_Add_expression_constraint.spy :language: python -The proof that this precludes the last solution is left as an exerise +The proof that this precludes the last solution is left as an exercise for the reader. The final lines in the outer for loop find a solution and display it: -.. literalinclude:: src/scripting/iterative1_Find_and_display_solution.spy +.. literalinclude:: /src/scripting/iterative1_Find_and_display_solution.spy :language: python .. note:: @@ -268,14 +268,14 @@ Fixing Variables and Re-solving Instead of changing model data, scripts are often used to fix variable values. The following example illustrates this. -.. literalinclude:: src/scripting/iterative2.spy +.. literalinclude:: /src/scripting/iterative2.spy :language: python In this example, the variables are binary. The model is solved and then the value of ``model.x[2]`` is flipped to the opposite value before solving the model again. The main lines of interest are: -.. literalinclude:: src/scripting/iterative2_Flip_value_before_solve_again.spy +.. literalinclude:: /src/scripting/iterative2_Flip_value_before_solve_again.spy :language: python This could also have been accomplished by setting the upper and lower @@ -393,312 +393,3 @@ individual index: >>> model.con = pyo.Constraint(model.s, rule=_con) >>> model.con.deactivate() # Deactivate all indices >>> model.con[1].activate() # Activate single index - - - - -.. _VarAccess: - -Accessing Variable Values -------------------------- - -Primal Variable Values -^^^^^^^^^^^^^^^^^^^^^^ - -Often, the point of optimization is to get optimal values of -variables. Some users may want to process the values in a script. We -will describe how to access a particular variable from a Python script -as well as how to access all variables from a Python script and from a -callback. This should enable the reader to understand how to get the -access that they desire. The Iterative example given above also -illustrates access to variable values. - -One Variable from a Python Script -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Assuming the model has been instantiated and solved and the results have -been loaded back into the instance object, then we can make use of the -fact that the variable is a member of the instance object and its value -can be accessed using its ``value`` member. For example, suppose the -model contains a variable named ``quant`` that is a singleton (has no -indexes) and suppose further that the name of the instance object is -``instance``. Then the value of this variable can be accessed using -``pyo.value(instance.quant)``. Variables with indexes can be referenced -by supplying the index. - -Consider the following very simple example, which is similar to the -iterative example. This is a concrete model. In this example, the value -of ``x[2]`` is accessed. - -.. literalinclude:: src/scripting/noiteration1.py - :language: python - -.. note:: - - If this script is run without modification, Pyomo is likely to issue - a warning because there are no constraints. The warning is because - some solvers may fail if given a problem instance that does not have - any constraints. - -All Variables from a Python Script -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -As with one variable, we assume that the model has been instantiated -and solved. Assuming the instance object has the name ``instance``, -the following code snippet displays all variables and their values: - - >>> for v in instance.component_objects(pyo.Var, active=True): - ... print("Variable",v) # doctest: +SKIP - ... for index in v: - ... print (" ",index, pyo.value(v[index])) # doctest: +SKIP - - -Alternatively, - - >>> for v in instance.component_data_objects(pyo.Var, active=True): - ... print(v, pyo.value(v)) # doctest: +SKIP - -This code could be improved by checking to see if the variable is not -indexed (i.e., the only index value is ``None``), then the code could -print the value without the word ``None`` next to it. - -Assuming again that the model has been instantiated and solved and the -results have been loaded back into the instance object. Here is a code -snippet for fixing all integers at their current value: - - >>> for var in instance.component_data_objects(pyo.Var, active=True): - ... if not var.is_continuous(): - ... print ("fixing "+str(v)) # doctest: +SKIP - ... var.fixed = True # fix the current value - - -Another way to access all of the variables (particularly if there are -blocks) is as follows (this particular snippet assumes that instead of -`import pyomo.environ as pyo` `from pyo.environ import *` was used): - -.. literalinclude:: src/scripting/block_iter_example_compprintloop.spy - :language: python - -.. _ParamAccess: - -Accessing Parameter Values --------------------------- - -Accessing parameter values is completely analogous to accessing variable -values. For example, here is a code snippet to print the name and value -of every Parameter in a model: - - >>> for parmobject in instance.component_objects(pyo.Param, active=True): - ... nametoprint = str(str(parmobject.name)) - ... print ("Parameter ", nametoprint) # doctest: +SKIP - ... for index in parmobject: - ... vtoprint = pyo.value(parmobject[index]) - ... print (" ",index, vtoprint) # doctest: +SKIP - - -Accessing Duals ---------------- - -Access to dual values in scripts is similar to accessing primal variable -values, except that dual values are not captured by default so -additional directives are needed before optimization to signal that -duals are desired. - -To get duals without a script, use the ``pyomo`` option -``--solver-suffixes='dual'`` which will cause dual values to be included -in output. Note: In addition to duals (``dual``) , reduced costs -(``rc``) and slack values (``slack``) can be requested. All suffixes can -be requested using the ``pyomo`` option ``--solver-suffixes='.*'`` - -.. warning:: - - Some of the duals may have the value ``None``, rather than ``0``. - -Access Duals in a Python Script -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -To signal that duals are desired, declare a Suffix component with the -name "dual" on the model or instance with an IMPORT or IMPORT_EXPORT -direction. - -.. literalinclude:: src/scripting/driveabs2_Create_dual_suffix_component.spy - :language: python - -See the section on Suffixes :ref:`Suffixes` for more information on -Pyomo's Suffix component. After the results are obtained and loaded into -an instance, duals can be accessed in the following fashion. - -.. literalinclude:: src/scripting/driveabs2_Access_all_dual.spy - :language: python - -The following snippet will only work, of course, if there is a -constraint with the name ``AxbConstraint`` that has and index, which is -the string ``Film``. - -.. literalinclude:: src/scripting/driveabs2_Access_one_dual.spy - :language: python - -Here is a complete example that relies on the file ``abstract2.py`` to -provide the model and the file ``abstract2.dat`` to provide the -data. Note that the model in ``abstract2.py`` does contain a constraint -named ``AxbConstraint`` and ``abstract2.dat`` does specify an index for -it named ``Film``. - -.. literalinclude:: src/scripting/driveabs2.spy - :language: python - -Concrete models are slightly different because the model is the -instance. Here is a complete example that relies on the file -``concrete1.py`` to provide the model and instantiate it. - -.. literalinclude:: src/scripting/driveconc1.py - :language: python - -Accessing Slacks ----------------- - -The functions ``lslack()`` and ``uslack()`` return the upper and lower -slacks, respectively, for a constraint. - - -Accessing Solver Status ------------------------ - -After a solve, the results object has a member ``Solution.Status`` that -contains the solver status. The following snippet shows an example of -access via a ``print`` statement: - -.. literalinclude:: src/scripting/spy4scripts_Print_solver_status.spy - :language: python - -The use of the Python ``str`` function to cast the value to a be string -makes it easy to test it. In particular, the value 'optimal' indicates -that the solver succeeded. It is also possible to access Pyomo data that -can be compared with the solver status as in the following code snippet: - -.. literalinclude:: src/scripting/spy4scripts_Pyomo_data_comparedwith_solver_status_1.spy - :language: python - -Alternatively, - -.. literalinclude:: src/scripting/spy4scripts_Pyomo_data_comparedwith_solver_status_2.spy - :language: python - -.. _TeeTrue: - -Display of Solver Output ------------------------- - - -To see the output of the solver, use the option ``tee=True`` as in - -.. literalinclude:: src/scripting/spy4scripts_See_solver_output.spy - :language: python - -This can be useful for troubleshooting solver difficulties. - -.. _SolverOpts: - -Sending Options to the Solver ------------------------------ - -Most solvers accept options and Pyomo can pass options through to a -solver. In scripts or callbacks, the options can be attached to the -solver object by adding to its options dictionary as illustrated by this -snippet: - -.. literalinclude:: src/scripting/spy4scripts_Add_option_to_solver.spy - :language: python - -If multiple options are needed, then multiple dictionary entries should -be added. - -Sometimes it is desirable to pass options as part of the call to the -solve function as in this snippet: - -.. literalinclude:: src/scripting/spy4scripts_Add_multiple_options_to_solver.spy - :language: python - -The quoted string is passed directly to the solver. If multiple options -need to be passed to the solver in this way, they should be separated by -a space within the quoted string. Notice that ``tee`` is a Pyomo option -and is solver-independent, while the string argument to ``options`` is -passed to the solver without very little processing by Pyomo. If the -solver does not have a "threads" option, it will probably complain, but -Pyomo will not. - -There are no default values for options on a ``SolverFactory`` -object. If you directly modify its options dictionary, as was done -above, those options will persist across every call to -``optimizer.solve(…)`` unless you delete them from the options -dictionary. You can also pass a dictionary of options into the -``opt.solve(…)`` method using the ``options`` keyword. Those options -will only persist within that solve and temporarily override any -matching options in the options dictionary on the solver object. - -Specifying the Path to a Solver -------------------------------- - -Often, the executables for solvers are in the path; however, for -situations where they are not, the SolverFactory function accepts the -keyword ``executable``, which you can use to set an absolute or relative -path to a solver executable. E.g., - -.. literalinclude:: src/scripting/spy4scripts_Set_path_to_solver_executable.spy - :language: python - -Warm Starts ------------ - -Some solvers support a warm start based on current values of -variables. To use this feature, set the values of variables in the -instance and pass ``warmstart=True`` to the ``solve()`` method. E.g., - -.. literalinclude:: src/scripting/spy4scripts_Pass_warmstart_to_solver.spy - :language: python - -.. note:: - - The Cplex and Gurobi LP file (and Python) interfaces will generate an - MST file with the variable data and hand this off to the solver in - addition to the LP file. - -.. warning:: - - Solvers using the NL file interface (e.g., "gurobi_ampl", "cplexamp") - do not accept warmstart as a keyword to the solve() method as the NL - file format, by default, includes variable initialization data (drawn - from the current value of all variables). - - -Solving Multiple Instances in Parallel --------------------------------------- - -Building and solving Pyomo models in parallel is a common requirement -for many applications. We recommend using MPI for Python (mpi4py) for -this purpose. For more information on mpi4py, see the mpi4py -documentation (https://mpi4py.readthedocs.io/en/stable/). The example -below demonstrates how to use mpi4py to solve two pyomo models in -parallel. The example can be run with the following command: - -.. code-block:: - - mpirun -np 2 python -m mpi4py parallel.py - - -.. literalinclude:: src/scripting/parallel.py - :language: python - - -Changing the temporary directory --------------------------------- - -A "temporary" directory is used for many intermediate files. Normally, -the name of the directory for temporary files is provided by the -operating system, but the user can specify their own directory name. -The pyomo command-line ``--tempdir`` option propagates through to the -TempFileManager service. One can accomplish the same through the -following few lines of code in a script: - -.. literalinclude:: src/scripting/spy4scripts_Specify_temporary_directory_name.spy - :language: python diff --git a/doc/OnlineDocs/howto/solver_recipes.rst b/doc/OnlineDocs/howto/solver_recipes.rst new file mode 100644 index 00000000000..c9be02405e2 --- /dev/null +++ b/doc/OnlineDocs/howto/solver_recipes.rst @@ -0,0 +1,145 @@ +Solver Recipes +============== + + +Accessing Solver Status +----------------------- + +After a solve, the results object has a member ``Solution.Status`` that +contains the solver status. The following snippet shows an example of +access via a ``print`` statement: + +.. literalinclude:: /src/scripting/spy4scripts_Print_solver_status.spy + :language: python + +The use of the Python ``str`` function to cast the value to a be string +makes it easy to test it. In particular, the value 'optimal' indicates +that the solver succeeded. It is also possible to access Pyomo data that +can be compared with the solver status as in the following code snippet: + +.. literalinclude:: /src/scripting/spy4scripts_Pyomo_data_comparedwith_solver_status_1.spy + :language: python + +Alternatively, + +.. literalinclude:: /src/scripting/spy4scripts_Pyomo_data_comparedwith_solver_status_2.spy + :language: python + +.. _TeeTrue: + +Display of Solver Output +------------------------ + + +To see the output of the solver, use the option ``tee=True`` as in + +.. literalinclude:: /src/scripting/spy4scripts_See_solver_output.spy + :language: python + +This can be useful for troubleshooting solver difficulties. + +.. _SolverOpts: + +Sending Options to the Solver +----------------------------- + +Most solvers accept options and Pyomo can pass options through to a +solver. In scripts or callbacks, the options can be attached to the +solver object by adding to its options dictionary as illustrated by this +snippet: + +.. literalinclude:: /src/scripting/spy4scripts_Add_option_to_solver.spy + :language: python + +If multiple options are needed, then multiple dictionary entries should +be added. + +Sometimes it is desirable to pass options as part of the call to the +solve function as in this snippet: + +.. literalinclude:: /src/scripting/spy4scripts_Add_multiple_options_to_solver.spy + :language: python + +The quoted string is passed directly to the solver. If multiple options +need to be passed to the solver in this way, they should be separated by +a space within the quoted string. Notice that ``tee`` is a Pyomo option +and is solver-independent, while the string argument to ``options`` is +passed to the solver without very little processing by Pyomo. If the +solver does not have a "threads" option, it will probably complain, but +Pyomo will not. + +There are no default values for options on a ``SolverFactory`` +object. If you directly modify its options dictionary, as was done +above, those options will persist across every call to +``optimizer.solve(…)`` unless you delete them from the options +dictionary. You can also pass a dictionary of options into the +``opt.solve(…)`` method using the ``options`` keyword. Those options +will only persist within that solve and temporarily override any +matching options in the options dictionary on the solver object. + +Specifying the Path to a Solver +------------------------------- + +Often, the executables for solvers are in the path; however, for +situations where they are not, the SolverFactory function accepts the +keyword ``executable``, which you can use to set an absolute or relative +path to a solver executable. E.g., + +.. literalinclude:: /src/scripting/spy4scripts_Set_path_to_solver_executable.spy + :language: python + +Warm Starts +----------- + +Some solvers support a warm start based on current values of +variables. To use this feature, set the values of variables in the +instance and pass ``warmstart=True`` to the ``solve()`` method. E.g., + +.. literalinclude:: /src/scripting/spy4scripts_Pass_warmstart_to_solver.spy + :language: python + +.. note:: + + The Cplex and Gurobi LP file (and Python) interfaces will generate an + MST file with the variable data and hand this off to the solver in + addition to the LP file. + +.. warning:: + + Solvers using the NL file interface (e.g., "gurobi_ampl", "cplexamp") + do not accept warmstart as a keyword to the solve() method as the NL + file format, by default, includes variable initialization data (drawn + from the current value of all variables). + + +Solving Multiple Instances in Parallel +-------------------------------------- + +Building and solving Pyomo models in parallel is a common requirement +for many applications. We recommend using MPI for Python (mpi4py) for +this purpose. For more information on mpi4py, see the mpi4py +documentation (https://mpi4py.readthedocs.io/en/stable/). The example +below demonstrates how to use mpi4py to solve two pyomo models in +parallel. The example can be run with the following command: + +.. code-block:: + + mpirun -np 2 python -m mpi4py parallel.py + + +.. literalinclude:: /src/scripting/parallel.py + :language: python + + +Changing the temporary directory +-------------------------------- + +A "temporary" directory is used for many intermediate files. Normally, +the name of the directory for temporary files is provided by the +operating system, but the user can specify their own directory name. +The pyomo command-line ``--tempdir`` option propagates through to the +TempFileManager service. One can accomplish the same through the +following few lines of code in a script: + +.. literalinclude:: /src/scripting/spy4scripts_Specify_temporary_directory_name.spy + :language: python diff --git a/doc/OnlineDocs/index.rst b/doc/OnlineDocs/index.rst index ef986a3429f..4d52f25c8d9 100644 --- a/doc/OnlineDocs/index.rst +++ b/doc/OnlineDocs/index.rst @@ -1,55 +1,125 @@ +============================= Pyomo Documentation |release| ============================= -.. image:: /../logos/pyomo/PyomoNewBlue3.png - :scale: 10% - :align: right - -Pyomo is a Python-based, open-source optimization modeling language -with a diverse set of optimization capabilities. - -.. toctree:: - :maxdepth: 2 - - installation.rst - citing_pyomo.rst - pyomo_overview/index.rst - pyomo_modeling_components/index.rst - solving_pyomo_models.rst - working_models.rst - working_abstractmodels/index.rst - model_transformations/index.rst - modeling_extensions/index.rst - tutorial_examples.rst - model_debugging/index.rst - advanced_topics/index.rst - errors.rst - developer_reference/index.rst - library_reference/index.rst - contribution_guide.rst - contributed_packages/index.rst - related_packages.rst - bibliography.rst - -Indices and Tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +.. Note: using rubric here so that the actual content from the toctree + is not considered to be sub-sections of "about" in LaTeX +.. rubric:: About Pyomo + :heading-level: 2 + +.. only:: html + + .. image:: /../logos/pyomo/PyomoNewBlue3.png + :scale: 10% + :align: right + +Pyomo is a Python-based open-source software package that supports a +diverse set of optimization capabilities for formulating, solving, and +analyzing optimization models. + +A core capability of Pyomo is modeling structured optimization +applications. Pyomo can be used to define general symbolic problems, +create specific problem instances, and solve these instances using +commercial and open-source solvers. + +.. only:: latex or text + + .. toctree:: + :maxdepth: 3 + + getting_started/index + howto/index + explanation/index + reference/index -Pyomo Resources ---------------- -The Pyomo home page provides resources for Pyomo users: +.. only:: html -* http://pyomo.org + Contents + ======== + + .. list-table:: + :width: 100% + :class: diataxis + + * - .. tocref:: + :maxdepth: 2 + :titlesonly: + + getting_started/index + - .. tocref:: + :maxdepth: 2 + :titlesonly: + + howto/index + * - .. tocref:: + :maxdepth: 3 + :titlesonly: + + explanation/index + - .. tocref:: + :maxdepth: 3 + :titlesonly: + + reference/index + + +Pyomo Resources +=============== Pyomo development is hosted at GitHub: * https://github.com/Pyomo/pyomo -See the Pyomo Forum for online discussions of Pyomo: +See the Pyomo Forum for online discussions of Pyomo or to ask a question: * http://groups.google.com/group/pyomo-forum/ + +Ask a question on StackOverflow using the ``#pyomo`` tag: + +* https://stackoverflow.com/questions/ask?tags=pyomo + +Additional Pyomo tutorials and examples can be found at the following links: + +* `Pyomo — Optimization Modeling in Python + `_ ([PyomoBookIII]_) + +* `Pyomo Workshop Slides and Exercises + `_ + +* `Prof. Jeffrey Kantor's Pyomo Cookbook + `_ + +* The `companion notebooks `_ + for *Hands-On Mathematical Optimization with Python* + +* `Pyomo Gallery `_ + + +Contributing to Pyomo +===================== + +Interested in contributing code or documentation to the project? Check out our +:doc:`Contribution Guide ` + +Related Packages +================ + +Pyomo is a key dependency for a number of other software packages for +specific domains or customized solution strategies. A non-comprehensive +list of Pyomo-related packages may be found :doc:`here `. + + +Citing Pyomo +============ + +If you use Pyomo in your work, please cite: + + Bynum, Michael L., Gabriel A. Hackebeil, William E. Hart, Carl D. Laird, + Bethany L. Nicholson, John D. Siirola, Jean-Paul Watson, and + David L. Woodruff. Pyomo - Optimization Modeling in Python, 3rd + Edition. Springer, 2021. + +Additionally, several Pyomo capabilities and subpackages are described +in further detail in separate :ref:`publications`. diff --git a/doc/OnlineDocs/library_reference/aml/index.rst b/doc/OnlineDocs/library_reference/aml/index.rst deleted file mode 100644 index f06ca35b087..00000000000 --- a/doc/OnlineDocs/library_reference/aml/index.rst +++ /dev/null @@ -1,85 +0,0 @@ -AML Library Reference -===================== - -The following modeling components make up the core of the Pyomo -Algebraic Modeling Language (AML). These classes are all available -through the `pyomo.environ` namespace. - -.. currentmodule:: pyomo.environ - -.. autosummary:: - - ConcreteModel - AbstractModel - Block - Set - RangeSet - Param - Var - Objective - Constraint - ExternalFunction - Reference - SOSConstraint - - -AML Component Documentation -~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: ConcreteModel - :show-inheritance: - :members: - :inherited-members: - -.. autoclass:: AbstractModel - :show-inheritance: - :members: - :inherited-members: - -.. autoclass:: Block - :show-inheritance: - :members: - :inherited-members: - -.. autoclass:: Constraint - :show-inheritance: - :members: - :inherited-members: - -.. autoclass:: ExternalFunction - :show-inheritance: - :special-members: __init__ - :members: - :inherited-members: - -.. autoclass:: Objective - :show-inheritance: - :members: - :inherited-members: - -.. autoclass:: Param - :show-inheritance: - :members: - :inherited-members: - -.. autoclass:: RangeSet - :show-inheritance: - :members: - :inherited-members: - -.. autofunction:: Reference - -.. autoclass:: Set - :show-inheritance: - :members: - :inherited-members: - -.. autoclass:: Var - :show-inheritance: - :members: - :inherited-members: - -.. autoclass:: SOSConstraint - :show-inheritance: - :members: - :inherited-members: - diff --git a/doc/OnlineDocs/library_reference/appsi/appsi.base.rst b/doc/OnlineDocs/library_reference/appsi/appsi.base.rst deleted file mode 100644 index 1b6d5761182..00000000000 --- a/doc/OnlineDocs/library_reference/appsi/appsi.base.rst +++ /dev/null @@ -1,47 +0,0 @@ -APPSI Base Classes -================== - -.. autoclass:: pyomo.contrib.appsi.base.TerminationCondition - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - -.. autoclass:: pyomo.contrib.appsi.base.Results - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - -.. autoclass:: pyomo.contrib.appsi.base.Solver - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - -.. autoclass:: pyomo.contrib.appsi.base.PersistentSolver - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - -.. autoclass:: pyomo.contrib.appsi.base.SolverConfig - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - :exclude-members: NoArgument - -.. autoclass:: pyomo.contrib.appsi.base.MIPSolverConfig - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - :exclude-members: NoArgument - -.. autoclass:: pyomo.contrib.appsi.base.UpdateConfig - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - :exclude-members: NoArgument diff --git a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.cbc.rst b/doc/OnlineDocs/library_reference/appsi/appsi.solvers.cbc.rst deleted file mode 100644 index a0a2f7d0f27..00000000000 --- a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.cbc.rst +++ /dev/null @@ -1,15 +0,0 @@ -Cbc -=== - -.. autoclass:: pyomo.contrib.appsi.solvers.cbc.CbcConfig - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - :exclude-members: NoArgument - -.. autoclass:: pyomo.contrib.appsi.solvers.cbc.Cbc - :members: - :inherited-members: - :undoc-members: - :show-inheritance: diff --git a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.cplex.rst b/doc/OnlineDocs/library_reference/appsi/appsi.solvers.cplex.rst deleted file mode 100644 index 0906fd7ea76..00000000000 --- a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.cplex.rst +++ /dev/null @@ -1,21 +0,0 @@ -Cplex -===== - -.. autoclass:: pyomo.contrib.appsi.solvers.cplex.CplexConfig - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - :exclude-members: NoArgument - -.. autoclass:: pyomo.contrib.appsi.solvers.cplex.CplexResults - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - -.. autoclass:: pyomo.contrib.appsi.solvers.cplex.Cplex - :members: - :inherited-members: - :undoc-members: - :show-inheritance: diff --git a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.highs.rst b/doc/OnlineDocs/library_reference/appsi/appsi.solvers.highs.rst deleted file mode 100644 index f2f72d0ad85..00000000000 --- a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.highs.rst +++ /dev/null @@ -1,14 +0,0 @@ -HiGHS -===== - -.. autoclass:: pyomo.contrib.appsi.solvers.highs.HighsResults - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - -.. autoclass:: pyomo.contrib.appsi.solvers.highs.Highs - :members: - :inherited-members: - :undoc-members: - :show-inheritance: diff --git a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.ipopt.rst b/doc/OnlineDocs/library_reference/appsi/appsi.solvers.ipopt.rst deleted file mode 100644 index 0d095644100..00000000000 --- a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.ipopt.rst +++ /dev/null @@ -1,14 +0,0 @@ -Ipopt -===== - -.. autoclass:: pyomo.contrib.appsi.solvers.ipopt.IpoptConfig - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - -.. autoclass:: pyomo.contrib.appsi.solvers.ipopt.Ipopt - :members: - :inherited-members: - :undoc-members: - :show-inheritance: diff --git a/doc/OnlineDocs/library_reference/common/config.rst b/doc/OnlineDocs/library_reference/common/config.rst deleted file mode 100644 index c5dc607977a..00000000000 --- a/doc/OnlineDocs/library_reference/common/config.rst +++ /dev/null @@ -1,85 +0,0 @@ -pyomo.common.config -=================== - -.. currentmodule:: pyomo.common.config - -Core classes -~~~~~~~~~~~~ - -.. autosummary:: - - ConfigDict - ConfigList - ConfigValue - -Utilities -~~~~~~~~~ - -.. autosummary:: - - document_kwargs_from_configdict - - -Domain validators -~~~~~~~~~~~~~~~~~ - -.. autosummary:: - - Bool - Integer - PositiveInt - NegativeInt - NonNegativeInt - NonPositiveInt - PositiveFloat - NegativeFloat - NonPositiveFloat - NonNegativeFloat - In - IsInstance - InEnum - ListOf - Module - Path - PathList - DynamicImplicitDomain - -.. autoclass:: ConfigBase - :members: - :undoc-members: - -.. autoclass:: ConfigDict - :show-inheritance: - :members: - :undoc-members: - -.. autoclass:: ConfigList - :show-inheritance: - :members: - :undoc-members: - -.. autoclass:: ConfigValue - :show-inheritance: - :members: - :undoc-members: - -.. autodecorator:: document_kwargs_from_configdict - -.. autofunction:: Bool -.. autofunction:: Integer -.. autofunction:: PositiveInt -.. autofunction:: NegativeInt -.. autofunction:: NonNegativeInt -.. autofunction:: NonPositiveInt -.. autofunction:: PositiveFloat -.. autofunction:: NegativeFloat -.. autofunction:: NonPositiveFloat -.. autofunction:: NonNegativeFloat -.. autoclass:: In -.. autoclass:: IsInstance -.. autoclass:: InEnum -.. autoclass:: ListOf -.. autoclass:: Module -.. autoclass:: Path -.. autoclass:: PathList -.. autoclass:: DynamicImplicitDomain diff --git a/doc/OnlineDocs/library_reference/common/dependencies.rst b/doc/OnlineDocs/library_reference/common/dependencies.rst deleted file mode 100644 index 18d5647681c..00000000000 --- a/doc/OnlineDocs/library_reference/common/dependencies.rst +++ /dev/null @@ -1,7 +0,0 @@ - -pyomo.common.dependencies -========================= - -.. automodule:: pyomo.common.dependencies - :members: - :member-order: bysource diff --git a/doc/OnlineDocs/library_reference/common/deprecation.rst b/doc/OnlineDocs/library_reference/common/deprecation.rst deleted file mode 100644 index 41066c040c4..00000000000 --- a/doc/OnlineDocs/library_reference/common/deprecation.rst +++ /dev/null @@ -1,6 +0,0 @@ -pyomo.common.deprecation -======================== - -.. automodule:: pyomo.common.deprecation - :members: - :member-order: bysource diff --git a/doc/OnlineDocs/library_reference/common/errors.rst b/doc/OnlineDocs/library_reference/common/errors.rst deleted file mode 100644 index 7b2bd01fe32..00000000000 --- a/doc/OnlineDocs/library_reference/common/errors.rst +++ /dev/null @@ -1,6 +0,0 @@ -pyomo.common.errors -=================== - -.. automodule:: pyomo.common.errors - :members: - :member-order: bysource diff --git a/doc/OnlineDocs/library_reference/common/fileutils.rst b/doc/OnlineDocs/library_reference/common/fileutils.rst deleted file mode 100644 index e582f4c2e94..00000000000 --- a/doc/OnlineDocs/library_reference/common/fileutils.rst +++ /dev/null @@ -1,6 +0,0 @@ -pyomo.common.fileutils -====================== - -.. automodule:: pyomo.common.fileutils - :members: - :member-order: bysource diff --git a/doc/OnlineDocs/library_reference/common/formatting.rst b/doc/OnlineDocs/library_reference/common/formatting.rst deleted file mode 100644 index 25f0ef2404c..00000000000 --- a/doc/OnlineDocs/library_reference/common/formatting.rst +++ /dev/null @@ -1,6 +0,0 @@ -pyomo.common.formatting -======================= - -.. automodule:: pyomo.common.formatting - :members: - :member-order: bysource diff --git a/doc/OnlineDocs/library_reference/common/index.rst b/doc/OnlineDocs/library_reference/common/index.rst deleted file mode 100644 index c9c99008250..00000000000 --- a/doc/OnlineDocs/library_reference/common/index.rst +++ /dev/null @@ -1,18 +0,0 @@ -Common Utilities -================ - -Pyomo provides a set of general-purpose utilities through -``pyomo.common``. These utilities are self-contained and do not import -or rely on any other parts of Pyomo. - -.. toctree:: - :maxdepth: 1 - - config.rst - dependencies.rst - deprecation.rst - errors.rst - fileutils.rst - formatting.rst - tempfiles.rst - timing.rst diff --git a/doc/OnlineDocs/library_reference/common/tempfiles.rst b/doc/OnlineDocs/library_reference/common/tempfiles.rst deleted file mode 100644 index 03cb056dffe..00000000000 --- a/doc/OnlineDocs/library_reference/common/tempfiles.rst +++ /dev/null @@ -1,7 +0,0 @@ - -pyomo.common.tempfiles -====================== - -.. automodule:: pyomo.common.tempfiles - :members: - :member-order: bysource diff --git a/doc/OnlineDocs/library_reference/common/timing.rst b/doc/OnlineDocs/library_reference/common/timing.rst deleted file mode 100644 index 06b6fc0f588..00000000000 --- a/doc/OnlineDocs/library_reference/common/timing.rst +++ /dev/null @@ -1,7 +0,0 @@ - -pyomo.common.timing -=================== - -.. automodule:: pyomo.common.timing - :members: - :member-order: bysource diff --git a/doc/OnlineDocs/library_reference/expressions/building.rst b/doc/OnlineDocs/library_reference/expressions/building.rst deleted file mode 100644 index 8ffcca9e310..00000000000 --- a/doc/OnlineDocs/library_reference/expressions/building.rst +++ /dev/null @@ -1,10 +0,0 @@ - -Utilities to Build Expressions -============================== - -.. autofunction:: pyomo.core.util.prod -.. autofunction:: pyomo.core.util.quicksum -.. autofunction:: pyomo.core.util.sum_product -.. autodata:: pyomo.core.util.summation -.. autodata:: pyomo.core.util.dot_product - diff --git a/doc/OnlineDocs/library_reference/expressions/classes.rst b/doc/OnlineDocs/library_reference/expressions/classes.rst deleted file mode 100644 index 4d448d2da6a..00000000000 --- a/doc/OnlineDocs/library_reference/expressions/classes.rst +++ /dev/null @@ -1,105 +0,0 @@ -Core Classes -============ - -The following are the two core classes documented here: - - * :class:`NumericValue` - * :class:`NumericExpression` - -The remaining classes are the public classes for expressions, which -developers may need to know about. The methods for these classes are not -documented because they are described in the -:class:`NumericExpression` class. - -Sets with Expression Types --------------------------- - -The following sets can be used to develop visitor patterns for -Pyomo expressions. - -.. autodata:: pyomo.core.expr.numvalue.native_numeric_types -.. autodata:: pyomo.core.expr.numvalue.native_types -.. autodata:: pyomo.core.expr.numvalue.nonpyomo_leaf_types - -NumericValue and NumericExpression ----------------------------------- - -.. autoclass:: pyomo.core.expr.numvalue.NumericValue - :members: - :special-members: - :private-members: - -.. autoclass:: pyomo.core.expr.NumericExpression - :members: - :show-inheritance: - :special-members: - :private-members: - -Other Public Classes --------------------- - -.. autoclass:: pyomo.core.expr.NegationExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.ExternalFunctionExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.ProductExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.DivisionExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.InequalityExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.EqualityExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.SumExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.GetItemExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.Expr_ifExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.UnaryFunctionExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: - -.. autoclass:: pyomo.core.expr.AbsExpression - :members: - :show-inheritance: - :undoc-members: - :private-members: diff --git a/doc/OnlineDocs/library_reference/expressions/context_managers.rst b/doc/OnlineDocs/library_reference/expressions/context_managers.rst deleted file mode 100644 index ae6884d684f..00000000000 --- a/doc/OnlineDocs/library_reference/expressions/context_managers.rst +++ /dev/null @@ -1,10 +0,0 @@ - -Context Managers -================ - -.. autoclass:: pyomo.core.expr.nonlinear_expression - :members: - -.. autoclass:: pyomo.core.expr.linear_expression - :members: - diff --git a/doc/OnlineDocs/library_reference/expressions/managing.rst b/doc/OnlineDocs/library_reference/expressions/managing.rst deleted file mode 100644 index 369dd3aace1..00000000000 --- a/doc/OnlineDocs/library_reference/expressions/managing.rst +++ /dev/null @@ -1,19 +0,0 @@ - -Utilities to Manage and Analyze Expressions -=========================================== - -Functions -~~~~~~~~~ - -.. autofunction:: pyomo.core.expr.expression_to_string -.. autofunction:: pyomo.core.expr.decompose_term -.. autofunction:: pyomo.core.expr.clone_expression -.. autofunction:: pyomo.core.expr.evaluate_expression -.. autofunction:: pyomo.core.expr.identify_components -.. autofunction:: pyomo.core.expr.identify_variables -.. autofunction:: pyomo.core.expr.differentiate - -Classes -~~~~~~~ - -.. autoclass:: pyomo.core.expr.symbol_map.SymbolMap diff --git a/doc/OnlineDocs/library_reference/expressions/visitors.rst b/doc/OnlineDocs/library_reference/expressions/visitors.rst deleted file mode 100644 index 77cffe7905f..00000000000 --- a/doc/OnlineDocs/library_reference/expressions/visitors.rst +++ /dev/null @@ -1,20 +0,0 @@ - -Visitor Classes -=============== - -.. autoclass:: pyomo.core.expr.StreamBasedExpressionVisitor - :members: - :inherited-members: - -.. autoclass:: pyomo.core.expr.SimpleExpressionVisitor - :members: - :inherited-members: - -.. autoclass:: pyomo.core.expr.ExpressionValueVisitor - :members: - :inherited-members: - -.. autoclass:: pyomo.core.expr.ExpressionReplacementVisitor - :members: - :inherited-members: - diff --git a/doc/OnlineDocs/library_reference/kernel/base.rst b/doc/OnlineDocs/library_reference/kernel/base.rst deleted file mode 100644 index 47a2afef68d..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/base.rst +++ /dev/null @@ -1,6 +0,0 @@ -Base Object Storage Interface -============================= - -.. automodule:: pyomo.core.kernel.base - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/block.rst b/doc/OnlineDocs/library_reference/kernel/block.rst deleted file mode 100644 index a61c12610eb..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/block.rst +++ /dev/null @@ -1,26 +0,0 @@ -Blocks -====== - -Summary -~~~~~~~ -.. autosummary:: - - pyomo.core.kernel.block.block - pyomo.core.kernel.block.block_tuple - pyomo.core.kernel.block.block_list - pyomo.core.kernel.block.block_dict - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: pyomo.core.kernel.block.block - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.block.block_tuple - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.block.block_list - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.block.block_dict - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/constraint.rst b/doc/OnlineDocs/library_reference/kernel/constraint.rst deleted file mode 100644 index 1645e57f9f2..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/constraint.rst +++ /dev/null @@ -1,34 +0,0 @@ -Constraints -=========== - -Summary -~~~~~~~ -.. autosummary:: - - pyomo.core.kernel.constraint.constraint - pyomo.core.kernel.constraint.linear_constraint - pyomo.core.kernel.constraint.constraint_tuple - pyomo.core.kernel.constraint.constraint_list - pyomo.core.kernel.constraint.constraint_dict - pyomo.core.kernel.matrix_constraint.matrix_constraint - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: pyomo.core.kernel.constraint.constraint - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.constraint.linear_constraint - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.constraint.constraint_tuple - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.constraint.constraint_list - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.constraint.constraint_dict - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.matrix_constraint.matrix_constraint - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/dict_container.rst b/doc/OnlineDocs/library_reference/kernel/dict_container.rst deleted file mode 100644 index 6e710fa76eb..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/dict_container.rst +++ /dev/null @@ -1,8 +0,0 @@ -Dict-like Object Storage -======================== - -.. autoclass:: pyomo.core.kernel.dict_container.DictContainer - :show-inheritance: - :members: - :inherited-members: - :special-members: diff --git a/doc/OnlineDocs/library_reference/kernel/expression.rst b/doc/OnlineDocs/library_reference/kernel/expression.rst deleted file mode 100644 index b2d4c2d1b35..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/expression.rst +++ /dev/null @@ -1,26 +0,0 @@ -Expressions -=========== - -Summary -~~~~~~~ -.. autosummary:: - - pyomo.core.kernel.expression.expression - pyomo.core.kernel.expression.expression_tuple - pyomo.core.kernel.expression.expression_list - pyomo.core.kernel.expression.expression_dict - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: pyomo.core.kernel.expression.expression - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.expression.expression_tuple - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.expression.expression_list - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.expression.expression_dict - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/heterogeneous_container.rst b/doc/OnlineDocs/library_reference/kernel/heterogeneous_container.rst deleted file mode 100644 index 74dad1d754e..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/heterogeneous_container.rst +++ /dev/null @@ -1,6 +0,0 @@ -Heterogeneous Object Containers -=============================== - -.. automodule:: pyomo.core.kernel.heterogeneous_container - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/homogeneous_container.rst b/doc/OnlineDocs/library_reference/kernel/homogeneous_container.rst deleted file mode 100644 index b722e026dc1..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/homogeneous_container.rst +++ /dev/null @@ -1,6 +0,0 @@ -Homogeneous Object Containers -============================= - -.. automodule:: pyomo.core.kernel.homogeneous_container - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/list_container.rst b/doc/OnlineDocs/library_reference/kernel/list_container.rst deleted file mode 100644 index b82c6d9c6f0..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/list_container.rst +++ /dev/null @@ -1,8 +0,0 @@ -List-like Object Storage -======================== - -.. autoclass:: pyomo.core.kernel.list_container.ListContainer - :show-inheritance: - :members: - :inherited-members: - :special-members: diff --git a/doc/OnlineDocs/library_reference/kernel/objective.rst b/doc/OnlineDocs/library_reference/kernel/objective.rst deleted file mode 100644 index 77f26d2f441..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/objective.rst +++ /dev/null @@ -1,26 +0,0 @@ -Objectives -========== - -Summary -~~~~~~~ -.. autosummary:: - - pyomo.core.kernel.objective.objective - pyomo.core.kernel.objective.objective_tuple - pyomo.core.kernel.objective.objective_list - pyomo.core.kernel.objective.objective_dict - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: pyomo.core.kernel.objective.objective - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.objective.objective_tuple - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.objective.objective_list - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.objective.objective_dict - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/parameter.rst b/doc/OnlineDocs/library_reference/kernel/parameter.rst deleted file mode 100644 index 212b0cb125e..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/parameter.rst +++ /dev/null @@ -1,30 +0,0 @@ -Parameters -========== - -Summary -~~~~~~~ -.. autosummary:: - - pyomo.core.kernel.parameter.parameter - pyomo.core.kernel.parameter.functional_value - pyomo.core.kernel.parameter.parameter_tuple - pyomo.core.kernel.parameter.parameter_list - pyomo.core.kernel.parameter.parameter_dict - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: pyomo.core.kernel.parameter.parameter - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.parameter.functional_value - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.parameter.parameter_tuple - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.parameter.parameter_list - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.parameter.parameter_dict - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/piecewise/piecewise.rst b/doc/OnlineDocs/library_reference/kernel/piecewise/piecewise.rst deleted file mode 100644 index 25c250d6559..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/piecewise/piecewise.rst +++ /dev/null @@ -1,53 +0,0 @@ -Single-variate Piecewise Functions -================================== - -Summary -~~~~~~~ -.. autosummary:: - pyomo.core.kernel.piecewise_library.transforms.piecewise - pyomo.core.kernel.piecewise_library.transforms.PiecewiseLinearFunction - pyomo.core.kernel.piecewise_library.transforms.TransformedPiecewiseLinearFunction - pyomo.core.kernel.piecewise_library.transforms.piecewise_convex - pyomo.core.kernel.piecewise_library.transforms.piecewise_sos2 - pyomo.core.kernel.piecewise_library.transforms.piecewise_dcc - pyomo.core.kernel.piecewise_library.transforms.piecewise_cc - pyomo.core.kernel.piecewise_library.transforms.piecewise_mc - pyomo.core.kernel.piecewise_library.transforms.piecewise_inc - pyomo.core.kernel.piecewise_library.transforms.piecewise_dlog - pyomo.core.kernel.piecewise_library.transforms.piecewise_log - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autofunction:: pyomo.core.kernel.piecewise_library.transforms.piecewise -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.PiecewiseLinearFunction - :show-inheritance: - :special-members: __call__ - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.TransformedPiecewiseLinearFunction - :show-inheritance: - :special-members: __call__ - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.piecewise_convex - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.piecewise_sos2 - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.piecewise_dcc - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.piecewise_cc - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.piecewise_mc - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.piecewise_inc - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.piecewise_dlog - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms.piecewise_log - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/piecewise/piecewise_nd.rst b/doc/OnlineDocs/library_reference/kernel/piecewise/piecewise_nd.rst deleted file mode 100644 index e5c71a4ec15..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/piecewise/piecewise_nd.rst +++ /dev/null @@ -1,25 +0,0 @@ -Multi-variate Piecewise Functions -================================= - -Summary -~~~~~~~ -.. autosummary:: - pyomo.core.kernel.piecewise_library.transforms_nd.piecewise_nd - pyomo.core.kernel.piecewise_library.transforms_nd.PiecewiseLinearFunctionND - pyomo.core.kernel.piecewise_library.transforms_nd.TransformedPiecewiseLinearFunctionND - pyomo.core.kernel.piecewise_library.transforms_nd.piecewise_nd_cc - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autofunction:: pyomo.core.kernel.piecewise_library.transforms_nd.piecewise_nd -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms_nd.PiecewiseLinearFunctionND - :show-inheritance: - :special-members: __call__ - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms_nd.TransformedPiecewiseLinearFunctionND - :show-inheritance: - :special-members: __call__ - :members: -.. autoclass:: pyomo.core.kernel.piecewise_library.transforms_nd.piecewise_nd_cc - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/piecewise/util.rst b/doc/OnlineDocs/library_reference/kernel/piecewise/util.rst deleted file mode 100644 index 52b7b1de8f7..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/piecewise/util.rst +++ /dev/null @@ -1,6 +0,0 @@ -Utilities for Piecewise Functions -================================= - -.. automodule:: pyomo.core.kernel.piecewise_library.util - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/sos.rst b/doc/OnlineDocs/library_reference/kernel/sos.rst deleted file mode 100644 index 0f3f5fedf54..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/sos.rst +++ /dev/null @@ -1,30 +0,0 @@ -Special Ordered Sets -==================== - -Summary -~~~~~~~ -.. autosummary:: - - pyomo.core.kernel.sos.sos - pyomo.core.kernel.sos.sos1 - pyomo.core.kernel.sos.sos2 - pyomo.core.kernel.sos.sos_tuple - pyomo.core.kernel.sos.sos_list - pyomo.core.kernel.sos.sos_dict - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: pyomo.core.kernel.sos.sos - :show-inheritance: - :members: -.. autofunction:: pyomo.core.kernel.sos.sos1 -.. autofunction:: pyomo.core.kernel.sos.sos2 -.. autoclass:: pyomo.core.kernel.sos.sos_tuple - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.sos.sos_list - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.sos.sos_dict - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/suffix.rst b/doc/OnlineDocs/library_reference/kernel/suffix.rst deleted file mode 100644 index d833f56daa9..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/suffix.rst +++ /dev/null @@ -1,6 +0,0 @@ -Suffixes -======== - -.. automodule:: pyomo.core.kernel.suffix - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/kernel/syntax_comparison.rst b/doc/OnlineDocs/library_reference/kernel/syntax_comparison.rst deleted file mode 100644 index 71c739214e3..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/syntax_comparison.rst +++ /dev/null @@ -1,133 +0,0 @@ -.. _kernel_syntax_comparison: - -Syntax Comparison Table (pyomo.kernel vs pyomo.environ) -======================================================= - -.. list-table:: - :header-rows: 1 - :align: center - - * - - - **pyomo.kernel** - - **pyomo.environ** - - * - **Import** - - .. literalinclude:: examples/kernel_example_Import_Syntax.spy - :language: python - - .. literalinclude:: examples/aml_example_Import_Syntax.spy - :language: python - * - **Model** [#models_fn]_ - - .. literalinclude:: examples/kernel_example_AbstractModels.spy - :language: python - .. literalinclude:: examples/kernel_example_ConcreteModels.spy - :language: python - - .. literalinclude:: examples/aml_example_AbstractModels.spy - :language: python - .. literalinclude:: examples/aml_example_ConcreteModels.spy - :language: python - * - **Set** [#sets_fn]_ - - .. literalinclude:: examples/kernel_example_Sets_1.spy - :language: python - .. literalinclude:: examples/kernel_example_Sets_2.spy - :language: python - - .. literalinclude:: examples/aml_example_Sets_1.spy - :language: python - .. literalinclude:: examples/aml_example_Sets_2.spy - :language: python - * - **Parameter** [#parameters_fn]_ - - .. literalinclude:: examples/kernel_example_Parameters_single.spy - :language: python - .. literalinclude:: examples/kernel_example_Parameters_dict.spy - :language: python - .. literalinclude:: examples/kernel_example_Parameters_list.spy - :language: python - - .. literalinclude:: examples/aml_example_Parameters_single.spy - :language: python - .. literalinclude:: examples/aml_example_Parameters_dict.spy - :language: python - .. literalinclude:: examples/aml_example_Parameters_list.spy - :language: python - * - **Variable** - - .. literalinclude:: examples/kernel_example_Variables_single.spy - :language: python - .. literalinclude:: examples/kernel_example_Variables_dict.spy - :language: python - .. literalinclude:: examples/kernel_example_Variables_list.spy - :language: python - - .. literalinclude:: examples/aml_example_Variables_single.spy - :language: python - .. literalinclude:: examples/aml_example_Variables_dict.spy - :language: python - .. literalinclude:: examples/aml_example_Variables_list.spy - :language: python - * - **Constraint** - - .. literalinclude:: examples/kernel_example_Constraints_single.spy - :language: python - .. literalinclude:: examples/kernel_example_Constraints_dict.spy - :language: python - .. literalinclude:: examples/kernel_example_Constraints_list.spy - :language: python - - .. literalinclude:: examples/aml_example_Constraints_single.spy - :language: python - .. literalinclude:: examples/aml_example_Constraints_dict.spy - :language: python - .. literalinclude:: examples/aml_example_Constraints_list.spy - :language: python - * - **Expression** - - .. literalinclude:: examples/kernel_example_Expressions_single.spy - :language: python - .. literalinclude:: examples/kernel_example_Expressions_dict.spy - :language: python - .. literalinclude:: examples/kernel_example_Expressions_list.spy - :language: python - - .. literalinclude:: examples/aml_example_Expressions_single.spy - :language: python - .. literalinclude:: examples/aml_example_Expressions_dict.spy - :language: python - .. literalinclude:: examples/aml_example_Expressions_list.spy - :language: python - * - **Objective** - - .. literalinclude:: examples/kernel_example_Objectives_single.spy - :language: python - .. literalinclude:: examples/kernel_example_Objectives_dict.spy - :language: python - .. literalinclude:: examples/kernel_example_Objectives_list.spy - :language: python - - .. literalinclude:: examples/aml_example_Objectives_single.spy - :language: python - .. literalinclude:: examples/aml_example_Objectives_dict.spy - :language: python - .. literalinclude:: examples/aml_example_Objectives_list.spy - :language: python - * - **SOS** [#sos_fn]_ - - .. literalinclude:: examples/kernel_example_SOS_single.spy - :language: python - .. literalinclude:: examples/kernel_example_SOS_dict.spy - :language: python - .. literalinclude:: examples/kernel_example_SOS_list.spy - :language: python - - .. literalinclude:: examples/aml_example_SOS_single.spy - :language: python - .. literalinclude:: examples/aml_example_SOS_dict.spy - :language: python - .. literalinclude:: examples/aml_example_SOS_list.spy - :language: python - * - **Suffix** - - .. literalinclude:: examples/kernel_example_Suffix_single.spy - :language: python - .. literalinclude:: examples/kernel_example_Suffix_dict.spy - :language: python - - .. literalinclude:: examples/aml_example_Suffix_single.spy - :language: python - .. literalinclude:: examples/aml_example_Suffix_dict.spy - :language: python - * - **Piecewise** [#pw_fn]_ - - .. literalinclude:: examples/kernel_example_Piecewise_1d.spy - :language: python - - .. literalinclude:: examples/aml_example_Piecewise_1d.spy - :language: python -.. [#models_fn] :python:`pyomo.kernel` does not include an alternative to the :python:`AbstractModel` component from :python:`pyomo.environ`. All data necessary to build a model must be imported by the user. -.. [#sets_fn] :python:`pyomo.kernel` does not include an alternative to the Pyomo :python:`Set` component from :python:`pyomo.environ`. -.. [#parameters_fn] :python:`pyomo.kernel.parameter` objects are always mutable. -.. [#sos_fn] Special Ordered Sets -.. [#pw_fn] Both :python:`pyomo.kernel.piecewise` and :python:`pyomo.kernel.piecewise_nd` create objects that are sub-classes of :python:`pyomo.kernel.block`. Thus, these objects can be stored in containers such as :python:`pyomo.kernel.block_dict` and :python:`pyomo.kernel.block_list`. diff --git a/doc/OnlineDocs/library_reference/kernel/tuple_container.rst b/doc/OnlineDocs/library_reference/kernel/tuple_container.rst deleted file mode 100644 index 8a2798753c4..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/tuple_container.rst +++ /dev/null @@ -1,8 +0,0 @@ -Tuple-like Object Storage -========================= - -.. autoclass:: pyomo.core.kernel.tuple_container.TupleContainer - :show-inheritance: - :members: - :inherited-members: - :special-members: diff --git a/doc/OnlineDocs/library_reference/kernel/variable.rst b/doc/OnlineDocs/library_reference/kernel/variable.rst deleted file mode 100644 index f743cee4003..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/variable.rst +++ /dev/null @@ -1,26 +0,0 @@ -Variables -========= - -Summary -~~~~~~~ -.. autosummary:: - - pyomo.core.kernel.variable.variable - pyomo.core.kernel.variable.variable_tuple - pyomo.core.kernel.variable.variable_list - pyomo.core.kernel.variable.variable_dict - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: pyomo.core.kernel.variable.variable - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.variable.variable_tuple - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.variable.variable_list - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.variable.variable_dict - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/library_reference/solvers/cplex_persistent.rst b/doc/OnlineDocs/library_reference/solvers/cplex_persistent.rst deleted file mode 100644 index ee28ecda5e5..00000000000 --- a/doc/OnlineDocs/library_reference/solvers/cplex_persistent.rst +++ /dev/null @@ -1,7 +0,0 @@ -CPLEXPersistent -================ - -.. autoclass:: pyomo.solvers.plugins.solvers.cplex_persistent.CPLEXPersistent - :members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/library_reference/solvers/xpress_persistent.rst b/doc/OnlineDocs/library_reference/solvers/xpress_persistent.rst deleted file mode 100644 index 2a98b4a09db..00000000000 --- a/doc/OnlineDocs/library_reference/solvers/xpress_persistent.rst +++ /dev/null @@ -1,7 +0,0 @@ -XpressPersistent -================ - -.. autoclass:: pyomo.solvers.plugins.solvers.xpress_persistent.XpressPersistent - :members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/make.bat b/doc/OnlineDocs/make.bat deleted file mode 100644 index 5c7a2549fca..00000000000 --- a/doc/OnlineDocs/make.bat +++ /dev/null @@ -1,36 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build -set SPHINXPROJ=pyomocontrib_simplemodel - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% - -:end -popd diff --git a/doc/OnlineDocs/model_debugging/FAQ.rst b/doc/OnlineDocs/model_debugging/FAQ.rst deleted file mode 100644 index eef8ad9bd56..00000000000 --- a/doc/OnlineDocs/model_debugging/FAQ.rst +++ /dev/null @@ -1,29 +0,0 @@ -FAQ -=== - -#. Solver not found - -Solvers are **not** distributed with Pyomo and must be installed -separately by the user. In general, the solver executable must be accessible using a terminal command. For example, ipopt can only be used as a solver if -the command - -:: - - $ ipopt - -invokes the solver. For example - -:: - - $ ipopt -? - usage: ipopt [options] stub [-AMPL] [ ...] - - Options: - -- {end of options} - -= {show name= possibilities} - -? {show usage} - -bf {read boundsfile f} - -e {suppress echoing of assignments} - -of {write .sol file to file f} - -s {write .sol file (without -AMPL)} - -v {just show version} diff --git a/doc/OnlineDocs/model_debugging/getting_help.rst b/doc/OnlineDocs/model_debugging/getting_help.rst deleted file mode 100644 index acc7c60b29e..00000000000 --- a/doc/OnlineDocs/model_debugging/getting_help.rst +++ /dev/null @@ -1,10 +0,0 @@ -Getting Help -============ - -See the Pyomo Forum for online discussions of Pyomo or to ask a question: - -* http://groups.google.com/group/pyomo-forum/ - -Ask a question on StackOverflow using the `#pyomo` tag: - -* https://stackoverflow.com/questions/ask?tags=pyomo diff --git a/doc/OnlineDocs/model_debugging/index.rst b/doc/OnlineDocs/model_debugging/index.rst deleted file mode 100644 index e1dafe45e0a..00000000000 --- a/doc/OnlineDocs/model_debugging/index.rst +++ /dev/null @@ -1,9 +0,0 @@ -Debugging Pyomo Models -====================== - -.. toctree:: - :maxdepth: 1 - - model_interrogation.rst - FAQ.rst - getting_help.rst diff --git a/doc/OnlineDocs/model_debugging/model_interrogation.rst b/doc/OnlineDocs/model_debugging/model_interrogation.rst deleted file mode 100644 index 4e019da88eb..00000000000 --- a/doc/OnlineDocs/model_debugging/model_interrogation.rst +++ /dev/null @@ -1,32 +0,0 @@ -Interrogating Pyomo Models -========================== - -.. doctest:: - :hide: - - >>> import pyomo.environ as pyo - >>> from pyomo.opt import SolverFactory - >>> model = pyo.ConcreteModel() - >>> model.n = pyo.Param(default=4) - >>> model.x = pyo.Var(pyo.RangeSet(model.n), within=pyo.Binary) - >>> def o_rule(model): - ... return pyo.summation(model.x) - >>> model.o = pyo.Objective(rule=o_rule) - >>> model.c = pyo.Constraint(expr=model.x[2] + model.x[3] >= 1) - >>> r = SolverFactory('glpk').solve(model) - -Show solver output by adding the `tee=True` option when calling the -`solve` function - -.. doctest:: - - >>> SolverFactory('glpk').solve(model, tee=True) # doctest: +SKIP - -You can use the `pprint` function to display the model or individual -model components - -.. doctest:: - - >>> model.pprint() # doctest: +SKIP - >>> model.x.pprint() # doctest: +SKIP - diff --git a/doc/OnlineDocs/modeling_extensions/bilevel.rst b/doc/OnlineDocs/modeling_extensions/bilevel.rst deleted file mode 100644 index 5e9ee9b0a7c..00000000000 --- a/doc/OnlineDocs/modeling_extensions/bilevel.rst +++ /dev/null @@ -1,6 +0,0 @@ -Bilevel Programming -=================== - -``pyomo.bilevel`` provides extensions supporting modeling of multi-level -optimization problems. - diff --git a/doc/OnlineDocs/modeling_extensions/gdp/index.rst b/doc/OnlineDocs/modeling_extensions/gdp/index.rst deleted file mode 100644 index 0c8529c60cb..00000000000 --- a/doc/OnlineDocs/modeling_extensions/gdp/index.rst +++ /dev/null @@ -1,79 +0,0 @@ -.. _gdp-main-page: - -*********************************** -Generalized Disjunctive Programming -*********************************** - -.. image:: /../logos/gdp/Pyomo-GDP-150.png - :scale: 35% - :align: right - :class: no-scaled-link - -The Pyomo.GDP modeling extension\ [#gdp-main-paper]_ provides support for Generalized Disjunctive Programming (GDP)\ [#gdp]_, an extension of Disjunctive Programming\ [#dp]_ from the operations research community to include nonlinear relationships. The classic form for a GDP is given by: - -.. math:: - - \min\ obj = &\ f(x, z) \\ - \text{s.t.} \quad &\ Ax+Bz \leq d\\ - &\ g(x,z) \leq 0\\ - &\ \bigvee_{i\in D_k} \left[ - \begin{gathered} - Y_{ik} \\ - M_{ik} x + N_{ik} z \leq e_{ik} \\ - r_{ik}(x,z)\leq 0\\ - \end{gathered} - \right] \quad k \in K\\ - &\ \Omega(Y) = True \\ - &\ x \in X \subseteq \mathbb{R}^n\\ - &\ Y \in \{True, False\}^{p}\\ - &\ z \in Z \subseteq \mathbb{Z}^m - -Here, we have the minimization of an objective :math:`obj` subject to global linear constraints :math:`Ax+Bz \leq d` and nonlinear constraints :math:`g(x,z) \leq 0`, with conditional linear constraints :math:`M_{ik} x + N_{ik} z \leq e_{ik}` and nonlinear constraints :math:`r_{ik}(x,z)\leq 0`. -These conditional constraints are collected into disjuncts :math:`D_k`, organized into disjunctions :math:`K`. Finally, there are logical propositions :math:`\Omega(Y) = True`. -Decision/state variables can be continuous :math:`x`, Boolean :math:`Y`, and/or integer :math:`z`. - -GDP is useful to model discrete decisions that have implications on the system behavior\ [#gdpreview]_. -For example, in process design, a disjunction may model the choice between processes A and B. -If A is selected, then its associated equations and inequalities will apply; otherwise, if B is selected, then its respective constraints should be enforced. - -Modelers often ask to model if-then-else relationships. -These can be expressed as a disjunction as follows: - -.. math:: - :nowrap: - - \begin{gather*} - \left[\begin{gathered} - Y_1 \\ - \text{constraints} \\ - \text{for }\textit{then} - \end{gathered}\right] - \vee - \left[\begin{gathered} - Y_2 \\ - \text{constraints} \\ - \text{for }\textit{else} - \end{gathered}\right] \\ - Y_1 \veebar Y_2 - \end{gather*} - -Here, if the Boolean :math:`Y_1` is ``True``, then the constraints in the first disjunct are enforced; otherwise, the constraints in the second disjunct are enforced. -The following sections describe the key concepts, modeling, and solution approaches available for Generalized Disjunctive Programming. - -.. toctree:: - :caption: Pyomo.GDP Contents - :maxdepth: 2 - - concepts - modeling - solving - -Literature References -===================== -.. [#gdp-main-paper] Chen, Q., Johnson, E. S., Bernal, D. E., Valentin, R., Kale, S., Bates, J., Siirola, J. D. and Grossmann, I. E. (2021). Pyomo.GDP: an ecosystem for logic based modeling and optimization development, *Optimization and Engineering* (pp. 1-36).https://doi.org/10.1007/s11081-021-09601-7 - -.. [#gdp] Raman, R., & Grossmann, I. E. (1994). Modelling and computational techniques for logic based integer programming. *Computers & Chemical Engineering*, 18(7), 563–578. https://doi.org/10.1016/0098-1354(93)E0010-7 - -.. [#dp] Balas, E. (1985). Disjunctive Programming and a Hierarchy of Relaxations for Discrete Optimization Problems. *SIAM Journal on Algebraic Discrete Methods*, 6(3), 466–486. https://doi.org/10.1137/0606047 - -.. [#gdpreview] Grossmann, I. E., & Trespalacios, F. (2013). Systematic modeling of discrete-continuous optimization models through generalized disjunctive programming. *AIChE Journal*, 59(9), 3276–3295. https://doi.org/10.1002/aic.14088 diff --git a/doc/OnlineDocs/modeling_extensions/index.rst b/doc/OnlineDocs/modeling_extensions/index.rst deleted file mode 100644 index 3a3370e510a..00000000000 --- a/doc/OnlineDocs/modeling_extensions/index.rst +++ /dev/null @@ -1,12 +0,0 @@ -Modeling Extensions -=================== - -.. toctree:: - :maxdepth: 1 - - bilevel.rst - dae.rst - gdp/index.rst - mpec.rst - stochastic_programming.rst - network.rst diff --git a/doc/OnlineDocs/modeling_extensions/stochastic_programming.rst b/doc/OnlineDocs/modeling_extensions/stochastic_programming.rst deleted file mode 100644 index 227a8d9aa8d..00000000000 --- a/doc/OnlineDocs/modeling_extensions/stochastic_programming.rst +++ /dev/null @@ -1,17 +0,0 @@ -Stochastic Programming in Pyomo -=============================== - -There are two extensions for modeling and solving Stochastic Programs in -Pyomo. Both are currently distributed as independent Python packages. -PySP was the original extension (and up through Pyomo 5.7.3 was -distributed as part of Pyomo). You can find the documentation here: - - `https://pysp.readthedocs.io `_ - -In 2020, the PySP developers released the mpi-sppy package, which -reimplemented much of the functionality from PySP in a new scalable -framework built on top of MPI and the mpi4py package. Future -development of stochastic programming capabilities is occurring in -mpi-sppy. The documentation is available here: - - `https://mpi-sppy.readthedocs.io `_ diff --git a/doc/OnlineDocs/pyomo_modeling_components/index.rst b/doc/OnlineDocs/pyomo_modeling_components/index.rst deleted file mode 100644 index c7f455be02c..00000000000 --- a/doc/OnlineDocs/pyomo_modeling_components/index.rst +++ /dev/null @@ -1,13 +0,0 @@ -Pyomo Modeling Components -========================= - -.. toctree:: - :maxdepth: 1 - - Sets.rst - Parameters.rst - Variables.rst - Objectives.rst - Constraints.rst - Expressions.rst - Suffixes.rst diff --git a/doc/OnlineDocs/reference/bibliography.rst b/doc/OnlineDocs/reference/bibliography.rst new file mode 100644 index 00000000000..4a4ea37f3e5 --- /dev/null +++ b/doc/OnlineDocs/reference/bibliography.rst @@ -0,0 +1,154 @@ +.. _publications: + +Publications +============ +.. + Note to developers: For these references, we will use the package + name followed by a description of the publication type. + +These publications describe various Pyomo capabilitites or subpackages: + +.. [Pyomo-paper] William E. Hart, Jean-Paul Watson, David L. Woodruff. + "Pyomo: modeling and solving mathematical programs in Python," + Mathematical Programming Computation, 3(3), August 2011. + +.. [PyomoBookI] William E. Hart, Carl D. Laird, Jean-Paul Watson, + David L. Woodruff. Pyomo – Optimization Modeling in Python, + Springer Optimization and Its Applications, Vol 67. Springer. 2012. + +.. [PyomoBookII] William E. Hart, Carl D. Laird, Jean-Paul Watson, + David L. Woodruff, Gabriel A. Hackebeil, Bethany L. Nicholson, + John D. Siirola. Pyomo - Optimization Modeling in Python, 2nd Edition. + Springer Optimization and Its Applications, Vol 67. + Springer. 2017. + +.. [PyomoBookIII] Michael L. Bynum, Gabriel A. Hackebeil, + William E. Hart, Carl D. Laird, Bethany L. Nicholson, + John D. Siirola, Jean-Paul Watson, and David L. Woodruff. Pyomo - + Optimization Modeling in Python, 3rd Edition. + Vol. 67. Springer. 2021. DOI `10.1007/978-3-030-68928-5 + `_ + +.. [PyomoDAE-paper] Bethany Nicholson, John D. Siirola, Jean-Paul Watson, + Victor M. Zavala, and Lorenz T. Biegler. "pyomo.dae: a modeling and + automatic discretization framework for optimization with differential + and algebraic equations", *Mathematical Programming Computation*, 10(2), + 187-223. 2018. + +.. [Parmest-paper] Katherine A. Klise, Bethany L. Nicholson, Andrea + Staid, David L.Woodruff. "Parmest: Parameter Estimation Via Pyomo." + *Computer Aided Chemical Engineering*, 47, 41-46. 2019. + +.. [PyomoGDP-paper] Qi Chen, Emma S. Johnson, David E. Bernal, Romeo + Valentin, Sunjeev Kale, Johnny Bates, John D. Siirola, and + Ignacio E. Grossmann. "Pyomo.GDP: an ecosystem for logic based + modeling and optimization development." *Optimization and + Engineering*, 1-36. 2021. DOI `10.1007/s11081-021-09601-7 + `_ + +.. [PyomoGDP-proceedings] Qi Chen, Emma S. Johnson, John D. Siirola, and + Ignacio E. Grossmann. "Pyomo.GDP: Disjunctive Models in Python." + In M. R. Eden, M. G. Ierapetritou, and G. P. Towler (Eds.), + *Proceedings of the 13th International Symposium on Process Systems + Engineering*, 889–894, 2018. DOI `10.1016/B978-0-444-64241-7.50143-9 + `_ + + +Bibliography +============ + +.. + Note to developers: We are using BiBTeX's `alpha` format for naming + bibliographic references: + + - single Author references use the 1st 3 characters (CamelCase) from + the last name plus the two digit publication year (e.g., [Aut00]) + + - 2- and 3-author references use the 1st character (capitalized) + from each last name plus the two digit publication year (e.g., [HWW11]) + + - 4+ author references use the 1st character (capitalized) from the + first 3 authors last names, plus a "+", plus the two digit + publication year (e.g., [BHH+21]) + + Reference collisions are resolved by adding a lower case character + (beginning with 'a', ordered in the same order that the references + appear in this Bibliography list) to *all* colliding references. + +.. [AIMMS] http://www.aimms.com/ + +.. [AM00] O. Abel and W. Marquardt, "Scenario-integrated modeling and + optimization of dynamic systems", *AIChE Journal*, 46(4). 2000. + +.. [Bal85] E. Balas. "Disjunctive Programming and a Hierarchy of + Relaxations for Discrete Optimization Problems", *SIAM Journal on + Algebraic Discrete Methods*, 6(3), 466–486, 1985. DOI + `10.1137/0606047 `_ + +.. [BJ72] E. Balas and R. Jeroslow. "Canonical Cuts on the Unit Hypercube", + *SIAM Journal on Applied Mathematics* 23(1), 61-19, 1972. + DOI `10.1137/0123007 `_ + +.. [FGK02] R. Fourer, D. M. Gay, and B. W. Kernighan. *AMPL: A Modeling + Language for Mathematical Programming*, 2nd Edition, Duxbury + Press, 2002. + +.. [GAMS] http://www.gams.com + +.. [GLM99] A. Grothey, S. Leyffer, and K. I. M. McKinnon. "A note + on feasibility in Benders Decomposition", Numerical Analysis Report + NA/188, Dundee University. 1999. + +.. [GT13] I. E. Grossmann and F. Trespalacios. "Systematic modeling + of discrete-continuous optimization models through generalized + disjunctive programming", *AIChE Journal*, 59(9), + 3276–3295. 2013. DOI `10.1002/aic.14088 `_ + +.. [IAE+21] N. M. Isenberg, P. Akula, J. C. Eslick, D. Bhattacharyya, + D. C. Miller, and C. E. Gounaris. "A generalized cutting‐set approach + for nonlinear robust optimization in process systems engineering", + *AIChE Journal*, 67:e17175. 2021. DOI `10.1002/aic.17175 + `_ + +.. [KMM+23] B. Knueven, D. Mildebrath, C. Muir, J. D. Siirola, + J.-P. Watson, and D. L. Woodruff. "A Parallel Hub-and-Spoke System + for Large-Scale Scenario-Based Optimization Under Uncertainty", *Math + Programming Computation*, 15, 591-619. 2023. DOI + `10.1007/s12532-023-00247-3 + `_ + +.. [KMT21] J. Kronqvist, R. Misener, and C. Tsay. "Between Steps: + Intermediate Relaxations between big-M and Convex Hull + Reformulations". 2021. https://arxiv.org/abs/2101.12708 + +.. [NW88] G. L. Nemhauser and L. A. Wolsey. *Integer and combinatorial + optimization*, New York: Wiley. 1988. + +.. [RB01] W. C. Rooney and L. T. Biegler. "Design for model parameter + uncertainty using nonlinear confidence regions", *AIChE Journal*, + 47(8). 2001. + +.. [RG94] R. Raman and I. E. Grossmann. "Modelling and computational + techniques for logic based integer programming", *Computers and + Chemical Engineering*, 18(7), 563–578. 1994. DOI + `10.1016/0098-1354(93)E0010-7 + `_ + +.. [SG03] N. W. Sawaya and I. E. Grossmann. "A cutting plane + method for solving linear generalized disjunctive programming + problems", *Computer Aided Chemical Engineering*, 15(C), + 1032–1037. 2003. DOI `10.1016/S1570-7946(03)80444-3 + `_ + +.. [TG15] F. Trespalacios and I. E. Grossmann. "Improved Big-M + reformulation for generalized disjunctive programs", *Computers and + Chemical Engineering*, 76, 98–103. 2015. DOI + `10.1016/j.compchemeng.2015.02.013 + `_ + +.. [VAN10] J. P. Vielma, S. Ahmed, and G. Nemhauser. "Mixed-Integer + Models for Non-separable Piecewise Linear Optimization: Unifying + framework and Extensions", *Operations Research* 58(2), 303-315. 2010. + +.. [NW99] Nocedal, Jorge, and Stephen J. Wright, eds. Numerical + optimization. New York, NY: Springer New York, 1999. diff --git a/doc/OnlineDocs/reference/future.rst b/doc/OnlineDocs/reference/future.rst new file mode 100644 index 00000000000..1dd7a1060f3 --- /dev/null +++ b/doc/OnlineDocs/reference/future.rst @@ -0,0 +1,5 @@ +Accessing preview features +========================== + +.. automodule:: pyomo.__future__ + :noindex: diff --git a/doc/OnlineDocs/reference/index.rst b/doc/OnlineDocs/reference/index.rst new file mode 100644 index 00000000000..6941c951c8c --- /dev/null +++ b/doc/OnlineDocs/reference/index.rst @@ -0,0 +1,23 @@ +Reference Guides +================ + +.. toctree:: + :maxdepth: 2 + + topical/index + +.. only:: html + + .. toctree:: + :maxdepth: 2 + + Library Reference <../api/pyomo> + +.. toctree:: + :maxdepth: 1 + + future + ../errors + ../related_packages + bibliography + diff --git a/doc/OnlineDocs/reference/topical/aml/index.rst b/doc/OnlineDocs/reference/topical/aml/index.rst new file mode 100644 index 00000000000..bdc3c5529d0 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/aml/index.rst @@ -0,0 +1,22 @@ +AML Library Reference +===================== + +The following modeling components make up the core of the Pyomo +Algebraic Modeling Language (AML). These classes are all available +through the :mod:`pyomo.environ` namespace. + +.. autosummary:: + + ~pyomo.core.base.PyomoModel.ConcreteModel + ~pyomo.core.base.PyomoModel.AbstractModel + ~pyomo.core.base.block.Block + ~pyomo.core.base.set.Set + ~pyomo.core.base.set.RangeSet + ~pyomo.core.base.param.Param + ~pyomo.core.base.var.Var + ~pyomo.core.base.objective.Objective + ~pyomo.core.base.constraint.Constraint + ~pyomo.core.base.external.ExternalFunction + ~pyomo.core.base.reference.Reference + ~pyomo.core.base.sos.SOSConstraint + diff --git a/doc/OnlineDocs/reference/topical/appsi/appsi.base.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.base.rst new file mode 100644 index 00000000000..c99d86350b7 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.base.rst @@ -0,0 +1,12 @@ +APPSI Base Classes +================== + +.. autosummary:: + + pyomo.contrib.appsi.base.TerminationCondition + pyomo.contrib.appsi.base.Results + pyomo.contrib.appsi.base.Solver + pyomo.contrib.appsi.base.PersistentSolver + pyomo.contrib.appsi.base.SolverConfig + pyomo.contrib.appsi.base.MIPSolverConfig + pyomo.contrib.appsi.base.UpdateConfig diff --git a/doc/OnlineDocs/library_reference/appsi/appsi.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.rst similarity index 81% rename from doc/OnlineDocs/library_reference/appsi/appsi.rst rename to doc/OnlineDocs/reference/topical/appsi/appsi.rst index e26e4b0e82a..797c37040d2 100644 --- a/doc/OnlineDocs/library_reference/appsi/appsi.rst +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.rst @@ -6,6 +6,7 @@ APPSI Auto-Persistent Pyomo Solver Interfaces .. automodule:: pyomo.contrib.appsi + :noindex: :members: :show-inheritance: @@ -23,17 +24,17 @@ example of using an APPSI solver interface. .. code-block:: python - >>> import pyomo.environ as pe + >>> import pyomo.environ as pyo >>> from pyomo.contrib import appsi >>> import numpy as np >>> from pyomo.common.timing import HierarchicalTimer - >>> m = pe.ConcreteModel() - >>> m.x = pe.Var() - >>> m.y = pe.Var() - >>> m.p = pe.Param(mutable=True) - >>> m.obj = pe.Objective(expr=m.x**2 + m.y**2) - >>> m.c1 = pe.Constraint(expr=m.y >= pe.exp(m.x)) - >>> m.c2 = pe.Constraint(expr=m.y >= (m.x - m.p)**2) + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var() + >>> m.y = pyo.Var() + >>> m.p = pyo.Param(mutable=True) + >>> m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + >>> m.c1 = pyo.Constraint(expr=m.y >= pyo.exp(m.x)) + >>> m.c2 = pyo.Constraint(expr=m.y >= (m.x - m.p)**2) >>> opt = appsi.solvers.Ipopt() >>> timer = HierarchicalTimer() >>> for p_val in np.linspace(1, 10, 100): @@ -43,6 +44,15 @@ example of using an APPSI solver interface. >>> print(res.best_feasible_objective) >>> print(timer) +Alternatively, you can access the APPSI solvers through the classic +``SolverFactory`` using the pattern ``appsi_solvername``. + +.. code-block:: python + + >>> import pyomo.environ as pyo + >>> opt_ipopt = pyo.SolverFactory('appsi_ipopt') + >>> opt_highs = pyo.SolverFactory('appsi_highs') + Extra performance improvements can be made if you know exactly what changes will be made in your model. In the example above, only parameter values are changed, so we can setup the diff --git a/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.cbc.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.cbc.rst new file mode 100644 index 00000000000..6f3c3bb98d1 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.cbc.rst @@ -0,0 +1,7 @@ +Cbc +=== + +.. autosummary:: + + pyomo.contrib.appsi.solvers.cbc.CbcConfig + pyomo.contrib.appsi.solvers.cbc.Cbc diff --git a/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.cplex.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.cplex.rst new file mode 100644 index 00000000000..9d64260cb81 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.cplex.rst @@ -0,0 +1,8 @@ +Cplex +===== + +.. autosummary:: + + `pyomo.contrib.appsi.solvers.cplex.CplexConfig` + `pyomo.contrib.appsi.solvers.cplex.CplexResults` + `pyomo.contrib.appsi.solvers.cplex.Cplex` diff --git a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.gurobi.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.gurobi.rst similarity index 87% rename from doc/OnlineDocs/library_reference/appsi/appsi.solvers.gurobi.rst rename to doc/OnlineDocs/reference/topical/appsi/appsi.solvers.gurobi.rst index 9e0af041410..09cad20bbfa 100644 --- a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.gurobi.rst +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.gurobi.rst @@ -42,14 +42,7 @@ calls to to unexpected errors. -.. autoclass:: pyomo.contrib.appsi.solvers.gurobi.GurobiResults - :members: - :inherited-members: - :undoc-members: - :show-inheritance: - -.. autoclass:: pyomo.contrib.appsi.solvers.gurobi.Gurobi - :members: - :inherited-members: - :undoc-members: - :show-inheritance: +.. autosummary:: + + pyomo.contrib.appsi.solvers.gurobi.GurobiResults + pyomo.contrib.appsi.solvers.gurobi.Gurobi diff --git a/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.highs.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.highs.rst new file mode 100644 index 00000000000..dbd804664ef --- /dev/null +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.highs.rst @@ -0,0 +1,7 @@ +HiGHS +===== + +.. autosummary:: + + pyomo.contrib.appsi.solvers.highs.HighsResults + pyomo.contrib.appsi.solvers.highs.Highs diff --git a/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.ipopt.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.ipopt.rst new file mode 100644 index 00000000000..0b48bbffb5f --- /dev/null +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.ipopt.rst @@ -0,0 +1,7 @@ +Ipopt +===== + +.. autosummary:: + + pyomo.contrib.appsi.solvers.ipopt.IpoptConfig + pyomo.contrib.appsi.solvers.ipopt.Ipopt diff --git a/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.maingo.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.maingo.rst new file mode 100644 index 00000000000..fa85fd45ad5 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.maingo.rst @@ -0,0 +1,7 @@ +MAiNGO +====== + +.. autosummary:: + + pyomo.contrib.appsi.solvers.maingo.MAiNGOConfig + pyomo.contrib.appsi.solvers.maingo.MAiNGO diff --git a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.rst b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.rst similarity index 76% rename from doc/OnlineDocs/library_reference/appsi/appsi.solvers.rst rename to doc/OnlineDocs/reference/topical/appsi/appsi.solvers.rst index 1c598d95628..275e6cb4f74 100644 --- a/doc/OnlineDocs/library_reference/appsi/appsi.solvers.rst +++ b/doc/OnlineDocs/reference/topical/appsi/appsi.solvers.rst @@ -2,9 +2,7 @@ Solvers ======= .. automodule:: pyomo.contrib.appsi.solvers - :members: - :show-inheritance: - :undoc-members: + :noindex: .. toctree:: @@ -13,3 +11,4 @@ Solvers appsi.solvers.cplex appsi.solvers.cbc appsi.solvers.highs + appsi.solvers.maingo diff --git a/doc/OnlineDocs/library_reference/data/index.rst b/doc/OnlineDocs/reference/topical/data/index.rst similarity index 89% rename from doc/OnlineDocs/library_reference/data/index.rst rename to doc/OnlineDocs/reference/topical/data/index.rst index fffb06240f8..778f797fa57 100644 --- a/doc/OnlineDocs/library_reference/data/index.rst +++ b/doc/OnlineDocs/reference/topical/data/index.rst @@ -2,10 +2,12 @@ Model Data Management ===================== .. autoclass:: pyomo.dataportal.DataPortal.DataPortal + :noindex: :members: :special-members: .. autoclass:: pyomo.dataportal.TableData.TableData + :noindex: :members: :special-members: diff --git a/doc/OnlineDocs/reference/topical/expressions/building.rst b/doc/OnlineDocs/reference/topical/expressions/building.rst new file mode 100644 index 00000000000..8335116e21f --- /dev/null +++ b/doc/OnlineDocs/reference/topical/expressions/building.rst @@ -0,0 +1,12 @@ + +Utilities to Build Expressions +============================== + +.. autosummary:: + + pyomo.core.util.prod + pyomo.core.util.quicksum + pyomo.core.util.sum_product + pyomo.core.util.summation + pyomo.core.util.dot_product + diff --git a/doc/OnlineDocs/reference/topical/expressions/classes.rst b/doc/OnlineDocs/reference/topical/expressions/classes.rst new file mode 100644 index 00000000000..786651be2a6 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/expressions/classes.rst @@ -0,0 +1,53 @@ +Core Classes +============ + +.. currentmodule:: pyomo.core.expr.numeric_expr + +The following are the two core classes documented here: + + * :class:`NumericValue` + * :class:`NumericExpression` + +The remaining classes are the public classes for expressions, which +developers may need to know about. The methods for these classes are not +documented because they are described in the +:class:`NumericExpression` class. + +Sets with Expression Types +-------------------------- + +The following sets can be used to develop visitor patterns for +Pyomo expressions. + +.. autosummary:: + + ~pyomo.common.numeric_types.native_numeric_types + ~pyomo.common.numeric_types.native_types + ~pyomo.common.numeric_types.nonpyomo_leaf_types + +NumericValue and NumericExpression +---------------------------------- + +.. autosummary:: + + NumericValue + NumericExpression + +Other Public Classes +-------------------- + + +.. autosummary:: + + NegationExpression + AbsExpression + UnaryFunctionExpression + ProductExpression + DivisionExpression + SumExpression + Expr_ifExpression + ExternalFunctionExpression + pyomo.core.expr.relational_expr.EqualityExpression + pyomo.core.expr.relational_expr.InequalityExpression + pyomo.core.expr.relational_expr.RangedExpression + pyomo.core.expr.template_expr.GetItemExpression diff --git a/doc/OnlineDocs/reference/topical/expressions/context_managers.rst b/doc/OnlineDocs/reference/topical/expressions/context_managers.rst new file mode 100644 index 00000000000..e77a4933a01 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/expressions/context_managers.rst @@ -0,0 +1,9 @@ + +Context Managers +================ + +.. autosummary:: + + pyomo.core.expr.nonlinear_expression + pyomo.core.expr.linear_expression + diff --git a/doc/OnlineDocs/library_reference/expressions/index.rst b/doc/OnlineDocs/reference/topical/expressions/index.rst similarity index 100% rename from doc/OnlineDocs/library_reference/expressions/index.rst rename to doc/OnlineDocs/reference/topical/expressions/index.rst diff --git a/doc/OnlineDocs/reference/topical/expressions/managing.rst b/doc/OnlineDocs/reference/topical/expressions/managing.rst new file mode 100644 index 00000000000..ba96f2a2907 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/expressions/managing.rst @@ -0,0 +1,23 @@ + +Utilities to Manage and Analyze Expressions +=========================================== + +Functions +~~~~~~~~~ + +.. autosummary:: + + pyomo.core.expr.expression_to_string + pyomo.core.expr.decompose_term + pyomo.core.expr.clone_expression + pyomo.core.expr.evaluate_expression + pyomo.core.expr.identify_components + pyomo.core.expr.identify_variables + pyomo.core.expr.differentiate + +Classes +~~~~~~~ + +.. autosummary:: + + pyomo.core.expr.symbol_map.SymbolMap diff --git a/doc/OnlineDocs/reference/topical/expressions/visitors.rst b/doc/OnlineDocs/reference/topical/expressions/visitors.rst new file mode 100644 index 00000000000..ef09c62c2ff --- /dev/null +++ b/doc/OnlineDocs/reference/topical/expressions/visitors.rst @@ -0,0 +1,9 @@ + +Visitor Classes +=============== + +.. autosummary:: + + pyomo.core.expr.StreamBasedExpressionVisitor + pyomo.core.expr.ExpressionValueVisitor + pyomo.core.expr.ExpressionReplacementVisitor diff --git a/doc/OnlineDocs/library_reference/index.rst b/doc/OnlineDocs/reference/topical/index.rst similarity index 94% rename from doc/OnlineDocs/library_reference/index.rst rename to doc/OnlineDocs/reference/topical/index.rst index 35dd8d30307..919b68f647a 100644 --- a/doc/OnlineDocs/library_reference/index.rst +++ b/doc/OnlineDocs/reference/topical/index.rst @@ -1,4 +1,4 @@ -Library Reference +Topical Reference ================= Pyomo is being increasingly used as a library to support Python @@ -10,7 +10,6 @@ Python scripts using Pyomo. .. toctree:: :maxdepth: 1 - common/index.rst aml/index.rst expressions/index.rst solvers/index.rst diff --git a/doc/OnlineDocs/reference/topical/kernel/base.rst b/doc/OnlineDocs/reference/topical/kernel/base.rst new file mode 100644 index 00000000000..884d40a47c2 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/base.rst @@ -0,0 +1,6 @@ +Base Object Storage Interface +============================= + +.. autosummary:: + + pyomo.core.kernel.base diff --git a/doc/OnlineDocs/reference/topical/kernel/block.rst b/doc/OnlineDocs/reference/topical/kernel/block.rst new file mode 100644 index 00000000000..0fae770c355 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/block.rst @@ -0,0 +1,12 @@ +Blocks +====== + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.block.block + pyomo.core.kernel.block.block_tuple + pyomo.core.kernel.block.block_list + pyomo.core.kernel.block.block_dict + diff --git a/doc/OnlineDocs/library_reference/kernel/conic.rst b/doc/OnlineDocs/reference/topical/kernel/conic.rst similarity index 54% rename from doc/OnlineDocs/library_reference/kernel/conic.rst rename to doc/OnlineDocs/reference/topical/kernel/conic.rst index 34552013623..98bc474aee5 100644 --- a/doc/OnlineDocs/library_reference/kernel/conic.rst +++ b/doc/OnlineDocs/reference/topical/kernel/conic.rst @@ -19,24 +19,3 @@ Summary pyomo.core.kernel.conic.primal_power pyomo.core.kernel.conic.dual_exponential pyomo.core.kernel.conic.dual_power - -Member Documentation -~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: pyomo.core.kernel.conic.quadratic - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.conic.rotated_quadratic - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.conic.primal_exponential - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.conic.primal_power - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.conic.dual_exponential - :show-inheritance: - :members: -.. autoclass:: pyomo.core.kernel.conic.dual_power - :show-inheritance: - :members: diff --git a/doc/OnlineDocs/reference/topical/kernel/constraint.rst b/doc/OnlineDocs/reference/topical/kernel/constraint.rst new file mode 100644 index 00000000000..a4422bfb61d --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/constraint.rst @@ -0,0 +1,13 @@ +Constraints +=========== + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.constraint.constraint + pyomo.core.kernel.constraint.linear_constraint + pyomo.core.kernel.constraint.constraint_tuple + pyomo.core.kernel.constraint.constraint_list + pyomo.core.kernel.constraint.constraint_dict + pyomo.core.kernel.matrix_constraint.matrix_constraint diff --git a/doc/OnlineDocs/reference/topical/kernel/dict_container.rst b/doc/OnlineDocs/reference/topical/kernel/dict_container.rst new file mode 100644 index 00000000000..923fe915b8e --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/dict_container.rst @@ -0,0 +1,6 @@ +Dict-like Object Storage +======================== + +.. autosummary:: + + pyomo.core.kernel.dict_container.DictContainer diff --git a/doc/OnlineDocs/reference/topical/kernel/expression.rst b/doc/OnlineDocs/reference/topical/kernel/expression.rst new file mode 100644 index 00000000000..6ac32ecd7dd --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/expression.rst @@ -0,0 +1,11 @@ +Expressions +=========== + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.expression.expression + pyomo.core.kernel.expression.expression_tuple + pyomo.core.kernel.expression.expression_list + pyomo.core.kernel.expression.expression_dict diff --git a/doc/OnlineDocs/reference/topical/kernel/heterogeneous_container.rst b/doc/OnlineDocs/reference/topical/kernel/heterogeneous_container.rst new file mode 100644 index 00000000000..158175af7f1 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/heterogeneous_container.rst @@ -0,0 +1,6 @@ +Heterogeneous Object Containers +=============================== + +.. autosummary:: + + pyomo.core.kernel.heterogeneous_container diff --git a/doc/OnlineDocs/reference/topical/kernel/homogeneous_container.rst b/doc/OnlineDocs/reference/topical/kernel/homogeneous_container.rst new file mode 100644 index 00000000000..f6dc88b355a --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/homogeneous_container.rst @@ -0,0 +1,6 @@ +Homogeneous Object Containers +============================= + +.. autosummary:: + + pyomo.core.kernel.homogeneous_container diff --git a/doc/OnlineDocs/reference/topical/kernel/index.rst b/doc/OnlineDocs/reference/topical/kernel/index.rst new file mode 100644 index 00000000000..03df24215a8 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/index.rst @@ -0,0 +1,52 @@ +.. role:: python(code) + :language: python + +.. warning:: + + The :python:`pyomo.kernel` API is still in the beta phase of development. It is fully tested and functional; however, the interface may change as it becomes further integrated with the rest of Pyomo. + +.. warning:: + + Models built with :python:`pyomo.kernel` components are not yet compatible with pyomo extension modules (e.g., :python:`PySP`, :python:`pyomo.dae`, :python:`pyomo.gdp`). + +The Kernel Library API Reference +================================ + +.. _kernel_modeling_components: + +Modeling Components: +^^^^^^^^^^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + + block.rst + variable.rst + constraint.rst + parameter.rst + objective.rst + expression.rst + sos.rst + suffix.rst + piecewise/index.rst + conic.rst + +Base API: +^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + + base.rst + homogeneous_container.rst + heterogeneous_container.rst + +Containers: +^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + + tuple_container.rst + list_container.rst + dict_container.rst diff --git a/doc/OnlineDocs/reference/topical/kernel/list_container.rst b/doc/OnlineDocs/reference/topical/kernel/list_container.rst new file mode 100644 index 00000000000..acd6fe4fabb --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/list_container.rst @@ -0,0 +1,6 @@ +List-like Object Storage +======================== + +.. autosummary:: + + pyomo.core.kernel.list_container.ListContainer diff --git a/doc/OnlineDocs/reference/topical/kernel/objective.rst b/doc/OnlineDocs/reference/topical/kernel/objective.rst new file mode 100644 index 00000000000..8f7a5422e9e --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/objective.rst @@ -0,0 +1,11 @@ +Objectives +========== + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.objective.objective + pyomo.core.kernel.objective.objective_tuple + pyomo.core.kernel.objective.objective_list + pyomo.core.kernel.objective.objective_dict diff --git a/doc/OnlineDocs/reference/topical/kernel/parameter.rst b/doc/OnlineDocs/reference/topical/kernel/parameter.rst new file mode 100644 index 00000000000..c09bd6262c3 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/parameter.rst @@ -0,0 +1,12 @@ +Parameters +========== + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.parameter.parameter + pyomo.core.kernel.parameter.functional_value + pyomo.core.kernel.parameter.parameter_tuple + pyomo.core.kernel.parameter.parameter_list + pyomo.core.kernel.parameter.parameter_dict diff --git a/doc/OnlineDocs/library_reference/kernel/piecewise/index.rst b/doc/OnlineDocs/reference/topical/kernel/piecewise/index.rst similarity index 100% rename from doc/OnlineDocs/library_reference/kernel/piecewise/index.rst rename to doc/OnlineDocs/reference/topical/kernel/piecewise/index.rst diff --git a/doc/OnlineDocs/reference/topical/kernel/piecewise/piecewise.rst b/doc/OnlineDocs/reference/topical/kernel/piecewise/piecewise.rst new file mode 100644 index 00000000000..4c67621426c --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/piecewise/piecewise.rst @@ -0,0 +1,19 @@ +Single-variate Piecewise Functions +================================== + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.piecewise_library.transforms.piecewise + pyomo.core.kernel.piecewise_library.transforms.PiecewiseLinearFunction + pyomo.core.kernel.piecewise_library.transforms.TransformedPiecewiseLinearFunction + pyomo.core.kernel.piecewise_library.transforms.piecewise_convex + pyomo.core.kernel.piecewise_library.transforms.piecewise_sos2 + pyomo.core.kernel.piecewise_library.transforms.piecewise_dcc + pyomo.core.kernel.piecewise_library.transforms.piecewise_cc + pyomo.core.kernel.piecewise_library.transforms.piecewise_mc + pyomo.core.kernel.piecewise_library.transforms.piecewise_inc + pyomo.core.kernel.piecewise_library.transforms.piecewise_dlog + pyomo.core.kernel.piecewise_library.transforms.piecewise_log + diff --git a/doc/OnlineDocs/reference/topical/kernel/piecewise/piecewise_nd.rst b/doc/OnlineDocs/reference/topical/kernel/piecewise/piecewise_nd.rst new file mode 100644 index 00000000000..057f3590a72 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/piecewise/piecewise_nd.rst @@ -0,0 +1,12 @@ +Multi-variate Piecewise Functions +================================= + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.piecewise_library.transforms_nd.piecewise_nd + pyomo.core.kernel.piecewise_library.transforms_nd.PiecewiseLinearFunctionND + pyomo.core.kernel.piecewise_library.transforms_nd.TransformedPiecewiseLinearFunctionND + pyomo.core.kernel.piecewise_library.transforms_nd.piecewise_nd_cc + diff --git a/doc/OnlineDocs/reference/topical/kernel/piecewise/util.rst b/doc/OnlineDocs/reference/topical/kernel/piecewise/util.rst new file mode 100644 index 00000000000..6b979ff42ff --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/piecewise/util.rst @@ -0,0 +1,6 @@ +Utilities for Piecewise Functions +================================= + +.. autosummary:: + + pyomo.core.kernel.piecewise_library.util diff --git a/doc/OnlineDocs/reference/topical/kernel/sos.rst b/doc/OnlineDocs/reference/topical/kernel/sos.rst new file mode 100644 index 00000000000..edb463ea1da --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/sos.rst @@ -0,0 +1,14 @@ +Special Ordered Sets +==================== + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.sos.sos + pyomo.core.kernel.sos.sos1 + pyomo.core.kernel.sos.sos2 + pyomo.core.kernel.sos.sos_tuple + pyomo.core.kernel.sos.sos_list + pyomo.core.kernel.sos.sos_dict + diff --git a/doc/OnlineDocs/reference/topical/kernel/suffix.rst b/doc/OnlineDocs/reference/topical/kernel/suffix.rst new file mode 100644 index 00000000000..f0f8f48a292 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/suffix.rst @@ -0,0 +1,6 @@ +Suffixes +======== + +.. autosummary:: + + pyomo.core.kernel.suffix diff --git a/doc/OnlineDocs/reference/topical/kernel/tuple_container.rst b/doc/OnlineDocs/reference/topical/kernel/tuple_container.rst new file mode 100644 index 00000000000..eb052d9ffb6 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/tuple_container.rst @@ -0,0 +1,6 @@ +Tuple-like Object Storage +========================= + +.. autosummary:: + + pyomo.core.kernel.tuple_container.TupleContainer diff --git a/doc/OnlineDocs/reference/topical/kernel/variable.rst b/doc/OnlineDocs/reference/topical/kernel/variable.rst new file mode 100644 index 00000000000..937ebae45dc --- /dev/null +++ b/doc/OnlineDocs/reference/topical/kernel/variable.rst @@ -0,0 +1,11 @@ +Variables +========= + +Summary +~~~~~~~ +.. autosummary:: + + pyomo.core.kernel.variable.variable + pyomo.core.kernel.variable.variable_tuple + pyomo.core.kernel.variable.variable_list + pyomo.core.kernel.variable.variable_dict diff --git a/doc/OnlineDocs/reference/topical/solvers/cplex_persistent.rst b/doc/OnlineDocs/reference/topical/solvers/cplex_persistent.rst new file mode 100644 index 00000000000..e0d34d0f51d --- /dev/null +++ b/doc/OnlineDocs/reference/topical/solvers/cplex_persistent.rst @@ -0,0 +1,6 @@ +CPLEXPersistent +================ + +.. autosummary:: + + pyomo.solvers.plugins.solvers.cplex_persistent.CPLEXPersistent diff --git a/doc/OnlineDocs/library_reference/solvers/gams.rst b/doc/OnlineDocs/reference/topical/solvers/gams.rst similarity index 82% rename from doc/OnlineDocs/library_reference/solvers/gams.rst rename to doc/OnlineDocs/reference/topical/solvers/gams.rst index f36de5d9e01..ca9a2a55d09 100644 --- a/doc/OnlineDocs/library_reference/solvers/gams.rst +++ b/doc/OnlineDocs/reference/topical/solvers/gams.rst @@ -8,28 +8,24 @@ GAMSShell Solver .. autosummary:: + GAMSShell GAMSShell.available GAMSShell.executable GAMSShell.solve GAMSShell.version GAMSShell.warm_start_capable -.. autoclass:: GAMSShell - :members: - GAMSDirect Solver ----------------- .. autosummary:: + GAMSDirect GAMSDirect.available GAMSDirect.solve GAMSDirect.version GAMSDirect.warm_start_capable -.. autoclass:: GAMSDirect - :members: - .. currentmodule:: pyomo.repn.plugins.gams_writer GAMS Writer @@ -39,5 +35,6 @@ This class is most commonly accessed and called upon via model.write("filename.gms", ...), but is also utilized by the GAMS solver interfaces. -.. autoclass:: ProblemWriter_gams - :members: __call__ +.. autosummary:: + + ProblemWriter_gams diff --git a/doc/OnlineDocs/library_reference/solvers/gurobi_direct.rst b/doc/OnlineDocs/reference/topical/solvers/gurobi_direct.rst similarity index 73% rename from doc/OnlineDocs/library_reference/solvers/gurobi_direct.rst rename to doc/OnlineDocs/reference/topical/solvers/gurobi_direct.rst index 21cb79e5531..bbb61143a94 100644 --- a/doc/OnlineDocs/library_reference/solvers/gurobi_direct.rst +++ b/doc/OnlineDocs/reference/topical/solvers/gurobi_direct.rst @@ -3,6 +3,14 @@ GurobiDirect .. currentmodule:: pyomo.solvers.plugins.solvers.gurobi_direct + +Interface +--------- + +.. autosummary:: + + GurobiDirect + Methods ------- @@ -14,5 +22,3 @@ Methods GurobiDirect.solve GurobiDirect.version -.. autoclass:: GurobiDirect - :members: available, close, close_global, solve, version diff --git a/doc/OnlineDocs/library_reference/solvers/gurobi_persistent.rst b/doc/OnlineDocs/reference/topical/solvers/gurobi_persistent.rst similarity index 91% rename from doc/OnlineDocs/library_reference/solvers/gurobi_persistent.rst rename to doc/OnlineDocs/reference/topical/solvers/gurobi_persistent.rst index 2472599c1ed..5832c8f8b9f 100644 --- a/doc/OnlineDocs/library_reference/solvers/gurobi_persistent.rst +++ b/doc/OnlineDocs/reference/topical/solvers/gurobi_persistent.rst @@ -3,6 +3,13 @@ GurobiPersistent .. currentmodule:: pyomo.solvers.plugins.solvers.gurobi_persistent +Interface +--------- + +.. autosummary:: + + GurobiPersistent + Methods ------- @@ -32,8 +39,3 @@ Methods GurobiPersistent.update_var GurobiPersistent.version GurobiPersistent.write - -.. autoclass:: GurobiPersistent - :members: - :inherited-members: - :show-inheritance: diff --git a/doc/OnlineDocs/library_reference/solvers/index.rst b/doc/OnlineDocs/reference/topical/solvers/index.rst similarity index 100% rename from doc/OnlineDocs/library_reference/solvers/index.rst rename to doc/OnlineDocs/reference/topical/solvers/index.rst diff --git a/doc/OnlineDocs/reference/topical/solvers/xpress_persistent.rst b/doc/OnlineDocs/reference/topical/solvers/xpress_persistent.rst new file mode 100644 index 00000000000..d8721a0931a --- /dev/null +++ b/doc/OnlineDocs/reference/topical/solvers/xpress_persistent.rst @@ -0,0 +1,6 @@ +XpressPersistent +================ + +.. autosummary:: + + pyomo.solvers.plugins.solvers.xpress_persistent.XpressPersistent diff --git a/doc/OnlineDocs/related_packages.rst b/doc/OnlineDocs/related_packages.rst index 1861b98ba6e..f32c726e9d6 100644 --- a/doc/OnlineDocs/related_packages.rst +++ b/doc/OnlineDocs/related_packages.rst @@ -16,6 +16,9 @@ Modeling Extensions | PAO | https://github.com/or-fusion/pao | Formulation and solution of multilevel | | | | optimization problems | +--------------------------+---------------------------------------------------------+---------------------------------------------+ +| OMLT | https://github.com/cog-imperial/OMLT | Represent machine learning models within | +| | | an optimization formulation | ++--------------------------+---------------------------------------------------------+---------------------------------------------+ Solvers and Solution Strategies diff --git a/doc/OnlineDocs/solving_pyomo_models.rst b/doc/OnlineDocs/solving_pyomo_models.rst deleted file mode 100644 index e95c74b2cee..00000000000 --- a/doc/OnlineDocs/solving_pyomo_models.rst +++ /dev/null @@ -1,73 +0,0 @@ -Solving Pyomo Models -==================== - -.. doctest:: - :hide: - - >>> import pyomo.environ as pyo - - >>> m = pyo.AbstractModel() - >>> m.n = pyo.Param(default=4) - >>> m.x = pyo.Var(pyo.RangeSet(m.n), within=pyo.Binary) - >>> def o_rule(m): - ... return pyo.summation(m.x) - >>> m.o = pyo.Objective(rule=o_rule) - - >>> model = m.create_instance() - >>> model.c = pyo.Constraint(expr=model.x[2]+model.x[3]>=1) - - -Solving ConcreteModels ----------------------- - -If you have a ConcreteModel, add these lines at the bottom of your -Python script to solve it - -.. doctest:: - - >>> opt = pyo.SolverFactory('glpk') - >>> opt.solve(model) # doctest: +SKIP - -Solving AbstractModels ----------------------- - -If you have an AbstractModel, you must create a concrete instance of -your model before solving it using the same lines as above: - -.. doctest:: - :hide: - - >>> model = m - -.. doctest:: - - >>> instance = model.create_instance() - >>> opt = pyo.SolverFactory('glpk') - >>> opt.solve(instance) # doctest: +SKIP - -``pyomo solve`` Command ------------------------ - -To solve a ConcreteModel contained in the file ``my_model.py`` using the -``pyomo`` command and the solver GLPK, use the following line in a -terminal window:: - - pyomo solve my_model.py --solver='glpk' - -To solve an AbstractModel contained in the file ``my_model.py`` with data -in the file ``my_data.dat`` using the ``pyomo`` command and the solver GLPK, -use the following line in a terminal window:: - - pyomo solve my_model.py my_data.dat --solver='glpk' - -Supported Solvers ------------------ - -Pyomo supports a wide variety of solvers. Pyomo has specialized -interfaces to some solvers (for example, BARON, CBC, CPLEX, and Gurobi). -It also has generic interfaces that support calling any solver that can -read AMPL "``.nl``" and write "``.sol``" files and the ability to -generate GAMS-format models and retrieve the results. You can get the -current list of supported solvers using the ``pyomo`` command:: - - pyomo help --solvers diff --git a/doc/OnlineDocs/src/data/ABCD1.py b/doc/OnlineDocs/src/data/ABCD1.py index aa2f46e71fa..35ad3abc670 100644 --- a/doc/OnlineDocs/src/data/ABCD1.py +++ b/doc/OnlineDocs/src/data/ABCD1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(dimen=4) +model.Z = pyo.Set(dimen=4) instance = model.create_instance('ABCD1.dat') diff --git a/doc/OnlineDocs/src/data/ABCD2.py b/doc/OnlineDocs/src/data/ABCD2.py index ec0e7ccb15c..2c26d0e7098 100644 --- a/doc/OnlineDocs/src/data/ABCD2.py +++ b/doc/OnlineDocs/src/data/ABCD2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(initialize=[('A1', 'B1', 1), ('A2', 'B2', 2), ('A3', 'B3', 3)]) -# model.Z = Set(dimen=3) -model.D = Param(model.Z) +model.Z = pyo.Set(initialize=[('A1', 'B1', 1), ('A2', 'B2', 2), ('A3', 'B3', 3)]) +# model.Z = pyo.Set(dimen=3) +model.D = pyo.Param(model.Z) instance = model.create_instance('ABCD2.dat') print('Z ' + str(sorted(list(instance.Z.data())))) print('D') for key in sorted(instance.D.keys()): - print(name(instance.D, key) + " " + str(value(instance.D[key]))) + print(pyo.name(instance.D, key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/ABCD3.py b/doc/OnlineDocs/src/data/ABCD3.py index ba55fd970cc..99a5a9ac4d7 100644 --- a/doc/OnlineDocs/src/data/ABCD3.py +++ b/doc/OnlineDocs/src/data/ABCD3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(dimen=3) -model.D = Param(model.Z) +model.Z = pyo.Set(dimen=3) +model.D = pyo.Param(model.Z) instance = model.create_instance('ABCD3.dat') print('Z ' + str(sorted(list(instance.Z.data())))) print('D') for key in sorted(instance.D.keys()): - print(name(instance.D, key) + " " + str(value(instance.D[key]))) + print(pyo.name(instance.D, key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/ABCD4.py b/doc/OnlineDocs/src/data/ABCD4.py index 2fb397aa3b0..589cac0b0d1 100644 --- a/doc/OnlineDocs/src/data/ABCD4.py +++ b/doc/OnlineDocs/src/data/ABCD4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(dimen=3) -model.Y = Param(model.Z) +model.Z = pyo.Set(dimen=3) +model.Y = pyo.Param(model.Z) instance = model.create_instance('ABCD4.dat') print('Z ' + str(sorted(list(instance.Z.data())))) print('Y') for key in sorted(instance.Y.keys()): - print(name(instance.Y, key) + " " + str(value(instance.Y[key]))) + print(pyo.name(instance.Y, key) + " " + str(pyo.value(instance.Y[key]))) diff --git a/doc/OnlineDocs/src/data/ABCD5.py b/doc/OnlineDocs/src/data/ABCD5.py index abc03505e96..67fa242e6c6 100644 --- a/doc/OnlineDocs/src/data/ABCD5.py +++ b/doc/OnlineDocs/src/data/ABCD5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,14 +9,14 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.Z = Set() -model.Y = Param(model.Z) -model.W = Param(model.Z) +model.Z = pyo.Set() +model.Y = pyo.Param(model.Z) +model.W = pyo.Param(model.Z) # @decl instance = model.create_instance('ABCD5.dat') @@ -24,7 +24,7 @@ print('Z ' + str(sorted(list(instance.Z.data())))) print('Y') for key in sorted(instance.Y.keys()): - print(name(instance.Y, key) + " " + str(value(instance.Y[key]))) + print(pyo.name(instance.Y, key) + " " + str(pyo.value(instance.Y[key]))) print('W') for key in sorted(instance.W.keys()): - print(name(instance.W, key) + " " + str(value(instance.W[key]))) + print(pyo.name(instance.W, key) + " " + str(pyo.value(instance.W[key]))) diff --git a/doc/OnlineDocs/src/data/ABCD6.py b/doc/OnlineDocs/src/data/ABCD6.py index 59e0e8e98ae..15c80882872 100644 --- a/doc/OnlineDocs/src/data/ABCD6.py +++ b/doc/OnlineDocs/src/data/ABCD6.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(dimen=3) -model.D = Param(model.Z) +model.Z = pyo.Set(dimen=3) +model.D = pyo.Param(model.Z) instance = model.create_instance('ABCD6.dat') print('Z ' + str(sorted(list(instance.Z.data())))) print('D') for key in sorted(instance.D.keys()): - print(name(instance.D, key) + " " + str(value(instance.D[key]))) + print(pyo.name(instance.D, key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/ABCD7.py b/doc/OnlineDocs/src/data/ABCD7.py index 1bfb4d1e3fb..ed14f1f343c 100644 --- a/doc/OnlineDocs/src/data/ABCD7.py +++ b/doc/OnlineDocs/src/data/ABCD7.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,14 +9,14 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo import pyomo.common import sys -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(dimen=3) -model.Y = Param(model.Z) +model.Z = pyo.Set(dimen=3) +model.Y = pyo.Param(model.Z) try: instance = model.create_instance('ABCD7.dat') @@ -27,4 +27,4 @@ print('Z ' + str(sorted(list(instance.Z.data())))) print('Y') for key in sorted(instance.Y.keys()): - print(name(instance.Y, key) + " " + str(value(instance.Y[key]))) + print(pyo.name(instance.Y, key) + " " + str(pyo.value(instance.Y[key]))) diff --git a/doc/OnlineDocs/src/data/ABCD8.py b/doc/OnlineDocs/src/data/ABCD8.py index aa1ba0b4cf5..eecadddcd3b 100644 --- a/doc/OnlineDocs/src/data/ABCD8.py +++ b/doc/OnlineDocs/src/data/ABCD8.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,14 +9,14 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo import pyomo.common import sys -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(dimen=3) -model.Y = Param(model.Z) +model.Z = pyo.Set(dimen=3) +model.Y = pyo.Param(model.Z) try: instance = model.create_instance('ABCD8.dat') @@ -27,4 +27,4 @@ print('Z ' + str(sorted(list(instance.Z.data())))) print('Y') for key in sorted(instance.Y.keys()): - print(name(instance.Y, key) + " " + str(value(instance.Y[key]))) + print(pyo.name(instance.Y, key) + " " + str(pyo.value(instance.Y[key]))) diff --git a/doc/OnlineDocs/src/data/ABCD9.py b/doc/OnlineDocs/src/data/ABCD9.py index 194c71486d9..37b7b5aaf66 100644 --- a/doc/OnlineDocs/src/data/ABCD9.py +++ b/doc/OnlineDocs/src/data/ABCD9.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,14 +9,14 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo import pyomo.common import sys -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(dimen=3) -model.Y = Param(model.Z) +model.Z = pyo.Set(dimen=3) +model.Y = pyo.Param(model.Z) try: instance = model.create_instance('ABCD9.dat') @@ -27,4 +27,4 @@ print('Z ' + str(sorted(list(instance.Z.data())))) print('Y') for key in sorted(instance.Y.keys()): - print(instance.Y[key] + " " + str(value(instance.Y[key]))) + print(instance.Y[key] + " " + str(pyo.value(instance.Y[key]))) diff --git a/doc/OnlineDocs/src/data/diet1.py b/doc/OnlineDocs/src/data/diet1.py index 40582e16ba0..84545133bca 100644 --- a/doc/OnlineDocs/src/data/diet1.py +++ b/doc/OnlineDocs/src/data/diet1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,30 +10,30 @@ # ___________________________________________________________________________ # diet1.py -from pyomo.environ import * +import pyomo.environ as pyo infinity = float('inf') MAX_FOOD_SUPPLY = 20.0 # There is a finite food supply -model = AbstractModel() +model = pyo.AbstractModel() # -------------------------------------------------------- -model.FOOD = Set() -model.cost = Param(model.FOOD, within=PositiveReals) -model.f_min = Param(model.FOOD, within=NonNegativeReals, default=0.0) +model.FOOD = pyo.Set() +model.cost = pyo.Param(model.FOOD, within=pyo.PositiveReals) +model.f_min = pyo.Param(model.FOOD, within=pyo.NonNegativeReals, default=0.0) def f_max_validate(model, value, j): return model.f_max[j] > model.f_min[j] -model.f_max = Param(model.FOOD, validate=f_max_validate, default=MAX_FOOD_SUPPLY) +model.f_max = pyo.Param(model.FOOD, validate=f_max_validate, default=MAX_FOOD_SUPPLY) -model.NUTR = Set() -model.n_min = Param(model.NUTR, within=NonNegativeReals, default=0.0) -model.n_max = Param(model.NUTR, default=infinity) -model.amt = Param(model.NUTR, model.FOOD, within=NonNegativeReals) +model.NUTR = pyo.Set() +model.n_min = pyo.Param(model.NUTR, within=pyo.NonNegativeReals, default=0.0) +model.n_max = pyo.Param(model.NUTR, default=infinity) +model.amt = pyo.Param(model.NUTR, model.FOOD, within=pyo.NonNegativeReals) # -------------------------------------------------------- @@ -42,7 +42,7 @@ def Buy_bounds(model, i): return (model.f_min[i], model.f_max[i]) -model.Buy = Var(model.FOOD, bounds=Buy_bounds, within=NonNegativeIntegers) +model.Buy = pyo.Var(model.FOOD, bounds=Buy_bounds, within=pyo.NonNegativeIntegers) # -------------------------------------------------------- @@ -51,7 +51,7 @@ def Total_Cost_rule(model): return sum(model.cost[j] * model.Buy[j] for j in model.FOOD) -model.Total_Cost = Objective(rule=Total_Cost_rule, sense=minimize) +model.Total_Cost = pyo.Objective(rule=Total_Cost_rule, sense=pyo.minimize) # -------------------------------------------------------- @@ -67,7 +67,7 @@ def Entree_rule(model): return sum(model.Buy[e] for e in entrees) >= 1 -model.Entree = Constraint(rule=Entree_rule) +model.Entree = pyo.Constraint(rule=Entree_rule) def Side_rule(model): @@ -75,7 +75,7 @@ def Side_rule(model): return sum(model.Buy[s] for s in sides) >= 1 -model.Side = Constraint(rule=Side_rule) +model.Side = pyo.Constraint(rule=Side_rule) def Drink_rule(model): @@ -83,4 +83,4 @@ def Drink_rule(model): return sum(model.Buy[d] for d in drinks) >= 1 -model.Drink = Constraint(rule=Drink_rule) +model.Drink = pyo.Constraint(rule=Drink_rule) diff --git a/doc/OnlineDocs/src/data/ex.py b/doc/OnlineDocs/src/data/ex.py index a66ee30b494..ef2260a0883 100644 --- a/doc/OnlineDocs/src/data/ex.py +++ b/doc/OnlineDocs/src/data/ex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,14 +9,14 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.z = Param() +model.z = pyo.Param() # @decl instance = model.create_instance('ex.dat') -print(value(instance.z)) +print(pyo.value(instance.z)) diff --git a/doc/OnlineDocs/src/data/import1.tab.py b/doc/OnlineDocs/src/data/import1.tab.py index e160e4fdcde..0393f1d30fa 100644 --- a/doc/OnlineDocs/src/data/import1.tab.py +++ b/doc/OnlineDocs/src/data/import1.tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set(initialize=['A1', 'A2', 'A3', 'A4']) -model.Y = Param(model.A) +model.A = pyo.Set(initialize=['A1', 'A2', 'A3', 'A4']) +model.Y = pyo.Param(model.A) instance = model.create_instance('import1.tab.dat') print('Y') keys = instance.Y.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.Y[key]))) + print(str(key) + " " + str(pyo.value(instance.Y[key]))) diff --git a/doc/OnlineDocs/src/data/import2.tab.py b/doc/OnlineDocs/src/data/import2.tab.py index 54339551279..057de057125 100644 --- a/doc/OnlineDocs/src/data/import2.tab.py +++ b/doc/OnlineDocs/src/data/import2.tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set() -model.Y = Param(model.A) +model.A = pyo.Set() +model.Y = pyo.Param(model.A) instance = model.create_instance('import2.tab.dat') @@ -22,4 +22,4 @@ print('Y') keys = instance.Y.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.Y[key]))) + print(str(key) + " " + str(pyo.value(instance.Y[key]))) diff --git a/doc/OnlineDocs/src/data/import3.tab.py b/doc/OnlineDocs/src/data/import3.tab.py index 664151d1438..47ac85613d2 100644 --- a/doc/OnlineDocs/src/data/import3.tab.py +++ b/doc/OnlineDocs/src/data/import3.tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set() +model.A = pyo.Set() instance = model.create_instance('import3.tab.dat') diff --git a/doc/OnlineDocs/src/data/import4.tab.py b/doc/OnlineDocs/src/data/import4.tab.py index 91dd3f26a42..6a49abe9130 100644 --- a/doc/OnlineDocs/src/data/import4.tab.py +++ b/doc/OnlineDocs/src/data/import4.tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.C = Set(dimen=2) +model.C = pyo.Set(dimen=2) instance = model.create_instance('import4.tab.dat') diff --git a/doc/OnlineDocs/src/data/import5.tab.py b/doc/OnlineDocs/src/data/import5.tab.py index 263677c308c..50f23a96c38 100644 --- a/doc/OnlineDocs/src/data/import5.tab.py +++ b/doc/OnlineDocs/src/data/import5.tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.B = Set(dimen=2) +model.B = pyo.Set(dimen=2) instance = model.create_instance('import5.tab.dat') diff --git a/doc/OnlineDocs/src/data/import6.tab.py b/doc/OnlineDocs/src/data/import6.tab.py index 8f4824ad3fe..0b76dc52bff 100644 --- a/doc/OnlineDocs/src/data/import6.tab.py +++ b/doc/OnlineDocs/src/data/import6.tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.p = Param() +model.p = pyo.Param() instance = model.create_instance('import6.tab.dat') -print('p ' + str(value(instance.p))) +print('p ' + str(pyo.value(instance.p))) diff --git a/doc/OnlineDocs/src/data/import7.tab.py b/doc/OnlineDocs/src/data/import7.tab.py index 503f9224323..74af30e6618 100644 --- a/doc/OnlineDocs/src/data/import7.tab.py +++ b/doc/OnlineDocs/src/data/import7.tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.I = Set(initialize=['I1', 'I2', 'I3', 'I4']) -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.U = Param(model.I, model.A) +model.I = pyo.Set(initialize=['I1', 'I2', 'I3', 'I4']) +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.U = pyo.Param(model.I, model.A) # BUG: This should cause an error -# model.U = Param(model.A,model.I) +# model.U = pyo.Param(model.A,model.I) instance = model.create_instance('import7.tab.dat') @@ -25,4 +25,4 @@ print('A ' + str(sorted(list(instance.A.data())))) print('U') for key in sorted(instance.U.keys()): - print(name(instance.U, key) + " " + str(value(instance.U[key]))) + print(pyo.name(instance.U, key) + " " + str(pyo.value(instance.U[key]))) diff --git a/doc/OnlineDocs/src/data/import8.tab.py b/doc/OnlineDocs/src/data/import8.tab.py index 02b8724fe45..f5ef022fe19 100644 --- a/doc/OnlineDocs/src/data/import8.tab.py +++ b/doc/OnlineDocs/src/data/import8.tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.I = Set(initialize=['I1', 'I2', 'I3', 'I4']) -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.U = Param(model.A, model.I) +model.I = pyo.Set(initialize=['I1', 'I2', 'I3', 'I4']) +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.U = pyo.Param(model.A, model.I) instance = model.create_instance('import8.tab.dat') @@ -23,4 +23,4 @@ print('I ' + str(sorted(list(instance.I.data())))) print('U') for key in sorted(instance.U.keys()): - print(name(instance.U, key) + " " + str(value(instance.U[key]))) + print(pyo.name(instance.U, key) + " " + str(pyo.value(instance.U[key]))) diff --git a/doc/OnlineDocs/src/data/param1.py b/doc/OnlineDocs/src/data/param1.py index 336a04287b9..dc02a76e0be 100644 --- a/doc/OnlineDocs/src/data/param1.py +++ b/doc/OnlineDocs/src/data/param1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,22 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Param() -model.B = Param() -model.C = Param() -model.D = Param() -model.E = Param() +model.A = pyo.Param() +model.B = pyo.Param() +model.C = pyo.Param() +model.D = pyo.Param() +model.E = pyo.Param() # @decl instance = model.create_instance('param1.dat') -print(value(instance.A)) -print(value(instance.B)) -print(value(instance.C)) -print(value(instance.D)) -print(value(instance.E)) +print(pyo.value(instance.A)) +print(pyo.value(instance.B)) +print(pyo.value(instance.C)) +print(pyo.value(instance.D)) +print(pyo.value(instance.E)) diff --git a/doc/OnlineDocs/src/data/param2.py b/doc/OnlineDocs/src/data/param2.py index a7d0feafff9..615b8c10d04 100644 --- a/doc/OnlineDocs/src/data/param2.py +++ b/doc/OnlineDocs/src/data/param2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set() -model.B = Param(model.A) +model.A = pyo.Set() +model.B = pyo.Param(model.A) # @decl instance = model.create_instance('param2.dat') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) diff --git a/doc/OnlineDocs/src/data/param2a.py b/doc/OnlineDocs/src/data/param2a.py index 42056793ffd..da4cea72b5c 100644 --- a/doc/OnlineDocs/src/data/param2a.py +++ b/doc/OnlineDocs/src/data/param2a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set() -model.B = Param(model.A) +model.A = pyo.Set() +model.B = pyo.Param(model.A) # @decl instance = model.create_instance('param2a.dat') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) diff --git a/doc/OnlineDocs/src/data/param3.py b/doc/OnlineDocs/src/data/param3.py index 952f9a9b707..4719cd069c8 100644 --- a/doc/OnlineDocs/src/data/param3.py +++ b/doc/OnlineDocs/src/data/param3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set() -model.B = Param(model.A) -model.C = Param(model.A) -model.D = Param(model.A) +model.A = pyo.Set() +model.B = pyo.Param(model.A) +model.C = pyo.Param(model.A) +model.D = pyo.Param(model.A) # @decl instance = model.create_instance('param3.dat') @@ -25,12 +25,12 @@ print('B') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) print('C') keys = instance.C.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.C[key]))) + print(str(key) + " " + str(pyo.value(instance.C[key]))) print('D') keys = instance.D.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.D[key]))) + print(str(key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/param3a.py b/doc/OnlineDocs/src/data/param3a.py index 028e1d07296..399e61c357b 100644 --- a/doc/OnlineDocs/src/data/param3a.py +++ b/doc/OnlineDocs/src/data/param3a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set() -model.B = Param(model.A) -model.C = Param(model.A) -model.D = Param(model.A) +model.A = pyo.Set() +model.B = pyo.Param(model.A) +model.C = pyo.Param(model.A) +model.D = pyo.Param(model.A) # @decl instance = model.create_instance('param3a.dat') @@ -25,12 +25,12 @@ print('B') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) print('C') keys = instance.C.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.C[key]))) + print(str(key) + " " + str(pyo.value(instance.C[key]))) print('D') keys = instance.D.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.D[key]))) + print(str(key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/param3b.py b/doc/OnlineDocs/src/data/param3b.py index 97f8598610a..bf3a324f79f 100644 --- a/doc/OnlineDocs/src/data/param3b.py +++ b/doc/OnlineDocs/src/data/param3b.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set() -model.B = Param(model.A) -model.C = Param(model.A) -model.D = Param(model.A) +model.A = pyo.Set() +model.B = pyo.Param(model.A) +model.C = pyo.Param(model.A) +model.D = pyo.Param(model.A) # @decl instance = model.create_instance('param3b.dat') @@ -25,12 +25,12 @@ print('B') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) print('C') keys = instance.C.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.C[key]))) + print(str(key) + " " + str(pyo.value(instance.C[key]))) print('D') keys = instance.D.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.D[key]))) + print(str(key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/param3c.py b/doc/OnlineDocs/src/data/param3c.py index 582b0f7db75..5ac54c697db 100644 --- a/doc/OnlineDocs/src/data/param3c.py +++ b/doc/OnlineDocs/src/data/param3c.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set() -model.B = Param(model.A) -model.C = Param(model.A) -model.D = Param(model.A) +model.A = pyo.Set() +model.B = pyo.Param(model.A) +model.C = pyo.Param(model.A) +model.D = pyo.Param(model.A) # @decl instance = model.create_instance('param3c.dat') @@ -25,12 +25,12 @@ print('B') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) print('C') keys = instance.C.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.C[key]))) + print(str(key) + " " + str(pyo.value(instance.C[key]))) print('D') keys = instance.D.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.D[key]))) + print(str(key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/param4.py b/doc/OnlineDocs/src/data/param4.py index 010c46fc9c5..545e01b2061 100644 --- a/doc/OnlineDocs/src/data/param4.py +++ b/doc/OnlineDocs/src/data/param4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set() -model.B = Param(model.A) +model.A = pyo.Set() +model.B = pyo.Param(model.A) # @decl instance = model.create_instance('param4.dat') @@ -23,4 +23,4 @@ print('B') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) diff --git a/doc/OnlineDocs/src/data/param5.py b/doc/OnlineDocs/src/data/param5.py index 2db07f3f990..ec3224ecf98 100644 --- a/doc/OnlineDocs/src/data/param5.py +++ b/doc/OnlineDocs/src/data/param5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=2) -model.B = Param(model.A) +model.A = pyo.Set(dimen=2) +model.B = pyo.Param(model.A) # @decl instance = model.create_instance('param5.dat') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) diff --git a/doc/OnlineDocs/src/data/param5a.py b/doc/OnlineDocs/src/data/param5a.py index 32a53d24e9b..8f27a830bfd 100644 --- a/doc/OnlineDocs/src/data/param5a.py +++ b/doc/OnlineDocs/src/data/param5a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=2) -model.B = Param(model.A) +model.A = pyo.Set(dimen=2) +model.B = pyo.Param(model.A) # @decl instance = model.create_instance('param5a.dat') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) diff --git a/doc/OnlineDocs/src/data/param6.py b/doc/OnlineDocs/src/data/param6.py index e3364a933cf..134bc83bb65 100644 --- a/doc/OnlineDocs/src/data/param6.py +++ b/doc/OnlineDocs/src/data/param6.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=2) -model.B = Param(model.A) -model.C = Param(model.A) -model.D = Param(model.A) +model.A = pyo.Set(dimen=2) +model.B = pyo.Param(model.A) +model.C = pyo.Param(model.A) +model.D = pyo.Param(model.A) # @decl instance = model.create_instance('param6.dat') @@ -25,12 +25,12 @@ keys = instance.B.keys() print('B') for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) print('C') keys = instance.C.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.C[key]))) + print(str(key) + " " + str(pyo.value(instance.C[key]))) print('D') keys = instance.D.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.D[key]))) + print(str(key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/param6a.py b/doc/OnlineDocs/src/data/param6a.py index 3d2fa645411..632fdf166cc 100644 --- a/doc/OnlineDocs/src/data/param6a.py +++ b/doc/OnlineDocs/src/data/param6a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=2) -model.B = Param(model.A) -model.C = Param(model.A) -model.D = Param(model.A) +model.A = pyo.Set(dimen=2) +model.B = pyo.Param(model.A) +model.C = pyo.Param(model.A) +model.D = pyo.Param(model.A) # @decl instance = model.create_instance('param6a.dat') @@ -25,12 +25,12 @@ keys = instance.B.keys() print('B') for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) print('C') keys = instance.C.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.C[key]))) + print(str(key) + " " + str(pyo.value(instance.C[key]))) print('D') keys = instance.D.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.D[key]))) + print(str(key) + " " + str(pyo.value(instance.D[key]))) diff --git a/doc/OnlineDocs/src/data/param7a.py b/doc/OnlineDocs/src/data/param7a.py index b3aba9ec23d..f1c2286466e 100644 --- a/doc/OnlineDocs/src/data/param7a.py +++ b/doc/OnlineDocs/src/data/param7a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=2) -model.B = Param(model.A) +model.A = pyo.Set(dimen=2) +model.B = pyo.Param(model.A) # @decl instance = model.create_instance('param7a.dat') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) diff --git a/doc/OnlineDocs/src/data/param7b.py b/doc/OnlineDocs/src/data/param7b.py index 8b022f399a8..105d37c2623 100644 --- a/doc/OnlineDocs/src/data/param7b.py +++ b/doc/OnlineDocs/src/data/param7b.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=2) -model.B = Param(model.A) +model.A = pyo.Set(dimen=2) +model.B = pyo.Param(model.A) # @decl instance = model.create_instance('param7b.dat') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) diff --git a/doc/OnlineDocs/src/data/param8a.py b/doc/OnlineDocs/src/data/param8a.py index abfa885ded4..7c85dd0ec1a 100644 --- a/doc/OnlineDocs/src/data/param8a.py +++ b/doc/OnlineDocs/src/data/param8a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=4) -model.B = Param(model.A) +model.A = pyo.Set(dimen=4) +model.B = pyo.Param(model.A) # @decl instance = model.create_instance('param8a.dat') keys = instance.B.keys() for key in sorted(keys): - print(str(key) + " " + str(value(instance.B[key]))) + print(str(key) + " " + str(pyo.value(instance.B[key]))) diff --git a/doc/OnlineDocs/src/data/set1.py b/doc/OnlineDocs/src/data/set1.py index c84c1ef0819..c76a29eb9cb 100644 --- a/doc/OnlineDocs/src/data/set1.py +++ b/doc/OnlineDocs/src/data/set1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set() -model.B = Set() -model.C = Set() +model.A = pyo.Set() +model.B = pyo.Set() +model.C = pyo.Set() instance = model.create_instance('set1.dat') diff --git a/doc/OnlineDocs/src/data/set2.py b/doc/OnlineDocs/src/data/set2.py index 9048a49fecb..51565891edd 100644 --- a/doc/OnlineDocs/src/data/set2.py +++ b/doc/OnlineDocs/src/data/set2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=3) +model.A = pyo.Set(dimen=3) # @decl instance = model.create_instance('set2.dat') diff --git a/doc/OnlineDocs/src/data/set2a.py b/doc/OnlineDocs/src/data/set2a.py index f2fa4d71916..5a3c1fcbc29 100644 --- a/doc/OnlineDocs/src/data/set2a.py +++ b/doc/OnlineDocs/src/data/set2a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=3) +model.A = pyo.Set(dimen=3) # @decl instance = model.create_instance('set2a.dat') diff --git a/doc/OnlineDocs/src/data/set3.py b/doc/OnlineDocs/src/data/set3.py index 9cdacbe39e0..d08eb5f5d16 100644 --- a/doc/OnlineDocs/src/data/set3.py +++ b/doc/OnlineDocs/src/data/set3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set() -model.B = Set(model.A) +model.A = pyo.Set() +model.B = pyo.Set(model.A) # @decl -# model.C = Set(model.A,model.A) +# model.C = pyo.Set(model.A,model.A) instance = model.create_instance('set3.dat') diff --git a/doc/OnlineDocs/src/data/set4.py b/doc/OnlineDocs/src/data/set4.py index b3485638c6f..513f5e3a10d 100644 --- a/doc/OnlineDocs/src/data/set4.py +++ b/doc/OnlineDocs/src/data/set4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=2) +model.A = pyo.Set(dimen=2) # @decl instance = model.create_instance('set4.dat') diff --git a/doc/OnlineDocs/src/data/set5.py b/doc/OnlineDocs/src/data/set5.py index d745d8408d0..7d633fecba1 100644 --- a/doc/OnlineDocs/src/data/set5.py +++ b/doc/OnlineDocs/src/data/set5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @decl -model.A = Set(dimen=4) +model.A = pyo.Set(dimen=4) # @decl instance = model.create_instance('set5.dat') diff --git a/doc/OnlineDocs/src/data/table0.py b/doc/OnlineDocs/src/data/table0.py index de0fae0c861..270519f8005 100644 --- a/doc/OnlineDocs/src/data/table0.py +++ b/doc/OnlineDocs/src/data/table0.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.M = Param(model.A) +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.M = pyo.Param(model.A) instance = model.create_instance('table0.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/data/table0.ul.py b/doc/OnlineDocs/src/data/table0.ul.py index 524c3756782..319b26901a5 100644 --- a/doc/OnlineDocs/src/data/table0.ul.py +++ b/doc/OnlineDocs/src/data/table0.ul.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.M = Param(model.A) +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.M = pyo.Param(model.A) instance = model.create_instance('table0.ul.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/data/table1.py b/doc/OnlineDocs/src/data/table1.py index f36714b8f1f..f49caeab59a 100644 --- a/doc/OnlineDocs/src/data/table1.py +++ b/doc/OnlineDocs/src/data/table1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.M = Param(model.A) +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.M = pyo.Param(model.A) instance = model.create_instance('table1.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/data/table2.py b/doc/OnlineDocs/src/data/table2.py index 03648a00f8c..d0071bf6eb1 100644 --- a/doc/OnlineDocs/src/data/table2.py +++ b/doc/OnlineDocs/src/data/table2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.B = Set(initialize=['B1', 'B2', 'B3']) +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.B = pyo.Set(initialize=['B1', 'B2', 'B3']) -model.M = Param(model.A) -model.N = Param(model.A, model.B) +model.M = pyo.Param(model.A) +model.N = pyo.Param(model.A, model.B) instance = model.create_instance('table2.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/data/table3.py b/doc/OnlineDocs/src/data/table3.py index 2c598f112df..6ef45369067 100644 --- a/doc/OnlineDocs/src/data/table3.py +++ b/doc/OnlineDocs/src/data/table3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set() -model.B = Set(initialize=['B1', 'B2', 'B3']) -model.Z = Set(dimen=2) +model.A = pyo.Set() +model.B = pyo.Set(initialize=['B1', 'B2', 'B3']) +model.Z = pyo.Set(dimen=2) -model.M = Param(model.A) -model.N = Param(model.A, model.B) +model.M = pyo.Param(model.A) +model.N = pyo.Param(model.A, model.B) instance = model.create_instance('table3.dat') diff --git a/doc/OnlineDocs/src/data/table3.ul.py b/doc/OnlineDocs/src/data/table3.ul.py index 18ced12b388..bb56e1f6586 100644 --- a/doc/OnlineDocs/src/data/table3.ul.py +++ b/doc/OnlineDocs/src/data/table3.ul.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set() -model.B = Set(initialize=['B1', 'B2', 'B3']) -model.Z = Set(dimen=2) +model.A = pyo.Set() +model.B = pyo.Set(initialize=['B1', 'B2', 'B3']) +model.Z = pyo.Set(dimen=2) -model.M = Param(model.A) -model.N = Param(model.A, model.B) +model.M = pyo.Param(model.A) +model.N = pyo.Param(model.A, model.B) instance = model.create_instance('table3.ul.dat') diff --git a/doc/OnlineDocs/src/data/table4.py b/doc/OnlineDocs/src/data/table4.py index bd20682b5a9..88ab7a5caf0 100644 --- a/doc/OnlineDocs/src/data/table4.py +++ b/doc/OnlineDocs/src/data/table4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set() -model.Z = Set(dimen=2) +model.A = pyo.Set() +model.Z = pyo.Set(dimen=2) -model.M = Param(model.A) -model.N = Param(model.Z) +model.M = pyo.Param(model.A) +model.N = pyo.Param(model.Z) instance = model.create_instance('table4.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/data/table4.ul.py b/doc/OnlineDocs/src/data/table4.ul.py index 9f16f21fe19..3dfb128c7fc 100644 --- a/doc/OnlineDocs/src/data/table4.ul.py +++ b/doc/OnlineDocs/src/data/table4.ul.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set() -model.Z = Set(dimen=2) +model.A = pyo.Set() +model.Z = pyo.Set(dimen=2) -model.M = Param(model.A) -model.N = Param(model.Z) +model.M = pyo.Param(model.A) +model.N = pyo.Param(model.Z) instance = model.create_instance('table4.ul.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/data/table5.py b/doc/OnlineDocs/src/data/table5.py index a3cb01209a2..b64f1a6ba12 100644 --- a/doc/OnlineDocs/src/data/table5.py +++ b/doc/OnlineDocs/src/data/table5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.Z = Set(dimen=2) -model.Y = Set(dimen=2) +model.Z = pyo.Set(dimen=2) +model.Y = pyo.Set(dimen=2) instance = model.create_instance('table5.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/data/table6.py b/doc/OnlineDocs/src/data/table6.py index 1db0a764a23..2133a419f50 100644 --- a/doc/OnlineDocs/src/data/table6.py +++ b/doc/OnlineDocs/src/data/table6.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.pi = Param() +model.pi = pyo.Param() instance = model.create_instance('table6.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/data/table7.py b/doc/OnlineDocs/src/data/table7.py index 84a841aca86..e51c98b72a5 100644 --- a/doc/OnlineDocs/src/data/table7.py +++ b/doc/OnlineDocs/src/data/table7.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.M = Param(model.A) -model.Z = Set(dimen=2) +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.M = pyo.Param(model.A) +model.Z = pyo.Set(dimen=2) instance = model.create_instance('table7.dat') instance.pprint() diff --git a/doc/OnlineDocs/src/dataportal/PP_sqlite.py b/doc/OnlineDocs/src/dataportal/PP_sqlite.py index 1592e820900..84c04db6402 100644 --- a/doc/OnlineDocs/src/dataportal/PP_sqlite.py +++ b/doc/OnlineDocs/src/dataportal/PP_sqlite.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/dataportal/dataportal_tab.py b/doc/OnlineDocs/src/dataportal/dataportal_tab.py index 655329d31de..23a43a5d462 100644 --- a/doc/OnlineDocs/src/dataportal/dataportal_tab.py +++ b/doc/OnlineDocs/src/dataportal/dataportal_tab.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,40 +9,40 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo # -------------------------------------------------- # @load -model = AbstractModel() -model.A = Set() -data = DataPortal() +model = pyo.AbstractModel() +model.A = pyo.Set() +data = pyo.DataPortal() data.load(filename='A.tab', set=model.A) instance = model.create_instance(data) # @load instance.pprint() # -------------------------------------------------- # @set1 -model = AbstractModel() -model.A = Set() -data = DataPortal() +model = pyo.AbstractModel() +model.A = pyo.Set() +data = pyo.DataPortal() data.load(filename='A.tab', set=model.A) instance = model.create_instance(data) # @set1 instance.pprint() # -------------------------------------------------- # @set2 -model = AbstractModel() -model.C = Set(dimen=2) -data = DataPortal() +model = pyo.AbstractModel() +model.C = pyo.Set(dimen=2) +data = pyo.DataPortal() data.load(filename='C.tab', set=model.C) instance = model.create_instance(data) # @set2 instance.pprint() # -------------------------------------------------- # @set3 -model = AbstractModel() -model.D = Set(dimen=2) -data = DataPortal() +model = pyo.AbstractModel() +model.D = pyo.Set(dimen=2) +data = pyo.DataPortal() data.load(filename='D.tab', set=model.D, format='set_array') instance = model.create_instance(data) # @set3 @@ -50,139 +50,139 @@ # -------------------------------------------------- # @param1 -model = AbstractModel() -data = DataPortal() -model.z = Param() +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.z = pyo.Param() data.load(filename='Z.tab', param=model.z) instance = model.create_instance(data) # @param1 instance.pprint() # -------------------------------------------------- # @param2 -model = AbstractModel() -data = DataPortal() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.y = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.y = pyo.Param(model.A) data.load(filename='Y.tab', param=model.y) instance = model.create_instance(data) # @param2 instance.pprint() # -------------------------------------------------- # @param4 -model = AbstractModel() -data = DataPortal() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.x = Param(model.A) -model.w = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.x = pyo.Param(model.A) +model.w = pyo.Param(model.A) data.load(filename='XW.tab', param=(model.x, model.w)) instance = model.create_instance(data) # @param4 instance.pprint() # -------------------------------------------------- # @param3 -model = AbstractModel() -data = DataPortal() -model.A = Set() -model.y = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set() +model.y = pyo.Param(model.A) data.load(filename='Y.tab', param=model.y, index=model.A) instance = model.create_instance(data) # @param3 instance.pprint() # -------------------------------------------------- # @param5 -model = AbstractModel() -data = DataPortal() -model.A = Set() -model.w = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set() +model.w = pyo.Param(model.A) data.load(filename='XW.tab', select=('A', 'W'), param=model.w, index=model.A) instance = model.create_instance(data) # @param5 instance.pprint() # -------------------------------------------------- # @param6 -model = AbstractModel() -data = DataPortal() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.I = Set(initialize=['I1', 'I2', 'I3', 'I4']) -model.u = Param(model.I, model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.I = pyo.Set(initialize=['I1', 'I2', 'I3', 'I4']) +model.u = pyo.Param(model.I, model.A) data.load(filename='U.tab', param=model.u, format='array') instance = model.create_instance(data) # @param6 instance.pprint() # -------------------------------------------------- # @param7 -model = AbstractModel() -data = DataPortal() -model.A = Set(initialize=['A1', 'A2', 'A3']) -model.I = Set(initialize=['I1', 'I2', 'I3', 'I4']) -model.t = Param(model.A, model.I) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(initialize=['A1', 'A2', 'A3']) +model.I = pyo.Set(initialize=['I1', 'I2', 'I3', 'I4']) +model.t = pyo.Param(model.A, model.I) data.load(filename='U.tab', param=model.t, format='transposed_array') instance = model.create_instance(data) # @param7 instance.pprint() # -------------------------------------------------- # @param8 -model = AbstractModel() -data = DataPortal() -model.A = Set() -model.s = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set() +model.s = pyo.Param(model.A) data.load(filename='S.tab', param=model.s, index=model.A) instance = model.create_instance(data) # @param8 instance.pprint() # -------------------------------------------------- # @param9 -model = AbstractModel() -data = DataPortal() -model.A = Set(initialize=['A1', 'A2', 'A3', 'A4']) -model.y = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(initialize=['A1', 'A2', 'A3', 'A4']) +model.y = pyo.Param(model.A) data.load(filename='Y.tab', param=model.y) instance = model.create_instance(data) # @param9 instance.pprint() # -------------------------------------------------- # @param10 -model = AbstractModel() -data = DataPortal() -model.A = Set(dimen=2) -model.p = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(dimen=2) +model.p = pyo.Param(model.A) data.load(filename='PP.tab', param=model.p, index=model.A) instance = model.create_instance(data) # @param10 instance.pprint() # -------------------------------------------------- # @param11 -model = AbstractModel() -data = DataPortal() -model.A = Set() -model.B = Set() -model.q = Param(model.A, model.B) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set() +model.B = pyo.Set() +model.q = pyo.Param(model.A, model.B) data.load(filename='PP.tab', param=model.q, index=(model.A, model.B)) # instance = model.create_instance(data) # @param11 # -------------------------------------------------- # @concrete1 -data = DataPortal() +data = pyo.DataPortal() data.load(filename='A.tab', set="A", format="set") -model = ConcreteModel() -model.A = Set(initialize=data['A']) +model = pyo.ConcreteModel() +model.A = pyo.Set(initialize=data['A']) # @concrete1 model.pprint() # -------------------------------------------------- # @concrete2 -data = DataPortal() +data = pyo.DataPortal() data.load(filename='Z.tab', param="z", format="param") data.load(filename='Y.tab', param="y", format="table") -model = ConcreteModel() -model.z = Param(initialize=data['z']) -model.y = Param(['A1', 'A2', 'A3'], initialize=data['y']) +model = pyo.ConcreteModel() +model.z = pyo.Param(initialize=data['z']) +model.y = pyo.Param(['A1', 'A2', 'A3'], initialize=data['y']) # @concrete2 model.pprint() # -------------------------------------------------- # @getitem -data = DataPortal() +data = pyo.DataPortal() data.load(filename='A.tab', set="A", format="set") print(data['A']) # ['A1', 'A2', 'A3'] @@ -195,20 +195,20 @@ # @getitem # -------------------------------------------------- # @excel1 -model = AbstractModel() -data = DataPortal() -model.A = Set(dimen=2) -model.p = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(dimen=2) +model.p = pyo.Param(model.A) data.load(filename='excel.xls', range='PPtable', param=model.p, index=model.A) instance = model.create_instance(data) # @excel1 instance.pprint() # -------------------------------------------------- # @excel2 -model = AbstractModel() -data = DataPortal() -model.A = Set(dimen=2) -model.p = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(dimen=2) +model.p = pyo.Param(model.A) # data.load(filename='excel.xls', range='AX2:AZ5', # param=model.p, index=model.A) instance = model.create_instance(data) @@ -216,16 +216,16 @@ instance.pprint() # -------------------------------------------------- # @db1 -model = AbstractModel() -data = DataPortal() -model.A = Set(dimen=2) -model.p = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set(dimen=2) +model.p = pyo.Param(model.A) data.load( filename='PP.sqlite', using='sqlite3', table='PPtable', param=model.p, index=model.A ) instance = model.create_instance(data) # @db1 -data = DataPortal() +data = pyo.DataPortal() data.load( filename='PP.sqlite', using='sqlite3', @@ -238,10 +238,10 @@ instance.pprint() # -------------------------------------------------- # @db2 -model = AbstractModel() -data = DataPortal() -model.A = Set() -model.p = Param(model.A) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set() +model.p = pyo.Param(model.A) data.load( filename='PP.sqlite', using='sqlite3', @@ -251,7 +251,7 @@ ) instance = model.create_instance(data) # @db2 -data = DataPortal() +data = pyo.DataPortal() data.load( filename='PP.sqlite', using='sqlite3', @@ -265,10 +265,10 @@ # -------------------------------------------------- # @db3 if False: - model = AbstractModel() - data = DataPortal() - model.A = Set() - model.p = Param(model.A) + model = pyo.AbstractModel() + data = pyo.DataPortal() + model.A = pyo.Set() + model.p = pyo.Param(model.A) data.load( filename="Driver={MySQL ODBC 5.2 UNICODE Driver}; Database=Pyomo; Server=localhost; User=pyomo;", using='pypyodbc', @@ -278,7 +278,7 @@ ) instance = model.create_instance(data) # @db3 - data = DataPortal() + data = pyo.DataPortal() data.load( filename="Driver={MySQL ODBC 5.2 UNICODE Driver}; Database=Pyomo; Server=localhost; User=pyomo;", using='pypyodbc', @@ -291,30 +291,30 @@ instance.pprint() # -------------------------------------------------- # @json1 -model = AbstractModel() -data = DataPortal() -model.A = Set() -model.B = Set(dimen=2) -model.C = Set(model.A) -model.p = Param() -model.q = Param(model.A) -model.r = Param(model.B) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set() +model.B = pyo.Set(dimen=2) +model.C = pyo.Set(model.A) +model.p = pyo.Param() +model.q = pyo.Param(model.A) +model.r = pyo.Param(model.B) data.load(filename='T.json') # @json1 -data = DataPortal() +data = pyo.DataPortal() data.load(filename='T.json', convert_unicode=True) instance = model.create_instance(data) instance.pprint() # -------------------------------------------------- # @yaml1 -model = AbstractModel() -data = DataPortal() -model.A = Set() -model.B = Set(dimen=2) -model.C = Set(model.A) -model.p = Param() -model.q = Param(model.A) -model.r = Param(model.B) +model = pyo.AbstractModel() +data = pyo.DataPortal() +model.A = pyo.Set() +model.B = pyo.Set(dimen=2) +model.C = pyo.Set(model.A) +model.p = pyo.Param() +model.q = pyo.Param(model.A) +model.r = pyo.Param(model.B) data.load(filename='T.yaml') # @yaml1 instance = model.create_instance(data) @@ -322,9 +322,9 @@ # -------------------------------------------------- # @namespaces1 -model = AbstractModel() -model.C = Set(dimen=2) -data = DataPortal() +model = pyo.AbstractModel() +model.C = pyo.Set(dimen=2) +data = pyo.DataPortal() data.load(filename='C.tab', set=model.C, namespace='ns1') data.load(filename='D.tab', set=model.C, namespace='ns2', format='set_array') instance1 = model.create_instance(data, namespaces=['ns1']) diff --git a/doc/OnlineDocs/src/dataportal/param_initialization.py b/doc/OnlineDocs/src/dataportal/param_initialization.py index 7f9270b5fda..5536e4c4ab1 100644 --- a/doc/OnlineDocs/src/dataportal/param_initialization.py +++ b/doc/OnlineDocs/src/dataportal/param_initialization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,18 +9,18 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo import numpy -model = ConcreteModel() +model = pyo.ConcreteModel() # @decl1 -model.a = Param(initialize=1.1) +model.a = pyo.Param(initialize=1.1) # @decl1 # Initialize with a dictionary # @decl2 -model.b = Param([1, 2, 3], initialize={1: 1, 2: 2, 3: 3}) +model.b = pyo.Param([1, 2, 3], initialize={1: 1, 2: 2, 3: 3}) # @decl2 @@ -30,7 +30,7 @@ def c(model): return {1: 1, 2: 2, 3: 3} -model.c = Param([1, 2, 3], initialize=c) +model.c = pyo.Param([1, 2, 3], initialize=c) # @decl3 model.pprint(verbose=True) diff --git a/doc/OnlineDocs/src/dataportal/set_initialization.py b/doc/OnlineDocs/src/dataportal/set_initialization.py index a5ab03894e3..e3cb65c9919 100644 --- a/doc/OnlineDocs/src/dataportal/set_initialization.py +++ b/doc/OnlineDocs/src/dataportal/set_initialization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,28 +9,28 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo import numpy -model = ConcreteModel() +model = pyo.ConcreteModel() # Initialize with a list, tuple or set # @decl2 -model.A = Set(initialize=[2, 3, 5]) -model.B = Set(initialize=set([2, 3, 5])) -model.C = Set(initialize=(2, 3, 5)) +model.A = pyo.Set(initialize=[2, 3, 5]) +model.B = pyo.Set(initialize=set([2, 3, 5])) +model.C = pyo.Set(initialize=(2, 3, 5)) # @decl2 # Initialize with a generator # @decl3 -model.D = Set(initialize=range(9)) -model.E = Set(initialize=(i for i in model.B if i % 2 == 0)) +model.D = pyo.Set(initialize=range(9)) +model.E = pyo.Set(initialize=(i for i in model.B if i % 2 == 0)) # @decl3 # Initialize with a numpy # @decl4 f = numpy.array([2, 3, 5]) -model.F = Set(initialize=f) +model.F = pyo.Set(initialize=f) # @decl4 @@ -40,7 +40,7 @@ def g(model): return [2, 3, 5] -model.G = Set(initialize=g) +model.G = pyo.Set(initialize=g) # @decl5 # Initialize an indexed set with a dictionary @@ -49,7 +49,7 @@ def g(model): H_init[2] = [1, 3, 5] H_init[3] = [2, 4, 6] H_init[4] = [3, 5, 7] -model.H = Set([2, 3, 4], initialize=H_init) +model.H = pyo.Set([2, 3, 4], initialize=H_init) # @decl6 model.pprint(verbose=True) diff --git a/doc/OnlineDocs/src/expr/design.py b/doc/OnlineDocs/src/expr/design.py index 647a4537ca4..0f2bd9b248a 100644 --- a/doc/OnlineDocs/src/expr/design.py +++ b/doc/OnlineDocs/src/expr/design.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,40 +9,40 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo # --------------------------------------------- # @categories -m = ConcreteModel() -m.p = Param(default=10, mutable=False) -m.q = Param(default=10, mutable=True) -m.x = Var() -m.y = Var(initialize=1) +m = pyo.ConcreteModel() +m.p = pyo.Param(default=10, mutable=False) +m.q = pyo.Param(default=10, mutable=True) +m.x = pyo.Var() +m.y = pyo.Var(initialize=1) m.y.fixed = True # @categories m.pprint() # --------------------------------------------- # @named_expression -M = ConcreteModel() -M.v = Var() -M.w = Var() +M = pyo.ConcreteModel() +M.v = pyo.Var() +M.w = pyo.Var() -M.e = Expression(expr=2 * M.v) +M.e = pyo.Expression(expr=2 * M.v) f = M.e + 3 # f == 2*v + 3 M.e += M.w # f == 2*v + 3 + w # @named_expression # --------------------------------------------- # @cm1 -M = ConcreteModel() -M.x = Var(range(5)) +M = pyo.ConcreteModel() +M.x = pyo.Var(range(5)) s = 0 for i in range(5): s += M.x[i] -with linear_expression() as e: +with pyo.linear_expression() as e: for i in range(5): e += M.x[i] # @cm1 @@ -52,13 +52,13 @@ # --------------------------------------------- # @cm2 -M = ConcreteModel() -M.x = Var(range(5)) -M.y = Var(range(5)) +M = pyo.ConcreteModel() +M.x = pyo.Var(range(5)) +M.y = pyo.Var(range(5)) -with linear_expression() as e: - quicksum((M.x[i] for i in M.x), start=e) - quicksum((M.y[i] for i in M.y), start=e) +with pyo.linear_expression() as e: + pyo.quicksum((M.x[i] for i in M.x), start=e) + pyo.quicksum((M.y[i] for i in M.y), start=e) # @cm2 print("cm2") print(e) diff --git a/doc/OnlineDocs/src/expr/index.py b/doc/OnlineDocs/src/expr/index.py index fe5b03461c0..e2a9e0caab1 100644 --- a/doc/OnlineDocs/src/expr/index.py +++ b/doc/OnlineDocs/src/expr/index.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo # --------------------------------------------- # @simple -M = ConcreteModel() -M.v = Var() +M = pyo.ConcreteModel() +M.v = pyo.Var() e = M.v * 2 # @simple diff --git a/doc/OnlineDocs/src/expr/managing.py b/doc/OnlineDocs/src/expr/managing.py index 00d521d16ab..8470dc9032e 100644 --- a/doc/OnlineDocs/src/expr/managing.py +++ b/doc/OnlineDocs/src/expr/managing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo from math import isclose import math import copy @@ -18,10 +18,10 @@ # @ex1 import pyomo.core.expr as EXPR -M = ConcreteModel() -M.x = Var() +M = pyo.ConcreteModel() +M.x = pyo.Var() -e = sin(M.x) + 2 * M.x +e = pyo.sin(M.x) + 2 * M.x # sin(x) + 2*x print(EXPR.expression_to_string(e)) @@ -34,22 +34,22 @@ # @ex2 import pyomo.core.expr as EXPR -M = ConcreteModel() -M.x = Var() -M.y = Var() +M = pyo.ConcreteModel() +M.x = pyo.Var() +M.y = pyo.Var() -e = sin(M.x) + 2 * M.y +e = pyo.sin(M.x) + 2 * M.y # sin(x1) + 2*x2 -print(EXPR.expression_to_string(e, labeler=NumericLabeler('x'))) +print(EXPR.expression_to_string(e, labeler=pyo.NumericLabeler('x'))) # @ex2 # --------------------------------------------- # @ex5 -M = ConcreteModel() -M.x = Var() +M = pyo.ConcreteModel() +M.x = pyo.Var() M.x.value = math.pi / 2.0 -val = value(M.x) +val = pyo.value(M.x) assert isclose(val, math.pi / 2.0) # @ex5 # @ex6 @@ -59,9 +59,9 @@ # --------------------------------------------- # @ex7 -M = ConcreteModel() -M.x = Var() -val = value(M.x, exception=False) +M = pyo.ConcreteModel() +M.x = pyo.Var() +val = pyo.value(M.x, exception=False) assert val is None # @ex7 @@ -69,9 +69,9 @@ # @ex8 import pyomo.core.expr as EXPR -M = ConcreteModel() -M.x = Var() -M.p = Param(mutable=True) +M = pyo.ConcreteModel() +M.x = pyo.Var() +M.p = pyo.Param(mutable=True) e = M.p + M.x s = set([type(M.p)]) @@ -82,9 +82,9 @@ # @ex9 import pyomo.core.expr as EXPR -M = ConcreteModel() -M.x = Var() -M.y = Var() +M = pyo.ConcreteModel() +M.x = pyo.Var() +M.y = pyo.Var() e = M.x + M.y M.y.value = 1 @@ -101,17 +101,17 @@ import pyomo.core.expr as EXPR -class SizeofVisitor(EXPR.SimpleExpressionVisitor): - def __init__(self): +class SizeofVisitor(EXPR.StreamBasedExpressionVisitor): + def initializeWalker(self, expr): self.counter = 0 + return True, expr - def visit(self, node): + def exitNode(self, node, data): self.counter += 1 - def finalize(self): + def finalizeResult(self, result): return self.counter - - # @visitor1 + # @visitor1 # --------------------------------------------- @@ -122,12 +122,20 @@ def sizeof_expression(expr): # visitor = SizeofVisitor() # - # Compute the value using the :func:`xbfs` search method. + # Compute the value using the :func:`walk_expression` search method. # - return visitor.xbfs(expr) + return visitor.walk_expression(expr) # @visitor2 +# Test: +m = pyo.ConcreteModel() +m.x = pyo.Var() +m.p = pyo.Param(mutable=True) +assert sizeof_expression(m.x) == 1 +assert sizeof_expression(m.x + m.p) == 3 +assert sizeof_expression(2 * m.x + m.p) == 5 + # --------------------------------------------- # @visitor3 import pyomo.core.expr as EXPR @@ -147,7 +155,7 @@ def visiting_potential_leaf(self, node): # # Clone leaf nodes in the expression tree # - if node.__class__ in native_numeric_types or not node.is_expression_type(): + if node.__class__ in pyo.native_numeric_types or not node.is_expression_type(): return True, copy.deepcopy(node, self.memo) return False, None @@ -170,9 +178,9 @@ def clone_expression(expr): # Test: -m = ConcreteModel() -m.x = Var(range(2)) -m.p = Param(range(5), mutable=True) +m = pyo.ConcreteModel() +m.x = pyo.Var(range(2)) +m.p = pyo.Param(range(5), mutable=True) e = m.x[0] + 5 * m.x[1] ce = clone_expression(e) print(e is not ce) @@ -181,7 +189,7 @@ def clone_expression(expr): # x[0] + 5*x[1] print(str(ce)) # x[0] + 5*x[1] -print(e.arg(0) is not ce.arg(0)) +print(e.arg(0) is ce.arg(0)) # True print(e.arg(1) is not ce.arg(1)) # True @@ -202,7 +210,7 @@ def beforeChild(self, node, child, child_idx): # nodes that do not conform to the ExpressionBase API (i.e., # define is_variable_type) # - if child.__class__ in native_numeric_types: + if child.__class__ in pyo.native_numeric_types: return False, child # # Replace leaf variables with scaled variables @@ -233,15 +241,15 @@ def scale_expression(expr, scale): # --------------------------------------------- # @visitor7 -M = ConcreteModel() -M.x = Var(range(5)) -M.p = Param(range(5), mutable=True) +M = pyo.ConcreteModel() +M.x = pyo.Var(range(5)) +M.p = pyo.Param(range(5), mutable=True) scale = {} for i in M.x: scale[id(M.x[i])] = M.p[i] -e = quicksum(M.x[i] for i in M.x) +e = pyo.quicksum(M.x[i] for i in M.x) f = scale_expression(e, scale) # p[0]*x[0] + p[1]*x[1] + p[2]*x[2] + p[3]*x[3] + p[4]*x[4] diff --git a/doc/OnlineDocs/src/expr/overview.py b/doc/OnlineDocs/src/expr/overview.py index d33725edb88..d8726d038b4 100644 --- a/doc/OnlineDocs/src/expr/overview.py +++ b/doc/OnlineDocs/src/expr/overview.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo # --------------------------------------------- # @example1 -M = ConcreteModel() -M.x = Var(range(100)) +M = pyo.ConcreteModel() +M.x = pyo.Var(range(100)) # This loop is fast. e = 0 @@ -30,10 +30,10 @@ # --------------------------------------------- # @example2 -M = ConcreteModel() -M.p = Param(initialize=3) +M = pyo.ConcreteModel() +M.p = pyo.Param(initialize=3) M.q = 1 / M.p -M.x = Var(range(100)) +M.x = pyo.Var(range(100)) # The value M.q is cloned every time it is used. e = 0 @@ -44,8 +44,8 @@ # --------------------------------------------- # @tree1 -M = ConcreteModel() -M.v = Var() +M = pyo.ConcreteModel() +M.v = pyo.Var() e = f = 2 * M.v # @tree1 @@ -53,8 +53,8 @@ # --------------------------------------------- # @tree2 -M = ConcreteModel() -M.v = Var() +M = pyo.ConcreteModel() +M.v = pyo.Var() e = 2 * M.v f = e + 3 @@ -64,8 +64,8 @@ # --------------------------------------------- # @tree3 -M = ConcreteModel() -M.v = Var() +M = pyo.ConcreteModel() +M.v = pyo.Var() e = 2 * M.v f = e + 3 @@ -77,9 +77,9 @@ # --------------------------------------------- # @tree4 -M = ConcreteModel() -M.v = Var() -M.w = Var() +M = pyo.ConcreteModel() +M.v = pyo.Var() +M.w = pyo.Var() e = 2 * M.v f = e + 3 @@ -91,11 +91,11 @@ # --------------------------------------------- # @tree5 -M = ConcreteModel() -M.v = Var() -M.w = Var() +M = pyo.ConcreteModel() +M.v = pyo.Var() +M.w = pyo.Var() -M.e = Expression(expr=2 * M.v) +M.e = pyo.Expression(expr=2 * M.v) f = M.e + 3 M.e += M.w diff --git a/doc/OnlineDocs/src/expr/performance.py b/doc/OnlineDocs/src/expr/performance.py index 8936bd2ed8c..f7897be8384 100644 --- a/doc/OnlineDocs/src/expr/performance.py +++ b/doc/OnlineDocs/src/expr/performance.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo # --------------------------------------------- # @loop1 -M = ConcreteModel() -M.x = Var(range(5)) +M = pyo.ConcreteModel() +M.x = pyo.Var(range(5)) s = 0 for i in range(5): @@ -36,18 +36,18 @@ # --------------------------------------------- # @prod -M = ConcreteModel() -M.x = Var(range(5)) -M.z = Var() +M = pyo.ConcreteModel() +M.x = pyo.Var(range(5)) +M.z = pyo.Var() # The product M.x[0] * M.x[1] * ... * M.x[4] -e1 = prod(M.x[i] for i in M.x) +e1 = pyo.prod(M.x[i] for i in M.x) # The product M.x[0]*M.z -e2 = prod([M.x[0], M.z]) +e2 = pyo.prod([M.x[0], M.z]) # The product M.z*(M.x[0] + ... + M.x[4]) -e3 = prod([sum(M.x[i] for i in M.x), M.z]) +e3 = pyo.prod([sum(M.x[i] for i in M.x), M.z]) # @prod print(e1) print(e2) @@ -55,42 +55,42 @@ # --------------------------------------------- # @quicksum -M = ConcreteModel() -M.x = Var(range(5)) +M = pyo.ConcreteModel() +M.x = pyo.Var(range(5)) # Summation using the Python sum() function e1 = sum(M.x[i] ** 2 for i in M.x) # Summation using the Pyomo quicksum function -e2 = quicksum(M.x[i] ** 2 for i in M.x) +e2 = pyo.quicksum(M.x[i] ** 2 for i in M.x) # @quicksum print(e1) print(e2) # --------------------------------------------- # @warning -M = ConcreteModel() -M.x = Var(range(5)) +M = pyo.ConcreteModel() +M.x = pyo.Var(range(5)) -e = quicksum(M.x[i] ** 2 if i > 0 else M.x[i] for i in range(5)) +e = pyo.quicksum(M.x[i] ** 2 if i > 0 else M.x[i] for i in range(5)) # @warning print(e) # --------------------------------------------- # @sum_product1 -M = ConcreteModel() -M.z = RangeSet(5) -M.x = Var(range(10)) -M.y = Var(range(10)) +M = pyo.ConcreteModel() +M.z = pyo.RangeSet(5) +M.x = pyo.Var(range(10)) +M.y = pyo.Var(range(10)) # Sum the elements of x -e1 = sum_product(M.x) +e1 = pyo.sum_product(M.x) # Sum the product of elements in x and y -e2 = sum_product(M.x, M.y) +e2 = pyo.sum_product(M.x, M.y) # Sum the product of elements in x and y, over the index set z -e3 = sum_product(M.x, M.y, index=M.z) +e3 = pyo.sum_product(M.x, M.y, index=M.z) # @sum_product1 print(e1) print(e2) @@ -99,10 +99,10 @@ # --------------------------------------------- # @sum_product2 # Sum the product of x_i/y_i -e1 = sum_product(M.x, denom=M.y) +e1 = pyo.sum_product(M.x, denom=M.y) # Sum the product of 1/(x_i*y_i) -e2 = sum_product(denom=(M.x, M.y)) +e2 = pyo.sum_product(denom=(M.x, M.y)) # @sum_product2 print(e1) print(e2) diff --git a/doc/OnlineDocs/src/expr/quicksum.py b/doc/OnlineDocs/src/expr/quicksum.py index 1b6cd3f9909..3e02f160c02 100644 --- a/doc/OnlineDocs/src/expr/quicksum.py +++ b/doc/OnlineDocs/src/expr/quicksum.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo from pyomo.repn import generate_standard_repn import time # @runtime -M = ConcreteModel() -M.A = RangeSet(100000) -M.p = Param(M.A, mutable=True, initialize=1) -M.x = Var(M.A) +M = pyo.ConcreteModel() +M.A = pyo.RangeSet(100000) +M.p = pyo.Param(M.A, mutable=True, initialize=1) +M.x = pyo.Var(M.A) start = time.time() e = sum((M.x[i] - 1) ** M.p[i] for i in M.A) @@ -28,7 +28,7 @@ print("repn: %f" % (time.time() - start)) start = time.time() -e = quicksum((M.x[i] - 1) ** M.p[i] for i in M.A) +e = pyo.quicksum((M.x[i] - 1) ** M.p[i] for i in M.A) print("quicksum: %f" % (time.time() - start)) start = time.time() diff --git a/doc/OnlineDocs/src/kernel/examples.sh b/doc/OnlineDocs/src/kernel/examples.sh index 0ac9e1a0fbf..1e5db3a6076 100755 --- a/doc/OnlineDocs/src/kernel/examples.sh +++ b/doc/OnlineDocs/src/kernel/examples.sh @@ -1,3 +1,4 @@ #! /bin/bash -for file in `ls ../../library_reference/kernel/examples/*.py | sort`; do python $file; done; +dir=`dirname $0` +for file in `ls ${dir}/examples/*.py | sort`; do python $file; done; diff --git a/doc/OnlineDocs/library_reference/kernel/examples/aml_example.py b/doc/OnlineDocs/src/kernel/examples/aml_example.py similarity index 99% rename from doc/OnlineDocs/library_reference/kernel/examples/aml_example.py rename to doc/OnlineDocs/src/kernel/examples/aml_example.py index a640b94cc76..3f3c0c2e4c4 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/aml_example.py +++ b/doc/OnlineDocs/src/kernel/examples/aml_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/library_reference/kernel/examples/conic.py b/doc/OnlineDocs/src/kernel/examples/conic.py similarity index 97% rename from doc/OnlineDocs/library_reference/kernel/examples/conic.py rename to doc/OnlineDocs/src/kernel/examples/conic.py index 0418d188722..70c1adefeda 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/conic.py +++ b/doc/OnlineDocs/src/kernel/examples/conic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/library_reference/kernel/examples/kernel_containers.py b/doc/OnlineDocs/src/kernel/examples/kernel_containers.py similarity index 96% rename from doc/OnlineDocs/library_reference/kernel/examples/kernel_containers.py rename to doc/OnlineDocs/src/kernel/examples/kernel_containers.py index 1931c6d9b56..9264562f095 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/kernel_containers.py +++ b/doc/OnlineDocs/src/kernel/examples/kernel_containers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/library_reference/kernel/examples/kernel_example.py b/doc/OnlineDocs/src/kernel/examples/kernel_example.py similarity index 99% rename from doc/OnlineDocs/library_reference/kernel/examples/kernel_example.py rename to doc/OnlineDocs/src/kernel/examples/kernel_example.py index 1f80bce9788..6e184586d4c 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/kernel_example.py +++ b/doc/OnlineDocs/src/kernel/examples/kernel_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/library_reference/kernel/examples/kernel_solving.py b/doc/OnlineDocs/src/kernel/examples/kernel_solving.py similarity index 96% rename from doc/OnlineDocs/library_reference/kernel/examples/kernel_solving.py rename to doc/OnlineDocs/src/kernel/examples/kernel_solving.py index 13d7efc052a..d0a96e16ca3 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/kernel_solving.py +++ b/doc/OnlineDocs/src/kernel/examples/kernel_solving.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/library_reference/kernel/examples/kernel_subclassing.py b/doc/OnlineDocs/src/kernel/examples/kernel_subclassing.py similarity index 98% rename from doc/OnlineDocs/library_reference/kernel/examples/kernel_subclassing.py rename to doc/OnlineDocs/src/kernel/examples/kernel_subclassing.py index d6e38f6b0e0..3bb57febd8d 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/kernel_subclassing.py +++ b/doc/OnlineDocs/src/kernel/examples/kernel_subclassing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/library_reference/kernel/examples/transformer.py b/doc/OnlineDocs/src/kernel/examples/transformer.py similarity index 84% rename from doc/OnlineDocs/library_reference/kernel/examples/transformer.py rename to doc/OnlineDocs/src/kernel/examples/transformer.py index 43a1d0675bf..c18504a8a54 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/transformer.py +++ b/doc/OnlineDocs/src/kernel/examples/transformer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.environ +import pyomo.environ as pyo import pyomo.kernel import pympler.asizeof @@ -53,11 +53,11 @@ def connect_v_out(self, v_out): # @aml def Transformer(): - b = pyomo.environ.Block(concrete=True) - b._a = pyomo.environ.Param(mutable=True) - b._v_in = pyomo.environ.Expression() - b._v_out = pyomo.environ.Expression() - b._c = pyomo.environ.Constraint(expr=b._a * b._v_out == b._v_in) + b = pyo.Block(concrete=True) + b._a = pyo.Param(mutable=True) + b._v_in = pyo.Expression() + b._v_out = pyo.Expression() + b._c = pyo.Constraint(expr=b._a * b._v_out == b._v_in) return b diff --git a/doc/OnlineDocs/src/scripting/AbstractSuffixes.py b/doc/OnlineDocs/src/scripting/AbstractSuffixes.py index 1c064042c6b..d600de64924 100644 --- a/doc/OnlineDocs/src/scripting/AbstractSuffixes.py +++ b/doc/OnlineDocs/src/scripting/AbstractSuffixes.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,25 +9,25 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() -model.I = RangeSet(1, 4) -model.x = Var(model.I) +model = pyo.AbstractModel() +model.I = pyo.RangeSet(1, 4) +model.x = pyo.Var(model.I) def c_rule(m, i): return m.x[i] >= i -model.c = Constraint(model.I, rule=c_rule) +model.c = pyo.Constraint(model.I, rule=c_rule) def foo_rule(m): return ((m.x[i], 3.0 * i) for i in m.I) -model.foo = Suffix(rule=foo_rule) +model.foo = pyo.Suffix(rule=foo_rule) # instantiate the model inst = model.create_instance() diff --git a/doc/OnlineDocs/src/scripting/Isinglebuild.py b/doc/OnlineDocs/src/scripting/Isinglebuild.py index 344f8905a4a..7e7e4da4353 100644 --- a/doc/OnlineDocs/src/scripting/Isinglebuild.py +++ b/doc/OnlineDocs/src/scripting/Isinglebuild.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,15 +11,15 @@ # Isinglebuild.py # NodesIn and NodesOut are created by a build action using the Arcs -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.Nodes = Set() -model.Arcs = Set(dimen=2) +model.Nodes = pyo.Set() +model.Arcs = pyo.Set(dimen=2) -model.NodesOut = Set(model.Nodes, within=model.Nodes, initialize=[]) -model.NodesIn = Set(model.Nodes, within=model.Nodes, initialize=[]) +model.NodesOut = pyo.Set(model.Nodes, within=model.Nodes, initialize=[]) +model.NodesIn = pyo.Set(model.Nodes, within=model.Nodes, initialize=[]) def Populate_In_and_Out(model): @@ -29,20 +29,20 @@ def Populate_In_and_Out(model): model.NodesOut[i].add(j) -model.In_n_Out = BuildAction(rule=Populate_In_and_Out) +model.In_n_Out = pyo.BuildAction(rule=Populate_In_and_Out) -model.Flow = Var(model.Arcs, domain=NonNegativeReals) -model.FlowCost = Param(model.Arcs) +model.Flow = pyo.Var(model.Arcs, domain=pyo.NonNegativeReals) +model.FlowCost = pyo.Param(model.Arcs) -model.Demand = Param(model.Nodes) -model.Supply = Param(model.Nodes) +model.Demand = pyo.Param(model.Nodes) +model.Supply = pyo.Param(model.Nodes) def Obj_rule(model): - return summation(model.FlowCost, model.Flow) + return pyo.summation(model.FlowCost, model.Flow) -model.Obj = Objective(rule=Obj_rule, sense=minimize) +model.Obj = pyo.Objective(rule=Obj_rule, sense=pyo.minimize) def FlowBalance_rule(model, node): @@ -55,4 +55,4 @@ def FlowBalance_rule(model, node): ) -model.FlowBalance = Constraint(model.Nodes, rule=FlowBalance_rule) +model.FlowBalance = pyo.Constraint(model.Nodes, rule=FlowBalance_rule) diff --git a/doc/OnlineDocs/src/scripting/NodesIn_init.py b/doc/OnlineDocs/src/scripting/NodesIn_init.py index c17b70150bc..efd039d4403 100644 --- a/doc/OnlineDocs/src/scripting/NodesIn_init.py +++ b/doc/OnlineDocs/src/scripting/NodesIn_init.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/scripting/Z_init.py b/doc/OnlineDocs/src/scripting/Z_init.py index 1dd2843f4f0..a97e0836101 100644 --- a/doc/OnlineDocs/src/scripting/Z_init.py +++ b/doc/OnlineDocs/src/scripting/Z_init.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/scripting/abstract2.py b/doc/OnlineDocs/src/scripting/abstract2.py index 544399a8a42..2ea43b834f0 100644 --- a/doc/OnlineDocs/src/scripting/abstract2.py +++ b/doc/OnlineDocs/src/scripting/abstract2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,26 +12,26 @@ # abstract2.py -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.I = Set() -model.J = Set() +model.I = pyo.Set() +model.J = pyo.Set() -model.a = Param(model.I, model.J) -model.b = Param(model.I) -model.c = Param(model.J) +model.a = pyo.Param(model.I, model.J) +model.b = pyo.Param(model.I) +model.c = pyo.Param(model.J) # the next line declares a variable indexed by the set J -model.x = Var(model.J, domain=NonNegativeReals) +model.x = pyo.Var(model.J, domain=pyo.NonNegativeReals) def obj_expression(model): - return summation(model.c, model.x) + return pyo.summation(model.c, model.x) -model.OBJ = Objective(rule=obj_expression) +model.OBJ = pyo.Objective(rule=obj_expression) def ax_constraint_rule(model, i): @@ -40,4 +40,4 @@ def ax_constraint_rule(model, i): # the next line creates one constraint for each member of the set model.I -model.AxbConstraint = Constraint(model.I, rule=ax_constraint_rule) +model.AxbConstraint = pyo.Constraint(model.I, rule=ax_constraint_rule) diff --git a/doc/OnlineDocs/src/scripting/abstract2piece.py b/doc/OnlineDocs/src/scripting/abstract2piece.py index 03c5139004e..d302c5b928e 100644 --- a/doc/OnlineDocs/src/scripting/abstract2piece.py +++ b/doc/OnlineDocs/src/scripting/abstract2piece.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,22 +12,22 @@ # abstract2piece.py # Similar to abstract2.py, but the objective is now c times x to the fourth power -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.I = Set() -model.J = Set() +model.I = pyo.Set() +model.J = pyo.Set() Topx = 6.1 # range of x variables -model.a = Param(model.I, model.J) -model.b = Param(model.I) -model.c = Param(model.J) +model.a = pyo.Param(model.I, model.J) +model.b = pyo.Param(model.I) +model.c = pyo.Param(model.J) # the next line declares a variable indexed by the set J -model.x = Var(model.J, domain=NonNegativeReals, bounds=(0, Topx)) -model.y = Var(model.J, domain=NonNegativeReals) +model.x = pyo.Var(model.J, domain=pyo.NonNegativeReals, bounds=(0, Topx)) +model.y = pyo.Var(model.J, domain=pyo.NonNegativeReals) # to avoid warnings, we set breakpoints at or beyond the bounds PieceCnt = 100 @@ -41,16 +41,16 @@ def f4(model, j, xp): return xp**4 -model.ComputeObj = Piecewise( +model.ComputeObj = pyo.Piecewise( model.J, model.y, model.x, pw_pts=bpts, pw_constr_type='EQ', f_rule=f4 ) def obj_expression(model): - return summation(model.c, model.y) + return pyo.summation(model.c, model.y) -model.OBJ = Objective(rule=obj_expression) +model.OBJ = pyo.Objective(rule=obj_expression) def ax_constraint_rule(model, i): @@ -59,4 +59,4 @@ def ax_constraint_rule(model, i): # the next line creates one constraint for each member of the set model.I -model.AxbConstraint = Constraint(model.I, rule=ax_constraint_rule) +model.AxbConstraint = pyo.Constraint(model.I, rule=ax_constraint_rule) diff --git a/doc/OnlineDocs/src/scripting/abstract2piecebuild.py b/doc/OnlineDocs/src/scripting/abstract2piecebuild.py index d454d7fbc79..f48c73ef47b 100644 --- a/doc/OnlineDocs/src/scripting/abstract2piecebuild.py +++ b/doc/OnlineDocs/src/scripting/abstract2piecebuild.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,23 +12,23 @@ # abstract2piecebuild.py # Similar to abstract2piece.py, but the breakpoints are created using a build action -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.I = Set() -model.J = Set() +model.I = pyo.Set() +model.J = pyo.Set() -model.a = Param(model.I, model.J) -model.b = Param(model.I) -model.c = Param(model.J) +model.a = pyo.Param(model.I, model.J) +model.b = pyo.Param(model.I) +model.c = pyo.Param(model.J) -model.Topx = Param(default=6.1) # range of x variables -model.PieceCnt = Param(default=100) +model.Topx = pyo.Param(default=6.1) # range of x variables +model.PieceCnt = pyo.Param(default=100) # the next line declares a variable indexed by the set J -model.x = Var(model.J, domain=NonNegativeReals, bounds=(0, model.Topx)) -model.y = Var(model.J, domain=NonNegativeReals) +model.x = pyo.Var(model.J, domain=pyo.NonNegativeReals, bounds=(0, model.Topx)) +model.y = pyo.Var(model.J, domain=pyo.NonNegativeReals) # to avoid warnings, we set breakpoints beyond the bounds # we are using a dictionary so that we can have different @@ -47,7 +47,7 @@ def bpts_build(model, j): # The object model.BuildBpts is not referred to again; # the only goal is to trigger the action at build time # @BuildAction_example -model.BuildBpts = BuildAction(model.J, rule=bpts_build) +model.BuildBpts = pyo.BuildAction(model.J, rule=bpts_build) # @BuildAction_example @@ -56,16 +56,16 @@ def f4(model, j, xp): return xp**4 -model.ComputePieces = Piecewise( +model.ComputePieces = pyo.Piecewise( model.J, model.y, model.x, pw_pts=model.bpts, pw_constr_type='EQ', f_rule=f4 ) def obj_expression(model): - return summation(model.c, model.y) + return pyo.summation(model.c, model.y) -model.OBJ = Objective(rule=obj_expression) +model.OBJ = pyo.Objective(rule=obj_expression) def ax_constraint_rule(model, i): @@ -74,4 +74,4 @@ def ax_constraint_rule(model, i): # the next line creates one constraint for each member of the set model.I -model.AxbConstraint = Constraint(model.I, rule=ax_constraint_rule) +model.AxbConstraint = pyo.Constraint(model.I, rule=ax_constraint_rule) diff --git a/doc/OnlineDocs/src/scripting/block_iter_example.py b/doc/OnlineDocs/src/scripting/block_iter_example.py index 10c8a4ea43d..d2d56cd6e76 100644 --- a/doc/OnlineDocs/src/scripting/block_iter_example.py +++ b/doc/OnlineDocs/src/scripting/block_iter_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ # written by jds, adapted for doc by dlw -from pyomo.environ import * +import pyomo.environ as pyo # simple way to get arbitrary, unique values for each thing val_iter = 0 @@ -22,30 +22,30 @@ def get_val(*args, **kwds): return val_iter -model = ConcreteModel() -model.I = RangeSet(3) -model.x = Var(initialize=get_val) -model.y = Var(model.I, initialize=get_val) +model = pyo.ConcreteModel() +model.I = pyo.RangeSet(3) +model.x = pyo.Var(initialize=get_val) +model.y = pyo.Var(model.I, initialize=get_val) -model.b = Block() -model.b.a = Var(initialize=get_val) -model.b.b = Var(model.I, initialize=get_val) +model.b = pyo.Block() +model.b.a = pyo.Var(initialize=get_val) +model.b.b = pyo.Var(model.I, initialize=get_val) def c_rule(b, i): - b.c = Var(initialize=get_val) - b.d = Var(b.model().I, initialize=get_val) + b.c = pyo.Var(initialize=get_val) + b.d = pyo.Var(b.model().I, initialize=get_val) -model.c = Block([1, 2], rule=c_rule) +model.c = pyo.Block([1, 2], rule=c_rule) model.pprint() # @compprintloop -for v in model.component_objects(Var, descend_into=True): +for v in model.component_objects(pyo.Var, descend_into=True): print("FOUND VAR:" + v.name) v.pprint() -for v_data in model.component_data_objects(Var, descend_into=True): - print("Found: " + v_data.name + ", value = " + str(value(v_data))) +for v_data in model.component_data_objects(pyo.Var, descend_into=True): + print("Found: " + v_data.name + ", value = " + str(pyo.value(v_data))) # @compprintloop diff --git a/doc/OnlineDocs/src/scripting/concrete1.py b/doc/OnlineDocs/src/scripting/concrete1.py index 399715efde6..8c47e21f577 100644 --- a/doc/OnlineDocs/src/scripting/concrete1.py +++ b/doc/OnlineDocs/src/scripting/concrete1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = ConcreteModel() +model = pyo.ConcreteModel() -model.x = Var([1, 2], domain=NonNegativeReals) +model.x = pyo.Var([1, 2], domain=pyo.NonNegativeReals) -model.OBJ = Objective(expr=2 * model.x[1] + 3 * model.x[2]) +model.OBJ = pyo.Objective(expr=2 * model.x[1] + 3 * model.x[2]) -model.Constraint1 = Constraint(expr=3 * model.x[1] + 4 * model.x[2] >= 1) +model.Constraint1 = pyo.Constraint(expr=3 * model.x[1] + 4 * model.x[2] >= 1) diff --git a/doc/OnlineDocs/src/scripting/doubleA.py b/doc/OnlineDocs/src/scripting/doubleA.py index abf35979a05..c029f2a892f 100644 --- a/doc/OnlineDocs/src/scripting/doubleA.py +++ b/doc/OnlineDocs/src/scripting/doubleA.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/scripting/driveabs2.py b/doc/OnlineDocs/src/scripting/driveabs2.py index f8f972460b1..f63b2d1c004 100644 --- a/doc/OnlineDocs/src/scripting/driveabs2.py +++ b/doc/OnlineDocs/src/scripting/driveabs2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/scripting/driveconc1.py b/doc/OnlineDocs/src/scripting/driveconc1.py index 49b92f32d09..cf17002dcd0 100644 --- a/doc/OnlineDocs/src/scripting/driveconc1.py +++ b/doc/OnlineDocs/src/scripting/driveconc1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/scripting/iterative1.py b/doc/OnlineDocs/src/scripting/iterative1.py index 939120e834f..fb43e8e9743 100644 --- a/doc/OnlineDocs/src/scripting/iterative1.py +++ b/doc/OnlineDocs/src/scripting/iterative1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/scripting/iterative2.py b/doc/OnlineDocs/src/scripting/iterative2.py index 7506337a491..01ff74c9759 100644 --- a/doc/OnlineDocs/src/scripting/iterative2.py +++ b/doc/OnlineDocs/src/scripting/iterative2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,6 @@ # iterative2.py import pyomo.environ as pyo -from pyomo.opt import SolverFactory # Create a solver opt = pyo.SolverFactory('cplex') diff --git a/doc/OnlineDocs/src/scripting/noiteration1.py b/doc/OnlineDocs/src/scripting/noiteration1.py index c7a86e9d1e9..ef4684e0572 100644 --- a/doc/OnlineDocs/src/scripting/noiteration1.py +++ b/doc/OnlineDocs/src/scripting/noiteration1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/scripting/parallel.py b/doc/OnlineDocs/src/scripting/parallel.py index e6cfa002780..e1b277e62ed 100644 --- a/doc/OnlineDocs/src/scripting/parallel.py +++ b/doc/OnlineDocs/src/scripting/parallel.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/scripting/spy4Constraints.py b/doc/OnlineDocs/src/scripting/spy4Constraints.py index 66f82802402..c6db6cc4699 100644 --- a/doc/OnlineDocs/src/scripting/spy4Constraints.py +++ b/doc/OnlineDocs/src/scripting/spy4Constraints.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,30 +14,30 @@ Code snippets for Constraints.rst in testable form """ -from pyomo.environ import * +import pyomo.environ as pyo -model = ConcreteModel() +model = pyo.ConcreteModel() # @Inequality_constraints_2expressions -model.x = Var() +model.x = pyo.Var() def aRule(model): return model.x >= 2 -model.Boundx = Constraint(rule=aRule) +model.Boundx = pyo.Constraint(rule=aRule) def bRule(model): return (2, model.x, None) -model.boundx = Constraint(rule=bRule) +model.boundx = pyo.Constraint(rule=bRule) # @Inequality_constraints_2expressions -model = ConcreteModel() -model.J = Set(initialize=['butter', 'scones']) -model.x = Var(model.J) +model = pyo.ConcreteModel() +model.J = pyo.Set(initialize=['butter', 'scones']) +model.x = pyo.Var(model.J) # @Constraint_example @@ -45,18 +45,18 @@ def teaOKrule(model): return model.x['butter'] + model.x['scones'] == 3 -model.TeaConst = Constraint(rule=teaOKrule) +model.TeaConst = pyo.Constraint(rule=teaOKrule) # @Constraint_example # @Passing_elements_crossproduct -model.A = RangeSet(1, 10) -model.a = Param(model.A, within=PositiveReals) -model.ToBuy = Var(model.A) +model.A = pyo.RangeSet(1, 10) +model.a = pyo.Param(model.A, within=pyo.PositiveReals) +model.ToBuy = pyo.Var(model.A) def bud_rule(model, i): return model.a[i] * model.ToBuy[i] <= i -aBudget = Constraint(model.A, rule=bud_rule) +aBudget = pyo.Constraint(model.A, rule=bud_rule) # @Passing_elements_crossproduct diff --git a/doc/OnlineDocs/src/scripting/spy4Expressions.py b/doc/OnlineDocs/src/scripting/spy4Expressions.py index cf7ed1f112f..1160fbfb7bd 100644 --- a/doc/OnlineDocs/src/scripting/spy4Expressions.py +++ b/doc/OnlineDocs/src/scripting/spy4Expressions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,44 +14,44 @@ Code snippets for Expressions.rst in testable form """ -from pyomo.environ import * +import pyomo.environ as pyo -model = ConcreteModel() +model = pyo.ConcreteModel() # @Buildup_expression_switch switch = 3 -model.A = RangeSet(1, 10) -model.c = Param(model.A) -model.d = Param() -model.x = Var(model.A, domain=Boolean) +model.A = pyo.RangeSet(1, 10) +model.c = pyo.Param(model.A) +model.d = pyo.Param() +model.x = pyo.Var(model.A, domain=pyo.Boolean) def pi_rule(model): - accexpr = summation(model.c, model.x) + accexpr = pyo.summation(model.c, model.x) if switch >= 2: accexpr = accexpr - model.d return accexpr >= 0.5 -PieSlice = Constraint(rule=pi_rule) +PieSlice = pyo.Constraint(rule=pi_rule) # @Buildup_expression_switch # @Abstract_wrong_usage -model.A = RangeSet(1, 10) -model.c = Param(model.A) -model.d = Param() -model.x = Var(model.A, domain=Boolean) +model.A = pyo.RangeSet(1, 10) +model.c = pyo.Param(model.A) +model.d = pyo.Param() +model.x = pyo.Var(model.A, domain=pyo.Boolean) def pi_rule(model): - accexpr = summation(model.c, model.x) + accexpr = pyo.summation(model.c, model.x) if model.d >= 2: # NOT in an abstract model!! accexpr = accexpr - model.d return accexpr >= 0.5 -PieSlice = Constraint(rule=pi_rule) +PieSlice = pyo.Constraint(rule=pi_rule) # @Abstract_wrong_usage # @Declare_piecewise_constraints @@ -70,30 +70,35 @@ def f(model, j, x): # A nonlinear function -f = lambda model, x: exp(x) + value(model.p) +f = lambda model, x: pyo.exp(x) + pyo.value(model.p) # A step function f = [0, 0, 1, 1, 2, 2] # @f_rule_Function_examples # @Keyword_assignment_example -kwds = {'pw_constr_type': 'EQ', 'pw_repn': 'SOS2', 'sense': maximize, 'force_pw': True} +kwds = { + 'pw_constr_type': 'EQ', + 'pw_repn': 'SOS2', + 'sense': pyo.maximize, + 'force_pw': True, +} # @Keyword_assignment_example # @Expression_objects_illustration -model = ConcreteModel() -model.x = Var(initialize=1.0) +model = pyo.ConcreteModel() +model.x = pyo.Var(initialize=1.0) def _e(m, i): return m.x * i -model.e = Expression([1, 2, 3], rule=_e) +model.e = pyo.Expression([1, 2, 3], rule=_e) instance = model.create_instance() -print(value(instance.e[1])) # -> 1.0 +print(pyo.value(instance.e[1])) # -> 1.0 print(instance.e[1]()) # -> 1.0 print(instance.e[1].value) # -> a pyomo expression object @@ -104,7 +109,7 @@ def _e(m, i): # ... load results # print the value of the expression given the loaded optimal solution -print(value(instance.e[1])) +print(pyo.value(instance.e[1])) # @Expression_objects_illustration @@ -116,8 +121,8 @@ def f(x, p): # @Define_python_function # @Generate_new_expression -model = ConcreteModel() -model.x = Var() +model = pyo.ConcreteModel() +model.x = pyo.Var() # create a Pyomo expression e1 = model.x + 5 diff --git a/doc/OnlineDocs/src/scripting/spy4PyomoCommand.py b/doc/OnlineDocs/src/scripting/spy4PyomoCommand.py index 9f6698d63c9..e136fb208e2 100644 --- a/doc/OnlineDocs/src/scripting/spy4PyomoCommand.py +++ b/doc/OnlineDocs/src/scripting/spy4PyomoCommand.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,14 +14,14 @@ Code snippets for PyomoCommand.rst in testable form """ -from pyomo.environ import * +import pyomo.environ as pyo -model = ConcreteModel() -model.I = RangeSet(3) -model.J = RangeSet(3) -model.a = Param(model.I, model.J, default=1.0) -model.x = Var(model.J) -model.b = Param(model.I, default=1.0) +model = pyo.ConcreteModel() +model.I = pyo.RangeSet(3) +model.J = pyo.RangeSet(3) +model.a = pyo.Param(model.I, model.J, default=1.0) +model.x = pyo.Var(model.J) +model.b = pyo.Param(model.I, default=1.0) # @Troubleshooting_printed_command @@ -32,5 +32,5 @@ def ax_constraint_rule(model, i): # the next line creates one constraint for each member of the set model.I -model.AxbConstraint = Constraint(model.I, rule=ax_constraint_rule) +model.AxbConstraint = pyo.Constraint(model.I, rule=ax_constraint_rule) # @Troubleshooting_printed_command diff --git a/doc/OnlineDocs/src/scripting/spy4Variables.py b/doc/OnlineDocs/src/scripting/spy4Variables.py index 1bc2dc9f1ef..d9fa4d495e0 100644 --- a/doc/OnlineDocs/src/scripting/spy4Variables.py +++ b/doc/OnlineDocs/src/scripting/spy4Variables.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,11 +14,11 @@ Code snippets for Variables.rst in testable form """ -from pyomo.environ import * +import pyomo.environ as pyo -model = ConcreteModel() +model = pyo.ConcreteModel() # @Declare_singleton_variable -model.LumberJack = Var(within=NonNegativeReals, bounds=(0, 6), initialize=1.5) +model.LumberJack = pyo.Var(within=pyo.NonNegativeReals, bounds=(0, 6), initialize=1.5) # @Declare_singleton_variable # @Assign_value @@ -26,7 +26,7 @@ # @Assign_value # @Declare_bounds -model.A = Set(initialize=['Scones', 'Tea']) +model.A = pyo.Set(initialize=['Scones', 'Tea']) lb = {'Scones': 2, 'Tea': 4} ub = {'Scones': 5, 'Tea': 7} @@ -35,5 +35,5 @@ def fb(model, i): return (lb[i], ub[i]) -model.PriceToCharge = Var(model.A, domain=PositiveIntegers, bounds=fb) +model.PriceToCharge = pyo.Var(model.A, domain=pyo.PositiveIntegers, bounds=fb) # @Declare_bounds diff --git a/doc/OnlineDocs/src/scripting/spy4scripts.py b/doc/OnlineDocs/src/scripting/spy4scripts.py index f71a1b67b11..cd9b05ea43e 100644 --- a/doc/OnlineDocs/src/scripting/spy4scripts.py +++ b/doc/OnlineDocs/src/scripting/spy4scripts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -56,7 +56,7 @@ instance.y.fixed = True # @Equivalent_form_of_instance.x.fix(2) -model = ConcreteModel() +model = pyo.ConcreteModel() model.obj1 = pyo.Objective(expr=0) model.obj2 = pyo.Objective(expr=0) diff --git a/doc/OnlineDocs/src/strip_examples.py b/doc/OnlineDocs/src/strip_examples.py index 2fd03256499..8a8aa052e66 100644 --- a/doc/OnlineDocs/src/strip_examples.py +++ b/doc/OnlineDocs/src/strip_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/src/test_examples.py b/doc/OnlineDocs/src/test_examples.py index c5c9a135ee9..24aa7088048 100644 --- a/doc/OnlineDocs/src/test_examples.py +++ b/doc/OnlineDocs/src/test_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/doc/OnlineDocs/tutorial_examples.rst b/doc/OnlineDocs/tutorial_examples.rst deleted file mode 100644 index dc58b6a6f59..00000000000 --- a/doc/OnlineDocs/tutorial_examples.rst +++ /dev/null @@ -1,15 +0,0 @@ -Pyomo Tutorial Examples -======================= - -Additional Pyomo tutorials and examples can be found at the following links: - -`Pyomo Workshop Slides and Exercises -`_ - -`Prof. Jeffrey Kantor's Pyomo Cookbook -`_ - -`Pyomo Gallery -`_ - - diff --git a/examples/dae/Heat_Conduction.py b/examples/dae/Heat_Conduction.py index 7e11ec59263..78662ec27c1 100644 --- a/examples/dae/Heat_Conduction.py +++ b/examples/dae/Heat_Conduction.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,19 +11,19 @@ # Heat Conduction Model -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -m = ConcreteModel() +m = pyo.ConcreteModel() m.time = ContinuousSet(bounds=(0, 1)) m.x = ContinuousSet(bounds=(0, 10)) m.y = ContinuousSet(bounds=(0, 5)) -m.T = Var(m.x, m.y, m.time) -m.u = Var(m.x, m.y, m.time) -m.T0 = Param(initialize=5) -m.TD = Param(m.x, m.y, initialize=25) -m.Ux0 = Param(initialize=10) -m.Uy5 = Param(initialize=15) +m.T = pyo.Var(m.x, m.y, m.time) +m.u = pyo.Var(m.x, m.y, m.time) +m.T0 = pyo.Param(initialize=5) +m.TD = pyo.Param(m.x, m.y, initialize=25) +m.Ux0 = pyo.Param(initialize=10) +m.Uy5 = pyo.Param(initialize=15) m.dTdx = DerivativeVar(m.T, wrt=m.x) m.d2Tdx2 = DerivativeVar(m.T, wrt=(m.x, m.x)) @@ -36,28 +36,28 @@ def _heateq(m, i, j, k): return m.d2Tdx2[i, j, k] + m.d2Tdy2[i, j, k] + m.u[i, j, k] == m.dTdt[i, j, k] -m.heateq = Constraint(m.x, m.y, m.time, rule=_heateq) +m.heateq = pyo.Constraint(m.x, m.y, m.time, rule=_heateq) def _initT(m, i, j): return m.T[i, j, 0] == m.T0 -m.initT = Constraint(m.x, m.y, rule=_initT) +m.initT = pyo.Constraint(m.x, m.y, rule=_initT) def _xbound(m, j, k): return m.dTdx[0, j, k] == m.Ux0 -m.xbound = Constraint(m.y, m.time, rule=_xbound) +m.xbound = pyo.Constraint(m.y, m.time, rule=_xbound) def _ybound(m, i, k): return m.dTdy[i, 5, k] == m.Uy5 -m.ybound = Constraint(m.x, m.time, rule=_ybound) +m.ybound = pyo.Constraint(m.x, m.time, rule=_ybound) # def _intExp(m,i,j): # return m.T[i,j,1] - m.TD[i,j] @@ -66,4 +66,4 @@ def _ybound(m, i, k): # def _obj(m): # return Integral(Integral(expr=m.intExp,wrt=m.x,bounds=(0,10)), # wrt=m.y,bounds=(0,5)) -# m.obj = Objective(rule=_obj) +# m.obj = pyo.Objective(rule=_obj) diff --git a/examples/dae/Optimal_Control.py b/examples/dae/Optimal_Control.py index 676c95271f2..82eca417e29 100644 --- a/examples/dae/Optimal_Control.py +++ b/examples/dae/Optimal_Control.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,45 +16,45 @@ # X2_dot = X1^2 + u^2 X2(0) = 0 # tf = 1 -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -m = ConcreteModel() +m = pyo.ConcreteModel() m.t = ContinuousSet(bounds=(0, 1)) -m.x1 = Var(m.t, bounds=(0, 1)) -m.x2 = Var(m.t, bounds=(0, 1)) -m.u = Var(m.t, initialize=0) +m.x1 = pyo.Var(m.t, bounds=(0, 1)) +m.x2 = pyo.Var(m.t, bounds=(0, 1)) +m.u = pyo.Var(m.t, initialize=0) m.x1dot = DerivativeVar(m.x1) m.x2dot = DerivativeVar(m.x2) -m.obj = Objective(expr=m.x2[1]) +m.obj = pyo.Objective(expr=m.x2[1]) def _x1dot(M, i): if i == 0: - return Constraint.Skip + return pyo.Constraint.Skip return M.x1dot[i] == M.u[i] -m.x1dotcon = Constraint(m.t, rule=_x1dot) +m.x1dotcon = pyo.Constraint(m.t, rule=_x1dot) def _x2dot(M, i): if i == 0: - return Constraint.Skip + return pyo.Constraint.Skip return M.x2dot[i] == M.x1[i] ** 2 + M.u[i] ** 2 -m.x2dotcon = Constraint(m.t, rule=_x2dot) +m.x2dotcon = pyo.Constraint(m.t, rule=_x2dot) def _init(M): yield M.x1[0] == 1 yield M.x2[0] == 0 - yield ConstraintList.End + yield pyo.ConstraintList.End -m.init_conditions = ConstraintList(rule=_init) +m.init_conditions = pyo.ConstraintList(rule=_init) diff --git a/examples/dae/PDE_example.py b/examples/dae/PDE_example.py index 0aea173415b..dcafc52b05e 100644 --- a/examples/dae/PDE_example.py +++ b/examples/dae/PDE_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,14 +11,14 @@ # Example 1 from http://www.mathworks.com/help/matlab/ref/pdepe.html -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -m = ConcreteModel() -m.pi = Param(initialize=3.1416) +m = pyo.ConcreteModel() +m.pi = pyo.Param(initialize=3.1416) m.t = ContinuousSet(bounds=(0, 2)) m.x = ContinuousSet(bounds=(0, 1)) -m.u = Var(m.x, m.t) +m.u = pyo.Var(m.x, m.t) m.dudx = DerivativeVar(m.u, wrt=m.x) m.dudx2 = DerivativeVar(m.u, wrt=(m.x, m.x)) @@ -27,54 +27,54 @@ def _pde(m, i, j): if i == 0 or i == 1 or j == 0: - return Constraint.Skip + return pyo.Constraint.Skip return m.pi**2 * m.dudt[i, j] == m.dudx2[i, j] -m.pde = Constraint(m.x, m.t, rule=_pde) +m.pde = pyo.Constraint(m.x, m.t, rule=_pde) def _initcon(m, i): if i == 0 or i == 1: - return Constraint.Skip - return m.u[i, 0] == sin(m.pi * i) + return pyo.Constraint.Skip + return m.u[i, 0] == pyo.sin(m.pi * i) -m.initcon = Constraint(m.x, rule=_initcon) +m.initcon = pyo.Constraint(m.x, rule=_initcon) def _lowerbound(m, j): return m.u[0, j] == 0 -m.lowerbound = Constraint(m.t, rule=_lowerbound) +m.lowerbound = pyo.Constraint(m.t, rule=_lowerbound) def _upperbound(m, j): - return m.pi * exp(-j) + m.dudx[1, j] == 0 + return m.pi * pyo.exp(-j) + m.dudx[1, j] == 0 -m.upperbound = Constraint(m.t, rule=_upperbound) +m.upperbound = pyo.Constraint(m.t, rule=_upperbound) -m.obj = Objective(expr=1) +m.obj = pyo.Objective(expr=1) # Discretize using Orthogonal Collocation -# discretizer = TransformationFactory('dae.collocation') +# discretizer = pyo.TransformationFactory('dae.collocation') # discretizer.apply_to(m,nfe=10,ncp=3,wrt=m.x) # discretizer.apply_to(m,nfe=20,ncp=3,wrt=m.t) # Discretize using Finite Difference and Collocation -discretizer = TransformationFactory('dae.finite_difference') -discretizer2 = TransformationFactory('dae.collocation') +discretizer = pyo.TransformationFactory('dae.finite_difference') +discretizer2 = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(m, nfe=25, wrt=m.x, scheme='BACKWARD') discretizer2.apply_to(m, nfe=20, ncp=3, wrt=m.t) # Discretize using Finite Difference Method -# discretizer = TransformationFactory('dae.finite_difference') +# discretizer = pyo.TransformationFactory('dae.finite_difference') # discretizer.apply_to(m,nfe=25,wrt=m.x,scheme='BACKWARD') # discretizer.apply_to(m,nfe=20,wrt=m.t,scheme='BACKWARD') -solver = SolverFactory('ipopt') +solver = pyo.SolverFactory('ipopt') results = solver.solve(m, tee=True) x = [] @@ -86,7 +86,7 @@ def _upperbound(m, j): tempx = [] for j in sorted(m.t): tempx.append(i) - temp.append(value(m.u[i, j])) + temp.append(pyo.value(m.u[i, j])) x.append(tempx) t.append(sorted(m.t)) u.append(temp) diff --git a/examples/dae/Parameter_Estimation.py b/examples/dae/Parameter_Estimation.py index 332a21d93dc..8a244715f2c 100644 --- a/examples/dae/Parameter_Estimation.py +++ b/examples/dae/Parameter_Estimation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,19 +19,19 @@ # tf = 6 # -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -model = AbstractModel() +model = pyo.AbstractModel() model.t = ContinuousSet() -model.MEAS_t = Set(within=model.t) # Measurement times, must be subset of t -model.x1_meas = Param(model.MEAS_t) +model.MEAS_t = pyo.Set(within=model.t) # Measurement times, must be subset of t +model.x1_meas = pyo.Param(model.MEAS_t) -model.x1 = Var(model.t) -model.x2 = Var(model.t) +model.x1 = pyo.Var(model.t) +model.x2 = pyo.Var(model.t) -model.p1 = Var(bounds=(-1.5, 1.5)) -model.p2 = Var(bounds=(-1.5, 1.5)) +model.p1 = pyo.Var(bounds=(-1.5, 1.5)) +model.p2 = pyo.Var(bounds=(-1.5, 1.5)) model.x1dot = DerivativeVar(model.x1, wrt=model.t) model.x2dot = DerivativeVar(model.x2) @@ -42,34 +42,34 @@ def _init_conditions(model): yield model.x2[0] == model.p2 -model.init_conditions = ConstraintList(rule=_init_conditions) +model.init_conditions = pyo.ConstraintList(rule=_init_conditions) # Alternate way to declare initial conditions # def _initx1(model): # return model.x1[0] == model.p1 -# model.initx1 = Constraint(rule=_initx1) +# model.initx1 = pyo.Constraint(rule=_initx1) # def _initx2(model): # return model.x2[0] == model.p2 -# model.initx2 = Constraint(rule=_initx2) +# model.initx2 = pyo.Constraint(rule=_initx2) def _x1dot(model, i): return model.x1dot[i] == model.x2[i] -model.x1dotcon = Constraint(model.t, rule=_x1dot) +model.x1dotcon = pyo.Constraint(model.t, rule=_x1dot) def _x2dot(model, i): return model.x2dot[i] == 1 - 2 * model.x2[i] - model.x1[i] -model.x2dotcon = Constraint(model.t, rule=_x2dot) +model.x2dotcon = pyo.Constraint(model.t, rule=_x2dot) def _obj(model): return sum((model.x1[i] - model.x1_meas[i]) ** 2 for i in model.MEAS_t) -model.obj = Objective(rule=_obj) +model.obj = pyo.Objective(rule=_obj) diff --git a/examples/dae/Path_Constraint.py b/examples/dae/Path_Constraint.py index 69f31980c63..8c4adfffc27 100644 --- a/examples/dae/Path_Constraint.py +++ b/examples/dae/Path_Constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,59 +20,59 @@ # tf = 1 # -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -m = ConcreteModel() -m.tf = Param(initialize=1) +m = pyo.ConcreteModel() +m.tf = pyo.Param(initialize=1) m.t = ContinuousSet(bounds=(0, m.tf)) -m.u = Var(m.t, initialize=0) -m.x1 = Var(m.t) -m.x2 = Var(m.t) -m.x3 = Var(m.t) +m.u = pyo.Var(m.t, initialize=0) +m.x1 = pyo.Var(m.t) +m.x2 = pyo.Var(m.t) +m.x3 = pyo.Var(m.t) m.dx1 = DerivativeVar(m.x1, wrt=m.t) m.dx2 = DerivativeVar(m.x2, wrt=m.t) m.dx3 = DerivativeVar(m.x3, wrt=m.t) -m.obj = Objective(expr=m.x3[m.tf]) +m.obj = pyo.Objective(expr=m.x3[m.tf]) def _x1dot(m, t): if t == 0: - return Constraint.Skip + return pyo.Constraint.Skip return m.dx1[t] == m.x2[t] -m.x1dotcon = Constraint(m.t, rule=_x1dot) +m.x1dotcon = pyo.Constraint(m.t, rule=_x1dot) def _x2dot(m, t): if t == 0: - return Constraint.Skip + return pyo.Constraint.Skip return m.dx2[t] == -m.x2[t] + m.u[t] -m.x2dotcon = Constraint(m.t, rule=_x2dot) +m.x2dotcon = pyo.Constraint(m.t, rule=_x2dot) def _x3dot(m, t): if t == 0: - return Constraint.Skip + return pyo.Constraint.Skip return m.dx3[t] == m.x1[t] ** 2 + m.x2[t] ** 2 + 0.005 * m.u[t] ** 2 -m.x3dotcon = Constraint(m.t, rule=_x3dot) +m.x3dotcon = pyo.Constraint(m.t, rule=_x3dot) def _con(m, t): return m.x2[t] - 8 * (t - 0.5) ** 2 + 0.5 <= 0 -m.con = Constraint(m.t, rule=_con) +m.con = pyo.Constraint(m.t, rule=_con) def _init(m): @@ -81,4 +81,4 @@ def _init(m): yield m.x3[0] == 0 -m.init_conditions = ConstraintList(rule=_init) +m.init_conditions = pyo.ConstraintList(rule=_init) diff --git a/examples/dae/ReactionKinetics.py b/examples/dae/ReactionKinetics.py index fa747cf8b21..0786a19e2c8 100644 --- a/examples/dae/ReactionKinetics.py +++ b/examples/dae/ReactionKinetics.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,17 +15,17 @@ # reaction models based on "mass action kinetics". # -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar try: import matplotlib.pyplot as plt except ImportError: plt = None -fdiff = TransformationFactory('dae.finite_difference') -colloc = TransformationFactory('dae.collocation') -solver = SolverFactory('ipopt') +fdiff = pyo.TransformationFactory('dae.finite_difference') +colloc = pyo.TransformationFactory('dae.collocation') +solver = pyo.SolverFactory('ipopt') class Reaction(object): @@ -127,12 +127,12 @@ def create_kinetic_model(rxnNet, time): """Generate the kinetic model for the specified reaction network on the time interval of [0, max(time)]. If time is a list, all time points in the list will be used as finite element boundaries.""" - model = ConcreteModel() + model = pyo.ConcreteModel() model.rxnNetwork = rxnNet - model.SPECIES = Set(initialize=rxnNet.species()) - model.REACTIONS = Set(initialize=rxnNet.reactions.keys()) + model.SPECIES = pyo.Set(initialize=rxnNet.species()) + model.REACTIONS = pyo.Set(initialize=rxnNet.reactions.keys()) try: maxTime = max(time) times = time @@ -141,11 +141,11 @@ def create_kinetic_model(rxnNet, time): times = [time] model.TIME = ContinuousSet(bounds=(0, maxTime), initialize=times) - model.c = Var(model.TIME, model.SPECIES, bounds=(0, None)) + model.c = pyo.Var(model.TIME, model.SPECIES, bounds=(0, None)) model.dcdt = DerivativeVar(model.c, wrt=model.TIME) - model.k = Var(model.REACTIONS, bounds=(0, None)) - model.rate = Var(model.TIME, model.REACTIONS) + model.k = pyo.Var(model.REACTIONS, bounds=(0, None)) + model.rate = pyo.Var(model.TIME, model.REACTIONS) def reaction_rate(m, t, r): rhs = m.k[r] @@ -153,7 +153,9 @@ def reaction_rate(m, t, r): rhs *= m.c[t, s] ** coef return m.rate[t, r] == rhs - model.reaction_rate = Constraint(model.TIME, model.REACTIONS, rule=reaction_rate) + model.reaction_rate = pyo.Constraint( + model.TIME, model.REACTIONS, rule=reaction_rate + ) def stoichiometry(m, t, s): rhs = 0 @@ -164,7 +166,7 @@ def stoichiometry(m, t, s): rhs += m.rate[t, r] * m.rxnNetwork.reactions[r].products[s] return m.dcdt[t, s] == rhs - model.stoichiometry = Constraint(model.TIME, model.SPECIES, rule=stoichiometry) + model.stoichiometry = pyo.Constraint(model.TIME, model.SPECIES, rule=stoichiometry) return model @@ -193,8 +195,8 @@ def simple_simulation_model(): R = 8.314 # J / K*mol T = 330 # K - model.k['AtoB'].fix(A1 * exp(-Ea1 / (R * T))) - model.k['BtoC'].fix(A2 * exp(-Ea2 / (R * T))) + model.k['AtoB'].fix(A1 * pyo.exp(-Ea1 / (R * T))) + model.k['BtoC'].fix(A2 * pyo.exp(-Ea2 / (R * T))) model.c[0, 'A'].fix(1) model.c[0, 'B'].fix(0) @@ -209,7 +211,7 @@ def simple_simulation_model(): for _i, _x in enumerate('ABC'): plt.plot( [x[0][0] for x in _tmp if x[0][1] == _x], - [value(x[1]) for x in _tmp if x[0][1] == _x], + [pyo.value(x[1]) for x in _tmp if x[0][1] == _x], 'bgr'[_i] + '*', label=_x, ) @@ -240,13 +242,13 @@ def simple_optimization_model(): Ea1 = 140000 # J/mol Ea2 = 100000 # J/mol R = 8.314 # J / K*mol - model.T = Var(bounds=(0, None), initialize=330) # K + model.T = pyo.Var(bounds=(0, None), initialize=330) # K def compute_k(m): - yield m.k['AtoB'] == A1 * exp(-Ea1 / (R * m.T)) - yield m.k['BtoC'] == A2 * exp(-Ea2 / (R * m.T)) + yield m.k['AtoB'] == A1 * pyo.exp(-Ea1 / (R * m.T)) + yield m.k['BtoC'] == A2 * pyo.exp(-Ea2 / (R * m.T)) - model.compute_k = ConstraintList(rule=compute_k) + model.compute_k = pyo.ConstraintList(rule=compute_k) # initial conditions model.c[0, 'A'].fix(1) @@ -255,7 +257,7 @@ def compute_k(m): fdiff.apply_to(model, nfe=100) - model.obj = Objective(sense=maximize, expr=model.c[max(model.TIME), 'B']) + model.obj = pyo.Objective(sense=pyo.maximize, expr=model.c[max(model.TIME), 'B']) results = solver.solve(model, tee=True) @@ -263,7 +265,7 @@ def compute_k(m): for _i, _x in enumerate('ABC'): plt.plot( [x.index()[0] for x in model.c[:, _x]], - [value(x) for x in model.c[:, _x]], + [pyo.value(x) for x in model.c[:, _x]], 'bgr'[_i] + '*', label=_x, ) @@ -282,10 +284,10 @@ def create_regression_model(b, t): model = create_kinetic_model(rxns, data.keys()) - model.T = Param(initialize=t) - model.error = Var(bounds=(0, None)) + model.T = pyo.Param(initialize=t) + model.error = pyo.Var(bounds=(0, None)) - model.compute_error = Constraint( + model.compute_error = pyo.Constraint( expr=model.error == sum( ((model.c[t, key[i]] - x) / max(data[_t][i] for _t in data)) ** 2 @@ -304,9 +306,12 @@ def regression_model(): # Model & data from: # - # http://www.doiserbia.nb.rs/img/doi/0367-598X/2014/0367-598X1300037A.pdf + # Almagrbi, A. M., Hatami, T., Glišić, S., & Orlović, + # A. Determination of kinetic parameters for complex + # transesterification reaction by standard optimisation methods. + # Hem. Ind. 68(2) 149–159 (2014). doi:10.2298/HEMIND130118037A # - model = ConcreteModel() + model = pyo.ConcreteModel() model.key = key = ('MeOH', 'TG', 'DG', 'MG', 'FAME', 'Glycerol') model.data = data = { @@ -341,10 +346,10 @@ def regression_model(): }, } - model.experiment = Block(data.keys(), rule=create_regression_model) + model.experiment = pyo.Block(data.keys(), rule=create_regression_model) - model.obj = Objective( - sense=minimize, expr=sum(b.error for b in model.experiment[:]) + model.obj = pyo.Objective( + sense=pyo.minimize, expr=sum(b.error for b in model.experiment[:]) ) _experiments = list(model.experiment.values()) @@ -370,20 +375,20 @@ def regression_model(): # independent regression as the starting point) regress_Ea = True if regress_Ea: - model.Kset = Set(initialize=['k_1', 'k_2', 'k_3']) - model.Ea = Var(model.Kset, bounds=(0, None), initialize=0) - model.A = Var(model.Kset, bounds=(0, None), initialize=0) - model.R = Param(initialize=8.314) + model.Kset = pyo.Set(initialize=['k_1', 'k_2', 'k_3']) + model.Ea = pyo.Var(model.Kset, bounds=(0, None), initialize=0) + model.A = pyo.Var(model.Kset, bounds=(0, None), initialize=0) + model.R = pyo.Param(initialize=8.314) for _e in _experiments: _e.k.fix() def compute_k(e, _k): m = e.model() # k11' == k_mt + k_11 * (C_DG + C_MG) / C_TG_0 - # return e.k[_k] == m.A[_k] * exp( -m.Ea[_k] / ( m.R * e.T ) ) - return log(e.k[_k]) == log(m.A[_k]) - m.Ea[_k] / (m.R * e.T) + # return e.k[_k] == m.A[_k] * pyo.exp( -m.Ea[_k] / ( m.R * e.T ) ) + return pyo.log(e.k[_k]) == pyo.log(m.A[_k]) - m.Ea[_k] / (m.R * e.T) - _e.compute_k = Constraint(model.Kset, rule=compute_k) + _e.compute_k = pyo.Constraint(model.Kset, rule=compute_k) solver.solve(model, tee=True) for _e in _experiments: @@ -408,7 +413,7 @@ def compute_k(e, _k): _ax = ax2 if _x == 'MeOH' else ax _ax.plot( [x.index()[0] for x in model.experiment[T].c[:, _x]], - [value(x) for x in model.experiment[T].c[:, _x]], + [pyo.value(x) for x in model.experiment[T].c[:, _x]], 'mkrgbc'[_i] + '-', ) plt.show() diff --git a/examples/dae/car_example.py b/examples/dae/car_example.py index b6ca2203860..a517ff432b6 100644 --- a/examples/dae/car_example.py +++ b/examples/dae/car_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,53 +22,53 @@ # v(0)=0; v(tf)=0 # -3<=a<=1 -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -m = ConcreteModel() +m = pyo.ConcreteModel() -m.R = Param(initialize=0.001) # Friction factor -m.L = Param(initialize=100.0) # Final position +m.R = pyo.Param(initialize=0.001) # Friction factor +m.L = pyo.Param(initialize=100.0) # Final position m.tau = ContinuousSet(bounds=(0, 1)) # Unscaled time -m.time = Var(m.tau) # Scaled time -m.tf = Var() -m.x = Var(m.tau, bounds=(0, m.L + 50)) -m.v = Var(m.tau, bounds=(0, None)) -m.a = Var(m.tau, bounds=(-3.0, 1.0), initialize=0) +m.time = pyo.Var(m.tau) # Scaled time +m.tf = pyo.Var() +m.x = pyo.Var(m.tau, bounds=(0, m.L + 50)) +m.v = pyo.Var(m.tau, bounds=(0, None)) +m.a = pyo.Var(m.tau, bounds=(-3.0, 1.0), initialize=0) m.dtime = DerivativeVar(m.time) m.dx = DerivativeVar(m.x) m.dv = DerivativeVar(m.v) -m.obj = Objective(expr=m.tf) +m.obj = pyo.Objective(expr=m.tf) def _ode1(m, i): if i == 0: - return Constraint.Skip + return pyo.Constraint.Skip return m.dx[i] == m.tf * m.v[i] -m.ode1 = Constraint(m.tau, rule=_ode1) +m.ode1 = pyo.Constraint(m.tau, rule=_ode1) def _ode2(m, i): if i == 0: - return Constraint.Skip + return pyo.Constraint.Skip return m.dv[i] == m.tf * (m.a[i] - m.R * m.v[i] ** 2) -m.ode2 = Constraint(m.tau, rule=_ode2) +m.ode2 = pyo.Constraint(m.tau, rule=_ode2) def _ode3(m, i): if i == 0: - return Constraint.Skip + return pyo.Constraint.Skip return m.dtime[i] == m.tf -m.ode3 = Constraint(m.tau, rule=_ode3) +m.ode3 = pyo.Constraint(m.tau, rule=_ode3) def _init(m): @@ -79,15 +79,15 @@ def _init(m): yield m.time[0] == 0 -m.initcon = ConstraintList(rule=_init) +m.initcon = pyo.ConstraintList(rule=_init) -discretizer = TransformationFactory('dae.finite_difference') +discretizer = pyo.TransformationFactory('dae.finite_difference') discretizer.apply_to(m, nfe=15, scheme='BACKWARD') -solver = SolverFactory('ipopt') +solver = pyo.SolverFactory('ipopt') solver.solve(m, tee=True) -print("final time = %6.2f" % (value(m.tf))) +print("final time = %6.2f" % (pyo.value(m.tf))) x = [] v = [] @@ -95,10 +95,10 @@ def _init(m): time = [] for i in m.tau: - time.append(value(m.time[i])) - x.append(value(m.x[i])) - v.append(value(m.v[i])) - a.append(value(m.a[i])) + time.append(pyo.value(m.time[i])) + x.append(pyo.value(m.x[i])) + v.append(pyo.value(m.v[i])) + a.append(pyo.value(m.a[i])) import matplotlib.pyplot as plt diff --git a/examples/dae/disease_DAE.py b/examples/dae/disease_DAE.py index bfeb2530fc9..399a0de2c7e 100644 --- a/examples/dae/disease_DAE.py +++ b/examples/dae/disease_DAE.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,8 +14,8 @@ ### # Scaling in Continuous Set -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar years = 20 beta_py = 26 @@ -23,23 +23,23 @@ fepy = beta_py * fepr fe = fepy * years step = 365.0 / fepy -model = AbstractModel() +model = pyo.AbstractModel() # Define unindexed parameters -model.P_GAMMA = Param(default=1.0 / 14.0) -model.P_NUM_BETA = Param(default=beta_py) -model.P_FEPY = Param(default=fepy) -model.P_FE = Param(default=fe) -model.P_STEP = Param(default=step) -model.P_TRI = Param(default=beta_py * years) -model.P_FEPR = Param(default=fepr) +model.P_GAMMA = pyo.Param(default=1.0 / 14.0) +model.P_NUM_BETA = pyo.Param(default=beta_py) +model.P_FEPY = pyo.Param(default=fepy) +model.P_FE = pyo.Param(default=fe) +model.P_STEP = pyo.Param(default=step) +model.P_TRI = pyo.Param(default=beta_py * years) +model.P_FEPR = pyo.Param(default=fepr) -model.I_OBJ_WT = Param(default=0.995) -model.PHI_OBJ_WT = Param(default=0.005) +model.I_OBJ_WT = pyo.Param(default=0.995) +model.PHI_OBJ_WT = pyo.Param(default=0.005) # Define sets -model.S_BETA = RangeSet(1, model.P_NUM_BETA) -model.S_FE = RangeSet(1, model.P_FE) +model.S_BETA = pyo.RangeSet(1, model.P_NUM_BETA) +model.S_FE = pyo.RangeSet(1, model.P_FE) def _TIME_init(model): @@ -48,7 +48,7 @@ def _TIME_init(model): model.TIME = ContinuousSet(initialize=_TIME_init, bounds=(0, None)) -model.S_TRI = RangeSet(1, model.P_TRI) +model.S_TRI = pyo.RangeSet(1, model.P_TRI) # Define indexed parameters beta_ndx = {} @@ -58,35 +58,35 @@ def _TIME_init(model): else: for i in range(1, fe + 1): beta_ndx[i] = ((i - 1) % beta_py) + 1 -model.P_BETA_NDX = Param(model.S_FE, initialize=beta_ndx, default=1.0) +model.P_BETA_NDX = pyo.Param(model.S_FE, initialize=beta_ndx, default=1.0) -model.P_POP = Param(default=1.0e6) -model.P_REP_FRAC = Param(model.S_TRI, default=0.4) -model.P_REP_CASES = Param(model.S_TRI, default=10.0) -model.P_BIRTHS = Param(model.S_FE, default=100.0) -model.P_DATA_WTS = Param(model.S_TRI, default=1.0) -model.P_ALL_CASES = Param(model.S_TRI, default=10.0) +model.P_POP = pyo.Param(default=1.0e6) +model.P_REP_FRAC = pyo.Param(model.S_TRI, default=0.4) +model.P_REP_CASES = pyo.Param(model.S_TRI, default=10.0) +model.P_BIRTHS = pyo.Param(model.S_FE, default=100.0) +model.P_DATA_WTS = pyo.Param(model.S_TRI, default=1.0) +model.P_ALL_CASES = pyo.Param(model.S_TRI, default=10.0) # Define initialization parameters and rules -model.init_S_bar = Param(default=1.0e5) -model.init_beta_bar = Param(default=1.0) -model.init_I_init = Param(default=10.0) -model.init_S_init = Param(default=1000.0) -model.init_beta = Param(model.S_BETA, default=1.0) -model.init_beta_pos = Param(model.S_BETA, default=0.0) -model.init_beta_neg = Param(model.S_BETA, default=0.0) -model.init_eps_I = Param(model.S_FE, default=0.0) -model.init_eps_phi = Param(model.S_TRI, default=0.0) - -model.init_S = Param(model.S_FE, default=100.0) -model.init_I = Param(model.S_FE, default=10.0) -model.init_phi = Param(model.S_FE, default=0.0) -model.init_Sdot = Param(model.S_FE, default=1.0) -model.init_Idot = Param(model.S_FE, default=1.0) -model.init_phidot = Param(model.S_FE, default=1.0) - -model.init_beta_patt = Param(model.S_BETA, default=0.0) -model.init_beta_int = Param(default=1.0) +model.init_S_bar = pyo.Param(default=1.0e5) +model.init_beta_bar = pyo.Param(default=1.0) +model.init_I_init = pyo.Param(default=10.0) +model.init_S_init = pyo.Param(default=1000.0) +model.init_beta = pyo.Param(model.S_BETA, default=1.0) +model.init_beta_pos = pyo.Param(model.S_BETA, default=0.0) +model.init_beta_neg = pyo.Param(model.S_BETA, default=0.0) +model.init_eps_I = pyo.Param(model.S_FE, default=0.0) +model.init_eps_phi = pyo.Param(model.S_TRI, default=0.0) + +model.init_S = pyo.Param(model.S_FE, default=100.0) +model.init_I = pyo.Param(model.S_FE, default=10.0) +model.init_phi = pyo.Param(model.S_FE, default=0.0) +model.init_Sdot = pyo.Param(model.S_FE, default=1.0) +model.init_Idot = pyo.Param(model.S_FE, default=1.0) +model.init_phidot = pyo.Param(model.S_FE, default=1.0) + +model.init_beta_patt = pyo.Param(model.S_BETA, default=0.0) +model.init_beta_int = pyo.Param(default=1.0) def _init_S_bar(model): @@ -190,28 +190,28 @@ def _init_people_bounds(model): # Define unindexed variables -model.S_bar = Var(initialize=_init_S_bar, bounds=(0, None)) -model.beta_bar = Var(initialize=_init_beta_bar, bounds=(0.05, 5)) -model.I_init = Var(initialize=_init_I_init, bounds=_init_people_bounds) -model.S_init = Var(initialize=_init_S_init, bounds=_init_people_bounds) -model.phi_init = Param(default=0.0) +model.S_bar = pyo.Var(initialize=_init_S_bar, bounds=(0, None)) +model.beta_bar = pyo.Var(initialize=_init_beta_bar, bounds=(0.05, 5)) +model.I_init = pyo.Var(initialize=_init_I_init, bounds=_init_people_bounds) +model.S_init = pyo.Var(initialize=_init_S_init, bounds=_init_people_bounds) +model.phi_init = pyo.Param(default=0.0) # Define indexed variables -model.beta = Var(model.S_BETA, initialize=_init_beta, bounds=(0.01, 5)) +model.beta = pyo.Var(model.S_BETA, initialize=_init_beta, bounds=(0.01, 5)) -model.beta_pos = Var(model.S_BETA, initialize=_init_beta_pos, bounds=(0, None)) -model.beta_neg = Var(model.S_BETA, initialize=_init_beta_neg, bounds=(0, None)) -model.beta_patt = Var(model.S_BETA, initialize=_init_beta_patt, bounds=(-5, 5)) -model.beta_int = Var(initialize=_init_beta_int, bounds=(0.01, 5.0)) -model.beta_c = Var(initialize=1.0) -model.alpha = Var(initialize=0.05, bounds=(-1.0, 1.0)) +model.beta_pos = pyo.Var(model.S_BETA, initialize=_init_beta_pos, bounds=(0, None)) +model.beta_neg = pyo.Var(model.S_BETA, initialize=_init_beta_neg, bounds=(0, None)) +model.beta_patt = pyo.Var(model.S_BETA, initialize=_init_beta_patt, bounds=(-5, 5)) +model.beta_int = pyo.Var(initialize=_init_beta_int, bounds=(0.01, 5.0)) +model.beta_c = pyo.Var(initialize=1.0) +model.alpha = pyo.Var(initialize=0.05, bounds=(-1.0, 1.0)) -model.eps_I = Var(model.S_FE, initialize=_init_eps_I) -model.eps_phi = Var(model.S_TRI, initialize=_init_eps_phi) +model.eps_I = pyo.Var(model.S_FE, initialize=_init_eps_I) +model.eps_phi = pyo.Var(model.S_TRI, initialize=_init_eps_phi) -model.S = Var(model.TIME, initialize=_init_S, bounds=_people_bounds) -model.I = Var(model.TIME, initialize=_init_I, bounds=_people_bounds) -model.phi = Var(model.TIME, initialize=_init_phi, bounds=(0, None)) +model.S = pyo.Var(model.TIME, initialize=_init_S, bounds=_people_bounds) +model.I = pyo.Var(model.TIME, initialize=_init_I, bounds=_people_bounds) +model.phi = pyo.Var(model.TIME, initialize=_init_phi, bounds=(0, None)) model.Sdot = DerivativeVar(model.S, initialize=_init_Sdot) model.Idot = DerivativeVar(model.I, initialize=_init_Idot) model.phidot = DerivativeVar(model.phi, initialize=_init_phidot, bounds=(-10, None)) @@ -225,7 +225,7 @@ def _obj_rule(model): ) -model.obj = Objective(rule=_obj_rule) +model.obj = pyo.Objective(rule=_obj_rule) ######################## @@ -237,7 +237,7 @@ def _init_conditions(model): yield model.phi[0] == model.phi_init -model.init_conditions = ConstraintList(rule=_init_conditions) +model.init_conditions = pyo.ConstraintList(rule=_init_conditions) def _reported_cases(model, i): @@ -250,7 +250,7 @@ def _reported_cases(model, i): + model.eps_phi[i] ) else: - return Constraint.Skip + return pyo.Constraint.Skip else: if model.P_DATA_WTS[i] > 0.1: return ( @@ -263,10 +263,10 @@ def _reported_cases(model, i): + model.eps_phi[i] ) else: - return Constraint.Skip + return pyo.Constraint.Skip -model.con_reported_cases = Constraint(model.S_TRI, rule=_reported_cases) +model.con_reported_cases = pyo.Constraint(model.S_TRI, rule=_reported_cases) def _beta_bar(model): @@ -276,12 +276,12 @@ def _beta_bar(model): ) -model.con_beta_bar = Constraint(rule=_beta_bar) +model.con_beta_bar = pyo.Constraint(rule=_beta_bar) def _phidot_eq(model, i): if i == 0: - return Constraint.Skip + return pyo.Constraint.Skip fe = model.TIME.get_upper_element_boundary(i) j = model.TIME._fe.index(fe) return ( @@ -291,45 +291,45 @@ def _phidot_eq(model, i): ) -model.phidot_eq = Constraint(model.TIME, rule=_phidot_eq) +model.phidot_eq = pyo.Constraint(model.TIME, rule=_phidot_eq) def _Idot_eq(model, i): if i == 0: - return Constraint.Skip + return pyo.Constraint.Skip return model.Idot[i] == model.phidot[i] - model.P_GAMMA * model.I[i] -model.Idot_eq = Constraint(model.TIME, rule=_Idot_eq) +model.Idot_eq = pyo.Constraint(model.TIME, rule=_Idot_eq) def _Sdot_eq(model, i): if i == 0: - return Constraint.Skip + return pyo.Constraint.Skip fe = model.TIME.get_upper_element_boundary(i) j = model.TIME._fe.index(fe) return model.Sdot[i] == -model.phidot[i] + model.P_BIRTHS[j] -model.Sdot_eq = Constraint(model.TIME, rule=_Sdot_eq) +model.Sdot_eq = pyo.Constraint(model.TIME, rule=_Sdot_eq) def _scaled_beta(model, i): return (model.beta[i], model.beta_c * model.beta_patt[i]) -model.con_city_varying_beta = Constraint(model.S_BETA, rule=_scaled_beta) +model.con_city_varying_beta = pyo.Constraint(model.S_BETA, rule=_scaled_beta) def _mean_patt(model): - return (1.0, sum_product(model.beta_patt) / len(model.S_BETA)) + return (1.0, pyo.sum_product(model.beta_patt) / len(model.S_BETA)) -model.con_mean_patt = Constraint(rule=_mean_patt) +model.con_mean_patt = pyo.Constraint(rule=_mean_patt) def _beta_c(model): return (0.75, model.beta_c, 1.5) -model.con_beta_c = Constraint(rule=_beta_c) +model.con_beta_c = pyo.Constraint(rule=_beta_c) diff --git a/examples/dae/distill_DAE.py b/examples/dae/distill_DAE.py index e822cfb1752..640e96abc08 100644 --- a/examples/dae/distill_DAE.py +++ b/examples/dae/distill_DAE.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,23 +9,23 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -model = AbstractModel() +model = pyo.AbstractModel() -model.Feed = Param(initialize=24.0 / 60.0) -model.x_Feed = Param(initialize=0.5) -model.D = Param(initialize=model.x_Feed * model.Feed) -model.vol = Param(initialize=1.6) -model.atray = Param(initialize=0.25) -model.acond = Param(initialize=0.5) -model.areb = Param(initialize=1.0) +model.Feed = pyo.Param(initialize=24.0 / 60.0) +model.x_Feed = pyo.Param(initialize=0.5) +model.D = pyo.Param(initialize=model.x_Feed * model.Feed) +model.vol = pyo.Param(initialize=1.6) +model.atray = pyo.Param(initialize=0.25) +model.acond = pyo.Param(initialize=0.5) +model.areb = pyo.Param(initialize=1.0) -model.S_TRAYS = Set(dimen=1) -model.S_RECTIFICATION = Set(within=model.S_TRAYS) -model.S_STRIPPING = Set(within=model.S_TRAYS) -model.x0 = Param(model.S_TRAYS) +model.S_TRAYS = pyo.Set(dimen=1) +model.S_RECTIFICATION = pyo.Set(within=model.S_TRAYS) +model.S_STRIPPING = pyo.Set(within=model.S_TRAYS) +model.x0 = pyo.Param(model.S_TRAYS) model.t = ContinuousSet(initialize=range(1, 52)) # Alternatively you could simply specify bounds on the @@ -33,26 +33,26 @@ # automatically. # model.t = ContinuousSet(bounds=(1,51)) -model.y = Var(model.S_TRAYS, model.t) +model.y = pyo.Var(model.S_TRAYS, model.t) def x_init_rule(m, n, ti): - return value(m.x0[n]) + return pyo.value(m.x0[n]) -model.x = Var(model.S_TRAYS, model.t, initialize=x_init_rule) +model.x = pyo.Var(model.S_TRAYS, model.t, initialize=x_init_rule) model.dx = DerivativeVar(model.x) -model.rr = Var(model.t, initialize=3.0) -model.L = Var(model.t, initialize=0.6) -model.V = Var(model.t, initialize=0.8) -model.FL = Var(model.t, initialize=1) -model.u1 = Var(model.t, initialize=3.0, bounds=(1, 5)) +model.rr = pyo.Var(model.t, initialize=3.0) +model.L = pyo.Var(model.t, initialize=0.6) +model.V = pyo.Var(model.t, initialize=0.8) +model.FL = pyo.Var(model.t, initialize=1) +model.u1 = pyo.Var(model.t, initialize=3.0, bounds=(1, 5)) -model.alpha = Param(initialize=1000) -model.rho = Param(initialize=1) -model.u1_ref = Param(initialize=2.0) -model.y1_ref = Param(initialize=0.895814) +model.alpha = pyo.Param(initialize=1000) +model.rho = pyo.Param(initialize=1) +model.u1_ref = pyo.Param(initialize=2.0) +model.y1_ref = pyo.Param(initialize=0.895814) ### @@ -62,42 +62,42 @@ def reflux_ratio_rule(m, t): return m.rr[t] == m.u1[t] -model.reflux_ratio = Constraint(model.t, rule=reflux_ratio_rule) +model.reflux_ratio = pyo.Constraint(model.t, rule=reflux_ratio_rule) def flowrate_rectificaiton_rule(m, t): return m.L[t] == m.rr[t] * m.D -model.flowrate_rectificaiton = Constraint(model.t, rule=flowrate_rectificaiton_rule) +model.flowrate_rectificaiton = pyo.Constraint(model.t, rule=flowrate_rectificaiton_rule) def vapor_column_rule(m, t): return m.V[t] == m.L[t] + m.D -model.vapor_column = Constraint(model.t, rule=vapor_column_rule) +model.vapor_column = pyo.Constraint(model.t, rule=vapor_column_rule) def flowrate_stripping_rule(m, t): return m.FL[t] == m.Feed + m.L[t] -model.flowrate_stripping = Constraint(model.t, rule=flowrate_stripping_rule) +model.flowrate_stripping = pyo.Constraint(model.t, rule=flowrate_stripping_rule) def mole_frac_balance_rule(m, n, t): return m.y[n, t] == m.x[n, t] * m.vol / (1 + ((m.vol - 1) * m.x[n, t])) -model.mole_frac_balance = Constraint( +model.mole_frac_balance = pyo.Constraint( model.S_TRAYS, model.t, rule=mole_frac_balance_rule ) def _diffeq(m, n, t): if t == 1: - return Constraint.Skip + return pyo.Constraint.Skip if n == 1: return m.dx[n, t] == 1 / m.acond * m.V[t] * (m.y[n + 1, t] - m.x[n, t]) elif n in m.S_RECTIFICATION: @@ -121,11 +121,11 @@ def _diffeq(m, n, t): ) -model.diffeq = Constraint(model.S_TRAYS, model.t, rule=_diffeq) +model.diffeq = pyo.Constraint(model.S_TRAYS, model.t, rule=_diffeq) def _init_rule(m, n): return m.x[n, 1] == m.x0[n] -model.init_rule = Constraint(model.S_TRAYS, rule=_init_rule) +model.init_rule = pyo.Constraint(model.S_TRAYS, rule=_init_rule) diff --git a/examples/dae/dynamic_scheduling.py b/examples/dae/dynamic_scheduling.py index 137307e31a9..0e2b5563468 100644 --- a/examples/dae/dynamic_scheduling.py +++ b/examples/dae/dynamic_scheduling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,28 +14,28 @@ # packages and includes modeling concepts from the DAE car example and # the GDP jobshop example. -from pyomo.environ import * -from pyomo.dae import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar +from pyomo.gdp import Disjunct, Disjunction -m = ConcreteModel() +m = pyo.ConcreteModel() -m.products = Set(initialize=['A', 'B']) -m.AthenB = Set(initialize=[0, 1]) +m.products = pyo.Set(initialize=['A', 'B']) +m.AthenB = pyo.Set(initialize=[0, 1]) m.tau = ContinuousSet(bounds=[0, 1]) # Unscaled Time -m.k = Param(m.products, initialize={'A': 1, 'B': 5}) # Reaction Rates +m.k = pyo.Param(m.products, initialize={'A': 1, 'B': 5}) # Reaction Rates # Cost of having 'A' or 'B' in the final product stream -m.cost = Param(m.products, initialize={'A': 15000, 'B': 20000}) +m.cost = pyo.Param(m.products, initialize={'A': 15000, 'B': 20000}) -m.tstart = Var(m.products, bounds=(0, None)) # Start Time -m.tproc = Var(m.products, bounds=(0, None)) # Processing Time -m.time = Var(m.products, m.tau, bounds=(0, None)) # Scaled time over each job -m.totaltime = Var() # Total job time +m.tstart = pyo.Var(m.products, bounds=(0, None)) # Start Time +m.tproc = pyo.Var(m.products, bounds=(0, None)) # Processing Time +m.time = pyo.Var(m.products, m.tau, bounds=(0, None)) # Scaled time over each job +m.totaltime = pyo.Var() # Total job time -m.c = Var(m.products, m.tau, bounds=(0, None)) +m.c = pyo.Var(m.products, m.tau, bounds=(0, None)) m.dc = DerivativeVar(m.c, wrt=m.tau) m.dtime = DerivativeVar(m.time, wrt=m.tau) @@ -49,7 +49,7 @@ def _diffeq(m, p, t): return m.dc[p, t] == -m.tproc[p] * m.k[p] * m.c[p, t] -m.diffeq = Constraint(m.products, m.tau, rule=_diffeq) +m.diffeq = pyo.Constraint(m.products, m.tau, rule=_diffeq) # Initial time m.time['A', 0].fix(0) @@ -61,7 +61,7 @@ def _finalc(m, p): return m.c[p, 1] <= 0.001 -m.finalc = Constraint(m.products, rule=_finalc) +m.finalc = pyo.Constraint(m.products, rule=_finalc) # Scaled time @@ -69,7 +69,7 @@ def _diffeqtime(m, p, t): return m.dtime[p, t] == m.tproc[p] -m.diffeqtime = Constraint(m.products, m.tau, rule=_diffeqtime) +m.diffeqtime = pyo.Constraint(m.products, m.tau, rule=_diffeqtime) # No clash disjuncts @@ -77,10 +77,10 @@ def _noclash(disjunct, AthenB): model = disjunct.model() if AthenB: e = model.tstart['A'] + model.tproc['A'] <= model.tstart['B'] - disjunct.c = Constraint(expr=e) + disjunct.c = pyo.Constraint(expr=e) else: e = model.tstart['B'] + model.tproc['B'] <= model.tstart['A'] - disjunct.c = Constraint(expr=e) + disjunct.c = pyo.Constraint(expr=e) m.noclash = Disjunct(m.AthenB, rule=_noclash) @@ -99,7 +99,7 @@ def _duetime(m): return m.tstart['B'] + m.tproc['B'] <= 2.0 -m.duetime = Constraint(rule=_duetime) +m.duetime = pyo.Constraint(rule=_duetime) # Feasibility @@ -107,7 +107,7 @@ def _feas(m, p): return m.totaltime >= m.tstart[p] + m.tproc[p] -m.feas = Constraint(m.products, rule=_feas) +m.feas = pyo.Constraint(m.products, rule=_feas) # Objective @@ -115,28 +115,28 @@ def _obj(m): return m.totaltime + sum(m.cost[p] * m.c[p, 1] for p in m.products) -m.obj = Objective(rule=_obj) +m.obj = pyo.Objective(rule=_obj) # Discretize model -discretizer = TransformationFactory('dae.collocation') +discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(m, nfe=5, ncp=3) # Reformulate Disjuncts -gdp_relax = TransformationFactory('gdp.bigm') +gdp_relax = pyo.TransformationFactory('gdp.bigm') gdp_relax.apply_to(m, default_bigM=50.0) # Solve the model -solver = SolverFactory('couenne') +solver = pyo.SolverFactory('couenne') solver.solve(m, tee=True) # Plot the results import matplotlib.pyplot as plt -timeA = [value(m.time['A', i]) + value(m.tstart['A']) for i in m.tau] -timeB = [value(m.time['B', i]) + value(m.tstart['B']) for i in m.tau] +timeA = [pyo.value(m.time['A', i]) + pyo.value(m.tstart['A']) for i in m.tau] +timeB = [pyo.value(m.time['B', i]) + pyo.value(m.tstart['B']) for i in m.tau] -concA = [value(m.c['A', i]) for i in m.tau] -concB = [value(m.c['B', i]) for i in m.tau] +concA = [pyo.value(m.c['A', i]) for i in m.tau] +concB = [pyo.value(m.c['B', i]) for i in m.tau] plt.plot(timeA, concA, 'r', label='Reactant A') plt.plot(timeB, concB, 'b', label='Reactant B') diff --git a/examples/dae/laplace_BVP.py b/examples/dae/laplace_BVP.py index 61f911b3826..c14cdc19edf 100644 --- a/examples/dae/laplace_BVP.py +++ b/examples/dae/laplace_BVP.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -m = ConcreteModel() +m = pyo.ConcreteModel() m.x = ContinuousSet(bounds=(0, 1)) m.y = ContinuousSet(bounds=(0, 1)) -m.u = Var(m.x, m.y) +m.u = pyo.Var(m.x, m.y) m.dudx = DerivativeVar(m.u, wrt=(m.x, m.x)) m.dudy = DerivativeVar(m.u, wrt=(m.y, m.y)) @@ -23,63 +23,63 @@ def _lowerY(m, i): if i == 0 or i == 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.u[i, 0] == 1 -m.lowerY = Constraint(m.x, rule=_lowerY) +m.lowerY = pyo.Constraint(m.x, rule=_lowerY) def _upperY(m, i): if i == 0 or i == 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.u[i, 1] == 2 -m.upperY = Constraint(m.x, rule=_upperY) +m.upperY = pyo.Constraint(m.x, rule=_upperY) def _lowerX(m, j): if j == 0 or j == 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.u[0, j] == 1 -m.lowerX = Constraint(m.y, rule=_lowerX) +m.lowerX = pyo.Constraint(m.y, rule=_lowerX) def _upperX(m, j): if j == 0 or j == 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.u[1, j] == 2 -m.upperX = Constraint(m.y, rule=_upperX) +m.upperX = pyo.Constraint(m.y, rule=_upperX) def _laplace(m, i, j): if i == 0 or i == 1: - return Constraint.Skip + return pyo.Constraint.Skip if j == 0 or j == 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.dudx[i, j] + m.dudy[i, j] == 0 -m.laplace = Constraint(m.x, m.y, rule=_laplace) +m.laplace = pyo.Constraint(m.x, m.y, rule=_laplace) def _dummy(m): return 1.0 -m.obj = Objective(rule=_dummy) +m.obj = pyo.Objective(rule=_dummy) -discretizer = TransformationFactory('dae.finite_difference') +discretizer = pyo.TransformationFactory('dae.finite_difference') discretizer.apply_to(m, nfe=20, wrt=m.y, scheme='FORWARD') discretizer.apply_to(m, nfe=20, wrt=m.x, scheme='CENTRAL') -solver = SolverFactory('ipopt') +solver = pyo.SolverFactory('ipopt') results = solver.solve(m, tee=True) @@ -94,7 +94,7 @@ def _dummy(m): tempx = [] for j in sorted(m.y): tempx.append(i) - temp.append(value(m.u[i, j])) + temp.append(pyo.value(m.u[i, j])) x.append(tempx) y.append(sorted(m.y)) u.append(temp) diff --git a/examples/dae/run_Optimal_Control.py b/examples/dae/run_Optimal_Control.py index 2e7bc79dff4..2bf85c00253 100644 --- a/examples/dae/run_Optimal_Control.py +++ b/examples/dae/run_Optimal_Control.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,20 +9,20 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from Optimal_Control import m # Discretize model using Backward Finite Difference method -# discretizer = TransformationFactory('dae.finite_difference') +# discretizer = pyo.TransformationFactory('dae.finite_difference') # discretizer.apply_to(m,nfe=20,scheme='BACKWARD') # Discretize model using Orthogonal Collocation -discretizer = TransformationFactory('dae.collocation') +discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(m, nfe=20, ncp=3, scheme='LAGRANGE-RADAU') discretizer.reduce_collocation_points(m, var=m.u, ncp=1, contset=m.t) -solver = SolverFactory('ipopt') +solver = pyo.SolverFactory('ipopt') results = solver.solve(m, tee=True) @@ -35,9 +35,9 @@ for i in sorted(m.t): t.append(i) - x1.append(value(m.x1[i])) - x2.append(value(m.x2[i])) - u.append(value(m.u[i])) + x1.append(pyo.value(m.x1[i])) + x2.append(pyo.value(m.x2[i])) + u.append(pyo.value(m.u[i])) import matplotlib.pyplot as plt diff --git a/examples/dae/run_Parameter_Estimation.py b/examples/dae/run_Parameter_Estimation.py index c9b649df8dd..1024cca73ca 100644 --- a/examples/dae/run_Parameter_Estimation.py +++ b/examples/dae/run_Parameter_Estimation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,22 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from Parameter_Estimation import model instance = model.create_instance('data_set2.dat') instance.t.pprint() # Discretize model using Backward Finite Difference method -# discretizer = TransformationFactory('dae.finite_difference') +# discretizer = pyo.TransformationFactory('dae.finite_difference') # discretizer.apply_to(instance,nfe=20,scheme='BACKWARD') # Discretize model using Orthogonal Collocation -discretizer = TransformationFactory('dae.collocation') +discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(instance, nfe=8, ncp=5) -solver = SolverFactory('ipopt') +solver = pyo.SolverFactory('ipopt') results = solver.solve(instance, tee=True) @@ -37,11 +37,11 @@ for i in sorted(instance.MEAS_t): t_meas.append(i) - x1_meas.append(value(instance.x1_meas[i])) + x1_meas.append(pyo.value(instance.x1_meas[i])) for i in sorted(instance.t): t.append(i) - x1.append(value(instance.x1[i])) + x1.append(pyo.value(instance.x1[i])) import matplotlib.pyplot as plt diff --git a/examples/dae/run_Path_Constraint.py b/examples/dae/run_Path_Constraint.py index 996b432a555..ddf1a98c240 100644 --- a/examples/dae/run_Path_Constraint.py +++ b/examples/dae/run_Path_Constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,27 +9,29 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from Path_Constraint import m # Discretize model using Finite Difference Method -# discretizer = TransformationFactory('dae.finite_difference') +# discretizer = pyo.TransformationFactory('dae.finite_difference') # discretizer.apply_to(m,nfe=20,scheme='BACKWARD') # Discretize model using Orthogonal Collocation -discretizer = TransformationFactory('dae.collocation') +discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(m, nfe=7, ncp=6, scheme='LAGRANGE-RADAU') discretizer.reduce_collocation_points(m, var=m.u, ncp=1, contset=m.t) -results = SolverFactory('ipopt').solve(m, tee=True) +results = pyo.SolverFactory('ipopt').solve(m, tee=True) def plotter(subplot, x, *series, **kwds): plt.subplot(subplot) for i, y in enumerate(series): plt.plot( - list(x), [value(y[t]) for t in x], 'brgcmk'[i % 6] + kwds.get('points', '') + list(x), + [pyo.value(y[t]) for t in x], + 'brgcmk'[i % 6] + kwds.get('points', ''), ) plt.title(kwds.get('title', '')) plt.legend(tuple(y.name for y in series), frameon=True, edgecolor='k').draw_frame( diff --git a/examples/dae/run_disease.py b/examples/dae/run_disease.py index 5d9595a89d5..8e195af3011 100644 --- a/examples/dae/run_disease.py +++ b/examples/dae/run_disease.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from disease_DAE import model instance = model.create_instance('disease.dat') -discretizer = TransformationFactory('dae.collocation') +discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(instance, nfe=520, ncp=3) @@ -25,7 +25,7 @@ def _S_bar(model): ) -instance.con_S_bar = Constraint(rule=_S_bar) +instance.con_S_bar = pyo.Constraint(rule=_S_bar) -solver = SolverFactory('ipopt') +solver = pyo.SolverFactory('ipopt') results = solver.solve(instance, tee=True) diff --git a/examples/dae/run_distill.py b/examples/dae/run_distill.py index 9b09850f90a..b0572f74b14 100644 --- a/examples/dae/run_distill.py +++ b/examples/dae/run_distill.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,18 +9,18 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from distill_DAE import model instance = model.create_instance('distill.dat') # Discretize using Finite Difference Approach -discretizer = TransformationFactory('dae.finite_difference') +discretizer = pyo.TransformationFactory('dae.finite_difference') discretizer.apply_to(instance, nfe=50, scheme='BACKWARD') # Discretize using Orthogonal Collocation -# discretizer = TransformationFactory('dae.collocation') +# discretizer = pyo.TransformationFactory('dae.collocation') # discretizer.apply_to(instance,nfe=50,ncp=3) # The objective function in the manually discretized pyomo model @@ -37,9 +37,9 @@ def obj_rule(m): ) + m.rho * sum((m.u1[i] - m.u1_ref) ** 2 for i in m.t if i != 1) -instance.OBJ = Objective(rule=obj_rule) +instance.OBJ = pyo.Objective(rule=obj_rule) -solver = SolverFactory('ipopt') +solver = pyo.SolverFactory('ipopt') results = solver.solve(instance, tee=True) @@ -50,8 +50,8 @@ def obj_rule(m): x20 = [] for i in sorted(instance.t): - x5.append(value(instance.x[5, i])) - x20.append(value(instance.x[20, i])) + x5.append(pyo.value(instance.x[5, i])) + x20.append(pyo.value(instance.x[20, i])) t.append(i) import matplotlib.pyplot as plt diff --git a/examples/dae/run_stochpdegas_automatic.py b/examples/dae/run_stochpdegas_automatic.py index 6fc9f6d594c..4a34b3965a8 100644 --- a/examples/dae/run_stochpdegas_automatic.py +++ b/examples/dae/run_stochpdegas_automatic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,15 +11,14 @@ import time -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo from stochpdegas_automatic import model start = time.time() instance = model.create_instance('stochpdegas_automatic.dat') # discretize model -discretizer = TransformationFactory('dae.finite_difference') +discretizer = pyo.TransformationFactory('dae.finite_difference') discretizer.apply_to(instance, nfe=1, wrt=instance.DIS, scheme='FORWARD') discretizer.apply_to(instance, nfe=47, wrt=instance.TIME, scheme='BACKWARD') @@ -37,7 +36,7 @@ def supcost_rule(m, k): ) -instance.supcost = Expression(instance.SCEN, rule=supcost_rule) +instance.supcost = pyo.Expression(instance.SCEN, rule=supcost_rule) def boostcost_rule(m, k): @@ -48,7 +47,7 @@ def boostcost_rule(m, k): ) -instance.boostcost = Expression(instance.SCEN, rule=boostcost_rule) +instance.boostcost = pyo.Expression(instance.SCEN, rule=boostcost_rule) def trackcost_rule(m, k): @@ -59,7 +58,7 @@ def trackcost_rule(m, k): ) -instance.trackcost = Expression(instance.SCEN, rule=trackcost_rule) +instance.trackcost = pyo.Expression(instance.SCEN, rule=trackcost_rule) def sspcost_rule(m, k): @@ -70,7 +69,7 @@ def sspcost_rule(m, k): ) -instance.sspcost = Expression(instance.SCEN, rule=sspcost_rule) +instance.sspcost = pyo.Expression(instance.SCEN, rule=sspcost_rule) def ssfcost_rule(m, k): @@ -81,7 +80,7 @@ def ssfcost_rule(m, k): ) -instance.ssfcost = Expression(instance.SCEN, rule=ssfcost_rule) +instance.ssfcost = pyo.Expression(instance.SCEN, rule=ssfcost_rule) def cost_rule(m, k): @@ -90,28 +89,28 @@ def cost_rule(m, k): ) -instance.cost = Expression(instance.SCEN, rule=cost_rule) +instance.cost = pyo.Expression(instance.SCEN, rule=cost_rule) def mcost_rule(m): return (1.0 / m.S) * sum(m.cost[k] for k in m.SCEN) -instance.mcost = Expression(rule=mcost_rule) +instance.mcost = pyo.Expression(rule=mcost_rule) def eqcvar_rule(m, k): return m.cost[k] - m.nu <= m.phi[k] -instance.eqcvar = Constraint(instance.SCEN, rule=eqcvar_rule) +instance.eqcvar = pyo.Constraint(instance.SCEN, rule=eqcvar_rule) def obj_rule(m): return (1.0 - m.cvar_lambda) * m.mcost + m.cvar_lambda * m.cvarcost -instance.obj = Objective(rule=obj_rule) +instance.obj = pyo.Objective(rule=obj_rule) endTime = time.time() - start print('model creation time = %s' % (endTime,)) @@ -122,14 +121,14 @@ def obj_rule(m): % ( i, sum( - sum(0.5 * value(instance.pow[i, j, k]) for j in instance.LINK_A) + sum(0.5 * pyo.value(instance.pow[i, j, k]) for j in instance.LINK_A) for k in instance.TIME.get_finite_elements() ), ) ) -solver = SolverFactory('ipopt') +solver = pyo.SolverFactory('ipopt') results = solver.solve(instance, tee=True) for i in instance.SCEN: @@ -138,7 +137,7 @@ def obj_rule(m): % ( i, sum( - sum(0.5 * value(instance.pow[i, j, k]) for j in instance.LINK_A) + sum(0.5 * pyo.value(instance.pow[i, j, k]) for j in instance.LINK_A) for k in instance.TIME.get_finite_elements() ), ) diff --git a/examples/dae/simulator_dae_example.py b/examples/dae/simulator_dae_example.py index 4ea1f9fd5f0..13ce30cadea 100644 --- a/examples/dae/simulator_dae_example.py +++ b/examples/dae/simulator_dae_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,24 +22,24 @@ # zB' = p1*zA - (p2 + p3)*zB + p4*zC, zB(0)=0 # zA + zB + zC = 1 -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from pyomo.dae.simulator import Simulator def create_model(): - m = ConcreteModel() + m = pyo.ConcreteModel() m.t = ContinuousSet(bounds=(0.0, 1)) - m.p1 = Param(initialize=4.0) - m.p2 = Param(initialize=2.0) - m.p3 = Param(initialize=40.0) - m.p4 = Param(initialize=20.0) + m.p1 = pyo.Param(initialize=4.0) + m.p2 = pyo.Param(initialize=2.0) + m.p3 = pyo.Param(initialize=40.0) + m.p4 = pyo.Param(initialize=20.0) - m.za = Var(m.t) - m.zb = Var(m.t) - m.zc = Var(m.t) + m.za = pyo.Var(m.t) + m.zb = pyo.Var(m.t) + m.zc = pyo.Var(m.t) m.dza = DerivativeVar(m.za) m.dzb = DerivativeVar(m.zb) @@ -50,17 +50,17 @@ def create_model(): def _diffeq1(m, t): return m.dza[t] == -m.p1 * m.za[t] + m.p2 * m.zb[t] - m.diffeq1 = Constraint(m.t, rule=_diffeq1) + m.diffeq1 = pyo.Constraint(m.t, rule=_diffeq1) def _diffeq2(m, t): return m.dzb[t] == m.p1 * m.za[t] - (m.p2 + m.p3) * m.zb[t] + m.p4 * m.zc[t] - m.diffeq2 = Constraint(m.t, rule=_diffeq2) + m.diffeq2 = pyo.Constraint(m.t, rule=_diffeq2) def _algeq1(m, t): return m.za[t] + m.zb[t] + m.zc[t] == 1 - m.algeq1 = Constraint(m.t, rule=_algeq1) + m.algeq1 = pyo.Constraint(m.t, rule=_algeq1) return m @@ -70,7 +70,7 @@ def simulate_model(m): tsim, profiles = sim.simulate(numpoints=100, integrator='idas') # Discretize model using Orthogonal Collocation - discretizer = TransformationFactory('dae.collocation') + discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(m, nfe=10, ncp=3) # Initialize the discretized model using the simulator profiles @@ -83,9 +83,9 @@ def plot_result(m, sim, tsim, profiles): import matplotlib.pyplot as plt time = list(m.t) - za = [value(m.za[t]) for t in m.t] - zb = [value(m.zb[t]) for t in m.t] - zc = [value(m.zc[t]) for t in m.t] + za = [pyo.value(m.za[t]) for t in m.t] + zb = [pyo.value(m.zb[t]) for t in m.t] + zc = [pyo.value(m.zc[t]) for t in m.t] varorder = sim.get_variable_order() algorder = sim.get_variable_order(vartype='algebraic') diff --git a/examples/dae/simulator_dae_multindex_example.py b/examples/dae/simulator_dae_multindex_example.py index 775eb4f8c79..c5c63f37990 100644 --- a/examples/dae/simulator_dae_multindex_example.py +++ b/examples/dae/simulator_dae_multindex_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,13 +25,13 @@ # This example is modified such that the reaction coefficient # p1 changes in time -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from pyomo.dae.simulator import Simulator def create_model(): - m = ConcreteModel() + m = pyo.ConcreteModel() m.t = ContinuousSet(bounds=(0.0, 1), initialize=[0.5]) @@ -40,14 +40,14 @@ def _p1_init(m, t): return 1.0 return 4.0 - m.p1 = Param(m.t, initialize=4.0, default=_p1_init) - m.p2 = Param(initialize=2.0) - m.p3 = Param(initialize=40.0) - m.p4 = Param(initialize=20.0) + m.p1 = pyo.Param(m.t, initialize=4.0, default=_p1_init) + m.p2 = pyo.Param(initialize=2.0) + m.p3 = pyo.Param(initialize=40.0) + m.p4 = pyo.Param(initialize=20.0) - m.za = Var(m.t) - m.zb = Var(m.t) - m.zc = Var(m.t) + m.za = pyo.Var(m.t) + m.zb = pyo.Var(m.t) + m.zc = pyo.Var(m.t) m.dza = DerivativeVar(m.za) m.dzb = DerivativeVar(m.zb) @@ -57,23 +57,23 @@ def _p1_init(m, t): # Setting the time-varying profile p1_profile = {0: 4.0, 0.5: 1.0} - m.var_input = Suffix(direction=Suffix.LOCAL) + m.var_input = pyo.Suffix(direction=pyo.Suffix.LOCAL) m.var_input[m.p1] = p1_profile def _diffeq1(m, t): return m.dza[t] == -m.p1[t] * m.za[t] + m.p2 * m.zb[t] - m.diffeq1 = Constraint(m.t, rule=_diffeq1) + m.diffeq1 = pyo.Constraint(m.t, rule=_diffeq1) def _diffeq2(m, t): return m.dzb[t] == m.p1[t] * m.za[t] - (m.p2 + m.p3) * m.zb[t] + m.p4 * m.zc[t] - m.diffeq2 = Constraint(m.t, rule=_diffeq2) + m.diffeq2 = pyo.Constraint(m.t, rule=_diffeq2) def _algeq1(m, t): return m.za[t] + m.zb[t] + m.zc[t] == 1 - m.algeq1 = Constraint(m.t, rule=_algeq1) + m.algeq1 = pyo.Constraint(m.t, rule=_algeq1) return m @@ -85,7 +85,7 @@ def simulate_model(m): ) # Discretize model using Orthogonal Collocation - discretizer = TransformationFactory('dae.collocation') + discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(m, nfe=10, ncp=3) # Initialize the discretized model using the simulator profiles @@ -98,9 +98,9 @@ def plot_results(m, sim, tsim, profiles): import matplotlib.pyplot as plt time = list(m.t) - za = [value(m.za[t]) for t in m.t] - zb = [value(m.zb[t]) for t in m.t] - zc = [value(m.zc[t]) for t in m.t] + za = [pyo.value(m.za[t]) for t in m.t] + zb = [pyo.value(m.zb[t]) for t in m.t] + zc = [pyo.value(m.zc[t]) for t in m.t] varorder = sim.get_variable_order() algorder = sim.get_variable_order(vartype='algebraic') diff --git a/examples/dae/simulator_ode_example.py b/examples/dae/simulator_ode_example.py index f6f28b87d07..5fe601218d9 100644 --- a/examples/dae/simulator_ode_example.py +++ b/examples/dae/simulator_ode_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,21 +18,21 @@ # theta' = omega # omega' = -b*omega - c*sin(theta) -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from pyomo.dae.simulator import Simulator def create_model(): - m = ConcreteModel() + m = pyo.ConcreteModel() m.t = ContinuousSet(bounds=(0.0, 10.0)) - m.b = Param(initialize=0.25) - m.c = Param(initialize=5.0) + m.b = pyo.Param(initialize=0.25) + m.c = pyo.Param(initialize=5.0) - m.omega = Var(m.t) - m.theta = Var(m.t) + m.omega = pyo.Var(m.t) + m.theta = pyo.Var(m.t) m.domegadt = DerivativeVar(m.omega, wrt=m.t) m.dthetadt = DerivativeVar(m.theta, wrt=m.t) @@ -42,14 +42,14 @@ def create_model(): m.theta[0] = 3.14 - 0.1 def _diffeq1(m, t): - return m.domegadt[t] == -m.b * m.omega[t] - m.c * sin(m.theta[t]) + return m.domegadt[t] == -m.b * m.omega[t] - m.c * pyo.sin(m.theta[t]) - m.diffeq1 = Constraint(m.t, rule=_diffeq1) + m.diffeq1 = pyo.Constraint(m.t, rule=_diffeq1) def _diffeq2(m, t): return m.dthetadt[t] == m.omega[t] - m.diffeq2 = Constraint(m.t, rule=_diffeq2) + m.diffeq2 = pyo.Constraint(m.t, rule=_diffeq2) return m @@ -65,7 +65,7 @@ def simulate_model(m): tsim, profiles = sim.simulate(numpoints=100, integrator='vode') # Discretize model using Orthogonal Collocation - discretizer = TransformationFactory('dae.collocation') + discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(m, nfe=8, ncp=5) # Initialize the discretized model using the simulator profiles @@ -78,8 +78,8 @@ def plot_result(m, sim, tsim, profiles): import matplotlib.pyplot as plt time = list(m.t) - omega = [value(m.omega[t]) for t in m.t] - theta = [value(m.theta[t]) for t in m.t] + omega = [pyo.value(m.omega[t]) for t in m.t] + theta = [pyo.value(m.theta[t]) for t in m.t] varorder = sim.get_variable_order() diff --git a/examples/dae/simulator_ode_multindex_example.py b/examples/dae/simulator_ode_multindex_example.py index b1b9111084b..81bba34061f 100644 --- a/examples/dae/simulator_ode_multindex_example.py +++ b/examples/dae/simulator_ode_multindex_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,13 +20,13 @@ # # Example modified to include time-varying values for b and c -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar from pyomo.dae.simulator import Simulator def create_model(): - m = ConcreteModel() + m = pyo.ConcreteModel() m.t = ContinuousSet(bounds=(0.0, 20.0)) @@ -35,17 +35,17 @@ def _b_default(m, t): return 0.025 return 0.25 - m.b = Param(m.t, initialize=0.25, default=_b_default) + m.b = pyo.Param(m.t, initialize=0.25, default=_b_default) def _c_default(m, t): if t >= 7: return 50 return 5 - m.c = Param(m.t, initialize=5.0, default=_c_default) + m.c = pyo.Param(m.t, initialize=5.0, default=_c_default) - m.omega = Var(m.t) - m.theta = Var(m.t) + m.omega = pyo.Var(m.t) + m.theta = pyo.Var(m.t) m.domegadt = DerivativeVar(m.omega, wrt=m.t) m.dthetadt = DerivativeVar(m.theta, wrt=m.t) @@ -55,19 +55,19 @@ def _c_default(m, t): m.theta[0] = 3.14 - 0.1 def _diffeq1(m, t): - return m.domegadt[t] == -m.b[t] * m.omega[t] - m.c[t] * sin(m.theta[t]) + return m.domegadt[t] == -m.b[t] * m.omega[t] - m.c[t] * pyo.sin(m.theta[t]) - m.diffeq1 = Constraint(m.t, rule=_diffeq1) + m.diffeq1 = pyo.Constraint(m.t, rule=_diffeq1) def _diffeq2(m, t): return m.dthetadt[t] == m.omega[t] - m.diffeq2 = Constraint(m.t, rule=_diffeq2) + m.diffeq2 = pyo.Constraint(m.t, rule=_diffeq2) b_profile = {0: 0.25, 15: 0.025} c_profile = {0: 5.0, 7: 50} - m.var_input = Suffix(direction=Suffix.LOCAL) + m.var_input = pyo.Suffix(direction=pyo.Suffix.LOCAL) m.var_input[m.b] = b_profile m.var_input[m.c] = c_profile @@ -89,7 +89,7 @@ def simulate_model(m): ) # Discretize model using Orthogonal Collocation - discretizer = TransformationFactory('dae.collocation') + discretizer = pyo.TransformationFactory('dae.collocation') discretizer.apply_to(m, nfe=20, ncp=3) # Initialize the discretized model using the simulator profiles @@ -102,8 +102,8 @@ def plot_result(m, sim, tsim, profiles): import matplotlib.pyplot as plt time = list(m.t) - omega = [value(m.omega[t]) for t in m.t] - theta = [value(m.theta[t]) for t in m.t] + omega = [pyo.value(m.omega[t]) for t in m.t] + theta = [pyo.value(m.theta[t]) for t in m.t] varorder = sim.get_variable_order() for idx, v in enumerate(varorder): diff --git a/examples/dae/stochpdegas_automatic.py b/examples/dae/stochpdegas_automatic.py index 397b4a18100..8251b4bfc97 100644 --- a/examples/dae/stochpdegas_automatic.py +++ b/examples/dae/stochpdegas_automatic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,105 +14,109 @@ # -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -model = AbstractModel() +model = pyo.AbstractModel() # sets -model.TF = Param(within=NonNegativeReals) +model.TF = pyo.Param(within=pyo.NonNegativeReals) def _tinit(m): - return [0.5, value(m.TF)] + return [0.5, pyo.value(m.TF)] # What it should be to match description in paper - # return [0,value(m.TF)] + # return [0,pyo.value(m.TF)] model.TIME = ContinuousSet(initialize=_tinit) model.DIS = ContinuousSet(bounds=(0.0, 1.0)) -model.S = Param(within=PositiveIntegers) -model.SCEN = RangeSet(1, model.S) +model.S = pyo.Param(within=pyo.PositiveIntegers) +model.SCEN = pyo.RangeSet(1, model.S) # links -model.LINK = Set(dimen=1) -model.lstartloc = Param(model.LINK, within=Any) -model.lendloc = Param(model.LINK, within=Any) -model.ldiam = Param(model.LINK, within=PositiveReals, mutable=True) -model.llength = Param(model.LINK, within=PositiveReals, mutable=True) -model.ltype = Param(model.LINK, within=Any) +model.LINK = pyo.Set(dimen=1) +model.lstartloc = pyo.Param(model.LINK, within=pyo.Any) +model.lendloc = pyo.Param(model.LINK, within=pyo.Any) +model.ldiam = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.llength = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.ltype = pyo.Param(model.LINK, within=pyo.Any) def link_a_init_rule(m): return (l for l in m.LINK if m.ltype[l] == "a") -model.LINK_A = Set(initialize=link_a_init_rule) +model.LINK_A = pyo.Set(initialize=link_a_init_rule) def link_p_init_rule(m): return (l for l in m.LINK if m.ltype[l] == "p") -model.LINK_P = Set(initialize=link_p_init_rule) +model.LINK_P = pyo.Set(initialize=link_p_init_rule) # nodes -model.NODE = Set() -model.pmin = Param(model.NODE, within=PositiveReals, mutable=True) -model.pmax = Param(model.NODE, within=PositiveReals, mutable=True) +model.NODE = pyo.Set() +model.pmin = pyo.Param(model.NODE, within=pyo.PositiveReals, mutable=True) +model.pmax = pyo.Param(model.NODE, within=pyo.PositiveReals, mutable=True) # supply -model.SUP = Set() -model.sloc = Param(model.SUP, within=Any) -model.smin = Param(model.SUP, within=NonNegativeReals, mutable=True) -model.smax = Param(model.SUP, within=NonNegativeReals, mutable=True) -model.scost = Param(model.SUP, within=NonNegativeReals) +model.SUP = pyo.Set() +model.sloc = pyo.Param(model.SUP, within=pyo.Any) +model.smin = pyo.Param(model.SUP, within=pyo.NonNegativeReals, mutable=True) +model.smax = pyo.Param(model.SUP, within=pyo.NonNegativeReals, mutable=True) +model.scost = pyo.Param(model.SUP, within=pyo.NonNegativeReals) # demand -model.DEM = Set() -model.dloc = Param(model.DEM, within=Any) -model.d = Param(model.DEM, within=PositiveReals, mutable=True) +model.DEM = pyo.Set() +model.dloc = pyo.Param(model.DEM, within=pyo.Any) +model.d = pyo.Param(model.DEM, within=pyo.PositiveReals, mutable=True) # physical data -model.eps = Param(initialize=0.025, within=PositiveReals) -model.z = Param(initialize=0.80, within=PositiveReals) -model.rhon = Param(initialize=0.72, within=PositiveReals) -model.R = Param(initialize=8314.0, within=PositiveReals) -model.M = Param(initialize=18.0, within=PositiveReals) -model.pi = Param(initialize=3.14, within=PositiveReals) -model.nu2 = Param(within=PositiveReals, mutable=True) -model.lam = Param(model.LINK, within=PositiveReals, mutable=True) -model.A = Param(model.LINK, within=NonNegativeReals, mutable=True) -model.Tgas = Param(initialize=293.15, within=PositiveReals) -model.Cp = Param(initialize=2.34, within=PositiveReals) -model.Cv = Param(initialize=1.85, within=PositiveReals) -model.gam = Param(initialize=model.Cp / model.Cv, within=PositiveReals) -model.om = Param(initialize=(model.gam - 1.0) / model.gam, within=PositiveReals) +model.eps = pyo.Param(initialize=0.025, within=pyo.PositiveReals) +model.z = pyo.Param(initialize=0.80, within=pyo.PositiveReals) +model.rhon = pyo.Param(initialize=0.72, within=pyo.PositiveReals) +model.R = pyo.Param(initialize=8314.0, within=pyo.PositiveReals) +model.M = pyo.Param(initialize=18.0, within=pyo.PositiveReals) +model.pi = pyo.Param(initialize=3.14, within=pyo.PositiveReals) +model.nu2 = pyo.Param(within=pyo.PositiveReals, mutable=True) +model.lam = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.A = pyo.Param(model.LINK, within=pyo.NonNegativeReals, mutable=True) +model.Tgas = pyo.Param(initialize=293.15, within=pyo.PositiveReals) +model.Cp = pyo.Param(initialize=2.34, within=pyo.PositiveReals) +model.Cv = pyo.Param(initialize=1.85, within=pyo.PositiveReals) +model.gam = pyo.Param(initialize=model.Cp / model.Cv, within=pyo.PositiveReals) +model.om = pyo.Param(initialize=(model.gam - 1.0) / model.gam, within=pyo.PositiveReals) # scaling and constants -model.ffac = Param( - within=PositiveReals, initialize=(1.0e6 * model.rhon) / (24.0 * 3600.0) +model.ffac = pyo.Param( + within=pyo.PositiveReals, initialize=(1.0e6 * model.rhon) / (24.0 * 3600.0) ) -model.ffac2 = Param(within=PositiveReals, initialize=(3600.0) / (1.0e4 * model.rhon)) -model.pfac = Param(within=PositiveReals, initialize=1.0e5) -model.pfac2 = Param(within=PositiveReals, initialize=1.0e-5) -model.dfac = Param(within=PositiveReals, initialize=1.0e-3) -model.lfac = Param(within=PositiveReals, initialize=1.0e3) +model.ffac2 = pyo.Param( + within=pyo.PositiveReals, initialize=(3600.0) / (1.0e4 * model.rhon) +) +model.pfac = pyo.Param(within=pyo.PositiveReals, initialize=1.0e5) +model.pfac2 = pyo.Param(within=pyo.PositiveReals, initialize=1.0e-5) +model.dfac = pyo.Param(within=pyo.PositiveReals, initialize=1.0e-3) +model.lfac = pyo.Param(within=pyo.PositiveReals, initialize=1.0e3) -model.c1 = Param(model.LINK, within=PositiveReals, mutable=True) -model.c2 = Param(model.LINK, within=PositiveReals, mutable=True) -model.c3 = Param(model.LINK, within=PositiveReals, mutable=True) -model.c4 = Param(within=PositiveReals, mutable=True) +model.c1 = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.c2 = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.c3 = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.c4 = pyo.Param(within=pyo.PositiveReals, mutable=True) # cost factors -model.ce = Param(initialize=0.1, within=NonNegativeReals) -model.cd = Param(initialize=1.0e6, within=NonNegativeReals) -model.cT = Param(initialize=1.0e6, within=NonNegativeReals) -model.cs = Param(initialize=0.0, within=NonNegativeReals) -model.TDEC = Param(within=PositiveReals) +model.ce = pyo.Param(initialize=0.1, within=pyo.NonNegativeReals) +model.cd = pyo.Param(initialize=1.0e6, within=pyo.NonNegativeReals) +model.cT = pyo.Param(initialize=1.0e6, within=pyo.NonNegativeReals) +model.cs = pyo.Param(initialize=0.0, within=pyo.NonNegativeReals) +model.TDEC = pyo.Param(within=pyo.PositiveReals) # define stochastic info -model.rand_d = Param(model.SCEN, model.DEM, within=NonNegativeReals, mutable=True) +model.rand_d = pyo.Param( + model.SCEN, model.DEM, within=pyo.NonNegativeReals, mutable=True +) # convert units for input data @@ -138,12 +142,12 @@ def rescale_rule(m): m.pmax[i] = m.pmax[i] * m.pfac * m.pfac2 # from bar to Pascals and then to bar -model.rescale = BuildAction(rule=rescale_rule) +model.rescale = pyo.BuildAction(rule=rescale_rule) def compute_constants(m): for i in m.LINK: - m.lam[i] = (2.0 * log10(3.7 * m.ldiam[i] / (m.eps * m.dfac))) ** (-2.0) + m.lam[i] = (2.0 * pyo.log10(3.7 * m.ldiam[i] / (m.eps * m.dfac))) ** (-2.0) m.A[i] = (1.0 / 4.0) * m.pi * m.ldiam[i] * m.ldiam[i] m.nu2 = m.gam * m.z * m.R * m.Tgas / m.M m.c1[i] = (m.pfac2 / m.ffac2) * (m.nu2 / m.A[i]) @@ -157,7 +161,7 @@ def compute_constants(m): m.c4 = (1 / m.ffac2) * (m.Cp * m.Tgas) -model.compute_constants = BuildAction(rule=compute_constants) +model.compute_constants = pyo.BuildAction(rule=compute_constants) # set stochastic demands @@ -172,7 +176,7 @@ def compute_demands_rule(m): m.rand_d[k, j] = 1.3 * m.d[j] -model.compute_demands = BuildAction(rule=compute_demands_rule) +model.compute_demands = pyo.BuildAction(rule=compute_demands_rule) def stochd_init(m, k, j, t): @@ -191,11 +195,11 @@ def stochd_init(m, k, j, t): return m.d[j] -model.stochd = Param( +model.stochd = pyo.Param( model.SCEN, model.DEM, model.TIME, - within=PositiveReals, + within=pyo.PositiveReals, mutable=True, default=stochd_init, ) @@ -203,45 +207,47 @@ def stochd_init(m, k, j, t): # define temporal variables def p_bounds_rule(m, k, j, t): - return (value(m.pmin[j]), value(m.pmax[j])) + return (pyo.value(m.pmin[j]), pyo.value(m.pmax[j])) def p_init(m, k, j, t): - return (value(m.pmax[j]) + value(m.pmin[j])) / 2 + return (pyo.value(m.pmax[j]) + pyo.value(m.pmin[j])) / 2 -model.p = Var( +model.p = pyo.Var( model.SCEN, model.NODE, model.TIME, bounds=p_bounds_rule, initialize=p_init ) -model.dp = Var( +model.dp = pyo.Var( model.SCEN, model.LINK_A, model.TIME, bounds=(0.0, 100.0), initialize=10.0 ) -model.fin = Var( +model.fin = pyo.Var( model.SCEN, model.LINK, model.TIME, bounds=(1.0, 500.0), initialize=100.0 ) -model.fout = Var( +model.fout = pyo.Var( model.SCEN, model.LINK, model.TIME, bounds=(1.0, 500.0), initialize=100.0 ) def s_bounds_rule(m, k, j, t): - return (0.01, value(m.smax[j])) + return (0.01, pyo.value(m.smax[j])) -model.s = Var(model.SCEN, model.SUP, model.TIME, bounds=s_bounds_rule, initialize=10.0) -model.dem = Var(model.SCEN, model.DEM, model.TIME, initialize=100.0) -model.pow = Var( +model.s = pyo.Var( + model.SCEN, model.SUP, model.TIME, bounds=s_bounds_rule, initialize=10.0 +) +model.dem = pyo.Var(model.SCEN, model.DEM, model.TIME, initialize=100.0) +model.pow = pyo.Var( model.SCEN, model.LINK_A, model.TIME, bounds=(0.0, 3000.0), initialize=1000.0 ) -model.slack = Var( +model.slack = pyo.Var( model.SCEN, model.LINK, model.TIME, model.DIS, bounds=(0.0, None), initialize=10.0 ) # define spatio-temporal variables -model.px = Var( +model.px = pyo.Var( model.SCEN, model.LINK, model.TIME, model.DIS, bounds=(10.0, 100.0), initialize=50.0 ) -model.fx = Var( +model.fx = pyo.Var( model.SCEN, model.LINK, model.TIME, model.DIS, bounds=(1.0, 100.0), initialize=100.0 ) @@ -263,19 +269,19 @@ def powereq_rule(m, j, i, t): ) -model.powereq = Constraint(model.SCEN, model.LINK_A, model.TIME, rule=powereq_rule) +model.powereq = pyo.Constraint(model.SCEN, model.LINK_A, model.TIME, rule=powereq_rule) # cvar model -model.cvar_lambda = Param(within=NonNegativeReals) -model.nu = Var(initialize=100.0) -model.phi = Var(model.SCEN, bounds=(0.0, None), initialize=100.0) +model.cvar_lambda = pyo.Param(within=pyo.NonNegativeReals) +model.nu = pyo.Var(initialize=100.0) +model.phi = pyo.Var(model.SCEN, bounds=(0.0, None), initialize=100.0) def cvarcost_rule(m): return (1.0 / m.S) * sum((m.phi[k] / (1.0 - 0.95) + m.nu) for k in m.SCEN) -model.cvarcost = Expression(rule=cvarcost_rule) +model.cvarcost = pyo.Expression(rule=cvarcost_rule) # node balances @@ -289,7 +295,7 @@ def nodeeq_rule(m, k, i, t): ) -model.nodeeq = Constraint(model.SCEN, model.NODE, model.TIME, rule=nodeeq_rule) +model.nodeeq = pyo.Constraint(model.SCEN, model.NODE, model.TIME, rule=nodeeq_rule) # boundary conditions flow @@ -297,51 +303,57 @@ def flow_start_rule(m, j, i, t): return m.fx[j, i, t, m.DIS.first()] == m.fin[j, i, t] -model.flow_start = Constraint(model.SCEN, model.LINK, model.TIME, rule=flow_start_rule) +model.flow_start = pyo.Constraint( + model.SCEN, model.LINK, model.TIME, rule=flow_start_rule +) def flow_end_rule(m, j, i, t): return m.fx[j, i, t, m.DIS.last()] == m.fout[j, i, t] -model.flow_end = Constraint(model.SCEN, model.LINK, model.TIME, rule=flow_end_rule) +model.flow_end = pyo.Constraint(model.SCEN, model.LINK, model.TIME, rule=flow_end_rule) # First PDE for gas network model def flow_rule(m, j, i, t, k): if t == m.TIME.first() or k == m.DIS.last(): - return Constraint.Skip # Do not apply pde at initial time or final location + return pyo.Constraint.Skip # Do not apply pde at initial time or final location return ( m.dpxdt[j, i, t, k] / 3600 + m.c1[i] / m.llength[i] * m.dfxdx[j, i, t, k] == 0 ) -model.flow = Constraint(model.SCEN, model.LINK, model.TIME, model.DIS, rule=flow_rule) +model.flow = pyo.Constraint( + model.SCEN, model.LINK, model.TIME, model.DIS, rule=flow_rule +) # Second PDE for gas network model def press_rule(m, j, i, t, k): if t == m.TIME.first() or k == m.DIS.last(): - return Constraint.Skip # Do not apply pde at initial time or final location + return pyo.Constraint.Skip # Do not apply pde at initial time or final location return ( m.dfxdt[j, i, t, k] / 3600 == -m.c2[i] / m.llength[i] * m.dpxdx[j, i, t, k] - m.slack[j, i, t, k] ) -model.press = Constraint(model.SCEN, model.LINK, model.TIME, model.DIS, rule=press_rule) +model.press = pyo.Constraint( + model.SCEN, model.LINK, model.TIME, model.DIS, rule=press_rule +) def slackeq_rule(m, j, i, t, k): if t == m.TIME.last(): - return Constraint.Skip + return pyo.Constraint.Skip return ( m.slack[j, i, t, k] * m.px[j, i, t, k] == m.c3[i] * m.fx[j, i, t, k] * m.fx[j, i, t, k] ) -model.slackeq = Constraint( +model.slackeq = pyo.Constraint( model.SCEN, model.LINK, model.TIME, model.DIS, rule=slackeq_rule ) @@ -351,7 +363,7 @@ def presspas_start_rule(m, j, i, t): return m.px[j, i, t, m.DIS.first()] == m.p[j, m.lstartloc[i], t] -model.presspas_start = Constraint( +model.presspas_start = pyo.Constraint( model.SCEN, model.LINK_P, model.TIME, rule=presspas_start_rule ) @@ -360,7 +372,7 @@ def presspas_end_rule(m, j, i, t): return m.px[j, i, t, m.DIS.last()] == m.p[j, m.lendloc[i], t] -model.presspas_end = Constraint( +model.presspas_end = pyo.Constraint( model.SCEN, model.LINK_P, model.TIME, rule=presspas_end_rule ) @@ -370,7 +382,7 @@ def pressact_start_rule(m, j, i, t): return m.px[j, i, t, m.DIS.first()] == m.p[j, m.lstartloc[i], t] + m.dp[j, i, t] -model.pressact_start = Constraint( +model.pressact_start = pyo.Constraint( model.SCEN, model.LINK_A, model.TIME, rule=pressact_start_rule ) @@ -379,7 +391,7 @@ def pressact_end_rule(m, j, i, t): return m.px[j, i, t, m.DIS.last()] == m.p[j, m.lendloc[i], t] -model.pressact_end = Constraint( +model.pressact_end = pyo.Constraint( model.SCEN, model.LINK_A, model.TIME, rule=pressact_end_rule ) @@ -389,7 +401,7 @@ def suppress_rule(m, k, j, t): return m.p[k, m.sloc[j], t] == m.pmin[m.sloc[j]] -model.suppress = Constraint(model.SCEN, model.SUP, model.TIME, rule=suppress_rule) +model.suppress = pyo.Constraint(model.SCEN, model.SUP, model.TIME, rule=suppress_rule) # discharge pressure for compressors @@ -397,22 +409,24 @@ def dispress_rule(m, j, i, t): return m.p[j, m.lstartloc[i], t] + m.dp[j, i, t] <= m.pmax[m.lstartloc[i]] -model.dispress = Constraint(model.SCEN, model.LINK_A, model.TIME, rule=dispress_rule) +model.dispress = pyo.Constraint( + model.SCEN, model.LINK_A, model.TIME, rule=dispress_rule +) # ss constraints def flow_ss_rule(m, j, i, k): if k == m.DIS.last(): - return Constraint.Skip + return pyo.Constraint.Skip return m.dfxdx[j, i, m.TIME.first(), k] / m.llength[i] == 0.0 -model.flow_ss = Constraint(model.SCEN, model.LINK, model.DIS, rule=flow_ss_rule) +model.flow_ss = pyo.Constraint(model.SCEN, model.LINK, model.DIS, rule=flow_ss_rule) def pres_ss_rule(m, j, i, k): if k == m.DIS.last(): - return Constraint.Skip + return pyo.Constraint.Skip return ( 0.0 == -m.c2[i] / m.llength[i] * m.dpxdx[j, i, m.TIME.first(), k] @@ -420,27 +434,29 @@ def pres_ss_rule(m, j, i, k): ) -model.pres_ss = Constraint(model.SCEN, model.LINK, model.DIS, rule=pres_ss_rule) +model.pres_ss = pyo.Constraint(model.SCEN, model.LINK, model.DIS, rule=pres_ss_rule) # non-anticipativity constraints def nonantdq_rule(m, j, i, t): if j == 1: - return Constraint.Skip + return pyo.Constraint.Skip if t >= m.TDEC + 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.dp[j, i, t] == m.dp[1, i, t] -model.nonantdq = Constraint(model.SCEN, model.LINK_A, model.TIME, rule=nonantdq_rule) +model.nonantdq = pyo.Constraint( + model.SCEN, model.LINK_A, model.TIME, rule=nonantdq_rule +) def nonantde_rule(m, j, i, t): if j == 1: - return Constraint.Skip + return pyo.Constraint.Skip if t >= m.TDEC + 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.dem[j, i, t] == m.dem[1, i, t] -model.nonantde = Constraint(model.SCEN, model.DEM, model.TIME, rule=nonantde_rule) +model.nonantde = pyo.Constraint(model.SCEN, model.DEM, model.TIME, rule=nonantde_rule) diff --git a/examples/doc/samples/__init__.py b/examples/doc/samples/__init__.py index 0110902b288..af79da12d7f 100644 --- a/examples/doc/samples/__init__.py +++ b/examples/doc/samples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/doc/samples/case_studies/deer/DeerProblem.py b/examples/doc/samples/case_studies/deer/DeerProblem.py index d09c9b53887..4da826fecb3 100644 --- a/examples/doc/samples/case_studies/deer/DeerProblem.py +++ b/examples/doc/samples/case_studies/deer/DeerProblem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,43 +9,43 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo # # Model # -model = AbstractModel() +model = pyo.AbstractModel() -model.p1 = Param() -model.p2 = Param() -model.p3 = Param() -model.p4 = Param() -model.p5 = Param() -model.p6 = Param() -model.p7 = Param() -model.p8 = Param() -model.p9 = Param() -model.ps = Param() +model.p1 = pyo.Param() +model.p2 = pyo.Param() +model.p3 = pyo.Param() +model.p4 = pyo.Param() +model.p5 = pyo.Param() +model.p6 = pyo.Param() +model.p7 = pyo.Param() +model.p8 = pyo.Param() +model.p9 = pyo.Param() +model.ps = pyo.Param() -model.f = Var(initialize=20, within=PositiveReals) -model.d = Var(initialize=20, within=PositiveReals) -model.b = Var(initialize=20, within=PositiveReals) +model.f = pyo.Var(initialize=20, within=pyo.PositiveReals) +model.d = pyo.Var(initialize=20, within=pyo.PositiveReals) +model.b = pyo.Var(initialize=20, within=pyo.PositiveReals) -model.hf = Var(initialize=20, within=PositiveReals) -model.hd = Var(initialize=20, within=PositiveReals) -model.hb = Var(initialize=20, within=PositiveReals) +model.hf = pyo.Var(initialize=20, within=pyo.PositiveReals) +model.hd = pyo.Var(initialize=20, within=pyo.PositiveReals) +model.hb = pyo.Var(initialize=20, within=pyo.PositiveReals) -model.br = Var(initialize=1.5, within=PositiveReals) +model.br = pyo.Var(initialize=1.5, within=pyo.PositiveReals) -model.c = Var(initialize=500000, within=PositiveReals) +model.c = pyo.Var(initialize=500000, within=pyo.PositiveReals) def obj_rule(amodel): return 10 * amodel.hb + amodel.hd + amodel.hf -model.obj = Objective(rule=obj_rule, sense=maximize) +model.obj = pyo.Objective(rule=obj_rule, sense=pyo.maximize) def f_bal_rule(amodel): @@ -56,21 +56,21 @@ def f_bal_rule(amodel): ) -model.f_bal = Constraint(rule=f_bal_rule) +model.f_bal = pyo.Constraint(rule=f_bal_rule) def d_bal_rule(amodel): return amodel.d == amodel.p4 * amodel.d + amodel.p5 / 2.0 * amodel.f - amodel.hd -model.d_bal = Constraint(rule=d_bal_rule) +model.d_bal = pyo.Constraint(rule=d_bal_rule) def b_bal_rule(amodel): return amodel.b == amodel.p6 * amodel.b + amodel.p5 / 2.0 * amodel.f - amodel.hb -model.b_bal = Constraint(rule=b_bal_rule) +model.b_bal = pyo.Constraint(rule=b_bal_rule) def food_cons_rule(amodel): @@ -79,25 +79,25 @@ def food_cons_rule(amodel): ) -model.food_cons = Constraint(rule=food_cons_rule) +model.food_cons = pyo.Constraint(rule=food_cons_rule) def supply_rule(amodel): return amodel.c <= amodel.ps -model.supply = Constraint(rule=supply_rule) +model.supply = pyo.Constraint(rule=supply_rule) def birth_rule(amodel): return amodel.br == 1.1 + 0.8 * (amodel.ps - amodel.c) / amodel.ps -model.birth = Constraint(rule=birth_rule) +model.birth = pyo.Constraint(rule=birth_rule) def proc_rule(amodel): return amodel.b >= 1.0 / 5.0 * (0.4 * amodel.f + amodel.d) -model.proc = Constraint(rule=proc_rule) +model.proc = pyo.Constraint(rule=proc_rule) diff --git a/examples/doc/samples/case_studies/diet/DietProblem.py b/examples/doc/samples/case_studies/diet/DietProblem.py index 64624310943..4652ff1a222 100644 --- a/examples/doc/samples/case_studies/diet/DietProblem.py +++ b/examples/doc/samples/case_studies/diet/DietProblem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,26 +9,26 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.foods = Set() -model.nutrients = Set() -model.costs = Param(model.foods) -model.min_nutrient = Param(model.nutrients) -model.max_nutrient = Param(model.nutrients) -model.volumes = Param(model.foods) -model.max_volume = Param() -model.nutrient_value = Param(model.nutrients, model.foods) -model.amount = Var(model.foods, within=NonNegativeReals) +model.foods = pyo.Set() +model.nutrients = pyo.Set() +model.costs = pyo.Param(model.foods) +model.min_nutrient = pyo.Param(model.nutrients) +model.max_nutrient = pyo.Param(model.nutrients) +model.volumes = pyo.Param(model.foods) +model.max_volume = pyo.Param() +model.nutrient_value = pyo.Param(model.nutrients, model.foods) +model.amount = pyo.Var(model.foods, within=pyo.NonNegativeReals) def costRule(model): return sum(model.costs[n] * model.amount[n] for n in model.foods) -model.cost = Objective(rule=costRule) +model.cost = pyo.Objective(rule=costRule) def volumeRule(model): @@ -37,7 +37,7 @@ def volumeRule(model): ) -model.volume = Constraint(rule=volumeRule) +model.volume = pyo.Constraint(rule=volumeRule) def nutrientRule(model, n): @@ -45,4 +45,4 @@ def nutrientRule(model, n): return (model.min_nutrient[n], value, model.max_nutrient[n]) -model.nutrientConstraint = Constraint(model.nutrients, rule=nutrientRule) +model.nutrientConstraint = pyo.Constraint(model.nutrients, rule=nutrientRule) diff --git a/examples/doc/samples/case_studies/diet/DietProblem.tex b/examples/doc/samples/case_studies/diet/DietProblem.tex index d933e097d88..9eaa7d68e9f 100644 --- a/examples/doc/samples/case_studies/diet/DietProblem.tex +++ b/examples/doc/samples/case_studies/diet/DietProblem.tex @@ -10,53 +10,53 @@ \subsection*{Build the model} \hspace*{5mm}We begin our Pyomo model with \begin{verbatim} -from pyomo.core import * +import pyomo.environ as pyo \end{verbatim} \noindent to import the Pyomo package for use in the code. The next step is to create an object of the model class, which by convention we call âmodel.â We implement this by writing -\begin{verbatim}model = AbstractModel() \end{verbatim} +\begin{verbatim}model = pyo.AbstractModel() \end{verbatim} \noindent The rest of our work will be contained within this object. In the diet problem, there are two main sets that we are looking at: the set of foods and the set of nutrients, which we define as -\begin{verbatim}model.foods = Set() -model.nutrients = Set()\end{verbatim} +\begin{verbatim}model.foods = pyo.Set() +model.nutrients = pyo.Set()\end{verbatim} Both of these sets could be very large (arguably, the set of all foods is unbounded); however, for implementing the model in Pyomo, this is irrelevant. It is in the data file that the information for these sets is filled in. The solution to the diet problem involves minimizing the amount of money we are spending on food. Obviously, to do this we must know the cost of each kind of food. To do this, we create a one dimensional parameter that measures the cost associated with each food: -\begin{verbatim}model.costs = Param(model.foods)\end{verbatim} +\begin{verbatim}model.costs = pyo.Param(model.foods)\end{verbatim} \noindent This says that for each food, there is a cost associated with it, which will be implemented later when we construct the data file. Similarly, each nutrient has a minimum and maximum requirement (note that for some nutrients the maximum is arbitrarily large). We can emulate the code for cost to create two one dimensional variables, each over nutrients. -\begin{verbatim}model.min_nutrient=Param(model.nutrients) -model.max_nutrient=Param(model.nutrients)\end{verbatim} +\begin{verbatim}model.min_nutrient=pyo.Param(model.nutrients) +model.max_nutrient=pyo.Param(model.nutrients)\end{verbatim} For this model, we also want to consider how much food a person can reasonably consume---the solution is useless if no person can eat that much. Thus, we add a volume parameter over the set of foods. This is done similarly to the previous examples. -\begin{verbatim}model.volumes=Param(model.foods)\end{verbatim} +\begin{verbatim}model.volumes=pyo.Param(model.foods)\end{verbatim} We also need a parameter for the max volume of food that can be consumed. We do this like the previous examples but without a set that the parameter is over. This is because the max volume of food consumed is independent of either food or nutrients. We input -\begin{verbatim}model.max_volume=Param()\end{verbatim} +\begin{verbatim}model.max_volume=pyo.Param()\end{verbatim} There's one last parameter that must be taken into consideration: how much of each nutrient each food contains. Unlike the previous examples, this parameter is in two dimensions---it is over both the set of foods and the set of nutrients. This just requires a small change in the code. -\begin{verbatim}model.nutrient_value=Param(model.nutrients, model.foods) \end{verbatim} +\begin{verbatim}model.nutrient_value=pyo.Param(model.nutrients, model.foods) \end{verbatim} The comma indicates that this parameter is over two different sets, and thus is in two dimensions. When we create the data file, we will be able to fill in how much of each nutrient each food contains. -At this point we have defined our sets and parameters. However, we have yet to cosnider the amount of food to be bought and eaten. This is the variable weâre trying to solve for, and thus we create an object of the variable class. Since this is just recording how much food to purchase, we create a one dimensional variable over food: +At this point we have defined our sets and parameters. However, we have yet to consider the amount of food to be bought and eaten. This is the variable weâre trying to solve for, and thus we create an object of the variable class. Since this is just recording how much food to purchase, we create a one dimensional variable over food: -\begin{verbatim}model.amount=Var(model.foods, within = NonNegativeReals) \end{verbatim} +\begin{verbatim}model.amount=pyo.Var(model.foods, within = pyo.NonNegativeReals) \end{verbatim} We restrict our domain to the non-negative reals. If we accepted negative numbers than the model could tell us to buy negative amounts of food, which is an unrealistic---and thus useless---model. We could further restrict the domain to the integers to make it more realistic, but that would make the problem much harder for little gain: if this model is used on a large scale than the difference between the integer solution and the non-integer solution is often irrelevant. @@ -71,7 +71,7 @@ \subsection*{Build the model} \noindent which will go through and multiply the costs and amounts of each food together and then take their sum as outlined above. We must include another line, though: -\begin{verbatim}model.cost=Objective(rule=costRule) \end{verbatim} +\begin{verbatim}model.cost=pyo.Objective(rule=costRule) \end{verbatim} This line defines the objective of the model as the costRule, which Pyomo interprets as the value it needs to minimize; in this case it will minimize our costs. Also, as a note, we defined the objective as ``model.cost'' which is not to be confused with the parameter we defined earlier as ``model.costs,'' despite their similar names. These are two different values and accidentally giving them the same name will cause problems when trying to solve the problem. @@ -81,7 +81,7 @@ \subsection*{Build the model} return sum(model.volumes[n]*model.amount[n] for n in model.foods) <= model.max_volume -model.volume = Constraint(rule=volumeRule) +model.volume = pyo.Constraint(rule=volumeRule) \end{verbatim} Note that here we have a constraint instead of an objective. This requires that the rule returns true, but otherwise the value is irrelevant. While objective looks for the least value, constraints just require that a value works. @@ -94,7 +94,7 @@ \subsection*{Build the model} for f in model.foods) return (model.min_nutrient[n], value, model.max_nutrient[n]) -model.nutrientConstraint = Constraint(model.nutrients, rule=nutrientRule) +model.nutrientConstraint = pyo.Constraint(model.nutrients, rule=nutrientRule) \end{verbatim} The rule itself will act much like the previous rules, but by adding an index into the constraint we will cycle through each of the nutrients. Essentially, what we have done is compressed many ``nutrient rules,'' each of which acts the same, into one rule that will look at each nutrient individually. diff --git a/examples/doc/samples/case_studies/diet/README.txt b/examples/doc/samples/case_studies/diet/README.txt index c30e963dc27..111d257b3f6 100644 --- a/examples/doc/samples/case_studies/diet/README.txt +++ b/examples/doc/samples/case_studies/diet/README.txt @@ -8,13 +8,13 @@ We begin our Pyomo model with {{{ #!python -from pyomo.core import * +import pyomo.environ as pyo }}} to import the Pyomo package for use in the code. The next step is to create an object of the model class, which by convention we call "`model`" We implement this by writing {{{ #!python -model = AbstractModel() +model = pyo.AbstractModel() }}} The rest of our work will be contained within this object. @@ -22,8 +22,8 @@ In the diet problem, there are two main sets that we are looking at: the set of {{{ #!python -model.foods = Set() -model.nutrients = Set() +model.foods = pyo.Set() +model.nutrients = pyo.Set() }}} Both of these sets could be very large (arguably, the set of all foods is unbounded); however, for implementing the model in Pyomo, this is irrelevant. It is in the data file that the information for these sets is filled in. @@ -32,7 +32,7 @@ The solution to the diet problem involves minimizing the amount of money we are {{{ #!python -model.costs = Param(model.foods) +model.costs = pyo.Param(model.foods) }}} This says that for each food, there is a cost associated with it, which will be implemented later when we construct the data file. @@ -41,38 +41,38 @@ Similarly, each nutrient has a minimum and maximum requirement (note that for so {{{ #!python -model.min_nutrient=Param(model.nutrients) -model.max_nutrient=Param(model.nutrients) +model.min_nutrient=pyo.Param(model.nutrients) +model.max_nutrient=pyo.Param(model.nutrients) }}} For this model, we also want to consider how much food a person can reasonably consume---the solution is useless if no person can eat that much. Thus, we add a volume parameter over the set of foods. This is done similarly to the previous examples. {{{ #!python -model.volumes=Param(model.foods) +model.volumes=pyo.Param(model.foods) }}} We also need a parameter for the max volume of food that can be consumed. We do this like the previous examples but without a set that the parameter is over. This is because the max volume of food consumed is independent of either food or nutrients. We input {{{ #!python -model.max_volume=Param() +model.max_volume=pyo.Param() }}} There's one last parameter that must be taken into consideration: how much of each nutrient each food contains. Unlike the previous examples, this parameter is in two dimensions--it is over both the set of foods and the set of nutrients. This just requires a small change in the code. {{{ #!python -model.nutrient_value=Param(model.nutrients, model.foods) +model.nutrient_value=pyo.Param(model.nutrients, model.foods) }}} The comma indicates that this parameter is over two different sets, and thus is in two dimensions. When we create the data file, we will be able to fill in how much of each nutrient each food contains. -At this point we have defined our sets and parameters. However, we have yet to cosnider the amount of food to be bought and eaten. This is the variable we're trying to solve for, and thus we create an object of the variable class. Since this is just recording how much food to purchase, we create a one dimensional variable over food: +At this point we have defined our sets and parameters. However, we have yet to consider the amount of food to be bought and eaten. This is the variable we're trying to solve for, and thus we create an object of the variable class. Since this is just recording how much food to purchase, we create a one dimensional variable over food: {{{ #!python -model.amount=Var(model.foods, within = NonNegativeReals) +model.amount=pyo.Var(model.foods, within = pyo.NonNegativeReals) }}} We restrict our domain to the non-negative reals. If we accepted negative numbers than the model could tell us to buy negative amounts of food, which is an unrealistic--and thus useless--model. We could further restrict the domain to the integers to make it more realistic, but that would make the problem much harder for little gain: if this model is used on a large scale than the difference between the integer solution and the non-integer solution is often irrelevant. @@ -90,7 +90,7 @@ which will go through and multiply the costs and amounts of each food together a {{{ #!python -model.cost=Objective(rule=costRule +model.cost=pyo.Objective(rule=costRule }}} This line defines the objective of the model as the costRule, which Pyomo interprets as the value it needs to minimize; in this case it will minimize our costs. Also, as a note, we defined the objective as "model.cost" which is not to be confused with the parameter we defined earlier as `"model.costs" despite their similar names. These are two different values and accidentally giving them the same name will cause problems when trying to solve the problem. @@ -102,7 +102,7 @@ We must also create a rule for the volume consumed. The construction of this ru def volumeRule(model): return sum(model.volumes[n]*model.amount[n] for n in model.foods) <= model.max_volume -model.volume = Constraint(rule=volumeRule) +model.volume = pyo.Constraint(rule=volumeRule) }}} Note that here we have a constraint instead of an objective. This requires that the rule returns true, but otherwise the value is irrelevant. While objective looks for the least value, constraints just require that a value works. @@ -116,7 +116,7 @@ def nutrientRule(n, model): for f in model.foods) return (model.min_nutrient[n], value, model.max_nutrient[n]) -model.nutrientConstraint = Constraint(model.nutrients, rule=nutrientRule) +model.nutrientConstraint = pyo.Constraint(model.nutrients, rule=nutrientRule) }}} The rule itself will act much like the previous rules, but by adding an index into the constraint we will cycle through each of the nutrients. Essentially, what we have done is compressed many "nutrient rules," each of which acts the same, into one rule that will look at each nutrient individually. diff --git a/examples/doc/samples/case_studies/disease_est/DiseaseEstimation.py b/examples/doc/samples/case_studies/disease_est/DiseaseEstimation.py index 6a0edb38350..22cf8f185e7 100644 --- a/examples/doc/samples/case_studies/disease_est/DiseaseEstimation.py +++ b/examples/doc/samples/case_studies/disease_est/DiseaseEstimation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,27 +9,27 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.S_SI = Set(ordered=True) +model.S_SI = pyo.Set(ordered=True) -model.P_REP_CASES = Param(model.S_SI) -model.P_POP = Param() +model.P_REP_CASES = pyo.Param(model.S_SI) +model.P_POP = pyo.Param() -model.I = Var(model.S_SI, bounds=(0, model.P_POP), initialize=1) -model.S = Var(model.S_SI, bounds=(0, model.P_POP), initialize=300) -model.beta = Var(bounds=(0.05, 70)) -model.alpha = Var(bounds=(0.5, 1.5)) -model.eps_I = Var(model.S_SI, initialize=0.0) +model.I = pyo.Var(model.S_SI, bounds=(0, model.P_POP), initialize=1) +model.S = pyo.Var(model.S_SI, bounds=(0, model.P_POP), initialize=300) +model.beta = pyo.Var(bounds=(0.05, 70)) +model.alpha = pyo.Var(bounds=(0.5, 1.5)) +model.eps_I = pyo.Var(model.S_SI, initialize=0.0) def _objective(model): return sum((model.eps_I[i]) ** 2 for i in model.S_SI) -model.objective = Objective(rule=_objective, sense=minimize) +model.objective = pyo.Objective(rule=_objective, sense=pyo.minimize) def _InfDynamics(model, i): @@ -41,7 +41,7 @@ def _InfDynamics(model, i): ) -model.InfDynamics = Constraint(model.S_SI, rule=_InfDynamics) +model.InfDynamics = pyo.Constraint(model.S_SI, rule=_InfDynamics) def _SusDynamics(model, i): @@ -49,11 +49,11 @@ def _SusDynamics(model, i): return model.S[i] == model.S[i - 1] - model.I[i] -model.SusDynamics = Constraint(model.S_SI, rule=_SusDynamics) +model.SusDynamics = pyo.Constraint(model.S_SI, rule=_SusDynamics) def _Data(model, i): return model.P_REP_CASES[i] == model.I[i] + model.eps_I[i] -model.Data = Constraint(model.S_SI, rule=_Data) +model.Data = pyo.Constraint(model.S_SI, rule=_Data) diff --git a/examples/doc/samples/case_studies/max_flow/MaxFlow.py b/examples/doc/samples/case_studies/max_flow/MaxFlow.py index 1e75fa4e79d..957b39e8a7e 100644 --- a/examples/doc/samples/case_studies/max_flow/MaxFlow.py +++ b/examples/doc/samples/case_studies/max_flow/MaxFlow.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,18 +9,18 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.nodes = Set() -model.arcs = Set(within=model.nodes * model.nodes) -model.sources = Set(within=model.nodes) -model.sinks = Set(within=model.nodes) -model.upperBound = Param(model.arcs) -model.supply = Param(model.sources) -model.demand = Param(model.sinks) -model.amount = Var(model.arcs, within=NonNegativeReals) +model.nodes = pyo.Set() +model.arcs = pyo.Set(within=model.nodes * model.nodes) +model.sources = pyo.Set(within=model.nodes) +model.sinks = pyo.Set(within=model.nodes) +model.upperBound = pyo.Param(model.arcs) +model.supply = pyo.Param(model.sources) +model.demand = pyo.Param(model.sinks) +model.amount = pyo.Var(model.arcs, within=pyo.NonNegativeReals) def totalRule(model): @@ -28,7 +28,7 @@ def totalRule(model): return expression -model.maxFlow = Objective(rule=totalRule, sense=maximize) +model.maxFlow = pyo.Objective(rule=totalRule, sense=pyo.maximize) def maxRule(model, arcIn, arcOut): @@ -36,7 +36,7 @@ def maxRule(model, arcIn, arcOut): return constraint_equation -model.loadOnArc = Constraint(model.arcs, rule=maxRule) +model.loadOnArc = pyo.Constraint(model.arcs, rule=maxRule) def flowRule(model, node): @@ -56,4 +56,4 @@ def flowRule(model, node): return constraint_equation -model.flow = Constraint(model.nodes, rule=flowRule) +model.flow = pyo.Constraint(model.nodes, rule=flowRule) diff --git a/examples/doc/samples/case_studies/max_flow/MaxFlow.tex b/examples/doc/samples/case_studies/max_flow/MaxFlow.tex index 417b0093652..ad2242e2fa5 100644 --- a/examples/doc/samples/case_studies/max_flow/MaxFlow.tex +++ b/examples/doc/samples/case_studies/max_flow/MaxFlow.tex @@ -25,23 +25,23 @@ \subsection*{Build the model} As always, start by importing Pyomo and creating an object of the class model. \begin{verbatim} -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() \end{verbatim} Now, we must define our sets. The set of locations, which we'll call nodes, is fairly typical. Additionally, we must create a set of arcs. Since each arc is a tuple from the set of nodes crossed with itself, we must define it slightly differently. This is very similar to how we defined the routes in the first network flow problem---in fact, the only difference is the names of the sets. \begin{verbatim} -model.nodes = Set() -model.arcs = Set(within=model.nodes*model.nodes) +model.nodes = pyo.Set() +model.arcs = pyo.Set(within=model.nodes*model.nodes) \end{verbatim} The ``within='' notation implies that we're creating a subset of something else---in this case arcs is a subset of all the tuples that could be generated by the nodes crossed with themselves. This is important since the set of sources and sinks are going to be defined as subsets of the set of nodes. In code, this looks like: \begin{verbatim} -model.sources = Set(within=model.nodes) -model.sinks = Set(within=model.nodes) +model.sources = pyo.Set(within=model.nodes) +model.sinks = pyo.Set(within=model.nodes) \end{verbatim} \noindent @@ -50,25 +50,25 @@ \subsection*{Build the model} We now consider the parameters. Each arc has an upper bound on how many families can travel along it, so we create a parameter over the set of arcs. \begin{verbatim} -model.upperBound = Param(model.arcs) +model.upperBound = pyo.Param(model.arcs) \end{verbatim} We will also create a supply parameter over the set of sources and a demand parameter for the sinks. Since we're just looking for the max supply in this problem, the supply parameter will be arbitrarily large and the demand parameter will be $0$ when we define them in the data file. This way, the program won't accidentally crash with an infeasible model and we can guarantee we achieve the max flow. However, in other cases it might be useful to have an explicit number for the supply at each source or demand at each sink, so we include it to make the model more general. \begin{verbatim} -model.supply = Param(model.sources) -model.demand = Param(model.sinks) +model.supply = pyo.Param(model.sources) +model.demand = pyo.Param(model.sinks) \end{verbatim} Finally, we need to define a variable. Like the previous examples, this variable will be the amount that goes along each arc; in this case it will be how many families travel on each path between the zoo and Home. As usual, we restrict the domain to non negative numbers since, simply put, negative families don't make sense. \begin{verbatim} -model.amount = Var(model.arcs, within=NonNegativeReals) +model.amount = pyo.Var(model.arcs, within=pyo.NonNegativeReals) \end{verbatim} At this point, we must now begin constructing the objective and constraints. The objective of this problem is to maximize the number of people flowing into Home, which is a very different objective than what we've done before; unlike the previous cases, we're not trying to minimize a value. Additionally, in previous examples we would just take the dot product of the cost and amount vectors, which isn't helpful in this case. -Solving these two problems is pretty simple, fortunately. To begin with, to maximize the objective we just insert ``sense=maximize'' into the arguments the objective takes, which will tell the model to try and maximize this value. Similarly, we could have put ``sense=minimize'' in the previous examples, but since the objective naturally tries to minimize the rule it takes in, this would be redundant. +Solving these two problems is pretty simple, fortunately. To begin with, to maximize the objective we just insert ``sense=pyo.maximize'' into the arguments the objective takes, which will tell the model to try and maximize this value. Similarly, we could have put ``sense=pyo.minimize'' in the previous examples, but since the objective naturally tries to minimize the rule it takes in, this would be redundant. The other problem we ran into was how to measure the number of people who arrive at Home. The most logical way to do this is take every arc that ends at home, find the amount of people traveling along it, and sum those values together. For the code, though, we're being a bit more general since in this abstract model there could be multiple sinks (rather than just Home). The logic is still the same: just find the amount traveling on each arc that goes into a sink and add those values together. @@ -81,7 +81,7 @@ \subsection*{Build the model} ) return expression -model.maxFlow = Objective(rule=totalRule, sense=maximize) +model.maxFlow = pyo.Objective(rule=totalRule, sense=pyo.maximize) \end{verbatim} Another important element of this model is the maximum amount that can travel on each route. Without that, the max flow problem would be trivial: the answer is always infinity! To avoid this situation, we need to ensure that the amount traveling along each arc is less than or equal to the upper bound of how much can move along the arc. One part that deserves note is that we give the constraint the set of arcs as an argument, but in defining the rule we give it two arguments: arcIn and arcOut. The reason for this is because the set of arcs is itself a set of tuples, so when we feed it in as an argument we're actually supplying two arguments. The final result will be this: @@ -92,7 +92,7 @@ \subsection*{Build the model} <= model.upperBound[arcIn, arcOut]) return constraint_equation -model.loadOnArc = Constraint(model.arcs, rule=maxRule) +model.loadOnArc = pyo.Constraint(model.arcs, rule=maxRule) \end{verbatim} We have one last rule to consider: the flow rule. The idea behind the flow rule is that something traveling along the network won't just appear or disappear. The amount that flows into a location must be equal to the amount that flows out, with the exception of sinks and sources. It's this last part that makes the constraint complicated since we now have three cases---the node is regular, a source or a sink---that must each be treated slightly differently. @@ -114,7 +114,7 @@ \subsection*{Build the model} return constraint_equation -model.flow = Constraint(model.nodes, rule=flowRule) +model.flow = pyo.Constraint(model.nodes, rule=flowRule) \end{verbatim} @@ -194,7 +194,7 @@ \subsection*{Build the model} return constraint_equation -model.flow = Constraint(model.nodes, rule=flowRule) +model.flow = pyo.Constraint(model.nodes, rule=flowRule) \end{verbatim} Save this model as a .py file before continuing. diff --git a/examples/doc/samples/case_studies/max_flow/README.txt b/examples/doc/samples/case_studies/max_flow/README.txt index 985a702d623..dcd16ba2f14 100644 --- a/examples/doc/samples/case_studies/max_flow/README.txt +++ b/examples/doc/samples/case_studies/max_flow/README.txt @@ -16,25 +16,25 @@ As always, start by importing Pyomo and creating an object of the class model. {{{ #!python -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() }}} Now, we must define our sets. The set of locations, which we'll call nodes, is fairly typical. Additionally, we must create a set of arcs. Since each arc is a tuple from the set of nodes crossed with itself, we must define it slightly differently. This is very similar to how we defined the routes in the first network flow problem--in fact, the only difference is the names of the sets. {{{ #!python -model.nodes = Set() -model.arcs = Set(within=model.nodes*model.nodes) +model.nodes = pyo.Set() +model.arcs = pyo.Set(within=model.nodes*model.nodes) }}} The "within=" notation implies that we're creating a subset of something else--in this case arcs is a subset of all the tuples that could be generated by the nodes crossed with themselves. This is important since the set of sources and sinks are going to be defined as subsets of the set of nodes. In code, this looks like: {{{ #!python -model.sources = Set(within=model.nodes) -model.sinks = Set(within=model.nodes) +model.sources = pyo.Set(within=model.nodes) +model.sinks = pyo.Set(within=model.nodes) }}} While we only have one source and sink in this example, it's often useful to create a more general model than necessary. This same model can be used next week to find the maximum flow between three office building and four residential areas, for example. @@ -42,27 +42,27 @@ We now consider the parameters. Each arc has an upper bound on how many familie {{{ #!python -model.upperBound = Param(model.arcs) +model.upperBound = pyo.Param(model.arcs) }}} We will also create a supply parameter over the set of sources and a demand parameter for the sinks. Since we're just looking for the max supply in this problem, the supply parameter will be arbitrarily large and the demand parameter will be 0 when we define them in the data file. This way, the program won't accidentally crash with an infeasible model and we can guarantee we achieve the max flow. However, in other cases it might be useful to have an explicit number for the supply at each source or demand at each sink, so we include it to make the model more general. {{{ #!python -model.supply = Param(model.sources) -model.demand = Param(model.sinks) +model.supply = pyo.Param(model.sources) +model.demand = pyo.Param(model.sinks) }}} Finally, we need to define a variable. Like the previous examples, this variable will be the amount that goes along each arc; in this case it will be how many families travel on each path between the zoo and Home. As usual, we restrict the domain to non negative numbers since, simply put, negative families don't make sense. {{{ #!python -model.amount = Var(model.arcs, within=NonNegativeReals) +model.amount = pyo.Var(model.arcs, within=pyo.NonNegativeReals) }}} At this point, we must now begin constructing the objective and constraints. The objective of this problem is to maximize the number of people flowing into Home, which is a very different objective than what we've done before; unlike the previous cases, we're not trying to minimize a value. Additionally, in previous examples we would just take the dot product of the cost and amount vectors, which isn't helpful in this case. -Solving these two problems is pretty simple, fortunately. To begin with, to maximize the objective we just insert "sense=maximize" into the arguments the objective takes, which will tell the model to try and maximize this value. Similarly, we could have put "sense=minimize" in the previous examples, but since the objective naturally tries to minimize the rule it takes in, this would be redundant. +Solving these two problems is pretty simple, fortunately. To begin with, to maximize the objective we just insert "sense=pyo.maximize" into the arguments the objective takes, which will tell the model to try and maximize this value. Similarly, we could have put "sense=pyo.minimize" in the previous examples, but since the objective naturally tries to minimize the rule it takes in, this would be redundant. The other problem we ran into was how to measure the number of people who arrive at Home. The most logical way to do this is take every arc that ends at home, find the amount of people traveling along it, and sum those values together. For the code, though, we're being a bit more general since in this abstract model there could be multiple sinks (rather than just Home). The logic is still the same: just find the amount traveling on each arc that goes into a sink and add those values together. @@ -76,7 +76,7 @@ def totalRule(model): ) return expression -model.maxFlow = Objective(rule=totalRule, sense=maximize) +model.maxFlow = pyo.Objective(rule=totalRule, sense=pyo.maximize) }}} Another important element of this model is the maximum amount that can travel on each route. Without that, the max flow problem would be trivial: the answer is always infinity! To avoid this situation, we need to ensure that the amount traveling along each arc is less than or equal to the upper bound of how much can move along the arc. One part that deserves note is that we give the constraint the set of arcs as an argument, but in defining the rule we give it two arguments: arcIn and arcOut. The reason for this is because the set of arcs is itself a set of tuples, so when we feed it in as an argument we're actually supplying two arguments. The final result will be this: @@ -88,7 +88,7 @@ def maxRule(arcIn, arcOut, model): <= model.upperBound[arcIn, arcOut]) return constraint_equation -model.loadOnArc = Constraint(model.arcs, rule=maxRule) +model.loadOnArc = pyo.Constraint(model.arcs, rule=maxRule) }}} We have one last rule to consider: the flow rule. The idea behind the flow rule is that something traveling along the network won't just appear or disappear. The amount that flows into a location must be equal to the amount that flows out, with the exception of sinks and sources. It's this last part that makes the constraint complicated since we now have three cases--the node is regular, a source or a sink--that must each be treated slightly differently. @@ -111,7 +111,7 @@ def flowRule(node, model): return constraint_equation -model.flow = Constraint(model.nodes, rule=flowRule) +model.flow = pyo.Constraint(model.nodes, rule=flowRule) }}} @@ -195,7 +195,7 @@ def flowRule(node, model): return constraint_equation -model.flow = Constraint(model.nodes, rule=flowRule) +model.flow = pyo.Constraint(model.nodes, rule=flowRule) }}} Save this model as a .py file before continuing. diff --git a/examples/doc/samples/case_studies/network_flow/README.txt b/examples/doc/samples/case_studies/network_flow/README.txt index 137138a6fdc..f2038912961 100644 --- a/examples/doc/samples/case_studies/network_flow/README.txt +++ b/examples/doc/samples/case_studies/network_flow/README.txt @@ -14,17 +14,17 @@ We begin this model as we always do {{{ #!python -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() }}} Now we must define our sets. In the transportation problem, we had a set for the warehouses and a set for the stores. In this example, we're going to be more sophisticated: we're going to just have one set for all the locations. Additionally, we're going to have one set that holds all of the different routes between these locations. This construction will help later when creating the constraints and is a more typical model for a network flow problem: we care about the nodes and arcs, so it makes sense to make those the sets. The code is a little different than what we've done before. {{{ #!python -model.places = Set() -model.routes = Set(within=model.places*model.places) +model.places = pyo.Set() +model.routes = pyo.Set(within=model.places*model.places) }}} While the definition for the set "places" should be familiar, how we defined "routes" is a bit more foreign. By saying @@ -38,25 +38,25 @@ The parameters are fairly straightforward after this point. Each place has an a {{{ #!python -model.supply = Param(model.places) -model.demand = Param(model.places) -model.cost = Param(model.routes) -model.minimum = Param(model.routes) -model.maximum = Param(model.routes) +model.supply = pyo.Param(model.places) +model.demand = pyo.Param(model.places) +model.cost = pyo.Param(model.routes) +model.minimum = pyo.Param(model.routes) +model.maximum = pyo.Param(model.routes) }}} Now to define our variables. We're trying to figure out how much to ship along any route, so creating an amount variable over the set of routes seems logical. As in the previous examples, we restrict it to non-negative numbers as shipping negative shoes is impossible and illogical. {{{ #!python -model.amount = Var(model.routes, within=NonNegativeReals) +model.amount = pyo.Var(model.routes, within=pyo.NonNegativeReals) }}} In the transportation problem, we were lucky and (after we added a bit more supply) the total supply was the same as the total demand. But what if we had more supply than demand? It would be hard for a user to track down where that extra supply is; they would need to carefully look through each warehouse and store, calculating how much is coming in compared to the location's supply and demand to find where the extra supplies went. To make it simpler for the user in this problem to find the extra amounts, we introduce another variable: excess. At the end, when we solve the problem, the solution will also include an "excess" section that tells us how many shoes are left over at each location so we won't lose track of any of our supply. Note that excess is a variable over the locations and shouldn't be negative. From here, the implementation is similar to the amount variable. {{{ #!python -model.excess = Var(model.places, within=NonNegativeReals) +model.excess = pyo.Var(model.places, within=pyo.NonNegativeReals) }}} As in the diet problem and the transportation problem, our goal is to minimize cost. To calculate the total cost, we just take the amount that will travel along each route, multiply it with the cost to travel that route, and sum all of these values together. To code it we input @@ -66,7 +66,7 @@ As in the diet problem and the transportation problem, our goal is to minimize c def costRule(model): return sum(model.cost[n]*model.amount[n] for n in model.routes) -model.costTotal = Objective(rule=costRule) +model.costTotal = pyo.Objective(rule=costRule) }}} Once again, this is just taking the dot product of the cost and amount vectors. Recall the being an objective means that Pyomo will try to minimize this value. Since total cost is our objective, Pyomo will find the least expensive way to ship our goods from the mills to the factories which is exactly what we're looking for. @@ -77,7 +77,7 @@ One of the main factors outlined above was that we ship a minimum amount on each def loadRule(i,j, model): return (model.minimum[i,j], model.amount[i,j], model.maximum[i,j]) -model.loadOnRoad = Constraint(model.routes, rule=loadRule) +model.loadOnRoad = pyo.Constraint(model.routes, rule=loadRule) }}} The second line here indicates that the value of "amount" must be between the minimum and maximum values (inclusive) allowed on that route. Also, by putting routes as one of the arguments to the constraint we will cycle through every possible route when doing this rule. Essentially, this is just the cost rule for each individual route compressed into one constraint. Considering that this one constraint also contains the minimum and maximum load rules, we see that it is very, very powerful. @@ -106,7 +106,7 @@ def supplyDemandRule(nn, model): return input == output -model.supplyDemand = Constraint(model.places, rule=supplyDemandRule) +model.supplyDemand = pyo.Constraint(model.places, rule=supplyDemandRule) }}} What we did was just construct a few additional variables; this wasn't required but makes reading and understanding the code much, much simpler. The variable "amountIn" looks at each route to find ones that end in this location, then adds the amount on each of those routes together to determine how much flows to each location. The "amountOut" variable functions similarly for the flow out. Then we just create an "input" and "output" and ensure they're equal. Like in some of the previous constraints, we feed the set of places into the constraint as an argument so it will index over that set, and thus this rule functions for all the places in our network. diff --git a/examples/doc/samples/case_studies/network_flow/networkFlow1.py b/examples/doc/samples/case_studies/network_flow/networkFlow1.py index eb8c8e48a1a..fbaf1a8242d 100644 --- a/examples/doc/samples/case_studies/network_flow/networkFlow1.py +++ b/examples/doc/samples/case_studies/network_flow/networkFlow1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,33 +9,33 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.places = Set() -model.routes = Set(within=model.places * model.places) -model.supply = Param(model.places) -model.demand = Param(model.places) -model.cost = Param(model.routes) -model.minimum = Param(model.routes) -model.maximum = Param(model.routes) -model.amount = Var(model.routes, within=NonNegativeReals) -model.excess = Var(model.places, within=NonNegativeReals) +model.places = pyo.Set() +model.routes = pyo.Set(within=model.places * model.places) +model.supply = pyo.Param(model.places) +model.demand = pyo.Param(model.places) +model.cost = pyo.Param(model.routes) +model.minimum = pyo.Param(model.routes) +model.maximum = pyo.Param(model.routes) +model.amount = pyo.Var(model.routes, within=pyo.NonNegativeReals) +model.excess = pyo.Var(model.places, within=pyo.NonNegativeReals) def costRule(model): return sum(model.cost[n] * model.amount[n] for n in model.routes) -model.costTotal = Objective(rule=costRule) +model.costTotal = pyo.Objective(rule=costRule) def loadRule(model, i, j): return (model.minimum[i, j], model.amount[i, j], model.maximum[i, j]) -model.loadOnRoad = Constraint(model.routes, rule=loadRule) +model.loadOnRoad = pyo.Constraint(model.routes, rule=loadRule) def supplyDemandRule(model, nn): @@ -48,4 +48,4 @@ def supplyDemandRule(model, nn): return input == output -model.supplyDemand = Constraint(model.places, rule=supplyDemandRule) +model.supplyDemand = pyo.Constraint(model.places, rule=supplyDemandRule) diff --git a/examples/doc/samples/case_studies/network_flow/networkFlow1.tex b/examples/doc/samples/case_studies/network_flow/networkFlow1.tex index 24e2c729374..6dbf14bb21a 100644 --- a/examples/doc/samples/case_studies/network_flow/networkFlow1.tex +++ b/examples/doc/samples/case_studies/network_flow/networkFlow1.tex @@ -85,16 +85,16 @@ \subsection*{Build the model} We begin this model as we always do \begin{verbatim} -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() \end{verbatim} Now we must define our sets. In the transportation problem, we had a set for the warehouses and a set for the stores. In this example, we're going to be more sophisticated: we're going to just have one set for all the locations. Additionally, we're going to have one set that holds all of the different routes between these locations. This construction will help later when creating the constraints and is a more typical model for a network flow problem: we care about the nodes and arcs, so it makes sense to make those the sets. The code is a little different than what we've done before. \begin{verbatim} -model.places = Set() -model.routes = Set(within=model.places*model.places) +model.places = pyo.Set() +model.routes = pyo.Set(within=model.places*model.places) \end{verbatim} While the definition for the set ``places'' should be familiar, how we defined ``routes'' is a bit more foreign. By saying \begin{verbatim}within=model.places*model.places\end{verbatim} we mean that routes is a subset of places crossed with itself. Thus, the set of routes will be a set of tuples, and each part of these tuples will come from the set of places. This is exactly what we want as each route is uniquely defined by its starting and ending place, so if we have a route (A,B) that just means we can ship grain from place A to place B. Note, though, that this does not mean we can ship from B to A, that's a different tuple, (B,A). @@ -102,23 +102,23 @@ \subsection*{Build the model} The parameters are fairly straightforward after this point. Each place has an associated supply and demand (either or both of which could be $0$), and each route has a minimum and maximum amount that it can be used. Also, each route has an associated cost with it. All of these are parameters over one set so the implementation should be routine at this point. \begin{verbatim} -model.supply = Param(model.places) -model.demand = Param(model.places) -model.cost = Param(model.routes) -model.minimum = Param(model.routes) -model.maximum = Param(model.routes) +model.supply = pyo.Param(model.places) +model.demand = pyo.Param(model.places) +model.cost = pyo.Param(model.routes) +model.minimum = pyo.Param(model.routes) +model.maximum = pyo.Param(model.routes) \end{verbatim} Now to define our variables. We're trying to figure out how much to ship along any route, so creating an amount variable over the set of routes seems logical. As in the previous examples, we restrict it to non-negative numbers as shipping negative amounts of grain is impossible and illogical. \begin{verbatim} -model.amount = Var(model.routes, within=NonNegativeReals) +model.amount = pyo.Var(model.routes, within=pyo.NonNegativeReals) \end{verbatim} In the transportation problem, we were lucky and (after we added a bit more supply) the total supply was the same as the total demand. But what if we have more supply than demand? It would be hard for a user to track down where that extra supply is; they would need to carefully look through each warehouse and store, calculating how much is coming in compared to the location's supply and demand to find where the extra supplies went. To make it simpler for the user to find the extra amounts, we introduce another variable: excess. At the end, when we solve the problem, the solution will also include an ``excess'' section that tells us how much grain is left over at each location so we won't lose track of any of our supply, but instead easily and quickly know where it is. Note that excess is a variable over the locations and shouldn't be negative. From here, the implementation is similar to the amount variable. \begin{verbatim} -model.excess = Var(model.places, within=NonNegativeReals) +model.excess = pyo.Var(model.places, within=pyo.NonNegativeReals) \end{verbatim} As in the diet problem and the transportation problem, our goal is to minimize cost. To calculate the total cost, we just take the amount that will travel along each route, multiply it with the cost to travel that route, and sum all of these values together. To code it we input @@ -127,7 +127,7 @@ \subsection*{Build the model} def costRule(model): return sum(model.cost[n]*model.amount[n] for n in model.routes) -model.costTotal = Objective(rule=costRule) +model.costTotal = pyo.Objective(rule=costRule) \end{verbatim} \noindent @@ -140,7 +140,7 @@ \subsection*{Build the model} return (model.minimum[i,j], model.amount[i,j], model.maximum[i,j]) -model.loadOnRoad = Constraint(model.routes, rule=loadRule) +model.loadOnRoad = pyo.Constraint(model.routes, rule=loadRule) \end{verbatim} The second line here indicates that the value of amount must be between the minimum and maximum values (inclusive) allowed on that route. Also, but putting routes as one of the arguments to the constraint we will cycle through every possible route when doing this rule. Essentially, this is just the cost rule for each individual route compressed into one constraint. Considering that this one constraint also contains the minimum and maximum load rules, we see that it is very, very useful. @@ -169,7 +169,7 @@ \subsection*{Build the model} return input == output -model.supplyDemand = Constraint(model.places, rule=supplyDemandRule) +model.supplyDemand = pyo.Constraint(model.places, rule=supplyDemandRule) \end{verbatim} What we did was just construct a few additional variables; this wasn't required but makes reading and understanding the code much, much simpler. The variable ``amountIn'' looks at each route to find ones that end in this location, then adds the amount on each of those routes together to determine how much flows to each location. The ``amountOut'' variable functions similarly for the flow out. Then we just create an ``input'' and ``output'' and ensure they're equal. As in some of the previous constraints, we feed the set of places into the constraint as an argument so it will index over that set, and thus this rule functions for all the places in our network. diff --git a/examples/doc/samples/case_studies/rosen/Rosenbrock.py b/examples/doc/samples/case_studies/rosen/Rosenbrock.py index 51e7d51b57d..e9d06f0b360 100644 --- a/examples/doc/samples/case_studies/rosen/Rosenbrock.py +++ b/examples/doc/samples/case_studies/rosen/Rosenbrock.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,13 +10,13 @@ # ___________________________________________________________________________ # @intro: -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # @:intro # @vars: -model.x = Var(initialize=1.5) -model.y = Var(initialize=1.5) +model.x = pyo.Var(initialize=1.5) +model.y = pyo.Var(initialize=1.5) # @:vars @@ -25,5 +25,5 @@ def rosenbrock(amodel): return (1.0 - amodel.x) ** 2 + 100.0 * (amodel.y - amodel.x**2) ** 2 -model.obj = Objective(rule=rosenbrock, sense=minimize) +model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) # @:obj diff --git a/examples/doc/samples/case_studies/transportation/README.txt b/examples/doc/samples/case_studies/transportation/README.txt index 8087666b83b..12c94a3059f 100644 --- a/examples/doc/samples/case_studies/transportation/README.txt +++ b/examples/doc/samples/case_studies/transportation/README.txt @@ -8,26 +8,26 @@ We begin by importing the Pyomo package and creating an object of the type Model {{{ #!python -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() }}} The next step is to define our sets that are being acted upon. The warehouses have a supply and the stores have demand, so those two make sense as sets (with supply and demand parameters that rely on them). Each route will have an associated cost, so we could create a set of routes. However, it's more efficient (and easier to read) to make the costs a parameter over two set, the warehouses and the stores, so we will only need to define those two sets. {{{ #!python -model.warehouses = Set() -model.stores = Set() +model.warehouses = pyo.Set() +model.stores = pyo.Set() }}} As outlined above, we also have several parameters to take into account: supply, demand and costs We input these similar to how we did it in the diet problem. {{{ #!python -model.supply = Param(model.warehouses) -model.demand = Param(model.stores) -model.costs = Param(model.warehouses, model.stores) +model.supply = pyo.Param(model.warehouses) +model.demand = pyo.Param(model.stores) +model.costs = pyo.Param(model.warehouses, model.stores) }}} Note once again that the costs are a parameter of two dimensions since it takes in two arguments. @@ -35,7 +35,7 @@ We have one final addition to make: the variable to solve for. We're looking fo {{{ #!python -model.amounts = Var(model.warehouses, model.stores, within = NonNegativeReals) +model.amounts = pyo.Var(model.warehouses, model.stores, within = pyo.NonNegativeReals) }}} We restrict our solutions to be the non-negative reals as otherwise the solution is likely to be illogical and useless---it could involve shipping negative amounts of supplies to gain money, for example. @@ -50,7 +50,7 @@ def costRule(model): for i in model.stores ) -model.cost=Objective(rule=costRule) +model.cost=pyo.Objective(rule=costRule) }}} It's worth looking at what's happning here a bit more in-depth. The line {{{ @@ -66,7 +66,7 @@ Now we must construct our constraints. In this example we only have two, and th def minDemandRule(store, model): return sum(model.amounts[i, store] for i in model.warehouses) >= model.demand[store] -model.demandConstraint = Constraint(model.stores, +model.demandConstraint = pyo.Constraint(model.stores, rule=minDemandRule) }}} By inputting the set "stores" into the constraint, it tells the constraint to run this rule for each element of "stores." What will happen is the rule will look at each store individually to insure the amount flowing into it exceeds the store's demands. We construct the supply rule in a similar fashion. @@ -76,7 +76,7 @@ By inputting the set "stores" into the constraint, it tells the constraint to ru def maxSupplyRule(warehouse, model): return sum(model.amounts[warehouse, j] for j in model.stores) <= model.supply[warehouse] -model.supplyConstraint = Constraint(model.warehouses, rule=maxSupplyRule) +model.supplyConstraint = pyo.Constraint(model.warehouses, rule=maxSupplyRule) }}} We have now finished constructing our transportation model, which we save as transportation.py. (That period is the end of a sentence, not part of the file name). The next step is to create a data file. diff --git a/examples/doc/samples/case_studies/transportation/transportation.py b/examples/doc/samples/case_studies/transportation/transportation.py index 588ae764953..bc9f89f18b6 100644 --- a/examples/doc/samples/case_studies/transportation/transportation.py +++ b/examples/doc/samples/case_studies/transportation/transportation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.warehouses = Set() -model.stores = Set() -model.supply = Param(model.warehouses) -model.demand = Param(model.stores) -model.costs = Param(model.warehouses, model.stores) -model.amounts = Var(model.warehouses, model.stores, within=NonNegativeReals) +model.warehouses = pyo.Set() +model.stores = pyo.Set() +model.supply = pyo.Param(model.warehouses) +model.demand = pyo.Param(model.stores) +model.costs = pyo.Param(model.warehouses, model.stores) +model.amounts = pyo.Var(model.warehouses, model.stores, within=pyo.NonNegativeReals) def costRule(model): @@ -29,14 +29,14 @@ def costRule(model): ) -model.cost = Objective(rule=costRule) +model.cost = pyo.Objective(rule=costRule) def minDemandRule(model, store): return sum(model.amounts[i, store] for i in model.warehouses) >= model.demand[store] -model.demandConstraint = Constraint(model.stores, rule=minDemandRule) +model.demandConstraint = pyo.Constraint(model.stores, rule=minDemandRule) def maxSupplyRule(model, warehouse): @@ -46,4 +46,4 @@ def maxSupplyRule(model, warehouse): ) -model.supplyConstraint = Constraint(model.warehouses, rule=maxSupplyRule) +model.supplyConstraint = pyo.Constraint(model.warehouses, rule=maxSupplyRule) diff --git a/examples/doc/samples/case_studies/transportation/transportationProblem.tex b/examples/doc/samples/case_studies/transportation/transportationProblem.tex index ca3caf2d751..b2c5565ddfd 100644 --- a/examples/doc/samples/case_studies/transportation/transportationProblem.tex +++ b/examples/doc/samples/case_studies/transportation/transportationProblem.tex @@ -10,24 +10,24 @@ \subsection*{Build the model} We begin by importing the Pyomo package and creating an object of the type Model, which we call model. \begin{verbatim} -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() \end{verbatim} The next step is to define our sets that are being acted upon. The warehouses have a supply and the stores have demand, so those two make sense as sets (with supply and demand parameters that rely on them). Each route will have an associated cost, so we could create a set of routes. However, it's more efficient (and easier to read) to make the costs a parameter over two set, the warehouses and the stores, so we will only need to define those two sets. \begin{verbatim} -model.warehouses = Set() -model.stores = Set() +model.warehouses = pyo.Set() +model.stores = pyo.Set() \end{verbatim} As outlined above, we also have several parameters to take into account: supply, demand and costs We input these similar to how we did it in the diet problem. \begin{verbatim} -model.supply = Param(model.warehouses) -model.demand = Param(model.stores) -model.costs = Param(model.warehouses, model.stores) +model.supply = pyo.Param(model.warehouses) +model.demand = pyo.Param(model.stores) +model.costs = pyo.Param(model.warehouses, model.stores) \end{verbatim} \noindent @@ -36,8 +36,8 @@ \subsection*{Build the model} We have one final addition to make: the variable to solve for. We're looking for the amount of goods to be sent from each warehouse to each store, so we create a variable over the set of warehouses and the set of stores that determines how much will be shipped from each warehouse to each store. This is slightly different than in the diet problem, where our variable was just over one set, but the difference is minor in the implementation. \begin{verbatim} -model.amounts = Var(model.warehouses, model.stores, - within = NonNegativeReals) +model.amounts = pyo.Var(model.warehouses, model.stores, + within = pyo.NonNegativeReals) \end{verbatim} \noindent @@ -53,7 +53,7 @@ \subsection*{Build the model} for i in model.stores ) -model.cost=Objective(rule=costRule) +model.cost=pyo.Objective(rule=costRule) \end{verbatim} \noindent @@ -66,7 +66,7 @@ \subsection*{Build the model} return sum(model.amounts[i, store] for i in model.warehouses) >= model.demand[store] -model.demandConstraint = Constraint(model.stores, +model.demandConstraint = pyo.Constraint(model.stores, rule=minDemandRule) \end{verbatim} @@ -78,7 +78,7 @@ \subsection*{Build the model} return sum(model.amounts[warehouse, j] for j in model.stores) <= model.supply[warehouse] -model.supplyConstraint = Constraint(model.warehouses, +model.supplyConstraint = pyo.Constraint(model.warehouses, rule=maxSupplyRule) \end{verbatim} diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_cplex.py b/examples/doc/samples/comparisons/cutstock/cutstock_cplex.py index f49c5b591ae..ca8442822a2 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_cplex.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_cplex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,16 @@ # ___________________________________________________________________________ import cplex -from cutstock_util import * +from cutstock_util import ( + getCutCount, + getPatCount, + getCuts, + getPatterns, + getSheetsAvail, + getCutDemand, + getPriceSheetData, + getCutsInPattern, +) from cplex.exceptions import CplexSolverError # Reading in Data using the cutstock_util diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_grb.py b/examples/doc/samples/comparisons/cutstock/cutstock_grb.py index 483d84b02e6..60186e69969 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_grb.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_grb.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,16 @@ # ___________________________________________________________________________ from gurobipy import * -from cutstock_util import * +from cutstock_util import ( + getCutCount, + getPatCount, + getCuts, + getPatterns, + getSheetsAvail, + getCutDemand, + getPriceSheetData, + getCutsInPattern, +) # Reading in Data using the cutstock_util cutcount = getCutCount() diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_lpsolve.py b/examples/doc/samples/comparisons/cutstock/cutstock_lpsolve.py index 9a6c8301e8f..6cf12b16b40 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_lpsolve.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_lpsolve.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,17 @@ # ___________________________________________________________________________ from lpsolve55 import * -from cutstock_util import * +from cutstock_util import ( + getCutCount, + getPatCount, + getCuts, + getPatterns, + getSheetsAvail, + getCutDemand, + getPriceSheetData, + getCutsInPattern, +) + # Reading in Data using the cutstock_util cutcount = getCutCount() diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_pulpor.py b/examples/doc/samples/comparisons/cutstock/cutstock_pulpor.py index d14b0fe46c1..25144b29e7d 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_pulpor.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_pulpor.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,17 @@ # ___________________________________________________________________________ from pulp import * -from cutstock_util import * +from cutstock_util import ( + getCutCount, + getPatCount, + getCuts, + getPatterns, + getSheetsAvail, + getCutDemand, + getPriceSheetData, + getCutsInPattern, +) + # Reading in Data using the cutstock_util cutcount = getCutCount() diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_pyomo.py b/examples/doc/samples/comparisons/cutstock/cutstock_pyomo.py index 48d7e6b26fd..d8768f24e12 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_pyomo.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_pyomo.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * -import pyomo.opt -from cutstock_util import * +import pyomo.environ as pyo +from cutstock_util import ( + getCutCount, + getPatCount, + getCuts, + getPatterns, + getSheetsAvail, + getCutDemand, + getPriceSheetData, + getCutsInPattern, +) # Reading in Data using the cutstock_util cutcount = getCutCount() @@ -37,22 +45,22 @@ tmp[Cuts[i]] = CutDemand[i] CutDemand = tmp -model = ConcreteModel(name="CutStock Problem") +model = pyo.ConcreteModel(name="CutStock Problem") # Defining Variables -model.SheetsCut = Var() -model.TotalCost = Var() -model.PatternCount = Var(Patterns, bounds=(0, None)) -model.ExcessCuts = Var(Cuts, bounds=(0, None)) +model.SheetsCut = pyo.Var() +model.TotalCost = pyo.Var() +model.PatternCount = pyo.Var(Patterns, bounds=(0, None)) +model.ExcessCuts = pyo.Var(Cuts, bounds=(0, None)) # objective -model.objective = Objective(expr=1.0 * model.TotalCost) +model.objective = pyo.Objective(expr=1.0 * model.TotalCost) # Constraints -model.TotCost = Constraint(expr=model.TotalCost == PriceSheet * model.SheetsCut) -model.RawAvail = Constraint(expr=model.SheetsCut <= SheetsAvail) -model.Sheets = Constraint(expr=summation(model.PatternCount) == model.SheetsCut) -model.CutReq = Constraint(Cuts) +model.TotCost = pyo.Constraint(expr=model.TotalCost == PriceSheet * model.SheetsCut) +model.RawAvail = pyo.Constraint(expr=model.SheetsCut <= SheetsAvail) +model.Sheets = pyo.Constraint(expr=pyo.summation(model.PatternCount) == model.SheetsCut) +model.CutReq = pyo.Constraint(Cuts) for c in Cuts: model.CutReq.add( c, @@ -61,13 +69,13 @@ ) instance = model.create() -opt = pyomo.opt.SolverFactory('glpk') +opt = pyo.SolverFactory('glpk') results = opt.solve(instance) instance.load(results) print("Status:", results.solver.status) -print("Minimum total cost:", value(instance.objective)) +print("Minimum total cost:", pyo.value(instance.objective)) for v in instance.variables(): var = instance.variable(v) - if value(var) > 0: - print(v, "=", value(var)) + if pyo.value(var) > 0: + print(v, "=", pyo.value(var)) diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_util.py b/examples/doc/samples/comparisons/cutstock/cutstock_util.py index da5349ec06c..0676f1884ce 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_util.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/doc/samples/comparisons/sched/pyomo/sched.py b/examples/doc/samples/comparisons/sched/pyomo/sched.py index cf781713641..ce588281eef 100644 --- a/examples/doc/samples/comparisons/sched/pyomo/sched.py +++ b/examples/doc/samples/comparisons/sched/pyomo/sched.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,78 +9,78 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.TASKS = Set() -model.PEOPLE = Set() -model.SLOTS = Set() +model.TASKS = pyo.Set() +model.PEOPLE = pyo.Set() +model.SLOTS = pyo.Set() -model.amt = Param(model.TASKS, model.PEOPLE, within=NonNegativeReals) -model.nrooms = Param(model.SLOTS, within=NonNegativeReals) -model.ntasks = Param(model.TASKS, within=NonNegativeReals) -model.minp = Param(model.TASKS, within=NonNegativeReals) +model.amt = pyo.Param(model.TASKS, model.PEOPLE, within=pyo.NonNegativeReals) +model.nrooms = pyo.Param(model.SLOTS, within=pyo.NonNegativeReals) +model.ntasks = pyo.Param(model.TASKS, within=pyo.NonNegativeReals) +model.minp = pyo.Param(model.TASKS, within=pyo.NonNegativeReals) def maxp_valid(value, i, Model): return Model.maxp[i] >= Model.minp[i] -model.maxp = Param(model.TASKS, validate=maxp_valid) +model.maxp = pyo.Param(model.TASKS, validate=maxp_valid) -model.x = Var(model.TASKS, model.PEOPLE, model.SLOTS, within=Binary) -model.xts = Var(model.TASKS, model.SLOTS, within=Binary) -model.xtp = Var(model.TASKS, model.PEOPLE, within=Binary) +model.x = pyo.Var(model.TASKS, model.PEOPLE, model.SLOTS, within=pyo.Binary) +model.xts = pyo.Var(model.TASKS, model.SLOTS, within=pyo.Binary) +model.xtp = pyo.Var(model.TASKS, model.PEOPLE, within=pyo.Binary) def rule1_rule(t, s, Model): return sum(Model.x[t, p, s] for p in Model.PEOPLE) >= Model.xts[t, s] -model.rule1 = Constraint(model.TASKS, model.SLOTS) +model.rule1 = pyo.Constraint(model.TASKS, model.SLOTS) def rule2_rule(t, p, s, Model): return Model.x[t, p, s] <= Model.xts[t, s] -model.rule2 = Constraint(model.TASKS, model.PEOPLE, model.SLOTS) +model.rule2 = pyo.Constraint(model.TASKS, model.PEOPLE, model.SLOTS) def rule3_rule(t, p, Model): return sum(Model.x[t, p, s] for s in Model.SLOTS) == Model.xtp[t, p] -model.rule3 = Constraint(model.TASKS, model.PEOPLE) +model.rule3 = pyo.Constraint(model.TASKS, model.PEOPLE) def rule4_rule(t, Model): return sum(Model.xts[t, s] for s in Model.SLOTS) == Model.ntasks[t] -model.rule4 = Constraint(model.TASKS) +model.rule4 = pyo.Constraint(model.TASKS) def rule5_rule(t, Model): return Model.minp[t] <= sum(Model.xtp[t, p] for p in Model.PEOPLE) <= Model.maxp[t] -model.rule5 = Constraint(model.TASKS) +model.rule5 = pyo.Constraint(model.TASKS) def rule6_rule(s, Model): return sum(Model.xts[t, s] for t in Model.TASKS) <= Model.nrooms[s] -model.rule6 = Constraint(model.SLOTS) +model.rule6 = pyo.Constraint(model.SLOTS) def rule7_rule(p, s, Model): return sum(Model.x[t, p, s] for t in Model.TASKS) == 1 -model.rule7 = Constraint(model.PEOPLE, model.SLOTS) +model.rule7 = pyo.Constraint(model.PEOPLE, model.SLOTS) def z_rule(Model): @@ -89,4 +89,4 @@ def z_rule(Model): ) -model.z = Objective(sense=maximize) +model.z = pyo.Objective(sense=pyo.maximize) diff --git a/examples/doc/samples/scripts/__init__.py b/examples/doc/samples/scripts/__init__.py index 0110902b288..af79da12d7f 100644 --- a/examples/doc/samples/scripts/__init__.py +++ b/examples/doc/samples/scripts/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/doc/samples/scripts/s1/knapsack.py b/examples/doc/samples/scripts/s1/knapsack.py index cee3937b668..989e61cd472 100644 --- a/examples/doc/samples/scripts/s1/knapsack.py +++ b/examples/doc/samples/scripts/s1/knapsack.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,31 +9,31 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.ITEMS = Set() +model.ITEMS = pyo.Set() -model.v = Param(model.ITEMS, within=PositiveReals) +model.v = pyo.Param(model.ITEMS, within=pyo.PositiveReals) -model.w = Param(model.ITEMS, within=PositiveReals) +model.w = pyo.Param(model.ITEMS, within=pyo.PositiveReals) -model.limit = Param(within=PositiveReals) +model.limit = pyo.Param(within=pyo.PositiveReals) -model.x = Var(model.ITEMS, within=Binary) +model.x = pyo.Var(model.ITEMS, within=pyo.Binary) def value_rule(model): return sum(model.v[i] * model.x[i] for i in model.ITEMS) -model.value = Objective(sense=maximize, rule=value_rule) +model.value = pyo.Objective(sense=pyo.maximize, rule=value_rule) def weight_rule(model): return sum(model.w[i] * model.x[i] for i in model.ITEMS) <= model.limit -model.weight = Constraint(rule=weight_rule) +model.weight = pyo.Constraint(rule=weight_rule) diff --git a/examples/doc/samples/scripts/s1/script.py b/examples/doc/samples/scripts/s1/script.py index 4ddaea45e19..305e2407033 100644 --- a/examples/doc/samples/scripts/s1/script.py +++ b/examples/doc/samples/scripts/s1/script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * -import pyomo.opt -import pyomo.environ +import pyomo.environ as pyo # # Import model @@ -22,7 +20,7 @@ instance = knapsack.model.create_instance("knapsack.dat") # # Setup the optimizer -opt = pyomo.opt.SolverFactory("glpk") +opt = pyo.SolverFactory("glpk") # # Optimize results = opt.solve(instance, symbolic_solver_labels=True) diff --git a/examples/doc/samples/scripts/s2/knapsack.py b/examples/doc/samples/scripts/s2/knapsack.py index 3131cee7bc5..fc8d17ac73a 100644 --- a/examples/doc/samples/scripts/s2/knapsack.py +++ b/examples/doc/samples/scripts/s2/knapsack.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,34 +9,34 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.ITEMS = Set() +model.ITEMS = pyo.Set() -model.v = Param(model.ITEMS, within=PositiveReals) +model.v = pyo.Param(model.ITEMS, within=pyo.PositiveReals) -model.w = Param(model.ITEMS, within=PositiveReals) +model.w = pyo.Param(model.ITEMS, within=pyo.PositiveReals) -model.limit = Param(within=PositiveReals) +model.limit = pyo.Param(within=pyo.PositiveReals) -model.x = Var(model.ITEMS, within=PercentFraction) +model.x = pyo.Var(model.ITEMS, within=pyo.PercentFraction) def value_rule(model): return sum(model.v[i] * model.x[i] for i in model.ITEMS) -model.value = Objective(sense=maximize, rule=value_rule) +model.value = pyo.Objective(sense=pyo.maximize, rule=value_rule) def weight_rule(model): return sum(model.w[i] * model.x[i] for i in model.ITEMS) <= model.limit -model.weight = Constraint(rule=weight_rule) +model.weight = pyo.Constraint(rule=weight_rule) # This constraint is not active, to illustrate how zero dual values are @@ -45,4 +45,4 @@ def W_rule(model): return sum(model.w[i] * model.x[i] for i in model.ITEMS) <= 2 * model.limit -model.W = Constraint(rule=W_rule) +model.W = pyo.Constraint(rule=W_rule) diff --git a/examples/doc/samples/scripts/s2/script.py b/examples/doc/samples/scripts/s2/script.py index fe97d6ab8fd..6f1e03ade79 100644 --- a/examples/doc/samples/scripts/s2/script.py +++ b/examples/doc/samples/scripts/s2/script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * -import pyomo.opt -import pyomo.environ +from pyomo.environ import SolverFactory # # Import model @@ -22,7 +20,7 @@ instance = knapsack.model.create_instance("knapsack.dat") # # Setup the optimizer -opt = pyomo.opt.SolverFactory("glpk") +opt = SolverFactory("glpk") # # Optimize results = opt.solve(instance, suffixes=['.*']) diff --git a/examples/doc/samples/scripts/test_scripts.py b/examples/doc/samples/scripts/test_scripts.py index 691a44aea2d..c36179a77b8 100644 --- a/examples/doc/samples/scripts/test_scripts.py +++ b/examples/doc/samples/scripts/test_scripts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/doc/samples/update.py b/examples/doc/samples/update.py index 8789413303c..e2acb830fab 100644 --- a/examples/doc/samples/update.py +++ b/examples/doc/samples/update.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/batchProcessing.py b/examples/gdp/batchProcessing.py index 9810f5d63f1..b1290c90674 100644 --- a/examples/gdp/batchProcessing.py +++ b/examples/gdp/batchProcessing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,8 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction '''Problem from http://www.minlp.org/library/problem/index.php?i=172&lib=GDP We are minimizing the cost of a design of a plant with parallel processing units and storage tanks @@ -23,10 +23,10 @@ def build_model(): - model = AbstractModel() + model = pyo.AbstractModel() # TODO: it looks like they set a bigM for each j. Which I need to look up how to do... - model.BigM = Suffix(direction=Suffix.LOCAL) + model.BigM = pyo.Suffix(direction=pyo.Suffix.LOCAL) model.BigM[None] = 1000 ## Constants from GAMS @@ -34,13 +34,13 @@ def build_model(): 2 * 5 ) # btw, I know 2*5 is 10... I don't know why it's written this way in GAMS? StorageTankSizeFactorByProd = 3 - MinFlow = -log(10000) - VolumeLB = log(300) - VolumeUB = log(3500) - StorageTankSizeLB = log(100) - StorageTankSizeUB = log(15000) - UnitsInPhaseUB = log(6) - UnitsOutOfPhaseUB = log(6) + MinFlow = -pyo.log(10000) + VolumeLB = pyo.log(300) + VolumeUB = pyo.log(3500) + StorageTankSizeLB = pyo.log(100) + StorageTankSizeUB = pyo.log(15000) + UnitsInPhaseUB = pyo.log(6) + UnitsOutOfPhaseUB = pyo.log(6) # TODO: YOU ARE HERE. YOU HAVEN'T ACTUALLY MADE THESE THE BOUNDS YET, NOR HAVE YOU FIGURED OUT WHOSE # BOUNDS THEY ARE. AND THERE ARE MORE IN GAMS. @@ -48,36 +48,36 @@ def build_model(): # Sets ########## - model.PRODUCTS = Set() - model.STAGES = Set(ordered=True) - model.PARALLELUNITS = Set(ordered=True) + model.PRODUCTS = pyo.Set() + model.STAGES = pyo.Set(ordered=True) + model.PARALLELUNITS = pyo.Set(ordered=True) # TODO: this seems like an over-complicated way to accomplish this task... def filter_out_last(model, j): return j != model.STAGES.last() - model.STAGESExceptLast = Set(initialize=model.STAGES, filter=filter_out_last) + model.STAGESExceptLast = pyo.Set(initialize=model.STAGES, filter=filter_out_last) # TODO: these aren't in the formulation?? - # model.STORAGE_TANKS = Set() + # model.STORAGE_TANKS = pyo.Set() ############### # Parameters ############### - model.HorizonTime = Param() - model.Alpha1 = Param() - model.Alpha2 = Param() - model.Beta1 = Param() - model.Beta2 = Param() + model.HorizonTime = pyo.Param() + model.Alpha1 = pyo.Param() + model.Alpha2 = pyo.Param() + model.Beta1 = pyo.Param() + model.Beta2 = pyo.Param() - model.ProductionAmount = Param(model.PRODUCTS) - model.ProductSizeFactor = Param(model.PRODUCTS, model.STAGES) - model.ProcessingTime = Param(model.PRODUCTS, model.STAGES) + model.ProductionAmount = pyo.Param(model.PRODUCTS) + model.ProductSizeFactor = pyo.Param(model.PRODUCTS, model.STAGES) + model.ProcessingTime = pyo.Param(model.PRODUCTS, model.STAGES) # These are hard-coded in the GAMS file, hence the defaults - model.StorageTankSizeFactor = Param(model.STAGES, default=StorageTankSizeFactor) - model.StorageTankSizeFactorByProd = Param( + model.StorageTankSizeFactor = pyo.Param(model.STAGES, default=StorageTankSizeFactor) + model.StorageTankSizeFactorByProd = pyo.Param( model.PRODUCTS, model.STAGES, default=StorageTankSizeFactorByProd ) @@ -86,17 +86,17 @@ def filter_out_last(model, j): # I made PRODUCTS ordered so I could do this... Is that bad? And it does index # from 1, right? def get_log_coeffs(model, k): - return log(model.PARALLELUNITS.ord(k)) + return pyo.log(model.PARALLELUNITS.ord(k)) - model.LogCoeffs = Param(model.PARALLELUNITS, initialize=get_log_coeffs) + model.LogCoeffs = pyo.Param(model.PARALLELUNITS, initialize=get_log_coeffs) # bounds - model.volumeLB = Param(model.STAGES, default=VolumeLB) - model.volumeUB = Param(model.STAGES, default=VolumeUB) - model.storageTankSizeLB = Param(model.STAGES, default=StorageTankSizeLB) - model.storageTankSizeUB = Param(model.STAGES, default=StorageTankSizeUB) - model.unitsInPhaseUB = Param(model.STAGES, default=UnitsInPhaseUB) - model.unitsOutOfPhaseUB = Param(model.STAGES, default=UnitsOutOfPhaseUB) + model.volumeLB = pyo.Param(model.STAGES, default=VolumeLB) + model.volumeUB = pyo.Param(model.STAGES, default=VolumeUB) + model.storageTankSizeLB = pyo.Param(model.STAGES, default=StorageTankSizeLB) + model.storageTankSizeUB = pyo.Param(model.STAGES, default=StorageTankSizeUB) + model.unitsInPhaseUB = pyo.Param(model.STAGES, default=UnitsInPhaseUB) + model.unitsOutOfPhaseUB = pyo.Param(model.STAGES, default=UnitsOutOfPhaseUB) ################ # Variables @@ -105,20 +105,20 @@ def get_log_coeffs(model, k): # TODO: right now these match the formulation. There are more in GAMS... # unit size of stage j - # model.volume = Var(model.STAGES) + # model.volume = pyo.Var(model.STAGES) # # TODO: GAMS has a batch size indexed just by products that isn't in the formulation... I'm going # # to try to avoid it for the moment... # # batch size of product i at stage j - # model.batchSize = Var(model.PRODUCTS, model.STAGES) + # model.batchSize = pyo.Var(model.PRODUCTS, model.STAGES) # # TODO: this is different in GAMS... They index by stages too? # # cycle time of product i divided by batch size of product i - # model.cycleTime = Var(model.PRODUCTS) + # model.cycleTime = pyo.Var(model.PRODUCTS) # # number of units in parallel out-of-phase (or in phase) at stage j - # model.unitsOutOfPhase = Var(model.STAGES) - # model.unitsInPhase = Var(model.STAGES) + # model.unitsOutOfPhase = pyo.Var(model.STAGES) + # model.unitsInPhase = pyo.Var(model.STAGES) # # TODO: what are we going to do as a boundary condition here? For that last stage? # # size of intermediate storage tank between stage j and j+1 - # model.storageTankSize = Var(model.STAGES) + # model.storageTankSize = pyo.Var(model.STAGES) # variables for convexified problem # TODO: I am beginning to think these are my only variables actually. @@ -127,29 +127,29 @@ def get_log_coeffs(model, k): def get_volume_bounds(model, j): return (model.volumeLB[j], model.volumeUB[j]) - model.volume_log = Var(model.STAGES, bounds=get_volume_bounds) - model.batchSize_log = Var(model.PRODUCTS, model.STAGES) - model.cycleTime_log = Var(model.PRODUCTS) + model.volume_log = pyo.Var(model.STAGES, bounds=get_volume_bounds) + model.batchSize_log = pyo.Var(model.PRODUCTS, model.STAGES) + model.cycleTime_log = pyo.Var(model.PRODUCTS) def get_unitsOutOfPhase_bounds(model, j): return (0, model.unitsOutOfPhaseUB[j]) - model.unitsOutOfPhase_log = Var(model.STAGES, bounds=get_unitsOutOfPhase_bounds) + model.unitsOutOfPhase_log = pyo.Var(model.STAGES, bounds=get_unitsOutOfPhase_bounds) def get_unitsInPhase_bounds(model, j): return (0, model.unitsInPhaseUB[j]) - model.unitsInPhase_log = Var(model.STAGES, bounds=get_unitsInPhase_bounds) + model.unitsInPhase_log = pyo.Var(model.STAGES, bounds=get_unitsInPhase_bounds) def get_storageTankSize_bounds(model, j): return (model.storageTankSizeLB[j], model.storageTankSizeUB[j]) # TODO: these bounds make it infeasible... - model.storageTankSize_log = Var(model.STAGES, bounds=get_storageTankSize_bounds) + model.storageTankSize_log = pyo.Var(model.STAGES, bounds=get_storageTankSize_bounds) # binary variables for deciding number of parallel units in and out of phase - model.outOfPhase = Var(model.STAGES, model.PARALLELUNITS, within=Binary) - model.inPhase = Var(model.STAGES, model.PARALLELUNITS, within=Binary) + model.outOfPhase = pyo.Var(model.STAGES, model.PARALLELUNITS, within=pyo.Binary) + model.inPhase = pyo.Var(model.STAGES, model.PARALLELUNITS, within=pyo.Binary) ############### # Objective @@ -157,18 +157,18 @@ def get_storageTankSize_bounds(model, j): def get_cost_rule(model): return model.Alpha1 * sum( - exp( + pyo.exp( model.unitsInPhase_log[j] + model.unitsOutOfPhase_log[j] + model.Beta1 * model.volume_log[j] ) for j in model.STAGES ) + model.Alpha2 * sum( - exp(model.Beta2 * model.storageTankSize_log[j]) + pyo.exp(model.Beta2 * model.storageTankSize_log[j]) for j in model.STAGESExceptLast ) - model.min_cost = Objective(rule=get_cost_rule) + model.min_cost = pyo.Objective(rule=get_cost_rule) ############## # Constraints @@ -177,34 +177,34 @@ def get_cost_rule(model): def processing_capacity_rule(model, j, i): return ( model.volume_log[j] - >= log(model.ProductSizeFactor[i, j]) + >= pyo.log(model.ProductSizeFactor[i, j]) + model.batchSize_log[i, j] - model.unitsInPhase_log[j] ) - model.processing_capacity = Constraint( + model.processing_capacity = pyo.Constraint( model.STAGES, model.PRODUCTS, rule=processing_capacity_rule ) def processing_time_rule(model, j, i): return ( model.cycleTime_log[i] - >= log(model.ProcessingTime[i, j]) + >= pyo.log(model.ProcessingTime[i, j]) - model.batchSize_log[i, j] - model.unitsOutOfPhase_log[j] ) - model.processing_time = Constraint( + model.processing_time = pyo.Constraint( model.STAGES, model.PRODUCTS, rule=processing_time_rule ) def finish_in_time_rule(model): return model.HorizonTime >= sum( - model.ProductionAmount[i] * exp(model.cycleTime_log[i]) + model.ProductionAmount[i] * pyo.exp(model.cycleTime_log[i]) for i in model.PRODUCTS ) - model.finish_in_time = Constraint(rule=finish_in_time_rule) + model.finish_in_time = pyo.Constraint(rule=finish_in_time_rule) ############### # Disjunctions @@ -216,38 +216,39 @@ def storage_tank_selection_disjunct_rule(disjunct, selectStorageTank, j): def volume_stage_j_rule(disjunct, i): return ( model.storageTankSize_log[j] - >= log(model.StorageTankSizeFactor[j]) + model.batchSize_log[i, j] + >= pyo.log(model.StorageTankSizeFactor[j]) + model.batchSize_log[i, j] ) def volume_stage_jPlus1_rule(disjunct, i): return ( model.storageTankSize_log[j] - >= log(model.StorageTankSizeFactor[j]) + model.batchSize_log[i, j + 1] + >= pyo.log(model.StorageTankSizeFactor[j]) + + model.batchSize_log[i, j + 1] ) def batch_size_rule(disjunct, i): - return inequality( - -log(model.StorageTankSizeFactorByProd[i, j]), + return pyo.inequality( + -pyo.log(model.StorageTankSizeFactorByProd[i, j]), model.batchSize_log[i, j] - model.batchSize_log[i, j + 1], - log(model.StorageTankSizeFactorByProd[i, j]), + pyo.log(model.StorageTankSizeFactorByProd[i, j]), ) def no_batch_rule(disjunct, i): return model.batchSize_log[i, j] - model.batchSize_log[i, j + 1] == 0 if selectStorageTank: - disjunct.volume_stage_j = Constraint( + disjunct.volume_stage_j = pyo.Constraint( model.PRODUCTS, rule=volume_stage_j_rule ) - disjunct.volume_stage_jPlus1 = Constraint( + disjunct.volume_stage_jPlus1 = pyo.Constraint( model.PRODUCTS, rule=volume_stage_jPlus1_rule ) - disjunct.batch_size = Constraint(model.PRODUCTS, rule=batch_size_rule) + disjunct.batch_size = pyo.Constraint(model.PRODUCTS, rule=batch_size_rule) else: # The formulation says 0, but GAMS has this constant. # 04/04: Francisco says volume should be free: - # disjunct.no_volume = Constraint(expr=model.storageTankSize_log[j] == MinFlow) - disjunct.no_batch = Constraint(model.PRODUCTS, rule=no_batch_rule) + # disjunct.no_volume = pyo.Constraint(expr=model.storageTankSize_log[j] == MinFlow) + disjunct.no_batch = pyo.Constraint(model.PRODUCTS, rule=no_batch_rule) model.storage_tank_selection_disjunct = Disjunct( [0, 1], model.STAGESExceptLast, rule=storage_tank_selection_disjunct_rule @@ -270,35 +271,39 @@ def units_out_of_phase_rule(model, j): model.LogCoeffs[k] * model.outOfPhase[j, k] for k in model.PARALLELUNITS ) - model.units_out_of_phase = Constraint(model.STAGES, rule=units_out_of_phase_rule) + model.units_out_of_phase = pyo.Constraint( + model.STAGES, rule=units_out_of_phase_rule + ) def units_in_phase_rule(model, j): return model.unitsInPhase_log[j] == sum( model.LogCoeffs[k] * model.inPhase[j, k] for k in model.PARALLELUNITS ) - model.units_in_phase = Constraint(model.STAGES, rule=units_in_phase_rule) + model.units_in_phase = pyo.Constraint(model.STAGES, rule=units_in_phase_rule) # and since I didn't do the disjunction as a disjunction, we need the XORs: def units_out_of_phase_xor_rule(model, j): return sum(model.outOfPhase[j, k] for k in model.PARALLELUNITS) == 1 - model.units_out_of_phase_xor = Constraint( + model.units_out_of_phase_xor = pyo.Constraint( model.STAGES, rule=units_out_of_phase_xor_rule ) def units_in_phase_xor_rule(model, j): return sum(model.inPhase[j, k] for k in model.PARALLELUNITS) == 1 - model.units_in_phase_xor = Constraint(model.STAGES, rule=units_in_phase_xor_rule) + model.units_in_phase_xor = pyo.Constraint( + model.STAGES, rule=units_in_phase_xor_rule + ) return model if __name__ == "__main__": m = build_model().create_instance('batchProcessing1.dat') - TransformationFactory('gdp.bigm').apply_to(m) - SolverFactory('gams').solve( + pyo.TransformationFactory('gdp.bigm').apply_to(m) + pyo.SolverFactory('gams').solve( m, solver='baron', tee=True, add_options=['option optcr=1e-6;'] ) m.min_cost.display() diff --git a/examples/gdp/circles/circles.py b/examples/gdp/circles/circles.py index a8b7a156fad..207baccca05 100644 --- a/examples/gdp/circles/circles.py +++ b/examples/gdp/circles/circles.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,8 +10,8 @@ # ___________________________________________________________________________ """ -The "circles" GDP example problem originating in Lee and Grossman (2000). The -goal is to choose a point to minimize a convex quadratic function over a set of +The "circles" GDP example problem originating in Lee and Grossman (2000). The +goal is to choose a point to minimize a convex quadratic function over a set of disjoint hyperspheres. """ diff --git a/examples/gdp/constrained_layout/cons_layout_model.py b/examples/gdp/constrained_layout/cons_layout_model.py index d38fd0cc66b..54ac1d989c0 100644 --- a/examples/gdp/constrained_layout/cons_layout_model.py +++ b/examples/gdp/constrained_layout/cons_layout_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/disease_model.py b/examples/gdp/disease_model.py index 498337e35e6..9ea16b4de43 100644 --- a/examples/gdp/disease_model.py +++ b/examples/gdp/disease_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,8 +17,8 @@ # ============================================ # import packages -from pyomo.environ import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction import math @@ -1596,10 +1596,8 @@ def build_model(): 26, ] - # from new_data_set import * - # declare model name - model = ConcreteModel() + model = pyo.ConcreteModel() # declare constants bpy = 26 # biweeks per year @@ -1607,9 +1605,9 @@ def build_model(): bigM = 50.0 # big M for disjunction constraints # declare sets - model.S_meas = RangeSet(1, bpy * years) - model.S_meas_small = RangeSet(1, bpy * years - 1) - model.S_beta = RangeSet(1, bpy) + model.S_meas = pyo.RangeSet(1, bpy * years) + model.S_meas_small = pyo.RangeSet(1, bpy * years - 1) + model.S_beta = pyo.RangeSet(1, bpy) # define variable bounds def _gt_zero(m, i): @@ -1621,22 +1619,22 @@ def _beta_bounds(m): # define variables # log of estimated cases - # model.logI = Var(model.S_meas, bounds=_gt_zero) - model.logI = Var(model.S_meas, bounds=(0.001, 1e7)) + # model.logI = pyo.Var(model.S_meas, bounds=_gt_zero) + model.logI = pyo.Var(model.S_meas, bounds=(0.001, 1e7)) # log of transmission parameter beta - # model.logbeta = Var(model.S_beta, bounds=_gt_zero) - model.logbeta = Var(model.S_beta, bounds=(0.0001, 5)) + # model.logbeta = pyo.Var(model.S_beta, bounds=_gt_zero) + model.logbeta = pyo.Var(model.S_beta, bounds=(0.0001, 5)) # binary variable y over all betas - # model.y = Var(model.S_beta, within=Binary) + # model.y = pyo.Var(model.S_beta, within=Binary) # low value of beta - # model.logbeta_low = Var(bounds=_beta_bounds) - model.logbeta_low = Var(bounds=(0.0001, 5)) + # model.logbeta_low = pyo.Var(bounds=_beta_bounds) + model.logbeta_low = pyo.Var(bounds=(0.0001, 5)) # high value of beta - # model.logbeta_high = Var(bounds=_beta_bounds) - model.logbeta_high = Var(bounds=(0.0001, 5)) + # model.logbeta_high = pyo.Var(bounds=_beta_bounds) + model.logbeta_high = pyo.Var(bounds=(0.0001, 5)) # dummy variables - model.p = Var(model.S_meas, bounds=_gt_zero) - model.n = Var(model.S_meas, bounds=_gt_zero) + model.p = pyo.Var(model.S_meas, bounds=_gt_zero) + model.n = pyo.Var(model.S_meas, bounds=_gt_zero) # define indexed constants @@ -1657,7 +1655,7 @@ def _obj_rule(m): expr = sum(m.p[i] + m.n[i] for i in m.S_meas) return expr - model.obj = Objective(rule=_obj_rule, sense=minimize) + model.obj = pyo.Objective(rule=_obj_rule, sense=pyo.minimize) # define constraints def _logSIR(m, i): @@ -1669,26 +1667,30 @@ def _logSIR(m, i): ) return (0.0, expr) - model.logSIR = Constraint(model.S_meas_small, rule=_logSIR) + model.logSIR = pyo.Constraint(model.S_meas_small, rule=_logSIR) # objective function constraint def _p_n_const(m, i): expr = logIstar[i - 1] - m.logI[i] - m.p[i] + m.n[i] return (0.0, expr) - model.p_n_const = Constraint(model.S_meas, rule=_p_n_const) + model.p_n_const = pyo.Constraint(model.S_meas, rule=_p_n_const) # disjuncts - model.BigM = Suffix() - model.y = RangeSet(0, 1) + model.BigM = pyo.Suffix() + model.y = pyo.RangeSet(0, 1) def _high_low(disjunct, i, y): model = disjunct.model() if y: - disjunct.c = Constraint(expr=model.logbeta_high - model.logbeta[i] == 0.0) + disjunct.c = pyo.Constraint( + expr=model.logbeta_high - model.logbeta[i] == 0.0 + ) else: - disjunct.c = Constraint(expr=model.logbeta[i] - model.logbeta_low == 0.0) + disjunct.c = pyo.Constraint( + expr=model.logbeta[i] - model.logbeta_low == 0.0 + ) model.BigM[disjunct.c] = bigM model.high_low = Disjunct(model.S_beta, model.y, rule=_high_low) @@ -1707,29 +1709,29 @@ def _disj(model, i): def highbeta_L(m,i): expr = m.logbeta[i] - m.logbeta_high + bigM*(1-m.y[i]) return (0.0, expr, None) -model.highbeta_L = Constraint(model.S_beta, rule=highbeta_L) +model.highbeta_L = pyo.Constraint(model.S_beta, rule=highbeta_L) def highbeta_U(m,i): expr = m.logbeta[i] - m.logbeta_high return (None, expr, 0.0) -model.highbeta_U = Constraint(model.S_beta, rule=highbeta_U) +model.highbeta_U = pyo.Constraint(model.S_beta, rule=highbeta_U) # low beta disjuncts def lowbeta_U(m,i): expr = m.logbeta[i] - m.logbeta_low - bigM*(m.y[i]) return (None, expr, 0.0) -model.lowbeta_U = Constraint(model.S_beta, rule=lowbeta_U) +model.lowbeta_U = pyo.Constraint(model.S_beta, rule=lowbeta_U) def lowbeta_L(m,i): expr = m.logbeta[i] - m.logbeta_low return (0.0, expr, None) -model.lowbeta_L = Constraint(model.S_beta, rule=lowbeta_L) +model.lowbeta_L = pyo.Constraint(model.S_beta, rule=lowbeta_L) """ if __name__ == "__main__": m = build_model() - TransformationFactory('gdp.bigm').apply_to(m) - SolverFactory('gams').solve( + pyo.TransformationFactory('gdp.bigm').apply_to(m) + pyo.SolverFactory('gams').solve( m, solver='baron', tee=True, add_options=['option optcr=1e-6;'] ) m.obj.display() diff --git a/examples/gdp/eight_process/eight_proc_logical.py b/examples/gdp/eight_process/eight_proc_logical.py index 4496427d421..08331a08513 100644 --- a/examples/gdp/eight_process/eight_proc_logical.py +++ b/examples/gdp/eight_process/eight_proc_logical.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/eight_process/eight_proc_model.py b/examples/gdp/eight_process/eight_proc_model.py index 41bb6d462f1..349f7fb5298 100644 --- a/examples/gdp/eight_process/eight_proc_model.py +++ b/examples/gdp/eight_process/eight_proc_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/eight_process/eight_proc_verbose_model.py b/examples/gdp/eight_process/eight_proc_verbose_model.py index 1fd68909146..f4150d1f4cc 100644 --- a/examples/gdp/eight_process/eight_proc_verbose_model.py +++ b/examples/gdp/eight_process/eight_proc_verbose_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/farm_layout/farm_layout.py b/examples/gdp/farm_layout/farm_layout.py index 1b232b9cfa6..961c85dc9a5 100644 --- a/examples/gdp/farm_layout/farm_layout.py +++ b/examples/gdp/farm_layout/farm_layout.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,10 +11,10 @@ """ Farm layout example from Sawaya (2006). The goal is to determine optimal placements and dimensions for farm -plots of specified areas to minimize the perimeter of a minimal enclosing fence. This is a GDP problem with -some hyperbolic constraints to establish consistency of areas with length and width. The FLay05 and FLay06 -instances may take some time to solve; the others should be fast. Note that the Sawaya paper contains a -little bit of nonclarity: it references "height" variables which do not exist - we use "length" for the x-axis +plots of specified areas to minimize the perimeter of a minimal enclosing fence. This is a GDP problem with +some hyperbolic constraints to establish consistency of areas with length and width. The FLay05 and FLay06 +instances may take some time to solve; the others should be fast. Note that the Sawaya paper contains a +little bit of nonclarity: it references "height" variables which do not exist - we use "length" for the x-axis and "width" on the y-axis, and it also is unclear on the way the coordinates define the rectangles; we have decided that they are on the bottom-left and adapted the disjunction constraints to match. """ diff --git a/examples/gdp/jobshop-nodisjuncts.py b/examples/gdp/jobshop-nodisjuncts.py index 0cd5b5ab274..96b04170456 100644 --- a/examples/gdp/jobshop-nodisjuncts.py +++ b/examples/gdp/jobshop-nodisjuncts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,8 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunction # # Jobshop example from http://www.gams.com/modlib/libhtml/logmip4.htm @@ -32,29 +32,29 @@ def build_model(): - model = AbstractModel() + model = pyo.AbstractModel() - model.JOBS = Set(ordered=True) - model.STAGES = Set(ordered=True) - model.I_BEFORE_K = RangeSet(0, 1) + model.JOBS = pyo.Set(ordered=True) + model.STAGES = pyo.Set(ordered=True) + model.I_BEFORE_K = pyo.RangeSet(0, 1) # Task durations - model.tau = Param(model.JOBS, model.STAGES, default=0) + model.tau = pyo.Param(model.JOBS, model.STAGES, default=0) # Total Makespan (this will be the objective) - model.ms = Var() + model.ms = pyo.Var() # Start time of each job def t_bounds(model, I): - return (0, sum(value(model.tau[idx]) for idx in model.tau)) + return (0, sum(pyo.value(model.tau[idx]) for idx in model.tau)) - model.t = Var(model.JOBS, within=NonNegativeReals, bounds=t_bounds) + model.t = pyo.Var(model.JOBS, within=pyo.NonNegativeReals, bounds=t_bounds) # Auto-generate the L set (potential collisions between 2 jobs at any stage. def _L_filter(model, I, K, J): return I < K and model.tau[I, J] and model.tau[K, J] - model.L = Set( + model.L = pyo.Set( initialize=model.JOBS * model.JOBS * model.STAGES, dimen=3, filter=_L_filter ) @@ -63,7 +63,7 @@ def _L_filter(model, I, K, J): def _feas(model, I): return model.ms >= model.t[I] + sum(model.tau[I, M] for M in model.STAGES) - model.Feas = Constraint(model.JOBS, rule=_feas) + model.Feas = pyo.Constraint(model.JOBS, rule=_feas) # Define the disjunctions: either job I occurs before K or K before I def _disj(model, I, K, J): @@ -74,5 +74,5 @@ def _disj(model, I, K, J): model.disj = Disjunction(model.L, rule=_disj) # minimize makespan - model.makespan = Objective(expr=model.ms) + model.makespan = pyo.Objective(expr=model.ms) return model diff --git a/examples/gdp/jobshop.py b/examples/gdp/jobshop.py index 7119ee7655c..90abca0cf10 100644 --- a/examples/gdp/jobshop.py +++ b/examples/gdp/jobshop.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,8 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction # # Jobshop example from http://www.gams.com/modlib/libhtml/logmip4.htm @@ -32,29 +32,29 @@ def build_model(): - model = AbstractModel() + model = pyo.AbstractModel() - model.JOBS = Set(ordered=True) - model.STAGES = Set(ordered=True) - model.I_BEFORE_K = RangeSet(0, 1) + model.JOBS = pyo.Set(ordered=True) + model.STAGES = pyo.Set(ordered=True) + model.I_BEFORE_K = pyo.RangeSet(0, 1) # Task durations - model.tau = Param(model.JOBS, model.STAGES, default=0) + model.tau = pyo.Param(model.JOBS, model.STAGES, default=0) # Total Makespan (this will be the objective) - model.ms = Var() + model.ms = pyo.Var() # Start time of each job def t_bounds(model, I): - return (0, sum(value(model.tau[idx]) for idx in model.tau)) + return (0, sum(pyo.value(model.tau[idx]) for idx in model.tau)) - model.t = Var(model.JOBS, within=NonNegativeReals, bounds=t_bounds) + model.t = pyo.Var(model.JOBS, within=pyo.NonNegativeReals, bounds=t_bounds) # Auto-generate the L set (potential collisions between 2 jobs at any stage. def _L_filter(model, I, K, J): return I < K and model.tau[I, J] and model.tau[K, J] - model.L = Set( + model.L = pyo.Set( initialize=model.JOBS * model.JOBS * model.STAGES, dimen=3, filter=_L_filter ) @@ -63,7 +63,7 @@ def _L_filter(model, I, K, J): def _feas(model, I): return model.ms >= model.t[I] + sum(model.tau[I, M] for M in model.STAGES) - model.Feas = Constraint(model.JOBS, rule=_feas) + model.Feas = pyo.Constraint(model.JOBS, rule=_feas) # Disjunctions to prevent clashes at a stage: This creates a set of # disjunct pairs: one if job I occurs before job K and the other if job @@ -73,9 +73,9 @@ def _NoClash(disjunct, I, K, J, IthenK): lhs = model.t[I] + sum([M < J and model.tau[I, M] or 0 for M in model.STAGES]) rhs = model.t[K] + sum([M < J and model.tau[K, M] or 0 for M in model.STAGES]) if IthenK: - disjunct.c = Constraint(expr=lhs + model.tau[I, J] <= rhs) + disjunct.c = pyo.Constraint(expr=lhs + model.tau[I, J] <= rhs) else: - disjunct.c = Constraint(expr=rhs + model.tau[K, J] <= lhs) + disjunct.c = pyo.Constraint(expr=rhs + model.tau[K, J] <= lhs) model.NoClash = Disjunct(model.L, model.I_BEFORE_K, rule=_NoClash) @@ -86,14 +86,14 @@ def _disj(model, I, K, J): model.disj = Disjunction(model.L, rule=_disj) # minimize makespan - model.makespan = Objective(expr=model.ms) + model.makespan = pyo.Objective(expr=model.ms) return model if __name__ == "__main__": m = build_model().create_instance('jobshop-small.dat') - TransformationFactory('gdp.bigm').apply_to(m) - SolverFactory('gams').solve( + pyo.TransformationFactory('gdp.bigm').apply_to(m) + pyo.SolverFactory('gams').solve( m, solver='baron', tee=True, add_options=['option optcr=1e-6;'] ) m.makespan.display() diff --git a/examples/gdp/medTermPurchasing_Literal.py b/examples/gdp/medTermPurchasing_Literal.py index b6d16c216fe..b0d2e69814a 100755 --- a/examples/gdp/medTermPurchasing_Literal.py +++ b/examples/gdp/medTermPurchasing_Literal.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,8 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction # Medium-term Purchasing Contracts problem from http://minlp.org/library/lib.php?lib=GDP # This model maximizes profit in a short-term horizon in which various contracts @@ -31,7 +31,7 @@ def build_model(): - model = AbstractModel() + model = pyo.AbstractModel() # Constants (data that was hard-coded in GAMS model) AMOUNT_UB = 1000 @@ -48,33 +48,33 @@ def build_model(): # T # t in GAMS - model.TimePeriods = Set(ordered=True) + model.TimePeriods = pyo.Set(ordered=True) # Available length contracts # p in GAMS - model.Contracts_Length = Set() + model.Contracts_Length = pyo.Set() # JP # final(j) in GAMS # Finished products - model.Products = Set() + model.Products = pyo.Set() # JM # rawmat(J) in GAMS # Set of Raw Materials-- raw materials, intermediate products, and final products partition J - model.RawMaterials = Set() + model.RawMaterials = pyo.Set() # C # c in GAMS - model.Contracts = Set() + model.Contracts = pyo.Set() # I # i in GAMS - model.Processes = Set() + model.Processes = pyo.Set() # J # j in GAMS - model.Streams = Set() + model.Streams = pyo.Set() ################## # Parameters @@ -82,85 +82,85 @@ def build_model(): # Q_it # excap(i) in GAMS - model.Capacity = Param(model.Processes) + model.Capacity = pyo.Param(model.Processes) # u_ijt # cov(i) in GAMS - model.ProcessConstants = Param(model.Processes) + model.ProcessConstants = pyo.Param(model.Processes) # a_jt^U and d_jt^U # spdm(j,t) in GAMS - model.SupplyAndDemandUBs = Param(model.Streams, model.TimePeriods, default=0) + model.SupplyAndDemandUBs = pyo.Param(model.Streams, model.TimePeriods, default=0) # d_jt^L # lbdm(j, t) in GAMS - model.DemandLB = Param(model.Streams, model.TimePeriods, default=0) + model.DemandLB = pyo.Param(model.Streams, model.TimePeriods, default=0) # delta_it # delta(i, t) in GAMS # operating cost of process i at time t - model.OperatingCosts = Param(model.Processes, model.TimePeriods) + model.OperatingCosts = pyo.Param(model.Processes, model.TimePeriods) # prices of raw materials under FP contract and selling prices of products # pf(j, t) in GAMS # omega_jt and pf_jt - model.Prices = Param(model.Streams, model.TimePeriods, default=0) + model.Prices = pyo.Param(model.Streams, model.TimePeriods, default=0) # Price for quantities less than min amount under discount contract # pd1(j, t) in GAMS - model.RegPrice_Discount = Param(model.Streams, model.TimePeriods) + model.RegPrice_Discount = pyo.Param(model.Streams, model.TimePeriods) # Discounted price for the quantity purchased exceeding the min amount # pd2(j,t0 in GAMS - model.DiscountPrice_Discount = Param(model.Streams, model.TimePeriods) + model.DiscountPrice_Discount = pyo.Param(model.Streams, model.TimePeriods) # Price for quantities below min amount # pb1(j,t) in GAMS - model.RegPrice_Bulk = Param(model.Streams, model.TimePeriods) + model.RegPrice_Bulk = pyo.Param(model.Streams, model.TimePeriods) # Price for quantities above min amount # pb2(j, t) in GAMS - model.DiscountPrice_Bulk = Param(model.Streams, model.TimePeriods) + model.DiscountPrice_Bulk = pyo.Param(model.Streams, model.TimePeriods) # prices with length contract # pl(j, p, t) in GAMS - model.Prices_Length = Param( + model.Prices_Length = pyo.Param( model.Streams, model.Contracts_Length, model.TimePeriods, default=0 ) # sigmad_jt # sigmad(j, t) in GAMS # Minimum quantity of chemical j that must be bought before receiving a Discount under discount contract - model.MinAmount_Discount = Param(model.Streams, model.TimePeriods, default=0) + model.MinAmount_Discount = pyo.Param(model.Streams, model.TimePeriods, default=0) # min quantity to receive discount under bulk contract # sigmab(j, t) in GAMS - model.MinAmount_Bulk = Param(model.Streams, model.TimePeriods, default=0) + model.MinAmount_Bulk = pyo.Param(model.Streams, model.TimePeriods, default=0) # min quantity to receive discount under length contract # sigmal(j, p) in GAMS - model.MinAmount_Length = Param(model.Streams, model.Contracts_Length, default=0) + model.MinAmount_Length = pyo.Param(model.Streams, model.Contracts_Length, default=0) # main products of process i # These are 1 (true) if stream j is the main product of process i, false otherwise. # jm(j, i) in GAMS - model.MainProducts = Param(model.Streams, model.Processes, default=0) + model.MainProducts = pyo.Param(model.Streams, model.Processes, default=0) # theta_jt # psf(j, t) in GAMS # Shortfall penalty of product j at time t - model.ShortfallPenalty = Param(model.Products, model.TimePeriods) + model.ShortfallPenalty = pyo.Param(model.Products, model.TimePeriods) # shortfall upper bound # sfub(j, t) in GAMS - model.ShortfallUB = Param(model.Products, model.TimePeriods, default=0) + model.ShortfallUB = pyo.Param(model.Products, model.TimePeriods, default=0) # epsilon_jt # cinv(j, t) in GAMS # inventory cost of material j at time t - model.InventoryCost = Param(model.Streams, model.TimePeriods) + model.InventoryCost = pyo.Param(model.Streams, model.TimePeriods) # invub(j, t) in GAMS # inventory upper bound - model.InventoryLevelUB = Param(model.Streams, model.TimePeriods, default=0) + model.InventoryLevelUB = pyo.Param(model.Streams, model.TimePeriods, default=0) ## UPPER BOUNDS HARDCODED INTO GAMS MODEL @@ -172,31 +172,33 @@ def getAmountUBs(model, j, t): def getCostUBs(model, j, t): return COST_UB - model.AmountPurchasedUB_FP = Param( + model.AmountPurchasedUB_FP = pyo.Param( model.Streams, model.TimePeriods, initialize=getAmountUBs ) - model.AmountPurchasedUB_Discount = Param( + model.AmountPurchasedUB_Discount = pyo.Param( model.Streams, model.TimePeriods, initialize=getAmountUBs ) - model.AmountPurchasedBelowMinUB_Discount = Param( + model.AmountPurchasedBelowMinUB_Discount = pyo.Param( model.Streams, model.TimePeriods, initialize=getAmountUBs ) - model.AmountPurchasedAboveMinUB_Discount = Param( + model.AmountPurchasedAboveMinUB_Discount = pyo.Param( model.Streams, model.TimePeriods, initialize=getAmountUBs ) - model.AmountPurchasedUB_FD = Param( + model.AmountPurchasedUB_FD = pyo.Param( model.Streams, model.TimePeriods, initialize=getAmountUBs ) - model.AmountPurchasedUB_Bulk = Param( + model.AmountPurchasedUB_Bulk = pyo.Param( model.Streams, model.TimePeriods, initialize=getAmountUBs ) - model.CostUB_FP = Param(model.Streams, model.TimePeriods, initialize=getCostUBs) - model.CostUB_FD = Param(model.Streams, model.TimePeriods, initialize=getCostUBs) - model.CostUB_Discount = Param( + model.CostUB_FP = pyo.Param(model.Streams, model.TimePeriods, initialize=getCostUBs) + model.CostUB_FD = pyo.Param(model.Streams, model.TimePeriods, initialize=getCostUBs) + model.CostUB_Discount = pyo.Param( + model.Streams, model.TimePeriods, initialize=getCostUBs + ) + model.CostUB_Bulk = pyo.Param( model.Streams, model.TimePeriods, initialize=getCostUBs ) - model.CostUB_Bulk = Param(model.Streams, model.TimePeriods, initialize=getCostUBs) #################### # VARIABLES @@ -204,11 +206,13 @@ def getCostUBs(model, j, t): # prof in GAMS # will be objective - model.Profit = Var() + model.Profit = pyo.Var() # f(j, t) in GAMS # mass flow rates in tons per time interval t - model.FlowRate = Var(model.Streams, model.TimePeriods, within=NonNegativeReals) + model.FlowRate = pyo.Var( + model.Streams, model.TimePeriods, within=pyo.NonNegativeReals + ) # V_jt # inv(j, t) in GAMS @@ -216,7 +220,7 @@ def getCostUBs(model, j, t): def getInventoryBounds(model, i, j): return (0, model.InventoryLevelUB[i, j]) - model.InventoryLevel = Var( + model.InventoryLevel = pyo.Var( model.Streams, model.TimePeriods, bounds=getInventoryBounds ) @@ -226,7 +230,9 @@ def getInventoryBounds(model, i, j): def getShortfallBounds(model, i, j): return (0, model.ShortfallUB[i, j]) - model.Shortfall = Var(model.Products, model.TimePeriods, bounds=getShortfallBounds) + model.Shortfall = pyo.Var( + model.Products, model.TimePeriods, bounds=getShortfallBounds + ) # amounts purchased under different contracts @@ -235,7 +241,7 @@ def getShortfallBounds(model, i, j): def get_FP_bounds(model, j, t): return (0, model.AmountPurchasedUB_FP[j, t]) - model.AmountPurchased_FP = Var( + model.AmountPurchased_FP = pyo.Var( model.Streams, model.TimePeriods, bounds=get_FP_bounds ) @@ -243,7 +249,7 @@ def get_FP_bounds(model, j, t): def get_Discount_Total_bounds(model, j, t): return (0, model.AmountPurchasedUB_Discount[j, t]) - model.AmountPurchasedTotal_Discount = Var( + model.AmountPurchasedTotal_Discount = pyo.Var( model.Streams, model.TimePeriods, bounds=get_Discount_Total_bounds ) @@ -252,7 +258,7 @@ def get_Discount_Total_bounds(model, j, t): def get_Discount_BelowMin_bounds(model, j, t): return (0, model.AmountPurchasedBelowMinUB_Discount[j, t]) - model.AmountPurchasedBelowMin_Discount = Var( + model.AmountPurchasedBelowMin_Discount = pyo.Var( model.Streams, model.TimePeriods, bounds=get_Discount_BelowMin_bounds ) @@ -261,7 +267,7 @@ def get_Discount_BelowMin_bounds(model, j, t): def get_Discount_AboveMin_bounds(model, j, t): return (0, model.AmountPurchasedBelowMinUB_Discount[j, t]) - model.AmountPurchasedAboveMin_Discount = Var( + model.AmountPurchasedAboveMin_Discount = pyo.Var( model.Streams, model.TimePeriods, bounds=get_Discount_AboveMin_bounds ) @@ -270,7 +276,7 @@ def get_Discount_AboveMin_bounds(model, j, t): def get_bulk_bounds(model, j, t): return (0, model.AmountPurchasedUB_Bulk[j, t]) - model.AmountPurchased_Bulk = Var( + model.AmountPurchased_Bulk = pyo.Var( model.Streams, model.TimePeriods, bounds=get_bulk_bounds ) @@ -279,7 +285,7 @@ def get_bulk_bounds(model, j, t): def get_FD_bounds(model, j, t): return (0, model.AmountPurchasedUB_FD[j, t]) - model.AmountPurchased_FD = Var( + model.AmountPurchased_FD = pyo.Var( model.Streams, model.TimePeriods, bounds=get_FD_bounds ) @@ -290,21 +296,21 @@ def get_FD_bounds(model, j, t): def get_CostUBs_FD(model, j, t): return (0, model.CostUB_FD[j, t]) - model.Cost_FD = Var(model.Streams, model.TimePeriods, bounds=get_CostUBs_FD) + model.Cost_FD = pyo.Var(model.Streams, model.TimePeriods, bounds=get_CostUBs_FD) # costpf(j, t) in GAMS # cost of fixed duration contract def get_CostUBs_FP(model, j, t): return (0, model.CostUB_FP[j, t]) - model.Cost_FP = Var(model.Streams, model.TimePeriods, bounds=get_CostUBs_FP) + model.Cost_FP = pyo.Var(model.Streams, model.TimePeriods, bounds=get_CostUBs_FP) # costpd(j, t) in GAMS # cost of discount contract def get_CostUBs_Discount(model, j, t): return (0, model.CostUB_Discount[j, t]) - model.Cost_Discount = Var( + model.Cost_Discount = pyo.Var( model.Streams, model.TimePeriods, bounds=get_CostUBs_Discount ) @@ -313,14 +319,18 @@ def get_CostUBs_Discount(model, j, t): def get_CostUBs_Bulk(model, j, t): return (0, model.CostUB_Bulk[j, t]) - model.Cost_Bulk = Var(model.Streams, model.TimePeriods, bounds=get_CostUBs_Bulk) + model.Cost_Bulk = pyo.Var(model.Streams, model.TimePeriods, bounds=get_CostUBs_Bulk) # binary variables - model.BuyFPContract = RangeSet(0, 1) - model.BuyDiscountContract = Set(initialize=('BelowMin', 'AboveMin', 'NotSelected')) - model.BuyBulkContract = Set(initialize=('BelowMin', 'AboveMin', 'NotSelected')) - model.BuyFDContract = Set(initialize=('1Month', '2Month', '3Month', 'NotSelected')) + model.BuyFPContract = pyo.RangeSet(0, 1) + model.BuyDiscountContract = pyo.Set( + initialize=('BelowMin', 'AboveMin', 'NotSelected') + ) + model.BuyBulkContract = pyo.Set(initialize=('BelowMin', 'AboveMin', 'NotSelected')) + model.BuyFDContract = pyo.Set( + initialize=('1Month', '2Month', '3Month', 'NotSelected') + ) ################ # CONSTRAINTS @@ -377,7 +387,7 @@ def profit_rule(model): salesIncome - purchaseCost - productionCost - inventoryCost - shortfallCost ) - model.profit = Objective(rule=profit_rule, sense=maximize) + model.profit = pyo.Objective(rule=profit_rule, sense=pyo.maximize) # flow of raw materials is the total amount purchased (across all contracts) def raw_material_flow_rule(model, j, t): @@ -389,7 +399,7 @@ def raw_material_flow_rule(model, j, t): + model.AmountPurchasedTotal_Discount[j, t] ) - model.raw_material_flow = Constraint( + model.raw_material_flow = pyo.Constraint( model.RawMaterials, model.TimePeriods, rule=raw_material_flow_rule ) @@ -400,7 +410,7 @@ def discount_amount_total_rule(model, j, t): + model.AmountPurchasedAboveMin_Discount[j, t] ) - model.discount_amount_total_rule = Constraint( + model.discount_amount_total_rule = pyo.Constraint( model.RawMaterials, model.TimePeriods, rule=discount_amount_total_rule ) @@ -409,36 +419,40 @@ def discount_amount_total_rule(model, j, t): def mass_balance_rule1(model, t): return model.FlowRate[1, t] == model.FlowRate[2, t] + model.FlowRate[3, t] - model.mass_balance1 = Constraint(model.TimePeriods, rule=mass_balance_rule1) + model.mass_balance1 = pyo.Constraint(model.TimePeriods, rule=mass_balance_rule1) def mass_balance_rule2(model, t): return model.FlowRate[5, t] == model.FlowRate[4, t] + model.FlowRate[8, t] - model.mass_balance2 = Constraint(model.TimePeriods, rule=mass_balance_rule2) + model.mass_balance2 = pyo.Constraint(model.TimePeriods, rule=mass_balance_rule2) def mass_balance_rule3(model, t): return model.FlowRate[6, t] == model.FlowRate[7, t] - model.mass_balance3 = Constraint(model.TimePeriods, rule=mass_balance_rule3) + model.mass_balance3 = pyo.Constraint(model.TimePeriods, rule=mass_balance_rule3) def mass_balance_rule4(model, t): return model.FlowRate[3, t] == 10 * model.FlowRate[5, t] - model.mass_balance4 = Constraint(model.TimePeriods, rule=mass_balance_rule4) + model.mass_balance4 = pyo.Constraint(model.TimePeriods, rule=mass_balance_rule4) # process input/output constraints # these are also totally specific to the process network def process_balance_rule1(model, t): return model.FlowRate[9, t] == model.ProcessConstants[1] * model.FlowRate[2, t] - model.process_balance1 = Constraint(model.TimePeriods, rule=process_balance_rule1) + model.process_balance1 = pyo.Constraint( + model.TimePeriods, rule=process_balance_rule1 + ) def process_balance_rule2(model, t): return model.FlowRate[10, t] == model.ProcessConstants[2] * ( model.FlowRate[5, t] + model.FlowRate[3, t] ) - model.process_balance2 = Constraint(model.TimePeriods, rule=process_balance_rule2) + model.process_balance2 = pyo.Constraint( + model.TimePeriods, rule=process_balance_rule2 + ) def process_balance_rule3(model, t): return ( @@ -446,7 +460,9 @@ def process_balance_rule3(model, t): == RandomConst_Line264 * model.ProcessConstants[3] * model.FlowRate[7, t] ) - model.process_balance3 = Constraint(model.TimePeriods, rule=process_balance_rule3) + model.process_balance3 = pyo.Constraint( + model.TimePeriods, rule=process_balance_rule3 + ) def process_balance_rule4(model, t): return ( @@ -454,24 +470,32 @@ def process_balance_rule4(model, t): == RandomConst_Line265 * model.ProcessConstants[3] * model.FlowRate[7, t] ) - model.process_balance4 = Constraint(model.TimePeriods, rule=process_balance_rule4) + model.process_balance4 = pyo.Constraint( + model.TimePeriods, rule=process_balance_rule4 + ) # process capacity constraints # these are hardcoded based on the three processes and the process flow structure def process_capacity_rule1(model, t): return model.FlowRate[9, t] <= model.Capacity[1] - model.process_capacity1 = Constraint(model.TimePeriods, rule=process_capacity_rule1) + model.process_capacity1 = pyo.Constraint( + model.TimePeriods, rule=process_capacity_rule1 + ) def process_capacity_rule2(model, t): return model.FlowRate[10, t] <= model.Capacity[2] - model.process_capacity2 = Constraint(model.TimePeriods, rule=process_capacity_rule2) + model.process_capacity2 = pyo.Constraint( + model.TimePeriods, rule=process_capacity_rule2 + ) def process_capacity_rule3(model, t): return model.FlowRate[11, t] + model.FlowRate[8, t] <= model.Capacity[3] - model.process_capacity3 = Constraint(model.TimePeriods, rule=process_capacity_rule3) + model.process_capacity3 = pyo.Constraint( + model.TimePeriods, rule=process_capacity_rule3 + ) # Inventory balance of final products # again, these are hardcoded. @@ -483,23 +507,25 @@ def inventory_balance1(model, t): == model.FlowRate[12, t] + model.InventoryLevel[12, t] ) - model.inventory_balance1 = Constraint(model.TimePeriods, rule=inventory_balance1) + model.inventory_balance1 = pyo.Constraint( + model.TimePeriods, rule=inventory_balance1 + ) def inventory_balance_rule2(model, t): if t != 1: - return Constraint.Skip + return pyo.Constraint.Skip return ( model.FlowRate[10, t] + model.FlowRate[11, t] == model.InventoryLevel[13, t] + model.FlowRate[13, t] ) - model.inventory_balance2 = Constraint( + model.inventory_balance2 = pyo.Constraint( model.TimePeriods, rule=inventory_balance_rule2 ) def inventory_balance_rule3(model, t): if t <= 1: - return Constraint.Skip + return pyo.Constraint.Skip return ( model.InventoryLevel[13, t - 1] + model.FlowRate[10, t] @@ -507,7 +533,7 @@ def inventory_balance_rule3(model, t): == model.InventoryLevel[13, t] + model.FlowRate[13, t] ) - model.inventory_balance3 = Constraint( + model.inventory_balance3 = pyo.Constraint( model.TimePeriods, rule=inventory_balance_rule3 ) @@ -515,7 +541,7 @@ def inventory_balance_rule3(model, t): def inventory_capacity_rule(model, j, t): return model.InventoryLevel[j, t] <= model.InventoryLevelUB[j, t] - model.inventory_capacity_rule = Constraint( + model.inventory_capacity_rule = pyo.Constraint( model.Products, model.TimePeriods, rule=inventory_capacity_rule ) @@ -526,13 +552,15 @@ def shortfall_rule(model, j, t): == model.SupplyAndDemandUBs[j, t] - model.FlowRate[j, t] ) - model.shortfall = Constraint(model.Products, model.TimePeriods, rule=shortfall_rule) + model.shortfall = pyo.Constraint( + model.Products, model.TimePeriods, rule=shortfall_rule + ) # maximum shortfall allowed def shortfall_max_rule(model, j, t): return model.Shortfall[j, t] <= model.ShortfallUB[j, t] - model.shortfall_max = Constraint( + model.shortfall_max = pyo.Constraint( model.Products, model.TimePeriods, rule=shortfall_max_rule ) @@ -540,7 +568,7 @@ def shortfall_max_rule(model, j, t): def supplier_capacity_rule(model, j, t): return model.FlowRate[j, t] <= model.SupplyAndDemandUBs[j, t] - model.supplier_capacity = Constraint( + model.supplier_capacity = pyo.Constraint( model.RawMaterials, model.TimePeriods, rule=supplier_capacity_rule ) @@ -548,13 +576,17 @@ def supplier_capacity_rule(model, j, t): def demand_UB_rule(model, j, t): return model.FlowRate[j, t] <= model.SupplyAndDemandUBs[j, t] - model.demand_UB = Constraint(model.Products, model.TimePeriods, rule=demand_UB_rule) + model.demand_UB = pyo.Constraint( + model.Products, model.TimePeriods, rule=demand_UB_rule + ) # demand lower bound def demand_LB_rule(model, j, t): return model.FlowRate[j, t] >= model.DemandLB[j, t] - model.demand_LB = Constraint(model.Products, model.TimePeriods, rule=demand_LB_rule) + model.demand_LB = pyo.Constraint( + model.Products, model.TimePeriods, rule=demand_LB_rule + ) # FIXED PRICE CONTRACT @@ -562,11 +594,11 @@ def demand_LB_rule(model, j, t): def FP_contract_disjunct_rule(disjunct, j, t, buy): model = disjunct.model() if buy: - disjunct.c = Constraint( + disjunct.c = pyo.Constraint( expr=model.AmountPurchased_FP[j, t] <= MAX_AMOUNT_FP ) else: - disjunct.c = Constraint(expr=model.AmountPurchased_FP[j, t] == 0) + disjunct.c = pyo.Constraint(expr=model.AmountPurchased_FP[j, t] == 0) model.FP_contract_disjunct = Disjunct( model.RawMaterials, @@ -589,7 +621,7 @@ def FP_contract_cost_rule(model, j, t): model.Cost_FP[j, t] == model.AmountPurchased_FP[j, t] * model.Prices[j, t] ) - model.FP_contract_cost = Constraint( + model.FP_contract_cost = pyo.Constraint( model.RawMaterials, model.TimePeriods, rule=FP_contract_cost_rule ) @@ -599,26 +631,26 @@ def FP_contract_cost_rule(model, j, t): def discount_contract_disjunct_rule(disjunct, j, t, buy): model = disjunct.model() if buy == 'BelowMin': - disjunct.belowMin = Constraint( + disjunct.belowMin = pyo.Constraint( expr=model.AmountPurchasedBelowMin_Discount[j, t] <= model.MinAmount_Discount[j, t] ) - disjunct.aboveMin = Constraint( + disjunct.aboveMin = pyo.Constraint( expr=model.AmountPurchasedAboveMin_Discount[j, t] == 0 ) elif buy == 'AboveMin': - disjunct.belowMin = Constraint( + disjunct.belowMin = pyo.Constraint( expr=model.AmountPurchasedBelowMin_Discount[j, t] == model.MinAmount_Discount[j, t] ) - disjunct.aboveMin = Constraint( + disjunct.aboveMin = pyo.Constraint( expr=model.AmountPurchasedAboveMin_Discount[j, t] >= 0 ) elif buy == 'NotSelected': - disjunct.belowMin = Constraint( + disjunct.belowMin = pyo.Constraint( expr=model.AmountPurchasedBelowMin_Discount[j, t] == 0 ) - disjunct.aboveMin = Constraint( + disjunct.aboveMin = pyo.Constraint( expr=model.AmountPurchasedAboveMin_Discount[j, t] == 0 ) else: @@ -652,7 +684,7 @@ def discount_cost_rule(model, j, t): * model.AmountPurchasedAboveMin_Discount[j, t] ) - model.discount_cost = Constraint( + model.discount_cost = pyo.Constraint( model.RawMaterials, model.TimePeriods, rule=discount_cost_rule ) @@ -662,24 +694,24 @@ def discount_cost_rule(model, j, t): def bulk_contract_disjunct_rule(disjunct, j, t, buy): model = disjunct.model() if buy == 'BelowMin': - disjunct.amount = Constraint( + disjunct.amount = pyo.Constraint( expr=model.AmountPurchased_Bulk[j, t] <= model.MinAmount_Bulk[j, t] ) - disjunct.price = Constraint( + disjunct.price = pyo.Constraint( expr=model.Cost_Bulk[j, t] == model.RegPrice_Bulk[j, t] * model.AmountPurchased_Bulk[j, t] ) elif buy == 'AboveMin': - disjunct.amount = Constraint( + disjunct.amount = pyo.Constraint( expr=model.AmountPurchased_Bulk[j, t] >= model.MinAmount_Bulk[j, t] ) - disjunct.price = Constraint( + disjunct.price = pyo.Constraint( expr=model.Cost_Bulk[j, t] == model.DiscountPrice_Bulk[j, t] * model.AmountPurchased_Bulk[j, t] ) elif buy == 'NotSelected': - disjunct.amount = Constraint(expr=model.AmountPurchased_Bulk[j, t] == 0) - disjunct.price = Constraint(expr=model.Cost_Bulk[j, t] == 0) + disjunct.amount = pyo.Constraint(expr=model.AmountPurchased_Bulk[j, t] == 0) + disjunct.price = pyo.Constraint(expr=model.Cost_Bulk[j, t] == 0) else: raise RuntimeError("Unrecognized choice for bulk contract: %s" % buy) @@ -704,10 +736,10 @@ def bulk_contract_rule(model, j, t): def FD_1mo_contract(disjunct, j, t): model = disjunct.model() - disjunct.amount1 = Constraint( + disjunct.amount1 = pyo.Constraint( expr=model.AmountPurchased_FD[j, t] >= MIN_AMOUNT_FD_1MONTH ) - disjunct.price1 = Constraint( + disjunct.price1 = pyo.Constraint( expr=model.Cost_FD[j, t] == model.Prices_Length[j, 1, t] * model.AmountPurchased_FD[j, t] ) @@ -718,19 +750,19 @@ def FD_1mo_contract(disjunct, j, t): def FD_2mo_contract(disjunct, j, t): model = disjunct.model() - disjunct.amount1 = Constraint( + disjunct.amount1 = pyo.Constraint( expr=model.AmountPurchased_FD[j, t] >= model.MinAmount_Length[j, 2] ) - disjunct.price1 = Constraint( + disjunct.price1 = pyo.Constraint( expr=model.Cost_FD[j, t] == model.Prices_Length[j, 2, t] * model.AmountPurchased_FD[j, t] ) # only enforce these if we aren't in the last time period if t < model.TimePeriods[-1]: - disjunct.amount2 = Constraint( + disjunct.amount2 = pyo.Constraint( expr=model.AmountPurchased_FD[j, t + 1] >= model.MinAmount_Length[j, 2] ) - disjunct.price2 = Constraint( + disjunct.price2 = pyo.Constraint( expr=model.Cost_FD[j, t + 1] == model.Prices_Length[j, 2, t] * model.AmountPurchased_FD[j, t + 1] ) @@ -744,27 +776,27 @@ def FD_3mo_contract(disjunct, j, t): # NOTE: I think there is a mistake in the GAMS file in line 327. # they use the bulk minamount rather than the length one. # I am doing the same here for validation purposes. - disjunct.amount1 = Constraint( + disjunct.amount1 = pyo.Constraint( expr=model.AmountPurchased_FD[j, t] >= model.MinAmount_Bulk[j, 3] ) - disjunct.cost1 = Constraint( + disjunct.cost1 = pyo.Constraint( expr=model.Cost_FD[j, t] == model.Prices_Length[j, 3, t] * model.AmountPurchased_FD[j, t] ) # check we aren't in one of the last two time periods if t < model.TimePeriods[-1]: - disjunct.amount2 = Constraint( + disjunct.amount2 = pyo.Constraint( expr=model.AmountPurchased_FD[j, t + 1] >= model.MinAmount_Length[j, 3] ) - disjunct.cost2 = Constraint( + disjunct.cost2 = pyo.Constraint( expr=model.Cost_FD[j, t + 1] == model.Prices_Length[j, 3, t] * model.AmountPurchased_FD[j, t + 1] ) if t < model.TimePeriods[-2]: - disjunct.amount3 = Constraint( + disjunct.amount3 = pyo.Constraint( expr=model.AmountPurchased_FD[j, t + 2] >= model.MinAmount_Length[j, 3] ) - disjunct.cost3 = Constraint( + disjunct.cost3 = pyo.Constraint( expr=model.Cost_FD[j, t + 2] == model.Prices_Length[j, 3, t] * model.AmountPurchased_FD[j, t + 2] ) @@ -775,14 +807,18 @@ def FD_3mo_contract(disjunct, j, t): def FD_no_contract(disjunct, j, t): model = disjunct.model() - disjunct.amount1 = Constraint(expr=model.AmountPurchased_FD[j, t] == 0) - disjunct.cost1 = Constraint(expr=model.Cost_FD[j, t] == 0) + disjunct.amount1 = pyo.Constraint(expr=model.AmountPurchased_FD[j, t] == 0) + disjunct.cost1 = pyo.Constraint(expr=model.Cost_FD[j, t] == 0) if t < model.TimePeriods[-1]: - disjunct.amount2 = Constraint(expr=model.AmountPurchased_FD[j, t + 1] == 0) - disjunct.cost2 = Constraint(expr=model.Cost_FD[j, t + 1] == 0) + disjunct.amount2 = pyo.Constraint( + expr=model.AmountPurchased_FD[j, t + 1] == 0 + ) + disjunct.cost2 = pyo.Constraint(expr=model.Cost_FD[j, t + 1] == 0) if t < model.TimePeriods[-2]: - disjunct.amount3 = Constraint(expr=model.AmountPurchased_FD[j, t + 2] == 0) - disjunct.cost3 = Constraint(expr=model.Cost_FD[j, t + 2] == 0) + disjunct.amount3 = pyo.Constraint( + expr=model.AmountPurchased_FD[j, t + 2] == 0 + ) + disjunct.cost3 = pyo.Constraint(expr=model.Cost_FD[j, t + 2] == 0) model.FD_no_contract = Disjunct( model.RawMaterials, model.TimePeriods, rule=FD_no_contract @@ -805,8 +841,8 @@ def FD_contract(model, j, t): if __name__ == "__main__": m = build_model().create_instance('medTermPurchasing_Literal_Hull.dat') - TransformationFactory('gdp.bigm').apply_to(m) - SolverFactory('gams').solve( + pyo.TransformationFactory('gdp.bigm').apply_to(m) + pyo.SolverFactory('gams').solve( m, solver='baron', tee=True, add_options=['option optcr=1e-6;'] ) m.profit.display() diff --git a/examples/gdp/nine_process/small_process.py b/examples/gdp/nine_process/small_process.py index 2abffef1af6..6431966dcc5 100644 --- a/examples/gdp/nine_process/small_process.py +++ b/examples/gdp/nine_process/small_process.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -"""Small process synthesis-inspired toy GDP example. - -""" +"""Small process synthesis-inspired toy GDP example.""" from pyomo.core import ConcreteModel, RangeSet, Var, Constraint, Objective from pyomo.core.expr.current import exp, log, sqrt diff --git a/examples/gdp/simple1.py b/examples/gdp/simple1.py index de41c0bfd00..4ac28153ad7 100644 --- a/examples/gdp/simple1.py +++ b/examples/gdp/simple1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,26 +15,26 @@ # This model does not work with existing transformations. # See simple2.py and simple3.py for variants that work. -from pyomo.core import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction def build_model(): - model = ConcreteModel() + model = pyo.ConcreteModel() # x >= 0 _|_ y>=0 - model.x = Var(bounds=(0, None)) - model.y = Var(bounds=(0, None)) + model.x = pyo.Var(bounds=(0, None)) + model.y = pyo.Var(bounds=(0, None)) # Two conditions def _d(disjunct, flag): model = disjunct.model() if flag: # x == 0 - disjunct.c = Constraint(expr=model.x == 0) + disjunct.c = pyo.Constraint(expr=model.x == 0) else: # y == 0 - disjunct.c = Constraint(expr=model.y == 0) + disjunct.c = pyo.Constraint(expr=model.y == 0) model.d = Disjunct([0, 1], rule=_d) @@ -44,7 +44,7 @@ def _c(model): model.c = Disjunction(rule=_c) - model.C = Constraint(expr=model.x + model.y <= 1) + model.C = pyo.Constraint(expr=model.x + model.y <= 1) - model.o = Objective(expr=2 * model.x + 3 * model.y, sense=maximize) + model.o = pyo.Objective(expr=2 * model.x + 3 * model.y, sense=pyo.maximize) return model diff --git a/examples/gdp/simple2.py b/examples/gdp/simple2.py index b066d705036..d5efd07565c 100644 --- a/examples/gdp/simple2.py +++ b/examples/gdp/simple2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,26 +14,26 @@ # # Specifying variable bounds -from pyomo.core import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction def build_model(): - model = ConcreteModel() + model = pyo.ConcreteModel() # x >= 0 _|_ y>=0 - model.x = Var(bounds=(0, 100)) - model.y = Var(bounds=(0, 100)) + model.x = pyo.Var(bounds=(0, 100)) + model.y = pyo.Var(bounds=(0, 100)) # Two conditions def _d(disjunct, flag): model = disjunct.model() if flag: # x == 0 - disjunct.c = Constraint(expr=model.x == 0) + disjunct.c = pyo.Constraint(expr=model.x == 0) else: # y == 0 - disjunct.c = Constraint(expr=model.y == 0) + disjunct.c = pyo.Constraint(expr=model.y == 0) model.d = Disjunct([0, 1], rule=_d) @@ -43,7 +43,7 @@ def _c(model): model.c = Disjunction(rule=_c) - model.C = Constraint(expr=model.x + model.y <= 1) + model.C = pyo.Constraint(expr=model.x + model.y <= 1) - model.o = Objective(expr=2 * model.x + 3 * model.y, sense=maximize) + model.o = pyo.Objective(expr=2 * model.x + 3 * model.y, sense=pyo.maximize) return model diff --git a/examples/gdp/simple3.py b/examples/gdp/simple3.py index 890daf8882b..b7d80ce6436 100644 --- a/examples/gdp/simple3.py +++ b/examples/gdp/simple3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,27 +14,27 @@ # # Specifying BigM suffix values for the gdp.bigm transformation -from pyomo.core import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction def build_model(): - model = ConcreteModel() + model = pyo.ConcreteModel() # x >= 0 _|_ y>=0 - model.x = Var(bounds=(0, None)) - model.y = Var(bounds=(0, None)) + model.x = pyo.Var(bounds=(0, None)) + model.y = pyo.Var(bounds=(0, None)) # Two conditions def _d(disjunct, flag): model = disjunct.model() if flag: # x == 0 - disjunct.c = Constraint(expr=model.x == 0) + disjunct.c = pyo.Constraint(expr=model.x == 0) else: # y == 0 - disjunct.c = Constraint(expr=model.y == 0) - disjunct.BigM = Suffix() + disjunct.c = pyo.Constraint(expr=model.y == 0) + disjunct.BigM = pyo.Suffix() disjunct.BigM[disjunct.c] = 1 model.d = Disjunct([0, 1], rule=_d) @@ -45,8 +45,8 @@ def _c(model): model.c = Disjunction(rule=_c) - model.C = Constraint(expr=model.x + model.y <= 1) + model.C = pyo.Constraint(expr=model.x + model.y <= 1) - model.o = Objective(expr=2 * model.x + 3 * model.y, sense=maximize) + model.o = pyo.Objective(expr=2 * model.x + 3 * model.y, sense=pyo.maximize) return model diff --git a/examples/gdp/small_lit/basic_step.py b/examples/gdp/small_lit/basic_step.py index 2d9da97167c..a31739be26f 100644 --- a/examples/gdp/small_lit/basic_step.py +++ b/examples/gdp/small_lit/basic_step.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,10 +9,10 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -""" Example from Section 3.2 in paper of Pseudo Basic Steps +"""Example from Section 3.2 in paper of Pseudo Basic Steps Ref: - Pseudo basic steps: bound improvement guarantees from Lagrangian + Pseudo basic steps: bound improvement guarantees from Lagrangian decomposition in convex disjunctive programming Papageorgiou and Trespalacios, 2017 @@ -21,38 +21,38 @@ Pyomo model implementation by @RomeoV """ -from pyomo.environ import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct from pyomo.gdp.basic_step import apply_basic_step def build_gdp_model(): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.x1 = Var(bounds=(-1, 6), initialize=0) - model.x2 = Var(bounds=(0, 7), initialize=3.5) + model.x1 = pyo.Var(bounds=(-1, 6), initialize=0) + model.x2 = pyo.Var(bounds=(0, 7), initialize=3.5) - model.objective = Objective(expr=0.2 * model.x1 + model.x2) - model.disjunction_set = RangeSet(1, 3) + model.objective = pyo.Objective(expr=0.2 * model.x1 + model.x2) + model.disjunction_set = pyo.RangeSet(1, 3) model.disjuncts = Disjunct([1, 2, 3], [1, 2]) - model.disjuncts[1, 1].c = Constraint( + model.disjuncts[1, 1].c = pyo.Constraint( expr=model.x1**2 + (1 / 4) * (model.x2 - 5) ** 2 <= 1 ) - model.disjuncts[2, 1].c = Constraint( + model.disjuncts[2, 1].c = pyo.Constraint( expr=model.x1**2 + (1 / 4) * (model.x2 - 2) ** 2 <= 1 ) - model.disjuncts[3, 1].c = Constraint( + model.disjuncts[3, 1].c = pyo.Constraint( expr=model.x1**2 + (1 / 4) * (model.x2 - 3.5) ** 2 <= 1 ) - model.disjuncts[1, 2].c = Constraint( + model.disjuncts[1, 2].c = pyo.Constraint( expr=(model.x1 - 5) ** 2 + (1 / 4) * (model.x2 - 2) ** 2 <= 1 ) - model.disjuncts[2, 2].c = Constraint( + model.disjuncts[2, 2].c = pyo.Constraint( expr=(model.x1 - 5) ** 2 + (1 / 4) * (model.x2 - 5) ** 2 <= 1 ) - model.disjuncts[3, 2].c = Constraint( + model.disjuncts[3, 2].c = pyo.Constraint( expr=(model.x1 - 5) ** 2 + (1 / 4) * (model.x2 - 3.5) ** 2 <= 1 ) @@ -65,9 +65,9 @@ def disjunctions(model, i): def solve_base_model(): m_base = build_gdp_model() - m_hull = TransformationFactory('gdp.hull').create_using(m_base) - # m_bigm = TransformationFactory('gdp.bigm').create_using(m_base, bigM=100) - solver = SolverFactory('gams') + m_hull = pyo.TransformationFactory('gdp.hull').create_using(m_base) + # m_bigm = pyo.TransformationFactory('gdp.bigm').create_using(m_base, bigM=100) + solver = pyo.SolverFactory('gams') solver.solve(m_hull, solver='baron') # m_hull.pprint() m_hull.objective.display() @@ -83,11 +83,11 @@ def solve_basic_step_model(): # with open('pprint.log','w') as outputfile: # m_base.disjunctions.pprint(outputfile) - # m_bs_hull = TransformationFactory('gdp.hull').create_using(m_base) - m_bigm = TransformationFactory('gdp.bigm').create_using(m_base, bigM=100) + # m_bs_hull = pyo.TransformationFactory('gdp.hull').create_using(m_base) + m_bigm = pyo.TransformationFactory('gdp.bigm').create_using(m_base, bigM=100) m_bigm.pprint() - solver = SolverFactory('gams') + solver = pyo.SolverFactory('gams') solver.solve(m_bigm, tee=True, solver='baron') m_bigm.objective.display() diff --git a/examples/gdp/small_lit/contracts_problem.py b/examples/gdp/small_lit/contracts_problem.py index 0c59d2264ee..26b4f6e53bc 100644 --- a/examples/gdp/small_lit/contracts_problem.py +++ b/examples/gdp/small_lit/contracts_problem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/small_lit/ex1_Lee.py b/examples/gdp/small_lit/ex1_Lee.py index abbf470a1c3..64168318e9a 100644 --- a/examples/gdp/small_lit/ex1_Lee.py +++ b/examples/gdp/small_lit/ex1_Lee.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,7 +13,7 @@ Taken from Example 1 of the paper "New Algorithms for Nonlinear Generalized Disjunctive Programming" by Lee and Grossmann This GDP problem has one disjunction containing three terms, and exactly one of them must be true. The literature can be found here: -http://egon.cheme.cmu.edu/Papers/LeeNewAlgo.pdf +https://egon.cheme.cmu.edu/Papers/LeeNewAlgo.pdf """ diff --git a/examples/gdp/small_lit/ex_633_trespalacios.py b/examples/gdp/small_lit/ex_633_trespalacios.py index b0c5fbd85ac..abf8c262fa0 100644 --- a/examples/gdp/small_lit/ex_633_trespalacios.py +++ b/examples/gdp/small_lit/ex_633_trespalacios.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -26,28 +26,28 @@ """ -from pyomo.environ import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunction def build_simple_nonconvex_gdp(): """Build the Analytical Problem.""" - m = ConcreteModel(name="Example 6.3.3") + m = pyo.ConcreteModel(name="Example 6.3.3") # Variables x1 and x2 - m.x1 = Var(bounds=(0, 5), doc="variable x1") - m.x2 = Var(bounds=(0, 3), doc="variable x2") - m.obj = Objective(expr=5 + 0.2 * m.x1 - m.x2, doc="Minimize objective") + m.x1 = pyo.Var(bounds=(0, 5), doc="variable x1") + m.x2 = pyo.Var(bounds=(0, 3), doc="variable x2") + m.obj = pyo.Objective(expr=5 + 0.2 * m.x1 - m.x2, doc="Minimize objective") m.disjunction1 = Disjunction( expr=[ [ - m.x2 <= 0.4 * exp(m.x1 / 2.0), + m.x2 <= 0.4 * pyo.exp(m.x1 / 2.0), m.x2 <= 0.5 * (m.x1 - 2.5) ** 2 + 0.3, m.x2 <= 6.5 / (m.x1 / 0.3 + 2.0) + 1.0, ], [ - m.x2 <= 0.3 * exp(m.x1 / 1.8), + m.x2 <= 0.3 * pyo.exp(m.x1 / 1.8), m.x2 <= 0.7 * (m.x1 / 1.2 - 2.1) ** 2 + 0.3, m.x2 <= 6.5 / (m.x1 / 0.8 + 1.1), ], @@ -56,12 +56,12 @@ def build_simple_nonconvex_gdp(): m.disjunction2 = Disjunction( expr=[ [ - m.x2 <= 0.9 * exp(m.x1 / 2.1), + m.x2 <= 0.9 * pyo.exp(m.x1 / 2.1), m.x2 <= 1.3 * (m.x1 / 1.5 - 1.8) ** 2 + 0.3, m.x2 <= 6.5 / (m.x1 / 0.8 + 1.1), ], [ - m.x2 <= 0.4 * exp(m.x1 / 1.5), + m.x2 <= 0.4 * pyo.exp(m.x1 / 1.5), m.x2 <= 1.2 * (m.x1 - 2.5) ** 2 + 0.3, m.x2 <= 6.0 / (m.x1 / 0.6 + 1.0) + 0.5, ], @@ -74,7 +74,7 @@ def build_simple_nonconvex_gdp(): if __name__ == "__main__": model = build_simple_nonconvex_gdp() model.pprint() - res = SolverFactory('gdpopt.gloa').solve(model, tee=True) + res = pyo.SolverFactory('gdpopt.gloa').solve(model, tee=True) model.display() print(res) diff --git a/examples/gdp/small_lit/nonconvex_HEN.py b/examples/gdp/small_lit/nonconvex_HEN.py index 05fad970b84..e24e5cc2a32 100644 --- a/examples/gdp/small_lit/nonconvex_HEN.py +++ b/examples/gdp/small_lit/nonconvex_HEN.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/stickies.py b/examples/gdp/stickies.py index 73b537ff13d..95278ded7f2 100644 --- a/examples/gdp/stickies.py +++ b/examples/gdp/stickies.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,11 +12,8 @@ import os from pyomo.common.fileutils import this_file_dir -from pyomo.environ import * -from pyomo.gdp import * - -# from pyomo.opt import SolverFactory -# from pyomo.core.base import Transformation +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction ''' Layout optimization for screening systems in waste paper recovery: @@ -30,9 +27,9 @@ def build_model(): - model = AbstractModel() + model = pyo.AbstractModel() - model.BigM = Suffix(direction=Suffix.LOCAL) + model.BigM = pyo.Suffix(direction=pyo.Suffix.LOCAL) model.BigM[None] = 1000 DATFILE = "stickies1.dat" @@ -42,27 +39,27 @@ def build_model(): ####################### # J - model.Components = Set() + model.Components = pyo.Set() # fiber - model.GoodComponents = Set() + model.GoodComponents = pyo.Set() # stickies - model.BadComponents = Set() + model.BadComponents = pyo.Set() # N: total nodes in the system - model.Nodes = Set() + model.Nodes = pyo.Set() # S: possible screens - model.Screens = Set() + model.Screens = pyo.Set() def screen_node_filter(model, s, n): return s != n - model.ScreenNodePairs = Set( + model.ScreenNodePairs = pyo.Set( initialize=model.Screens * model.Nodes, dimen=2, filter=screen_node_filter ) def screen_filter(model, s, sprime): return s != sprime - model.ScreenPairs = Set( + model.ScreenPairs = pyo.Set( initialize=model.Screens * model.Screens, dimen=2, filter=screen_filter ) @@ -71,24 +68,24 @@ def screen_filter(model, s, sprime): ###################### # exponent coefficient for cost in screen s (alpha(s)) - model.ExpScreenCostCoeff = Param(model.Screens) + model.ExpScreenCostCoeff = pyo.Param(model.Screens) # beta(s, j) - model.AcceptanceFactor = Param(model.Screens, model.Components) + model.AcceptanceFactor = pyo.Param(model.Screens, model.Components) # C_s^1 - model.ScreenCostCoeff1 = Param(model.Screens) + model.ScreenCostCoeff1 = pyo.Param(model.Screens) # C_s^2 - model.ScreenCostCoeff2 = Param(model.Screens, default=0) + model.ScreenCostCoeff2 = pyo.Param(model.Screens, default=0) # max percentage inlet stickies accepted in total flow (C_{st}^{up}, q(kb)) - model.AcceptedLeftover = Param(model.BadComponents) + model.AcceptedLeftover = pyo.Param(model.BadComponents) # F_j^0, m_src(k) - model.InitialComponentFlow = Param(model.Components) + model.InitialComponentFlow = pyo.Param(model.Components) # m_src_lo(k) - model.InitialComponentFlowLB = Param(model.Components, default=0) + model.InitialComponentFlowLB = pyo.Param(model.Components, default=0) # constants for objective function (W^1, W^2, W^3) - model.FiberWeight = Param() - model.StickiesWeight = Param() - model.CostWeight = Param() + model.FiberWeight = pyo.Param() + model.StickiesWeight = pyo.Param() + model.CostWeight = pyo.Param() ## Bounds on variables @@ -96,27 +93,29 @@ def screen_filter(model, s, sprime): def flow_ub_rule(model, s): return sum(model.InitialComponentFlow[k] for k in model.Components) - model.ScreenFlowLB = Param(model.Screens) - model.ScreenFlowUB = Param(model.Screens, initialize=flow_ub_rule) + model.ScreenFlowLB = pyo.Param(model.Screens) + model.ScreenFlowUB = pyo.Param(model.Screens, initialize=flow_ub_rule) # m_in_lo(ss, k): lower bound of individual flow into nodes. - model.InletComponentFlowLB = Param(model.Components, model.Nodes, default=0) + model.InletComponentFlowLB = pyo.Param(model.Components, model.Nodes, default=0) def component_flow_ub_rule(model, k, n): return model.InitialComponentFlow[k] # m_in_up(ss, k) - model.InletComponentFlowUB = Param( + model.InletComponentFlowUB = pyo.Param( model.Components, model.Nodes, initialize=component_flow_ub_rule ) # r_lo(s) - model.RejectRateLB = Param(model.Screens) + model.RejectRateLB = pyo.Param(model.Screens) # r_up(s) - model.RejectRateUB = Param(model.Screens) + model.RejectRateUB = pyo.Param(model.Screens) # m_rej_lo(s, k) - model.RejectedComponentFlowLB = Param(model.Components, model.Screens, default=0) + model.RejectedComponentFlowLB = pyo.Param( + model.Components, model.Screens, default=0 + ) def rejected_component_flow_bound(model, k, s): return model.InitialComponentFlow[k] * ( @@ -124,12 +123,14 @@ def rejected_component_flow_bound(model, k, s): ) # m_rej_up(s, k) - model.RejectedComponentFlowUB = Param( + model.RejectedComponentFlowUB = pyo.Param( model.Components, model.Screens, initialize=rejected_component_flow_bound ) # m_acc_lo(s, k): lower bound of accepted individual flow - model.AcceptedComponentFlowLB = Param(model.Components, model.Screens, default=0) + model.AcceptedComponentFlowLB = pyo.Param( + model.Components, model.Screens, default=0 + ) def accepted_component_flow_bound(model, k, s): return model.InitialComponentFlow[k] * ( @@ -137,7 +138,7 @@ def accepted_component_flow_bound(model, k, s): ) # m_acc_up(s, k) - model.AcceptedComponentFlowUB = Param( + model.AcceptedComponentFlowUB = pyo.Param( model.Components, model.Screens, initialize=accepted_component_flow_bound ) @@ -146,8 +147,8 @@ def accepted_component_flow_bound(model, k, s): ###################### # c_s, C(s), cost of selecting screen - model.screenCost = Var( - model.Screens, within=NonNegativeReals + model.screenCost = pyo.Var( + model.Screens, within=pyo.NonNegativeReals ) # , bounds=get_screen_cost_bounds) # total inlet flow into screen s (f_s, F_IN(s)) @@ -156,18 +157,18 @@ def accepted_component_flow_bound(model, k, s): def get_inlet_flow_bounds(model, s): return (0, model.ScreenFlowUB[s]) - model.inletScreenFlow = Var( - model.Screens, within=NonNegativeReals, bounds=get_inlet_flow_bounds + model.inletScreenFlow = pyo.Var( + model.Screens, within=pyo.NonNegativeReals, bounds=get_inlet_flow_bounds ) # inlet flow of component j into node n, (f_{n,j}^I, M_IN) def get_inlet_component_flow_bounds(model, j, n): return (model.InletComponentFlowLB[j, n], model.InletComponentFlowUB[j, n]) - model.inletComponentFlow = Var( + model.inletComponentFlow = pyo.Var( model.Components, model.Nodes, - within=NonNegativeReals, + within=pyo.NonNegativeReals, bounds=get_inlet_component_flow_bounds, ) @@ -178,10 +179,10 @@ def get_accepted_component_flow_bounds(model, j, s): model.AcceptedComponentFlowUB[j, s], ) - model.acceptedComponentFlow = Var( + model.acceptedComponentFlow = pyo.Var( model.Components, model.Screens, - within=NonNegativeReals, + within=pyo.NonNegativeReals, bounds=get_accepted_component_flow_bounds, ) @@ -192,10 +193,10 @@ def rej_component_flow_bounds(model, k, s): model.RejectedComponentFlowUB[k, s], ) - model.rejectedComponentFlow = Var( + model.rejectedComponentFlow = pyo.Var( model.Components, model.Screens, - within=NonNegativeReals, + within=pyo.NonNegativeReals, bounds=rej_component_flow_bounds, ) @@ -203,11 +204,11 @@ def rej_component_flow_bounds(model, k, s): def get_accepted_node_flow_bounds(model, j, s, n): return (0, model.AcceptedComponentFlowUB[j, s]) - model.acceptedNodeFlow = Var( + model.acceptedNodeFlow = pyo.Var( model.Components, model.Screens, model.Nodes, - within=NonNegativeReals, + within=pyo.NonNegativeReals, bounds=get_accepted_node_flow_bounds, ) @@ -215,11 +216,11 @@ def get_accepted_node_flow_bounds(model, j, s, n): def get_rejected_node_flow_bounds(model, j, s, n): return (0, model.RejectedComponentFlowUB[j, s]) - model.rejectedNodeFlow = Var( + model.rejectedNodeFlow = pyo.Var( model.Components, model.Screens, model.Nodes, - within=NonNegativeReals, + within=pyo.NonNegativeReals, bounds=get_rejected_node_flow_bounds, ) @@ -227,14 +228,16 @@ def get_rejected_node_flow_bounds(model, j, s, n): def get_src_flow_bounds(model, j, n): return (0, model.InitialComponentFlow[j]) - model.flowFromSource = Var(model.Components, model.Nodes, within=NonNegativeReals) + model.flowFromSource = pyo.Var( + model.Components, model.Nodes, within=pyo.NonNegativeReals + ) # reject rate of screen s (r_s) def get_rej_rate_bounds(model, s): return (model.RejectRateLB[s], model.RejectRateUB[s]) - model.rejectRate = Var( - model.Screens, within=NonNegativeReals, bounds=get_rej_rate_bounds + model.rejectRate = pyo.Var( + model.Screens, within=pyo.NonNegativeReals, bounds=get_rej_rate_bounds ) ###################### @@ -251,7 +254,7 @@ def calc_cost_rule(model): screenCost = model.CostWeight * sum(model.screenCost[s] for s in model.Screens) return lostFiberCost + stickiesCost + screenCost - model.min_cost = Objective(rule=calc_cost_rule) + model.min_cost = pyo.Objective(rule=calc_cost_rule) ###################### # Constraints @@ -263,7 +266,7 @@ def stickies_bound_rule(model, j): <= model.AcceptedLeftover[j] * model.InitialComponentFlow[j] ) - model.stickies_bound = Constraint(model.BadComponents, rule=stickies_bound_rule) + model.stickies_bound = pyo.Constraint(model.BadComponents, rule=stickies_bound_rule) def inlet_flow_rule(model, s, j): return ( @@ -271,14 +274,16 @@ def inlet_flow_rule(model, s, j): == model.acceptedComponentFlow[j, s] + model.rejectedComponentFlow[j, s] ) - model.inlet_flow = Constraint(model.Screens, model.Components, rule=inlet_flow_rule) + model.inlet_flow = pyo.Constraint( + model.Screens, model.Components, rule=inlet_flow_rule + ) def total_inlet_flow_rule(model, s): return model.inletScreenFlow[s] == sum( model.inletComponentFlow[j, s] for j in model.Components ) - model.total_inlet_flow = Constraint(model.Screens, rule=total_inlet_flow_rule) + model.total_inlet_flow = pyo.Constraint(model.Screens, rule=total_inlet_flow_rule) def inlet_flow_balance_rule(model, n, j): return model.inletComponentFlow[j, n] == model.flowFromSource[j, n] + sum( @@ -287,7 +292,7 @@ def inlet_flow_balance_rule(model, n, j): if s != n ) - model.inlet_flow_balance = Constraint( + model.inlet_flow_balance = pyo.Constraint( model.Nodes, model.Components, rule=inlet_flow_balance_rule ) @@ -296,7 +301,7 @@ def source_flow_rule(model, j): model.flowFromSource[j, n] for n in model.Nodes ) - model.source_flow = Constraint(model.Components, rule=source_flow_rule) + model.source_flow = pyo.Constraint(model.Components, rule=source_flow_rule) ################# ## Disjunctions @@ -311,22 +316,22 @@ def rejected_flow_rule(disjunct, j): ] * (model.rejectRate[s] ** model.AcceptanceFactor[s, j]) if selectScreen: - disjunct.inlet_flow_bounds = Constraint( + disjunct.inlet_flow_bounds = pyo.Constraint( expr=model.ScreenFlowLB[s] <= model.inletScreenFlow[s] ) # <= \ # model.ScreenFlowUB[s]) - disjunct.rejected_flow = Constraint( + disjunct.rejected_flow = pyo.Constraint( model.Components, rule=rejected_flow_rule ) - disjunct.screen_cost = Constraint( + disjunct.screen_cost = pyo.Constraint( expr=model.screenCost[s] == model.ScreenCostCoeff1[s] * (model.inletScreenFlow[s] ** model.ExpScreenCostCoeff[s]) + model.ScreenCostCoeff2[s] * (1 - model.rejectRate[s]) ) else: - disjunct.no_flow = Constraint(expr=model.inletScreenFlow[s] == 0) - disjunct.no_cost = Constraint(expr=model.screenCost[s] == 0) + disjunct.no_flow = pyo.Constraint(expr=model.inletScreenFlow[s] == 0) + disjunct.no_cost = pyo.Constraint(expr=model.screenCost[s] == 0) model.screen_selection_disjunct = Disjunct( [0, 1], model.Screens, rule=screen_disjunct_rule @@ -349,9 +354,11 @@ def no_flow_rule(disjunct, j): return model.acceptedNodeFlow[j, s, n] == 0 if acceptFlow: - disjunct.flow_balance = Constraint(model.Components, rule=flow_balance_rule) + disjunct.flow_balance = pyo.Constraint( + model.Components, rule=flow_balance_rule + ) else: - disjunct.no_flow = Constraint(model.Components, rule=no_flow_rule) + disjunct.no_flow = pyo.Constraint(model.Components, rule=no_flow_rule) model.flow_acceptance_disjunct = Disjunct( model.ScreenNodePairs, [0, 1], rule=accepted_flow_disjunct_rule @@ -376,9 +383,11 @@ def no_reject_rule(disjunct, j): return model.rejectedNodeFlow[j, s, n] == 0 if rejectFlow: - disjunct.flow_balance = Constraint(model.Components, rule=flow_balance_rule) + disjunct.flow_balance = pyo.Constraint( + model.Components, rule=flow_balance_rule + ) else: - disjunct.no_reject = Constraint(model.Components, rule=no_reject_rule) + disjunct.no_reject = pyo.Constraint(model.Components, rule=no_reject_rule) model.flow_rejection_disjunct = Disjunct( model.ScreenNodePairs, [0, 1], rule=rejected_flow_disjunct_rule @@ -408,13 +417,13 @@ def sourceFlow_balance_rule2(disjunct, j): def no_sourceFlow_rule(disjunct, j, nprime): return model.flowFromSource[j, nprime] == 0 - disjunct.flow_balance1 = Constraint( + disjunct.flow_balance1 = pyo.Constraint( model.Components, rule=sourceFlow_balance_rule1 ) - disjunct.flow_balance2 = Constraint( + disjunct.flow_balance2 = pyo.Constraint( model.Components, rule=sourceFlow_balance_rule2 ) - disjunct.no_flow = Constraint( + disjunct.no_flow = pyo.Constraint( model.Components, model.Nodes - [n], rule=no_sourceFlow_rule ) @@ -445,7 +454,7 @@ def log1_rule(model, s): if s != n ) - model.log1 = Constraint(model.Screens, rule=log1_rule) + model.log1 = pyo.Constraint(model.Screens, rule=log1_rule) def log2_rule(model, s): return model.screen_selection_disjunct[1, s].binary_indicator_var == sum( @@ -454,7 +463,7 @@ def log2_rule(model, s): if s != n ) - model.log2 = Constraint(model.Screens, rule=log2_rule) + model.log2 = pyo.Constraint(model.Screens, rule=log2_rule) def log3_rule(model, s): return model.screen_selection_disjunct[1, s].binary_indicator_var >= sum( @@ -463,7 +472,7 @@ def log3_rule(model, s): if s != sprime ) - model.log3 = Constraint(model.Screens, rule=log3_rule) + model.log3 = pyo.Constraint(model.Screens, rule=log3_rule) def log4_rule(model, s): return model.screen_selection_disjunct[1, s].binary_indicator_var >= sum( @@ -472,7 +481,7 @@ def log4_rule(model, s): if s != sprime ) - model.log4 = Constraint(model.Screens, rule=log4_rule) + model.log4 = pyo.Constraint(model.Screens, rule=log4_rule) def log6_rule(model, s, sprime): return ( @@ -481,7 +490,7 @@ def log6_rule(model, s, sprime): <= 1 ) - model.log6 = Constraint(model.ScreenPairs, rule=log6_rule) + model.log6 = pyo.Constraint(model.ScreenPairs, rule=log6_rule) def log7_rule(model, s, sprime): return ( @@ -490,7 +499,7 @@ def log7_rule(model, s, sprime): <= 1 ) - model.log7 = Constraint(model.ScreenPairs, rule=log7_rule) + model.log7 = pyo.Constraint(model.ScreenPairs, rule=log7_rule) def log8_rule(model, s, n): return ( @@ -499,7 +508,7 @@ def log8_rule(model, s, n): <= 1 ) - model.log8 = Constraint(model.ScreenNodePairs, rule=log8_rule) + model.log8 = pyo.Constraint(model.ScreenNodePairs, rule=log8_rule) def log9_rule(model, s, sprime): return ( @@ -508,7 +517,7 @@ def log9_rule(model, s, sprime): <= 1 ) - model.log9 = Constraint(model.ScreenPairs, rule=log9_rule) + model.log9 = pyo.Constraint(model.ScreenPairs, rule=log9_rule) # These are the above logical constraints implemented correctly (I think) # However, this doesn't match what is actually coded in gams and makes the @@ -518,93 +527,93 @@ def log9_rule(model, s, sprime): # def flow_existence_rule1(model, s, n): # return model.screen_selection_disjunct[1, s].indicator_var >= \ # model.flow_acceptance_disjunct[s, n, 1].indicator_var - # model.flow_existence1 = Constraint(model.ScreenNodePairs, + # model.flow_existence1 = pyo.Constraint(model.ScreenNodePairs, # rule=flow_existence_rule1) # def flow_existence_rule2(model, s, n): # return model.screen_selection_disjunct[1, s].indicator_var >= \ # model.flow_rejection_disjunct[s, n, 1].indicator_var - # model.flow_existence2 = Constraint(model.ScreenNodePairs, + # model.flow_existence2 = pyo.Constraint(model.ScreenNodePairs, # rule=flow_existence_rule2) # # YA_{s,s'} v YR_{s',s} implies Y_s # def screen_flow_existence_rule1(model, s, sprime): # return model.screen_selection_disjunct[1, s].indicator_var >= \ # model.flow_acceptance_disjunct[s, sprime, 1].indicator_var - # model.screen_flow_existence1 = Constraint(model.ScreenPairs, + # model.screen_flow_existence1 = pyo.Constraint(model.ScreenPairs, # rule=screen_flow_existence_rule1) # def screen_flow_existence_rule2(model, s, sprime): # return model.screen_selection_disjunct[1,s].indicator_var >= \ # model.flow_rejection_disjunct[sprime, s, 1].indicator_var - # model.screen_flow_existence2 = Constraint(model.ScreenPairs, + # model.screen_flow_existence2 = pyo.Constraint(model.ScreenPairs, # rule=screen_flow_existence_rule2) # # YA_{s', s} XOR YA_{s, s'} # def accept_rule1(model, s, sprime): # return 1 <= model.flow_acceptance_disjunct[s, sprime, 1].indicator_var + \ # model.flow_acceptance_disjunct[sprime, s, 1].indicator_var - # model.accept1 = Constraint(model.ScreenPairs, rule=accept_rule1) + # model.accept1 = pyo.Constraint(model.ScreenPairs, rule=accept_rule1) # def accept_rule2(model, s, sprime): # return 1 >= model.flow_acceptance_disjunct[s, sprime, 1].indicator_var - \ # model.flow_acceptance_disjunct[sprime, s, 1].indicator_var - # model.accept2 = Constraint(model.ScreenPairs, rule=accept_rule2) + # model.accept2 = pyo.Constraint(model.ScreenPairs, rule=accept_rule2) # def accept_rule3(model, s, sprime): # return 1 >= model.flow_acceptance_disjunct[sprime, s, 1].indicator_var - \ # model.flow_acceptance_disjunct[s, sprime, 1].indicator_var - # model.accept3 = Constraint(model.ScreenPairs, rule=accept_rule3) + # model.accept3 = pyo.Constraint(model.ScreenPairs, rule=accept_rule3) # def accept_rule4(model, s, sprime): # return 1 <= 2 - model.flow_acceptance_disjunct[sprime,s,1].indicator_var - \ # model.flow_acceptance_disjunct[s, sprime, 1].indicator_var - # model.accept4 = Constraint(model.ScreenPairs, rule=accept_rule4) + # model.accept4 = pyo.Constraint(model.ScreenPairs, rule=accept_rule4) # # YR_{s', s} XOR YR_{s, s'} # def reject_rule1(model, s, sprime): # return 1 <= model.flow_rejection_disjunct[s, sprime, 1].indicator_var + \ # model.flow_rejection_disjunct[sprime, s, 1].indicator_var - # model.reject1 = Constraint(model.ScreenPairs, rule=reject_rule1) + # model.reject1 = pyo.Constraint(model.ScreenPairs, rule=reject_rule1) # def reject_rule2(model, s, sprime): # return 1 >= model.flow_rejection_disjunct[s, sprime, 1].indicator_var - \ # model.flow_rejection_disjunct[sprime, s, 1].indicator_var - # model.reject2 = Constraint(model.ScreenPairs, rule=reject_rule2) + # model.reject2 = pyo.Constraint(model.ScreenPairs, rule=reject_rule2) # def reject_rule3(model, s, sprime): # return 1 >= model.flow_rejection_disjunct[sprime, s, 1].indicator_var - \ # model.flow_rejection_disjunct[s, sprime, 1].indicator_var - # model.reject3 = Constraint(model.ScreenPairs, rule=reject_rule3) + # model.reject3 = pyo.Constraint(model.ScreenPairs, rule=reject_rule3) # def reject_rule4(model, s, sprime): # return 1 <= 2 - model.flow_rejection_disjunct[sprime,s,1].indicator_var - \ # model.flow_rejection_disjunct[s, sprime, 1].indicator_var - # model.reject4 = Constraint(model.ScreenPairs, rule=reject_rule4) + # model.reject4 = pyo.Constraint(model.ScreenPairs, rule=reject_rule4) # # YA_{s,n} XOR YR_{s,n} # def accept_or_reject_rule1(model, s, n): # return 1 <= model.flow_acceptance_disjunct[s, n, 1].indicator_var + \ # model.flow_rejection_disjunct[s, n, 1].indicator_var - # model.accept_or_reject1 = Constraint(model.ScreenNodePairs, + # model.accept_or_reject1 = pyo.Constraint(model.ScreenNodePairs, # rule=accept_or_reject_rule1) # def accept_or_reject_rule2(model, s, n): # return 1 >= model.flow_acceptance_disjunct[s, n, 1].indicator_var - \ # model.flow_rejection_disjunct[s, n, 1].indicator_var - # model.accept_or_reject2 = Constraint(model.ScreenNodePairs, + # model.accept_or_reject2 = pyo.Constraint(model.ScreenNodePairs, # rule=accept_or_reject_rule2) # def accept_or_reject_rule3(model, s, n): # return 1 >= model.flow_rejection_disjunct[s, n, 1].indicator_var - \ # model.flow_acceptance_disjunct[s, n, 1].indicator_var - # model.accept_or_reject3 = Constraint(model.ScreenNodePairs, + # model.accept_or_reject3 = pyo.Constraint(model.ScreenNodePairs, # rule=accept_or_reject_rule3) # def accept_or_reject_rule4(model, s, n): # return 1 <= 2 - model.flow_acceptance_disjunct[s, n, 1].indicator_var - \ # model.flow_rejection_disjunct[s, n, 1].indicator_var - # model.accept_or_reject4 = Constraint(model.ScreenNodePairs, + # model.accept_or_reject4 = pyo.Constraint(model.ScreenNodePairs, # rule=accept_or_reject_rule4) instance = model.create_instance(os.path.join(this_file_dir(), DATFILE)) @@ -718,11 +727,11 @@ def log9_rule(model, s, sprime): instance = build_model() # bigm transformation! - bigM = TransformationFactory('gdp.bigm') + bigM = pyo.TransformationFactory('gdp.bigm') bigM.apply_to(instance) # solve the model - opt = SolverFactory('baron') + opt = pyo.SolverFactory('baron') opt.options["MaxTime"] = 32400 results = opt.solve(instance, tee=True) diff --git a/examples/gdp/strip_packing/stripPacking.py b/examples/gdp/strip_packing/stripPacking.py index 39f7208b838..3094c3117bb 100644 --- a/examples/gdp/strip_packing/stripPacking.py +++ b/examples/gdp/strip_packing/stripPacking.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,24 +9,24 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * -from pyomo.gdp import * +import pyomo.environ as pyo +from pyomo.gdp import Disjunct, Disjunction # Strip-packing example from http://minlp.org/library/lib.php?lib=GDP # This model packs a set of rectangles without rotation or overlap within a # strip of a given width, minimizing the length of the strip. -model = AbstractModel() +model = pyo.AbstractModel() -model.RECTANGLES = Set(ordered=True) +model.RECTANGLES = pyo.Set(ordered=True) # height and width of each rectangle -model.Heights = Param(model.RECTANGLES) -model.Lengths = Param(model.RECTANGLES) +model.Heights = pyo.Param(model.RECTANGLES) +model.Lengths = pyo.Param(model.RECTANGLES) # width of strip -model.StripWidth = Param() +model.StripWidth = pyo.Param() # upperbound on length (default is sum of lengths of rectangles) @@ -34,17 +34,17 @@ def sumLengths(model): return sum(model.Lengths[i] for i in model.RECTANGLES) -model.LengthUB = Param(initialize=sumLengths) +model.LengthUB = pyo.Param(initialize=sumLengths) # rectangle relations -model.RecRelations = Set(initialize=['LeftOf', 'RightOf', 'Above', 'Below']) +model.RecRelations = pyo.Set(initialize=['LeftOf', 'RightOf', 'Above', 'Below']) # x and y coordinates of each of the rectangles -model.x = Var(model.RECTANGLES, bounds=(0, model.LengthUB)) -model.y = Var(model.RECTANGLES, bounds=(0, model.StripWidth)) +model.x = pyo.Var(model.RECTANGLES, bounds=(0, model.LengthUB)) +model.y = pyo.Var(model.RECTANGLES, bounds=(0, model.StripWidth)) # length of strip (this will be the objective) -model.Lt = Var(within=NonNegativeReals) +model.Lt = pyo.Var(within=pyo.NonNegativeReals) # generate the list of possible rectangle conflicts (which are any pair) @@ -52,7 +52,7 @@ def rec_pairs_filter(model, i, j): return i < j -model.RectanglePairs = Set( +model.RectanglePairs = pyo.Set( initialize=model.RECTANGLES * model.RECTANGLES, dimen=2, filter=rec_pairs_filter ) @@ -62,24 +62,26 @@ def strip_ends_after_last_rec_rule(model, i): return model.Lt >= model.x[i] + model.Lengths[i] -model.strip_ends_after_last_rec = Constraint( +model.strip_ends_after_last_rec = pyo.Constraint( model.RECTANGLES, rule=strip_ends_after_last_rec_rule ) # constraints to prevent rectangles from going off strip def no_recs_off_end_rule(model, i): - return inequality(0, model.x[i], model.LengthUB - model.Lengths[i]) + return pyo.inequality(0, model.x[i], model.LengthUB - model.Lengths[i]) -model.no_recs_off_end = Constraint(model.RECTANGLES, rule=no_recs_off_end_rule) +model.no_recs_off_end = pyo.Constraint(model.RECTANGLES, rule=no_recs_off_end_rule) def no_recs_off_bottom_rule(model, i): - return inequality(model.Heights[i], model.y[i], model.StripWidth) + return pyo.inequality(model.Heights[i], model.y[i], model.StripWidth) -model.no_recs_off_bottom = Constraint(model.RECTANGLES, rule=no_recs_off_bottom_rule) +model.no_recs_off_bottom = pyo.Constraint( + model.RECTANGLES, rule=no_recs_off_bottom_rule +) # Disjunctions to prevent overlap between rectangles @@ -87,16 +89,16 @@ def no_overlap_disjunct_rule(disjunct, i, j, recRelation): model = disjunct.model() # left if recRelation == 'LeftOf': - disjunct.c = Constraint(expr=model.x[i] + model.Lengths[i] <= model.x[j]) + disjunct.c = pyo.Constraint(expr=model.x[i] + model.Lengths[i] <= model.x[j]) # right elif recRelation == 'RightOf': - disjunct.c = Constraint(expr=model.x[j] + model.Lengths[j] <= model.x[i]) + disjunct.c = pyo.Constraint(expr=model.x[j] + model.Lengths[j] <= model.x[i]) # above elif recRelation == 'Above': - disjunct.c = Constraint(expr=model.y[i] - model.Heights[i] >= model.y[j]) + disjunct.c = pyo.Constraint(expr=model.y[i] - model.Heights[i] >= model.y[j]) # below elif recRelation == 'Below': - disjunct.c = Constraint(expr=model.y[j] - model.Heights[j] >= model.y[i]) + disjunct.c = pyo.Constraint(expr=model.y[j] - model.Heights[j] >= model.y[i]) else: raise RuntimeError("Unrecognized rectangle relationship: %s" % recRelation) @@ -115,4 +117,4 @@ def no_overlap(model, i, j): model.disj = Disjunction(model.RectanglePairs, rule=no_overlap) # minimize length -model.length = Objective(expr=model.Lt) +model.length = pyo.Objective(expr=model.Lt) diff --git a/examples/gdp/strip_packing/strip_packing_8rect.py b/examples/gdp/strip_packing/strip_packing_8rect.py index 2bd7c4840ca..1169ceb150a 100644 --- a/examples/gdp/strip_packing/strip_packing_8rect.py +++ b/examples/gdp/strip_packing/strip_packing_8rect.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/strip_packing/strip_packing_concrete.py b/examples/gdp/strip_packing/strip_packing_concrete.py index b0907cdea61..57423237748 100644 --- a/examples/gdp/strip_packing/strip_packing_concrete.py +++ b/examples/gdp/strip_packing/strip_packing_concrete.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/gdp/two_rxn_lee/two_rxn_model.py b/examples/gdp/two_rxn_lee/two_rxn_model.py index 98e4cc2e878..3207e21e30c 100644 --- a/examples/gdp/two_rxn_lee/two_rxn_model.py +++ b/examples/gdp/two_rxn_lee/two_rxn_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,19 +11,17 @@ """Two reactor model from literature. See README.md.""" -from pyomo.core import ConcreteModel, Constraint, Objective, Param, Var, maximize - -# from pyomo.environ import * # NOQA +import pyomo.environ as pyo from pyomo.gdp import Disjunction def build_model(use_mccormick=False): """Build the GDP model.""" - m = ConcreteModel() - m.F = Var(bounds=(0, 8), doc="Flow into reactor") - m.X = Var(bounds=(0, 1), doc="Reactor conversion") - m.d = Param(initialize=2, doc="Max product demand") - m.c = Param( + m = pyo.ConcreteModel() + m.F = pyo.Var(bounds=(0, 8), doc="Flow into reactor") + m.X = pyo.Var(bounds=(0, 1), doc="Reactor conversion") + m.d = pyo.Param(initialize=2, doc="Max product demand") + m.c = pyo.Param( [1, 2, 'I', 'II'], doc="Costs", initialize={ @@ -33,23 +31,23 @@ def build_model(use_mccormick=False): 'II': 1.5, # Cost of reactor II }, ) - m.alpha = Param( + m.alpha = pyo.Param( ['I', 'II'], doc="Reactor coefficient", initialize={'I': -8, 'II': -10} ) - m.beta = Param( + m.beta = pyo.Param( ['I', 'II'], doc="Reactor coefficient", initialize={'I': 9, 'II': 15} ) - m.X_LB = Param( + m.X_LB = pyo.Param( ['I', 'II'], doc="Reactor conversion lower bound", initialize={'I': 0.2, 'II': 0.7}, ) - m.X_UB = Param( + m.X_UB = pyo.Param( ['I', 'II'], doc="Reactor conversion upper bound", initialize={'I': 0.95, 'II': 0.99}, ) - m.C_rxn = Var(bounds=(1.5, 2.5), doc="Cost of reactor") + m.C_rxn = pyo.Var(bounds=(1.5, 2.5), doc="Cost of reactor") m.reactor_choice = Disjunction( expr=[ # Disjunct 1: Choose reactor I @@ -70,21 +68,23 @@ def build_model(use_mccormick=False): xor=True, ) if use_mccormick: - m.P = Var(bounds=(0, 8), doc="McCormick approximation of F*X") - m.mccormick_1 = Constraint( + m.P = pyo.Var(bounds=(0, 8), doc="McCormick approximation of F*X") + m.mccormick_1 = pyo.Constraint( expr=m.P <= m.F.lb * m.X + m.F * m.X.ub - m.F.lb * m.X.ub, doc="McCormick overestimator", ) - m.mccormick_2 = Constraint( + m.mccormick_2 = pyo.Constraint( expr=m.P <= m.F.ub * m.X + m.F * m.X.lb - m.F.ub * m.X.lb, doc="McCormick underestimator", ) - m.max_demand = Constraint(expr=m.P <= m.d, doc="product demand") - m.profit = Objective(expr=m.c[1] * m.P - m.c[2] * m.F - m.C_rxn, sense=maximize) + m.max_demand = pyo.Constraint(expr=m.P <= m.d, doc="product demand") + m.profit = pyo.Objective( + expr=m.c[1] * m.P - m.c[2] * m.F - m.C_rxn, sense=pyo.maximize + ) else: - m.max_demand = Constraint(expr=m.F * m.X <= m.d, doc="product demand") - m.profit = Objective( - expr=m.c[1] * m.F * m.X - m.c[2] * m.F - m.C_rxn, sense=maximize + m.max_demand = pyo.Constraint(expr=m.F * m.X <= m.d, doc="product demand") + m.profit = pyo.Objective( + expr=m.c[1] * m.F * m.X - m.c[2] * m.F - m.C_rxn, sense=pyo.maximize ) return m diff --git a/examples/kernel/blocks.py b/examples/kernel/blocks.py index db1cb6655c2..612302fd392 100644 --- a/examples/kernel/blocks.py +++ b/examples/kernel/blocks.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/conic.py b/examples/kernel/conic.py index 5ee66a00ee9..ab2ba0c72bb 100644 --- a/examples/kernel/conic.py +++ b/examples/kernel/conic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/constraints.py b/examples/kernel/constraints.py index 69823a6ebbe..4f6941b3095 100644 --- a/examples/kernel/constraints.py +++ b/examples/kernel/constraints.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/containers.py b/examples/kernel/containers.py index 9ec749b8c3e..15a1fd5193b 100644 --- a/examples/kernel/containers.py +++ b/examples/kernel/containers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/expressions.py b/examples/kernel/expressions.py index faef8d1d4ad..3695d9648d3 100644 --- a/examples/kernel/expressions.py +++ b/examples/kernel/expressions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/mosek/geometric1.py b/examples/kernel/mosek/geometric1.py index 8148e707819..1fa18c20e53 100644 --- a/examples/kernel/mosek/geometric1.py +++ b/examples/kernel/mosek/geometric1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/mosek/geometric2.py b/examples/kernel/mosek/geometric2.py index 3fb62c86312..77c44826709 100644 --- a/examples/kernel/mosek/geometric2.py +++ b/examples/kernel/mosek/geometric2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/mosek/maximum_volume_cuboid.py b/examples/kernel/mosek/maximum_volume_cuboid.py index df200cc801c..128f832e81e 100644 --- a/examples/kernel/mosek/maximum_volume_cuboid.py +++ b/examples/kernel/mosek/maximum_volume_cuboid.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/mosek/power1.py b/examples/kernel/mosek/power1.py index a6d6ebbe47d..fb47d35335f 100644 --- a/examples/kernel/mosek/power1.py +++ b/examples/kernel/mosek/power1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/mosek/semidefinite.py b/examples/kernel/mosek/semidefinite.py index 6be47d85451..97169d90ba2 100644 --- a/examples/kernel/mosek/semidefinite.py +++ b/examples/kernel/mosek/semidefinite.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/objectives.py b/examples/kernel/objectives.py index 27a41f4edb5..9f03ee211e7 100644 --- a/examples/kernel/objectives.py +++ b/examples/kernel/objectives.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/parameters.py b/examples/kernel/parameters.py index e9e412525bb..bb1d96d664d 100644 --- a/examples/kernel/parameters.py +++ b/examples/kernel/parameters.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/piecewise_functions.py b/examples/kernel/piecewise_functions.py index 73a7f680725..87d34eda5f6 100644 --- a/examples/kernel/piecewise_functions.py +++ b/examples/kernel/piecewise_functions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/piecewise_nd_functions.py b/examples/kernel/piecewise_nd_functions.py index 7de37fcbfc6..4525c77303f 100644 --- a/examples/kernel/piecewise_nd_functions.py +++ b/examples/kernel/piecewise_nd_functions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/special_ordered_sets.py b/examples/kernel/special_ordered_sets.py index abacc3d4205..b9ee3f24ca5 100644 --- a/examples/kernel/special_ordered_sets.py +++ b/examples/kernel/special_ordered_sets.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/suffixes.py b/examples/kernel/suffixes.py index ae95fbbdd09..cf15aab3edc 100644 --- a/examples/kernel/suffixes.py +++ b/examples/kernel/suffixes.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/kernel/variables.py b/examples/kernel/variables.py index 36865b58183..f02f82c42d7 100644 --- a/examples/kernel/variables.py +++ b/examples/kernel/variables.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/bard1.py b/examples/mpec/bard1.py index 59955eefb8e..3c092374fec 100644 --- a/examples/mpec/bard1.py +++ b/examples/mpec/bard1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/df.py b/examples/mpec/df.py index 7bb25b11e07..6a1adf08dc0 100644 --- a/examples/mpec/df.py +++ b/examples/mpec/df.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/indexed.py b/examples/mpec/indexed.py index 0aff5de5b20..000ae57e27d 100644 --- a/examples/mpec/indexed.py +++ b/examples/mpec/indexed.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/linear1.py b/examples/mpec/linear1.py index f24fd357e62..408d8a6b910 100644 --- a/examples/mpec/linear1.py +++ b/examples/mpec/linear1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/munson1.py b/examples/mpec/munson1.py index 99c240b5c06..7c37d443753 100644 --- a/examples/mpec/munson1.py +++ b/examples/mpec/munson1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/munson1.yml b/examples/mpec/munson1.yml index 02fb0e09884..544a1cdf7a2 100644 --- a/examples/mpec/munson1.yml +++ b/examples/mpec/munson1.yml @@ -24,7 +24,7 @@ runtime: logfile: null # Redirect output to the specified file. catch errors: true # Trigger failures for exceptions to print # the program stack. - disable gc: false # Disable the garbage collecter. + disable gc: false # Disable the garbage collector. interactive: false # After executing Pyomo, launch an # interactive Python shell. If IPython is # installed, this shell is an IPython diff --git a/examples/mpec/munson1a.py b/examples/mpec/munson1a.py index 67f8f318531..864b8b1565f 100644 --- a/examples/mpec/munson1a.py +++ b/examples/mpec/munson1a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/munson1b.py b/examples/mpec/munson1b.py index 46fff90a785..6a79e4de08b 100644 --- a/examples/mpec/munson1b.py +++ b/examples/mpec/munson1b.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/munson1c.py b/examples/mpec/munson1c.py index dee5b224e75..a2a73d53aa7 100644 --- a/examples/mpec/munson1c.py +++ b/examples/mpec/munson1c.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/munson1d.py b/examples/mpec/munson1d.py index 157177f2eb0..7af4ae83e85 100644 --- a/examples/mpec/munson1d.py +++ b/examples/mpec/munson1d.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/mpec/scholtes4.py b/examples/mpec/scholtes4.py index 8d574dd1916..27bfd7f772a 100644 --- a/examples/mpec/scholtes4.py +++ b/examples/mpec/scholtes4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/performance/dae/run_stochpdegas1_automatic.py b/examples/performance/dae/run_stochpdegas1_automatic.py index fffa1a71ae1..994a231c361 100644 --- a/examples/performance/dae/run_stochpdegas1_automatic.py +++ b/examples/performance/dae/run_stochpdegas1_automatic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,15 +11,14 @@ import time -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo from stochpdegas1_automatic import model start = time.time() instance = model.create_instance('stochpdegas_automatic.dat') # discretize model -discretizer = TransformationFactory('dae.finite_difference') +discretizer = pyo.TransformationFactory('dae.finite_difference') discretizer.apply_to(instance, nfe=1, wrt=instance.DIS, scheme='FORWARD') discretizer.apply_to(instance, nfe=47, wrt=instance.TIME, scheme='BACKWARD') @@ -37,7 +36,7 @@ def supcost_rule(m, k): ) -instance.supcost = Expression(instance.SCEN, rule=supcost_rule) +instance.supcost = pyo.Expression(instance.SCEN, rule=supcost_rule) def boostcost_rule(m, k): @@ -48,7 +47,7 @@ def boostcost_rule(m, k): ) -instance.boostcost = Expression(instance.SCEN, rule=boostcost_rule) +instance.boostcost = pyo.Expression(instance.SCEN, rule=boostcost_rule) def trackcost_rule(m, k): @@ -59,7 +58,7 @@ def trackcost_rule(m, k): ) -instance.trackcost = Expression(instance.SCEN, rule=trackcost_rule) +instance.trackcost = pyo.Expression(instance.SCEN, rule=trackcost_rule) def sspcost_rule(m, k): @@ -70,7 +69,7 @@ def sspcost_rule(m, k): ) -instance.sspcost = Expression(instance.SCEN, rule=sspcost_rule) +instance.sspcost = pyo.Expression(instance.SCEN, rule=sspcost_rule) def ssfcost_rule(m, k): @@ -81,7 +80,7 @@ def ssfcost_rule(m, k): ) -instance.ssfcost = Expression(instance.SCEN, rule=ssfcost_rule) +instance.ssfcost = pyo.Expression(instance.SCEN, rule=ssfcost_rule) def cost_rule(m, k): @@ -90,28 +89,28 @@ def cost_rule(m, k): ) -instance.cost = Expression(instance.SCEN, rule=cost_rule) +instance.cost = pyo.Expression(instance.SCEN, rule=cost_rule) def mcost_rule(m): return (1.0 / m.S) * sum(m.cost[k] for k in m.SCEN) -instance.mcost = Expression(rule=mcost_rule) +instance.mcost = pyo.Expression(rule=mcost_rule) def eqcvar_rule(m, k): return m.cost[k] - m.nu <= m.phi[k] -instance.eqcvar = Constraint(instance.SCEN, rule=eqcvar_rule) +instance.eqcvar = pyo.Constraint(instance.SCEN, rule=eqcvar_rule) def obj_rule(m): return (1.0 - m.cvar_lambda) * m.mcost + m.cvar_lambda * m.cvarcost -instance.obj = Objective(rule=obj_rule) +instance.obj = pyo.Objective(rule=obj_rule) endTime = time.time() - start print('%f seconds required to construct' % endTime) @@ -131,13 +130,13 @@ def obj_rule(m): % ( i, sum( - sum(0.5 * value(instance.pow[i, j, k]) for j in instance.LINK_A) + sum(0.5 * pyo.value(instance.pow[i, j, k]) for j in instance.LINK_A) for k in instance.TIME.get_finite_elements() ), ) ) - solver = SolverFactory('ipopt') + solver = pyo.SolverFactory('ipopt') results = solver.solve(instance, tee=True) for i in instance.SCEN: @@ -146,7 +145,7 @@ def obj_rule(m): % ( i, sum( - sum(0.5 * value(instance.pow[i, j, k]) for j in instance.LINK_A) + sum(0.5 * pyo.value(instance.pow[i, j, k]) for j in instance.LINK_A) for k in instance.TIME.get_finite_elements() ), ) diff --git a/examples/performance/dae/stochpdegas1_automatic.py b/examples/performance/dae/stochpdegas1_automatic.py index ce6132e6cf5..06426f558c4 100644 --- a/examples/performance/dae/stochpdegas1_automatic.py +++ b/examples/performance/dae/stochpdegas1_automatic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,105 +14,109 @@ # -from pyomo.environ import * -from pyomo.dae import * +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar -model = AbstractModel() +model = pyo.AbstractModel() # sets -model.TF = Param(within=NonNegativeReals) +model.TF = pyo.Param(within=pyo.NonNegativeReals) def _tinit(m): - return [0.5, value(m.TF)] + return [0.5, pyo.value(m.TF)] # What it should be to match description in paper - # return [0,value(m.TF)] + # return [0,pyo.value(m.TF)] model.TIME = ContinuousSet(initialize=_tinit) model.DIS = ContinuousSet(bounds=(0.0, 1.0)) -model.S = Param(within=PositiveIntegers) -model.SCEN = RangeSet(1, model.S) +model.S = pyo.Param(within=pyo.PositiveIntegers) +model.SCEN = pyo.RangeSet(1, model.S) # links -model.LINK = Set() -model.lstartloc = Param(model.LINK) -model.lendloc = Param(model.LINK) -model.ldiam = Param(model.LINK, within=PositiveReals, mutable=True) -model.llength = Param(model.LINK, within=PositiveReals, mutable=True) -model.ltype = Param(model.LINK) +model.LINK = pyo.Set() +model.lstartloc = pyo.Param(model.LINK) +model.lendloc = pyo.Param(model.LINK) +model.ldiam = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.llength = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.ltype = pyo.Param(model.LINK) def link_a_init_rule(m): return (l for l in m.LINK if m.ltype[l] == "a") -model.LINK_A = Set(initialize=link_a_init_rule) +model.LINK_A = pyo.Set(initialize=link_a_init_rule) def link_p_init_rule(m): return (l for l in m.LINK if m.ltype[l] == "p") -model.LINK_P = Set(initialize=link_p_init_rule) +model.LINK_P = pyo.Set(initialize=link_p_init_rule) # nodes -model.NODE = Set() -model.pmin = Param(model.NODE, within=PositiveReals, mutable=True) -model.pmax = Param(model.NODE, within=PositiveReals, mutable=True) +model.NODE = pyo.Set() +model.pmin = pyo.Param(model.NODE, within=pyo.PositiveReals, mutable=True) +model.pmax = pyo.Param(model.NODE, within=pyo.PositiveReals, mutable=True) # supply -model.SUP = Set() -model.sloc = Param(model.SUP) -model.smin = Param(model.SUP, within=NonNegativeReals, mutable=True) -model.smax = Param(model.SUP, within=NonNegativeReals, mutable=True) -model.scost = Param(model.SUP, within=NonNegativeReals) +model.SUP = pyo.Set() +model.sloc = pyo.Param(model.SUP) +model.smin = pyo.Param(model.SUP, within=pyo.NonNegativeReals, mutable=True) +model.smax = pyo.Param(model.SUP, within=pyo.NonNegativeReals, mutable=True) +model.scost = pyo.Param(model.SUP, within=pyo.NonNegativeReals) # demand -model.DEM = Set() -model.dloc = Param(model.DEM) -model.d = Param(model.DEM, within=PositiveReals, mutable=True) +model.DEM = pyo.Set() +model.dloc = pyo.Param(model.DEM) +model.d = pyo.Param(model.DEM, within=pyo.PositiveReals, mutable=True) # physical data -model.eps = Param(initialize=0.025, within=PositiveReals) -model.z = Param(initialize=0.80, within=PositiveReals) -model.rhon = Param(initialize=0.72, within=PositiveReals) -model.R = Param(initialize=8314.0, within=PositiveReals) -model.M = Param(initialize=18.0, within=PositiveReals) -model.pi = Param(initialize=3.14, within=PositiveReals) -model.nu2 = Param(within=PositiveReals, mutable=True) -model.lam = Param(model.LINK, within=PositiveReals, mutable=True) -model.A = Param(model.LINK, within=NonNegativeReals, mutable=True) -model.Tgas = Param(initialize=293.15, within=PositiveReals) -model.Cp = Param(initialize=2.34, within=PositiveReals) -model.Cv = Param(initialize=1.85, within=PositiveReals) -model.gam = Param(initialize=model.Cp / model.Cv, within=PositiveReals) -model.om = Param(initialize=(model.gam - 1.0) / model.gam, within=PositiveReals) +model.eps = pyo.Param(initialize=0.025, within=pyo.PositiveReals) +model.z = pyo.Param(initialize=0.80, within=pyo.PositiveReals) +model.rhon = pyo.Param(initialize=0.72, within=pyo.PositiveReals) +model.R = pyo.Param(initialize=8314.0, within=pyo.PositiveReals) +model.M = pyo.Param(initialize=18.0, within=pyo.PositiveReals) +model.pi = pyo.Param(initialize=3.14, within=pyo.PositiveReals) +model.nu2 = pyo.Param(within=pyo.PositiveReals, mutable=True) +model.lam = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.A = pyo.Param(model.LINK, within=pyo.NonNegativeReals, mutable=True) +model.Tgas = pyo.Param(initialize=293.15, within=pyo.PositiveReals) +model.Cp = pyo.Param(initialize=2.34, within=pyo.PositiveReals) +model.Cv = pyo.Param(initialize=1.85, within=pyo.PositiveReals) +model.gam = pyo.Param(initialize=model.Cp / model.Cv, within=pyo.PositiveReals) +model.om = pyo.Param(initialize=(model.gam - 1.0) / model.gam, within=pyo.PositiveReals) # scaling and constants -model.ffac = Param( - within=PositiveReals, initialize=(1.0e6 * model.rhon) / (24.0 * 3600.0) +model.ffac = pyo.Param( + within=pyo.PositiveReals, initialize=(1.0e6 * model.rhon) / (24.0 * 3600.0) ) -model.ffac2 = Param(within=PositiveReals, initialize=(3600.0) / (1.0e4 * model.rhon)) -model.pfac = Param(within=PositiveReals, initialize=1.0e5) -model.pfac2 = Param(within=PositiveReals, initialize=1.0e-5) -model.dfac = Param(within=PositiveReals, initialize=1.0e-3) -model.lfac = Param(within=PositiveReals, initialize=1.0e3) +model.ffac2 = pyo.Param( + within=pyo.PositiveReals, initialize=(3600.0) / (1.0e4 * model.rhon) +) +model.pfac = pyo.Param(within=pyo.PositiveReals, initialize=1.0e5) +model.pfac2 = pyo.Param(within=pyo.PositiveReals, initialize=1.0e-5) +model.dfac = pyo.Param(within=pyo.PositiveReals, initialize=1.0e-3) +model.lfac = pyo.Param(within=pyo.PositiveReals, initialize=1.0e3) -model.c1 = Param(model.LINK, within=PositiveReals, mutable=True) -model.c2 = Param(model.LINK, within=PositiveReals, mutable=True) -model.c3 = Param(model.LINK, within=PositiveReals, mutable=True) -model.c4 = Param(within=PositiveReals, mutable=True) +model.c1 = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.c2 = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.c3 = pyo.Param(model.LINK, within=pyo.PositiveReals, mutable=True) +model.c4 = pyo.Param(within=pyo.PositiveReals, mutable=True) # cost factors -model.ce = Param(initialize=0.1, within=NonNegativeReals) -model.cd = Param(initialize=1.0e6, within=NonNegativeReals) -model.cT = Param(initialize=1.0e6, within=NonNegativeReals) -model.cs = Param(initialize=0.0, within=NonNegativeReals) -model.TDEC = Param(within=PositiveReals) +model.ce = pyo.Param(initialize=0.1, within=pyo.NonNegativeReals) +model.cd = pyo.Param(initialize=1.0e6, within=pyo.NonNegativeReals) +model.cT = pyo.Param(initialize=1.0e6, within=pyo.NonNegativeReals) +model.cs = pyo.Param(initialize=0.0, within=pyo.NonNegativeReals) +model.TDEC = pyo.Param(within=pyo.PositiveReals) # define stochastic info -model.rand_d = Param(model.SCEN, model.DEM, within=NonNegativeReals, mutable=True) +model.rand_d = pyo.Param( + model.SCEN, model.DEM, within=pyo.NonNegativeReals, mutable=True +) # convert units for input data @@ -138,12 +142,12 @@ def rescale_rule(m): m.pmax[i] = m.pmax[i] * m.pfac * m.pfac2 # from bar to Pascals and then to bar -model.rescale = BuildAction(rule=rescale_rule) +model.rescale = pyo.BuildAction(rule=rescale_rule) def compute_constants(m): for i in m.LINK: - m.lam[i] = (2.0 * log10(3.7 * m.ldiam[i] / (m.eps * m.dfac))) ** (-2.0) + m.lam[i] = (2.0 * pyo.log10(3.7 * m.ldiam[i] / (m.eps * m.dfac))) ** (-2.0) m.A[i] = (1.0 / 4.0) * m.pi * m.ldiam[i] * m.ldiam[i] m.nu2 = m.gam * m.z * m.R * m.Tgas / m.M m.c1[i] = (m.pfac2 / m.ffac2) * (m.nu2 / m.A[i]) @@ -157,7 +161,7 @@ def compute_constants(m): m.c4 = (1 / m.ffac2) * (m.Cp * m.Tgas) -model.compute_constants = BuildAction(rule=compute_constants) +model.compute_constants = pyo.BuildAction(rule=compute_constants) # set stochastic demands @@ -172,7 +176,7 @@ def compute_demands_rule(m): m.rand_d[k, j] = 1.3 * m.d[j] -model.compute_demands = BuildAction(rule=compute_demands_rule) +model.compute_demands = pyo.BuildAction(rule=compute_demands_rule) def stochd_init(m, k, j, t): @@ -191,11 +195,11 @@ def stochd_init(m, k, j, t): return m.d[j] -model.stochd = Param( +model.stochd = pyo.Param( model.SCEN, model.DEM, model.TIME, - within=PositiveReals, + within=pyo.PositiveReals, mutable=True, default=stochd_init, ) @@ -203,39 +207,43 @@ def stochd_init(m, k, j, t): # define temporal variables def p_bounds_rule(m, k, j, t): - return (value(m.pmin[j]), value(m.pmax[j])) + return (pyo.value(m.pmin[j]), pyo.value(m.pmax[j])) -model.p = Var(model.SCEN, model.NODE, model.TIME, bounds=p_bounds_rule, initialize=50.0) -model.dp = Var( +model.p = pyo.Var( + model.SCEN, model.NODE, model.TIME, bounds=p_bounds_rule, initialize=50.0 +) +model.dp = pyo.Var( model.SCEN, model.LINK_A, model.TIME, bounds=(0.0, 100.0), initialize=10.0 ) -model.fin = Var( +model.fin = pyo.Var( model.SCEN, model.LINK, model.TIME, bounds=(1.0, 500.0), initialize=100.0 ) -model.fout = Var( +model.fout = pyo.Var( model.SCEN, model.LINK, model.TIME, bounds=(1.0, 500.0), initialize=100.0 ) def s_bounds_rule(m, k, j, t): - return (0.01, value(m.smax[j])) + return (0.01, pyo.value(m.smax[j])) -model.s = Var(model.SCEN, model.SUP, model.TIME, bounds=s_bounds_rule, initialize=10.0) -model.dem = Var(model.SCEN, model.DEM, model.TIME, initialize=100.0) -model.pow = Var( +model.s = pyo.Var( + model.SCEN, model.SUP, model.TIME, bounds=s_bounds_rule, initialize=10.0 +) +model.dem = pyo.Var(model.SCEN, model.DEM, model.TIME, initialize=100.0) +model.pow = pyo.Var( model.SCEN, model.LINK_A, model.TIME, bounds=(0.0, 3000.0), initialize=1000.0 ) -model.slack = Var( +model.slack = pyo.Var( model.SCEN, model.LINK, model.TIME, model.DIS, bounds=(0.0, None), initialize=10.0 ) # define spatio-temporal variables -model.px = Var( +model.px = pyo.Var( model.SCEN, model.LINK, model.TIME, model.DIS, bounds=(10.0, 100.0), initialize=50.0 ) -model.fx = Var( +model.fx = pyo.Var( model.SCEN, model.LINK, model.TIME, model.DIS, bounds=(1.0, 100.0), initialize=100.0 ) @@ -257,19 +265,19 @@ def powereq_rule(m, j, i, t): ) -model.powereq = Constraint(model.SCEN, model.LINK_A, model.TIME, rule=powereq_rule) +model.powereq = pyo.Constraint(model.SCEN, model.LINK_A, model.TIME, rule=powereq_rule) # cvar model -model.cvar_lambda = Param(within=NonNegativeReals) -model.nu = Var(initialize=100.0) -model.phi = Var(model.SCEN, bounds=(0.0, None), initialize=100.0) +model.cvar_lambda = pyo.Param(within=pyo.NonNegativeReals) +model.nu = pyo.Var(initialize=100.0) +model.phi = pyo.Var(model.SCEN, bounds=(0.0, None), initialize=100.0) def cvarcost_rule(m): return (1.0 / m.S) * sum((m.phi[k] / (1.0 - 0.95) + m.nu) for k in m.SCEN) -model.cvarcost = Expression(rule=cvarcost_rule) +model.cvarcost = pyo.Expression(rule=cvarcost_rule) # node balances @@ -283,7 +291,7 @@ def nodeeq_rule(m, k, i, t): ) -model.nodeeq = Constraint(model.SCEN, model.NODE, model.TIME, rule=nodeeq_rule) +model.nodeeq = pyo.Constraint(model.SCEN, model.NODE, model.TIME, rule=nodeeq_rule) # boundary conditions flow @@ -291,51 +299,57 @@ def flow_start_rule(m, j, i, t): return m.fx[j, i, t, m.DIS.first()] == m.fin[j, i, t] -model.flow_start = Constraint(model.SCEN, model.LINK, model.TIME, rule=flow_start_rule) +model.flow_start = pyo.Constraint( + model.SCEN, model.LINK, model.TIME, rule=flow_start_rule +) def flow_end_rule(m, j, i, t): return m.fx[j, i, t, m.DIS.last()] == m.fout[j, i, t] -model.flow_end = Constraint(model.SCEN, model.LINK, model.TIME, rule=flow_end_rule) +model.flow_end = pyo.Constraint(model.SCEN, model.LINK, model.TIME, rule=flow_end_rule) # First PDE for gas network model def flow_rule(m, j, i, t, k): if t == m.TIME.first() or k == m.DIS.last(): - return Constraint.Skip # Do not apply pde at initial time or final location + return pyo.Constraint.Skip # Do not apply pde at initial time or final location return ( m.dpxdt[j, i, t, k] / 3600 + m.c1[i] / m.llength[i] * m.dfxdx[j, i, t, k] == 0 ) -model.flow = Constraint(model.SCEN, model.LINK, model.TIME, model.DIS, rule=flow_rule) +model.flow = pyo.Constraint( + model.SCEN, model.LINK, model.TIME, model.DIS, rule=flow_rule +) # Second PDE for gas network model def press_rule(m, j, i, t, k): if t == m.TIME.first() or k == m.DIS.last(): - return Constraint.Skip # Do not apply pde at initial time or final location + return pyo.Constraint.Skip # Do not apply pde at initial time or final location return ( m.dfxdt[j, i, t, k] / 3600 == -m.c2[i] / m.llength[i] * m.dpxdx[j, i, t, k] - m.slack[j, i, t, k] ) -model.press = Constraint(model.SCEN, model.LINK, model.TIME, model.DIS, rule=press_rule) +model.press = pyo.Constraint( + model.SCEN, model.LINK, model.TIME, model.DIS, rule=press_rule +) def slackeq_rule(m, j, i, t, k): if t == m.TIME.last(): - return Constraint.Skip + return pyo.Constraint.Skip return ( m.slack[j, i, t, k] * m.px[j, i, t, k] == m.c3[i] * m.fx[j, i, t, k] * m.fx[j, i, t, k] ) -model.slackeq = Constraint( +model.slackeq = pyo.Constraint( model.SCEN, model.LINK, model.TIME, model.DIS, rule=slackeq_rule ) @@ -345,7 +359,7 @@ def presspas_start_rule(m, j, i, t): return m.px[j, i, t, m.DIS.first()] == m.p[j, m.lstartloc[i], t] -model.presspas_start = Constraint( +model.presspas_start = pyo.Constraint( model.SCEN, model.LINK_P, model.TIME, rule=presspas_start_rule ) @@ -354,7 +368,7 @@ def presspas_end_rule(m, j, i, t): return m.px[j, i, t, m.DIS.last()] == m.p[j, m.lendloc[i], t] -model.presspas_end = Constraint( +model.presspas_end = pyo.Constraint( model.SCEN, model.LINK_P, model.TIME, rule=presspas_end_rule ) @@ -364,7 +378,7 @@ def pressact_start_rule(m, j, i, t): return m.px[j, i, t, m.DIS.first()] == m.p[j, m.lstartloc[i], t] + m.dp[j, i, t] -model.pressact_start = Constraint( +model.pressact_start = pyo.Constraint( model.SCEN, model.LINK_A, model.TIME, rule=pressact_start_rule ) @@ -373,7 +387,7 @@ def pressact_end_rule(m, j, i, t): return m.px[j, i, t, m.DIS.last()] == m.p[j, m.lendloc[i], t] -model.pressact_end = Constraint( +model.pressact_end = pyo.Constraint( model.SCEN, model.LINK_A, model.TIME, rule=pressact_end_rule ) @@ -383,7 +397,7 @@ def suppress_rule(m, k, j, t): return m.p[k, m.sloc[j], t] == m.pmin[m.sloc[j]] -model.suppress = Constraint(model.SCEN, model.SUP, model.TIME, rule=suppress_rule) +model.suppress = pyo.Constraint(model.SCEN, model.SUP, model.TIME, rule=suppress_rule) # discharge pressure for compressors @@ -391,22 +405,24 @@ def dispress_rule(m, j, i, t): return m.p[j, m.lstartloc[i], t] + m.dp[j, i, t] <= m.pmax[m.lstartloc[i]] -model.dispress = Constraint(model.SCEN, model.LINK_A, model.TIME, rule=dispress_rule) +model.dispress = pyo.Constraint( + model.SCEN, model.LINK_A, model.TIME, rule=dispress_rule +) # ss constraints def flow_ss_rule(m, j, i, k): if k == m.DIS.last(): - return Constraint.Skip + return pyo.Constraint.Skip return m.dfxdx[j, i, m.TIME.first(), k] / m.llength[i] == 0.0 -model.flow_ss = Constraint(model.SCEN, model.LINK, model.DIS, rule=flow_ss_rule) +model.flow_ss = pyo.Constraint(model.SCEN, model.LINK, model.DIS, rule=flow_ss_rule) def pres_ss_rule(m, j, i, k): if k == m.DIS.last(): - return Constraint.Skip + return pyo.Constraint.Skip return ( 0.0 == -m.c2[i] / m.llength[i] * m.dpxdx[j, i, m.TIME.first(), k] @@ -414,27 +430,29 @@ def pres_ss_rule(m, j, i, k): ) -model.pres_ss = Constraint(model.SCEN, model.LINK, model.DIS, rule=pres_ss_rule) +model.pres_ss = pyo.Constraint(model.SCEN, model.LINK, model.DIS, rule=pres_ss_rule) # non-anticipativity constraints def nonantdq_rule(m, j, i, t): if j == 1: - return Constraint.Skip + return pyo.Constraint.Skip if t >= m.TDEC + 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.dp[j, i, t] == m.dp[1, i, t] -model.nonantdq = Constraint(model.SCEN, model.LINK_A, model.TIME, rule=nonantdq_rule) +model.nonantdq = pyo.Constraint( + model.SCEN, model.LINK_A, model.TIME, rule=nonantdq_rule +) def nonantde_rule(m, j, i, t): if j == 1: - return Constraint.Skip + return pyo.Constraint.Skip if t >= m.TDEC + 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.dem[j, i, t] == m.dem[1, i, t] -model.nonantde = Constraint(model.SCEN, model.DEM, model.TIME, rule=nonantde_rule) +model.nonantde = pyo.Constraint(model.SCEN, model.DEM, model.TIME, rule=nonantde_rule) diff --git a/examples/performance/jump/clnlbeam.py b/examples/performance/jump/clnlbeam.py index 410068a6753..d00047e927f 100644 --- a/examples/performance/jump/clnlbeam.py +++ b/examples/performance/jump/clnlbeam.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,22 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.N = Param(within=PositiveIntegers) +model.N = pyo.Param(within=pyo.PositiveIntegers) model.h = 1.0 / model.N -model.VarIdx = RangeSet(model.N + 1) +model.VarIdx = pyo.RangeSet(model.N + 1) -model.t = Var( - model.VarIdx, bounds=(-1.0, 1.0), initialize=lambda m, i: 0.05 * cos(i * m.h) +model.t = pyo.Var( + model.VarIdx, bounds=(-1.0, 1.0), initialize=lambda m, i: 0.05 * pyo.cos(i * m.h) ) -model.x = Var( - model.VarIdx, bounds=(-0.05, 0.05), initialize=lambda m, i: 0.05 * cos(i * m.h) +model.x = pyo.Var( + model.VarIdx, bounds=(-0.05, 0.05), initialize=lambda m, i: 0.05 * pyo.cos(i * m.h) ) -model.u = Var(model.VarIdx, initialize=0.01) +model.u = pyo.Var(model.VarIdx, initialize=0.01) alpha = 350 @@ -35,27 +35,29 @@ def c_rule(m): if i == m.N + 1: continue ex += 0.5 * m.h * (m.u[i + 1] ** 2 + m.u[i] ** 2) + 0.5 * alpha * m.h * ( - cos(m.t[i + 1]) + cos(m.t[i]) + pyo.cos(m.t[i + 1]) + pyo.cos(m.t[i]) ) return ex -model.c = Objective(rule=c_rule) +model.c = pyo.Objective(rule=c_rule) def cons1_rule(m, i): if i == m.N + 1: - return Constraint.Skip - return m.x[i + 1] - m.x[i] - (0.5 * m.h) * (sin(m.t[i + 1]) + sin(m.t[i])) == 0 + return pyo.Constraint.Skip + return ( + m.x[i + 1] - m.x[i] - (0.5 * m.h) * (pyo.sin(m.t[i + 1]) + pyo.sin(m.t[i])) == 0 + ) -model.cons1 = Constraint(model.VarIdx, rule=cons1_rule) +model.cons1 = pyo.Constraint(model.VarIdx, rule=cons1_rule) def cons2_rule(m, i): if i == m.N + 1: - return Constraint.Skip + return pyo.Constraint.Skip return m.t[i + 1] - m.t[i] - (0.5 * m.h) * m.u[i + 1] - (0.5 * m.h) * m.u[i] == 0 -model.cons2 = Constraint(model.VarIdx, rule=cons2_rule) +model.cons2 = pyo.Constraint(model.VarIdx, rule=cons2_rule) diff --git a/examples/performance/jump/facility.py b/examples/performance/jump/facility.py index fa0c306d6e5..462116aab3a 100644 --- a/examples/performance/jump/facility.py +++ b/examples/performance/jump/facility.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,36 +9,36 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.G = 25 # Param(within=PositiveIntegers) -model.F = 25 # Param(within=PositiveIntegers) +model.G = 25 # pyo.Param(within=pyo.PositiveIntegers) +model.F = 25 # pyo.Param(within=pyo.PositiveIntegers) -model.Grid = RangeSet(0, model.G) -model.Facs = RangeSet(1, model.F) -model.Dims = RangeSet(1, 2) +model.Grid = pyo.RangeSet(0, model.G) +model.Facs = pyo.RangeSet(1, model.F) +model.Dims = pyo.RangeSet(1, 2) -model.d = Var() -model.y = Var(model.Facs, model.Dims, bounds=(0.0, 1.0)) -model.z = Var(model.Grid, model.Grid, model.Facs, within=Binary) -model.s = Var(model.Grid, model.Grid, model.Facs, bounds=(0.0, None)) -model.r = Var(model.Grid, model.Grid, model.Facs, model.Dims) +model.d = pyo.Var() +model.y = pyo.Var(model.Facs, model.Dims, bounds=(0.0, 1.0)) +model.z = pyo.Var(model.Grid, model.Grid, model.Facs, within=pyo.Binary) +model.s = pyo.Var(model.Grid, model.Grid, model.Facs, bounds=(0.0, None)) +model.r = pyo.Var(model.Grid, model.Grid, model.Facs, model.Dims) def obj_rule(mod): return 1.0 * mod.d -model.obj = Objective(rule=obj_rule) +model.obj = pyo.Objective(rule=obj_rule) def assmt_rule(mod, i, j): return sum([mod.z[i, j, f] for f in mod.Facs]) == 1 -model.assmt = Constraint(model.Grid, model.Grid, rule=assmt_rule) +model.assmt = pyo.Constraint(model.Grid, model.Grid, rule=assmt_rule) M = 2 * 1.414 @@ -47,25 +47,29 @@ def quadrhs_rule(mod, i, j, f): return mod.s[i, j, f] == mod.d + M * (1 - mod.z[i, j, f]) -model.quadrhs = Constraint(model.Grid, model.Grid, model.Facs, rule=quadrhs_rule) +model.quadrhs = pyo.Constraint(model.Grid, model.Grid, model.Facs, rule=quadrhs_rule) def quaddistk1_rule(mod, i, j, f): return mod.r[i, j, f, 1] == (1.0 * i) / mod.G - mod.y[f, 1] -model.quaddistk1 = Constraint(model.Grid, model.Grid, model.Facs, rule=quaddistk1_rule) +model.quaddistk1 = pyo.Constraint( + model.Grid, model.Grid, model.Facs, rule=quaddistk1_rule +) def quaddistk2_rule(mod, i, j, f): return mod.r[i, j, f, 2] == (1.0 * j) / mod.G - mod.y[f, 2] -model.quaddistk2 = Constraint(model.Grid, model.Grid, model.Facs, rule=quaddistk2_rule) +model.quaddistk2 = pyo.Constraint( + model.Grid, model.Grid, model.Facs, rule=quaddistk2_rule +) def quaddist_rule(mod, i, j, f): return mod.r[i, j, f, 1] ** 2 + mod.r[i, j, f, 2] ** 2 <= mod.s[i, j, f] ** 2 -model.quaddist = Constraint(model.Grid, model.Grid, model.Facs, rule=quaddist_rule) +model.quaddist = pyo.Constraint(model.Grid, model.Grid, model.Facs, rule=quaddist_rule) diff --git a/examples/performance/jump/lqcp.py b/examples/performance/jump/lqcp.py index b8ef096d7be..db7e5b3b774 100644 --- a/examples/performance/jump/lqcp.py +++ b/examples/performance/jump/lqcp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,9 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = ConcreteModel() +model = pyo.ConcreteModel() model.n = 1000 model.m = 1000 @@ -21,11 +21,11 @@ model.h2 = model.dx**2 model.a = 0.001 -model.ns = RangeSet(0, model.n) -model.ms = RangeSet(0, model.n) +model.ns = pyo.RangeSet(0, model.n) +model.ms = pyo.RangeSet(0, model.n) -model.y = Var(model.ms, model.ns, bounds=(0.0, 1.0)) -model.u = Var(model.ms, bounds=(-1.0, 1.0)) +model.y = pyo.Var(model.ms, model.ns, bounds=(0.0, 1.0)) +model.u = pyo.Var(model.ms, bounds=(-1.0, 1.0)) def yt(j, dx): @@ -43,7 +43,7 @@ def rule(model): ) -model.obj = Objective(rule=rule) +model.obj = pyo.Objective(rule=rule) def pde_rule(model, i, j): @@ -57,8 +57,8 @@ def pde_rule(model, i, j): ) / model.h2 -model.pde = Constraint( - RangeSet(0, model.n - 1), RangeSet(1, model.n - 1), rule=pde_rule +model.pde = pyo.Constraint( + pyo.RangeSet(0, model.n - 1), pyo.RangeSet(1, model.n - 1), rule=pde_rule ) @@ -66,14 +66,14 @@ def ic_rule(model, j): return model.y[0, j] == 0 -model.ic = Constraint(model.ns, rule=ic_rule) +model.ic = pyo.Constraint(model.ns, rule=ic_rule) def bc1_rule(model, i): return model.y[i, 2] - 4 * model.y[i, 1] + 3 * model.y[i, 0] == 0 -model.bc1 = Constraint(RangeSet(1, model.n), rule=bc1_rule) +model.bc1 = pyo.Constraint(pyo.RangeSet(1, model.n), rule=bc1_rule) def bc2_rule(model, i): @@ -82,4 +82,4 @@ def bc2_rule(model, i): ] == (2 * model.dx) * (model.u[i] - model.y[i, model.n - 0]) -model.bc2 = Constraint(RangeSet(1, model.n), rule=bc2_rule) +model.bc2 = pyo.Constraint(pyo.RangeSet(1, model.n), rule=bc2_rule) diff --git a/examples/performance/jump/opf_66200bus.py b/examples/performance/jump/opf_66200bus.py index 702ff59a61c..6af5071eb50 100644 --- a/examples/performance/jump/opf_66200bus.py +++ b/examples/performance/jump/opf_66200bus.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo class Bus: @@ -97,9 +97,9 @@ class Branch: assert b.to >= 0 and b.to < nbus -model = ConcreteModel() +model = pyo.ConcreteModel() -model.bus_voltage = Var( +model.bus_voltage = pyo.Var( range(nbus), bounds=lambda model, i: ( bus_voltage_min[bus[i].bustype], @@ -107,17 +107,17 @@ class Branch: ), initialize=1, ) -model.bus_b_shunt = Var( +model.bus_b_shunt = pyo.Var( range(nbus), bounds=lambda model, i: (bus[i].b_shunt_min, bus[i].b_shunt_max), initialize=lambda model, i: bus[i].b_shunt0, ) -model.bus_angle = Var(range(nbus), initialize=0) +model.bus_angle = pyo.Var(range(nbus), initialize=0) -model.branch_tap = Var( +model.branch_tap = pyo.Var( range(nbranch), bounds=(branch_tap_min, branch_tap_max), initialize=1 ) -model.branch_def = Var( +model.branch_def = pyo.Var( range(nbranch), bounds=lambda model, i: (branch[i].def_min, branch[i].def_max), initialize=lambda model, i: branch[i].def0, @@ -134,13 +134,15 @@ def Gself(k): def Gout(i): return ( - -branch[i].g * cos(model.branch_def[i]) + branch[i].b * sin(model.branch_def[i]) + -branch[i].g * pyo.cos(model.branch_def[i]) + + branch[i].b * pyo.sin(model.branch_def[i]) ) * model.branch_tap[i] def Gin(i): return ( - -branch[i].g * cos(model.branch_def[i]) - branch[i].b * sin(model.branch_def[i]) + -branch[i].g * pyo.cos(model.branch_def[i]) + - branch[i].b * pyo.sin(model.branch_def[i]) ) * model.branch_tap[i] @@ -157,17 +159,19 @@ def Bself(k): def Bin(i): return ( - branch[i].g * sin(model.branch_def[i]) - branch[i].b * cos(model.branch_def[i]) + branch[i].g * pyo.sin(model.branch_def[i]) + - branch[i].b * pyo.cos(model.branch_def[i]) ) * model.branch_tap[i] def Bout(i): return ( - -branch[i].g * sin(model.branch_def[i]) - branch[i].b * cos(model.branch_def[i]) + -branch[i].g * pyo.sin(model.branch_def[i]) + - branch[i].b * pyo.cos(model.branch_def[i]) ) * model.branch_tap[i] -model.obj = Objective( +model.obj = pyo.Objective( expr=sum( ( bus[k].p_load @@ -175,8 +179,10 @@ def Bout(i): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) + * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) + * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -184,8 +190,10 @@ def Bout(i): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) + * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) + * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -200,7 +208,7 @@ def Bout(i): def p_load_rule(model, k): if bus[k].bustype != 0: - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].p_gen @@ -209,8 +217,8 @@ def p_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -218,8 +226,8 @@ def p_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -228,12 +236,12 @@ def p_load_rule(model, k): ) -model.p_load_constr = Constraint(range(nbus), rule=p_load_rule) +model.p_load_constr = pyo.Constraint(range(nbus), rule=p_load_rule) def q_load_rule(model, k): if bus[k].bustype != 0: - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].q_gen @@ -242,8 +250,8 @@ def q_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - - Bin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + - Bin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -251,8 +259,8 @@ def q_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) - - Bout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + - Bout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -261,12 +269,12 @@ def q_load_rule(model, k): ) -model.q_load_constr = Constraint(range(nbus), rule=q_load_rule) +model.q_load_constr = pyo.Constraint(range(nbus), rule=q_load_rule) def q_inj_rule(model, k): if not (bus[k].bustype == 2 or bus[k].bustype == 3): - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].q_min, @@ -275,8 +283,8 @@ def q_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - - Bin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + - Bin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -284,8 +292,8 @@ def q_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) - - Bout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + - Bout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -294,12 +302,12 @@ def q_inj_rule(model, k): ) -model.q_inj_rule = Constraint(range(nbus), rule=q_inj_rule) +model.q_inj_rule = pyo.Constraint(range(nbus), rule=q_inj_rule) def p_inj_rule(model, k): if not (bus[k].bustype == 2 or bus[k].bustype == 3): - return Constraint.Skip + return pyo.Constraint.Skip return ( 0, @@ -308,8 +316,8 @@ def p_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -317,8 +325,8 @@ def p_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -327,7 +335,7 @@ def p_inj_rule(model, k): ) -model.p_inj_rule = Constraint(range(nbus), rule=p_inj_rule) +model.p_inj_rule = pyo.Constraint(range(nbus), rule=p_inj_rule) for i in range(nbus): diff --git a/examples/performance/jump/opf_6620bus.py b/examples/performance/jump/opf_6620bus.py index 34b910f43c0..bf02b63b5e8 100644 --- a/examples/performance/jump/opf_6620bus.py +++ b/examples/performance/jump/opf_6620bus.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo class Bus: @@ -97,9 +97,9 @@ class Branch: assert b.to >= 0 and b.to < nbus -model = ConcreteModel() +model = pyo.ConcreteModel() -model.bus_voltage = Var( +model.bus_voltage = pyo.Var( range(nbus), bounds=lambda model, i: ( bus_voltage_min[bus[i].bustype], @@ -107,17 +107,17 @@ class Branch: ), initialize=1, ) -model.bus_b_shunt = Var( +model.bus_b_shunt = pyo.Var( range(nbus), bounds=lambda model, i: (bus[i].b_shunt_min, bus[i].b_shunt_max), initialize=lambda model, i: bus[i].b_shunt0, ) -model.bus_angle = Var(range(nbus), initialize=0) +model.bus_angle = pyo.Var(range(nbus), initialize=0) -model.branch_tap = Var( +model.branch_tap = pyo.Var( range(nbranch), bounds=(branch_tap_min, branch_tap_max), initialize=1 ) -model.branch_def = Var( +model.branch_def = pyo.Var( range(nbranch), bounds=lambda model, i: (branch[i].def_min, branch[i].def_max), initialize=lambda model, i: branch[i].def0, @@ -134,13 +134,15 @@ def Gself(k): def Gout(i): return ( - -branch[i].g * cos(model.branch_def[i]) + branch[i].b * sin(model.branch_def[i]) + -branch[i].g * pyo.cos(model.branch_def[i]) + + branch[i].b * pyo.sin(model.branch_def[i]) ) * model.branch_tap[i] def Gin(i): return ( - -branch[i].g * cos(model.branch_def[i]) - branch[i].b * sin(model.branch_def[i]) + -branch[i].g * pyo.cos(model.branch_def[i]) + - branch[i].b * pyo.sin(model.branch_def[i]) ) * model.branch_tap[i] @@ -157,17 +159,19 @@ def Bself(k): def Bin(i): return ( - branch[i].g * sin(model.branch_def[i]) - branch[i].b * cos(model.branch_def[i]) + branch[i].g * pyo.sin(model.branch_def[i]) + - branch[i].b * pyo.cos(model.branch_def[i]) ) * model.branch_tap[i] def Bout(i): return ( - -branch[i].g * sin(model.branch_def[i]) - branch[i].b * cos(model.branch_def[i]) + -branch[i].g * pyo.sin(model.branch_def[i]) + - branch[i].b * pyo.cos(model.branch_def[i]) ) * model.branch_tap[i] -model.obj = Objective( +model.obj = pyo.Objective( expr=sum( ( bus[k].p_load @@ -175,8 +179,10 @@ def Bout(i): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) + * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) + * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -184,8 +190,10 @@ def Bout(i): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) + * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) + * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -200,7 +208,7 @@ def Bout(i): def p_load_rule(model, k): if bus[k].bustype != 0: - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].p_gen @@ -209,8 +217,8 @@ def p_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -218,8 +226,8 @@ def p_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -228,12 +236,12 @@ def p_load_rule(model, k): ) -model.p_load_constr = Constraint(range(nbus), rule=p_load_rule) +model.p_load_constr = pyo.Constraint(range(nbus), rule=p_load_rule) def q_load_rule(model, k): if bus[k].bustype != 0: - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].q_gen @@ -242,8 +250,8 @@ def q_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - - Bin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + - Bin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -251,8 +259,8 @@ def q_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) - - Bout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + - Bout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -261,12 +269,12 @@ def q_load_rule(model, k): ) -model.q_load_constr = Constraint(range(nbus), rule=q_load_rule) +model.q_load_constr = pyo.Constraint(range(nbus), rule=q_load_rule) def q_inj_rule(model, k): if not (bus[k].bustype == 2 or bus[k].bustype == 3): - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].q_min, @@ -275,8 +283,8 @@ def q_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - - Bin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + - Bin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -284,8 +292,8 @@ def q_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) - - Bout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + - Bout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -294,12 +302,12 @@ def q_inj_rule(model, k): ) -model.q_inj_rule = Constraint(range(nbus), rule=q_inj_rule) +model.q_inj_rule = pyo.Constraint(range(nbus), rule=q_inj_rule) def p_inj_rule(model, k): if not (bus[k].bustype == 2 or bus[k].bustype == 3): - return Constraint.Skip + return pyo.Constraint.Skip return ( 0, @@ -308,8 +316,8 @@ def p_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -317,8 +325,8 @@ def p_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -327,7 +335,7 @@ def p_inj_rule(model, k): ) -model.p_inj_rule = Constraint(range(nbus), rule=p_inj_rule) +model.p_inj_rule = pyo.Constraint(range(nbus), rule=p_inj_rule) for i in range(nbus): diff --git a/examples/performance/jump/opf_662bus.py b/examples/performance/jump/opf_662bus.py index 8a768ca16e0..672aa0e679b 100644 --- a/examples/performance/jump/opf_662bus.py +++ b/examples/performance/jump/opf_662bus.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo class Bus: @@ -97,9 +97,9 @@ class Branch: assert b.to >= 0 and b.to < nbus -model = ConcreteModel() +model = pyo.ConcreteModel() -model.bus_voltage = Var( +model.bus_voltage = pyo.Var( range(nbus), bounds=lambda model, i: ( bus_voltage_min[bus[i].bustype], @@ -107,17 +107,17 @@ class Branch: ), initialize=1, ) -model.bus_b_shunt = Var( +model.bus_b_shunt = pyo.Var( range(nbus), bounds=lambda model, i: (bus[i].b_shunt_min, bus[i].b_shunt_max), initialize=lambda model, i: bus[i].b_shunt0, ) -model.bus_angle = Var(range(nbus), initialize=0) +model.bus_angle = pyo.Var(range(nbus), initialize=0) -model.branch_tap = Var( +model.branch_tap = pyo.Var( range(nbranch), bounds=(branch_tap_min, branch_tap_max), initialize=1 ) -model.branch_def = Var( +model.branch_def = pyo.Var( range(nbranch), bounds=lambda model, i: (branch[i].def_min, branch[i].def_max), initialize=lambda model, i: branch[i].def0, @@ -134,13 +134,15 @@ def Gself(k): def Gout(i): return ( - -branch[i].g * cos(model.branch_def[i]) + branch[i].b * sin(model.branch_def[i]) + -branch[i].g * pyo.cos(model.branch_def[i]) + + branch[i].b * pyo.sin(model.branch_def[i]) ) * model.branch_tap[i] def Gin(i): return ( - -branch[i].g * cos(model.branch_def[i]) - branch[i].b * sin(model.branch_def[i]) + -branch[i].g * pyo.cos(model.branch_def[i]) + - branch[i].b * pyo.sin(model.branch_def[i]) ) * model.branch_tap[i] @@ -157,17 +159,19 @@ def Bself(k): def Bin(i): return ( - branch[i].g * sin(model.branch_def[i]) - branch[i].b * cos(model.branch_def[i]) + branch[i].g * pyo.sin(model.branch_def[i]) + - branch[i].b * pyo.cos(model.branch_def[i]) ) * model.branch_tap[i] def Bout(i): return ( - -branch[i].g * sin(model.branch_def[i]) - branch[i].b * cos(model.branch_def[i]) + -branch[i].g * pyo.sin(model.branch_def[i]) + - branch[i].b * pyo.cos(model.branch_def[i]) ) * model.branch_tap[i] -model.obj = Objective( +model.obj = pyo.Objective( expr=sum( ( bus[k].p_load @@ -175,8 +179,10 @@ def Bout(i): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) + * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) + * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -184,8 +190,10 @@ def Bout(i): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) + * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) + * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -200,7 +208,7 @@ def Bout(i): def p_load_rule(model, k): if bus[k].bustype != 0: - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].p_gen @@ -209,8 +217,8 @@ def p_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -218,8 +226,8 @@ def p_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -228,12 +236,12 @@ def p_load_rule(model, k): ) -model.p_load_constr = Constraint(range(nbus), rule=p_load_rule) +model.p_load_constr = pyo.Constraint(range(nbus), rule=p_load_rule) def q_load_rule(model, k): if bus[k].bustype != 0: - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].q_gen @@ -242,8 +250,8 @@ def q_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - - Bin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + - Bin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -251,8 +259,8 @@ def q_load_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) - - Bout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + - Bout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -261,12 +269,12 @@ def q_load_rule(model, k): ) -model.q_load_constr = Constraint(range(nbus), rule=q_load_rule) +model.q_load_constr = pyo.Constraint(range(nbus), rule=q_load_rule) def q_inj_rule(model, k): if not (bus[k].bustype == 2 or bus[k].bustype == 3): - return Constraint.Skip + return pyo.Constraint.Skip return ( bus[k].q_min, @@ -275,8 +283,8 @@ def q_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - - Bin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + - Bin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -284,8 +292,8 @@ def q_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) - - Bout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + - Bout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -294,12 +302,12 @@ def q_inj_rule(model, k): ) -model.q_inj_rule = Constraint(range(nbus), rule=q_inj_rule) +model.q_inj_rule = pyo.Constraint(range(nbus), rule=q_inj_rule) def p_inj_rule(model, k): if not (bus[k].bustype == 2 or bus[k].bustype == 3): - return Constraint.Skip + return pyo.Constraint.Skip return ( 0, @@ -308,8 +316,8 @@ def p_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].frm] * ( - Gin(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) - + Bin(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + Gin(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].frm]) + + Bin(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].frm]) ) for i in in_lines[k] ) @@ -317,8 +325,8 @@ def p_inj_rule(model, k): model.bus_voltage[k] * model.bus_voltage[branch[i].to] * ( - Gout(i) * cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) - + Bout(i) * sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) + Gout(i) * pyo.cos(model.bus_angle[k] - model.bus_angle[branch[i].to]) + + Bout(i) * pyo.sin(model.bus_angle[k] - model.bus_angle[branch[i].to]) ) for i in out_lines[k] ) @@ -327,7 +335,7 @@ def p_inj_rule(model, k): ) -model.p_inj_rule = Constraint(range(nbus), rule=p_inj_rule) +model.p_inj_rule = pyo.Constraint(range(nbus), rule=p_inj_rule) for i in range(nbus): diff --git a/examples/performance/misc/bilinear1_100.py b/examples/performance/misc/bilinear1_100.py index d86091c4c76..90ee4e7e4cc 100644 --- a/examples/performance/misc/bilinear1_100.py +++ b/examples/performance/misc/bilinear1_100.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,21 +9,21 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def create_model(N): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.A = RangeSet(N) - model.x = Var(model.A, bounds=(1, 2)) + model.A = pyo.RangeSet(N) + model.x = pyo.Var(model.A, bounds=(1, 2)) expr = 0 for i in model.A: if not (i + 1) in model.A: continue expr += i * (model.x[i] * model.x[i + 1] + 1) - model.obj = Objective(expr=expr) + model.obj = pyo.Objective(expr=expr) return model diff --git a/examples/performance/misc/bilinear1_100000.py b/examples/performance/misc/bilinear1_100000.py index 0fa2eafedc6..5dc26bedc60 100644 --- a/examples/performance/misc/bilinear1_100000.py +++ b/examples/performance/misc/bilinear1_100000.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,21 +9,21 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def create_model(N): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.A = RangeSet(N) - model.x = Var(model.A, bounds=(1, 2)) + model.A = pyo.RangeSet(N) + model.x = pyo.Var(model.A, bounds=(1, 2)) expr = 0 for i in model.A: if not (i + 1) in model.A: continue expr += i * (model.x[i] * model.x[i + 1] + 1) - model.obj = Objective(expr=expr) + model.obj = pyo.Objective(expr=expr) return model diff --git a/examples/performance/misc/bilinear2_100.py b/examples/performance/misc/bilinear2_100.py index 227bfe000e0..9c21d19f986 100644 --- a/examples/performance/misc/bilinear2_100.py +++ b/examples/performance/misc/bilinear2_100.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,21 +9,21 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def create_model(N): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.A = RangeSet(N) - model.x = Var(model.A, bounds=(1, 2)) + model.A = pyo.RangeSet(N) + model.x = pyo.Var(model.A, bounds=(1, 2)) - with nonlinear_expression as expr: + with pyo.nonlinear_expression as expr: for i in model.A: if not (i + 1) in model.A: continue expr += i * (model.x[i] * model.x[i + 1] + 1) - model.obj = Objective(expr=expr) + model.obj = pyo.Objective(expr=expr) return model diff --git a/examples/performance/misc/bilinear2_100000.py b/examples/performance/misc/bilinear2_100000.py index 9d2a4d6fb7c..805fccc9a9a 100644 --- a/examples/performance/misc/bilinear2_100000.py +++ b/examples/performance/misc/bilinear2_100000.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,21 +9,21 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def create_model(N): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.A = RangeSet(N) - model.x = Var(model.A, bounds=(1, 2)) + model.A = pyo.RangeSet(N) + model.x = pyo.Var(model.A, bounds=(1, 2)) - with nonlinear_expression as expr: + with pyo.nonlinear_expression as expr: for i in model.A: if not (i + 1) in model.A: continue expr += i * (model.x[i] * model.x[i + 1] + 1) - model.obj = Objective(expr=expr) + model.obj = pyo.Objective(expr=expr) return model diff --git a/examples/performance/misc/diag1_100.py b/examples/performance/misc/diag1_100.py index 369d81982f0..97034c8b679 100644 --- a/examples/performance/misc/diag1_100.py +++ b/examples/performance/misc/diag1_100.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,22 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def create_model(N): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.A = RangeSet(N) - model.x = Var(model.A) + model.A = pyo.RangeSet(N) + model.x = pyo.Var(model.A) expr = sum(i * model.x[i] for i in model.A) - model.obj = Objective(expr=expr) + model.obj = pyo.Objective(expr=expr) def c_rule(model, i): return (N - i + 1) * model.x[i] >= N - model.c = Constraint(model.A) + model.c = pyo.Constraint(model.A) return model diff --git a/examples/performance/misc/diag1_100000.py b/examples/performance/misc/diag1_100000.py index 536758fda5d..8a94c85419c 100644 --- a/examples/performance/misc/diag1_100000.py +++ b/examples/performance/misc/diag1_100000.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,22 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def create_model(N): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.A = RangeSet(N) - model.x = Var(model.A) + model.A = pyo.RangeSet(N) + model.x = pyo.Var(model.A) expr = sum(i * model.x[i] for i in model.A) - model.obj = Objective(expr=expr) + model.obj = pyo.Objective(expr=expr) def c_rule(model, i): return (N - i + 1) * model.x[i] >= N - model.c = Constraint(model.A) + model.c = pyo.Constraint(model.A) return model diff --git a/examples/performance/misc/diag2_100.py b/examples/performance/misc/diag2_100.py index 6ad47528ff2..c3ef1eacfaa 100644 --- a/examples/performance/misc/diag2_100.py +++ b/examples/performance/misc/diag2_100.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,22 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def create_model(N): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.A = RangeSet(N) - model.x = Var(model.A) + model.A = pyo.RangeSet(N) + model.x = pyo.Var(model.A) - expr = Sum(i * model.x[i] for i in model.A) - model.obj = Objective(expr=expr) + expr = pyo.quicksum(i * model.x[i] for i in model.A) + model.obj = pyo.Objective(expr=expr) def c_rule(model, i): return (N - i + 1) * model.x[i] >= N - model.c = Constraint(model.A) + model.c = pyo.Constraint(model.A) return model diff --git a/examples/performance/misc/diag2_100000.py b/examples/performance/misc/diag2_100000.py index b95e2dd1d6f..e59f765a5b9 100644 --- a/examples/performance/misc/diag2_100000.py +++ b/examples/performance/misc/diag2_100000.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,22 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def create_model(N): - model = ConcreteModel() + model = pyo.ConcreteModel() - model.A = RangeSet(N) - model.x = Var(model.A) + model.A = pyo.RangeSet(N) + model.x = pyo.Var(model.A) - expr = Sum(i * model.x[i] for i in model.A) - model.obj = Objective(expr=expr) + expr = pyo.quicksum(i * model.x[i] for i in model.A) + model.obj = pyo.Objective(expr=expr) def c_rule(model, i): return (N - i + 1) * model.x[i] >= N - model.c = Constraint(model.A) + model.c = pyo.Constraint(model.A) return model diff --git a/examples/performance/misc/set1.py b/examples/performance/misc/set1.py index 8a8b84fdcc3..fb251b784b3 100644 --- a/examples/performance/misc/set1.py +++ b/examples/performance/misc/set1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,25 +9,25 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = ConcreteModel() +model = pyo.ConcreteModel() -model.d = Param(default=10) +model.d = pyo.Param(default=10) def A_rule(model): - return range(0, value(model.d)) + return range(0, pyo.value(model.d)) -model.A = Set() +model.A = pyo.Set() def B_rule(model): - return range(1, value(model.d) + 1) + return range(1, pyo.value(model.d) + 1) -model.B = Set() +model.B = pyo.Set() if 1 > 0: model.X1 = model.A * model.B @@ -36,7 +36,7 @@ def B_rule(model): model.X4 = model.B - model.A model.X5 = model.B & model.A -model.Y = Set(initialize=model.B - model.A) +model.Y = pyo.Set(initialize=model.B - model.A) model.Y.add('foo') if 1 > 0: diff --git a/examples/performance/misc/sparse1.py b/examples/performance/misc/sparse1.py index b4883d379bc..7f80ae9d83b 100644 --- a/examples/performance/misc/sparse1.py +++ b/examples/performance/misc/sparse1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,21 +12,21 @@ # # This is a performance test that we cannot easily execute right now # -from pyomo.environ import * +import pyomo.environ as pyo def f(N): - M = ConcreteModel() - M.A = Set(initialize=range(N)) - M.x = Var() - M.o = Objective(expr=M.x) + M = pyo.ConcreteModel() + M.A = pyo.Set(initialize=range(N)) + M.x = pyo.Var() + M.o = pyo.Objective(expr=M.x) def rule(m, i): if i == 3 or i == 5: return M.x >= i - return Constraint.Skip + return pyo.Constraint.Skip - M.c = Constraint(M.A, rule=rule) + M.c = pyo.Constraint(M.A, rule=rule) return M diff --git a/examples/performance/pmedian/pmedian1.py b/examples/performance/pmedian/pmedian1.py index a22540efdd5..a9f708be85f 100644 --- a/examples/performance/pmedian/pmedian1.py +++ b/examples/performance/pmedian/pmedian1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def pyomo_create_model(options=None, model_options=None): @@ -18,28 +18,30 @@ def pyomo_create_model(options=None, model_options=None): random.seed(1000) - model = AbstractModel() + model = pyo.AbstractModel() - model.N = Param(within=PositiveIntegers) + model.N = pyo.Param(within=pyo.PositiveIntegers) - model.Locations = RangeSet(1, model.N) + model.Locations = pyo.RangeSet(1, model.N) - model.P = Param(within=RangeSet(1, model.N)) + model.P = pyo.Param(within=pyo.RangeSet(1, model.N)) - model.M = Param(within=PositiveIntegers) + model.M = pyo.Param(within=pyo.PositiveIntegers) - model.Customers = RangeSet(1, model.M) + model.Customers = pyo.RangeSet(1, model.M) - model.d = Param( + model.d = pyo.Param( model.Locations, model.Customers, initialize=lambda n, m, model: random.uniform(1.0, 2.0), - within=Reals, + within=pyo.Reals, ) - model.x = Var(model.Locations, model.Customers, bounds=(0.0, 1.0), initialize=0.0) + model.x = pyo.Var( + model.Locations, model.Customers, bounds=(0.0, 1.0), initialize=0.0 + ) - model.y = Var(model.Locations, bounds=(0.0, 1.0), initialize=0.0) + model.y = pyo.Var(model.Locations, bounds=(0.0, 1.0), initialize=0.0) def rule(model): return sum( @@ -48,21 +50,21 @@ def rule(model): for m in model.Customers ) - model.obj = Objective(rule=rule) + model.obj = pyo.Objective(rule=rule) def rule(model, m): return (sum(model.x[n, m] for n in model.Locations), 1.0) - model.single_x = Constraint(model.Customers, rule=rule) + model.single_x = pyo.Constraint(model.Customers, rule=rule) def rule(model, n, m): return (None, model.x[n, m] - model.y[n], 0.0) - model.bound_y = Constraint(model.Locations, model.Customers, rule=rule) + model.bound_y = pyo.Constraint(model.Locations, model.Customers, rule=rule) def rule(model): return (sum(model.y[n] for n in model.Locations) - model.P, 0.0) - model.num_facilities = Constraint(rule=rule) + model.num_facilities = pyo.Constraint(rule=rule) return model diff --git a/examples/performance/pmedian/pmedian2.py b/examples/performance/pmedian/pmedian2.py index ff25a6c15eb..a9f708be85f 100644 --- a/examples/performance/pmedian/pmedian2.py +++ b/examples/performance/pmedian/pmedian2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def pyomo_create_model(options=None, model_options=None): @@ -18,51 +18,53 @@ def pyomo_create_model(options=None, model_options=None): random.seed(1000) - model = AbstractModel() + model = pyo.AbstractModel() - model.N = Param(within=PositiveIntegers) + model.N = pyo.Param(within=pyo.PositiveIntegers) - model.Locations = RangeSet(1, model.N) + model.Locations = pyo.RangeSet(1, model.N) - model.P = Param(within=RangeSet(1, model.N)) + model.P = pyo.Param(within=pyo.RangeSet(1, model.N)) - model.M = Param(within=PositiveIntegers) + model.M = pyo.Param(within=pyo.PositiveIntegers) - model.Customers = RangeSet(1, model.M) + model.Customers = pyo.RangeSet(1, model.M) - model.d = Param( + model.d = pyo.Param( model.Locations, model.Customers, initialize=lambda n, m, model: random.uniform(1.0, 2.0), - within=Reals, + within=pyo.Reals, ) - model.x = Var(model.Locations, model.Customers, bounds=(0.0, 1.0), initialize=0.0) + model.x = pyo.Var( + model.Locations, model.Customers, bounds=(0.0, 1.0), initialize=0.0 + ) - model.y = Var(model.Locations, bounds=(0.0, 1.0), initialize=0.0) + model.y = pyo.Var(model.Locations, bounds=(0.0, 1.0), initialize=0.0) def rule(model): - return Sum( + return sum( model.d[n, m] * model.x[n, m] for n in model.Locations for m in model.Customers ) - model.obj = Objective(rule=rule) + model.obj = pyo.Objective(rule=rule) def rule(model, m): - return (Sum(model.x[n, m] for n in model.Locations), 1.0) + return (sum(model.x[n, m] for n in model.Locations), 1.0) - model.single_x = Constraint(model.Customers, rule=rule) + model.single_x = pyo.Constraint(model.Customers, rule=rule) def rule(model, n, m): return (None, model.x[n, m] - model.y[n], 0.0) - model.bound_y = Constraint(model.Locations, model.Customers, rule=rule) + model.bound_y = pyo.Constraint(model.Locations, model.Customers, rule=rule) def rule(model): - return (Sum(model.y[n] for n in model.Locations) - model.P, 0.0) + return (sum(model.y[n] for n in model.Locations) - model.P, 0.0) - model.num_facilities = Constraint(rule=rule) + model.num_facilities = pyo.Constraint(rule=rule) return model diff --git a/examples/pyomo/amplbook2/diet.py b/examples/pyomo/amplbook2/diet.py index cc52eacae20..78b2da63b80 100644 --- a/examples/pyomo/amplbook2/diet.py +++ b/examples/pyomo/amplbook2/diet.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,53 +11,53 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo # # Setup # -model = AbstractModel() +model = pyo.AbstractModel() -model.NUTR = Set() +model.NUTR = pyo.Set() -model.FOOD = Set() +model.FOOD = pyo.Set() -model.cost = Param(model.FOOD, within=NonNegativeReals) +model.cost = pyo.Param(model.FOOD, within=pyo.NonNegativeReals) -model.f_min = Param(model.FOOD, within=NonNegativeReals) +model.f_min = pyo.Param(model.FOOD, within=pyo.NonNegativeReals) def f_max_valid(model, value, j): return value > model.f_min[j] -model.f_max = Param(model.FOOD, validate=f_max_valid) +model.f_max = pyo.Param(model.FOOD, validate=f_max_valid) -model.n_min = Param(model.NUTR, within=NonNegativeReals) +model.n_min = pyo.Param(model.NUTR, within=pyo.NonNegativeReals) def paramn_max(model, value, i): return value > model.n_min[i] -model.n_max = Param(model.NUTR, validate=paramn_max) +model.n_max = pyo.Param(model.NUTR, validate=paramn_max) -model.amt = Param(model.NUTR, model.FOOD, within=NonNegativeReals) +model.amt = pyo.Param(model.NUTR, model.FOOD, within=pyo.NonNegativeReals) def Buy_bounds(model, i): return (model.f_min[i], model.f_max[i]) -model.Buy = Var(model.FOOD, bounds=Buy_bounds) +model.Buy = pyo.Var(model.FOOD, bounds=Buy_bounds) def Objective_rule(model): - return sum_product(model.cost, model.Buy) + return pyo.sum_product(model.cost, model.Buy) -model.totalcost = Objective(rule=Objective_rule) +model.totalcost = pyo.Objective(rule=Objective_rule) def Diet_rule(model, i): @@ -67,4 +67,4 @@ def Diet_rule(model, i): return (model.n_min[i], expr, model.n_max[i]) -model.Diet = Constraint(model.NUTR, rule=Diet_rule) +model.Diet = pyo.Constraint(model.NUTR, rule=Diet_rule) diff --git a/examples/pyomo/amplbook2/dieti.py b/examples/pyomo/amplbook2/dieti.py index 45d403dd810..00d611048d7 100644 --- a/examples/pyomo/amplbook2/dieti.py +++ b/examples/pyomo/amplbook2/dieti.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,53 +11,53 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo # # Setup # -model = AbstractModel() +model = pyo.AbstractModel() -model.NUTR = Set() +model.NUTR = pyo.Set() -model.FOOD = Set() +model.FOOD = pyo.Set() -model.cost = Param(model.FOOD, within=NonNegativeReals) +model.cost = pyo.Param(model.FOOD, within=pyo.NonNegativeReals) -model.f_min = Param(model.FOOD, within=NonNegativeReals) +model.f_min = pyo.Param(model.FOOD, within=pyo.NonNegativeReals) def f_max_valid(model, value, j): return value > model.f_min[j] -model.f_max = Param(model.FOOD, validate=f_max_valid) +model.f_max = pyo.Param(model.FOOD, validate=f_max_valid) -model.n_min = Param(model.NUTR, within=NonNegativeReals) +model.n_min = pyo.Param(model.NUTR, within=pyo.NonNegativeReals) def paramn_max(model, value, i): return value > model.n_min[i] -model.n_max = Param(model.NUTR, validate=paramn_max) +model.n_max = pyo.Param(model.NUTR, validate=paramn_max) -model.amt = Param(model.NUTR, model.FOOD, within=NonNegativeReals) +model.amt = pyo.Param(model.NUTR, model.FOOD, within=pyo.NonNegativeReals) def Buy_bounds(model, i): return (model.f_min[i], model.f_max[i]) -model.Buy = Var(model.FOOD, bounds=Buy_bounds, domain=Integers) +model.Buy = pyo.Var(model.FOOD, bounds=Buy_bounds, domain=pyo.Integers) def Objective_rule(model): - return sum_product(model.cost, model.Buy) + return pyo.sum_product(model.cost, model.Buy) -model.totalcost = Objective(rule=Objective_rule) +model.totalcost = pyo.Objective(rule=Objective_rule) def Diet_rule(model, i): @@ -67,4 +67,4 @@ def Diet_rule(model, i): return (model.n_min[i], expr, model.n_max[i]) -model.Diet = Constraint(model.NUTR, rule=Diet_rule) +model.Diet = pyo.Constraint(model.NUTR, rule=Diet_rule) diff --git a/examples/pyomo/amplbook2/econ2min.py b/examples/pyomo/amplbook2/econ2min.py index fb870e02364..908e5cf34b1 100644 --- a/examples/pyomo/amplbook2/econ2min.py +++ b/examples/pyomo/amplbook2/econ2min.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,43 +12,43 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo # # Setup # -model = AbstractModel() +model = pyo.AbstractModel() # *********************************** -model.PROD = Set(doc='products') +model.PROD = pyo.Set(doc='products') -model.ACT = Set(doc='activities') +model.ACT = pyo.Set(doc='activities') # *********************************** -model.cost = Param( - model.ACT, within=PositiveReals, doc='cost per unit of each activity' +model.cost = pyo.Param( + model.ACT, within=pyo.PositiveReals, doc='cost per unit of each activity' ) -model.demand = Param( - model.PROD, within=NonNegativeReals, doc='units of demand for each product' +model.demand = pyo.Param( + model.PROD, within=pyo.NonNegativeReals, doc='units of demand for each product' ) -model.io = Param( +model.io = pyo.Param( model.PROD, model.ACT, - within=NonNegativeReals, + within=pyo.NonNegativeReals, doc='units of each product from 1 unit of each activity', ) -model.level_min = Param( - model.ACT, within=NonNegativeReals, doc='min allowed level for each activity' +model.level_min = pyo.Param( + model.ACT, within=pyo.NonNegativeReals, doc='min allowed level for each activity' ) -model.level_max = Param( - model.ACT, within=NonNegativeReals, doc='max allowed level for each activity' +model.level_max = pyo.Param( + model.ACT, within=pyo.NonNegativeReals, doc='max allowed level for each activity' ) # *********************************** @@ -58,16 +58,16 @@ def Level_bounds(model, i): return (model.level_min[i], model.level_max[i]) -model.Level = Var(model.ACT, bounds=Level_bounds, doc='level for each activity') +model.Level = pyo.Var(model.ACT, bounds=Level_bounds, doc='level for each activity') # *********************************** def Total_Cost_rule(model): - return sum_product(model.cost, model.Level) + return pyo.sum_product(model.cost, model.Level) -model.Total_Cost = Objective(rule=Total_Cost_rule, doc='minimize total cost') +model.Total_Cost = pyo.Objective(rule=Total_Cost_rule, doc='minimize total cost') def Demand_rule(model, i): @@ -77,6 +77,6 @@ def Demand_rule(model, i): return model.demand[i] < expr -model.Demand = Constraint( +model.Demand = pyo.Constraint( model.PROD, rule=Demand_rule, doc='total level for each activity exceeds demand' ) diff --git a/examples/pyomo/amplbook2/econmin.py b/examples/pyomo/amplbook2/econmin.py index d9c95758d4d..ff70fcca094 100644 --- a/examples/pyomo/amplbook2/econmin.py +++ b/examples/pyomo/amplbook2/econmin.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,40 +11,40 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo # # Setup # -model = AbstractModel() +model = pyo.AbstractModel() # *********************************** -model.PROD = Set() +model.PROD = pyo.Set() -model.ACT = Set() +model.ACT = pyo.Set() # *********************************** -model.cost = Param(model.ACT, within=PositiveReals) +model.cost = pyo.Param(model.ACT, within=pyo.PositiveReals) -model.demand = Param(model.PROD, within=NonNegativeReals) +model.demand = pyo.Param(model.PROD, within=pyo.NonNegativeReals) -model.io = Param(model.PROD, model.ACT, within=NonNegativeReals) +model.io = pyo.Param(model.PROD, model.ACT, within=pyo.NonNegativeReals) # *********************************** -model.Level = Var(model.ACT) +model.Level = pyo.Var(model.ACT) # *********************************** def Total_Cost_rule(model): - return sum_product(model.cost, model.Level) + return pyo.sum_product(model.cost, model.Level) -model.Total_Cost = Objective(rule=Total_Cost_rule) +model.Total_Cost = pyo.Objective(rule=Total_Cost_rule) def Demand_rule(model, i): @@ -54,4 +54,4 @@ def Demand_rule(model, i): return expr > model.demand[i] -model.Demand = Constraint(model.PROD, rule=Demand_rule) +model.Demand = pyo.Constraint(model.PROD, rule=Demand_rule) diff --git a/examples/pyomo/amplbook2/prod.py b/examples/pyomo/amplbook2/prod.py index 236f7254b29..be1898c9b18 100644 --- a/examples/pyomo/amplbook2/prod.py +++ b/examples/pyomo/amplbook2/prod.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,22 +10,22 @@ # ___________________________________________________________________________ # Imports -from pyomo.core import * +import pyomo.environ as pyo # Create the model object -model = AbstractModel() +model = pyo.AbstractModel() # Sets -model.P = Set() +model.P = pyo.Set() # Parameters -model.a = Param(model.P) -model.b = Param() -model.c = Param(model.P) -model.u = Param(model.P) +model.a = pyo.Param(model.P) +model.b = pyo.Param() +model.c = pyo.Param(model.P) +model.u = pyo.Param(model.P) # Variables -model.X = Var(model.P) +model.X = pyo.Var(model.P) # Objective @@ -33,15 +33,15 @@ def Objective_rule(model): return sum([model.c[j] * model.X[j] for j in model.P]) -model.Total_Profit = Objective(rule=Objective_rule, sense=maximize) +model.Total_Profit = pyo.Objective(rule=Objective_rule, sense=pyo.maximize) # Time Constraint def Time_rule(model): - return sum_product(model.X, denom=model.a) <= model.b + return pyo.sum_product(model.X, denom=model.a) <= model.b -model.Time = Constraint(rule=Time_rule) +model.Time = pyo.Constraint(rule=Time_rule) # Limit Constraint @@ -49,4 +49,4 @@ def Limit_rule(model, j): return (0, model.X[j], model.u[j]) -model.Limit = Constraint(model.P, rule=Limit_rule) +model.Limit = pyo.Constraint(model.P, rule=Limit_rule) diff --git a/examples/pyomo/amplbook2/steel.py b/examples/pyomo/amplbook2/steel.py index 8c5c9b2a1d3..3d00d79f486 100644 --- a/examples/pyomo/amplbook2/steel.py +++ b/examples/pyomo/amplbook2/steel.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,37 +12,37 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo # # Setup # -model = AbstractModel() +model = pyo.AbstractModel() -model.PROD = Set() +model.PROD = pyo.Set() -model.rate = Param(model.PROD, within=PositiveReals) +model.rate = pyo.Param(model.PROD, within=pyo.PositiveReals) -model.avail = Param(within=NonNegativeReals) +model.avail = pyo.Param(within=pyo.NonNegativeReals) -model.profit = Param(model.PROD) +model.profit = pyo.Param(model.PROD) -model.market = Param(model.PROD, within=NonNegativeReals) +model.market = pyo.Param(model.PROD, within=pyo.NonNegativeReals) def Make_bounds(model, i): return (0, model.market[i]) -model.Make = Var(model.PROD, bounds=Make_bounds) +model.Make = pyo.Var(model.PROD, bounds=Make_bounds) def Objective_rule(model): - return sum_product(model.profit, model.Make) + return pyo.sum_product(model.profit, model.Make) -model.Total_Profit = Objective(rule=Objective_rule, sense=maximize) +model.Total_Profit = pyo.Objective(rule=Objective_rule, sense=pyo.maximize) def Time_rule(model): @@ -53,7 +53,7 @@ def Time_rule(model): def XTime_rule(model): - return sum_product(model.Make, denom=(model.rate,)) < model.avail + return pyo.sum_product(model.Make, denom=(model.rate,)) < model.avail -# model.Time = Constraint(rule=Time_rule) +# model.Time = pyo.Constraint(rule=Time_rule) diff --git a/examples/pyomo/amplbook2/steel3.py b/examples/pyomo/amplbook2/steel3.py index dd3b3ac202f..d5544d04865 100644 --- a/examples/pyomo/amplbook2/steel3.py +++ b/examples/pyomo/amplbook2/steel3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,43 +12,43 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo # # Setup # -model = AbstractModel() +model = pyo.AbstractModel() -model.PROD = Set() +model.PROD = pyo.Set() -model.rate = Param(model.PROD, within=PositiveReals) +model.rate = pyo.Param(model.PROD, within=pyo.PositiveReals) -model.avail = Param(within=NonNegativeReals) +model.avail = pyo.Param(within=pyo.NonNegativeReals) -model.profit = Param(model.PROD) +model.profit = pyo.Param(model.PROD) -model.commit = Param(model.PROD, within=NonNegativeReals) +model.commit = pyo.Param(model.PROD, within=pyo.NonNegativeReals) -model.market = Param(model.PROD, within=NonNegativeReals) +model.market = pyo.Param(model.PROD, within=pyo.NonNegativeReals) def Make_bounds(model, i): return (model.commit[i], model.market[i]) -model.Make = Var(model.PROD, bounds=Make_bounds) +model.Make = pyo.Var(model.PROD, bounds=Make_bounds) def Objective_rule(model): - return sum_product(model.profit, model.Make) + return pyo.sum_product(model.profit, model.Make) -model.totalprofit = Objective(rule=Objective_rule, sense=maximize) +model.totalprofit = pyo.Objective(rule=Objective_rule, sense=pyo.maximize) def Time_rule(model): - return sum_product(model.Make, denom=(model.rate)) < model.avail + return pyo.sum_product(model.Make, denom=(model.rate)) < model.avail -model.Time = Constraint(rule=Time_rule) +model.Time = pyo.Constraint(rule=Time_rule) diff --git a/examples/pyomo/amplbook2/steel4.py b/examples/pyomo/amplbook2/steel4.py index 10cb0979d24..45d20400575 100644 --- a/examples/pyomo/amplbook2/steel4.py +++ b/examples/pyomo/amplbook2/steel4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,41 +12,41 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo # # Setup # -model = AbstractModel() +model = pyo.AbstractModel() -model.PROD = Set() +model.PROD = pyo.Set() -model.STAGE = Set() +model.STAGE = pyo.Set() -model.rate = Param(model.PROD, model.STAGE, within=PositiveReals) +model.rate = pyo.Param(model.PROD, model.STAGE, within=pyo.PositiveReals) -model.avail = Param(model.STAGE, within=NonNegativeReals) +model.avail = pyo.Param(model.STAGE, within=pyo.NonNegativeReals) -model.profit = Param(model.PROD) +model.profit = pyo.Param(model.PROD) -model.commit = Param(model.PROD, within=NonNegativeReals) +model.commit = pyo.Param(model.PROD, within=pyo.NonNegativeReals) -model.market = Param(model.PROD, within=NonNegativeReals) +model.market = pyo.Param(model.PROD, within=pyo.NonNegativeReals) def Make_bounds(model, i): return (model.commit[i], model.market[i]) -model.Make = Var(model.PROD, bounds=Make_bounds) +model.Make = pyo.Var(model.PROD, bounds=Make_bounds) def Objective_rule(model): - return sum_product(model.profit, model.Make) + return pyo.sum_product(model.profit, model.Make) -model.Total_Profit = Objective(rule=Objective_rule, sense=maximize) +model.Total_Profit = pyo.Objective(rule=Objective_rule, sense=pyo.maximize) def Timelim_rule(model, s): @@ -56,4 +56,4 @@ def Timelim_rule(model, s): return timeexpr < model.avail[s] -model.Time = Constraint(model.STAGE, rule=Timelim_rule) +model.Time = pyo.Constraint(model.STAGE, rule=Timelim_rule) diff --git a/examples/pyomo/benders/master.py b/examples/pyomo/benders/master.py index 372810dc024..fabf235b857 100644 --- a/examples/pyomo/benders/master.py +++ b/examples/pyomo/benders/master.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,81 +12,85 @@ ### Pyomo master for AMPL Bender's example, available ### from http://www.ampl.com/NEW/LOOP2/stoch2.mod -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel(name="Master") +model = pyo.AbstractModel(name="Master") ############################################################################## -model.NUMSCEN = Param(within=PositiveIntegers) +model.NUMSCEN = pyo.Param(within=pyo.PositiveIntegers) -model.SCEN = RangeSet(model.NUMSCEN) +model.SCEN = pyo.RangeSet(model.NUMSCEN) # products -model.PROD = Set() +model.PROD = pyo.Set() # number of weeks -model.T = Param(within=PositiveIntegers) +model.T = pyo.Param(within=pyo.PositiveIntegers) # derived set containing all valid week indices and subsets of interest. -model.WEEKS = RangeSet(model.T) +model.WEEKS = pyo.RangeSet(model.T) -model.TWOPLUSWEEKS = RangeSet(2, model.T) +model.TWOPLUSWEEKS = pyo.RangeSet(2, model.T) # tons per hour produced -model.rate = Param(model.PROD, within=PositiveReals) +model.rate = pyo.Param(model.PROD, within=pyo.PositiveReals) # hours available in week -model.avail = Param(model.WEEKS, within=NonNegativeReals) +model.avail = pyo.Param(model.WEEKS, within=pyo.NonNegativeReals) # limit on tons sold in week -model.market = Param(model.PROD, model.WEEKS, within=NonNegativeReals) +model.market = pyo.Param(model.PROD, model.WEEKS, within=pyo.NonNegativeReals) # cost per ton produced -model.prodcost = Param(model.PROD, within=NonNegativeReals) +model.prodcost = pyo.Param(model.PROD, within=pyo.NonNegativeReals) # carrying cost/ton of inventory -model.invcost = Param(model.PROD, within=NonNegativeReals) +model.invcost = pyo.Param(model.PROD, within=pyo.NonNegativeReals) # initial inventory -model.inv0 = Param(model.PROD, within=NonNegativeReals) +model.inv0 = pyo.Param(model.PROD, within=pyo.NonNegativeReals) # projected revenue/ton, per scenario. -model.revenue = Param(model.PROD, model.WEEKS, model.SCEN, within=NonNegativeReals) +model.revenue = pyo.Param( + model.PROD, model.WEEKS, model.SCEN, within=pyo.NonNegativeReals +) def prob_validator(model, value, s): return (value >= 0) and (value <= 1.0) -model.prob = Param(model.SCEN, validate=prob_validator) +model.prob = pyo.Param(model.SCEN, validate=prob_validator) ############################################################################## -model.Make1 = Var(model.PROD, within=NonNegativeReals) +model.Make1 = pyo.Var(model.PROD, within=pyo.NonNegativeReals) -model.Inv1 = Var(model.PROD, within=NonNegativeReals) +model.Inv1 = pyo.Var(model.PROD, within=pyo.NonNegativeReals) def sell_bounds(model, p): return (0, model.market[p, 1]) -model.Sell1 = Var(model.PROD, within=NonNegativeReals, bounds=sell_bounds) +model.Sell1 = pyo.Var(model.PROD, within=pyo.NonNegativeReals, bounds=sell_bounds) -model.Min_Stage2_Profit = Var(within=NonNegativeReals) +model.Min_Stage2_Profit = pyo.Var(within=pyo.NonNegativeReals) ############################################################################## -model.CUTS = Set(within=PositiveIntegers, ordered=True) +model.CUTS = pyo.Set(within=pyo.PositiveIntegers, ordered=True) -model.time_price = Param( +model.time_price = pyo.Param( model.TWOPLUSWEEKS, model.SCEN, model.CUTS, default=0.0, mutable=True ) -model.bal2_price = Param(model.PROD, model.SCEN, model.CUTS, default=0.0, mutable=True) +model.bal2_price = pyo.Param( + model.PROD, model.SCEN, model.CUTS, default=0.0, mutable=True +) -model.sell_lim_price = Param( +model.sell_lim_price = pyo.Param( model.PROD, model.TWOPLUSWEEKS, model.SCEN, model.CUTS, default=0.0, mutable=True ) @@ -100,17 +104,17 @@ def time1_rule(model): ) -model.Time1 = Constraint(rule=time1_rule) +model.Time1 = pyo.Constraint(rule=time1_rule) def balance1_rule(model, p): return model.Make1[p] + model.inv0[p] - (model.Sell1[p] + model.Inv1[p]) == 0.0 -model.Balance1 = Constraint(model.PROD, rule=balance1_rule) +model.Balance1 = pyo.Constraint(model.PROD, rule=balance1_rule) # cuts are generated on-the-fly, so no rules are necessary. -model.Cut_Defn = ConstraintList() +model.Cut_Defn = pyo.ConstraintList() def expected_profit_rule(model): @@ -128,4 +132,4 @@ def expected_profit_rule(model): ) -model.Expected_Profit = Objective(rule=expected_profit_rule, sense=maximize) +model.Expected_Profit = pyo.Objective(rule=expected_profit_rule, sense=pyo.maximize) diff --git a/examples/pyomo/benders/runbenders b/examples/pyomo/benders/runbenders index 1d360ca985a..7bc4248a9fc 100755 --- a/examples/pyomo/benders/runbenders +++ b/examples/pyomo/benders/runbenders @@ -6,7 +6,6 @@ # # Python imports -from pyomo.environ import * from pyomo.common.fileutils import import_file from pyomo.opt.base import SolverFactory from pyomo.opt.parallel import SolverManagerFactory diff --git a/examples/pyomo/benders/subproblem.py b/examples/pyomo/benders/subproblem.py index ae46dad2d41..eadafce89ab 100644 --- a/examples/pyomo/benders/subproblem.py +++ b/examples/pyomo/benders/subproblem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,61 +12,65 @@ ### Pyomo sub-problem for AMPL Bender's example, available ### from http://www.ampl.com/NEW/LOOP2/stoch2.mod -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel(name="SubProblem") +model = pyo.AbstractModel(name="SubProblem") # Declare suffixes -model.rc = Suffix(direction=Suffix.IMPORT) -model.lrc = Suffix(direction=Suffix.IMPORT) -model.urc = Suffix(direction=Suffix.IMPORT) -model.dual = Suffix(direction=Suffix.IMPORT) +model.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT) +model.lrc = pyo.Suffix(direction=pyo.Suffix.IMPORT) +model.urc = pyo.Suffix(direction=pyo.Suffix.IMPORT) +model.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) # products -model.PROD = Set() +model.PROD = pyo.Set() # number of weeks -model.T = Param(within=PositiveIntegers) +model.T = pyo.Param(within=pyo.PositiveIntegers) # derived set containing all valid week indices and subsets of interest. def weeks_rule(model): - return list(sequence(model.T())) + return list(pyo.sequence(model.T())) -model.WEEKS = Set(initialize=weeks_rule, within=PositiveIntegers) +model.WEEKS = pyo.Set(initialize=weeks_rule, within=pyo.PositiveIntegers) def two_plus_weeks_rule(model): - return list(sequence(2, model.T())) + return list(pyo.sequence(2, model.T())) -model.TWOPLUSWEEKS = Set(initialize=two_plus_weeks_rule, within=PositiveIntegers) +model.TWOPLUSWEEKS = pyo.Set( + initialize=two_plus_weeks_rule, within=pyo.PositiveIntegers +) def three_plus_weeks_rule(model): - return list(sequence(3, model.T())) + return list(pyo.sequence(3, model.T())) -model.THREEPLUSWEEKS = Set(initialize=three_plus_weeks_rule, within=PositiveIntegers) +model.THREEPLUSWEEKS = pyo.Set( + initialize=three_plus_weeks_rule, within=pyo.PositiveIntegers +) # tons per hour produced -model.rate = Param(model.PROD, within=PositiveReals) +model.rate = pyo.Param(model.PROD, within=pyo.PositiveReals) # hours available in week -model.avail = Param(model.WEEKS, within=NonNegativeReals) +model.avail = pyo.Param(model.WEEKS, within=pyo.NonNegativeReals) # limit on tons sold in week -model.market = Param(model.PROD, model.WEEKS, within=NonNegativeReals) +model.market = pyo.Param(model.PROD, model.WEEKS, within=pyo.NonNegativeReals) # cost per ton produced -model.prodcost = Param(model.PROD, within=NonNegativeReals) +model.prodcost = pyo.Param(model.PROD, within=pyo.NonNegativeReals) # carrying cost/ton of inventory -model.invcost = Param(model.PROD, within=NonNegativeReals) +model.invcost = pyo.Param(model.PROD, within=pyo.NonNegativeReals) # projected revenue/ton -model.revenue = Param(model.PROD, model.WEEKS, within=NonNegativeReals) +model.revenue = pyo.Param(model.PROD, model.WEEKS, within=pyo.NonNegativeReals) # scenario probability @@ -74,16 +78,16 @@ def unit_interval_validate(model, value): return (value >= 0.0) and (value <= 1.0) -model.prob = Param(validate=unit_interval_validate) +model.prob = pyo.Param(validate=unit_interval_validate) # inventory at end of first period. -model.inv1 = Param(model.PROD, within=NonNegativeReals, mutable=True) +model.inv1 = pyo.Param(model.PROD, within=pyo.NonNegativeReals, mutable=True) # tons produced -model.Make = Var(model.PROD, model.TWOPLUSWEEKS, domain=NonNegativeReals) +model.Make = pyo.Var(model.PROD, model.TWOPLUSWEEKS, domain=pyo.NonNegativeReals) # tons inventoried -model.Inv = Var(model.PROD, model.TWOPLUSWEEKS, domain=NonNegativeReals) +model.Inv = pyo.Var(model.PROD, model.TWOPLUSWEEKS, domain=pyo.NonNegativeReals) # tons sold @@ -91,8 +95,8 @@ def sell_bounds(model, p, t): return (0, model.market[p, t]) -model.Sell = Var( - model.PROD, model.TWOPLUSWEEKS, within=NonNegativeReals, bounds=sell_bounds +model.Sell = pyo.Var( + model.PROD, model.TWOPLUSWEEKS, within=pyo.NonNegativeReals, bounds=sell_bounds ) @@ -104,7 +108,7 @@ def time_rule(model, t): ) -model.Time = Constraint(model.TWOPLUSWEEKS, rule=time_rule) +model.Time = pyo.Constraint(model.TWOPLUSWEEKS, rule=time_rule) def balance2_rule(model, p): @@ -113,7 +117,7 @@ def balance2_rule(model, p): ) == 0.0 -model.Balance2 = Constraint(model.PROD, rule=balance2_rule) +model.Balance2 = pyo.Constraint(model.PROD, rule=balance2_rule) def balance_rule(model, p, t): @@ -122,7 +126,7 @@ def balance_rule(model, p, t): ) == 0.0 -model.Balance = Constraint(model.PROD, model.THREEPLUSWEEKS, rule=balance_rule) +model.Balance = pyo.Constraint(model.PROD, model.THREEPLUSWEEKS, rule=balance_rule) # the manual distribution of model.prob is ugly, but at the moment necessary; Pyomo @@ -139,4 +143,4 @@ def exp_stage2_profit_rule(model): ) -model.Exp_Stage2_Profit = Objective(rule=exp_stage2_profit_rule, sense=maximize) +model.Exp_Stage2_Profit = pyo.Objective(rule=exp_stage2_profit_rule, sense=pyo.maximize) diff --git a/examples/pyomo/callbacks/sc.py b/examples/pyomo/callbacks/sc.py index 0882815c6b7..8cbf3336fc9 100644 --- a/examples/pyomo/callbacks/sc.py +++ b/examples/pyomo/callbacks/sc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ from pyomo.common.collections import Bunch -from pyomo.core import * +import pyomo.environ as pyo import math import random @@ -138,11 +138,11 @@ def S_rule(model): # # CREATE MODEL # - model = ConcreteModel() + model = pyo.ConcreteModel() # # (i,j) in S if element i in set j # - model.S = Set(dimen=2, initialize=S_rule) + model.S = pyo.Set(dimen=2, initialize=S_rule) # # Dynamically create the I and J index sets, since @@ -151,28 +151,28 @@ def S_rule(model): def I_rule(model): return set((i for (i, j) in model.S)) - model.I = Set(initialize=I_rule) + model.I = pyo.Set(initialize=I_rule) def J_rule(model): return set((j for (i, j) in model.S)) - model.J = Set(initialize=J_rule) + model.J = pyo.Set(initialize=J_rule) # # Weights # - model.w = Param(model.J, within=NonNegativeReals, initialize=1.0) + model.w = pyo.Param(model.J, within=pyo.NonNegativeReals, initialize=1.0) # # Set selection binary variables # - model.x = Var(model.J, within=Binary) + model.x = pyo.Var(model.J, within=pyo.Binary) # # Objective # def cost_rule(model): - return sum_product(model.w, model.x) + return pyo.sum_product(model.w, model.x) - model.cost = Objective(rule=cost_rule) + model.cost = pyo.Objective(rule=cost_rule) # # Constraint @@ -189,7 +189,7 @@ def cover_rule(model, i): # return Constraint.Skip return expr >= 1 - model.cover = Constraint(model.I, rule=cover_rule) + model.cover = pyo.Constraint(model.I, rule=cover_rule) # print_model_stats(model_options, model) diff --git a/examples/pyomo/callbacks/sc_callback.py b/examples/pyomo/callbacks/sc_callback.py index cacc438b380..f60f1537655 100644 --- a/examples/pyomo/callbacks/sc_callback.py +++ b/examples/pyomo/callbacks/sc_callback.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,8 +10,7 @@ # ___________________________________________________________________________ -from pyomo.core import * -from sc import * +from sc import pyomo_callback @pyomo_callback('solve-callback') diff --git a/examples/pyomo/callbacks/sc_script.py b/examples/pyomo/callbacks/sc_script.py index d3044e4d667..143e5665157 100644 --- a/examples/pyomo/callbacks/sc_script.py +++ b/examples/pyomo/callbacks/sc_script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,9 +10,7 @@ # ___________________________________________________________________________ -from pyomo.opt import * -from pyomo.core import * - +import pyomo.environ as pyo import sc model = sc.pyomo_create_model() @@ -32,7 +30,7 @@ def node_callback(solver, model): instance = model.create() -opt = SolverFactory('_cplex_direct') +opt = pyo.SolverFactory('_cplex_direct') opt.set_callback('cut-callback', cut_callback) opt.set_callback('node-callback', node_callback) opt.set_callback('solve-callback', solve_callback) diff --git a/examples/pyomo/callbacks/scalability/run.py b/examples/pyomo/callbacks/scalability/run.py index cf95076fcc3..23633357cbb 100644 --- a/examples/pyomo/callbacks/scalability/run.py +++ b/examples/pyomo/callbacks/scalability/run.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomo/callbacks/tsp.py b/examples/pyomo/callbacks/tsp.py index 8526a540b66..0299f84f589 100644 --- a/examples/pyomo/callbacks/tsp.py +++ b/examples/pyomo/callbacks/tsp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,11 +14,12 @@ # import re import math -from pyomo.core import * +import pyomo.environ as pyo +from sc import pyomo_callback def pyomo_create_model(options=None, model_options=None): - model = ConcreteModel() + model = pyo.ConcreteModel() # # Read from the 'points' file # @@ -38,22 +39,22 @@ def pyomo_create_model(options=None, model_options=None): # # # Number of points - model.N = Param(within=PositiveIntegers, initialize=N) + model.N = pyo.Param(within=pyo.PositiveIntegers, initialize=N) # # Index set for points - model.POINTS = RangeSet(1, model.N) + model.POINTS = pyo.RangeSet(1, model.N) # # (x,y) location def x_rule(model, i): return x[i - 1] - model.x = Param(model.POINTS) + model.x = pyo.Param(model.POINTS) def y_rule(model, i): return y[i - 1] - model.y = Param(model.POINTS) + model.y = pyo.Param(model.POINTS) # # Derived data @@ -63,7 +64,7 @@ def y_rule(model, i): def LINKS_rule(model): return set([(i, j) for i in model.POINTS for j in model.POINTS if i < j]) - model.LINKS = Set(dimen=2) + model.LINKS = pyo.Set(dimen=2) # # Distance between points @@ -72,17 +73,17 @@ def d_rule(model, i, j): (model.x[i] - model.x[j]) ** 2 + (model.y[i] - model.y[j]) ** 2 ) - model.d = Param(model.LINKS) + model.d = pyo.Param(model.LINKS) # # Variables # - model.Z = Var(model.LINKS, within=Binary) + model.Z = pyo.Var(model.LINKS, within=pyo.Binary) # # Constraints # # # Minimize tour length - model.tour_length = Objective(expr=sum_product(model.d, model.Z)) + model.tour_length = pyo.Objective(expr=pyo.sum_product(model.d, model.Z)) # # Each vertex has degree 2 @@ -93,17 +94,17 @@ def Degrees_rule(model, i): == 2 ) - model.Degrees = Constraint(model.POINTS) + model.Degrees = pyo.Constraint(model.POINTS) # # NOTE: subtour constraints are added dynamically # ## Number of subtour elimination cuts - # model.M = Param(within=PositiveIntegers) + # model.M = pyo.Param(within=pyo.PositiveIntegers) ## Number of subtour elimination cuts appended - # model.numcut = Param(within=NonNegativeIntegers) - # model.CUTINDICES = RangeSet(1,model.M) - # model.CUTSET = Set(model.CUTINDICES) + # model.numcut = pyo.Param(within=pyo.NonNegativeIntegers) + # model.CUTINDICES = pyo.RangeSet(1,model.M) + # model.CUTSET = pyo.Set(model.CUTINDICES) return model diff --git a/examples/pyomo/columngeneration/cutting_stock.py b/examples/pyomo/columngeneration/cutting_stock.py index 2d9399c7db4..f6987c2f689 100644 --- a/examples/pyomo/columngeneration/cutting_stock.py +++ b/examples/pyomo/columngeneration/cutting_stock.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,7 +16,7 @@ Bradley, S.P., A.C. Hax, and T.L. Magnanti. 1977. Applied Mathematical Programming, Addison-Wesley, Reading, MA. Available: http://web.mit.edu/15.053/www/AMP.htm. -Data from https://en.wikipedia.org/wiki/Cutting_stock_problem +Data from https://en.wikipedia.org/wiki/Cutting_stock_problem ''' import pyomo.environ as pyo diff --git a/examples/pyomo/concrete/Whiskas.py b/examples/pyomo/concrete/Whiskas.py index 3d3c19e94ac..18f3c47639e 100644 --- a/examples/pyomo/concrete/Whiskas.py +++ b/examples/pyomo/concrete/Whiskas.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo # Creates a list of the Ingredients Ingredients = ['CHICKEN', 'BEEF', 'MUTTON', 'RICE', 'WHEAT', 'GEL'] @@ -65,33 +65,33 @@ 'GEL': 0.000, } -model = ConcreteModel(name="The Whiskas Problem") +model = pyo.ConcreteModel(name="The Whiskas Problem") -model.ingredient_vars = Var( +model.ingredient_vars = pyo.Var( Ingredients, bounds=(0, None), doc="The amount of each ingredient that is used" ) -model.obj = Objective( +model.obj = pyo.Objective( expr=sum(costs[i] * model.ingredient_vars[i] for i in Ingredients), doc="Total Cost of Ingredients per can", ) -model.c0 = Constraint( +model.c0 = pyo.Constraint( expr=sum(model.ingredient_vars[i] for i in Ingredients) == 100, doc="PercentagesSum" ) -model.c1 = Constraint( +model.c1 = pyo.Constraint( expr=sum(proteinPercent[i] * model.ingredient_vars[i] for i in Ingredients) >= 8.0, doc="ProteinRequirement", ) -model.c2 = Constraint( +model.c2 = pyo.Constraint( expr=sum(fatPercent[i] * model.ingredient_vars[i] for i in Ingredients) >= 6.0, doc="FatRequirement", ) -model.c3 = Constraint( +model.c3 = pyo.Constraint( expr=sum(fibrePercent[i] * model.ingredient_vars[i] for i in Ingredients) <= 2.0, doc="FibreRequirement", ) -model.c4 = Constraint( +model.c4 = pyo.Constraint( expr=sum(saltPercent[i] * model.ingredient_vars[i] for i in Ingredients) <= 0.4, doc="SaltRequirement", ) diff --git a/examples/pyomo/concrete/knapsack-abstract.py b/examples/pyomo/concrete/knapsack-abstract.py index 9766d902722..791173fc455 100644 --- a/examples/pyomo/concrete/knapsack-abstract.py +++ b/examples/pyomo/concrete/knapsack-abstract.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,33 +13,33 @@ # Abstract Knapsack Problem # -from pyomo.environ import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.ITEMS = Set() +model.ITEMS = pyo.Set() -model.v = Param(model.ITEMS, within=PositiveReals) +model.v = pyo.Param(model.ITEMS, within=pyo.PositiveReals) -model.w = Param(model.ITEMS, within=PositiveReals) +model.w = pyo.Param(model.ITEMS, within=pyo.PositiveReals) -model.limit = Param(within=PositiveReals) +model.limit = pyo.Param(within=pyo.PositiveReals) -model.x = Var(model.ITEMS, within=Binary) +model.x = pyo.Var(model.ITEMS, within=pyo.Binary) def value_rule(model): return sum(model.v[i] * model.x[i] for i in model.ITEMS) -model.value = Objective(sense=maximize, rule=value_rule) +model.value = pyo.Objective(sense=pyo.maximize, rule=value_rule) def weight_rule(model): return sum(model.w[i] * model.x[i] for i in model.ITEMS) <= model.limit -model.weight = Constraint(rule=weight_rule) +model.weight = pyo.Constraint(rule=weight_rule) if __name__ == '__main__': diff --git a/examples/pyomo/concrete/knapsack-concrete.py b/examples/pyomo/concrete/knapsack-concrete.py index 8966d0b8498..3efe4bd0dec 100644 --- a/examples/pyomo/concrete/knapsack-concrete.py +++ b/examples/pyomo/concrete/knapsack-concrete.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,19 +13,19 @@ # Knapsack Problem # -from pyomo.environ import * +import pyomo.environ as pyo v = {'hammer': 8, 'wrench': 3, 'screwdriver': 6, 'towel': 11} w = {'hammer': 5, 'wrench': 7, 'screwdriver': 4, 'towel': 3} limit = 14 -M = ConcreteModel() +M = pyo.ConcreteModel() -M.ITEMS = Set(initialize=v.keys()) +M.ITEMS = pyo.Set(initialize=v.keys()) -M.x = Var(M.ITEMS, within=Binary) +M.x = pyo.Var(M.ITEMS, within=pyo.Binary) -M.value = Objective(expr=sum(v[i] * M.x[i] for i in M.ITEMS), sense=maximize) +M.value = pyo.Objective(expr=sum(v[i] * M.x[i] for i in M.ITEMS), sense=pyo.maximize) -M.weight = Constraint(expr=sum(w[i] * M.x[i] for i in M.ITEMS) <= limit) +M.weight = pyo.Constraint(expr=sum(w[i] * M.x[i] for i in M.ITEMS) <= limit) diff --git a/examples/pyomo/concrete/rosen.py b/examples/pyomo/concrete/rosen.py index ae51ae50ac0..c3596e8be0d 100644 --- a/examples/pyomo/concrete/rosen.py +++ b/examples/pyomo/concrete/rosen.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,11 +10,11 @@ # ___________________________________________________________________________ # rosen.py -from pyomo.environ import * +import pyomo.environ as pyo -M = ConcreteModel() -M.x = Var() -M.y = Var() -M.o = Objective(expr=(M.x - 1) ** 2 + 100 * (M.y - M.x**2) ** 2) +M = pyo.ConcreteModel() +M.x = pyo.Var() +M.y = pyo.Var() +M.o = pyo.Objective(expr=(M.x - 1) ** 2 + 100 * (M.y - M.x**2) ** 2) model = M diff --git a/examples/pyomo/concrete/sodacan.py b/examples/pyomo/concrete/sodacan.py index 5429b27a9d5..d3e9623f20b 100644 --- a/examples/pyomo/concrete/sodacan.py +++ b/examples/pyomo/concrete/sodacan.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,11 +10,11 @@ # ___________________________________________________________________________ # sodacan.py -from pyomo.environ import * +import pyomo.environ as pyo from math import pi -M = ConcreteModel() -M.r = Var(bounds=(0, None)) -M.h = Var(bounds=(0, None)) -M.o = Objective(expr=2 * pi * M.r * (M.r + M.h)) -M.c = Constraint(expr=pi * M.h * M.r**2 == 355) +M = pyo.ConcreteModel() +M.r = pyo.Var(bounds=(0, None)) +M.h = pyo.Var(bounds=(0, None)) +M.o = pyo.Objective(expr=2 * pi * M.r * (M.r + M.h)) +M.c = pyo.Constraint(expr=pi * M.h * M.r**2 == 355) diff --git a/examples/pyomo/concrete/sodacan_fig.py b/examples/pyomo/concrete/sodacan_fig.py index b263eaf558d..e216aa31733 100644 --- a/examples/pyomo/concrete/sodacan_fig.py +++ b/examples/pyomo/concrete/sodacan_fig.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomo/concrete/sp.py b/examples/pyomo/concrete/sp.py index e82a4bca0a9..91e42ed444d 100644 --- a/examples/pyomo/concrete/sp.py +++ b/examples/pyomo/concrete/sp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,27 +10,27 @@ # ___________________________________________________________________________ # sp.py -from pyomo.environ import * -from sp_data import * # define c, b, h, and d +import pyomo.environ as pyo +from sp_data import c, b, h, d # define c, b, h, and d scenarios = range(1, 6) -M = ConcreteModel() -M.x = Var(within=NonNegativeReals) +M = pyo.ConcreteModel() +M.x = pyo.Var(within=pyo.NonNegativeReals) def b_rule(B, i): - B.y = Var() - B.l = Constraint(expr=B.y >= (c - b) * M.x + b * d[i]) - B.u = Constraint(expr=B.y >= (c + h) * M.x + h * d[i]) + B.y = pyo.Var() + B.l = pyo.Constraint(expr=B.y >= (c - b) * M.x + b * d[i]) + B.u = pyo.Constraint(expr=B.y >= (c + h) * M.x + h * d[i]) return B -M.B = Block(scenarios, rule=b_rule) +M.B = pyo.Block(scenarios, rule=b_rule) def o_rule(M): return sum(M.B[i].y for i in scenarios) / 5.0 -M.o = Objective(rule=o_rule) +M.o = pyo.Objective(rule=o_rule) diff --git a/examples/pyomo/concrete/sp_data.py b/examples/pyomo/concrete/sp_data.py index 4453a10cead..11356ed7e42 100644 --- a/examples/pyomo/concrete/sp_data.py +++ b/examples/pyomo/concrete/sp_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomo/connectors/network_flow.py b/examples/pyomo/connectors/network_flow.py index d5587fdf4c8..afa327cdfa3 100644 --- a/examples/pyomo/connectors/network_flow.py +++ b/examples/pyomo/connectors/network_flow.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,63 +10,67 @@ # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo def pipe_rule(pipe, i): m = pipe.model() - pipe.flow = Var() - pipe.pIn = Var(within=NonNegativeReals) - pipe.pOut = Var(within=NonNegativeReals) - pipe.pDrop = Constraint( + pipe.flow = pyo.Var() + pipe.pIn = pyo.Var(within=pyo.NonNegativeReals) + pipe.pOut = pyo.Var(within=pyo.NonNegativeReals) + pipe.pDrop = pyo.Constraint( expr=pipe.pIn - pipe.pOut == m.friction * m.pipe_length[i] * pipe.flow ) - pipe.IN = Connector() + pipe.IN = pyo.Connector() pipe.IN.add(-pipe.flow, "flow") pipe.IN.add(pipe.pIn, "pressure") - pipe.OUT = Connector() + pipe.OUT = pyo.Connector() pipe.OUT.add(pipe.flow) pipe.OUT.add(pipe.pOut, "pressure") def node_rule(node, i): def _mass_balance(node, flows): - return node.model().demands[i] == sum_product(flows) + return node.model().demands[i] == pyo.sum_product(flows) - node.flow = VarList() - node.pressure = Var(within=NonNegativeReals) - node.port = Connector() + node.flow = pyo.VarList() + node.pressure = pyo.Var(within=pyo.NonNegativeReals) + node.port = pyo.Connector() # node.port.add(node.flow, - # aggregate=lambda n,v: n.model().demands[id] == sum_product(v)) + # aggregate=lambda n,v: n.model().demands[id] == pyo.sum_product(v)) node.port.add(node.flow, aggregate=_mass_balance) node.port.add(node.pressure) def _src_rule(model, pipe): - return model.nodes[value(model.pipe_links[pipe, 0])].port == model.pipes[pipe].IN + return ( + model.nodes[pyo.value(model.pipe_links[pipe, 0])].port == model.pipes[pipe].IN + ) def _sink_rule(model, pipe): - return model.nodes[value(model.pipe_links[pipe, 1])].port == model.pipes[pipe].OUT + return ( + model.nodes[pyo.value(model.pipe_links[pipe, 1])].port == model.pipes[pipe].OUT + ) -model = AbstractModel() -model.PIPES = Set() -model.NODES = Set() +model = pyo.AbstractModel() +model.PIPES = pyo.Set() +model.NODES = pyo.Set() -model.friction = Param(within=NonNegativeReals) -model.pipe_length = Param(model.PIPES, within=NonNegativeReals) -model.pipe_links = Param(model.PIPES, [0, 1]) -model.demands = Param(model.NODES, within=Reals, default=0) +model.friction = pyo.Param(within=pyo.NonNegativeReals) +model.pipe_length = pyo.Param(model.PIPES, within=pyo.NonNegativeReals) +model.pipe_links = pyo.Param(model.PIPES, [0, 1]) +model.demands = pyo.Param(model.NODES, within=pyo.Reals, default=0) -model.pipes = Block(model.PIPES, rule=pipe_rule) -model.nodes = Block(model.NODES, rule=node_rule) +model.pipes = pyo.Block(model.PIPES, rule=pipe_rule) +model.nodes = pyo.Block(model.NODES, rule=node_rule) # Connect the network -model.network_src = Constraint(model.PIPES, rule=_src_rule) -model.network_sink = Constraint(model.PIPES, rule=_sink_rule) +model.network_src = pyo.Constraint(model.PIPES, rule=_src_rule) +model.network_sink = pyo.Constraint(model.PIPES, rule=_sink_rule) # Solve so the minimum pressure in the network is 0 @@ -74,4 +78,4 @@ def _obj(model): return sum(model.nodes[n].pressure for n in model.NODES) -model.obj = Objective(rule=_obj) +model.obj = pyo.Objective(rule=_obj) diff --git a/examples/pyomo/connectors/network_flow_proposed.py b/examples/pyomo/connectors/network_flow_proposed.py index f234f2decf4..8844ded504a 100644 --- a/examples/pyomo/connectors/network_flow_proposed.py +++ b/examples/pyomo/connectors/network_flow_proposed.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,64 +10,68 @@ # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo def pipe_rule(model, pipe, id): - pipe.length = Param(within=NonNegativeReals) + pipe.length = pyo.Param(within=pyo.NonNegativeReals) - pipe.flow = Var() - pipe.pIn = Var(within=NonNegativeReals) - pipe.pOut = Var(within=NonNegativeReals) - pipe.pDrop = Constraint( + pipe.flow = pyo.Var() + pipe.pIn = pyo.Var(within=pyo.NonNegativeReals) + pipe.pOut = pyo.Var(within=pyo.NonNegativeReals) + pipe.pDrop = pyo.Constraint( expr=pipe.pIn - pipe.pOut == model.friction * model.pipe_length[id] * pipe.flow ) - pipe.IN = Connector() + pipe.IN = pyo.Connector() pipe.IN.add(-1 * pipe.flow, "flow") pipe.IN.add(pipe.pIn, "pressure") - pipe.OUT = Connector() + pipe.OUT = pyo.Connector() pipe.OUT.add(pipe.flow) pipe.OUT.add(pipe.pOut, "pressure") def node_rule(model, node, id): def _mass_balance(model, node, flows): - return node.demand == sum_product(flows) + return node.demand == pyo.sum_product(flows) - node.demand = Param(within=Reals, default=0) + node.demand = pyo.Param(within=pyo.Reals, default=0) - node.flow = VarList() - node.pressure = Var(within=NonNegativeReals) - node.port = Connector() + node.flow = pyo.VarList() + node.pressure = pyo.Var(within=pyo.NonNegativeReals) + node.port = pyo.Connector() # node.port.add( node.flow, - # aggregate=lambda m,n,v: m.demands[id] == sum_product(v) ) + # aggregate=lambda m,n,v: m.demands[id] == pyo.sum_product(v) ) node.port.add(node.flow, aggregate=_mass_balance) node.port.add(node.pressure) def _src_rule(model, pipe): - return model.nodes[value(model.pipe_links[pipe, 0])].port == model.pipes[pipe].IN + return ( + model.nodes[pyo.value(model.pipe_links[pipe, 0])].port == model.pipes[pipe].IN + ) def _sink_rule(model, pipe): - return model.nodes[value(model.pipe_links[pipe, 1])].port == model.pipes[pipe].OUT + return ( + model.nodes[pyo.value(model.pipe_links[pipe, 1])].port == model.pipes[pipe].OUT + ) -model = AbstractModel() -model.PIPES = Set() -model.NODES = Set() +model = pyo.AbstractModel() +model.PIPES = pyo.Set() +model.NODES = pyo.Set() -model.friction = Param(within=NonNegativeReals) -model.pipe_links = Param(model.PIPES, RangeSet(2)) +model.friction = pyo.Param(within=pyo.NonNegativeReals) +model.pipe_links = pyo.Param(model.PIPES, pyo.RangeSet(2)) -model.pipes = Block(model.PIPES, rule=pipe_rule) -model.nodes = Block(model.NODES, rule=node_rule) +model.pipes = pyo.Block(model.PIPES, rule=pipe_rule) +model.nodes = pyo.Block(model.NODES, rule=node_rule) # Connect the network -model.network_src = Constraint(model.PIPES, rule=_src_rule) -model.network_sink = Constraint(model.PIPES, rule=_sink_rule) +model.network_src = pyo.Constraint(model.PIPES, rule=_src_rule) +model.network_sink = pyo.Constraint(model.PIPES, rule=_sink_rule) # Solve so the minimum pressure in the network is 0 @@ -75,4 +79,4 @@ def _obj(model): return sum(model.nodes[n].pressure for n in model.NODES) -model.obj = Objective(rule=_obj) +model.obj = pyo.Objective(rule=_obj) diff --git a/examples/pyomo/core/block1.py b/examples/pyomo/core/block1.py index 161fc2ca2f7..cf8386e6a6e 100644 --- a/examples/pyomo/core/block1.py +++ b/examples/pyomo/core/block1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -model = ConcreteModel() -model.x = Var() +model = pyo.ConcreteModel() +model.x = pyo.Var() -model.b = Block() -model.b.x = Var() +model.b = pyo.Block() +model.b.x = pyo.Var() -model.o = Objective(expr=(model.x - 1.0) ** 2 + (model.b.x - 2.0) ** 2) +model.o = pyo.Objective(expr=(model.x - 1.0) ** 2 + (model.b.x - 2.0) ** 2) diff --git a/examples/pyomo/core/integrality1.py b/examples/pyomo/core/integrality1.py index 0ab3a433dac..6fa103195a7 100644 --- a/examples/pyomo/core/integrality1.py +++ b/examples/pyomo/core/integrality1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -M = ConcreteModel() -M.x1 = Var(within=Boolean) -M.x2 = Var(within=Boolean) -M.x3 = Var(within=Boolean) +M = pyo.ConcreteModel() +M.x1 = pyo.Var(within=pyo.Boolean) +M.x2 = pyo.Var(within=pyo.Boolean) +M.x3 = pyo.Var(within=pyo.Boolean) -M.o = Objective(expr=M.x1 + M.x2 + M.x3) -M.c1 = Constraint(expr=4 * M.x1 + M.x2 >= 1) -M.c2 = Constraint(expr=M.x2 + 4 * M.x3 >= 1) +M.o = pyo.Objective(expr=M.x1 + M.x2 + M.x3) +M.c1 = pyo.Constraint(expr=4 * M.x1 + M.x2 >= 1) +M.c2 = pyo.Constraint(expr=M.x2 + 4 * M.x3 >= 1) model = M diff --git a/examples/pyomo/core/integrality2.py b/examples/pyomo/core/integrality2.py index 6461d36f923..b3edadc7925 100644 --- a/examples/pyomo/core/integrality2.py +++ b/examples/pyomo/core/integrality2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo -M = ConcreteModel() -M.x = Var([1, 2, 3], within=Boolean) +M = pyo.ConcreteModel() +M.x = pyo.Var([1, 2, 3], within=pyo.Boolean) -M.o = Objective(expr=sum_product(M.x)) -M.c1 = Constraint(expr=4 * M.x[1] + M.x[2] >= 1) -M.c2 = Constraint(expr=M.x[2] + 4 * M.x[3] >= 1) +M.o = pyo.Objective(expr=pyo.sum_product(M.x)) +M.c1 = pyo.Constraint(expr=4 * M.x[1] + M.x[2] >= 1) +M.c2 = pyo.Constraint(expr=M.x[2] + 4 * M.x[3] >= 1) model = M diff --git a/examples/pyomo/core/simple.py b/examples/pyomo/core/simple.py index 6976f3d25ad..1c8de769d23 100644 --- a/examples/pyomo/core/simple.py +++ b/examples/pyomo/core/simple.py @@ -1,22 +1,23 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ + # simple.py -from pyomo.environ import * +import pyomo.environ as pyo -M = ConcreteModel() -M.x1 = Var() -M.x2 = Var(bounds=(-1, 1)) -M.x3 = Var(bounds=(1, 2)) -M.o = Objective( - expr=M.x1**2 + (M.x2 * M.x3) ** 4 + M.x1 * M.x3 + M.x2 * sin(M.x1 + M.x3) + M.x2 +M = pyo.ConcreteModel() +M.x1 = pyo.Var() +M.x2 = pyo.Var(bounds=(-1, 1)) +M.x3 = pyo.Var(bounds=(1, 2)) +M.o = pyo.Objective( + expr=M.x1**2 + (M.x2 * M.x3) ** 4 + M.x1 * M.x3 + M.x2 * pyo.sin(M.x1 + M.x3) + M.x2 ) model = M diff --git a/examples/pyomo/core/t1.py b/examples/pyomo/core/t1.py index 5d5416985a9..eaa3fbef242 100644 --- a/examples/pyomo/core/t1.py +++ b/examples/pyomo/core/t1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,19 +9,21 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def pyomo_create_model(options, model_options): - model = ConcreteModel() - model.x1 = Var(within=NonNegativeReals) - model.x2 = Var(within=NonNegativeReals) - model.x3 = Var(within=NonNegativeReals) + model = pyo.ConcreteModel() + model.x1 = pyo.Var(within=pyo.NonNegativeReals) + model.x2 = pyo.Var(within=pyo.NonNegativeReals) + model.x3 = pyo.Var(within=pyo.NonNegativeReals) - model.o = Objective(expr=6 * model.x1 + 4 * model.x2 + 2 * model.x3, sense=minimize) + model.o = pyo.Objective( + expr=6 * model.x1 + 4 * model.x2 + 2 * model.x3, sense=pyo.minimize + ) - model.c1 = Constraint(expr=4 * model.x1 + 2 * model.x2 + model.x3 >= 5) - model.c2 = Constraint(expr=model.x1 + model.x2 >= 3) - model.c3 = Constraint(expr=model.x2 + model.x3 >= 4) + model.c1 = pyo.Constraint(expr=4 * model.x1 + 2 * model.x2 + model.x3 >= 5) + model.c2 = pyo.Constraint(expr=model.x1 + model.x2 >= 3) + model.c3 = pyo.Constraint(expr=model.x2 + model.x3 >= 4) return model diff --git a/examples/pyomo/core/t2.py b/examples/pyomo/core/t2.py index 4d3f1934cbe..0117adb860d 100644 --- a/examples/pyomo/core/t2.py +++ b/examples/pyomo/core/t2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,19 +9,21 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo def pyomo_create_model(options, model_options): - model = ConcreteModel() - model.x1 = Var(within=NonNegativeReals) - model.x2 = Var(within=NonPositiveReals) - model.x3 = Var(within=Reals) + model = pyo.ConcreteModel() + model.x1 = pyo.Var(within=pyo.NonNegativeReals) + model.x2 = pyo.Var(within=pyo.NonPositiveReals) + model.x3 = pyo.Var(within=pyo.Reals) - model.o = Objective(expr=model.x1 + 2 * model.x2 + 3 * model.x3, sense=maximize) + model.o = pyo.Objective( + expr=model.x1 + 2 * model.x2 + 3 * model.x3, sense=pyo.maximize + ) - model.c1 = Constraint(expr=-model.x1 + 3 * model.x2 == 5) - model.c2 = Constraint(expr=2 * model.x1 - model.x2 + 3 * model.x3 >= 6) - model.c3 = Constraint(expr=model.x3 <= 4) + model.c1 = pyo.Constraint(expr=-model.x1 + 3 * model.x2 == 5) + model.c2 = pyo.Constraint(expr=2 * model.x1 - model.x2 + 3 * model.x3 >= 6) + model.c3 = pyo.Constraint(expr=model.x3 <= 4) return model diff --git a/examples/pyomo/core/t5.py b/examples/pyomo/core/t5.py index 6b9d94e0ff1..aa973b5401e 100644 --- a/examples/pyomo/core/t5.py +++ b/examples/pyomo/core/t5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,18 +13,18 @@ # A duality example adapted from # http://www.stanford.edu/~ashishg/msande111/notes/chapter4.pdf # -from pyomo.environ import * +import pyomo.environ as pyo def pyomo_create_model(options, model_options): - model = ConcreteModel() - model.x1 = Var(within=NonNegativeReals) - model.x2 = Var(within=NonNegativeReals) - model.o = Objective(expr=3 * model.x1 + 2.5 * model.x2, sense=maximize) + model = pyo.ConcreteModel() + model.x1 = pyo.Var(within=pyo.NonNegativeReals) + model.x2 = pyo.Var(within=pyo.NonNegativeReals) + model.o = pyo.Objective(expr=3 * model.x1 + 2.5 * model.x2, sense=pyo.maximize) - model.c1 = Constraint(expr=4.44 * model.x1 <= 100) - model.c2 = Constraint(expr=6.67 * model.x2 <= 100) - model.c3 = Constraint(expr=4 * model.x1 + 2.86 * model.x2 <= 100) - model.c4 = Constraint(expr=3 * model.x1 + 6 * model.x2 <= 100) + model.c1 = pyo.Constraint(expr=4.44 * model.x1 <= 100) + model.c2 = pyo.Constraint(expr=6.67 * model.x2 <= 100) + model.c3 = pyo.Constraint(expr=4 * model.x1 + 2.86 * model.x2 <= 100) + model.c4 = pyo.Constraint(expr=3 * model.x1 + 6 * model.x2 <= 100) return model diff --git a/examples/pyomo/diet/diet-sqlite.py b/examples/pyomo/diet/diet-sqlite.py index dccd3c338d0..4aa252d7db6 100644 --- a/examples/pyomo/diet/diet-sqlite.py +++ b/examples/pyomo/diet/diet-sqlite.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomo/diet/diet1.py b/examples/pyomo/diet/diet1.py index 217f80b9c25..dece6c38eb5 100644 --- a/examples/pyomo/diet/diet1.py +++ b/examples/pyomo/diet/diet1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # # Imports # -from pyomo.environ import * +import pyomo.environ as pyo infinity = float('inf') @@ -20,13 +20,13 @@ # Model # -model = AbstractModel() +model = pyo.AbstractModel() -model.FOOD = Set() +model.FOOD = pyo.Set() -model.cost = Param(model.FOOD, within=PositiveReals) +model.cost = pyo.Param(model.FOOD, within=pyo.PositiveReals) -model.f_min = Param(model.FOOD, within=NonNegativeReals, default=0.0) +model.f_min = pyo.Param(model.FOOD, within=pyo.NonNegativeReals, default=0.0) MAX_FOOD_SUPPLY = 20.0 # McDonald's doesn't stock infinite food @@ -35,13 +35,13 @@ def f_max_validate(model, value, j): return model.f_max[j] > model.f_min[j] -model.f_max = Param(model.FOOD, validate=f_max_validate, default=MAX_FOOD_SUPPLY) +model.f_max = pyo.Param(model.FOOD, validate=f_max_validate, default=MAX_FOOD_SUPPLY) # Unneeded vars - they're in the .dat file, so we list them here -model.NUTR = Set() -model.n_min = Param(model.NUTR, within=NonNegativeReals, default=0.0) -model.n_max = Param(model.NUTR, default=infinity) -model.amt = Param(model.NUTR, model.FOOD, within=NonNegativeReals) +model.NUTR = pyo.Set() +model.n_min = pyo.Param(model.NUTR, within=pyo.NonNegativeReals, default=0.0) +model.n_max = pyo.Param(model.NUTR, default=infinity) +model.amt = pyo.Param(model.NUTR, model.FOOD, within=pyo.NonNegativeReals) # -------------------------------------------------------- @@ -50,7 +50,7 @@ def Buy_bounds(model, i): return (model.f_min[i], model.f_max[i]) -model.Buy = Var(model.FOOD, bounds=Buy_bounds, within=NonNegativeIntegers) +model.Buy = pyo.Var(model.FOOD, bounds=Buy_bounds, within=pyo.NonNegativeIntegers) # -------------------------------------------------------- @@ -59,7 +59,7 @@ def Total_Cost_rule(model): return sum(model.cost[j] * model.Buy[j] for j in model.FOOD) -model.Total_Cost = Objective(rule=Total_Cost_rule, sense=minimize) +model.Total_Cost = pyo.Objective(rule=Total_Cost_rule, sense=pyo.minimize) # -------------------------------------------------------- @@ -75,7 +75,7 @@ def Entree_rule(model): return sum(model.Buy[e] for e in entrees) >= 1 -model.Entree = Constraint(rule=Entree_rule) +model.Entree = pyo.Constraint(rule=Entree_rule) def Side_rule(model): @@ -83,7 +83,7 @@ def Side_rule(model): return sum(model.Buy[s] for s in sides) >= 1 -model.Side = Constraint(rule=Side_rule) +model.Side = pyo.Constraint(rule=Side_rule) def Drink_rule(model): @@ -91,4 +91,4 @@ def Drink_rule(model): return sum(model.Buy[d] for d in drinks) >= 1 -model.Drink = Constraint(rule=Drink_rule) +model.Drink = pyo.Constraint(rule=Drink_rule) diff --git a/examples/pyomo/diet/diet2.py b/examples/pyomo/diet/diet2.py index 291261b0901..383ddebc857 100644 --- a/examples/pyomo/diet/diet2.py +++ b/examples/pyomo/diet/diet2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # # Imports # -from pyomo.environ import * +import pyomo.environ as pyo infinity = float('inf') @@ -20,32 +20,32 @@ # Model # -model = AbstractModel() +model = pyo.AbstractModel() -model.NUTR = Set() -model.FOOD = Set() +model.NUTR = pyo.Set() +model.FOOD = pyo.Set() -model.cost = Param(model.FOOD, within=PositiveReals) +model.cost = pyo.Param(model.FOOD, within=pyo.PositiveReals) -model.f_min = Param(model.FOOD, within=NonNegativeReals, default=0.0) +model.f_min = pyo.Param(model.FOOD, within=pyo.NonNegativeReals, default=0.0) def f_max_validate(model, value, j): return model.f_max[j] > model.f_min[j] -model.f_max = Param(model.FOOD, validate=f_max_validate, default=infinity) +model.f_max = pyo.Param(model.FOOD, validate=f_max_validate, default=infinity) -model.n_min = Param(model.NUTR, within=NonNegativeReals, default=0.0) +model.n_min = pyo.Param(model.NUTR, within=pyo.NonNegativeReals, default=0.0) def n_max_validate(model, value, j): return value > model.n_min[j] -model.n_max = Param(model.NUTR, validate=n_max_validate, default=infinity) +model.n_max = pyo.Param(model.NUTR, validate=n_max_validate, default=infinity) -model.amt = Param(model.NUTR, model.FOOD, within=NonNegativeReals) +model.amt = pyo.Param(model.NUTR, model.FOOD, within=pyo.NonNegativeReals) # -------------------------------------------------------- @@ -54,7 +54,7 @@ def Buy_bounds(model, i): return (model.f_min[i], model.f_max[i]) -model.Buy = Var(model.FOOD, bounds=Buy_bounds, within=NonNegativeIntegers) +model.Buy = pyo.Var(model.FOOD, bounds=Buy_bounds, within=pyo.NonNegativeIntegers) # -------------------------------------------------------- @@ -66,7 +66,7 @@ def Total_Cost_rule(model): return ans -model.Total_Cost = Objective(rule=Total_Cost_rule, sense=minimize) +model.Total_Cost = pyo.Objective(rule=Total_Cost_rule, sense=pyo.minimize) def Nutr_Amt_rule(model, i): @@ -76,7 +76,7 @@ def Nutr_Amt_rule(model, i): return ans -# model.Nutr_Amt = Objective(model.NUTR, rule=Nutr_Amt_rule) +# model.Nutr_Amt = pyo.Objective(model.NUTR, rule=Nutr_Amt_rule) # -------------------------------------------------------- @@ -88,4 +88,4 @@ def Diet_rule(model, i): return (model.n_min[i], expr, model.n_max[i]) -model.Diet = Constraint(model.NUTR, rule=Diet_rule) +model.Diet = pyo.Constraint(model.NUTR, rule=Diet_rule) diff --git a/examples/pyomo/draft/api.py b/examples/pyomo/draft/api.py index d785f41935e..c758647714d 100644 --- a/examples/pyomo/draft/api.py +++ b/examples/pyomo/draft/api.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,7 +18,7 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo ## ------------------------------------------------------------------------- @@ -32,7 +32,7 @@ # objects are contained in a model, which supports the generation of # problem instances that can be solved. # -model = AbstractModel() +model = pyo.AbstractModel() # # Once a model has been setup, a problem instance can be created with the # create() method: @@ -55,11 +55,11 @@ # # Simple constructor of a single set # -model.A = Set() +model.A = pyo.Set() # # Creating an array of sets, which are indexed with another set # -model.C = Set(model.A, model.A) +model.C = pyo.Set(model.A, model.A) # # Option 'initialize' indicates how values in the set will be constructed. # This option behaves differently depending on the type of data provided: @@ -68,15 +68,15 @@ # (3) a function can be used to initial a set (perhaps using model # information. # -model.A = Set(initialize=[1, 4, 9]) -model.B = Set(model.A, initialize={1: [1, 4, 9], 2: [2, 5, 10]}) +model.A = pyo.Set(initialize=[1, 4, 9]) +model.B = pyo.Set(model.A, initialize={1: [1, 4, 9], 2: [2, 5, 10]}) def f(model): return range(0, 10) -model.A = Set(initialize=f) +model.A = pyo.Set(initialize=f) # @@ -85,18 +85,18 @@ def f(model): # def f(model, i): if i == 10: - return Set.End + return pyo.Set.End if i == 0: return 1 else: return model.A[i - 1] * (i + 1) -model.A = Set(ordered=True, initialize=f) +model.A = pyo.Set(ordered=True, initialize=f) # # Option 'within' specifies a set that is used to validate set elements # -model.B = Set(within=model.A) +model.B = pyo.Set(within=model.A) # @@ -106,11 +106,11 @@ def f(model, value): return value in model.A -model.B = Set(validate=f) +model.B = pyo.Set(validate=f) # # Option 'dimen' specifies the arity of the data in the set # -model.A = Set(dimen=3) +model.A = pyo.Set(dimen=3) # #### #### Set Methods @@ -121,7 +121,7 @@ def f(model, value): len(model.A) len(model.A[i]) # -# data() - returns the underlying set object used by Set() +# data() - returns the underlying set object used by pyo.Set() # instance.A.data() instance.A[i].data() @@ -216,17 +216,17 @@ def f(model, value): # # Scalar parameter # -model.Z = Param() +model.Z = pyo.Param() # # Array of parameters # -model.Z = Param(model.A, model.B) +model.Z = pyo.Param(model.A, model.B) # # Option 'initialize' specifies values used to construct the parameter # -model.Z = Param(initialize=9) -model.Z = Param(model.A, initialize={1: 1, 2: 4, 3: 9}) -model.Z = Param(model.A, initialize=2) +model.Z = pyo.Param(initialize=9) +model.Z = pyo.Param(model.A, initialize={1: 1, 2: 4, 3: 9}) +model.Z = pyo.Param(model.A, initialize=2) # @@ -237,19 +237,19 @@ def f(model, i): return 3 * i -model.Z = Param(model.A, initialize=f) +model.Z = pyo.Param(model.A, initialize=f) # # Option 'default' specifies values used for a parameter if no value # has been set. Note that for scalar parameters this has the same # role as the 'initialize' option. For parameter arrays this # 'fills in' parameter values that have not been initialized. # -model.Z = Param(default=9.0) -model.Z = Param(model.A, default=9.0) +model.Z = pyo.Param(default=9.0) +model.Z = pyo.Param(model.A, default=9.0) # # Option 'within' specifies a set that is used to validate parameters # -model.Z = Param(within=model.A) +model.Z = pyo.Param(within=model.A) # @@ -259,7 +259,7 @@ def f(model, value): return value in model.A -model.Z = Param(validate=f) +model.Z = pyo.Param(validate=f) # #### #### Param Methods @@ -284,8 +284,8 @@ def f(model, value): # # Getting parameter values (which may be integer or floats) # -tmp = value(instance.Z) -tmp = value(instance.Z[i]) +tmp = pyo.value(instance.Z) +tmp = pyo.value(instance.Z[i]) tmp = instance.Z.value tmp = instance.Z[i].value # @@ -318,17 +318,17 @@ def f(model, value): # # Scalar variable # -model.x = Var() +model.x = pyo.Var() # # Array of variables # -model.x = Var(model.A, model.B) +model.x = pyo.Var(model.A, model.B) # # Option 'initialize' specifies the initial values of variables # -model.x = Var(initialize=9) -model.x = Var(model.A, initialize={1: 1, 2: 4, 3: 9}) -model.x = Var(model.A, initialize=2) +model.x = pyo.Var(initialize=9) +model.x = pyo.Var(model.A, initialize={1: 1, 2: 4, 3: 9}) +model.x = pyo.Var(model.A, initialize=2) # @@ -339,24 +339,24 @@ def f(model, i): return 3 * i -model.x = Var(model.A, initialize=f) +model.x = pyo.Var(model.A, initialize=f) # # Option 'within' specifies a set that is used to constrain variables # -model.x = Var(within=model.A) +model.x = pyo.Var(within=model.A) # # Option 'bounds' specifies upper and lower bounds for variables. # Simple bounds can be specified, or a function that defines bounds for # different variables. # -model.x = Var(bounds=(0.0, 1.0)) +model.x = pyo.Var(bounds=(0.0, 1.0)) def f(model, i): return (model.x_low[i], model._x_high[i]) -model.x = Var(bounds=f) +model.x = pyo.Var(bounds=f) # #### #### Var Methods @@ -381,8 +381,8 @@ def f(model, i): # # Getting variable values (which may be integer or floats) # -tmp = value(instance.x) -tmp = value(instance.x[i]) +tmp = pyo.value(instance.x) +tmp = pyo.value(instance.x[i]) tmp = instance.x.value tmp = instance.x[i].value # @@ -402,7 +402,7 @@ def f(model, i): #### # # Value - the value of a variable -# Note: value(x) == x.value +# Note: pyo.value(x) == x.value # instance.x.value = 1.0 tmp = instance.x.value @@ -435,29 +435,29 @@ def f(model, i): # # Scalar objective # -model.obj = Objective() +model.obj = pyo.Objective() # # Array of objectives # -model.obj = Objective(model.A, model.B) +model.obj = pyo.Objective(model.A, model.B) # # Option 'rule' can specify a function used to construct the objective # expression # -model.Z = Param(model.A) -model.x = Var(model.A) +model.Z = pyo.Param(model.A) +model.x = pyo.Var(model.A) def f(model, i): return model.Z[i] * model.A[i] -model.obj = Objective(model.A, rule=f) +model.obj = pyo.Objective(model.A, rule=f) # # Option 'sense' specifies whether the objective is maximized or minimized # Note: this option applies to all objectives in an array objective # -model.obj = Objective(sense=maximize) +model.obj = pyo.Objective(sense=pyo.maximize) # #### #### Objective Methods @@ -481,13 +481,13 @@ def f(model, i): # # sense - the optimization sense # -instance.x.sense = maximize +instance.x.sense = pyo.maximize # # value - returns the value of the objective # NOTE: this attribute cannot be set # tmp = instance.obj.value -tmp = value(instance.obj) +tmp = pyo.value(instance.obj) # # NOTE: if the objective is an array, then this returns a dictionary of # objective values, indexed by the array indices. Thus, the following @@ -495,7 +495,7 @@ def f(model, i): # for index '2' # tmp = instance.obj.value[2] -tmp = value(instance.obj)[2] +tmp = pyo.value(instance.obj)[2] ## ------------------------------------------------------------------------- @@ -514,17 +514,17 @@ def f(model, i): # # Scalar constraint # -model.con = Constraint() +model.con = pyo.Constraint() # # Array of constraint # -model.con = Constraint(model.A, model.B) +model.con = pyo.Constraint(model.A, model.B) # # Option 'rule' can specify a function used to construct the constraint # expression # -model.Z = Param(model.A) -model.x = Var(model.A) +model.Z = pyo.Param(model.A) +model.x = pyo.Var(model.A) def f(model, i): @@ -532,7 +532,7 @@ def f(model, i): return (0, expr, 1) -model.con = Constraint(model.A, rule=f) +model.con = pyo.Constraint(model.A, rule=f) # @@ -577,12 +577,12 @@ def f(model, i): # def f1(model, i): if i % 2 == 0: - return Constraint.Skip + return pyo.Constraint.Skip expr = model.Z[i] * model.A[i] return (0, expr, 1) -model.con1 = Constraint(model.A, rule=f1) +model.con1 = pyo.Constraint(model.A, rule=f1) def f2(model): @@ -594,7 +594,7 @@ def f2(model): return res -model.con2 = Constraint(model.A, rule=f2) +model.con2 = pyo.Constraint(model.A, rule=f2) #### #### Constraint Methods @@ -620,7 +620,7 @@ def f2(model): # NOTE: this attribute cannot be set # tmp = instance.con.value -tmp = value(instance.con) +tmp = pyo.value(instance.con) # # NOTE: if the constraint is an array, then this returns a dictionary of # constraint values, indexed by the array indices. Thus, the following @@ -628,4 +628,4 @@ def f2(model): # for index '2' # tmp = instance.con.value[2] -tmp = value(instance.con)[2] +tmp = pyo.value(instance.con)[2] diff --git a/examples/pyomo/draft/bpack.py b/examples/pyomo/draft/bpack.py index 7b076f7737b..f16697ba22c 100644 --- a/examples/pyomo/draft/bpack.py +++ b/examples/pyomo/draft/bpack.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,21 +10,21 @@ # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # # Parameter N # -model.N = Param(within=Integers) +model.N = pyo.Param(within=pyo.Integers) # # Set I # -model.I = RangeSet(1, model.N) +model.I = pyo.RangeSet(1, model.N) # # Variable b # -model.b = Var(model.I, domain=Boolean) +model.b = pyo.Var(model.I, domain=pyo.Boolean) # @@ -38,8 +38,8 @@ def costrule(model): return ans -# model.zot = Objective(rule=costrule) -model.zot = Objective(rule=costrule, sense=maximize) +# model.zot = pyo.Objective(rule=costrule) +model.zot = pyo.Objective(rule=costrule, sense=pyo.maximize) # @@ -59,19 +59,19 @@ def w_ind_rule(model): return ans -model.w_ind = Set(initialize=w_ind_rule, dimen=2) +model.w_ind = pyo.Set(initialize=w_ind_rule, dimen=2) # # Parameter w # -model.w = Param(model.w_ind) +model.w = pyo.Param(model.w_ind) # # Set rhs_ind # -model.rhs_ind = RangeSet(1, model.N - 9) +model.rhs_ind = pyo.RangeSet(1, model.N - 9) # # Parameter rhs # -model.rhs = Param(model.rhs_ind) +model.rhs = pyo.Param(model.rhs_ind) # @@ -88,4 +88,4 @@ def bletch_rule(model, i): return ans -model.bletch = Constraint(model.rhs_ind, rule=bletch_rule) +model.bletch = pyo.Constraint(model.rhs_ind, rule=bletch_rule) diff --git a/examples/pyomo/draft/diet2.py b/examples/pyomo/draft/diet2.py index d23fa3cf5db..9c318438a51 100644 --- a/examples/pyomo/draft/diet2.py +++ b/examples/pyomo/draft/diet2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,28 +15,28 @@ # # Imports # -from pyomo.core import * +import pyomo.environ as pyo # # Setup # -model = AbstractModel() -model.NUTR = Set() -model.FOOD = Set() +model = pyo.AbstractModel() +model.NUTR = pyo.Set() +model.FOOD = pyo.Set() -model.cost = Param(model.FOOD, within=NonNegativeReals) +model.cost = pyo.Param(model.FOOD, within=pyo.NonNegativeReals) -model.f_min = Param(model.FOOD, within=NonNegativeReals) +model.f_min = pyo.Param(model.FOOD, within=pyo.NonNegativeReals) def f_max_valid(model, value, j): return model.f_max[j] > model.f_min[j] -model.f_max = Param(model.FOOD, validate=f_max_valid) +model.f_max = pyo.Param(model.FOOD, validate=f_max_valid) -model.n_min = Param(model.NUTR, within=NonNegativeReals) +model.n_min = pyo.Param(model.NUTR, within=pyo.NonNegativeReals) def paramn_max(model, i): @@ -44,18 +44,18 @@ def paramn_max(model, i): return model.n_max[i] -model.n_max = Param(model.NUTR, initialize=paramn_max) +model.n_max = pyo.Param(model.NUTR, initialize=paramn_max) # *********************************** -model.amt = Param(model.NUTR, model.FOOD, within=NonNegativeReals) +model.amt = pyo.Param(model.NUTR, model.FOOD, within=pyo.NonNegativeReals) def Buy_bounds(model, i): return (model.f_min[i], model.f_max[i]) -model.Buy = Var(model.FOOD, bounds=Buy_bounds) +model.Buy = pyo.Var(model.FOOD, bounds=Buy_bounds) def Objective_rule(model): @@ -67,7 +67,7 @@ def Objective_rule(model): return ans -model.totalcost = Objective(rule=Objective_rule) +model.totalcost = pyo.Objective(rule=Objective_rule) def Diet_rule(model, i): @@ -81,4 +81,4 @@ def Diet_rule(model, i): return expr -model.Diet = Constraint(model.NUTR, rule=Diet_rule) +model.Diet = pyo.Constraint(model.NUTR, rule=Diet_rule) diff --git a/examples/pyomo/draft/morphEpanet3.mod b/examples/pyomo/draft/morphEpanet3.mod index a1a8dc79dee..2899d868dcf 100644 --- a/examples/pyomo/draft/morphEpanet3.mod +++ b/examples/pyomo/draft/morphEpanet3.mod @@ -25,7 +25,7 @@ set ID; param pipe := 1; param pump := 2; param valve := 3; -param typ {ID}; +param type {ID}; param source {ID}; param sink {ID}; param demand {V}; @@ -436,19 +436,19 @@ let DA := setof {(id,i,j,l,d,f,h) in Pipes} (id,i,j) let ID := setof {(id,i,j) in DA} id; for {(idd,i,j,l,d,f,h) in Pipes} { - let typ[idd] := pipe; + let type[idd] := pipe; let source[idd] := i; let sink[idd] := j; } for {(id,i,j,d,f,h) in Valves} { - let typ[id] := valve; + let type[id] := valve; let source[id] := i; let sink[id] := j; } for {(id,i,j,f,h) in Pumps} { - let typ[id] := pump; + let type[id] := pump; let source[id] := i; let sink[id] := j; } diff --git a/examples/pyomo/p-median/decorated_pmedian.py b/examples/pyomo/p-median/decorated_pmedian.py index c66971945f3..f66a1463745 100644 --- a/examples/pyomo/p-median/decorated_pmedian.py +++ b/examples/pyomo/p-median/decorated_pmedian.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,30 +9,30 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.environ import * +import pyomo.environ as pyo import random random.seed(1000) -model = AbstractModel() +model = pyo.AbstractModel() -model.N = Param(within=PositiveIntegers) -model.P = Param(within=RangeSet(1, model.N)) -model.M = Param(within=PositiveIntegers) +model.N = pyo.Param(within=pyo.PositiveIntegers) +model.P = pyo.Param(within=pyo.RangeSet(1, model.N)) +model.M = pyo.Param(within=pyo.PositiveIntegers) -model.Locations = RangeSet(1, model.N) -model.Customers = RangeSet(1, model.M) +model.Locations = pyo.RangeSet(1, model.N) +model.Customers = pyo.RangeSet(1, model.M) -model.d = Param( +model.d = pyo.Param( model.Locations, model.Customers, initialize=lambda n, m, model: random.uniform(1.0, 2.0), - within=Reals, + within=pyo.Reals, ) -model.x = Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) -model.y = Var(model.Locations, within=Binary) +model.x = pyo.Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) +model.y = pyo.Var(model.Locations, within=pyo.Binary) @model.Objective() diff --git a/examples/pyomo/p-median/pmedian.py b/examples/pyomo/p-median/pmedian.py index 865aa7cb61f..86143d1c3fa 100644 --- a/examples/pyomo/p-median/pmedian.py +++ b/examples/pyomo/p-median/pmedian.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo def pyomo_create_model(options=None, model_options=None): @@ -18,28 +18,28 @@ def pyomo_create_model(options=None, model_options=None): random.seed(1000) - model = AbstractModel() + model = pyo.AbstractModel() - model.N = Param(within=PositiveIntegers) + model.N = pyo.Param(within=pyo.PositiveIntegers) - model.Locations = RangeSet(1, model.N) + model.Locations = pyo.RangeSet(1, model.N) - model.P = Param(within=RangeSet(1, model.N)) + model.P = pyo.Param(within=pyo.RangeSet(1, model.N)) - model.M = Param(within=PositiveIntegers) + model.M = pyo.Param(within=pyo.PositiveIntegers) - model.Customers = RangeSet(1, model.M) + model.Customers = pyo.RangeSet(1, model.M) - model.d = Param( + model.d = pyo.Param( model.Locations, model.Customers, initialize=lambda n, m, model: random.uniform(1.0, 2.0), - within=Reals, + within=pyo.Reals, ) - model.x = Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) + model.x = pyo.Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) - model.y = Var(model.Locations, within=Binary) + model.y = pyo.Var(model.Locations, within=pyo.Binary) def rule(model): return sum( @@ -48,21 +48,21 @@ def rule(model): for m in model.Customers ) - model.obj = Objective(rule=rule) + model.obj = pyo.Objective(rule=rule) def rule(model, m): return (sum(model.x[n, m] for n in model.Locations), 1.0) - model.single_x = Constraint(model.Customers, rule=rule) + model.single_x = pyo.Constraint(model.Customers, rule=rule) def rule(model, n, m): return (None, model.x[n, m] - model.y[n], 0.0) - model.bound_y = Constraint(model.Locations, model.Customers, rule=rule) + model.bound_y = pyo.Constraint(model.Locations, model.Customers, rule=rule) def rule(model): return (sum(model.y[n] for n in model.Locations) - model.P, 0.0) - model.num_facilities = Constraint(rule=rule) + model.num_facilities = pyo.Constraint(rule=rule) return model diff --git a/examples/pyomo/p-median/solver1.py b/examples/pyomo/p-median/solver1.py index 2652ab13943..7d63400aded 100644 --- a/examples/pyomo/p-median/solver1.py +++ b/examples/pyomo/p-median/solver1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,12 +10,13 @@ # ___________________________________________________________________________ # Imports from Pyomo -from pyomo.core import * -from pyomo.common.plugin_base import * -from pyomo.opt import * +import copy +import pyomo.environ as pyo +from pyomo.common.plugin_base import alias, implements +from pyomo.opt import SolverStatus, SolutionStatus, ProblemSense -@plugin_factory(SolverFactory) +@plugin_factory(pyo.SolverFactory) class MySolver(object): alias('greedy') @@ -27,9 +28,9 @@ class MySolver(object): def solve(self, instance, **kwds): print("Starting greedy heuristic") val, instance = self._greedy(instance) - n = value(instance.N) + n = pyo.value(instance.N) # Setup results - results = SolverResults() + results = pyo.SolverResults() results.problem.name = instance.name results.problem.sense = ProblemSense.minimize results.problem.num_constraints = 1 @@ -39,31 +40,31 @@ def solve(self, instance, **kwds): soln = results.solution.add() soln.value = val soln.status = SolutionStatus.feasible - for j in sequence(n): + for j in pyo.sequence(n): if instance.y[j].value is 1: soln.variable[instance.y[j].name] = {"Value": 1, "Id": j} return results # Perform a greedy search def _greedy(self, instance): - p = value(instance.P) - n = value(instance.N) - m = value(instance.M) + p = pyo.value(instance.P) + n = pyo.value(instance.N) + m = pyo.value(instance.M) fixed = set() # Initialize - for j in sequence(n): + for j in pyo.sequence(n): instance.y[j].value = 0 # Greedily fix the next best facility - for i in sequence(p): + for i in pyo.sequence(p): best = None ndx = j - for j in sequence(n): + for j in pyo.sequence(n): if j in fixed: continue instance.y[j].value = 1 # Compute value val = 0.0 - for kk in sequence(m): + for kk in pyo.sequence(m): tmp = copy.copy(fixed) tmp.add(j) tbest = None diff --git a/examples/pyomo/p-median/solver2.py b/examples/pyomo/p-median/solver2.py index 50ec5388811..f8355d34b23 100644 --- a/examples/pyomo/p-median/solver2.py +++ b/examples/pyomo/p-median/solver2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,9 +10,9 @@ # ___________________________________________________________________________ # Imports from Pyomo -from pyomo.core import * -from pyomo.common.plugin_base import * -from pyomo.opt import * +import pyomo.environ as pyo +from pyomo.common.plugin_base import alias, implements +from pyomo.opt import SolverStatus, SolutionStatus, ProblemSense import random import copy @@ -29,9 +29,9 @@ class MySolver(object): def solve(self, instance, **kwds): print("Starting random heuristic") val, sol = self._random(instance) - n = value(instance.N) + n = pyo.value(instance.N) # Setup results - results = SolverResults() + results = pyo.SolverResults() results.problem.name = instance.name results.problem.sense = ProblemSense.minimize results.problem.num_constraints = 1 @@ -41,7 +41,7 @@ def solve(self, instance, **kwds): soln = results.solution.add() soln.value = val soln.status = SolutionStatus.feasible - for j in sequence(n): + for j in pyo.sequence(n): soln.variable[instance.y[j].name] = {"Value": sol[j - 1], "Id": j} # Return results return results @@ -58,11 +58,11 @@ def _random(self, instance): random.shuffle(sol) # Compute value val = 0.0 - for j in sequence(instance.M.value): + for j in pyo.sequence(instance.M.value): val += min( [ instance.d[i, j].value - for i in sequence(instance.N.value) + for i in pyo.sequence(instance.N.value) if sol[i - 1] == 1 ] ) diff --git a/examples/pyomo/piecewise/convex.py b/examples/pyomo/piecewise/convex.py index fb8095f80e3..f9136bf12e8 100644 --- a/examples/pyomo/piecewise/convex.py +++ b/examples/pyomo/piecewise/convex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,7 +17,7 @@ # \ X , 1 <= X <= 5 # -from pyomo.core import * +import pyomo.environ as pyo # Define the function @@ -27,14 +27,14 @@ def f(model, x): return abs(x - 1) + 1.0 -model = ConcreteModel() +model = pyo.ConcreteModel() -model.X = Var(bounds=(-5, 5)) -model.Z = Var() +model.X = pyo.Var(bounds=(-5, 5)) +model.Z = pyo.Var() # See documentation on Piecewise component by typing # help(Piecewise) in a python terminal after importing pyomo.core -model.con = Piecewise( +model.con = pyo.Piecewise( model.Z, model.X, # range and domain variables pw_pts=[-5, 1, 5], @@ -48,4 +48,4 @@ def f(model, x): # when a lower bounding convex function is supplied. Adding 'force_pw=True' # to the Piecewise argument list will cause the original piecewise constraints # to be used even when simplifications can be applied. -model.obj = Objective(expr=model.Z, sense=minimize) +model.obj = pyo.Objective(expr=model.Z, sense=pyo.minimize) diff --git a/examples/pyomo/piecewise/indexed.py b/examples/pyomo/piecewise/indexed.py index cde21ec847e..2aa47e5745c 100644 --- a/examples/pyomo/piecewise/indexed.py +++ b/examples/pyomo/piecewise/indexed.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,24 +11,24 @@ # A piewise approximiation of a nonconvex objective function. -from pyomo.core import * +import pyomo.environ as pyo # Define the function # Just like in Pyomo constraint rules, a Pyomo model object # must be the first argument for the function rule def f(model, t1, t2, x): - return 0.1 * x - cos(5.0 * x) + return 0.1 * x - pyo.cos(5.0 * x) -model = ConcreteModel() +model = pyo.ConcreteModel() # Note we can use an arbitrary number of index sets of # arbitrary dimension as the first arguments to the # Piecewise component. -model.INDEX1 = Set(dimen=2, initialize=[(0, 1), (8, 3)]) -model.X = Var(model.INDEX1, bounds=(-2, 2)) -model.Z = Var(model.INDEX1) +model.INDEX1 = pyo.Set(dimen=2, initialize=[(0, 1), (8, 3)]) +model.X = pyo.Var(model.INDEX1, bounds=(-2, 2)) +model.Z = pyo.Var(model.INDEX1) # For indexed variables, pw_pts must be a # python dictionary with keys the same as the variable index @@ -45,7 +45,7 @@ def f(model, t1, t2, x): for idx in model.X.index_set(): PW_PTS[idx] = [-2.0 + i * step for i in range(num_points)] # [-2.0, ..., 2.0] -model.linearized_constraint = Piecewise( +model.linearized_constraint = pyo.Piecewise( model.INDEX1, # indexing sets model.Z, model.X, # range and domain variables @@ -59,4 +59,4 @@ def f(model, t1, t2, x): # maximize the sum of Z over its index # This is just a simple example of how to implement indexed variables. All indices # of Z will have the same solution. -model.obj = Objective(expr=sum_product(model.Z), sense=maximize) +model.obj = pyo.Objective(expr=pyo.sum_product(model.Z), sense=pyo.maximize) diff --git a/examples/pyomo/piecewise/indexed_nonlinear.py b/examples/pyomo/piecewise/indexed_nonlinear.py index d72fbc8a899..d3e66e5c52c 100644 --- a/examples/pyomo/piecewise/indexed_nonlinear.py +++ b/examples/pyomo/piecewise/indexed_nonlinear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # Nonlinear version of example4. # Must have a nonlinear solver # to run this example. -from pyomo.core import * +import pyomo.environ as pyo from indexed import model, f @@ -22,7 +22,7 @@ def nonlinear_con_rule(model, i, j): return model.Z[i, j] == f(model, i, j, model.X[i, j]) -model.nonlinear_constraint = Constraint(model.INDEX1, rule=nonlinear_con_rule) +model.nonlinear_constraint = pyo.Constraint(model.INDEX1, rule=nonlinear_con_rule) # deactivate all constraints on the Piecewise component model.linearized_constraint.deactivate() diff --git a/examples/pyomo/piecewise/indexed_points.py b/examples/pyomo/piecewise/indexed_points.py index 66110bea342..06b178af341 100644 --- a/examples/pyomo/piecewise/indexed_points.py +++ b/examples/pyomo/piecewise/indexed_points.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,25 +13,25 @@ # index set when starting from a discrete set of (x,y) # points. -from pyomo.core import * +import pyomo.environ as pyo # Note: One or both of these can also be a dictionary of # lists with keys matching the index set x = [0.0, 1.5, 3.0, 5.0] y = [1.1, -1.1, 2.0, 1.1] -model = ConcreteModel() -model.index = Set(initialize=[1, 2, 3]) -model.x = Var(model.index, bounds=(min(x), max(x))) -model.y = Var(model.index) +model = pyo.ConcreteModel() +model.index = pyo.Set(initialize=[1, 2, 3]) +model.x = pyo.Var(model.index, bounds=(min(x), max(x))) +model.y = pyo.Var(model.index) -model.fx = Piecewise( +model.fx = pyo.Piecewise( model.index, model.y, model.x, pw_pts=x, pw_constr_type='EQ', f_rule=y ) -model.c = ConstraintList() +model.c = pyo.ConstraintList() model.c.add(model.x[1] >= 1.0) model.c.add(model.x[2] >= 2.0) model.c.add(model.x[3] >= 3.1) -model.o = Objective(expr=sum_product(model.y)) +model.o = pyo.Objective(expr=pyo.sum_product(model.y)) diff --git a/examples/pyomo/piecewise/nonconvex.py b/examples/pyomo/piecewise/nonconvex.py index 5300278d5b9..8164fc8cbe8 100644 --- a/examples/pyomo/piecewise/nonconvex.py +++ b/examples/pyomo/piecewise/nonconvex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,7 +15,7 @@ # Z(X) = | -2X + 4 , 2 <= X <= 6 # \ 5X - 38 , 6 <= X <= 10 -from pyomo.core import * +import pyomo.environ as pyo # Define the function # Just like in Pyomo constraint rules, a Pyomo model object @@ -27,17 +27,17 @@ def f(model, x): return RANGE_POINTS[x] -model = ConcreteModel() +model = pyo.ConcreteModel() -model.X = Var(bounds=(-1.0, 10.0)) -model.Z = Var() -model.p = Var(within=NonNegativeReals) -model.n = Var(within=NonNegativeReals) +model.X = pyo.Var(bounds=(-1.0, 10.0)) +model.Z = pyo.Var() +model.p = pyo.Var(within=pyo.NonNegativeReals) +model.n = pyo.Var(within=pyo.NonNegativeReals) # See documentation on Piecewise component by typing # help(Piecewise) in a python terminal after importing pyomo.core # Using BigM constraints with binary variables to represent the piecewise constraints -model.con = Piecewise( +model.con = pyo.Piecewise( model.Z, model.X, # range and domain variables pw_pts=[-1.0, 2.0, 6.0, 10.0], @@ -47,5 +47,5 @@ def f(model, x): ) # minimize the 1-norm distance of Z to 7.0, i.e., |Z-7| -model.pn_con = Constraint(expr=model.Z - 7.0 == model.p - model.n) -model.obj = Objective(rule=lambda model: model.p + model.n, sense=minimize) +model.pn_con = pyo.Constraint(expr=model.Z - 7.0 == model.p - model.n) +model.obj = pyo.Objective(rule=lambda model: model.p + model.n, sense=pyo.minimize) diff --git a/examples/pyomo/piecewise/points.py b/examples/pyomo/piecewise/points.py index 91d45684c4f..6d4980e5949 100644 --- a/examples/pyomo/piecewise/points.py +++ b/examples/pyomo/piecewise/points.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,15 +12,15 @@ # An example of how to use the Piecewise component when # starting from a discrete set of (x,y) points. -from pyomo.core import * +import pyomo.environ as pyo x = [0.0, 1.5, 3.0, 5.0] y = [1.1, -1.1, 2.0, 1.1] -model = ConcreteModel() -model.x = Var(bounds=(min(x), max(x))) -model.y = Var() +model = pyo.ConcreteModel() +model.x = pyo.Var(bounds=(min(x), max(x))) +model.y = pyo.Var() -model.fx = Piecewise(model.y, model.x, pw_pts=x, pw_constr_type='EQ', f_rule=y) +model.fx = pyo.Piecewise(model.y, model.x, pw_pts=x, pw_constr_type='EQ', f_rule=y) -model.o = Objective(expr=model.y) +model.o = pyo.Objective(expr=model.y) diff --git a/examples/pyomo/piecewise/step.py b/examples/pyomo/piecewise/step.py index 95aac74d7f7..f6e58ae562a 100644 --- a/examples/pyomo/piecewise/step.py +++ b/examples/pyomo/piecewise/step.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -24,16 +24,16 @@ DOMAIN_PTS = [0.0, 1.0, 1.0, 2.0, 2.0, 3.0] RANGE_PTS = [0.0, 0.0, 2.0, 2.0, 0.5, 0.5] -from pyomo.core import * +import pyomo.environ as pyo -model = ConcreteModel() +model = pyo.ConcreteModel() -model.X = Var(bounds=(0, 3)) -model.Z = Var() +model.X = pyo.Var(bounds=(0, 3)) +model.Z = pyo.Var() # See documentation on Piecewise component by typing # help(Piecewise) in a python terminal after importing pyomo.core -model.con = Piecewise( +model.con = pyo.Piecewise( model.Z, model.X, # range and domain variables pw_pts=DOMAIN_PTS, @@ -45,4 +45,4 @@ # not work with step functions are: # BIGM_SOS1, BIGM_BIN, and MC -model.obj = Objective(expr=model.Z + model.X, sense=maximize) +model.obj = pyo.Objective(expr=model.Z + model.X, sense=pyo.maximize) diff --git a/examples/pyomo/quadratic/example1.py b/examples/pyomo/quadratic/example1.py index ab77c5a1733..3b2fce05820 100644 --- a/examples/pyomo/quadratic/example1.py +++ b/examples/pyomo/quadratic/example1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,15 +14,15 @@ # constraints could (but shouldn't in a perfect world) cause issues for # certain solvers. -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.x = Var(bounds=(-10, 10), within=Reals) +model.x = pyo.Var(bounds=(-10, 10), within=pyo.Reals) def objective_rule(model): return model.x * model.x -model.objective = Objective(rule=objective_rule, sense=minimize) +model.objective = pyo.Objective(rule=objective_rule, sense=pyo.minimize) diff --git a/examples/pyomo/quadratic/example2.py b/examples/pyomo/quadratic/example2.py index ce02c6f70c8..8978f47cde9 100644 --- a/examples/pyomo/quadratic/example2.py +++ b/examples/pyomo/quadratic/example2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,29 +12,29 @@ # a slightly less brain-dead separable parabolic function of three variables, whose # minimum is obviously at x[1]=x[2]=x[3]=0. -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() def indices_rule(model): return range(1, 4) -model.indices = Set(initialize=indices_rule, within=PositiveIntegers) +model.indices = pyo.Set(initialize=indices_rule, within=pyo.PositiveIntegers) -model.x = Var(model.indices, within=Reals) +model.x = pyo.Var(model.indices, within=pyo.Reals) def bound_x_rule(model, i): return (-10, model.x[i], 10) -model.bound_x = Constraint(model.indices, rule=bound_x_rule) +model.bound_x = pyo.Constraint(model.indices, rule=bound_x_rule) def objective_rule(model): return sum([model.x[i] * model.x[i] for i in model.indices]) -model.objective = Objective(rule=objective_rule, sense=minimize) +model.objective = pyo.Objective(rule=objective_rule, sense=pyo.minimize) diff --git a/examples/pyomo/quadratic/example3.py b/examples/pyomo/quadratic/example3.py index bdba936f694..5a000dc633b 100644 --- a/examples/pyomo/quadratic/example3.py +++ b/examples/pyomo/quadratic/example3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,29 +13,29 @@ # optimal objective function value is obviously 5, with the optimal solution # being x[1]=x[2]=x[3]=3. -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() def indices_rule(model): - return xrange(1, 4) + return range(1, 4) -model.indices = Set(initialize=indices_rule, within=PositiveIntegers) +model.indices = pyo.Set(initialize=indices_rule, within=pyo.PositiveIntegers) -model.x = Var(model.indices, within=Reals) +model.x = pyo.Var(model.indices, within=pyo.Reals) def bound_x_rule(model, i): return (-10, model.x[i], 10) -model.bound_x = Constraint(model.indices, rule=bound_x_rule) +model.bound_x = pyo.Constraint(model.indices, rule=bound_x_rule) def objective_rule(model): return 5 + sum([(model.x[i] - 3) * (model.x[i] - 3) for i in model.indices]) -model.objective = Objective(rule=objective_rule, sense=minimize) +model.objective = pyo.Objective(rule=objective_rule, sense=pyo.minimize) diff --git a/examples/pyomo/quadratic/example4.py b/examples/pyomo/quadratic/example4.py index ecfc9981162..f39e1941f93 100644 --- a/examples/pyomo/quadratic/example4.py +++ b/examples/pyomo/quadratic/example4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,19 +12,19 @@ # a simple quadratic function of two variables - taken from the CPLEX file format reference manual. # optimal objective function value is 60. solution is x=10, y=0. -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() -model.x = Var(within=NonNegativeReals) -model.y = Var(within=NonNegativeReals) +model.x = pyo.Var(within=pyo.NonNegativeReals) +model.y = pyo.Var(within=pyo.NonNegativeReals) def constraint_rule(model): return model.x + model.y >= 10 -model.constraint = Constraint(rule=constraint_rule) +model.constraint = pyo.Constraint(rule=constraint_rule) def objective_rule(model): @@ -35,4 +35,4 @@ def objective_rule(model): ) -model.objective = Objective(rule=objective_rule, sense=minimize) +model.objective = pyo.Objective(rule=objective_rule, sense=pyo.minimize) diff --git a/examples/pyomo/radertext/Ex2_1.py b/examples/pyomo/radertext/Ex2_1.py index 981388d4c72..22f4efc8c8d 100644 --- a/examples/pyomo/radertext/Ex2_1.py +++ b/examples/pyomo/radertext/Ex2_1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,26 +13,26 @@ # Example 2.1 - Allen Holder # -from pyomo.core import * +import pyomo.environ as pyo # Instantiate the model -model = AbstractModel() +model = pyo.AbstractModel() # Sets -model.DoorType = Set() -model.MachineType = Set() -model.MarketDoorType1 = Set(within=model.DoorType) -model.MarketDoorType2 = Set(within=model.DoorType) +model.DoorType = pyo.Set() +model.MachineType = pyo.Set() +model.MarketDoorType1 = pyo.Set(within=model.DoorType) +model.MarketDoorType2 = pyo.Set(within=model.DoorType) # Parameters -model.Hours = Param(model.DoorType, model.MachineType, within=NonNegativeReals) -model.Labor = Param(model.DoorType, model.MachineType, within=NonNegativeReals) -model.Profit = Param(model.DoorType, within=NonNegativeReals) -model.MachineLimit = Param(model.MachineType, within=NonNegativeReals) -model.LaborLimit = Param(within=NonNegativeReals) +model.Hours = pyo.Param(model.DoorType, model.MachineType, within=pyo.NonNegativeReals) +model.Labor = pyo.Param(model.DoorType, model.MachineType, within=pyo.NonNegativeReals) +model.Profit = pyo.Param(model.DoorType, within=pyo.NonNegativeReals) +model.MachineLimit = pyo.Param(model.MachineType, within=pyo.NonNegativeReals) +model.LaborLimit = pyo.Param(within=pyo.NonNegativeReals) # Variables -model.NumDoors = Var(model.DoorType, within=NonNegativeIntegers) +model.NumDoors = pyo.Var(model.DoorType, within=pyo.NonNegativeIntegers) # Objective @@ -40,7 +40,7 @@ def CalcProfit(M): return sum(M.NumDoors[d] * M.Profit[d] for d in M.DoorType) -model.TotProf = Objective(rule=CalcProfit, sense=maximize) +model.TotProf = pyo.Objective(rule=CalcProfit, sense=pyo.maximize) # Constraints @@ -48,7 +48,7 @@ def EnsureMachineLimit(M, m): return sum(M.NumDoors[d] * M.Labor[d, m] for d in M.DoorType) <= M.MachineLimit[m] -model.MachineUpBound = Constraint(model.MachineType, rule=EnsureMachineLimit) +model.MachineUpBound = pyo.Constraint(model.MachineType, rule=EnsureMachineLimit) def EnsureLaborLimit(M): @@ -58,7 +58,7 @@ def EnsureLaborLimit(M): ) -model.MachineUpBound = Constraint(rule=EnsureLaborLimit) +model.MachineUpBound = pyo.Constraint(rule=EnsureLaborLimit) def EnsureMarketRatio(M): @@ -67,4 +67,4 @@ def EnsureMarketRatio(M): ) -model.MarketRatio = Constraint(rule=EnsureMarketRatio) +model.MarketRatio = pyo.Constraint(rule=EnsureMarketRatio) diff --git a/examples/pyomo/radertext/Ex2_2.py b/examples/pyomo/radertext/Ex2_2.py index 41b56e52669..d610c178c19 100644 --- a/examples/pyomo/radertext/Ex2_2.py +++ b/examples/pyomo/radertext/Ex2_2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,22 +13,22 @@ # Example 2.2 - Allen Holder # -from pyomo.core import * +import pyomo.environ as pyo # Instantiate the model -model = AbstractModel() +model = pyo.AbstractModel() # Parameters for Set Definitions -model.NumTimePeriods = Param(within=NonNegativeIntegers) +model.NumTimePeriods = pyo.Param(within=pyo.NonNegativeIntegers) # Sets -model.StartTime = RangeSet(1, model.NumTimePeriods) +model.StartTime = pyo.RangeSet(1, model.NumTimePeriods) # Parameters -model.RequiredWorkers = Param(model.StartTime, within=NonNegativeIntegers) +model.RequiredWorkers = pyo.Param(model.StartTime, within=pyo.NonNegativeIntegers) # Variables -model.NumWorkers = Var(model.StartTime, within=NonNegativeIntegers) +model.NumWorkers = pyo.Var(model.StartTime, within=pyo.NonNegativeIntegers) # Objective @@ -36,7 +36,7 @@ def CalcTotalWorkers(M): return sum(M.NumWorkers[i] for i in M.StartTime) -model.TotalWorkers = Objective(rule=CalcTotalWorkers, sense=minimize) +model.TotalWorkers = pyo.Objective(rule=CalcTotalWorkers, sense=pyo.minimize) # Constraints @@ -50,4 +50,4 @@ def EnsureWorkforce(M, i): ) -model.WorkforceDemand = Constraint(model.StartTime, rule=EnsureWorkforce) +model.WorkforceDemand = pyo.Constraint(model.StartTime, rule=EnsureWorkforce) diff --git a/examples/pyomo/radertext/Ex2_3.py b/examples/pyomo/radertext/Ex2_3.py index 7dc39afa773..534524053c2 100644 --- a/examples/pyomo/radertext/Ex2_3.py +++ b/examples/pyomo/radertext/Ex2_3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,38 +13,37 @@ # Problem 2.17 - courtesy Allen Holder # -from pyomo.core import * -from pyomo.opt import * +import pyomo.environ as pyo # Instantiate the model -model = AbstractModel() +model = pyo.AbstractModel() # Parameters for Set Definitions -model.NumCrudeTypes = Param(within=PositiveIntegers) -model.NumGasTypes = Param(within=PositiveIntegers) +model.NumCrudeTypes = pyo.Param(within=pyo.PositiveIntegers) +model.NumGasTypes = pyo.Param(within=pyo.PositiveIntegers) # Sets -model.CrudeType = RangeSet(1, model.NumCrudeTypes) -model.GasType = RangeSet(1, model.NumGasTypes) +model.CrudeType = pyo.RangeSet(1, model.NumCrudeTypes) +model.GasType = pyo.RangeSet(1, model.NumGasTypes) # Parameters -model.Cost = Param(model.CrudeType, within=NonNegativeReals) -model.CrudeOctane = Param(model.CrudeType, within=NonNegativeReals) -model.CrudeMax = Param(model.CrudeType, within=NonNegativeReals) -model.MinGasOctane = Param(model.GasType, within=NonNegativeReals) -model.GasPrice = Param(model.GasType, within=NonNegativeReals) -model.GasDemand = Param(model.GasType, within=NonNegativeReals) -model.MixtureUpBounds = Param( - model.CrudeType, model.GasType, within=NonNegativeReals, default=10**8 +model.Cost = pyo.Param(model.CrudeType, within=pyo.NonNegativeReals) +model.CrudeOctane = pyo.Param(model.CrudeType, within=pyo.NonNegativeReals) +model.CrudeMax = pyo.Param(model.CrudeType, within=pyo.NonNegativeReals) +model.MinGasOctane = pyo.Param(model.GasType, within=pyo.NonNegativeReals) +model.GasPrice = pyo.Param(model.GasType, within=pyo.NonNegativeReals) +model.GasDemand = pyo.Param(model.GasType, within=pyo.NonNegativeReals) +model.MixtureUpBounds = pyo.Param( + model.CrudeType, model.GasType, within=pyo.NonNegativeReals, default=10**8 ) -model.MixtureLowBounds = Param( - model.CrudeType, model.GasType, within=NonNegativeReals, default=0 +model.MixtureLowBounds = pyo.Param( + model.CrudeType, model.GasType, within=pyo.NonNegativeReals, default=0 ) # Variabls -model.x = Var(model.CrudeType, model.GasType, within=NonNegativeReals) -model.q = Var(model.CrudeType, within=NonNegativeReals) -model.z = Var(model.GasType, within=NonNegativeReals) +model.x = pyo.Var(model.CrudeType, model.GasType, within=pyo.NonNegativeReals) +model.q = pyo.Var(model.CrudeType, within=pyo.NonNegativeReals) +model.z = pyo.Var(model.GasType, within=pyo.NonNegativeReals) # Objective @@ -54,7 +53,7 @@ def CalcProfit(M): ) -model.Profit = Objective(rule=CalcProfit, sense=maximize) +model.Profit = pyo.Objective(rule=CalcProfit, sense=pyo.maximize) # Constraints @@ -63,28 +62,28 @@ def BalanceCrude(M, i): return sum(M.x[i, j] for j in M.GasType) == M.q[i] -model.BalanceCrudeProduction = Constraint(model.CrudeType, rule=BalanceCrude) +model.BalanceCrudeProduction = pyo.Constraint(model.CrudeType, rule=BalanceCrude) def BalanceGas(M, j): return sum(M.x[i, j] for i in M.CrudeType) == M.z[j] -model.BalanceGasProduction = Constraint(model.GasType, rule=BalanceGas) +model.BalanceGasProduction = pyo.Constraint(model.GasType, rule=BalanceGas) def EnsureCrudeLimit(M, i): return M.q[i] <= M.CrudeMax[i] -model.LimitCrude = Constraint(model.CrudeType, rule=EnsureCrudeLimit) +model.LimitCrude = pyo.Constraint(model.CrudeType, rule=EnsureCrudeLimit) def EnsureGasDemand(M, j): return M.z[j] >= M.GasDemand[j] -model.DemandGas = Constraint(model.GasType, rule=EnsureGasDemand) +model.DemandGas = pyo.Constraint(model.GasType, rule=EnsureGasDemand) def EnsureOctane(M, j): @@ -94,18 +93,22 @@ def EnsureOctane(M, j): ) -model.OctaneLimit = Constraint(model.GasType, rule=EnsureOctane) +model.OctaneLimit = pyo.Constraint(model.GasType, rule=EnsureOctane) def EnsureLowMixture(M, i, j): return sum(M.x[k, j] for k in M.CrudeType) * M.MixtureLowBounds[i, j] <= M.x[i, j] -model.LowCrudeBound = Constraint(model.CrudeType, model.GasType, rule=EnsureLowMixture) +model.LowCrudeBound = pyo.Constraint( + model.CrudeType, model.GasType, rule=EnsureLowMixture +) def EnsureUpMixture(M, i, j): return sum(M.x[k, j] for k in M.CrudeType) * M.MixtureUpBounds[i, j] >= M.x[i, j] -model.UpCrudeBound = Constraint(model.CrudeType, model.GasType, rule=EnsureUpMixture) +model.UpCrudeBound = pyo.Constraint( + model.CrudeType, model.GasType, rule=EnsureUpMixture +) diff --git a/examples/pyomo/radertext/Ex2_5.py b/examples/pyomo/radertext/Ex2_5.py index fee49b46cb0..10ab0bd5da1 100644 --- a/examples/pyomo/radertext/Ex2_5.py +++ b/examples/pyomo/radertext/Ex2_5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,30 +13,29 @@ # Example 2.5 - Allen Holder # -from pyomo.core import * -from pyomo.opt import * +import pyomo.environ as pyo # Instantiate the model -model = AbstractModel() +model = pyo.AbstractModel() # Sets -model.NumMonths = Param(within=NonNegativeIntegers) -model.EngineType = Set() -model.Month = RangeSet(1, model.NumMonths) +model.NumMonths = pyo.Param(within=pyo.NonNegativeIntegers) +model.EngineType = pyo.Set() +model.Month = pyo.RangeSet(1, model.NumMonths) # Parameters -model.Demand = Param(model.EngineType, model.Month, within=NonNegativeIntegers) -model.InvCost = Param(within=NonNegativeReals) -model.InitInv = Param(model.EngineType, within=NonNegativeIntegers) -model.FinInv = Param(model.EngineType, within=NonNegativeIntegers) -model.Labor = Param(model.EngineType, within=NonNegativeReals) -model.LaborBound = Param(within=NonNegativeReals) -model.ProdCost = Param(model.EngineType, within=NonNegativeReals) -model.ProdBound = Param(within=NonNegativeIntegers) +model.Demand = pyo.Param(model.EngineType, model.Month, within=pyo.NonNegativeIntegers) +model.InvCost = pyo.Param(within=pyo.NonNegativeReals) +model.InitInv = pyo.Param(model.EngineType, within=pyo.NonNegativeIntegers) +model.FinInv = pyo.Param(model.EngineType, within=pyo.NonNegativeIntegers) +model.Labor = pyo.Param(model.EngineType, within=pyo.NonNegativeReals) +model.LaborBound = pyo.Param(within=pyo.NonNegativeReals) +model.ProdCost = pyo.Param(model.EngineType, within=pyo.NonNegativeReals) +model.ProdBound = pyo.Param(within=pyo.NonNegativeIntegers) # Variables -model.Produce = Var(model.EngineType, model.Month, within=NonNegativeIntegers) -model.Inventory = Var(model.EngineType, model.Month, within=NonNegativeIntegers) +model.Produce = pyo.Var(model.EngineType, model.Month, within=pyo.NonNegativeIntegers) +model.Inventory = pyo.Var(model.EngineType, model.Month, within=pyo.NonNegativeIntegers) # Objective @@ -46,7 +45,7 @@ def CalcCost(M): ) + sum(M.Inventory[e, t] * M.InvCost for e in M.EngineType for t in M.Month) -model.TotalCost = Objective(rule=CalcCost, sense=minimize) +model.TotalCost = pyo.Objective(rule=CalcCost, sense=pyo.minimize) # Constraints @@ -60,21 +59,23 @@ def EnsureBalance(M, e, t): return M.Inventory[e, t] == M.InitInv[e] + M.Produce[e, t] - M.Demand[e, t] -model.InventoryBalance = Constraint(model.EngineType, model.Month, rule=EnsureBalance) +model.InventoryBalance = pyo.Constraint( + model.EngineType, model.Month, rule=EnsureBalance +) def EnsureLaborLimit(M, t): return sum(M.Produce[e, t] * M.Labor[e] for e in M.EngineType) <= M.LaborBound -model.LimitLabor = Constraint(model.Month, rule=EnsureLaborLimit) +model.LimitLabor = pyo.Constraint(model.Month, rule=EnsureLaborLimit) def EnsureProdLimit(M, t): return sum(M.Produce[e, t] for e in M.EngineType) <= M.ProdBound -model.ProdLimit = Constraint(model.Month, rule=EnsureProdLimit) +model.ProdLimit = pyo.Constraint(model.Month, rule=EnsureProdLimit) def LeaveEnough(M, e, t): @@ -82,4 +83,4 @@ def LeaveEnough(M, e, t): return M.Inventory[e, t] >= M.FinInv[e] -model.FinalInventory = Constraint(model.EngineType, model.Month, rule=LeaveEnough) +model.FinalInventory = pyo.Constraint(model.EngineType, model.Month, rule=LeaveEnough) diff --git a/examples/pyomo/radertext/Ex2_6a.py b/examples/pyomo/radertext/Ex2_6a.py index 24bb866ec51..92c59d37968 100644 --- a/examples/pyomo/radertext/Ex2_6a.py +++ b/examples/pyomo/radertext/Ex2_6a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,25 +13,24 @@ # Example 2.6a - Allen Holder # -from pyomo.core import * -from pyomo.opt import * +import pyomo.environ as pyo # Instantiate the model -model = AbstractModel() +model = pyo.AbstractModel() # Sets and Set Parameters -model.NumSensors = Param(within=NonNegativeIntegers) -model.Sensor = RangeSet(1, model.NumSensors) +model.NumSensors = pyo.Param(within=pyo.NonNegativeIntegers) +model.Sensor = pyo.RangeSet(1, model.NumSensors) # Parameters -model.xPos = Param(model.Sensor, within=NonNegativeIntegers) -model.yPos = Param(model.Sensor, within=NonNegativeIntegers) +model.xPos = pyo.Param(model.Sensor, within=pyo.NonNegativeIntegers) +model.yPos = pyo.Param(model.Sensor, within=pyo.NonNegativeIntegers) # Variables -model.xCentralSensor = Var(within=NonNegativeIntegers) -model.yCentralSensor = Var(within=NonNegativeIntegers) -model.xMax = Var(model.Sensor, within=NonNegativeReals) -model.yMax = Var(model.Sensor, within=NonNegativeReals) +model.xCentralSensor = pyo.Var(within=pyo.NonNegativeIntegers) +model.yCentralSensor = pyo.Var(within=pyo.NonNegativeIntegers) +model.xMax = pyo.Var(model.Sensor, within=pyo.NonNegativeReals) +model.yMax = pyo.Var(model.Sensor, within=pyo.NonNegativeReals) # Objective @@ -39,7 +38,7 @@ def CalcDist(M): return sum(M.xMax[s] + M.yMax[s] for s in M.Sensor) -model.Dist = Objective(rule=CalcDist, sense=minimize) +model.Dist = pyo.Objective(rule=CalcDist, sense=pyo.minimize) # Constraints @@ -48,25 +47,25 @@ def xEnsureUp(s, M): return M.xCentralSensor - M.xPos[s] <= M.xMax[s] -model.xUpBound = Constraint(model.Sensor, rule=xEnsureUp) +model.xUpBound = pyo.Constraint(model.Sensor, rule=xEnsureUp) def xEnsureLow(s, M): return M.xCentralSensor - M.xPos[s] >= -M.xMax[s] -model.xLowBound = Constraint(model.Sensor, rule=xEnsureLow) +model.xLowBound = pyo.Constraint(model.Sensor, rule=xEnsureLow) def yEnsureUp(s, M): return M.yCentralSensor - M.yPos[s] <= M.yMax[s] -model.yUpBound = Constraint(model.Sensor, rule=yEnsureUp) +model.yUpBound = pyo.Constraint(model.Sensor, rule=yEnsureUp) def yEnsureLow(s, M): return M.yCentralSensor - M.yPos[s] >= -M.yMax[s] -model.yLowBound = Constraint(model.Sensor, rule=yEnsureLow) +model.yLowBound = pyo.Constraint(model.Sensor, rule=yEnsureLow) diff --git a/examples/pyomo/radertext/Ex2_6b.py b/examples/pyomo/radertext/Ex2_6b.py index 1be55461b9e..5e7a046c887 100644 --- a/examples/pyomo/radertext/Ex2_6b.py +++ b/examples/pyomo/radertext/Ex2_6b.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,26 +13,25 @@ # Example 2.6b - Allen Holder # -from pyomo.core import * -from pyomo.opt import * +import pyomo.environ as pyo # Instantiate the model -model = AbstractModel() +model = pyo.AbstractModel() # Sets and Set Parameters -model.NumSensors = Param(within=NonNegativeIntegers) -model.Sensor = RangeSet(1, model.NumSensors) +model.NumSensors = pyo.Param(within=pyo.NonNegativeIntegers) +model.Sensor = pyo.RangeSet(1, model.NumSensors) # Parameters -model.xPos = Param(model.Sensor, within=NonNegativeIntegers) -model.yPos = Param(model.Sensor, within=NonNegativeIntegers) +model.xPos = pyo.Param(model.Sensor, within=pyo.NonNegativeIntegers) +model.yPos = pyo.Param(model.Sensor, within=pyo.NonNegativeIntegers) # Variables -model.xCentralSensor = Var(within=NonNegativeIntegers) -model.yCentralSensor = Var(within=NonNegativeIntegers) -model.xMax = Var(model.Sensor, within=NonNegativeReals) -model.yMax = Var(model.Sensor, within=NonNegativeReals) -model.Max = Var(within=NonNegativeReals) +model.xCentralSensor = pyo.Var(within=pyo.NonNegativeIntegers) +model.yCentralSensor = pyo.Var(within=pyo.NonNegativeIntegers) +model.xMax = pyo.Var(model.Sensor, within=pyo.NonNegativeReals) +model.yMax = pyo.Var(model.Sensor, within=pyo.NonNegativeReals) +model.Max = pyo.Var(within=pyo.NonNegativeReals) # Objective @@ -40,7 +39,7 @@ def CalcDist(M): return M.Max -model.Dist = Objective(rule=CalcDist, sense=minimize) +model.Dist = pyo.Objective(rule=CalcDist, sense=pyo.minimize) # Constraints @@ -49,32 +48,32 @@ def xEnsureUp(M, s): return M.xCentralSensor - M.xPos[s] <= M.xMax[s] -model.xUpBound = Constraint(model.Sensor, rule=xEnsureUp) +model.xUpBound = pyo.Constraint(model.Sensor, rule=xEnsureUp) def xEnsureLow(M, s): return M.xCentralSensor - M.xPos[s] >= -M.xMax[s] -model.xLowBound = Constraint(model.Sensor, rule=xEnsureLow) +model.xLowBound = pyo.Constraint(model.Sensor, rule=xEnsureLow) def yEnsureUp(M, s): return M.yCentralSensor - M.yPos[s] <= M.yMax[s] -model.yUpBound = Constraint(model.Sensor, rule=yEnsureUp) +model.yUpBound = pyo.Constraint(model.Sensor, rule=yEnsureUp) def yEnsureLow(M, s): return M.yCentralSensor - M.yPos[s] >= -M.yMax[s] -model.yLowBound = Constraint(model.Sensor, rule=yEnsureLow) +model.yLowBound = pyo.Constraint(model.Sensor, rule=yEnsureLow) def EnsureSensorBound(M, s): return M.xMax[s] + M.yMax[s] <= M.Max -model.MaxDist = Constraint(model.Sensor, rule=EnsureSensorBound) +model.MaxDist = pyo.Constraint(model.Sensor, rule=EnsureSensorBound) diff --git a/examples/pyomo/sos/DepotSiting.py b/examples/pyomo/sos/DepotSiting.py index 40826e989b7..6c3eed91d86 100644 --- a/examples/pyomo/sos/DepotSiting.py +++ b/examples/pyomo/sos/DepotSiting.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,33 +11,35 @@ # a simple SOS example from "Modeling Building in Mathematical Programming", H. Paul Williams, 4th Edition, p. 166. -from pyomo.core import * +import pyomo.environ as pyo -model = AbstractModel() +model = pyo.AbstractModel() # the set of customers. -model.Customers = Set() +model.Customers = pyo.Set() # the possible locations of the facility that you're trying to place. -model.Sites = Set() +model.Sites = pyo.Set() # the cost of satisfying customer demand from each of the potential sites. -model.SatisfactionCost = Param(model.Customers, model.Sites, within=NonNegativeReals) +model.SatisfactionCost = pyo.Param( + model.Customers, model.Sites, within=pyo.NonNegativeReals +) # indicators of which sites are selected. constraints ensure only one site is selected, # and allow the binary integrality to be implicit. -model.SiteSelected = Var(model.Sites, bounds=(0, 1)) +model.SiteSelected = pyo.Var(model.Sites, bounds=(0, 1)) # ensure that only one of the site selected variables is non-zero. -model.SiteSelectedSOS = SOSConstraint(var=model.SiteSelected, sos=1) +model.SiteSelectedSOS = pyo.SOSConstraint(var=model.SiteSelected, sos=1) # ensure that one of the sites is selected (enforce binary). def enforce_site_selected_binary_rule(model): - return sum_product(model.SiteSelected) == 1 + return pyo.sum_product(model.SiteSelected) == 1 -model.EnforceSiteSelectedBinary = Constraint(rule=enforce_site_selected_binary_rule) +model.EnforceSiteSelectedBinary = pyo.Constraint(rule=enforce_site_selected_binary_rule) # the objective is to minimize the cost to satisfy all customers. @@ -51,4 +53,4 @@ def minimize_cost_rule(model): ) -model.MinimizeCost = Objective(rule=minimize_cost_rule, sense=minimize) +model.MinimizeCost = pyo.Objective(rule=minimize_cost_rule, sense=pyo.minimize) diff --git a/examples/pyomo/sos/basic_sos2_example.py b/examples/pyomo/sos/basic_sos2_example.py index 3aa0887356c..0620dfd7416 100644 --- a/examples/pyomo/sos/basic_sos2_example.py +++ b/examples/pyomo/sos/basic_sos2_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core import * +import pyomo.environ as pyo # Problem borrowed from the lpsolve project documentation # http://lpsolve.sourceforge.net/5.0/SOS.htm @@ -60,28 +60,28 @@ def constraint_rule(model, c): ############################################################################### # Cruft above complete, now we actually create and tie the model together -model = AbstractModel() +model = pyo.AbstractModel() M = model -M.variable_set = RangeSet(1, 5) -M.constraint_set = RangeSet(1, 2) +M.variable_set = pyo.RangeSet(1, 5) +M.constraint_set = pyo.RangeSet(1, 2) -M.c = Param(M.variable_set, initialize=c_param_init) +M.c = pyo.Param(M.variable_set, initialize=c_param_init) -M.A = Param(M.constraint_set, M.variable_set, initialize=A_param_init) -M.b = Param(M.constraint_set, initialize=b_param_init) +M.A = pyo.Param(M.constraint_set, M.variable_set, initialize=A_param_init) +M.b = pyo.Param(M.constraint_set, initialize=b_param_init) -M.x = Var(M.variable_set, within=PositiveReals) +M.x = pyo.Var(M.variable_set, within=pyo.PositiveReals) -M.obj = Objective(rule=obj_rule, sense=minimize) # min "c transpose" X +M.obj = pyo.Objective(rule=obj_rule, sense=pyo.minimize) # min "c transpose" X # At first, this is little more than a standard form Ax=b ... -M.constraints = Constraint(M.constraint_set, rule=constraint_rule) +M.constraints = pyo.Constraint(M.constraint_set, rule=constraint_rule) # ... with a couple of extra constraints ... -M.x1_constraint = Constraint(rule=x1_constraint_rule) -M.x2_constraint = Constraint(rule=x2_constraint_rule) -M.x5_constraint = Constraint(rule=x5_constraint_rule) +M.x1_constraint = pyo.Constraint(rule=x1_constraint_rule) +M.x2_constraint = pyo.Constraint(rule=x2_constraint_rule) +M.x5_constraint = pyo.Constraint(rule=x5_constraint_rule) # ... and finally, add the constraint for which this example was created -M.x_sos_vars = SOSConstraint(var=M.x, sos=2) +M.x_sos_vars = pyo.SOSConstraint(var=M.x, sos=2) diff --git a/examples/pyomo/sos/sos2_piecewise.py b/examples/pyomo/sos/sos2_piecewise.py index 79195761f3d..bbcaccab6eb 100644 --- a/examples/pyomo/sos/sos2_piecewise.py +++ b/examples/pyomo/sos/sos2_piecewise.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,19 +10,19 @@ # ___________________________________________________________________________ """ -This example shows how to represent a piecewise function using +This example shows how to represent a piecewise function using Pyomo's built SOSConstraint component. The function is defined as: / 3x-2 , 1 <= x <= 2 -f(x) = | +f(x) = | \ 5x-6 , 2 <= x <= 3 """ -from pyomo.core import * +import pyomo.environ as pyo -model = ConcreteModel() +model = pyo.ConcreteModel() -model.index_set = Set(initialize=[1, 2]) +model.index_set = pyo.Set(initialize=[1, 2]) DOMAIN_PTS = {1: [1, 2, 3], 2: [1, 2, 3]} F = {1: [1, 4, 9], 2: [1, 4, 9]} # Note we can also implement this like below @@ -36,7 +36,7 @@ def SOS_indices_init(model, t): return [(t, i) for i in range(len(DOMAIN_PTS[t]))] -model.SOS_indices = Set( +model.SOS_indices = pyo.Set( model.index_set, dimen=2, ordered=True, initialize=SOS_indices_init ) @@ -45,13 +45,13 @@ def sos_var_indices_init(model): return [(t, i) for t in model.index_set for i in range(len(DOMAIN_PTS[t]))] -model.sos_var_indices = Set(ordered=True, dimen=2, initialize=sos_var_indices_init) +model.sos_var_indices = pyo.Set(ordered=True, dimen=2, initialize=sos_var_indices_init) -model.x = Var(model.index_set) # domain variable -model.Fx = Var(model.index_set) # range variable -model.y = Var(model.sos_var_indices, within=NonNegativeReals) # SOS2 variable +model.x = pyo.Var(model.index_set) # domain variable +model.Fx = pyo.Var(model.index_set) # range variable +model.y = pyo.Var(model.sos_var_indices, within=pyo.NonNegativeReals) # SOS2 variable -model.obj = Objective(expr=sum_product(model.Fx), sense=maximize) +model.obj = pyo.Objective(expr=pyo.sum_product(model.Fx), sense=pyo.maximize) def constraint1_rule(model, t): @@ -73,13 +73,13 @@ def constraint3_rule(model, t): return sum(model.y[t, j] for j in range(len(DOMAIN_PTS[t]))) == 1 -model.constraint1 = Constraint(model.index_set, rule=constraint1_rule) -model.constraint2 = Constraint(model.index_set, rule=constraint2_rule) -model.constraint3 = Constraint(model.index_set, rule=constraint3_rule) -model.SOS_set_constraint = SOSConstraint( +model.constraint1 = pyo.Constraint(model.index_set, rule=constraint1_rule) +model.constraint2 = pyo.Constraint(model.index_set, rule=constraint2_rule) +model.constraint3 = pyo.Constraint(model.index_set, rule=constraint3_rule) +model.SOS_set_constraint = pyo.SOSConstraint( model.index_set, var=model.y, index=model.SOS_indices, sos=2 ) # Fix the answer for testing purposes -model.set_answer_constraint1 = Constraint(expr=model.x[1] == 2.5) -model.set_answer_constraint2 = Constraint(expr=model.x[2] == 2.0) +model.set_answer_constraint1 = pyo.Constraint(expr=model.x[1] == 2.5) +model.set_answer_constraint2 = pyo.Constraint(expr=model.x[2] == 2.0) diff --git a/examples/pyomo/suffixes/duals_pyomo.py b/examples/pyomo/suffixes/duals_pyomo.py index 6ce88fde429..41ec6e78ea0 100644 --- a/examples/pyomo/suffixes/duals_pyomo.py +++ b/examples/pyomo/suffixes/duals_pyomo.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,17 +11,17 @@ # A Suffix example for the collection of duals. -from pyomo.core import * +import pyomo.environ as pyo ### Create a trivial and infeasible example model -model = ConcreteModel() -model.x = Var(within=NonNegativeReals) -model.obj = Objective(expr=model.x) -model.con = Constraint(expr=model.x >= 1) +model = pyo.ConcreteModel() +model.x = pyo.Var(within=pyo.NonNegativeReals) +model.obj = pyo.Objective(expr=model.x) +model.con = pyo.Constraint(expr=model.x >= 1) ### # Declare an IMPORT Suffix to store the dual information that will -# be returned by the solver. When Suffix components are declared +# be returned by the solver. When pyo.Suffix components are declared # with an IMPORT direction, Pyomo solver interfaces will attempt to collect # this named information from a solver solution. -model.dual = Suffix(direction=Suffix.IMPORT) +model.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) diff --git a/examples/pyomo/suffixes/duals_script.py b/examples/pyomo/suffixes/duals_script.py index e8ef9aef1bc..754eb85ba4e 100644 --- a/examples/pyomo/suffixes/duals_script.py +++ b/examples/pyomo/suffixes/duals_script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,16 +22,14 @@ # on this system. This example was tested using Gurobi # Solver 5.1.0 -import pyomo.environ -from pyomo.core import * -from pyomo.opt import SolverFactory +import pyomo.environ as pyo ### Create the a solver plugin solver = 'gurobi' solver_io = 'lp' # Uses the LP file interface stream_solver = False # True prints solver output to screen keepfiles = False # True prints intermediate file names (.nl,.sol,...) -opt = SolverFactory(solver, solver_io=solver_io) +opt = pyo.SolverFactory(solver, solver_io=solver_io) if opt is None: print("") diff --git a/examples/pyomo/suffixes/gurobi_ampl_basis.py b/examples/pyomo/suffixes/gurobi_ampl_basis.py index eab86f8aa47..d323f49c448 100644 --- a/examples/pyomo/suffixes/gurobi_ampl_basis.py +++ b/examples/pyomo/suffixes/gurobi_ampl_basis.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,7 +22,7 @@ # solver is in the current search path for executables # on this system. This example was tested using # gurobi_ampl version 6.5.0. -from pyomo.environ import * +import pyomo.environ as pyo # @@ -32,7 +32,7 @@ solver_io = 'nl' stream_solver = True # True prints solver output to screen keepfiles = False # True prints intermediate file names (.nl,.sol,...) -opt = SolverFactory(solver, solver_io=solver_io) +opt = pyo.SolverFactory(solver, solver_io=solver_io) if opt is None: print("") print( @@ -61,11 +61,11 @@ # # Create a trivial example model # -model = ConcreteModel() -model.s = Set(initialize=[1, 2, 3]) -model.x = Var(model.s, within=NonNegativeReals) -model.obj = Objective(expr=sum_product(model.x)) -model.con = Constraint(model.s, rule=lambda model, i: model.x[i] >= i - 1) +model = pyo.ConcreteModel() +model.s = pyo.Set(initialize=[1, 2, 3]) +model.x = pyo.Var(model.s, within=pyo.NonNegativeReals) +model.obj = pyo.Objective(expr=pyo.sum_product(model.x)) +model.con = pyo.Constraint(model.s, rule=lambda model, i: model.x[i] >= i - 1) ### # @@ -81,8 +81,8 @@ # - 5: nonbasic at equal lower and upper bounds # - 6: nonbasic between bounds -model.sstatus = Suffix(direction=Suffix.IMPORT_EXPORT, datatype=Suffix.INT) -model.dual = Suffix(direction=Suffix.IMPORT_EXPORT) +model.sstatus = pyo.Suffix(direction=pyo.Suffix.IMPORT_EXPORT, datatype=pyo.Suffix.INT) +model.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT_EXPORT) # diff --git a/examples/pyomo/suffixes/gurobi_ampl_example.py b/examples/pyomo/suffixes/gurobi_ampl_example.py index 4f3364c09dc..5c1e9748cc6 100644 --- a/examples/pyomo/suffixes/gurobi_ampl_example.py +++ b/examples/pyomo/suffixes/gurobi_ampl_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,16 +21,14 @@ # on this system. This example was tested using Gurobi # Solver 5.0.0 -import pyomo.environ -from pyomo.core import * -from pyomo.opt import SolverFactory +import pyomo.environ as pyo ### Create the gurobi_ampl solver plugin using the ASL interface solver = 'gurobi_ampl' solver_io = 'nl' stream_solver = False # True prints solver output to screen keepfiles = False # True prints intermediate file names (.nl,.sol,...) -opt = SolverFactory(solver, solver_io=solver_io) +opt = pyo.SolverFactory(solver, solver_io=solver_io) if opt is None: print("") @@ -45,11 +43,11 @@ ### ### Create a trivial example model -model = ConcreteModel() -model.s = Set(initialize=[1, 2, 3]) -model.x = Var(model.s, within=NonNegativeReals) -model.obj = Objective(expr=sum_product(model.x)) -model.con = Constraint(model.s, rule=lambda model, i: model.x[i] >= i - 1) +model = pyo.ConcreteModel() +model.s = pyo.Set(initialize=[1, 2, 3]) +model.x = pyo.Var(model.s, within=pyo.NonNegativeReals) +model.obj = pyo.Objective(expr=pyo.sum_product(model.x)) +model.con = pyo.Constraint(model.s, rule=lambda model, i: model.x[i] >= i - 1) ### ### Declare all suffixes @@ -63,20 +61,30 @@ 'equ': 5, # nonbasic at equal lower and upper bounds 'btw': 6, } # nonbasic between bounds -model.sstatus = Suffix(direction=Suffix.IMPORT_EXPORT, datatype=Suffix.INT) -model.dual = Suffix(direction=Suffix.IMPORT_EXPORT) +model.sstatus = pyo.Suffix(direction=pyo.Suffix.IMPORT_EXPORT, datatype=pyo.Suffix.INT) +model.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT_EXPORT) # Report the best known bound on the objective function -model.bestbound = Suffix(direction=Suffix.IMPORT) +model.bestbound = pyo.Suffix(direction=pyo.Suffix.IMPORT) # A few Gurobi variable solution sensitivity suffixes -model.senslblo = Suffix(direction=Suffix.IMPORT) # smallest variable lower bound -model.senslbhi = Suffix(direction=Suffix.IMPORT) # greatest variable lower bound -model.sensublo = Suffix(direction=Suffix.IMPORT) # smallest variable upper bound -model.sensubhi = Suffix(direction=Suffix.IMPORT) # greatest variable upper bound +model.senslblo = pyo.Suffix( + direction=pyo.Suffix.IMPORT +) # smallest variable lower bound +model.senslbhi = pyo.Suffix( + direction=pyo.Suffix.IMPORT +) # greatest variable lower bound +model.sensublo = pyo.Suffix( + direction=pyo.Suffix.IMPORT +) # smallest variable upper bound +model.sensubhi = pyo.Suffix( + direction=pyo.Suffix.IMPORT +) # greatest variable upper bound # A Gurobi constraint solution sensitivity suffix -model.sensrhshi = Suffix(direction=Suffix.IMPORT) # greatest right-hand side value +model.sensrhshi = pyo.Suffix( + direction=pyo.Suffix.IMPORT +) # greatest right-hand side value ### # Tell gurobi_ampl to report solution sensitivities @@ -91,21 +99,21 @@ def print_model_suffixes(model): # print all suffix values for all model components in a nice table print("\t", end='') - for name, suffix in active_import_suffix_generator(model): + for name, suffix in pyo.active_import_suffix_generator(model): print("%10s" % (name), end='') print("") for i in model.s: print(model.x[i].name + "\t", end='') - for name, suffix in active_import_suffix_generator(model): + for name, suffix in pyo.active_import_suffix_generator(model): print("%10s" % (suffix.get(model.x[i])), end='') print("") for i in model.s: print(model.con[i].name + "\t", end='') - for name, suffix in active_import_suffix_generator(model): + for name, suffix in pyo.active_import_suffix_generator(model): print("%10s" % (suffix.get(model.con[i])), end='') print("") print(model.obj.name + "\t", end='') - for name, suffix in active_import_suffix_generator(model): + for name, suffix in pyo.active_import_suffix_generator(model): print("%10s" % (suffix.get(model.obj)), end='') print("") print("") diff --git a/examples/pyomo/suffixes/gurobi_ampl_iis.py b/examples/pyomo/suffixes/gurobi_ampl_iis.py index da5bad073e7..46d6e8304aa 100644 --- a/examples/pyomo/suffixes/gurobi_ampl_iis.py +++ b/examples/pyomo/suffixes/gurobi_ampl_iis.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,16 +21,14 @@ # on this system. This example was tested using Gurobi # Solver 5.0.0 -import pyomo.environ -from pyomo.core import * -from pyomo.opt import SolverFactory +import pyomo.environ as pyo ### Create the gurobi_ampl solver plugin using the ASL interface solver = 'gurobi_ampl' solver_io = 'nl' stream_solver = False # True prints solver output to screen keepfiles = False # True prints intermediate file names (.nl,.sol,...) -opt = SolverFactory(solver, solver_io=solver_io) +opt = pyo.SolverFactory(solver, solver_io=solver_io) if opt is None: print("") @@ -48,15 +46,15 @@ opt.options['iisfind'] = 1 # tell gurobi to be verbose with output ### Create a trivial and infeasible example model -model = ConcreteModel() -model.x = Var(within=NonNegativeReals) -model.obj = Objective(expr=model.x) -model.con = Constraint(expr=model.x <= -1) +model = pyo.ConcreteModel() +model.x = pyo.Var(within=pyo.NonNegativeReals) +model.obj = pyo.Objective(expr=model.x) +model.con = pyo.Constraint(expr=model.x <= -1) ### # Create an IMPORT Suffix to store the iis information that will # be returned by gurobi_ampl -model.iis = Suffix(direction=Suffix.IMPORT) +model.iis = pyo.Suffix(direction=pyo.Suffix.IMPORT) ### Send the model to gurobi_ampl and collect the solution # The solver plugin will scan the model for all active suffixes diff --git a/examples/pyomo/suffixes/ipopt_scaling.py b/examples/pyomo/suffixes/ipopt_scaling.py index 7113128c21d..7685c287ad4 100644 --- a/examples/pyomo/suffixes/ipopt_scaling.py +++ b/examples/pyomo/suffixes/ipopt_scaling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,16 +19,14 @@ # on this system. This example was tested using Ipopt # version 3.10.2 -import pyomo.environ -from pyomo.core import * -from pyomo.opt import SolverFactory +import pyomo.environ as pyo ### Create the ipopt solver plugin using the ASL interface solver = 'ipopt' solver_io = 'nl' stream_solver = False # True prints solver output to screen keepfiles = False # True prints intermediate file names (.nl,.sol,...) -opt = SolverFactory(solver, solver_io=solver_io) +opt = pyo.SolverFactory(solver, solver_io=solver_io) if opt is None: print("") @@ -45,23 +43,23 @@ ### ### Create the example model -model = ConcreteModel() -model.s = Set(initialize=[1, 2, 3]) -model.y = Var(bounds=(1, 5), initialize=1.0) -model.x = Var(model.s, bounds=(1, 5), initialize=5.0) -model.obj = Objective( +model = pyo.ConcreteModel() +model.s = pyo.Set(initialize=[1, 2, 3]) +model.y = pyo.Var(bounds=(1, 5), initialize=1.0) +model.x = pyo.Var(model.s, bounds=(1, 5), initialize=5.0) +model.obj = pyo.Objective( expr=model.y * model.x[3] * (model.y + model.x[1] + model.x[2]) + model.x[2] ) -model.inequality = Constraint( +model.inequality = pyo.Constraint( expr=model.y * model.x[1] * model.x[2] * model.x[3] >= 25.0 ) -model.equality = Constraint( +model.equality = pyo.Constraint( expr=model.y**2 + model.x[1] ** 2 + model.x[2] ** 2 + model.x[3] ** 2 == 40.0 ) ### ### Declare the scaling_factor suffix -model.scaling_factor = Suffix(direction=Suffix.EXPORT) +model.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) # set objective scaling factor model.scaling_factor[model.obj] = 4.23 # set variable scaling factor diff --git a/examples/pyomo/suffixes/ipopt_warmstart.py b/examples/pyomo/suffixes/ipopt_warmstart.py index 4882c48c8c8..a8ade973004 100644 --- a/examples/pyomo/suffixes/ipopt_warmstart.py +++ b/examples/pyomo/suffixes/ipopt_warmstart.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,16 +23,14 @@ # on this system. This example was tested using Ipopt # version 3.10.2 -import pyomo.environ -from pyomo.core import * -from pyomo.opt import SolverFactory +import pyomo.environ as pyo ### Create the ipopt solver plugin using the ASL interface solver = 'ipopt' solver_io = 'nl' stream_solver = False # True prints solver output to screen keepfiles = False # True prints intermediate file names (.nl,.sol,...) -opt = SolverFactory(solver, solver_io=solver_io) +opt = pyo.SolverFactory(solver, solver_io=solver_io) if opt is None: print("") @@ -45,29 +43,31 @@ ### ### Create the example model -model = ConcreteModel() -model.x1 = Var(bounds=(1, 5), initialize=1.0) -model.x2 = Var(bounds=(1, 5), initialize=5.0) -model.x3 = Var(bounds=(1, 5), initialize=5.0) -model.x4 = Var(bounds=(1, 5), initialize=1.0) -model.obj = Objective( +model = pyo.ConcreteModel() +model.x1 = pyo.Var(bounds=(1, 5), initialize=1.0) +model.x2 = pyo.Var(bounds=(1, 5), initialize=5.0) +model.x3 = pyo.Var(bounds=(1, 5), initialize=5.0) +model.x4 = pyo.Var(bounds=(1, 5), initialize=1.0) +model.obj = pyo.Objective( expr=model.x1 * model.x4 * (model.x1 + model.x2 + model.x3) + model.x3 ) -model.inequality = Constraint(expr=model.x1 * model.x2 * model.x3 * model.x4 >= 25.0) -model.equality = Constraint( +model.inequality = pyo.Constraint( + expr=model.x1 * model.x2 * model.x3 * model.x4 >= 25.0 +) +model.equality = pyo.Constraint( expr=model.x1**2 + model.x2**2 + model.x3**2 + model.x4**2 == 40.0 ) ### ### Declare all suffixes # Ipopt bound multipliers (obtained from solution) -model.ipopt_zL_out = Suffix(direction=Suffix.IMPORT) -model.ipopt_zU_out = Suffix(direction=Suffix.IMPORT) +model.ipopt_zL_out = pyo.Suffix(direction=pyo.Suffix.IMPORT) +model.ipopt_zU_out = pyo.Suffix(direction=pyo.Suffix.IMPORT) # Ipopt bound multipliers (sent to solver) -model.ipopt_zL_in = Suffix(direction=Suffix.EXPORT) -model.ipopt_zU_in = Suffix(direction=Suffix.EXPORT) +model.ipopt_zL_in = pyo.Suffix(direction=pyo.Suffix.EXPORT) +model.ipopt_zU_in = pyo.Suffix(direction=pyo.Suffix.EXPORT) # Obtain dual solutions from first solve and send to warm start -model.dual = Suffix(direction=Suffix.IMPORT_EXPORT) +model.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT_EXPORT) ### ### Send the model to ipopt and collect the solution @@ -80,7 +80,8 @@ print(" %7s %12s %12s" % ("Value", "ipopt_zL_out", "ipopt_zU_out")) for v in [model.x1, model.x2, model.x3, model.x4]: print( - "%s %7g %12g %12g" % (v, value(v), model.ipopt_zL_out[v], model.ipopt_zU_out[v]) + "%s %7g %12g %12g" + % (v, pyo.value(v), model.ipopt_zL_out[v], model.ipopt_zU_out[v]) ) print("inequality.dual = " + str(model.dual[model.inequality])) print("equality.dual = " + str(model.dual[model.equality])) @@ -112,7 +113,8 @@ print(" %7s %12s %12s" % ("Value", "ipopt_zL_out", "ipopt_zU_out")) for v in [model.x1, model.x2, model.x3, model.x4]: print( - "%s %7g %12g %12g" % (v, value(v), model.ipopt_zL_out[v], model.ipopt_zU_out[v]) + "%s %7g %12g %12g" + % (v, pyo.value(v), model.ipopt_zL_out[v], model.ipopt_zU_out[v]) ) print("inequality.dual = " + str(model.dual[model.inequality])) print("equality.dual = " + str(model.dual[model.equality])) diff --git a/examples/pyomo/suffixes/sipopt_hicks.py b/examples/pyomo/suffixes/sipopt_hicks.py index c7e058d5907..bb543bbf2ca 100644 --- a/examples/pyomo/suffixes/sipopt_hicks.py +++ b/examples/pyomo/suffixes/sipopt_hicks.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,16 +23,14 @@ # are the numpy and matplotlib python modules (needed for # viewing results). -import pyomo.environ -from pyomo.core import * -from pyomo.opt import SolverFactory +import pyomo.environ as pyo ### Create the ipopt_sens solver plugin using the ASL interface solver = 'ipopt_sens' solver_io = 'nl' stream_solver = False # True prints solver output to screen keepfiles = False # True prints intermediate file names (.nl,.sol,...) -opt = SolverFactory(solver, solver_io=solver_io) +opt = pyo.SolverFactory(solver, solver_io=solver_io) ### if opt is None: @@ -69,26 +67,26 @@ yc = tc / (jj * cf) yf = tf / (jj * cf) -model = ConcreteModel() -model.c_init_var = Var() -model.t_init_var = Var() -model.cdot = Var(FE, CP) -model.tdot = Var(FE, CP) +model = pyo.ConcreteModel() +model.c_init_var = pyo.Var() +model.t_init_var = pyo.Var() +model.cdot = pyo.Var(FE, CP) +model.tdot = pyo.Var(FE, CP) def c_init_rule(m, i, j): return float(i) / nfe * (c_des - c_init) + c_init -model.c = Var(FE, CP, within=NonNegativeReals, initialize=c_init_rule) +model.c = pyo.Var(FE, CP, within=pyo.NonNegativeReals, initialize=c_init_rule) def t_init_rule(m, i, j): return float(i) / nfe * (t_des - t_init) + t_init -model.t = Var(FE, CP, within=NonNegativeReals, initialize=t_init_rule) -model.u = Var(FE, within=NonNegativeReals, initialize=1.0) +model.t = pyo.Var(FE, CP, within=pyo.NonNegativeReals, initialize=t_init_rule) +model.u = pyo.Var(FE, within=pyo.NonNegativeReals, initialize=1.0) a_init = {} a_init[0, 0] = 0.19681547722366 @@ -101,7 +99,7 @@ def t_init_rule(m, i, j): a_init[2, 1] = -0.04154875212600 a_init[2, 2] = 0.11111111111111 -model.a = Param(FE, CP, initialize=a_init) +model.a = pyo.Param(FE, CP, initialize=a_init) h = [1.0 / nfe] * nfe @@ -109,20 +107,20 @@ def t_init_rule(m, i, j): def cdot_ode_rule(m, i, j): return ( m.cdot[i, j] - == (1.0 - m.c[i, j]) / theta - k10 * exp(-n / m.t[i, j]) * m.c[i, j] + == (1.0 - m.c[i, j]) / theta - k10 * pyo.exp(-n / m.t[i, j]) * m.c[i, j] ) -model.cdot_ode = Constraint(FE, CP, rule=cdot_ode_rule) +model.cdot_ode = pyo.Constraint(FE, CP, rule=cdot_ode_rule) def tdot_ode_rule(m, i, j): - return m.tdot[i, j] == (yf - m.t[i, j]) / theta + k10 * exp(-n / m.t[i, j]) * m.c[ - i, j - ] - alpha * m.u[i] * (m.t[i, j] - yc) + return m.tdot[i, j] == (yf - m.t[i, j]) / theta + k10 * pyo.exp( + -n / m.t[i, j] + ) * m.c[i, j] - alpha * m.u[i] * (m.t[i, j] - yc) -model.tdot_ode = Constraint(FE, CP, rule=tdot_ode_rule) +model.tdot_ode = pyo.Constraint(FE, CP, rule=tdot_ode_rule) def fecolc_rule(m, i, j): @@ -136,9 +134,9 @@ def fecolc_rule(m, i, j): ) -model.fecolc = Constraint(FE, CP, rule=fecolc_rule) -model.c_init_def = Constraint(expr=model.c_init_var == c_init) -model.t_init_def = Constraint(expr=model.t_init_var == t_init) +model.fecolc = pyo.Constraint(FE, CP, rule=fecolc_rule) +model.c_init_def = pyo.Constraint(expr=model.c_init_var == c_init) +model.t_init_def = pyo.Constraint(expr=model.t_init_var == t_init) def fecolt_rule(m, i, j): @@ -152,7 +150,7 @@ def fecolt_rule(m, i, j): ) -model.fecolt = Constraint(FE, CP, rule=fecolt_rule) +model.fecolt = pyo.Constraint(FE, CP, rule=fecolt_rule) def obj_rule(m): @@ -189,15 +187,15 @@ def obj_rule(m): ) -model.cost = Objective(rule=obj_rule) +model.cost = pyo.Objective(rule=obj_rule) ### ### declare suffixes -model.sens_state_0 = Suffix(direction=Suffix.EXPORT) -model.sens_state_1 = Suffix(direction=Suffix.EXPORT) -model.sens_state_value_1 = Suffix(direction=Suffix.EXPORT) -model.sens_sol_state_1 = Suffix(direction=Suffix.IMPORT) -model.sens_init_constr = Suffix(direction=Suffix.EXPORT) +model.sens_state_0 = pyo.Suffix(direction=pyo.Suffix.EXPORT) +model.sens_state_1 = pyo.Suffix(direction=pyo.Suffix.EXPORT) +model.sens_state_value_1 = pyo.Suffix(direction=pyo.Suffix.EXPORT) +model.sens_sol_state_1 = pyo.Suffix(direction=pyo.Suffix.IMPORT) +model.sens_init_constr = pyo.Suffix(direction=pyo.Suffix.EXPORT) ### ### set sIPOPT data @@ -252,10 +250,10 @@ def collocation_idx(n_fe, n_cp): tnominal = np.zeros((nfe * ncp, 1)) tperturbed = np.zeros((nfe * ncp, 1)) for k, (i, j) in enumerate(collocation_idx(nfe, ncp)): - cnominal[k] = value(model.c[i, j]) - tnominal[k] = value(model.t[i, j]) - cperturbed[k] = value(model.sens_sol_state_1[model.c[i, j]]) - tperturbed[k] = value(model.sens_sol_state_1[model.t[i, j]]) + cnominal[k] = pyo.value(model.c[i, j]) + tnominal[k] = pyo.value(model.t[i, j]) + cperturbed[k] = pyo.value(model.sens_sol_state_1[model.c[i, j]]) + tperturbed[k] = pyo.value(model.sens_sol_state_1[model.t[i, j]]) plt.subplot(2, 1, 1) plt.plot(times, cnominal, label='c_nominal') diff --git a/examples/pyomo/suffixes/sipopt_parametric.py b/examples/pyomo/suffixes/sipopt_parametric.py index 0cb1c35f441..c51486011ba 100644 --- a/examples/pyomo/suffixes/sipopt_parametric.py +++ b/examples/pyomo/suffixes/sipopt_parametric.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,16 +21,14 @@ # solver (distributed with Ipopt) is in the current search # path for executables on this system. -import pyomo.environ -from pyomo.core import * -from pyomo.opt import SolverFactory +import pyomo.environ as pyo ### Create the ipopt_sens solver plugin using the ASL interface solver = 'ipopt_sens' solver_io = 'nl' stream_solver = True # True prints solver output to screen keepfiles = False # True prints intermediate file names (.nl,.sol,...) -opt = SolverFactory(solver, solver_io=solver_io) +opt = pyo.SolverFactory(solver, solver_io=solver_io) ### if opt is None: @@ -46,30 +44,30 @@ perturbed_eta2 = 1.0 ### Create the model -model = ConcreteModel() +model = pyo.ConcreteModel() # variables -model.x1 = Var(initialize=0.15, within=NonNegativeReals) -model.x2 = Var(initialize=0.15, within=NonNegativeReals) -model.x3 = Var(initialize=0.0, within=NonNegativeReals) +model.x1 = pyo.Var(initialize=0.15, within=pyo.NonNegativeReals) +model.x2 = pyo.Var(initialize=0.15, within=pyo.NonNegativeReals) +model.x3 = pyo.Var(initialize=0.0, within=pyo.NonNegativeReals) # parameters -model.eta1 = Var() -model.eta2 = Var() +model.eta1 = pyo.Var() +model.eta2 = pyo.Var() # constraints + objective -model.const1 = Constraint( +model.const1 = pyo.Constraint( expr=6 * model.x1 + 3 * model.x2 + 2 * model.x3 - model.eta1 == 0 ) -model.const2 = Constraint(expr=model.eta2 * model.x1 + model.x2 - model.x3 - 1 == 0) -model.cost = Objective(expr=model.x1**2 + model.x2**2 + model.x3**2) -model.consteta1 = Constraint(expr=model.eta1 == nominal_eta1) -model.consteta2 = Constraint(expr=model.eta2 == nominal_eta2) +model.const2 = pyo.Constraint(expr=model.eta2 * model.x1 + model.x2 - model.x3 - 1 == 0) +model.cost = pyo.Objective(expr=model.x1**2 + model.x2**2 + model.x3**2) +model.consteta1 = pyo.Constraint(expr=model.eta1 == nominal_eta1) +model.consteta2 = pyo.Constraint(expr=model.eta2 == nominal_eta2) ### ### declare suffixes -model.sens_state_0 = Suffix(direction=Suffix.EXPORT) -model.sens_state_1 = Suffix(direction=Suffix.EXPORT) -model.sens_state_value_1 = Suffix(direction=Suffix.EXPORT) -model.sens_sol_state_1 = Suffix(direction=Suffix.IMPORT) -model.sens_init_constr = Suffix(direction=Suffix.EXPORT) +model.sens_state_0 = pyo.Suffix(direction=pyo.Suffix.EXPORT) +model.sens_state_1 = pyo.Suffix(direction=pyo.Suffix.EXPORT) +model.sens_state_value_1 = pyo.Suffix(direction=pyo.Suffix.EXPORT) +model.sens_sol_state_1 = pyo.Suffix(direction=pyo.Suffix.IMPORT) +model.sens_init_constr = pyo.Suffix(direction=pyo.Suffix.EXPORT) ### ### set sIPOPT data @@ -91,5 +89,5 @@ ### Print Solution print("Nominal and perturbed solution:") for v in [model.x1, model.x2, model.x3, model.eta1, model.eta2]: - print("%5s %14g %14g" % (v, value(v), model.sens_sol_state_1[v])) + print("%5s %14g %14g" % (v, pyo.value(v), model.sens_sol_state_1[v])) ### diff --git a/examples/pyomo/transform/scaling_ex.py b/examples/pyomo/transform/scaling_ex.py index 34f937cbb45..8ef768de988 100644 --- a/examples/pyomo/transform/scaling_ex.py +++ b/examples/pyomo/transform/scaling_ex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,19 +9,19 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.environ as pe +import pyomo.environ as pyo ### # create the original unscaled model ### -model = pe.ConcreteModel() -model.x = pe.Var([1, 2, 3], bounds=(-10, 10), initialize=5.0) -model.z = pe.Var(bounds=(10, 20)) -model.obj = pe.Objective(expr=model.z + model.x[1]) +model = pyo.ConcreteModel() +model.x = pyo.Var([1, 2, 3], bounds=(-10, 10), initialize=5.0) +model.z = pyo.Var(bounds=(10, 20)) +model.obj = pyo.Objective(expr=model.z + model.x[1]) # demonstrate scaling of duals as well -model.dual = pe.Suffix(direction=pe.Suffix.IMPORT) -model.rc = pe.Suffix(direction=pe.Suffix.IMPORT) +model.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) +model.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT) def con_rule(m, i): @@ -33,13 +33,13 @@ def con_rule(m, i): return m.x[1] + 3.0 * m.x[2] + 1 * m.x[3] == 5.0 -model.con = pe.Constraint([1, 2, 3], rule=con_rule) -model.zcon = pe.Constraint(expr=model.z >= model.x[2]) +model.con = pyo.Constraint([1, 2, 3], rule=con_rule) +model.zcon = pyo.Constraint(expr=model.z >= model.x[2]) ### # set the scaling parameters ### -model.scaling_factor = pe.Suffix(direction=pe.Suffix.EXPORT) +model.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) model.scaling_factor[model.obj] = 2.0 model.scaling_factor[model.x] = 0.5 model.scaling_factor[model.z] = -10.0 @@ -51,14 +51,14 @@ def con_rule(m, i): ### # build and solve the scaled model ### -scaled_model = pe.TransformationFactory('core.scale_model').create_using(model) -pe.SolverFactory('glpk').solve(scaled_model) +scaled_model = pyo.TransformationFactory('core.scale_model').create_using(model) +pyo.SolverFactory('glpk').solve(scaled_model) ### # propagate the solution back to the original model ### -pe.TransformationFactory('core.scale_model').propagate_solution(scaled_model, model) +pyo.TransformationFactory('core.scale_model').propagate_solution(scaled_model, model) # print the scaled model scaled_model.pprint() @@ -73,12 +73,12 @@ def con_rule(m, i): # solve the original (unscaled) model original_model = model.clone() - pe.SolverFactory('glpk').solve(original_model) + pyo.SolverFactory('glpk').solve(original_model) # create and solve the scaled model - scaling_tx = pe.TransformationFactory('core.scale_model') + scaling_tx = pyo.TransformationFactory('core.scale_model') scaled_model = scaling_tx.create_using(model) - pe.SolverFactory('glpk').solve(scaled_model) + pyo.SolverFactory('glpk').solve(scaled_model) # propagate the solution from the scaled model back to a clone of the original model backmapped_unscaled_model = model.clone() @@ -88,8 +88,8 @@ def con_rule(m, i): print('\n\n') print('%s\t%12s %18s' % ('Var', 'Orig.', 'Scaled -> Backmapped')) print('=====================================================') - for v in original_model.component_data_objects(ctype=pe.Var, descend_into=True): - cuid = pe.ComponentUID(v) + for v in original_model.component_data_objects(ctype=pyo.Var, descend_into=True): + cuid = pyo.ComponentUID(v) bv = cuid.find_component_on(backmapped_unscaled_model) - print('%s\t%.16f\t%.16f' % (v.local_name, pe.value(v), pe.value(bv))) + print('%s\t%.16f\t%.16f' % (v.local_name, pyo.value(v), pyo.value(bv))) print('=====================================================') diff --git a/examples/pyomo/tutorials/data.py b/examples/pyomo/tutorials/data.py index ea2569af934..7db01fa9f19 100644 --- a/examples/pyomo/tutorials/data.py +++ b/examples/pyomo/tutorials/data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # # Imports # -from pyomo.environ import * +import pyomo.environ as pyo ## ## Using a Model @@ -23,7 +23,7 @@ # declaration of model components (e.g. sets and variables), and to # generate a problem instance. # -model = AbstractModel() +model = pyo.AbstractModel() ## ## Declaring Sets @@ -31,11 +31,11 @@ # # An unordered set of arbitrary objects # -model.A = Set() +model.A = pyo.Set() # # An unordered set of numeric values # -model.B = Set() +model.B = pyo.Set() # # A simple cross-product # @@ -43,36 +43,36 @@ # # A simple cross-product loaded with a tabular data format # -model.D = Set(within=model.A * model.B) +model.D = pyo.Set(within=model.A * model.B) # # A multiple cross-product # -model.E = Set(within=model.A * model.B * model.A) +model.E = pyo.Set(within=model.A * model.B * model.A) # # An indexed set # -model.F = Set(model.A) +model.F = pyo.Set(model.A) # # An indexed set # -model.G = Set(model.A, model.B) +model.G = pyo.Set(model.A, model.B) # # A simple set # -model.H = Set() +model.H = pyo.Set() # # A simple set # -model.I = Set() +model.I = pyo.Set() # # A two-dimensional set # -model.J = Set(dimen=2) +model.J = pyo.Set(dimen=2) # # A two-dimensional set # -model.K = Set(dimen=2) +model.K = pyo.Set(dimen=2) ## ## Declaring Params @@ -81,49 +81,49 @@ # # A simple parameter # -model.Z = Param() -model.ZZ = Param() +model.Z = pyo.Param() +model.ZZ = pyo.Param() # # A single-dimension parameter # -model.Y = Param(model.A) +model.Y = pyo.Param(model.A) # # An example of initializing two single-dimension parameters together # -model.X = Param(model.A) -model.W = Param(model.A) +model.X = pyo.Param(model.A) +model.W = pyo.Param(model.A) # # Initializing a parameter with two indices # -model.U = Param(model.I, model.A) -model.T = Param(model.A, model.I) +model.U = pyo.Param(model.I, model.A) +model.T = pyo.Param(model.A, model.I) # # Initializing a parameter with missing data # -model.S = Param(model.A) +model.S = pyo.Param(model.A) # # An example of initializing two single-dimension parameters together with # an index set # -model.R = Param(model.H, within=Reals) -model.Q = Param(model.H, within=Reals) +model.R = pyo.Param(model.H, within=pyo.Reals) +model.Q = pyo.Param(model.H, within=pyo.Reals) # # An example of initializing parameters with a two-dimensional index set # -model.P = Param(model.J, within=Reals) -model.PP = Param(model.J, within=Reals) -model.O = Param(model.J, within=Reals) +model.P = pyo.Param(model.J, within=pyo.Reals) +model.PP = pyo.Param(model.J, within=pyo.Reals) +model.O = pyo.Param(model.J, within=pyo.Reals) -model.z = Set(dimen=2) -model.y = Set() -model.x = Set() +model.z = pyo.Set(dimen=2) +model.y = pyo.Set() +model.x = pyo.Set() -model.M = Param(model.K, within=Reals) -model.N = Param(model.y, within=Reals) +model.M = pyo.Param(model.K, within=pyo.Reals) +model.N = pyo.Param(model.y, within=pyo.Reals) -model.MM = Param(model.z) -model.MMM = Param(model.z) -model.NNN = Param(model.x) +model.MM = pyo.Param(model.z) +model.MMM = pyo.Param(model.z) +model.NNN = pyo.Param(model.x) ## ## Process an input file and confirm that we get appropriate diff --git a/examples/pyomo/tutorials/excel.py b/examples/pyomo/tutorials/excel.py index f9a5f66826b..22b9a5360ee 100644 --- a/examples/pyomo/tutorials/excel.py +++ b/examples/pyomo/tutorials/excel.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # # Imports # -from pyomo.environ import * +import pyomo.environ as pyo ## ## Using a Model @@ -23,7 +23,7 @@ # declaration of model components (e.g. sets and variables), and to # generate a problem instance. # -model = AbstractModel() +model = pyo.AbstractModel() ## ## Declaring Sets @@ -31,11 +31,11 @@ # # An unordered set of arbitrary objects # -model.A = Set() +model.A = pyo.Set() # # An unordered set of numeric values # -model.B = Set() +model.B = pyo.Set() # # A simple cross-product # @@ -43,32 +43,32 @@ # # A simple cross-product loaded with a tabular data format # -model.D = Set(within=model.A * model.B) +model.D = pyo.Set(within=model.A * model.B) # # A multiple cross-product # -model.E = Set(within=model.A * model.B * model.A) +model.E = pyo.Set(within=model.A * model.B * model.A) # # An indexed set # -model.F = Set(model.A) +model.F = pyo.Set(model.A) # # An indexed set # -model.G = Set(model.A, model.B) +model.G = pyo.Set(model.A, model.B) # # A simple set # -model.H = Set() +model.H = pyo.Set() # # A simple set # -model.I = Set() +model.I = pyo.Set() # # A two-dimensional set # -model.J = Set(dimen=2) +model.J = pyo.Set(dimen=2) ## ## Declaring Params @@ -77,37 +77,37 @@ # # A simple parameter # -model.Z = Param() +model.Z = pyo.Param() # # A single-dimension parameter # -model.Y = Param(model.A) +model.Y = pyo.Param(model.A) # # An example of initializing two single-dimension parameters together # -model.X = Param(model.A) -model.W = Param(model.A) +model.X = pyo.Param(model.A) +model.W = pyo.Param(model.A) # # Initializing a parameter with two indices # -model.U = Param(model.I, model.A) -model.T = Param(model.A, model.I) +model.U = pyo.Param(model.I, model.A) +model.T = pyo.Param(model.A, model.I) # # Initializing a parameter with missing data # -model.S = Param(model.A) +model.S = pyo.Param(model.A) # # An example of initializing two single-dimension parameters together with # an index set # -model.R = Param(model.H, within=Reals) -model.Q = Param(model.H, within=Reals) +model.R = pyo.Param(model.H, within=pyo.Reals) +model.Q = pyo.Param(model.H, within=pyo.Reals) # # An example of initializing parameters with a two-dimensional index set # -model.P = Param(model.J, within=Reals) -model.PP = Param(model.J, within=Reals) -model.O = Param(model.J, within=Reals) +model.P = pyo.Param(model.J, within=pyo.Reals) +model.PP = pyo.Param(model.J, within=pyo.Reals) +model.O = pyo.Param(model.J, within=pyo.Reals) ## ## Process an input file and confirm that we get appropriate @@ -115,7 +115,7 @@ ## # model.pprint() -data = DataPortal(model=model) +data = pyo.DataPortal(model=model) data.load(filename="excel.xls", range="Atable", format='set', set='A') data.load(filename="excel.xls", range="Btable", format='set', set='B') data.load(filename="excel.xls", range="Ctable", format='set', set='C') diff --git a/examples/pyomo/tutorials/param.py b/examples/pyomo/tutorials/param.py index 5a94bafaa5e..375943d25c3 100644 --- a/examples/pyomo/tutorials/param.py +++ b/examples/pyomo/tutorials/param.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # # Imports # -from pyomo.environ import * +import pyomo.environ as pyo ## ## Setting up a Model @@ -20,12 +20,12 @@ # # Create the model # -model = AbstractModel() +model = pyo.AbstractModel() # # Create sets used to define parameters # -model.A = Set() -model.B = Set() +model.A = pyo.Set() +model.B = pyo.Set() ## ## Declaring Params @@ -34,15 +34,15 @@ # # A simple parameter # -model.Z = Param() +model.Z = pyo.Param() # # A single-dimension parameter # -model.Y = Param(model.A) +model.Y = pyo.Param(model.A) # # Initializing a parameter with two indices # -model.X = Param(model.A, model.B) +model.X = pyo.Param(model.A, model.B) ## @@ -60,7 +60,7 @@ def W_init(model, i, j): return i * j -model.W = Param(model.A, model.B, initialize=W_init) +model.W = pyo.Param(model.A, model.B, initialize=W_init) # # Note that the parameter model.W is not created when this object is # constructed. Instead, W_init() is called during the construction of a @@ -74,19 +74,19 @@ def W_init(model, i, j): V_init[1] = 1 V_init[2] = 2 V_init[3] = 9 -model.V = Param(model.B, initialize=V_init) +model.V = pyo.Param(model.B, initialize=V_init) # # Note that parameter V is initialized with a dictionary, which maps # tuples from parameter indices to parameter values. Simple, unindexed # parameters can be initialized with a scalar value. # -model.U = Param(initialize=9.9) +model.U = pyo.Param(initialize=9.9) # # Validation of parameter data is supported in two different ways. First, # the domain of feasible parameter values can be specified with the _within_ # option: # -model.T = Param(within=model.B) +model.T = pyo.Param(within=model.B) # @@ -100,7 +100,7 @@ def S_validate(model, value): return value in model.A -model.S = Param(validate=S_validate) +model.S = pyo.Param(validate=S_validate) ## ## Default Values @@ -120,7 +120,7 @@ def S_validate(model, value): R_init[2, 1] = 1 R_init[2, 2] = 1 R_init[2, 3] = 1 -model.R = Param(model.A, model.B, default=99.0, initialize=R_init) +model.R = pyo.Param(model.A, model.B, default=99.0, initialize=R_init) # # Note that the parameter default value can also be specified in an input # file. See data.dat for an example. @@ -128,8 +128,8 @@ def S_validate(model, value): # Note that the explicit specification of a zero default changes Pyomo # behavior. For example, consider: # -# model.a = Param(model.A, default=0.0) -# model.b = Param(model.A) +# model.a = pyo.Param(model.A, default=0.0) +# model.b = pyo.Param(model.A) # # When model.a[x] is accessed and the index has not been explicitly initialized, # the value zero is returned. This is true whether or not the parameter has diff --git a/examples/pyomo/tutorials/set.dat b/examples/pyomo/tutorials/set.dat index ab0d00b43cc..16ad7ff9698 100644 --- a/examples/pyomo/tutorials/set.dat +++ b/examples/pyomo/tutorials/set.dat @@ -16,3 +16,10 @@ set S[5] := 2 3; set T[2] := 1 3; set T[5] := 2 3; + +set T_indexed_validate[2] := 1; +set T_indexed_validate[3] := 1 2; +set T_indexed_validate[4] := 1 2 3; + +set X[2] := 1; +set X[5] := 2 3; diff --git a/examples/pyomo/tutorials/set.out b/examples/pyomo/tutorials/set.out index 818977f6155..3f278a2f9b2 100644 --- a/examples/pyomo/tutorials/set.out +++ b/examples/pyomo/tutorials/set.out @@ -1,4 +1,4 @@ -23 Set Declarations +25 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} @@ -80,6 +80,11 @@ Key : Dimen : Domain : Size : Members 2 : 1 : Any : 2 : {1, 3} 5 : 1 : Any : 2 : {2, 3} + T_indexed_validate : Size=3, Index=B, Ordered=Insertion + Key : Dimen : Domain : Size : Members + 2 : 1 : Any : 1 : {1,} + 3 : 1 : Any : 2 : {1, 2} + 4 : 1 : Any : 3 : {1, 2, 3} U : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 5 : {1, 2, 6, 24, 120} @@ -89,5 +94,9 @@ 2 : 1 : Any : 5 : {1, 3, 5, 7, 9} 3 : 1 : Any : 5 : {1, 4, 7, 10, 13} 4 : 1 : Any : 5 : {1, 5, 9, 13, 17} + X : Size=2, Index=B, Ordered=Insertion + Key : Dimen : Domain : Size : Members + 2 : 1 : S[2] : 1 : {1,} + 5 : 1 : S[5] : 2 : {2, 3} -23 Declarations: A B C D E F G H Hsub I J K K_2 L M N O P R S T U V +25 Declarations: A B C D E F G H Hsub I J K K_2 L M N O P R S X T T_indexed_validate U V diff --git a/examples/pyomo/tutorials/set.py b/examples/pyomo/tutorials/set.py index a14301484c9..e6d380cc23d 100644 --- a/examples/pyomo/tutorials/set.py +++ b/examples/pyomo/tutorials/set.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,12 +12,12 @@ # # Imports # -from pyomo.environ import * +import pyomo.environ as pyo ## ## Creating a model ## -model = AbstractModel() +model = pyo.AbstractModel() ## ## Declaring Sets @@ -26,13 +26,13 @@ # An unordered set of arbitrary objects can be defined by creating a Set() # object: # -model.A = Set() +model.A = pyo.Set() # # An index set of sets can also be specified by providing sets as options -# to the Set() object: +# to the pyo.Set() object: # -model.B = Set() -model.C = Set(model.A, model.B) +model.B = pyo.Set() +model.C = pyo.Set(model.A, model.B) # # Set declarations can also use standard set operations to declare # a set in a constructive fashion: @@ -49,7 +49,7 @@ # Note that this is different from the following, which specifies that Hsub # is a subset of this cross-product. # -model.Hsub = Set(within=model.A * model.B) +model.Hsub = pyo.Set(within=model.A * model.B) ## @@ -68,7 +68,7 @@ def I_init(model): return ans -model.I = Set(within=model.A * model.B, initialize=I_init) +model.I = pyo.Set(within=model.A * model.B, initialize=I_init) # # Note that the set model.I is not created when this set object is # constructed. Instead, I_init() is called during the construction of a @@ -76,7 +76,7 @@ def I_init(model): # # A set can also be explicitly constructed by adding set elements: # -model.J = Set() +model.J = pyo.Set() model.J.construct() model.J.add(1, 4, 9) # @@ -84,13 +84,13 @@ def I_init(model): # a set. These default values may be overridden by later construction # steps, or by data in an input file: # -model.K = Set(initialize=[1, 4, 9]) -model.K_2 = Set(initialize=[(1, 4), (9, 16)], dimen=2) +model.K = pyo.Set(initialize=[1, 4, 9]) +model.K_2 = pyo.Set(initialize=[(1, 4), (9, 16)], dimen=2) # # Validation of set data is supported in two different ways. First, a # superset can be specified with the _within_ option: # -model.L = Set(within=model.A) +model.L = pyo.Set(within=model.A) # @@ -101,12 +101,12 @@ def M_validate(model, value): return value in model.A -model.M = Set(validate=M_validate) +model.M = pyo.Set(validate=M_validate) # # Although the _within_ option is convenient, it can force the creation of # a temporary set. For example, consider the declaration # -model.N = Set(within=model.A * model.B) +model.N = pyo.Set(within=model.A * model.B) # @@ -119,7 +119,7 @@ def O_validate(model, value): return value[0] in model.A and value[1] in model.B -model.O = Set(validate=O_validate) +model.O = pyo.Set(validate=O_validate) ## @@ -134,18 +134,18 @@ def P_init(model, i, j): return range(0, i * j) -model.P = Set(model.B, model.B, initialize=P_init) +model.P = pyo.Set(model.B, model.B, initialize=P_init) # # A set array CANNOT be explicitly constructed by adding set elements # to individual arrays. For example, the following is invalid: # -# model.Q = Set(model.B) +# model.Q = pyo.Set(model.B) # model.Q[2].add(4) # model.Q[4].add(16) # # The reason is that the line # -# model.Q = Set(model.B) +# model.Q = pyo.Set(model.B) # # declares set Q with an abstract index set B. However, B is not initialized # until the 'model.create_instance()' call is executed at the end of this file. We @@ -164,12 +164,19 @@ def P_init(model, i, j): R_init[2] = [1, 3, 5] R_init[3] = [2, 4, 6] R_init[4] = [3, 5, 7] -model.R = Set(model.B, initialize=R_init) +model.R = pyo.Set(model.B, initialize=R_init) # # Validation of a set array is supported with the _within_ option. The # elements of all sets in the array must be in this set: # -model.S = Set(model.B, within=model.A) +model.S = pyo.Set(model.B, within=model.A) + +# +# Validation of a set array can also be linked to another set array. If so, the +# elements under each index must also be found under the corresponding index in +# the validation set array: +# +model.X = pyo.Set(model.B, within=model.S) # @@ -180,7 +187,17 @@ def T_validate(model, value): return value in model.A -model.T = Set(model.B, validate=M_validate) +model.T = pyo.Set(model.B, validate=T_validate) + + +# +# Validation also provides the index within the IndexedSet being validated: +# +def T_indexed_validate(model, value, i): + return value in model.A and value < i + + +model.T_indexed_validate = pyo.Set(model.B, validate=T_indexed_validate) ## @@ -198,14 +215,14 @@ def T_validate(model, value): # def U_init(model, z): if z == 6: - return Set.End + return pyo.Set.End if z == 1: return 1 else: return model.U[z - 1] * z -model.U = Set(ordered=True, initialize=U_init) +model.U = pyo.Set(ordered=True, initialize=U_init) # @@ -217,13 +234,13 @@ def U_init(model, z): # def V_init(model, z, i): if z == 6: - return Set.End + return pyo.Set.End if i == 1: return z return model.V[i - 1][z] + z - 1 -model.V = Set(RangeSet(1, 4), initialize=V_init, ordered=True) +model.V = pyo.Set(pyo.RangeSet(1, 4), initialize=V_init, ordered=True) ## ## Process an input file and confirm that we get appropriate diff --git a/examples/pyomo/tutorials/table.py b/examples/pyomo/tutorials/table.py index 7d9fceda14a..75802fa98f5 100644 --- a/examples/pyomo/tutorials/table.py +++ b/examples/pyomo/tutorials/table.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # # Imports # -from pyomo.environ import * +import pyomo.environ as pyo ## ## Using a Model @@ -23,7 +23,7 @@ # declaration of model components (e.g. sets and variables), and to # generate a problem instance. # -model = AbstractModel() +model = pyo.AbstractModel() ## ## Declaring Sets @@ -31,11 +31,11 @@ # # An unordered set of arbitrary objects # -model.A = Set() +model.A = pyo.Set() # # An unordered set of numeric values # -model.B = Set() +model.B = pyo.Set() # # A simple cross-product # @@ -43,32 +43,32 @@ # # A simple cross-product loaded with a tabular data format # -model.D = Set(within=model.A * model.B) +model.D = pyo.Set(within=model.A * model.B) # # A multiple cross-product # -model.E = Set(within=model.A * model.B * model.A) +model.E = pyo.Set(within=model.A * model.B * model.A) # # An indexed set # -model.F = Set(model.A) +model.F = pyo.Set(model.A) # # An indexed set # -model.G = Set(model.A, model.B) +model.G = pyo.Set(model.A, model.B) # # A simple set # -model.H = Set() +model.H = pyo.Set() # # A simple set # -model.I = Set() +model.I = pyo.Set() # # A two-dimensional set # -model.J = Set(dimen=2) +model.J = pyo.Set(dimen=2) ## ## Declaring Params @@ -77,43 +77,43 @@ # # A simple parameter # -model.Z = Param() +model.Z = pyo.Param() # # A single-dimension parameter # -model.Y = Param(model.A) +model.Y = pyo.Param(model.A) # # An example of initializing two single-dimension parameters together # -model.X = Param(model.A) -model.W = Param(model.A) +model.X = pyo.Param(model.A) +model.W = pyo.Param(model.A) # # Initializing a parameter with two indices # -model.U = Param(model.I, model.A) -model.T = Param(model.A, model.I) +model.U = pyo.Param(model.I, model.A) +model.T = pyo.Param(model.A, model.I) # # Initializing a parameter with missing data # -model.S = Param(model.A) +model.S = pyo.Param(model.A) # # An example of initializing two single-dimension parameters together with # an index set # -model.R = Param(model.H, within=Reals) -model.Q = Param(model.H, within=Reals) +model.R = pyo.Param(model.H, within=pyo.Reals) +model.Q = pyo.Param(model.H, within=pyo.Reals) # # An example of initializing parameters with a two-dimensional index set # -model.P = Param(model.J, within=Reals) -model.PP = Param(model.J, within=Reals) -model.O = Param(model.J, within=Reals) +model.P = pyo.Param(model.J, within=pyo.Reals) +model.PP = pyo.Param(model.J, within=pyo.Reals) +model.O = pyo.Param(model.J, within=pyo.Reals) ## ## Process an input file and confirm that we get appropriate ## set instances. ## -data = DataPortal() +data = pyo.DataPortal() data.load(filename="tab/A.tab", format='set', set='A') data.load(filename="tab/B.tab", format='set', set='B') data.load(filename="tab/C.tab", format='set', set="C") diff --git a/examples/pyomobook/__init__.py b/examples/pyomobook/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/examples/pyomobook/__init__.py +++ b/examples/pyomobook/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/AbstHLinScript.py b/examples/pyomobook/abstract-ch/AbstHLinScript.py index 687d3fc4e6b..118f77e811c 100644 --- a/examples/pyomobook/abstract-ch/AbstHLinScript.py +++ b/examples/pyomobook/abstract-ch/AbstHLinScript.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/AbstractH.py b/examples/pyomobook/abstract-ch/AbstractH.py index 7595cbc4933..69f308fecfd 100644 --- a/examples/pyomobook/abstract-ch/AbstractH.py +++ b/examples/pyomobook/abstract-ch/AbstractH.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/AbstractHLinear.py b/examples/pyomobook/abstract-ch/AbstractHLinear.py index f312020a9d5..67eecb24b7a 100644 --- a/examples/pyomobook/abstract-ch/AbstractHLinear.py +++ b/examples/pyomobook/abstract-ch/AbstractHLinear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/abstract5.py b/examples/pyomobook/abstract-ch/abstract5.py index 8849d2dfe7f..6f558dabf00 100644 --- a/examples/pyomobook/abstract-ch/abstract5.py +++ b/examples/pyomobook/abstract-ch/abstract5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/abstract6.py b/examples/pyomobook/abstract-ch/abstract6.py index 121b12a51fa..9c8d401049c 100644 --- a/examples/pyomobook/abstract-ch/abstract6.py +++ b/examples/pyomobook/abstract-ch/abstract6.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/abstract7.py b/examples/pyomobook/abstract-ch/abstract7.py index 3e8131bf42b..fabf13b56ec 100644 --- a/examples/pyomobook/abstract-ch/abstract7.py +++ b/examples/pyomobook/abstract-ch/abstract7.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/buildactions.py b/examples/pyomobook/abstract-ch/buildactions.py index 6963f285c4c..54b3db441e9 100644 --- a/examples/pyomobook/abstract-ch/buildactions.py +++ b/examples/pyomobook/abstract-ch/buildactions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/concrete1.py b/examples/pyomobook/abstract-ch/concrete1.py index 2c89fbafaad..91a2a2ac52f 100644 --- a/examples/pyomobook/abstract-ch/concrete1.py +++ b/examples/pyomobook/abstract-ch/concrete1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/concrete2.py b/examples/pyomobook/abstract-ch/concrete2.py index f68c4d6e242..ff001e165a7 100644 --- a/examples/pyomobook/abstract-ch/concrete2.py +++ b/examples/pyomobook/abstract-ch/concrete2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/diet1.py b/examples/pyomobook/abstract-ch/diet1.py index fa8bf5f549f..84545133bca 100644 --- a/examples/pyomobook/abstract-ch/diet1.py +++ b/examples/pyomobook/abstract-ch/diet1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/ex.py b/examples/pyomobook/abstract-ch/ex.py index 83cfd445e01..852596e87c6 100644 --- a/examples/pyomobook/abstract-ch/ex.py +++ b/examples/pyomobook/abstract-ch/ex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param1.py b/examples/pyomobook/abstract-ch/param1.py index 3ff8b648661..4d3db689466 100644 --- a/examples/pyomobook/abstract-ch/param1.py +++ b/examples/pyomobook/abstract-ch/param1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param2.py b/examples/pyomobook/abstract-ch/param2.py index aca8fac0baf..ec1b9e32429 100644 --- a/examples/pyomobook/abstract-ch/param2.py +++ b/examples/pyomobook/abstract-ch/param2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param2a.py b/examples/pyomobook/abstract-ch/param2a.py index 6b6f77f2a8f..38bdd712ae1 100644 --- a/examples/pyomobook/abstract-ch/param2a.py +++ b/examples/pyomobook/abstract-ch/param2a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param3.py b/examples/pyomobook/abstract-ch/param3.py index 7545b47dadc..0ea722b2e0c 100644 --- a/examples/pyomobook/abstract-ch/param3.py +++ b/examples/pyomobook/abstract-ch/param3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param3a.py b/examples/pyomobook/abstract-ch/param3a.py index 4c52b6432fb..4025d33db9a 100644 --- a/examples/pyomobook/abstract-ch/param3a.py +++ b/examples/pyomobook/abstract-ch/param3a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param3b.py b/examples/pyomobook/abstract-ch/param3b.py index 786d6b58a16..cd51507b1d8 100644 --- a/examples/pyomobook/abstract-ch/param3b.py +++ b/examples/pyomobook/abstract-ch/param3b.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param3c.py b/examples/pyomobook/abstract-ch/param3c.py index 3f5da5f837e..862bb1e7a60 100644 --- a/examples/pyomobook/abstract-ch/param3c.py +++ b/examples/pyomobook/abstract-ch/param3c.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param4.py b/examples/pyomobook/abstract-ch/param4.py index c1926ddea74..6e94fbd96ee 100644 --- a/examples/pyomobook/abstract-ch/param4.py +++ b/examples/pyomobook/abstract-ch/param4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param5.py b/examples/pyomobook/abstract-ch/param5.py index 7e0020f70b7..f68c8c70fc7 100644 --- a/examples/pyomobook/abstract-ch/param5.py +++ b/examples/pyomobook/abstract-ch/param5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param5a.py b/examples/pyomobook/abstract-ch/param5a.py index efdd1855f3f..9de1c963747 100644 --- a/examples/pyomobook/abstract-ch/param5a.py +++ b/examples/pyomobook/abstract-ch/param5a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param6.py b/examples/pyomobook/abstract-ch/param6.py index f6d60f11e4b..dc882ca8ee9 100644 --- a/examples/pyomobook/abstract-ch/param6.py +++ b/examples/pyomobook/abstract-ch/param6.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param6a.py b/examples/pyomobook/abstract-ch/param6a.py index 280e942d01d..f5d7c6fae65 100644 --- a/examples/pyomobook/abstract-ch/param6a.py +++ b/examples/pyomobook/abstract-ch/param6a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param7a.py b/examples/pyomobook/abstract-ch/param7a.py index 21839bf3b64..68d01a4343d 100644 --- a/examples/pyomobook/abstract-ch/param7a.py +++ b/examples/pyomobook/abstract-ch/param7a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param7b.py b/examples/pyomobook/abstract-ch/param7b.py index a4d79b6dee9..ad7c27d5bdc 100644 --- a/examples/pyomobook/abstract-ch/param7b.py +++ b/examples/pyomobook/abstract-ch/param7b.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/param8a.py b/examples/pyomobook/abstract-ch/param8a.py index f00ed649c30..7df9a9960a7 100644 --- a/examples/pyomobook/abstract-ch/param8a.py +++ b/examples/pyomobook/abstract-ch/param8a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/postprocess_fn.py b/examples/pyomobook/abstract-ch/postprocess_fn.py index 2f2d114c216..ab7c99264ac 100644 --- a/examples/pyomobook/abstract-ch/postprocess_fn.py +++ b/examples/pyomobook/abstract-ch/postprocess_fn.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/set1.py b/examples/pyomobook/abstract-ch/set1.py index 5a23fe683e0..c76a29eb9cb 100644 --- a/examples/pyomobook/abstract-ch/set1.py +++ b/examples/pyomobook/abstract-ch/set1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/set2.py b/examples/pyomobook/abstract-ch/set2.py index 5ecc0914bee..a8b3fc316ce 100644 --- a/examples/pyomobook/abstract-ch/set2.py +++ b/examples/pyomobook/abstract-ch/set2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/set2a.py b/examples/pyomobook/abstract-ch/set2a.py index 7252ec0ad69..082fda43758 100644 --- a/examples/pyomobook/abstract-ch/set2a.py +++ b/examples/pyomobook/abstract-ch/set2a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/set3.py b/examples/pyomobook/abstract-ch/set3.py index f3e3efc33c7..fa8ca7cd363 100644 --- a/examples/pyomobook/abstract-ch/set3.py +++ b/examples/pyomobook/abstract-ch/set3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/set4.py b/examples/pyomobook/abstract-ch/set4.py index 0c29798b816..cc71b5b616a 100644 --- a/examples/pyomobook/abstract-ch/set4.py +++ b/examples/pyomobook/abstract-ch/set4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/set5.py b/examples/pyomobook/abstract-ch/set5.py index 781b956404e..6d6a6ed74e3 100644 --- a/examples/pyomobook/abstract-ch/set5.py +++ b/examples/pyomobook/abstract-ch/set5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/wl_abstract.py b/examples/pyomobook/abstract-ch/wl_abstract.py index 61eeed6b506..6b81070a239 100644 --- a/examples/pyomobook/abstract-ch/wl_abstract.py +++ b/examples/pyomobook/abstract-ch/wl_abstract.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/abstract-ch/wl_abstract_script.py b/examples/pyomobook/abstract-ch/wl_abstract_script.py index 7f0871350fc..f8aa546d2e8 100644 --- a/examples/pyomobook/abstract-ch/wl_abstract_script.py +++ b/examples/pyomobook/abstract-ch/wl_abstract_script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/blocks-ch/blocks_gen.py b/examples/pyomobook/blocks-ch/blocks_gen.py index 31a4462f7d6..11c3cbce547 100644 --- a/examples/pyomobook/blocks-ch/blocks_gen.py +++ b/examples/pyomobook/blocks-ch/blocks_gen.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/blocks-ch/blocks_intro.py b/examples/pyomobook/blocks-ch/blocks_intro.py index ba2bd9d3a97..6f19a7eaa8c 100644 --- a/examples/pyomobook/blocks-ch/blocks_intro.py +++ b/examples/pyomobook/blocks-ch/blocks_intro.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/blocks-ch/blocks_lotsizing.py b/examples/pyomobook/blocks-ch/blocks_lotsizing.py index 758ad964dc5..4e5791c4617 100644 --- a/examples/pyomobook/blocks-ch/blocks_lotsizing.py +++ b/examples/pyomobook/blocks-ch/blocks_lotsizing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/blocks-ch/lotsizing.py b/examples/pyomobook/blocks-ch/lotsizing.py index ece4d6b541c..f4c355654d1 100644 --- a/examples/pyomobook/blocks-ch/lotsizing.py +++ b/examples/pyomobook/blocks-ch/lotsizing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/blocks-ch/lotsizing_no_time.py b/examples/pyomobook/blocks-ch/lotsizing_no_time.py index 60e8ba44424..2c61aefb02b 100644 --- a/examples/pyomobook/blocks-ch/lotsizing_no_time.py +++ b/examples/pyomobook/blocks-ch/lotsizing_no_time.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/blocks-ch/lotsizing_uncertain.py b/examples/pyomobook/blocks-ch/lotsizing_uncertain.py index f72161db5c6..7bbfd1b24ac 100644 --- a/examples/pyomobook/blocks-ch/lotsizing_uncertain.py +++ b/examples/pyomobook/blocks-ch/lotsizing_uncertain.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/dae-ch/dae_tester_model.py b/examples/pyomobook/dae-ch/dae_tester_model.py index 396b8a53db1..2a47a2f7a55 100644 --- a/examples/pyomobook/dae-ch/dae_tester_model.py +++ b/examples/pyomobook/dae-ch/dae_tester_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/dae-ch/path_constraint.py b/examples/pyomobook/dae-ch/path_constraint.py index 5e252d1b99f..3e8ccfdf952 100644 --- a/examples/pyomobook/dae-ch/path_constraint.py +++ b/examples/pyomobook/dae-ch/path_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/dae-ch/plot_path_constraint.py b/examples/pyomobook/dae-ch/plot_path_constraint.py index be86f13cbc0..23b80ca9be2 100644 --- a/examples/pyomobook/dae-ch/plot_path_constraint.py +++ b/examples/pyomobook/dae-ch/plot_path_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/dae-ch/run_path_constraint.py b/examples/pyomobook/dae-ch/run_path_constraint.py index fc115f5649c..c8db1bb7bd6 100644 --- a/examples/pyomobook/dae-ch/run_path_constraint.py +++ b/examples/pyomobook/dae-ch/run_path_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,6 @@ # ___________________________________________________________________________ import pyomo.environ as pyo -from pyomo.dae import * from path_constraint import m # Discretize model using Orthogonal Collocation diff --git a/examples/pyomobook/dae-ch/run_path_constraint_tester.py b/examples/pyomobook/dae-ch/run_path_constraint_tester.py index 22d887e9b11..86a5bd08b74 100644 --- a/examples/pyomobook/dae-ch/run_path_constraint_tester.py +++ b/examples/pyomobook/dae-ch/run_path_constraint_tester.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/gdp-ch/gdp_uc.py b/examples/pyomobook/gdp-ch/gdp_uc.py index 6268bcce068..ed27802bf5b 100644 --- a/examples/pyomobook/gdp-ch/gdp_uc.py +++ b/examples/pyomobook/gdp-ch/gdp_uc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ # gdp_uc.py import pyomo.environ as pyo -from pyomo.gdp import * +from pyomo.gdp import Disjunct, Disjunction model = pyo.AbstractModel() @@ -116,3 +116,9 @@ def obj(m): @model.Constraint(model.GENERATORS) def nontrivial(m, g): return sum(m.Power[g, t] for t in m.TIME) >= len(m.TIME) / 2 * m.MinPower[g] + + +@model.ConstraintList() +def nondegenerate(m): + for i, g in enumerate(m.GENERATORS): + yield m.Power[g, i + 1] == 0 diff --git a/examples/pyomobook/gdp-ch/pyomo.gdp_uc.txt b/examples/pyomobook/gdp-ch/pyomo.gdp_uc.txt index 477336d48ba..f0b5a5c4795 100644 --- a/examples/pyomobook/gdp-ch/pyomo.gdp_uc.txt +++ b/examples/pyomobook/gdp-ch/pyomo.gdp_uc.txt @@ -22,9 +22,9 @@ Problem: Lower bound: 45.0 Upper bound: 45.0 Number of objectives: 1 - Number of constraints: 58 + Number of constraints: 60 Number of variables: 24 - Number of nonzeros: 124 + Number of nonzeros: 126 Sense: minimize # ---------------------------------------------------------- # Solver Information @@ -34,8 +34,8 @@ Solver: Termination condition: optimal Statistics: Branch and bound: - Number of bounded subproblems: 15 - Number of created subproblems: 15 + Number of bounded subproblems: 9 + Number of created subproblems: 9 Error rc: 0 Time: 0.007754325866699219 # ---------------------------------------------------------- @@ -51,24 +51,24 @@ Solution: obj: Value: 45 Variable: - GenOff[g1,2].binary_indicator_var: + GenOff[g1,1].binary_indicator_var: Value: 1 - GenOff[g2,1].binary_indicator_var: + GenOff[g2,2].binary_indicator_var: Value: 1 - GenOn[g1,1].binary_indicator_var: + GenOn[g1,3].binary_indicator_var: Value: 1 - GenOn[g2,3].binary_indicator_var: + GenOn[g2,1].binary_indicator_var: Value: 1 - GenStartup[g1,3].binary_indicator_var: + GenStartup[g1,2].binary_indicator_var: Value: 1 - GenStartup[g2,2].binary_indicator_var: + GenStartup[g2,3].binary_indicator_var: Value: 1 - Power[g1,1]: - Value: 10 - Power[g1,3]: + Power[g1,2]: Value: 5 - Power[g2,2]: + Power[g1,3]: Value: 10 - Power[g2,3]: + Power[g2,1]: Value: 20 + Power[g2,3]: + Value: 10 Constraint: No values diff --git a/examples/pyomobook/gdp-ch/scont.py b/examples/pyomobook/gdp-ch/scont.py index d1cf4b172bd..3c275a13158 100644 --- a/examples/pyomobook/gdp-ch/scont.py +++ b/examples/pyomobook/gdp-ch/scont.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/gdp-ch/scont2.py b/examples/pyomobook/gdp-ch/scont2.py index 2c77fe670d5..fbfd1540b57 100644 --- a/examples/pyomobook/gdp-ch/scont2.py +++ b/examples/pyomobook/gdp-ch/scont2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/gdp-ch/scont_script.py b/examples/pyomobook/gdp-ch/scont_script.py index fe0702dc262..a9285ac0aa4 100644 --- a/examples/pyomobook/gdp-ch/scont_script.py +++ b/examples/pyomobook/gdp-ch/scont_script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/gdp-ch/verify_scont.py b/examples/pyomobook/gdp-ch/verify_scont.py index 222453560b6..4d73ce520eb 100644 --- a/examples/pyomobook/gdp-ch/verify_scont.py +++ b/examples/pyomobook/gdp-ch/verify_scont.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/intro-ch/abstract5.py b/examples/pyomobook/intro-ch/abstract5.py index 2caad5f9351..f42918af027 100644 --- a/examples/pyomobook/intro-ch/abstract5.py +++ b/examples/pyomobook/intro-ch/abstract5.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/intro-ch/coloring_concrete.py b/examples/pyomobook/intro-ch/coloring_concrete.py index 9931b5d80de..2a5c25ea611 100644 --- a/examples/pyomobook/intro-ch/coloring_concrete.py +++ b/examples/pyomobook/intro-ch/coloring_concrete.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/intro-ch/concrete1.py b/examples/pyomobook/intro-ch/concrete1.py index 169fbeb281c..2f379d9dcb0 100644 --- a/examples/pyomobook/intro-ch/concrete1.py +++ b/examples/pyomobook/intro-ch/concrete1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/intro-ch/concrete1_generic.py b/examples/pyomobook/intro-ch/concrete1_generic.py index 9a2d26bded8..a271269a611 100644 --- a/examples/pyomobook/intro-ch/concrete1_generic.py +++ b/examples/pyomobook/intro-ch/concrete1_generic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/intro-ch/mydata.py b/examples/pyomobook/intro-ch/mydata.py index 209546ebeaf..9e9ad32c18f 100644 --- a/examples/pyomobook/intro-ch/mydata.py +++ b/examples/pyomobook/intro-ch/mydata.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/mpec-ch/ex1a.py b/examples/pyomobook/mpec-ch/ex1a.py index e6f1c33fbbc..c24695af1a6 100644 --- a/examples/pyomobook/mpec-ch/ex1a.py +++ b/examples/pyomobook/mpec-ch/ex1a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/mpec-ch/ex1b.py b/examples/pyomobook/mpec-ch/ex1b.py index 2b0ac2ce1b7..1ee594c2019 100644 --- a/examples/pyomobook/mpec-ch/ex1b.py +++ b/examples/pyomobook/mpec-ch/ex1b.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/mpec-ch/ex1c.py b/examples/pyomobook/mpec-ch/ex1c.py index eaf0292b50d..98aa89e0c82 100644 --- a/examples/pyomobook/mpec-ch/ex1c.py +++ b/examples/pyomobook/mpec-ch/ex1c.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/mpec-ch/ex1d.py b/examples/pyomobook/mpec-ch/ex1d.py index 4c0e0d9fd0f..2b8e55845ac 100644 --- a/examples/pyomobook/mpec-ch/ex1d.py +++ b/examples/pyomobook/mpec-ch/ex1d.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/mpec-ch/ex1e.py b/examples/pyomobook/mpec-ch/ex1e.py index c552847fcfb..bb47e12a5b0 100644 --- a/examples/pyomobook/mpec-ch/ex1e.py +++ b/examples/pyomobook/mpec-ch/ex1e.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/mpec-ch/ex2.py b/examples/pyomobook/mpec-ch/ex2.py index 6981af33376..4743e2144f8 100644 --- a/examples/pyomobook/mpec-ch/ex2.py +++ b/examples/pyomobook/mpec-ch/ex2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ # ex2.py import pyomo.environ as pyo -from pyomo.mpec import * +from pyomo.mpec import Complementarity, complements model = pyo.ConcreteModel() diff --git a/examples/pyomobook/mpec-ch/munson1.py b/examples/pyomobook/mpec-ch/munson1.py index e85d9359768..61ce93cc058 100644 --- a/examples/pyomobook/mpec-ch/munson1.py +++ b/examples/pyomobook/mpec-ch/munson1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/mpec-ch/ralph1.py b/examples/pyomobook/mpec-ch/ralph1.py index b6a8b45e8df..c56bb94ae4f 100644 --- a/examples/pyomobook/mpec-ch/ralph1.py +++ b/examples/pyomobook/mpec-ch/ralph1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/nonlinear-ch/deer/DeerProblem.py b/examples/pyomobook/nonlinear-ch/deer/DeerProblem.py index dc3ca179a58..794f2f5b900 100644 --- a/examples/pyomobook/nonlinear-ch/deer/DeerProblem.py +++ b/examples/pyomobook/nonlinear-ch/deer/DeerProblem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/nonlinear-ch/disease_est/disease_estimation.py b/examples/pyomobook/nonlinear-ch/disease_est/disease_estimation.py index 5675d7a715b..c090bca0c9f 100644 --- a/examples/pyomobook/nonlinear-ch/disease_est/disease_estimation.py +++ b/examples/pyomobook/nonlinear-ch/disease_est/disease_estimation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init1.py b/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init1.py index a50bf3321d6..1439744799b 100644 --- a/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init1.py +++ b/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init2.py b/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init2.py index 6a209334521..46d7c7e77ec 100644 --- a/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init2.py +++ b/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/nonlinear-ch/react_design/ReactorDesign.py b/examples/pyomobook/nonlinear-ch/react_design/ReactorDesign.py index 1cfe3b7193f..b8b6d02dcc1 100644 --- a/examples/pyomobook/nonlinear-ch/react_design/ReactorDesign.py +++ b/examples/pyomobook/nonlinear-ch/react_design/ReactorDesign.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/nonlinear-ch/react_design/ReactorDesignTable.py b/examples/pyomobook/nonlinear-ch/react_design/ReactorDesignTable.py index 2bd9574b427..8d6f23d1bab 100644 --- a/examples/pyomobook/nonlinear-ch/react_design/ReactorDesignTable.py +++ b/examples/pyomobook/nonlinear-ch/react_design/ReactorDesignTable.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/nonlinear-ch/rosen/rosenbrock.py b/examples/pyomobook/nonlinear-ch/rosen/rosenbrock.py index bec1d04c12c..274f2649474 100644 --- a/examples/pyomobook/nonlinear-ch/rosen/rosenbrock.py +++ b/examples/pyomobook/nonlinear-ch/rosen/rosenbrock.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/optimization-ch/ConcHLinScript.py b/examples/pyomobook/optimization-ch/ConcHLinScript.py index b94903585dc..b4ae096e041 100644 --- a/examples/pyomobook/optimization-ch/ConcHLinScript.py +++ b/examples/pyomobook/optimization-ch/ConcHLinScript.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/optimization-ch/ConcreteH.py b/examples/pyomobook/optimization-ch/ConcreteH.py index d7474291d0d..25b0322fa76 100644 --- a/examples/pyomobook/optimization-ch/ConcreteH.py +++ b/examples/pyomobook/optimization-ch/ConcreteH.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/optimization-ch/ConcreteHLinear.py b/examples/pyomobook/optimization-ch/ConcreteHLinear.py index 772c18cb6d5..daffdd1fc4e 100644 --- a/examples/pyomobook/optimization-ch/ConcreteHLinear.py +++ b/examples/pyomobook/optimization-ch/ConcreteHLinear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/optimization-ch/IC_model_dict.py b/examples/pyomobook/optimization-ch/IC_model_dict.py index b7e359777c7..4ccde353fea 100644 --- a/examples/pyomobook/optimization-ch/IC_model_dict.py +++ b/examples/pyomobook/optimization-ch/IC_model_dict.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/var_obj_con_snippet.py b/examples/pyomobook/overview-ch/var_obj_con_snippet.py index 22524b5815a..3e026ecb352 100644 --- a/examples/pyomobook/overview-ch/var_obj_con_snippet.py +++ b/examples/pyomobook/overview-ch/var_obj_con_snippet.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_abstract.py b/examples/pyomobook/overview-ch/wl_abstract.py index 61eeed6b506..6b81070a239 100644 --- a/examples/pyomobook/overview-ch/wl_abstract.py +++ b/examples/pyomobook/overview-ch/wl_abstract.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_abstract_script.py b/examples/pyomobook/overview-ch/wl_abstract_script.py index 7f0871350fc..f8aa546d2e8 100644 --- a/examples/pyomobook/overview-ch/wl_abstract_script.py +++ b/examples/pyomobook/overview-ch/wl_abstract_script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_concrete.py b/examples/pyomobook/overview-ch/wl_concrete.py index c1bf70b07f1..5f599836b3d 100644 --- a/examples/pyomobook/overview-ch/wl_concrete.py +++ b/examples/pyomobook/overview-ch/wl_concrete.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_concrete_script.py b/examples/pyomobook/overview-ch/wl_concrete_script.py index b369521994c..6b593fca774 100644 --- a/examples/pyomobook/overview-ch/wl_concrete_script.py +++ b/examples/pyomobook/overview-ch/wl_concrete_script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_excel.py b/examples/pyomobook/overview-ch/wl_excel.py index 180e36422fe..354d90ecc15 100644 --- a/examples/pyomobook/overview-ch/wl_excel.py +++ b/examples/pyomobook/overview-ch/wl_excel.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_list.py b/examples/pyomobook/overview-ch/wl_list.py index 37cba5a9595..9ec943f838b 100644 --- a/examples/pyomobook/overview-ch/wl_list.py +++ b/examples/pyomobook/overview-ch/wl_list.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_mutable.py b/examples/pyomobook/overview-ch/wl_mutable.py index 8e129dd3c49..631d60f227a 100644 --- a/examples/pyomobook/overview-ch/wl_mutable.py +++ b/examples/pyomobook/overview-ch/wl_mutable.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_mutable_excel.py b/examples/pyomobook/overview-ch/wl_mutable_excel.py index 935fa4963e5..e139c58e7f1 100644 --- a/examples/pyomobook/overview-ch/wl_mutable_excel.py +++ b/examples/pyomobook/overview-ch/wl_mutable_excel.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/overview-ch/wl_scalar.py b/examples/pyomobook/overview-ch/wl_scalar.py index 6f538baedb8..c559813235b 100644 --- a/examples/pyomobook/overview-ch/wl_scalar.py +++ b/examples/pyomobook/overview-ch/wl_scalar.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/performance-ch/SparseSets.py b/examples/pyomobook/performance-ch/SparseSets.py index 519808306de..5109070b33f 100644 --- a/examples/pyomobook/performance-ch/SparseSets.py +++ b/examples/pyomobook/performance-ch/SparseSets.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/performance-ch/lin_expr.py b/examples/pyomobook/performance-ch/lin_expr.py index af50ddd6228..2611122e8ba 100644 --- a/examples/pyomobook/performance-ch/lin_expr.py +++ b/examples/pyomobook/performance-ch/lin_expr.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/performance-ch/persistent.py b/examples/pyomobook/performance-ch/persistent.py index 67f8c656cfe..5574fec1c6b 100644 --- a/examples/pyomobook/performance-ch/persistent.py +++ b/examples/pyomobook/performance-ch/persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/performance-ch/wl.py b/examples/pyomobook/performance-ch/wl.py index 000f81272a1..fc5764ede44 100644 --- a/examples/pyomobook/performance-ch/wl.py +++ b/examples/pyomobook/performance-ch/wl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/con_declaration.py b/examples/pyomobook/pyomo-components-ch/con_declaration.py index 0890ba4771b..8bec3dee42a 100644 --- a/examples/pyomobook/pyomo-components-ch/con_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/con_declaration.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/examples.py b/examples/pyomobook/pyomo-components-ch/examples.py index 1a59e9e308e..1799ca74f1d 100644 --- a/examples/pyomobook/pyomo-components-ch/examples.py +++ b/examples/pyomobook/pyomo-components-ch/examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/expr_declaration.py b/examples/pyomobook/pyomo-components-ch/expr_declaration.py index da0d854e513..303bbf332b2 100644 --- a/examples/pyomobook/pyomo-components-ch/expr_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/expr_declaration.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/obj_declaration.py b/examples/pyomobook/pyomo-components-ch/obj_declaration.py index a63fc441206..69dd718c4ec 100644 --- a/examples/pyomobook/pyomo-components-ch/obj_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/obj_declaration.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/obj_declaration.txt b/examples/pyomobook/pyomo-components-ch/obj_declaration.txt index 607586a1fb3..e4d4b02a252 100644 --- a/examples/pyomobook/pyomo-components-ch/obj_declaration.txt +++ b/examples/pyomobook/pyomo-components-ch/obj_declaration.txt @@ -55,7 +55,7 @@ Model unknown None value x[Q] + 2*x[R] -1 +minimize 6.5 Model unknown diff --git a/examples/pyomobook/pyomo-components-ch/param_declaration.py b/examples/pyomobook/pyomo-components-ch/param_declaration.py index 98b16548c28..a2409b543a9 100644 --- a/examples/pyomobook/pyomo-components-ch/param_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/param_declaration.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/param_initialization.py b/examples/pyomobook/pyomo-components-ch/param_initialization.py index 88da8a68354..d98c23a174a 100644 --- a/examples/pyomobook/pyomo-components-ch/param_initialization.py +++ b/examples/pyomobook/pyomo-components-ch/param_initialization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/param_misc.py b/examples/pyomobook/pyomo-components-ch/param_misc.py index 72fca60f787..7e045da8fe9 100644 --- a/examples/pyomobook/pyomo-components-ch/param_misc.py +++ b/examples/pyomobook/pyomo-components-ch/param_misc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/param_validation.py b/examples/pyomobook/pyomo-components-ch/param_validation.py index baf5f0ac1e2..96efa16ad68 100644 --- a/examples/pyomobook/pyomo-components-ch/param_validation.py +++ b/examples/pyomobook/pyomo-components-ch/param_validation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/rangeset.py b/examples/pyomobook/pyomo-components-ch/rangeset.py index 169060e9ab2..b617be600e3 100644 --- a/examples/pyomobook/pyomo-components-ch/rangeset.py +++ b/examples/pyomobook/pyomo-components-ch/rangeset.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/set_declaration.py b/examples/pyomobook/pyomo-components-ch/set_declaration.py index bf3cfa1be15..9d183e13027 100644 --- a/examples/pyomobook/pyomo-components-ch/set_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/set_declaration.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/set_initialization.py b/examples/pyomobook/pyomo-components-ch/set_initialization.py index bdfd662c985..0390d0b9a3c 100644 --- a/examples/pyomobook/pyomo-components-ch/set_initialization.py +++ b/examples/pyomobook/pyomo-components-ch/set_initialization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/set_misc.py b/examples/pyomobook/pyomo-components-ch/set_misc.py index 20ed9518f52..eef741f2ac9 100644 --- a/examples/pyomobook/pyomo-components-ch/set_misc.py +++ b/examples/pyomobook/pyomo-components-ch/set_misc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/set_options.py b/examples/pyomobook/pyomo-components-ch/set_options.py index 30c0b49706d..289a6b1479d 100644 --- a/examples/pyomobook/pyomo-components-ch/set_options.py +++ b/examples/pyomobook/pyomo-components-ch/set_options.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/set_validation.py b/examples/pyomobook/pyomo-components-ch/set_validation.py index 2300c0be693..6a9cbe60a89 100644 --- a/examples/pyomobook/pyomo-components-ch/set_validation.py +++ b/examples/pyomobook/pyomo-components-ch/set_validation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/suffix_declaration.py b/examples/pyomobook/pyomo-components-ch/suffix_declaration.py index 619093712f1..ae1e72ec168 100644 --- a/examples/pyomobook/pyomo-components-ch/suffix_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/suffix_declaration.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/pyomo-components-ch/var_declaration.py b/examples/pyomobook/pyomo-components-ch/var_declaration.py index 2ee5d7fb749..d22e64e5c73 100644 --- a/examples/pyomobook/pyomo-components-ch/var_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/var_declaration.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/BadIndent.py b/examples/pyomobook/python-ch/BadIndent.py index 4a00cae12ef..27eb99b6fa6 100644 --- a/examples/pyomobook/python-ch/BadIndent.py +++ b/examples/pyomobook/python-ch/BadIndent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/LineExample.py b/examples/pyomobook/python-ch/LineExample.py index 31cface5760..21ba8889bb9 100644 --- a/examples/pyomobook/python-ch/LineExample.py +++ b/examples/pyomobook/python-ch/LineExample.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/class.py b/examples/pyomobook/python-ch/class.py index a09f991d37b..0cd3fdd5b54 100644 --- a/examples/pyomobook/python-ch/class.py +++ b/examples/pyomobook/python-ch/class.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/ctob.py b/examples/pyomobook/python-ch/ctob.py index 8945e4863de..d9161480121 100644 --- a/examples/pyomobook/python-ch/ctob.py +++ b/examples/pyomobook/python-ch/ctob.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/example.py b/examples/pyomobook/python-ch/example.py index 184153545a3..e95b8da371a 100644 --- a/examples/pyomobook/python-ch/example.py +++ b/examples/pyomobook/python-ch/example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/example2.py b/examples/pyomobook/python-ch/example2.py index 9a6a28bedbd..7f049444783 100644 --- a/examples/pyomobook/python-ch/example2.py +++ b/examples/pyomobook/python-ch/example2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/functions.py b/examples/pyomobook/python-ch/functions.py index 97fb77edbe4..66e64027a1b 100644 --- a/examples/pyomobook/python-ch/functions.py +++ b/examples/pyomobook/python-ch/functions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/iterate.py b/examples/pyomobook/python-ch/iterate.py index 50d74f93da7..e25ec1fd412 100644 --- a/examples/pyomobook/python-ch/iterate.py +++ b/examples/pyomobook/python-ch/iterate.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/python-ch/pythonconditional.py b/examples/pyomobook/python-ch/pythonconditional.py index a39e148622b..480d12221b0 100644 --- a/examples/pyomobook/python-ch/pythonconditional.py +++ b/examples/pyomobook/python-ch/pythonconditional.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/attributes.py b/examples/pyomobook/scripts-ch/attributes.py index c406bbf3e1c..33125a28e13 100644 --- a/examples/pyomobook/scripts-ch/attributes.py +++ b/examples/pyomobook/scripts-ch/attributes.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/prob_mod_ex.py b/examples/pyomobook/scripts-ch/prob_mod_ex.py index dceafe9d4f0..310d6eaa861 100644 --- a/examples/pyomobook/scripts-ch/prob_mod_ex.py +++ b/examples/pyomobook/scripts-ch/prob_mod_ex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/sudoku/sudoku.py b/examples/pyomobook/scripts-ch/sudoku/sudoku.py index 8aa39f91203..e274616ec53 100644 --- a/examples/pyomobook/scripts-ch/sudoku/sudoku.py +++ b/examples/pyomobook/scripts-ch/sudoku/sudoku.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/sudoku/sudoku_run.py b/examples/pyomobook/scripts-ch/sudoku/sudoku_run.py index b3f861f86b5..c770db4b9e8 100644 --- a/examples/pyomobook/scripts-ch/sudoku/sudoku_run.py +++ b/examples/pyomobook/scripts-ch/sudoku/sudoku_run.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/value_expression.py b/examples/pyomobook/scripts-ch/value_expression.py index 00c79fec501..ccb850654ed 100644 --- a/examples/pyomobook/scripts-ch/value_expression.py +++ b/examples/pyomobook/scripts-ch/value_expression.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/warehouse_cuts.py b/examples/pyomobook/scripts-ch/warehouse_cuts.py index 345dc5540cb..771887037a1 100644 --- a/examples/pyomobook/scripts-ch/warehouse_cuts.py +++ b/examples/pyomobook/scripts-ch/warehouse_cuts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/warehouse_load_solutions.py b/examples/pyomobook/scripts-ch/warehouse_load_solutions.py index d38412f84df..d7ba0f7416e 100644 --- a/examples/pyomobook/scripts-ch/warehouse_load_solutions.py +++ b/examples/pyomobook/scripts-ch/warehouse_load_solutions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/warehouse_model.py b/examples/pyomobook/scripts-ch/warehouse_model.py index 149eb212759..02b67c5db3c 100644 --- a/examples/pyomobook/scripts-ch/warehouse_model.py +++ b/examples/pyomobook/scripts-ch/warehouse_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/warehouse_print.py b/examples/pyomobook/scripts-ch/warehouse_print.py index 8c862506bf0..96947165aec 100644 --- a/examples/pyomobook/scripts-ch/warehouse_print.py +++ b/examples/pyomobook/scripts-ch/warehouse_print.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/warehouse_script.py b/examples/pyomobook/scripts-ch/warehouse_script.py index 617b8036abf..508a54e301b 100644 --- a/examples/pyomobook/scripts-ch/warehouse_script.py +++ b/examples/pyomobook/scripts-ch/warehouse_script.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/scripts-ch/warehouse_solver_options.py b/examples/pyomobook/scripts-ch/warehouse_solver_options.py index 4e79e158d50..106e3cb9c07 100644 --- a/examples/pyomobook/scripts-ch/warehouse_solver_options.py +++ b/examples/pyomobook/scripts-ch/warehouse_solver_options.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/strip_examples.py b/examples/pyomobook/strip_examples.py index 84017299fb6..6128162eda3 100644 --- a/examples/pyomobook/strip_examples.py +++ b/examples/pyomobook/strip_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/examples/pyomobook/test_book_examples.py b/examples/pyomobook/test_book_examples.py index 192330dc1bf..d3fa418950e 100644 --- a/examples/pyomobook/test_book_examples.py +++ b/examples/pyomobook/test_book_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/__init__.py b/pyomo/__init__.py index 14cc42b626e..bce2e9f0ba3 100644 --- a/pyomo/__init__.py +++ b/pyomo/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,3 +11,22 @@ from . import common from .version import __version__ + + +# +# declare deprecation paths for removed modules +# +from pyomo.common.deprecation import moved_module + +moved_module( + 'pyomo.pysp', + 'pysp', + version='6.0', + msg="PySP has been removed from the pyomo.pysp namespace. " + "Beginning in Pyomo 6.0, PySP is distributed as a separate " + "package. Please see https://github.com/Pyomo/pysp for " + "information on downloading and installing PySP", +) +# (*silently*) redirect 'pyomo.__future__' to 'pyomo.future' +moved_module('pyomo.__future__', 'pyomo.future', msg=None) +del moved_module diff --git a/pyomo/_archive/__init__.py b/pyomo/_archive/__init__.py new file mode 100644 index 00000000000..af86df84849 --- /dev/null +++ b/pyomo/_archive/__init__.py @@ -0,0 +1,18 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +__doc__ = """This package contains archived modules that are no longer part of the +official Pyomo API. + +These modules are still importable through their old names via +:func:`pyomo.common.moved_module()` + +""" diff --git a/pyomo/contrib/parmest/ipopt_solver_wrapper.py b/pyomo/_archive/chull.py similarity index 75% rename from pyomo/contrib/parmest/ipopt_solver_wrapper.py rename to pyomo/_archive/chull.py index 75c470a4b81..547b75df26e 100644 --- a/pyomo/contrib/parmest/ipopt_solver_wrapper.py +++ b/pyomo/_archive/chull.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.deprecation import relocated_module +"This is the deprecated pyomo.gdp.plugins.chull module" -relocated_module('pyomo.contrib.parmest.utils.ipopt_solver_wrapper', version='6.4.2') +from pyomo.gdp.plugins.hull import _Deprecated_Name_Hull as ConvexHull_Transformation diff --git a/pyomo/common/plugin.py b/pyomo/_archive/component_map.py similarity index 78% rename from pyomo/common/plugin.py rename to pyomo/_archive/component_map.py index ac88388ebc0..df510cac9e6 100644 --- a/pyomo/common/plugin.py +++ b/pyomo/_archive/component_map.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.deprecation import relocated_module +"This is the deprecated pyomo.core.kernel.component_map module" -relocated_module('pyomo.common.plugin_base', version='6.5.0') +from pyomo.common.collections import ComponentMap diff --git a/pyomo/repn/tests/ampl/nl_diff.py b/pyomo/_archive/component_set.py similarity index 76% rename from pyomo/repn/tests/ampl/nl_diff.py rename to pyomo/_archive/component_set.py index 9fe352ee503..319b12e092f 100644 --- a/pyomo/repn/tests/ampl/nl_diff.py +++ b/pyomo/_archive/component_set.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.deprecation import relocated_module +"This is the deprecated pyomo.core.kernel.component_set module" -relocated_module('pyomo.repn.tests.nl_diff', version='6.6.0', remove_in='6.6.1') +from pyomo.common.collections import ComponentSet diff --git a/pyomo/core/expr/current.py b/pyomo/_archive/current.py similarity index 94% rename from pyomo/core/expr/current.py rename to pyomo/_archive/current.py index 1209dac0310..a839dbe5633 100644 --- a/pyomo/core/expr/current.py +++ b/pyomo/_archive/current.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +"This is the deprecated pyomo.core.expr.current module" + import enum import math -from pyomo.common.deprecation import deprecation_warning - -deprecation_warning( - "pyomo.core.expr.current is deprecated. " - "Please import expression symbols from pyomo.core.expr", - version='6.6.2', -) - # # Common intrinsic functions # diff --git a/pyomo/core/base/plugin.py b/pyomo/_archive/plugin.py similarity index 76% rename from pyomo/core/base/plugin.py rename to pyomo/_archive/plugin.py index 062e9f9fb85..11e1aa54e34 100644 --- a/pyomo/core/base/plugin.py +++ b/pyomo/_archive/plugin.py @@ -1,25 +1,15 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import inspect -from pyomo.common.deprecation import deprecation_warning -deprecation_warning( - "The pyomo.core.base.plugin module is deprecated. " - "See pyomo.core.base.transformation for Transformation and " - "TransformationFactory, pyomo.core.base.component for " - "ModelComponentFactory and pyomo.scripting.interface for " - "IPyomoScript* interfaces.", - version='6.0', - calling_frame=inspect.currentframe().f_back, -) +"This is the deprecated pyomo.core.base.plugin module" from pyomo.core.base.component import ModelComponentFactory from pyomo.core.base.transformation import ( diff --git a/pyomo/common/getGSL.py b/pyomo/_archive/rangeset.py similarity index 79% rename from pyomo/common/getGSL.py rename to pyomo/_archive/rangeset.py index 66b75b45665..bf6b56809f5 100644 --- a/pyomo/common/getGSL.py +++ b/pyomo/_archive/rangeset.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.deprecation import relocated_module +"This is the deprecated pyomo.core.base.rangeset module" -relocated_module('pyomo.common.gsl', version='6.5.0') +from pyomo.core.base.set import RangeSet diff --git a/pyomo/core/kernel/register_numpy_types.py b/pyomo/_archive/register_numpy_types.py similarity index 82% rename from pyomo/core/kernel/register_numpy_types.py rename to pyomo/_archive/register_numpy_types.py index 86877be2230..3b48e0c1c6e 100644 --- a/pyomo/core/kernel/register_numpy_types.py +++ b/pyomo/_archive/register_numpy_types.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.deprecation import deprecation_warning - -deprecation_warning( - "pyomo.core.kernel.register_numpy_types is deprecated. NumPy type " - "registration is handled automatically by pyomo.common.dependencies.numpy", - version='6.1', -) +"This is the deprecated pyomo.core.kernel.register_numpy_types module" from pyomo.common.numeric_types import ( RegisterNumericType, @@ -45,10 +39,12 @@ # Historically, the lists included several numpy aliases numpy_int_names.extend(('int_', 'intc', 'intp')) numpy_int.extend((numpy.int_, numpy.intc, numpy.intp)) - numpy_float_names.append('float_') - numpy_float.append(numpy.float_) - numpy_complex_names.append('complex_') - numpy_complex.append(numpy.complex_) + if hasattr(numpy, 'float_'): + numpy_float_names.append('float_') + numpy_float.append(numpy.float_) + if hasattr(numpy, 'complex_'): + numpy_complex_names.append('complex_') + numpy_complex.append(numpy.complex_) # Re-build the old numpy_* lists for t in native_boolean_types: diff --git a/pyomo/_archive/sets.py b/pyomo/_archive/sets.py new file mode 100644 index 00000000000..4aa29d9870a --- /dev/null +++ b/pyomo/_archive/sets.py @@ -0,0 +1,23 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +"This is the deprecated pyomo.core.base.sets module" + +from pyomo.core.base.set import ( + process_setarg, + set_options, + simple_set_rule, + _SetDataBase, + SetData, + Set, + SetOf, + IndexedSet, +) diff --git a/pyomo/_archive/template_expr.py b/pyomo/_archive/template_expr.py new file mode 100644 index 00000000000..42a5547d143 --- /dev/null +++ b/pyomo/_archive/template_expr.py @@ -0,0 +1,18 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +"This is the deprecated pyomo.core.base.template_expr module" + +from pyomo.core.expr.template_expr import ( + IndexTemplate, + _GetItemIndexer, + TemplateExpressionError, +) diff --git a/pyomo/common/__init__.py b/pyomo/common/__init__.py index d7297c067c9..cee0ae3cb05 100644 --- a/pyomo/common/__init__.py +++ b/pyomo/common/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -28,3 +28,12 @@ from .deprecation import deprecated from .errors import DeveloperError from ._command import pyomo_command, get_pyomo_commands + +# +# declare deprecation paths for removed modules +# +from .deprecation import moved_module + +moved_module('pyomo.common.getGSL', 'pyomo.common.gsl', version='6.5.0') +moved_module('pyomo.common.plugin', 'pyomo.common.plugin_base', version='6.5.0') +del moved_module diff --git a/pyomo/common/_command.py b/pyomo/common/_command.py index ad521659aa7..2e26efca465 100644 --- a/pyomo/common/_command.py +++ b/pyomo/common/_command.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/_common.py b/pyomo/common/_common.py index 0d50f74537a..61f7ff39f55 100644 --- a/pyomo/common/_common.py +++ b/pyomo/common/_common.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/autoslots.py b/pyomo/common/autoslots.py index 89fefaf4f21..3b5b3b00154 100644 --- a/pyomo/common/autoslots.py +++ b/pyomo/common/autoslots.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,23 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import collections import types -from collections import namedtuple from copy import deepcopy from weakref import ref as _weakref_ref -_autoslot_info = namedtuple( +_autoslot_info = collections.namedtuple( '_autoslot_info', ['has_dict', 'slots', 'slot_mappers', 'field_mappers'] ) def _deepcopy_tuple(obj, memo, _id): ans = [] + _append = ans.append unchanged = True for item in obj: new_item = fast_deepcopy(item, memo) - ans.append(new_item) + _append(new_item) if new_item is not item: unchanged = False if unchanged: @@ -46,22 +47,43 @@ def _deepcopy_tuple(obj, memo, _id): def _deepcopy_list(obj, memo, _id): # Two steps here because a list can include itself memo[_id] = ans = [] - ans.extend(fast_deepcopy(x, memo) for x in obj) + _append = ans.append + for x in obj: + _append(fast_deepcopy(x, memo)) return ans def _deepcopy_dict(obj, memo, _id): # Two steps here because a dict can include itself memo[_id] = ans = {} + _setter = ans.__setitem__ for key, val in obj.items(): - ans[fast_deepcopy(key, memo)] = fast_deepcopy(val, memo) + _setter(fast_deepcopy(key, memo), fast_deepcopy(val, memo)) return ans -def _deepcopier(obj, memo, _id): +def _deepcopy_dunder_deepcopy(obj, memo, _id): + ans = memo[_id] = obj.__deepcopy__(memo) + return ans + + +def _deepcopy(obj, memo, _id): return deepcopy(obj, memo) +class _DeepcopyDispatcher(collections.defaultdict): + def __missing__(self, key): + if hasattr(key, '__deepcopy__'): + ans = _deepcopy_dunder_deepcopy + else: + ans = _deepcopy + self[key] = ans + return ans + + +_deepcopy_dispatcher = _DeepcopyDispatcher( + None, {tuple: _deepcopy_tuple, list: _deepcopy_list, dict: _deepcopy_dict} +) _atomic_types = { int, float, @@ -76,8 +98,6 @@ def _deepcopier(obj, memo, _id): types.FunctionType, } -_deepcopy_mapper = {tuple: _deepcopy_tuple, list: _deepcopy_list, dict: _deepcopy_dict} - def fast_deepcopy(obj, memo): """A faster implementation of copy.deepcopy() @@ -87,6 +107,29 @@ def fast_deepcopy(obj, memo): deepcopy that provides special handling to circumvent some of the slowest parts of deepcopy(). + Note + ---- + + This implementation is not as aggressive about keeping the copied + state alive until the end of the deepcopy operation. In particular, + the ``dict``, ``list`` and ``tuple`` handlers do not register their + source objects with the memo. This is acceptable, as + fast_deepcopy() is only called in situations where we are ensuring + that the source object will persist: + + - :meth:`AutoSlots.__deepcopy_state__` explicitly preserved the + source state + - :meth:`Component.__deepcopy_field__` is only called by + :meth:`AutoSlots.__deepcopy_state__` - + - :meth:`IndexedComponent._create_objects_for_deepcopy` is + deepcopying the raw keys from the source ``_data`` dict (which is + not a temporary object and will persist) + + If other consumers wish to make use of this function (e.g., within + their implementation of ``__deepcopy__``), they must remember that + they are responsible to ensure that any temporary source ``obj`` + persists. + """ if obj.__class__ in _atomic_types: return obj @@ -94,7 +137,7 @@ def fast_deepcopy(obj, memo): if _id in memo: return memo[_id] else: - return _deepcopy_mapper.get(obj.__class__, _deepcopier)(obj, memo, _id) + return _deepcopy_dispatcher[obj.__class__](obj, memo, _id) class AutoSlots(type): @@ -269,12 +312,104 @@ def __deepcopy__(self, memo): """ # Note: this implementation avoids deepcopying the temporary # 'state' list, significantly speeding things up. - memo[id(self)] = ans = self.__class__.__new__(self.__class__) - ans.__setstate__( - [fast_deepcopy(field, memo) for field in self.__getstate__()] - ) + ans = self.__class__.__new__(self.__class__) + self.__deepcopy_state__(memo, ans) return ans + def __deepcopy_state__(self, memo, new_object): + """This implements the state copy from a source object to the new + instance in the deepcopy memo. + + This splits out the logic for actually duplicating the + object state from the "boilerplate" that creates a new + object and registers the object in the memo. This allows us + to create new schemes for duplicating / registering objects + that reuse all the logic here for copying the state. + + """ + # + # At this point we know we need to deepcopy this object. + # But, we can't do the "obvious", since this is a + # (partially) slot-ized class and the __dict__ structure is + # nonauthoritative: + # + # for key, val in self.__dict__.iteritems(): + # object.__setattr__(ans, key, deepcopy(val, memo)) + # + # Further, __slots__ is also nonauthoritative (this may be a + # derived class that also has a __dict__), or this may be a + # derived class with several layers of slots. So, we will + # piggyback on the __getstate__/__setstate__ logic and + # resort to partially "pickling" the object, deepcopying the + # state, and then restoring the copy into the new instance. + # + # [JDS 7/7/14] I worry about the efficiency of using both + # getstate/setstate *and* deepcopy, but we need to update + # fields like weakrefs correctly (and that logic is all in + # __getstate__/__setstate__). + # + # There is a particularly subtle bug with 'uncopyable' + # attributes: if the exception is thrown while copying a + # complex data structure, we can be in a state where objects + # have been created and assigned to the memo in the try + # block, but they haven't had their state set yet. When the + # exception moves us into the except block, we need to + # effectively "undo" those partially copied classes. The + # only way is to restore the memo to the state it was in + # before we started. We will make use of the knowledge that + # 1) memo entries are never reassigned during a deepcopy(), + # and 2) dict are ordered by insertion order in Python >= + # 3.7. As a result, we do not need to preserve the whole + # memo before calling __getstate__/__setstate__, and can get + # away with only remembering the number of items in the + # memo. + # + state = self.__getstate__() + # It is important to keep this temporary state alive (which + # in turn keeps things like the temporary fields dict alive) + # until after deepcopy is finished in order to prevent + # accidentally recycling id()'s for temporary objects that + # were recorded in the memo. We will follow the pattern + # used by copy._keep_alive(): + try: + memo['__auto_slots__'].append(state) + except KeyError: + memo['__auto_slots__'] = [state] + + memo_size = len(memo) + try: + new_state = [fast_deepcopy(field, memo) for field in state] + except: + # We hit an error deepcopying the state. Attempt to + # reset things and try again, but in a more cautious + # manner. + # + # We want to remove any new entries added to the memo + # during the failed try above. + for _ in range(len(memo) - memo_size): + memo.popitem() + # + # Now we are going to continue on, but in a more + # cautious manner: we will clone entries field at a time + # so that we can get the most "complete" copy possible. + # + # Note: if has_dict, then __auto_slots__.slots will be 1 + # shorter than the state (the last element is the + # __dict__). Zip will ignore it. + _copier = getattr(self, '__deepcopy_field__', _deepcopy) + new_state = [ + _copier(value, memo, slot) + for slot, value in zip(self.__auto_slots__.slots, state) + ] + if self.__auto_slots__.has_dict: + new_state.append( + { + slot: _copier(value, memo, slot) + for slot, value in state[-1].items() + } + ) + new_object.__setstate__(new_state) + def __getstate__(self): """Generic implementation of `__getstate__` @@ -300,7 +435,7 @@ def __getstate__(self): if self.__auto_slots__.has_dict: fields = dict(self.__dict__) # Map (encode) any field values. It is not an error if - # the field if not present. + # the field is not present. for name, mapper in self.__auto_slots__.field_mappers.items(): if name in fields: fields[name] = mapper(True, fields[name]) diff --git a/pyomo/common/backports.py b/pyomo/common/backports.py index e70b0f6d267..5ec7f7b2122 100644 --- a/pyomo/common/backports.py +++ b/pyomo/common/backports.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/cmake_builder.py b/pyomo/common/cmake_builder.py index 523dbf64c91..39918bda06c 100644 --- a/pyomo/common/cmake_builder.py +++ b/pyomo/common/cmake_builder.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/collections/__init__.py b/pyomo/common/collections/__init__.py index 717caf87b2c..2b6587bb4f0 100644 --- a/pyomo/common/collections/__init__.py +++ b/pyomo/common/collections/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/collections/bunch.py b/pyomo/common/collections/bunch.py index 2ae9cf8c517..34568565994 100644 --- a/pyomo/common/collections/bunch.py +++ b/pyomo/common/collections/bunch.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index 8dcfdb6c837..b0d4e9fb4ac 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -47,6 +47,18 @@ def _unhashable(val): def _tuple(self, val): return tuple(self[i.__class__](i) for i in val) + def hashable(self, obj, hashable=None): + if isinstance(obj, type): + cls = obj + else: + cls = type(obj) + if hashable is None: + fcn = self.get(cls, None) + if fcn is None: + raise KeyError(obj) + return fcn is self._hashable + self[cls] = self._hashable if hashable else self._unhashable + _hasher = _Hasher() @@ -78,6 +90,8 @@ class ComponentMap(AutoSlots.Mixin, collections.abc.MutableMapping): __slots__ = ("_dict",) __autoslot_mappers__ = {'_dict': _rehash_keys} + # Expose a "public" interface to the global _hasher dict + hasher = _hasher def __init__(self, *args, **kwds): # maps id_hash(obj) -> (obj,val) diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index 6e12bad7277..82e40670801 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -61,6 +61,8 @@ class ComponentSet(AutoSlots.Mixin, collections_MutableSet): __slots__ = ("_data",) __autoslot_mappers__ = {'_data': _rehash_keys} + # Expose a "public" interface to the global _hasher dict + hasher = _hasher def __init__(self, iterable=None): # maps id_hash(obj) -> obj diff --git a/pyomo/common/collections/orderedset.py b/pyomo/common/collections/orderedset.py index 834101e3896..c364e016246 100644 --- a/pyomo/common/collections/orderedset.py +++ b/pyomo/common/collections/orderedset.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/config.py b/pyomo/common/config.py index 238bdd78e9d..806501d7df9 100644 --- a/pyomo/common/config.py +++ b/pyomo/common/config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -40,8 +40,8 @@ relocated_module_attribute, ) from pyomo.common.fileutils import import_file +from pyomo.common.flags import building_documentation, NOTSET from pyomo.common.formatting import wrap_reStructuredText -from pyomo.common.modeling import NOTSET logger = logging.getLogger(__name__) @@ -298,7 +298,7 @@ def __call__(self, value): raise ValueError("%r is not a valid %s" % (value, self._domain.__name__)) def domain_name(self): - return f'InEnum[{self._domain.__name__}]' + return f'InEnum[{_domain_name(self._domain)}]' class IsInstance(object): @@ -361,8 +361,8 @@ def __call__(self, obj): raise ValueError(msg) def domain_name(self): - class_names = (self._get_class_name(kls) for kls in self.baseClasses) - return f"IsInstance({', '.join(class_names)})" + class_names = (_domain_name(kls) for kls in self.baseClasses) + return f"IsInstance[{', '.join(class_names)}]" class ListOf(object): @@ -442,23 +442,22 @@ class Module(object): name, by module name, or by module object. Regardless of how the module is specified, what is stored in the configuration is a module object. - .. doctest:: + .. testcode:: - >>> from pyomo.common.config import ( - ... ConfigDict, ConfigValue, Module - ... ) - >>> config = ConfigDict() - >>> config.declare('my_module', ConfigValue( - ... domain=Module(), - ... )) - - >>> # Set using file path - >>> config.my_module = '../../pyomo/common/tests/config_plugin.py' - >>> # Set using python module name, as a string - >>> config.my_module = 'os.path' - >>> # Set using an imported module object - >>> import os.path - >>> config.my_module = os.path + from pyomo.common.config import ( + ConfigDict, ConfigValue, Module + ) + config = ConfigDict() + config.declare('my_module', ConfigValue( + domain=Module(), + )) + # Set using file path + config.my_module = '../../pyomo/common/tests/config_plugin.py' + # Set using python module name, as a string + config.my_module = 'os.path' + # Set using an imported module object + import os.path + config.my_module = os.path """ @@ -533,7 +532,7 @@ def __call__(self, path): base = self.basePath else: base = Path.BasePath - if type(base) is ConfigValue: + if isinstance(base, ConfigValue): base = base.value() if base is None: base = "" @@ -558,7 +557,7 @@ def __call__(self, path): return ans def domain_name(self): - return type(self).__name__ + return _domain_name(type(self)) class PathList(Path): @@ -613,18 +612,24 @@ class DynamicImplicitDomain(object): >>> import pyomo.common.fileutils >>> from pyomo.common.config import ConfigDict, DynamicImplicitDomain - .. doctest:: + Then we can declare a `:class:``ConfigDict`` that imports the domain + for specific keys from a module that matches the key name: + + .. testcode:: + + def _pluginImporter(name, config): + mod = importlib.import_module(name) + return mod.get_configuration(config) + config = ConfigDict() + config.declare('plugins', ConfigDict( + implicit=True, + implicit_domain=DynamicImplicitDomain(_pluginImporter))) + config.plugins['pyomo.common.tests.config_plugin'] = {'key1': 5} + config.display() + + + .. testoutput:: - >>> def _pluginImporter(name, config): - ... mod = importlib.import_module(name) - ... return mod.get_configuration(config) - >>> config = ConfigDict() - >>> config.declare('plugins', ConfigDict( - ... implicit=True, - ... implicit_domain=DynamicImplicitDomain(_pluginImporter))) - - >>> config.plugins['pyomo.common.tests.config_plugin'] = {'key1': 5} - >>> config.display() plugins: pyomo.common.tests.config_plugin: key1: 5 @@ -699,35 +704,37 @@ def from_enum_or_string(cls, arg): Python's dict and list classes, respectively. At its simplest, the Config system allows for developers to specify a -dictionary of documented configuration entries, allow users to provide -values for those entries, and retrieve the current values: +dictionary of documented configuration entries: + +.. testcode:: + + from pyomo.common.config import ( + ConfigDict, ConfigList, ConfigValue + ) + config = ConfigDict() + config.declare('filename', ConfigValue( + default=None, + domain=str, + description="Input file name", + )) + config.declare("bound tolerance", ConfigValue( + default=1E-5, + domain=float, + description="Bound tolerance", + doc="Relative tolerance for bound feasibility checks" + )) + config.declare("iteration limit", ConfigValue( + default=30, + domain=int, + description="Iteration limit", + doc="Number of maximum iterations in the decomposition methods" + )) + +Users can then provide values for those entries, and retrieve the +current values: .. doctest:: - >>> from pyomo.common.config import ( - ... ConfigDict, ConfigList, ConfigValue - ... ) - >>> config = ConfigDict() - >>> config.declare('filename', ConfigValue( - ... default=None, - ... domain=str, - ... description="Input file name", - ... )) - - >>> config.declare("bound tolerance", ConfigValue( - ... default=1E-5, - ... domain=float, - ... description="Bound tolerance", - ... doc="Relative tolerance for bound feasibility checks" - ... )) - - >>> config.declare("iteration limit", ConfigValue( - ... default=30, - ... domain=int, - ... description="Iteration limit", - ... doc="Number of maximum iterations in the decomposition methods" - ... )) - >>> config['filename'] = 'tmp.txt' >>> print(config['filename']) tmp.txt @@ -882,45 +889,40 @@ class will still create ``c`` instances that only have the single simpler, the :py:meth:`declare` method returns the declared Config object so that the argument declaration can be done inline: -.. doctest:: - - >>> import argparse - >>> config = ConfigDict() - >>> config.declare('iterlim', ConfigValue( - ... domain=int, - ... default=100, - ... description="iteration limit", - ... )).declare_as_argument() - - >>> config.declare('lbfgs', ConfigValue( - ... domain=bool, - ... description="use limited memory BFGS update", - ... )).declare_as_argument() - - >>> config.declare('linesearch', ConfigValue( - ... domain=bool, - ... default=True, - ... description="use line search", - ... )).declare_as_argument() - - >>> config.declare('relative tolerance', ConfigValue( - ... domain=float, - ... description="relative convergence tolerance", - ... )).declare_as_argument('--reltol', '-r', group='Tolerances') - - >>> config.declare('absolute tolerance', ConfigValue( - ... domain=float, - ... description="absolute convergence tolerance", - ... )).declare_as_argument('--abstol', '-a', group='Tolerances') - +.. testcode:: + + import argparse + config = ConfigDict() + config.declare('iterlim', ConfigValue( + domain=int, + default=100, + description="iteration limit", + )).declare_as_argument() + config.declare('lbfgs', ConfigValue( + domain=bool, + description="use limited memory BFGS update", + )).declare_as_argument() + config.declare('linesearch', ConfigValue( + domain=bool, + default=True, + description="use line search", + )).declare_as_argument() + config.declare('relative tolerance', ConfigValue( + domain=float, + description="relative convergence tolerance", + )).declare_as_argument('--reltol', '-r', group='Tolerances') + config.declare('absolute tolerance', ConfigValue( + domain=float, + description="absolute convergence tolerance", + )).declare_as_argument('--abstol', '-a', group='Tolerances') The ConfigDict can then be used to initialize (or augment) an argparse ArgumentParser object: -.. doctest:: +.. testcode:: - >>> parser = argparse.ArgumentParser("tester") - >>> config.initialize_argparse(parser) + parser = argparse.ArgumentParser("tester") + config.initialize_argparse(parser) Key information from the ConfigDict is automatically transferred over @@ -945,10 +947,8 @@ class will still create ``c`` instances that only have the single --disable-linesearch [DON'T] use line search Tolerances: - --reltol FLOAT, -r FLOAT - relative convergence tolerance - --abstol FLOAT, -a FLOAT - absolute convergence tolerance + --reltol... -r FLOAT relative convergence tolerance + --abstol... -a FLOAT absolute convergence tolerance .. doctest:: @@ -996,34 +996,34 @@ class will still create ``c`` instances that only have the single :py:meth:`generate_documentation()`. The simplest is :py:meth:`display()`, which prints out the current values of the configuration object (and if it is a container type, all of it's -children). :py:meth:`generate_yaml_template` is simular to +children). :py:meth:`generate_yaml_template` is similar to :py:meth:`display`, but also includes the description fields as formatted comments. +.. testcode:: + + solver_config = config + config = ConfigDict() + config.declare('output', ConfigValue( + default='results.yml', + domain=str, + description='output results filename' + )) + config.declare('verbose', ConfigValue( + default=0, + domain=int, + description='output verbosity', + doc='This sets the system verbosity. The default (0) only logs ' + 'warnings and errors. Larger integer values will produce ' + 'additional log messages.', + )) + config.declare('solvers', ConfigList( + domain=solver_config, + description='list of solvers to apply', + )) + .. doctest:: - >>> solver_config = config - >>> config = ConfigDict() - >>> config.declare('output', ConfigValue( - ... default='results.yml', - ... domain=str, - ... description='output results filename' - ... )) - - >>> config.declare('verbose', ConfigValue( - ... default=0, - ... domain=int, - ... description='output verbosity', - ... doc='This sets the system verbosity. The default (0) only logs ' - ... 'warnings and errors. Larger integer values will produce ' - ... 'additional log messages.', - ... )) - - >>> config.declare('solvers', ConfigList( - ... domain=solver_config, - ... description='list of solvers to apply', - ... )) - >>> config.display() output: results.yml verbose: 0 @@ -1116,6 +1116,8 @@ def _dump(*args, **kwds): def dump(x, **args): if type(x) is bool: return str(x).lower() + if type(x) is type: + return str(type(x)) return str(x) assert '_dump' in globals() @@ -1133,12 +1135,26 @@ def _munge_name(name, space_to_dash=True): def _domain_name(domain): if domain is None: return "" - elif hasattr(domain, 'domain_name'): - return domain.domain_name() - elif domain.__class__ is type: - return domain.__name__ + if hasattr(domain, 'domain_name') and not isinstance(domain, type): + dn = domain.domain_name + if hasattr(dn, '__call__'): + return dn() + else: + return dn + if domain.__module__ == 'builtins': + module = "" + else: + module = "~" + domain.__module__ + '.' + if isinstance(domain, type): + if building_documentation(): + return module + domain.__qualname__ + else: + return domain.__name__ elif inspect.isfunction(domain): - return domain.__name__ + if building_documentation(): + return module + domain.__qualname__ + else: + return domain.__name__ else: return None @@ -1168,17 +1184,17 @@ def _value2string(prefix, value, obj): _str = prefix if value is not None: try: - _data = value._data if value is obj else value - if getattr(builtins, _data.__class__.__name__, None) is not None: + data = value.value(False) if value is obj else value + if getattr(builtins, data.__class__.__name__, None) is not None: _str += _dump( - _data, default_flow_style=True, allow_unicode=True + data, default_flow_style=True, allow_unicode=True ).rstrip() if _str.endswith("..."): _str = _str[:-3].rstrip() else: - _str += str(_data) + _str += str(data) except: - _str += str(type(_data)) + _str += str(type(data)) return _str.rstrip() @@ -1186,12 +1202,12 @@ def _value2yaml(prefix, value, obj): _str = prefix if value is not None: try: - _data = value._data if value is obj else value - _str += _dump(_data, default_flow_style=True).rstrip() + data = value.value(False) if value is obj else value + _str += _dump(data, default_flow_style=True).rstrip() if _str.endswith("..."): _str = _str[:-3].rstrip() except: - _str += str(type(_data)) + _str += str(type(data)) return _str.rstrip() @@ -1201,7 +1217,7 @@ def __init__(self, obj): self._name = obj.name(True) def __call__(self, arg): - logging.error( + logger.error( """%s '%s' was pickled with an unpicklable domain. The domain was stripped and lost during the pickle process. Setting new values on the restored object cannot be mapped into the correct @@ -1483,7 +1499,7 @@ def _item_body(self, indent, obj): ( 'optional' if obj._default is None - else f'default={repr(obj._default)}' + else f'default={obj._default!r}' ), ], ) @@ -1516,7 +1532,7 @@ def _item_body(self, indent, obj): self.wrapper, ) if itemdoc: - self.out.write(itemdoc + '\n') + self.out.write('\n' + itemdoc + '\n') def _finalize(self): return inspect.cleandoc(self.out.getvalue()) @@ -1660,15 +1676,83 @@ def __call__(self, fcn): return fcn +class UninitializedMixin(object): + """Mixin class to support delayed data initialization. + + This mixin can be used to create a derived Config class that hides + the (uninitialized) ``_data`` attribute behind a property. Any + attempt to access the ``_data`` will trigger the initialization of the + Config object from its ``_default`` value. Setting the ``_data`` + attribute will also trigger resolution of the Config object, but + without processing the ``_default__``. + + """ + + __slots__ = () + + @property + def _data(self): + # + # We assume that _default is usually a concrete value. But, we + # also accept a types (classes) and initialization functions as + # defaults, in which case we will construct an instance of that + # class and use that as the default. If they both raise + # exceptions, we will let the original exception propagate up. + # + try: + self._setter(self._default) + except: + if hasattr(self._default, '__call__'): + _default_val = self._default() + try: + self._setter(_default_val) + return self._data + except: + pass + raise + return self._data + + @_data.setter + def _data(self, value): + _mro = self.__class__.__mro__ + # There is an edge case in multithreaded environments where this + # function could actually be called more than once for a single + # ConfigValue. We want to make sure that only the first of the + # calls actually updates the __class__ (the others will + # recursively lookup the _data attribute and the second lookup + # will resolve to normal attribute assignment). + # + # We first encountered this issue for Config objects stores as + # class attributes (i.e., the default Config for something like + # a solver or writer) and multiple threads were simultaneously + # creating instances of the class (each of which was resolving + # the default values for the class attribute). + # + # Note that this explicitly assumes that the uninitialized + # Config object was defined as: + # + # class UninitializedConfig(UninitializedMixin, Config) + # + # and that the resulting class was never inherited from. If + # this assumption is ever violated, attempts to use the + # uninitialized config object will generate infinite recursion + # (and that is OK, as the developer should immediately be + # informed of their error) + if _mro[1] is UninitializedMixin: + self.__class__ = _mro[2] + self._data = value + + class ConfigBase(object): + # Note: __getstate__ relies on this field ordering. Do not change. __slots__ = ( '_parent', + '_domain', '_name', '_userSet', '_userAccessed', '_data', '_default', - '_domain', '_description', '_doc', '_visibility', @@ -1690,13 +1774,15 @@ def __init__( self._userSet = False self._userAccessed = False - self._data = None + self._data = NOTSET self._default = default self._domain = domain self._description = _strip_indentation(description) self._doc = _strip_indentation(doc) self._visibility = visibility self._argparse = None + if self._UninitializedClass is not None: + self.__class__ = self._UninitializedClass def __getstate__(self): # Nominally, __getstate__() should return: @@ -1714,13 +1800,15 @@ def __getstate__(self): # can allocate the state dictionary. If it is not, then we call # the super-class's __getstate__ (since that class is NOT # 'object'). - state = {key: getattr(self, key) for key in ConfigBase.__slots__} - state['_domain'] = _picklable(state['_domain'], self) - state['_parent'] = None + state = [None, _picklable(self._domain, self)] + # Note: [2:] skips _parent and _domain (intentionally): We just + # wrapped _domain in _picklable and explicitly set _parent to + # None (it will be restored in __setstate__). + state.extend(getattr(self, key) for key in ConfigBase.__slots__[2:]) return state def __setstate__(self, state): - for key, val in state.items(): + for key, val in zip(ConfigBase.__slots__, state): # Note: per the Python data model docs, we explicitly # set the attribute using object.__setattr__() instead # of setting self.__dict__[key] = val. @@ -1752,7 +1840,11 @@ def __call__( assert default is NOTSET else: fields += ('domain',) - kwds['default'] = self.value() if default is NOTSET else default + if default is NOTSET: + default = self.value() + if default is NOTSET: + default = None + kwds['default'] = default assert implicit is NOTSET assert implicit_domain is NOTSET for field in fields: @@ -1768,9 +1860,7 @@ def __call__( # Initialize the new config object ans = self.__class__(**kwds) - if not isinstance(self, ConfigDict): - ans.reset() - else: + if isinstance(self, ConfigDict): # Copy over any Dict definitions for k, v in self._data.items(): if preserve_implicit or k in self._declared: @@ -1836,17 +1926,9 @@ def _cast(self, value): return value def reset(self): - # - # This is a dangerous construct, the failure in the first try block - # can mask a real problem. - # - try: - self.set_value(self._default) - except: - if hasattr(self._default, '__call__'): - self.set_value(self._default()) - else: - raise + # Reset the object back to its default value and clear the + # userSet and userAccessed flags + self._UninitializedClass._data.fget(self) self._userAccessed = False self._userSet = False @@ -2167,20 +2249,18 @@ class ConfigValue(ConfigBase): """ - def __init__(self, *args, **kwds): - ConfigBase.__init__(self, *args, **kwds) - self.reset() + __slots__ = () def value(self, accessValue=True): if accessValue: self._userAccessed = True return self._data - def set_value(self, value): - # Trap self-assignment (useful for providing editor completion) - if value is self: - return + def _setter(self, value): self._data = self._cast(value) + + def set_value(self, value): + self._setter(value) self._userSet = True def _data_collector(self, level, prefix, visibility=None, docMode=False): @@ -2189,17 +2269,28 @@ def _data_collector(self, level, prefix, visibility=None, docMode=False): yield (level, prefix, self, self) +ConfigValue._UninitializedClass = type( + 'UninitializedConfigValue', (UninitializedMixin, ConfigValue), {'__slots__': ()} +) + + class ImmutableConfigValue(ConfigValue): + __slots__ = () + def __new__(self, *args, **kwds): # ImmutableConfigValue objects are never directly created, and # any attempt to copy one will generate a mutable ConfigValue # object return ConfigValue(*args, **kwds) - def set_value(self, value): - if self._cast(value) != self._data: - raise RuntimeError(str(self) + ' is currently immutable') - super(ImmutableConfigValue, self).set_value(value) + def _setter(self, value): + try: + _data = self._data + super()._setter(value) + if _data != self._data: + raise RuntimeError(f"'{self.name(True)}' is currently immutable") + finally: + self._data = _data class MarkImmutable(object): @@ -2214,14 +2305,48 @@ class MarkImmutable(object): Examples -------- - >>> config = ConfigDict() - >>> config.declare('a', ConfigValue(default=1, domain=int)) - >>> config.declare('b', ConfigValue(default=1, domain=int)) - >>> locker = MarkImmutable(config.get('a'), config.get('b')) + .. testcode:: + + config = ConfigDict() + config.declare('a', ConfigValue(default=1, domain=int)) + config.declare('b', ConfigValue(default=1, domain=int)) + locker = MarkImmutable(config.get('a'), config.get('b')) + + Now, config.a and config.b cannot be changed: + + .. doctest:: + + >>> config.a = 5 + Traceback (most recent call last): + ... + RuntimeError: ConfigValue 'a' is currently immutable + >>> print(config.a) + 1 - Now, config.a and config.b cannot be changed. To make them mutable again, + To make them mutable again, + + .. doctest:: + + >>> locker.release_lock() + >>> config.a = 5 + >>> print(config.a) + 5 + + Note that this can be used as a context manager as well: + + .. doctest:: + + >>> with MarkImmutable(config.get('a'), config.get('b')): + ... config.a = 10 + Traceback (most recent call last): + ... + RuntimeError: ConfigValue 'a' is currently immutable + >>> print(config.a) + 5 + >>> config.a = 10 + >>> print(config.a) + 10 - >>> locker.release_lock() """ def __init__(self, *args): @@ -2233,9 +2358,13 @@ def lock(self): try: for cfg in self._targets: if type(cfg) is not ConfigValue: - raise ValueError( - 'Only ConfigValue instances can be marked immutable.' - ) + if isinstance(cfg, ConfigValue): + # Resolve any UninitializedConfigValue + cfg._data + else: + raise ValueError( + 'Only ConfigValue instances can be marked immutable.' + ) cfg.__class__ = ImmutableConfigValue self._locked.append(cfg) except: @@ -2295,15 +2424,25 @@ class ConfigList(ConfigBase, Sequence): """ - def __init__(self, *args, **kwds): - ConfigBase.__init__(self, *args, **kwds) - if self._domain is None: - self._domain = ConfigValue() - elif isinstance(self._domain, ConfigBase): + __slots__ = () + + def __init__( + self, default=None, domain=None, description=None, doc=None, visibility=0 + ): + if domain is None: + domain = ConfigValue() + elif isinstance(domain, ConfigBase): pass else: - self._domain = ConfigValue(None, domain=self._domain) - self.reset() + domain = ConfigValue(None, domain=domain) + ConfigBase.__init__( + self, + default=default, + domain=domain, + description=description, + doc=doc, + visibility=visibility, + ) def __setstate__(self, state): state = super(ConfigList, self).__setstate__(state) @@ -2352,43 +2491,43 @@ def value(self, accessValue=True): self._userAccessed = True return [config.value(accessValue) for config in self._data] - def set_value(self, value): - # If the set_value fails part-way through the list values, we + def _setter(self, value): + # If the _setter fails part-way through the list values, we # want to restore a deterministic state. That is, either # set_value succeeds completely, or else nothing happens. - _old = self._data - self._data = [] - try: - if isinstance(value, str): - value = list(_default_string_list_lexer(value)) - if (type(value) is list) or isinstance(value, ConfigList): - for val in value: - self.append(val) - else: - self.append(value) - except: - self._data = _old - raise - self._userSet = True + _data = [] + if isinstance(value, str): + value = list(_default_string_list_lexer(value)) + if (type(value) is list) or isinstance(value, ConfigList): + for val in value: + self._append(_data, val) + else: + self._append(_data, value) + self._data = _data - def reset(self): - ConfigBase.reset(self) - # Because the base reset() calls set_value, any deefault list - # entries will get their userSet flag set. This is wrong, as - # reset() should conceptually reset the object to it's default - # state (e.g., before the user ever had a chance to mess with - # things). As the list could contain a ConfigDict, this is a - # recursive operation to put the userSet values back. - for val in self.user_values(): - val._userSet = False + def set_value(self, value): + self._setter(value) + self._userSet = True + for _data in self._data: + _data._userSet = True - def append(self, value=NOTSET): + def _append(self, _data, value): val = self._cast(value) if val is None: return - self._data.append(val) - self._data[-1]._parent = self - self._data[-1]._name = '[%s]' % (len(self._data) - 1,) + val._parent = self + val._name = f'[{len(_data)}]' + # We need to reset the _userSet to False because the List domain + # is a ConfigValue and __call__ will trigger set_value(), which + # will set the _userSet flag. As we get here during _default + # processing, we want to clear that flag. If this is actually + # getting triggered through set_value() / append(), then + # append() will be responsible for setting _userSet. + val._userSet = False + _data.append(val) + + def append(self, value=NOTSET): + self._append(self._data, value) self._data[-1]._userSet = True # Adding something to the container should not change the # userSet on the container (see Pyomo/pyomo#352; now @@ -2431,6 +2570,11 @@ def _data_collector(self, level, prefix, visibility=None, docMode=False): yield v +ConfigList._UninitializedClass = type( + 'UninitializedConfigList', (UninitializedMixin, ConfigList), {'__slots__': ()} +) + + class ConfigDict(ConfigBase, Mapping): """Store and manipulate a dictionary of configuration values. @@ -2465,8 +2609,9 @@ class ConfigDict(ConfigBase, Mapping): content_filters = {None, 'all', 'userdata'} - __slots__ = ('_declared', '_implicit_declaration', '_implicit_domain') - _all_slots = set(__slots__ + ConfigBase.__slots__) + # Note: __getstate__ relies on this field ordering. Do not change. + __slots__ = ('_implicit_domain', '_declared', '_implicit_declaration') + _reserved_words = set() def __init__( self, @@ -2493,13 +2638,17 @@ def domain_name(self): return _munge_name(self.name(), False) def __getstate__(self): - state = super(ConfigDict, self).__getstate__() - state.update((key, getattr(self, key)) for key in ConfigDict.__slots__) - state['_implicit_domain'] = _picklable(state['_implicit_domain'], self) + state = super().__getstate__() + state.append(_picklable(self._implicit_domain, self)) + # Note: [1:] intentionally skips the _implicit_domain (which we + # just handled) + state.extend(getattr(self, key) for key in ConfigDict.__slots__[1:]) return state def __setstate__(self, state): - state = super(ConfigDict, self).__setstate__(state) + super().__setstate__(state) + for key, val in zip(ConfigDict.__slots__, state[len(ConfigBase.__slots__) :]): + object.__setattr__(self, key, val) for x in self._data.values(): x._parent = self @@ -2545,8 +2694,11 @@ def __setitem__(self, key, val): if _key not in self._data: self.add(key, val) else: - self._data[_key].set_value(val) - # self._userAccessed = True + cfg = self._data[_key] + # Trap self-assignment (useful for providing editor completion) + if cfg is val: + return + cfg.set_value(val) def __delitem__(self, key): # Note that this will produce a KeyError if the key is not valid @@ -2566,20 +2718,23 @@ def __len__(self): def __iter__(self): return map(attrgetter('_name'), self._data.values()) - def __getattr__(self, name): + def __getattr__(self, attr): # Note: __getattr__ is only called after all "usual" attribute # lookup methods have failed. So, if we get here, we already # know that key is not a __slot__ or a method, etc... - # if name in ConfigDict._all_slots: - # return super(ConfigDict,self).__getattribute__(name) - _name = name.replace(' ', '_') - if _name not in self._data: - raise AttributeError("Unknown attribute '%s'" % name) - return ConfigDict.__getitem__(self, _name) + _attr = attr.replace(' ', '_') + # Note: we test for "_data" because finding attributes on a + # partially constructed ConfigDict (before the _data attribute + # was declared) can lead to infinite recursion. + if _attr == "_data" or _attr not in self._data: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{attr}'" + ) + return ConfigDict.__getitem__(self, _attr) def __setattr__(self, name, value): - if name in ConfigDict._all_slots: - super(ConfigDict, self).__setattr__(name, value) + if name in ConfigDict._reserved_words: + super().__setattr__(name, value) else: ConfigDict.__setitem__(self, name, value) @@ -2764,5 +2919,8 @@ def _data_collector(self, level, prefix, visibility=None, docMode=False): yield from cfg._data_collector(level, cfg._name + ': ', visibility, docMode) +ConfigDict._UninitializedClass = None +ConfigDict._reserved_words.update(dir(ConfigDict)) + # Backwards compatibility: ConfigDict was originally named ConfigBlock. ConfigBlock = ConfigDict diff --git a/pyomo/common/dependencies.py b/pyomo/common/dependencies.py index 9e96fdd5860..61fbb645e3c 100644 --- a/pyomo/common/dependencies.py +++ b/pyomo/common/dependencies.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,24 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from collections.abc import Mapping import inspect import importlib +import importlib.util import logging import sys import warnings -from .deprecation import deprecated, deprecation_warning, in_testing_environment -from .errors import DeferredImportError - +from collections.abc import Mapping +from types import ModuleType +from typing import List + +from pyomo.common.deprecation import deprecated, deprecation_warning +from pyomo.common.errors import DeferredImportError +from pyomo.common.flags import ( + in_testing_environment, + building_documentation, + serializing, +) SUPPRESS_DEPENDENCY_WARNINGS = False @@ -67,10 +75,9 @@ def __init__(self, name, message, version_error, import_error, package): self._moduleunavailable_info_ = (message, version_error, import_error, package) def __getattr__(self, attr): - if attr in ModuleUnavailable._getattr_raises_attributeerror: - raise AttributeError( - "'%s' object has no attribute '%s'" % (type(self).__name__, attr) - ) + if serializing() or attr in ModuleUnavailable._getattr_raises_attributeerror: + msg = "'%s' object has no attribute '%s'" % (type(self).__name__, attr) + raise AttributeError(msg) raise DeferredImportError(self._moduleunavailable_message()) def __getstate__(self): @@ -127,7 +134,7 @@ class DeferredImportModule(object): This object is returned by :py:func:`attempt_import()` in lieu of the module when :py:func:`attempt_import()` is called with - ``defer_check=True``. Any attempts to access attributes on this + ``defer_import=True``. Any attempts to access attributes on this object will trigger the actual module import and return either the appropriate module attribute or else if the module import fails, raise a :py:class:`.DeferredImportError` exception. @@ -223,6 +230,13 @@ def UnavailableClass(unavailable_module): As does attempting to access class attributes on the derived class: + .. testcode:: + :hide: + + # We suppress this exception when building the documentation + # from pyomo.common.flags import building_documentation + building_documentation(False) + .. doctest:: >>> MyPlugin.create_instance() @@ -233,10 +247,21 @@ def UnavailableClass(unavailable_module): dependency was not found (import raised ModuleNotFoundError: No module named 'bogus_unavailable_class') + .. testcode:: + :hide: + + building_documentation(None) + """ class UnavailableMeta(type): def __getattr__(cls, name): + if building_documentation(): + # If we are building documentation, avoid the + # DeferredImportError (we will still raise one if + # someone attempts to *create* an instance of this + # class) + return getattr(super(), name) raise DeferredImportError( unavailable_module._moduleunavailable_message( f"The class attribute '{cls.__name__}.{name}' is not available " @@ -312,6 +337,12 @@ def __init__( self._module = None self._available = None self._deferred_submodules = deferred_submodules + # If this import has a callback, then record this deferred + # import so that any direct imports of this module also trigger + # the resolution of this DeferredImportIndicator (and the + # corresponding callback) + if callback is not None: + DeferredImportCallbackFinder._callbacks.setdefault(name, []).append(self) def __bool__(self): self.resolve() @@ -433,6 +464,102 @@ def check_min_version(module, min_version): check_min_version._parser = None +# +# Note that we are duck-typing the Loader and MetaPathFinder base +# classes from importlib.abc. This avoids a (surprisingly costly) +# import of importlib.abc +# +class DeferredImportCallbackLoader: + """Custom Loader to resolve registered :py:class:`DeferredImportIndicator` objects + + This :py:class:`importlib.abc.Loader` loader wraps a regular loader + and automatically resolves the registered + :py:class:`DeferredImportIndicator` objects after the module is + loaded. + + """ + + def __init__(self, loader, deferred_indicators: List[DeferredImportIndicator]): + self._loader = loader + self._deferred_indicators = deferred_indicators + + def module_repr(self, module: ModuleType) -> str: + return self._loader.module_repr(module) + + def create_module(self, spec) -> ModuleType: + return self._loader.create_module(spec) + + def exec_module(self, module: ModuleType) -> None: + self._loader.exec_module(module) + # Now that the module has been loaded, trigger the resolution of + # the deferred indicators (and their associated callbacks) + for deferred in self._deferred_indicators: + deferred.resolve() + + def load_module(self, fullname) -> ModuleType: + return self._loader.load_module(fullname) + + +class DeferredImportCallbackFinder: + """Custom Finder that will wrap the normal loader to trigger callbacks + + This :py:class:`importlib.abc.MetaPathFinder` finder will wrap the + normal loader returned by ``PathFinder`` with a loader that will + trigger custom callbacks after the module is loaded. We use this to + trigger the post import callbacks registered through + :py:func:`attempt_import` even when a user imports the target library + directly (and not through attribute access on the + :py:class:`DeferredImportModule`. + + """ + + _callbacks = {} + + def find_spec(self, fullname, path, target=None): + if fullname not in self._callbacks: + return None + + spec = None + # Continue looking for the finder that would have originally + # loaded the deferred import module by starting at the next + # finder in sys.meta_path (this way, we are agnostic to where + # the module is coming from: file system, registry, etc.) + for finder in sys.meta_path[sys.meta_path.index(self) + 1 :]: + if hasattr(finder, 'find_spec'): + # Support standard importlib MetaPathFinders + spec = finder.find_spec(fullname, path, target) + if spec is not None: + break + else: + # Support for imp finders/loaders (deprecated, but + # supported through Python 3.11) + loader = finder.find_module(fullname, path) + if loader is not None: + spec = importlib.util.spec_from_loader(fullname, loader) + break + else: + # Module not found. Returning None will proceed to the next + # finder (which will eventually raise a ModuleNotFoundError) + return None + # Override the loader to trigger the finalization callback + # after the original loader is finished + spec.loader = DeferredImportCallbackLoader( + spec.loader, self._callbacks[fullname] + ) + return spec + + def invalidate_caches(self): + pass + + +_DeferredImportCallbackFinder = DeferredImportCallbackFinder() +# Insert the DeferredImportCallbackFinder at the beginning of the +# sys.meta_path so that it is found before the standard finders (so that +# we can correctly inject the resolution of the DeferredImportIndicators +# -- which triggers the needed callbacks) +sys.meta_path.insert(0, _DeferredImportCallbackFinder) + + def attempt_import( name, error_message=None, @@ -441,7 +568,8 @@ def attempt_import( alt_names=None, callback=None, importer=None, - defer_check=True, + defer_check=None, + defer_import=None, deferred_submodules=None, catch_exceptions=None, ): @@ -495,7 +623,8 @@ def attempt_import( The message for the exception raised by :py:class:`ModuleUnavailable` only_catch_importerror: bool, optional - DEPRECATED: use catch_exceptions instead or only_catch_importerror. + DEPRECATED: use ``catch_exceptions`` instead of ``only_catch_importerror``. + If True (the default), exceptions other than ``ImportError`` raised during module import will be reraised. If False, any exception will result in returning a :py:class:`ModuleUnavailable` object. @@ -506,13 +635,14 @@ def attempt_import( ``module.__version__``) alt_names: list, optional - DEPRECATED: alt_names no longer needs to be specified and is ignored. + DEPRECATED: ``alt_names`` no longer needs to be specified and is ignored. + A list of common alternate names by which to look for this module in the ``globals()`` namespaces. For example, the alt_names for NumPy would be ``['np']``. (deprecated in version 6.0) - callback: function, optional - A function with the signature "``fcn(module, available)``" that + callback: Callable[[ModuleType, bool], None], optional + A function with the signature ``fcn(module, available)`` that will be called after the import is first attempted. importer: function, optional @@ -522,10 +652,16 @@ def attempt_import( want to import/return the first one that is available. defer_check: bool, optional - If True (the default), then the attempted import is deferred - until the first use of either the module or the availability - flag. The method will return instances of :py:class:`DeferredImportModule` - and :py:class:`DeferredImportIndicator`. + DEPRECATED: renamed to ``defer_import`` (deprecated in version 6.7.2) + + defer_import: bool, optional + If True, then the attempted import is deferred until the first + use of either the module or the availability flag. The method + will return instances of :py:class:`DeferredImportModule` and + :py:class:`DeferredImportIndicator`. If False, the import will + be attempted immediately. If not set, then the import will be + deferred unless the ``name`` is already present in + ``sys.modules``. deferred_submodules: Iterable[str], optional If provided, an iterable of submodule names within this module @@ -576,9 +712,26 @@ def attempt_import( if catch_exceptions is None: catch_exceptions = (ImportError,) + if defer_check is not None: + deprecation_warning( + 'defer_check=%s is deprecated. Please use defer_import' % (defer_check,), + version='6.7.2', + ) + assert defer_import is None + defer_import = defer_check + + # If the module has already been imported, there is no reason to + # further defer things: just import it. + if defer_import is None: + if name in sys.modules: + defer_import = False + deferred_submodules = None + else: + defer_import = True + # If we are going to defer the check until later, return the # deferred import module object - if defer_check: + if defer_import: if deferred_submodules: if isinstance(deferred_submodules, Mapping): deprecation_warning( @@ -621,7 +774,7 @@ def attempt_import( return DeferredImportModule(indicator, deferred, None), indicator if deferred_submodules: - raise ValueError("deferred_submodules is only valid if defer_check==True") + raise ValueError("deferred_submodules is only valid if defer_import==True") return _perform_import( name=name, @@ -672,6 +825,11 @@ def _perform_import( return module, False +@deprecated( + "``declare_deferred_modules_as_importable()`` is deprecated. " + "Use the :py:class:`declare_modules_as_importable` context manager.", + version='6.7.2', +) def declare_deferred_modules_as_importable(globals_dict): """Make all :py:class:`DeferredImportModules` in ``globals_dict`` importable @@ -698,6 +856,7 @@ def declare_deferred_modules_as_importable(globals_dict): ... 'scipy', callback=_finalize_scipy, ... deferred_submodules=['stats', 'sparse', 'spatial', 'integrate']) >>> declare_deferred_modules_as_importable(globals()) + WARNING: DEPRECATED: ... Which enables users to use: @@ -712,20 +871,87 @@ def declare_deferred_modules_as_importable(globals_dict): :py:class:`ModuleUnavailable` instance. """ - _global_name = globals_dict['__name__'] + '.' - deferred = list( - (k, v) for k, v in globals_dict.items() if type(v) is DeferredImportModule - ) - while deferred: - name, mod = deferred.pop(0) - mod.__path__ = None - mod.__spec__ = None - sys.modules[_global_name + name] = mod - deferred.extend( - (name + '.' + k, v) - for k, v in mod.__dict__.items() - if type(v) is DeferredImportModule - ) + return declare_modules_as_importable(globals_dict).__exit__(None, None, None) + + +class declare_modules_as_importable(object): + """Make all :py:class:`ModuleType` and :py:class:`DeferredImportModules` + importable through the ``globals_dict`` context. + + This context manager will detect all modules imported into the + specified ``globals_dict`` environment (either directly or through + :py:func:`attempt_import`) and will make those modules importable + from the specified ``globals_dict`` context. It works by detecting + changes in the specified ``globals_dict`` dictionary and adding any new + modules or instances of :py:class:`DeferredImportModule` that it + finds (and any of their deferred submodules) to ``sys.modules`` so + that the modules can be imported through the ``globals_dict`` + namespace. + + For example, ``pyomo/common/dependencies.py`` declares: + + .. doctest:: + :hide: + + >>> from pyomo.common.dependencies import ( + ... attempt_import, _finalize_scipy, __dict__ as dep_globals, + ... declare_modules_as_importable, ) + >>> # Sphinx does not provide a proper globals() + >>> def globals(): return dep_globals + + .. doctest:: + + >>> with declare_modules_as_importable(globals()): + ... scipy, scipy_available = attempt_import( + ... 'scipy', callback=_finalize_scipy, + ... deferred_submodules=['stats', 'sparse', 'spatial', 'integrate']) + + Which enables users to use: + + .. doctest:: + + >>> import pyomo.common.dependencies.scipy.sparse as spa + + If the deferred import has not yet been triggered, then the + :py:class:`DeferredImportModule` is returned and named ``spa``. + However, if the import has already been triggered, then ``spa`` will + either be the ``scipy.sparse`` module, or a + :py:class:`ModuleUnavailable` instance. + + """ + + def __init__(self, globals_dict): + self.globals_dict = globals_dict + self.init_dict = {} + self.init_modules = None + + def __enter__(self): + self.init_dict.update(self.globals_dict) + self.init_modules = set(sys.modules) + + def __exit__(self, exc_type, exc_value, traceback): + _global_name = self.globals_dict['__name__'] + '.' + deferred = { + k: v + for k, v in self.globals_dict.items() + if k not in self.init_dict + and isinstance(v, (ModuleType, DeferredImportModule)) + } + if self.init_modules: + for name in set(sys.modules) - self.init_modules: + if '.' in name and name.split('.', 1)[0] in deferred: + sys.modules[_global_name + name] = sys.modules[name] + while deferred: + name, mod = deferred.popitem() + sys.modules[_global_name + name] = mod + if isinstance(mod, DeferredImportModule): + mod.__path__ = None + mod.__spec__ = None + deferred.update( + (name + '.' + k, v) + for k, v in mod.__dict__.items() + if type(v) is DeferredImportModule + ) # @@ -778,11 +1004,24 @@ def _finalize_matplotlib(module, available): if in_testing_environment(): module.use('Agg') import matplotlib.pyplot + import matplotlib.pylab + import matplotlib.backends + + +def _finalize_mpi4py(module, available): + if not available: + return + import mpi4py.MPI def _finalize_numpy(np, available): if not available: return + # scipy has a dependence on numpy.testing, and if we don't import it + # as part of resolving numpy, then certain deferred scipy imports + # fail when run under pytest. + import numpy.testing + from . import numeric_types # Register ndarray as a native type to prevent 1-element ndarrays @@ -807,10 +1046,13 @@ def _finalize_numpy(np, available): # registration here (to bypass the deprecation warning) until we # finally remove all support for it numeric_types._native_boolean_types.add(t) - _floats = [np.float_, np.float16, np.float32, np.float64] + _floats = [np.float16, np.float32, np.float64] # float96 and float128 may or may not be defined in this particular # numpy build (it depends on platform and version). # Register them only if they are present + if hasattr(np, 'float_'): + # Prepend to preserve previous functionality + _floats.insert(0, np.float_) if hasattr(np, 'float96'): _floats.append(np.float96) if hasattr(np, 'float128'): @@ -821,10 +1063,13 @@ def _finalize_numpy(np, available): # registration here (to bypass the deprecation warning) until we # finally remove all support for it numeric_types._native_boolean_types.add(t) - _complex = [np.complex_, np.complex64, np.complex128] + _complex = [np.complex64, np.complex128] # complex192 and complex256 may or may not be defined in this # particular numpy build (it depends on platform and version). # Register them only if they are present + if hasattr(np, 'np.complex_'): + # Prepend to preserve functionality + _complex.insert(0, np.complex_) if hasattr(np, 'complex192'): _complex.append(np.complex192) if hasattr(np, 'complex256'): @@ -842,41 +1087,50 @@ def _pyutilib_importer(): return importlib.import_module('pyutilib') -# Standard libraries that are slower to import and not strictly required -# on all platforms / situations. -ctypes, _ = attempt_import( - 'ctypes', deferred_submodules=['util'], callback=_finalize_ctypes -) -random, _ = attempt_import('random') - -# Commonly-used optional dependencies -dill, dill_available = attempt_import('dill') -mpi4py, mpi4py_available = attempt_import('mpi4py') -networkx, networkx_available = attempt_import('networkx') -numpy, numpy_available = attempt_import('numpy', callback=_finalize_numpy) -pandas, pandas_available = attempt_import('pandas') -plotly, plotly_available = attempt_import('plotly') -pympler, pympler_available = attempt_import('pympler', callback=_finalize_pympler) -pyutilib, pyutilib_available = attempt_import('pyutilib', importer=_pyutilib_importer) -scipy, scipy_available = attempt_import( - 'scipy', - callback=_finalize_scipy, - deferred_submodules=['stats', 'sparse', 'spatial', 'integrate'], -) -yaml, yaml_available = attempt_import('yaml', callback=_finalize_yaml) - -# Note that matplotlib.pyplot can generate a runtime error on OSX when -# not installed as a Framework (as is the case in the CI systems) -matplotlib, matplotlib_available = attempt_import( - 'matplotlib', - callback=_finalize_matplotlib, - deferred_submodules=['pyplot', 'pylab'], - catch_exceptions=(ImportError, RuntimeError), -) +with declare_modules_as_importable(globals()): + # Standard libraries that are slower to import and not strictly required + # on all platforms / situations. + ctypes, _ = attempt_import( + 'ctypes', deferred_submodules=['util'], callback=_finalize_ctypes + ) + random, _ = attempt_import('random') + + # Commonly-used optional dependencies + dill, dill_available = attempt_import('dill') + mpi4py, mpi4py_available = attempt_import( + 'mpi4py', deferred_submodules=['MPI'], callback=_finalize_mpi4py + ) + networkx, networkx_available = attempt_import('networkx') + numpy, numpy_available = attempt_import('numpy', callback=_finalize_numpy) + pandas, pandas_available = attempt_import('pandas') + pathlib, pathlib_available = attempt_import('pathlib') + pint, pint_available = attempt_import( + 'pint', + # TypeError for pint<=0.24.3 and python>=3.13 + catch_exceptions=(ImportError, TypeError), + ) + plotly, plotly_available = attempt_import('plotly') + pympler, pympler_available = attempt_import('pympler', callback=_finalize_pympler) + pyutilib, pyutilib_available = attempt_import( + 'pyutilib', importer=_pyutilib_importer + ) + scipy, scipy_available = attempt_import( + 'scipy', + callback=_finalize_scipy, + deferred_submodules=['stats', 'sparse', 'spatial', 'integrate'], + ) + yaml, yaml_available = attempt_import('yaml', callback=_finalize_yaml) + + # Note that matplotlib.pyplot can generate a runtime error on OSX when + # not installed as a Framework (as is the case in the CI systems) + matplotlib, matplotlib_available = attempt_import( + 'matplotlib', + callback=_finalize_matplotlib, + deferred_submodules=['pyplot', 'pylab', 'backends'], + catch_exceptions=(ImportError, RuntimeError), + ) try: import cPickle as pickle except ImportError: import pickle - -declare_deferred_modules_as_importable(globals()) diff --git a/pyomo/common/deprecation.py b/pyomo/common/deprecation.py index 5a6ca456079..1246de84b8c 100644 --- a/pyomo/common/deprecation.py +++ b/pyomo/common/deprecation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,13 +22,16 @@ import logging import functools +import importlib import inspect import itertools import sys import textwrap import types +import typing from pyomo.common.errors import DeveloperError +from pyomo.common.flags import NOTSET, in_testing_environment, building_documentation _doc_flag = '.. deprecated::' @@ -101,7 +104,7 @@ def _wrap_class(cls, msg, logger, version, remove_in): if msg is not None or _doc is None: _doc = _deprecation_docstring(cls, msg, version, remove_in) if cls.__doc__: - _doc = cls.__doc__ + '\n\n' + _doc + _doc = inspect.cleandoc(cls.__doc__) + '\n\n' + _doc cls.__doc__ = 'DEPRECATED.\n\n' + _doc if _flagIdx < 0: @@ -131,7 +134,7 @@ def wrapper(*args, **kwargs): return func(*args, **kwargs) wrapper.__doc__ = 'DEPRECATED.\n\n' - _doc = func.__doc__ or '' + _doc = inspect.cleandoc(func.__doc__ or '') if _doc: wrapper.__doc__ += _doc + '\n\n' wrapper.__doc__ += _deprecation_docstring(func, msg, version, remove_in) @@ -151,17 +154,6 @@ def _find_calling_frame(module_offset): return calling_frame -def in_testing_environment(): - """Return True if we are currently running in a "testing" environment - - This currently includes if nose, nose2, pytest, or Sphinx are - running (imported). - - """ - - return any(mod in sys.modules for mod in ('nose', 'nose2', 'pytest', 'sphinx')) - - def deprecation_warning( msg, logger=None, version=None, remove_in=None, calling_frame=None ): @@ -238,7 +230,11 @@ def deprecation_warning( logger.warning(msg) -if in_testing_environment(): +# We do not want to cache / suppress repeated warnings when we are +# testing or when we are building the documentation. Note that doctest +# doesn't set the "in_testing" flag until after pyomo.common is +# imported. +if in_testing_environment() or building_documentation(): deprecation_warning.emitted_warnings = None else: deprecation_warning.emitted_warnings = set() @@ -292,10 +288,8 @@ def wrap(obj): def _import_object(name, target, version, remove_in, msg): - from importlib import import_module - modname, targetname = target.rsplit('.', 1) - _object = getattr(import_module(modname), targetname) + _object = getattr(importlib.import_module(modname), targetname) if msg is None: if inspect.isclass(_object): _type = 'class' @@ -311,15 +305,27 @@ def _import_object(name, target, version, remove_in, msg): return _object +@deprecated( + "relocated_module() has been deprecated. Please use moved_module()", + version='6.8.1', +) def relocated_module(new_name, msg=None, logger=None, version=None, remove_in=None): """Provide a deprecation path for moved / renamed modules Upon import, the old module (that called `relocated_module()`) will - be replaced in `sys.modules` by an alias that points directly to the + be replaced in :data:`sys.modules` by an alias that points directly to the new module. As a result, the old module should have only two lines of executable Python code (the import of `relocated_module` and the call to it). + Note + ---- + This method (which was placed in the old module that is + being removed) is deprecated and should be replaced by calls to + :func:`moved_module()`, which can be called in any parent scope of + the removed module and does not require that the old module continue + to exist in the project. + Parameters ---------- new_name: str @@ -345,17 +351,15 @@ def relocated_module(new_name, msg=None, logger=None, version=None, remove_in=No ------- >>> from pyomo.common.deprecation import relocated_module >>> relocated_module('pyomo.common.deprecation', version='1.2.3') - WARNING: DEPRECATED: The '...' module has been moved to + WARNING: DEPRECATED: ... The '...' module has been moved to 'pyomo.common.deprecation'. Please update your import. (deprecated in 1.2.3) ... """ - from importlib import import_module - - new_module = import_module(new_name) + new_module = importlib.import_module(new_name) # The relevant module (the one being deprecated) is the one that - # holds the function/method that called deprecated_module(). The + # holds the function/method that called relocated_module(). The # relevant calling frame for the deprecation warning is the first # frame in the stack that doesn't look like the importer (i.e., the # thing that imported the deprecated module). @@ -542,7 +546,7 @@ def __renamed__warning__(msg): if new_class is None and '__renamed__new_class__' not in classdict: if not any( - hasattr(base, '__renamed__new_class__') + hasattr(mro, '__renamed__new_class__') for mro in itertools.chain.from_iterable( base.__mro__ for base in renamed_bases ) @@ -574,3 +578,170 @@ def __subclasscheck__(cls, subclass): return issubclass(subclass, getattr(cls, '__renamed__new_class__')) else: return super().__subclasscheck__(subclass) + + +class MovedModuleLoader: + """Custom module loader that supports loading modules through alternate names + + This class implements the :class:`importlib.abc.Loader` interface + (through duck-typing to avoid a surprisingly costly import of + :mod:`importlib.abc`). Calls to :meth:`create_module()` and + :meth:`exec_module()` are delegated to corresponding methods on the + loader for the new module name. + + """ + + def __init__(self, info): + self._info = info + + def create_module(self, spec) -> types.ModuleType: + msg = self._info.msg + if msg is NOTSET: + msg = ( + f"The '{spec.name}' module has been moved to '{self._info.new_name}'. " + 'Please update your import.' + ) + if msg is not None: + deprecation_warning( + msg, self._info.logger, self._info.version, self._info.remove_in + ) + if self._info.new_name in sys.modules: + return sys.modules[self._info.new_name] + return importlib.import_module(self._info.new_name) + + def exec_module(self, module: types.ModuleType) -> None: + pass + + +class MovedModuleFinder: + """Custom finder that supports loading a module through an alternative name. + + This class implements the :class:`importlib.abc.Finder` interface + (through duck-typing to avoid a surprisingly costly import of + :mod:`importlib.abc`). + + Pyomo automatically registers a single instance of this finder with + :mod:`importlib` by appending it to the end of the + :data:`sys.meta_path` list when this module is imported. + Subsequent calls to :func:`moved_module` register the association + between the old and new module names with the ``mapping`` class + attribute. + + """ + + mapping = {} + ":class:`dict` that maps (removed) module names to :class:`MovedModuleInfo` objects" + + def find_spec(self, fullname, path, target=None): + if fullname not in self.mapping: + return None + + info = MovedModuleFinder.mapping[fullname] + src_spec = importlib.util.find_spec(info.new_name) + return importlib.machinery.ModuleSpec( + name=fullname, + loader=MovedModuleLoader(info), + origin=getattr(src_spec, 'origin', None), + ) + + def invalidate_caches(self): + pass + + +# Insert the MovedModuleFinder at the end of the sys.meta_path. This +# way, it has no impact on the performance of importing "normal" +# (present) modules, and instead is called as a "last-chance" finder +# before Python would raise an ImportError +sys.meta_path.append(MovedModuleFinder()) + + +MovedModuleInfo = typing.NamedTuple( + 'MovedModuleInfo', + [ + ('old_name', str), + ('new_name', str), + ('msg', str), + ('logger', str), + ('version', str), + ('remove_in', str), + ], +) + + +def moved_module( + old_name, new_name, msg=NOTSET, logger=None, version=None, remove_in=None +): + """Provide a deprecation path for moved / renamed modules + + This function hooks into the Python :mod:`importlib` to cause any + import of the ``old_name`` to instead import and return the module + from ``new_name``. The new module is automatically registered with + :data:`sys.modules` under both the old and new names. + + Because :func:`moved_module()` works through the Python + :mod:`importlib` system, the old module file can be completely + deleted (in contrast to the [deprecated] :func:`relocated_module()` + function). Calls to :func:`moved_module` should be placed in any + package above the removed module in the package hierarchy (or in any + other location that is guaranteeded to be imported / executed before + any attempts at importng the module through its old name. + + Any import of the module through the old name will emit a + deprecation warning unless the ``msg`` is ``None`` (see also + :func:`deprecated`). + + Parameters + ---------- + old_name: str + The original (fully-qualified) module name (that has been removed) + + new_name: str + The new (fully-qualified) module name + + msg: str + A custom deprecation message. If None, the deprecation message + will be suppressed. If NOTSET (default), a generic deprecation + message will be logged. + + logger: str + The logger to use for emitting the warning (default: the calling + pyomo package, or "pyomo") + + version: str [required] + The version in which the module was renamed or moved. General + practice is to set version to the current development version + (from `pyomo --version`) during development and update it to the + actual release as part of the release process. + + remove_in: str + The version in which the module will be removed from the code. + + Example + ------- + >>> from pyomo.common.deprecation import moved_module + >>> moved_module( + ... 'pyomo.common.old_deprecation', + ... 'pyomo.common.deprecation', + ... version='1.2.3', + ... ) + >>> import pyomo.common.old_deprecation + WARNING: DEPRECATED: The 'pyomo.common.old_deprecation' module has + been moved to 'pyomo.common.deprecation'. Please update your import. + (deprecated in 1.2.3) ... + >>> import pyomo.common.deprecation + >>> pyomo.common.old_deprecation is pyomo.common.deprecation + True + + """ + if old_name in MovedModuleFinder.mapping: + _current = MovedModuleFinder.mapping[old_name].new_name + if new_name == _current: + return + raise RuntimeError( + "Duplicate module alias declaration.\n" + f"\toriginal: {old_name} -> {_current}\n" + f"\tconflict: {old_name} -> {new_name}\n" + ) + MovedModuleFinder.mapping[old_name] = MovedModuleInfo( + old_name, new_name, msg, logger, version, remove_in + ) diff --git a/pyomo/common/download.py b/pyomo/common/download.py index 5332287cfc7..4fc26deb102 100644 --- a/pyomo/common/download.py +++ b/pyomo/common/download.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -29,6 +29,7 @@ urllib_error = attempt_import('urllib.error')[0] ssl = attempt_import('ssl')[0] zipfile = attempt_import('zipfile')[0] +tarfile = attempt_import('tarfile')[0] gzip = attempt_import('gzip')[0] distro, distro_available = attempt_import('distro') @@ -176,6 +177,7 @@ def get_os_version(cls, normalize=True): This method was designed to help identify compatible binaries, and will return strings similar to: + - rhel6 - fedora24 - ubuntu18.04 @@ -371,7 +373,7 @@ def get_zip_archive(self, url, dirOffset=0): # Simple sanity checks for info in zip_file.infolist(): f = info.filename - if f[0] in '\\/' or '..' in f: + if f[0] in '\\/' or '..' in f or os.path.isabs(f): logger.error( "malformed (potentially insecure) filename (%s) " "found in zip archive. Skipping file." % (f,) @@ -387,6 +389,61 @@ def get_zip_archive(self, url, dirOffset=0): info.filename = target[-1] + '/' if f[-1] == '/' else target[-1] zip_file.extract(f, os.path.join(self._fname, *tuple(target[dirOffset:-1]))) + def get_tar_archive(self, url, dirOffset=0): + if self._fname is None: + raise DeveloperError( + "target file name has not been initialized " + "with set_destination_filename" + ) + if os.path.exists(self._fname) and not os.path.isdir(self._fname): + raise RuntimeError( + "Target directory (%s) exists, but is not a directory" % (self._fname,) + ) + + def filter_fcn(info): + # this mocks up the `tarfile` filter introduced in Python + # 3.12 and backported to later releases of Python (e.g., + # 3.8.17, 3.9.17, 3.10.12, and 3.11.4) + f = info.name + if os.path.isabs(f) or '..' in f or f.startswith(('/', os.sep)): + logger.error( + "malformed or potentially insecure filename (%s). " + "Skipping file." % (f,) + ) + return False + target = self._splitpath(f) + if len(target) <= dirOffset: + if not info.isdir(): + logger.warning( + "Skipping file (%s) in tar archive due to dirOffset." % (f,) + ) + return False + info.name = f = '/'.join(target[dirOffset:]) + target = os.path.realpath(os.path.join(dest, f)) + try: + if os.path.commonpath([target, dest]) != dest: + logger.error( + "potentially insecure filename (%s) resolves outside target " + "directory. Skipping file." % (f,) + ) + return False + except ValueError: + # commonpath() will raise ValueError for paths that + # don't have anything in common (notably, when files are + # on different drives on Windows) + logger.error( + "potentially insecure filename (%s) resolves outside target " + "directory. Skipping file." % (f,) + ) + return False + # Strip high bits & group/other write bits + info.mode &= 0o755 + return True + + with tarfile.open(fileobj=io.BytesIO(self.retrieve_url(url))) as TAR: + dest = os.path.realpath(self._fname) + TAR.extractall(dest, filter(filter_fcn, TAR.getmembers())) + def get_gzipped_binary_file(self, url): if self._fname is None: raise DeveloperError( diff --git a/pyomo/common/enums.py b/pyomo/common/enums.py new file mode 100644 index 00000000000..0c90d358d65 --- /dev/null +++ b/pyomo/common/enums.py @@ -0,0 +1,217 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +"""This module provides standard :py:class:`enum.Enum` definitions used in +Pyomo, along with additional utilities for working with custom Enums + +Utilities: + +.. autosummary:: + + ExtendedEnumType + NamedIntEnum + +Standard Enums: + +.. autosummary:: + + ObjectiveSense + +""" + +import enum +import itertools +import re +import sys + +# A local alias for enum.Enum so that clients can use +# :py:mod:`pyomo.common.enums` like they would :py:mod:`enum`. +Enum = enum.Enum + +if sys.version_info[:2] < (3, 11): + _EnumType = enum.EnumMeta +else: + _EnumType = enum.EnumType +if sys.version_info[:2] < (3, 13): + # prior to 3.13 the int.{to,from}_bytes docstrings had LaTeX-like + # "`..'" quotations, which Sphinx can't parse correctly. + def _fix_doc(ref): + def _rewrite(func): + func.__doc__ = re.sub(r"`(\S+)'", r"`\1`", ref.__doc__) + return func + + return _rewrite + + class IntEnum(enum.IntEnum): + __doc__ = ( + """A compatibility wrapper around :class:`enum.IntEnum` + + This wrapper class updates the :meth:`to_bytes` and + :meth:`from_bytes` docstrings in Python <= 3.12 to suppress + warnings generated by Sphinx. + + .. rubric:: IntEnum + + """ + + enum.IntEnum.__doc__ + ) + + @_fix_doc(enum.IntEnum.to_bytes) + def to_bytes(self, /, length=1, byteorder='big', *, signed=False): + return super().to_bytes(length=length, byteorder=byteorder, signed=signed) + + # Note: we need to use a decorator to set the __doc__ *before* + # the @classmethod (which makes __doc__ read-only). + @classmethod + @_fix_doc(enum.IntEnum.from_bytes) + def from_bytes(cls, bytes, byteorder='big', *, signed=False): + return super()(bytes, byteorder=byteorder, signed=signed) + +else: + IntEnum = enum.IntEnum + + +class ExtendedEnumType(_EnumType): + """Metaclass for creating an :py:class:`enum.Enum` that extends another Enum + + In general, :py:class:`enum.Enum` classes are not extensible: that is, + they are frozen when defined and cannot be the base class of another + Enum. This Metaclass provides a workaround for creating a new Enum + that extends an existing enum. Members in the base Enum are all + present as members on the extended enum. + + Example + ------- + + .. testcode:: + :hide: + + import enum + from pyomo.common.enums import ExtendedEnumType + + .. testcode:: + + class ObjectiveSense(enum.IntEnum): + minimize = 1 + maximize = -1 + + class ProblemSense(enum.IntEnum, metaclass=ExtendedEnumType): + __base_enum__ = ObjectiveSense + + unknown = 0 + + .. doctest:: + + >>> list(ProblemSense) + [, , ] + >>> ProblemSense.unknown + + >>> ProblemSense.maximize + + >>> ProblemSense(0) + + >>> ProblemSense(1) + + >>> ProblemSense('unknown') + + >>> ProblemSense('maximize') + + >>> hasattr(ProblemSense, 'minimize') + True + >>> ProblemSense.minimize is ObjectiveSense.minimize + True + >>> ProblemSense.minimize in ProblemSense + True + + """ + + def __getattr__(cls, attr): + try: + return getattr(cls.__base_enum__, attr) + except: + return super().__getattr__(attr) + + def __iter__(cls): + # The members of this Enum are the base enum members joined with + # the local members + return itertools.chain(super().__iter__(), cls.__base_enum__.__iter__()) + + def __contains__(cls, member): + # This enum "contains" both its local members and the members in + # the __base_enum__ (necessary for good auto-enum[sphinx] docs) + return super().__contains__(member) or member in cls.__base_enum__ + + def __dir__(self): + _dir = set(super().__dir__()) + _dir.update(e.name for e in self) + return _dir + + def __instancecheck__(cls, instance): + if cls.__subclasscheck__(type(instance)): + return True + # Also pretend that members of the extended enum are subclasses + # of the __base_enum__. This is needed to circumvent error + # checking in enum.__new__ (e.g., for `ProblemSense('minimize')`) + return cls.__base_enum__.__subclasscheck__(type(instance)) + + def _missing_(cls, value): + # Support attribute lookup by value or name + for attr in ('value', 'name'): + for member in cls: + if getattr(member, attr) == value: + return member + return None + + def __new__(metacls, cls, bases, classdict, **kwds): + # Support lookup by name - but only if the new Enum doesn't + # specify its own implementation of _missing_ + if '_missing_' not in classdict: + classdict['_missing_'] = classmethod(ExtendedEnumType._missing_) + return super().__new__(metacls, cls, bases, classdict, **kwds) + + +class NamedIntEnum(IntEnum): + """An extended version of :py:class:`~pyomo.common.enums.IntEnum` that supports + creating members by name as well as value. + + """ + + @classmethod + def _missing_(cls, value): + for member in cls: + if member.name == value: + return member + return None + + +class ObjectiveSense(NamedIntEnum): + """Flag indicating if an objective is minimizing (1) or maximizing (-1). + + While the numeric values are arbitrary, there are parts of Pyomo + that rely on this particular choice of value. These values are also + consistent with some solvers (notably Gurobi). + + """ + + minimize = 1 + maximize = -1 + + # Overloading __str__ is needed to match the behavior of the old + # pyutilib.enum class (removed June 2020). There are spots in the + # code base that expect the string representation for items in the + # enum to not include the class name. New uses of enum shouldn't + # need to do this. + def __str__(self): + return self.name + + +minimize = ObjectiveSense.minimize +maximize = ObjectiveSense.maximize diff --git a/pyomo/common/env.py b/pyomo/common/env.py index ee07cdc1e6a..d372b061e51 100644 --- a/pyomo/common/env.py +++ b/pyomo/common/env.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -405,7 +405,6 @@ class CtypesEnviron(object): :hide: import os - from pyomo.common.env import TemporaryEnv orig_env_val = os.environ.get('TEMP_ENV_VAR', None) .. doctest:: @@ -415,7 +414,7 @@ class CtypesEnviron(object): original value >>> with CtypesEnviron(TEMP_ENV_VAR='temporary value'): - ... print(os.envion['TEMP_ENV_VAR']) + ... print(os.environ['TEMP_ENV_VAR']) temporary value >>> print(os.environ['TEMP_ENV_VAR']) diff --git a/pyomo/common/envvar.py b/pyomo/common/envvar.py index 1f933d4b08c..36ed5910781 100644 --- a/pyomo/common/envvar.py +++ b/pyomo/common/envvar.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/errors.py b/pyomo/common/errors.py index 3c82f2b07c1..58139fbd5eb 100644 --- a/pyomo/common/errors.py +++ b/pyomo/common/errors.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/extensions.py b/pyomo/common/extensions.py index 0ac27f125a7..7ccb81d165c 100644 --- a/pyomo/common/extensions.py +++ b/pyomo/common/extensions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/factory.py b/pyomo/common/factory.py index c449cf826b4..e1cb8543f74 100644 --- a/pyomo/common/factory.py +++ b/pyomo/common/factory.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/fileutils.py b/pyomo/common/fileutils.py index 2cade36154d..4a0b2beb36b 100644 --- a/pyomo/common/fileutils.py +++ b/pyomo/common/fileutils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -38,6 +38,7 @@ import os import platform import importlib.util +import subprocess import sys from . import envvar @@ -285,10 +286,17 @@ def find_dir( ) -_exeExt = {'linux': None, 'windows': '.exe', 'cygwin': '.exe', 'darwin': None} +_exeExt = { + 'linux': None, + 'freebsd': None, + 'windows': '.exe', + 'cygwin': '.exe', + 'darwin': None, +} _libExt = { 'linux': ('.so', '.so.*'), + 'freebsd': ('.so', '.so.*'), 'windows': ('.dll', '.pyd'), 'cygwin': ('.dll', '.so', '.so.*'), 'darwin': ('.dylib', '.so', '.so.*'), @@ -375,9 +383,27 @@ def find_library(libname, cwd=True, include_PATH=True, pathlist=None): if libname_base.startswith('lib') and _system() != 'windows': libname_base = libname_base[3:] if ext.lower().startswith(('.so', '.dll', '.dylib')): - return ctypes.util.find_library(libname_base) + lib = ctypes.util.find_library(libname_base) else: - return ctypes.util.find_library(libname) + lib = ctypes.util.find_library(libname) + if lib and os.path.sep not in lib: + # work around https://github.com/python/cpython/issues/65241, + # where python does not return the absolute path on *nix + try: + libname = lib + ' ' + with subprocess.Popen( + ['/sbin/ldconfig', '-p'], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + env={'LC_ALL': 'C', 'LANG': 'C'}, + ) as p: + for line in os.fsdecode(p.stdout.read()).splitlines(): + if line.lstrip().startswith(libname): + return os.path.realpath(line.split()[-1]) + except: + pass + return lib def find_executable(exename, cwd=True, include_PATH=True, pathlist=None): @@ -687,7 +713,7 @@ class PathManager(object): The ``Executable`` singleton uses :py:class:`ExecutableData`, an extended form of the :py:class:`PathData` class, which provides the - ``executable`` property as an alais for :py:meth:`path()` and + ``executable`` property as an alias for :py:meth:`path()` and :py:meth:`set_path()`: .. doctest:: diff --git a/pyomo/common/flags.py b/pyomo/common/flags.py new file mode 100644 index 00000000000..d589a20dbf0 --- /dev/null +++ b/pyomo/common/flags.py @@ -0,0 +1,139 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import inspect +import sys + + +class FlagType(type): + """Metaclass to help generate "Flag Types". + + This is useful for defining "flag types" that are default arguments + in functions so that the Sphinx-generated documentation is + "cleaner". These types are not constructable (attempts to construct + the class return the class) and simplify the repr(type) and + str(type). + + This metaclass redefines the ``str()`` and ``repr()`` of resulting + classes. The str() of the class returns only the class' ``__name__``, + whereas the repr() returns either the qualified class name + (``__qualname__``) if Sphinx has been imported, or else the + fully-qualified class name (``__module__ + '.' + __qualname__``). + + """ + + def __new__(mcs, name, bases, dct): + # Ensure that attempts to construct instances of a Flag type + # return the type. + def __new_flag__(cls, *args, **kwargs): + return cls + + dct["__new__"] = __new_flag__ + return type.__new__(mcs, name, bases, dct) + + def __repr__(cls): + if building_documentation(): + return cls.__qualname__ + else: + return cls.__module__ + "." + cls.__qualname__ + + def __str__(cls): + return cls.__name__ + + +class NOTSET(object, metaclass=FlagType): + """ + Class to be used to indicate that an optional argument + was not specified, if `None` may be ambiguous. Usage: + + Examples + -------- + >>> def foo(value=NOTSET): + ... if value is NOTSET: + ... pass # no argument was provided to `value` + + """ + + pass + + +def in_testing_environment(state=NOTSET): + """Return True if we are currently running in a "testing" environment + + This currently includes if ``nose``, ``nose2``, or ``pytest`` are + running (imported). + + Parameters + ---------- + state : bool or None + If provided, sets the current state of the testing environment + (Setting to None reverts to the normal interrogation of + ``sys.modules``) + + Returns + ------- + bool + + """ + if state is not NOTSET: + in_testing_environment.state = state + if in_testing_environment.state is not None: + return bool(in_testing_environment.state) + return any(mod in sys.modules for mod in ('nose', 'nose2', 'pytest')) + + +in_testing_environment.state = None + + +def building_documentation(state=NOTSET): + """True if we are building the Sphinx documentation + + We detect if we are building the documentation by looking if the + ``sphinx`` or ``Sphinx`` modules are imported. + + Parameters + ---------- + state : bool or None + If provided, sets the current state of the building environment + flag (Setting to None reverts to the normal interrogation of + ``sys.modules``) + + Returns + ------- + bool + + """ + if state is not NOTSET: + building_documentation.state = state + if building_documentation.state is not None: + return bool(building_documentation.state) + return 'sphinx' in sys.modules or 'Sphinx' in sys.modules + + +building_documentation.state = None + + +def serializing(): + """True if it looks like we are serializing objects + + This looks through the call stack and returns True if it finds a + `dump` function anywhere in the call stack. While not foolproof, + this should reliably catch most serializers, including ``pickle`` + and `yaml``. + + """ + # Start by skipping this function + frame = inspect.currentframe().f_back + while frame is not None: + if frame.f_code.co_name == 'dump': + return True + frame = frame.f_back + return False diff --git a/pyomo/common/formatting.py b/pyomo/common/formatting.py index 430ec96ca09..71798565417 100644 --- a/pyomo/common/formatting.py +++ b/pyomo/common/formatting.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/gc_manager.py b/pyomo/common/gc_manager.py index 751eb95cf18..5745fe1a97e 100644 --- a/pyomo/common/gc_manager.py +++ b/pyomo/common/gc_manager.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/gsl.py b/pyomo/common/gsl.py index 1c14b64bd70..fde7eeded79 100644 --- a/pyomo/common/gsl.py +++ b/pyomo/common/gsl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,8 +23,8 @@ ) def get_gsl(downloader): logger.info( - "As of February 9, 2023, AMPL GSL can no longer be downloaded\ - through download-extensions. Visit https://portal.ampl.com/\ + "As of February 9, 2023, AMPL GSL can no longer be downloaded \ + through download-extensions. Visit https://portal.ampl.com/ \ to download the AMPL GSL binaries." ) diff --git a/pyomo/common/log.py b/pyomo/common/log.py index d61ed62f373..b8275290839 100644 --- a/pyomo/common/log.py +++ b/pyomo/common/log.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,6 +21,7 @@ import inspect import io import logging +import os import re import sys import textwrap @@ -28,21 +29,21 @@ from pyomo.version.info import releaselevel from pyomo.common.deprecation import deprecated from pyomo.common.fileutils import PYOMO_ROOT_DIR +from pyomo.common.flags import in_testing_environment, building_documentation from pyomo.common.formatting import wrap_reStructuredText _indentation_re = re.compile(r'\s*') -_RTD_URL = "https://pyomo.readthedocs.io/en/%s/errors.html" % ( - 'stable' - if (releaselevel == 'final' or 'sphinx' in sys.modules or 'Sphinx' in sys.modules) - else 'latest' -) +_RTD_URL = "https://pyomo.readthedocs.io/en/%s/errors.html" def RTD(_id): _id = str(_id).lower() + _release = ( + 'stable' if releaselevel == 'final' or in_testing_environment() else 'latest' + ) assert _id[0] in 'wex' - return f"{_RTD_URL}#{_id}" + return (_RTD_URL % (_release,)) + f"#{_id}" _DEBUG = logging.DEBUG @@ -234,7 +235,12 @@ def __init__(self): self.logger = logging.getLogger() def filter(self, record): - return not self.logger.handlers + # We will not emit messages using the default Pyomo log handler + # if someone has registered a global handler. However, we will + # ignore this if we are building documentation + # (sphinx.ext.doctest adds a handler, but we want to ignore that + # handler when we are testing our documentation!) + return not self.logger.handlers or building_documentation() # This mocks up the historical Pyomo logging system, which uses a @@ -267,7 +273,7 @@ def __init__(self, base='', stream=None, level=logging.NOTSET, verbosity=None): class LoggingIntercept(object): - """Context manager for intercepting messages sent to a log stream + r"""Context manager for intercepting messages sent to a log stream This class is designed to enable easy testing of log messages. @@ -281,28 +287,52 @@ class LoggingIntercept(object): ---------- output: io.TextIOBase the file stream to send log messages to + module: str - the target logger name to intercept + the target logger name to intercept. `logger` and `module` are + mutually exclusive. + level: int the logging level to intercept + formatter: logging.Formatter the formatter to use when rendering the log messages. If not specified, uses `'%(message)s'` - Examples: - >>> import io, logging - >>> from pyomo.common.log import LoggingIntercept - >>> buf = io.StringIO() - >>> with LoggingIntercept(buf, 'pyomo.core', logging.WARNING): - ... logging.getLogger('pyomo.core').warning('a simple message') - >>> buf.getvalue() + logger: logging.Logger + the target logger to intercept. `logger` and `module` are + mutually exclusive. + + Examples + -------- + >>> import io, logging + >>> from pyomo.common.log import LoggingIntercept + >>> buf = io.StringIO() + >>> with LoggingIntercept(buf, 'pyomo.core', logging.WARNING): + ... logging.getLogger('pyomo.core').warning('a simple message') + >>> buf.getvalue() + 'a simple message\n' """ - def __init__(self, output=None, module=None, level=logging.WARNING, formatter=None): + def __init__( + self, + output=None, + module=None, + level=logging.WARNING, + formatter=None, + logger=None, + ): self.handler = None self.output = output - self.module = module + if logger is not None: + if module is not None: + raise ValueError( + "LoggingIntercept: only one of 'module' and 'logger' is allowed" + ) + self._logger = logger + else: + self._logger = logging.getLogger(module) self._level = level if formatter is None: formatter = logging.Formatter('%(message)s') @@ -310,6 +340,11 @@ def __init__(self, output=None, module=None, level=logging.WARNING, formatter=No self._save = None def __enter__(self): + # Get the logger for the scope we will be overriding + logger = self._logger + self._save = logger.level, logger.propagate, logger.handlers + if self._level is None: + self._level = logger.getEffectiveLevel() # Set up the handler output = self.output if output is None: @@ -319,22 +354,24 @@ def __enter__(self): self.handler.setFormatter(self._formatter) self.handler.setLevel(self._level) # Register the handler with the appropriate module scope - logger = logging.getLogger(self.module) - self._save = logger.level, logger.propagate, logger.handlers logger.handlers = [] - logger.propagate = 0 + logger.propagate = False logger.setLevel(self.handler.level) logger.addHandler(self.handler) return output def __exit__(self, et, ev, tb): - logger = logging.getLogger(self.module) + logger = self._logger logger.removeHandler(self.handler) self.handler = None logger.setLevel(self._save[0]) logger.propagate = self._save[1] - for h in self._save[2]: - logger.handlers.append(h) + assert not logger.handlers + logger.handlers.extend(self._save[2]) + + @property + def module(self): + return self._logger.name class LogStream(io.TextIOBase): @@ -350,6 +387,8 @@ def __init__(self, level, logger): self._buffer = '' def write(self, s: str) -> int: + if not s: + return 0 res = len(s) if self._buffer: s = self._buffer + s @@ -362,3 +401,90 @@ def write(self, s: str) -> int: def flush(self): if self._buffer: self.write('\n') + + def redirect_streams(self, redirects): + """Redirect StreamHandler objects to the original file descriptors + + This utility method for py:class:`~pyomo.common.tee.capture_output` + locates any StreamHandlers that would process messages from the + logger assigned to this :py:class:`LogStream` that would write + to the file descriptors redirected by `capture_output` and + yields context managers that will redirect those StreamHandlers + back to duplicates of the original file descriptors. + + """ + found = 0 + logger = self._logger + while isinstance(logger, logging.LoggerAdapter): + logger = logger.logger + while logger: + for handler in logger.handlers: + found += 1 + if not isinstance(handler, logging.StreamHandler): + continue + try: + fd = handler.stream.fileno() + except (AttributeError, OSError): + fd = None + if fd not in redirects: + continue + yield _StreamRedirector(handler, redirects[fd].original_fd) + if not logger.propagate: + break + else: + logger = logger.parent + if not found: + fd = logging.lastResort.stream.fileno() + if not redirects: + yield _LastResortRedirector(fd) + elif fd in redirects: + yield _LastResortRedirector(redirects[fd].original_fd) + + +class _StreamRedirector(object): + def __init__(self, handler, fd): + self.handler = handler + self.fd = fd + self.orig_stream = None + + def __enter__(self): + self.orig_stream = self.handler.stream + # Note: ideally, we would use closefd=True and let Python handle + # closing the local file descriptor that we are about to create. + # However, it appears that closefd is ignored on Windows (see + # #3587), so we will just handle it explicitly ourselves. + self.handler.stream = os.fdopen( + os.dup(self.fd), mode="w", closefd=False + ).__enter__() + + def __exit__(self, et, ev, tb): + try: + fd = self.handler.stream.fileno() + self.handler.stream.__exit__(et, ev, tb) + os.close(fd) + finally: + self.handler.stream = self.orig_stream + + +class _LastResortRedirector(object): + def __init__(self, fd): + self.fd = fd + self.orig_stream = None + + def __enter__(self): + self.orig = logging.lastResort + # Note: ideally, we would use closefd=True and let Python handle + # closing the local file descriptor that we are about to create. + # However, it appears that closefd is ignored on Windows (see + # #3587), so we will just handle it explicitly ourselves. + logging.lastResort = logging.StreamHandler( + os.fdopen(os.dup(self.fd), mode="w", closefd=False).__enter__() + ) + + def __exit__(self, et, ev, tb): + try: + fd = logging.lastResort.stream.fileno() + logging.lastResort.stream.close() + os.close(fd) + finally: + logging.lastResort = self.orig diff --git a/pyomo/common/modeling.py b/pyomo/common/modeling.py index 4c07048d77a..d9901c8525d 100644 --- a/pyomo/common/modeling.py +++ b/pyomo/common/modeling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,14 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import sys from .dependencies import random +# [Aug 24] Importing for backwards compatibility; may deprecate this import later +from .flags import FlagType, NOTSET + +# Backward compatibility with the previous name for this flag +NoArgumentGiven = NOTSET + def randint(a, b): """Our implementation of random.randint. @@ -35,49 +40,3 @@ def unique_component_name(instance, name): return name else: name += str(randint(0, 9)) - - -class FlagType(type): - """Metaclass to simplify the repr(type) and str(type) - - This metaclass redefines the ``str()`` and ``repr()`` of resulting - classes. The str() of the class returns only the class' ``__name__``, - whereas the repr() returns either the qualified class name - (``__qualname__``) if Sphinx has been imported, or else the - fully-qualified class name (``__module__ + '.' + __qualname__``). - - This is useful for defining "flag types" that are default arguments - in functions so that the Sphinx-generated documentation is "cleaner" - - """ - - if 'sphinx' in sys.modules or 'Sphinx' in sys.modules: - - def __repr__(cls): - return cls.__qualname__ - - else: - - def __repr__(cls): - return cls.__module__ + "." + cls.__qualname__ - - def __str__(cls): - return cls.__name__ - - -class NOTSET(object, metaclass=FlagType): - """ - Class to be used to indicate that an optional argument - was not specified, if `None` may be ambiguous. Usage: - - >>> def foo(value=NOTSET): - >>> if value is NOTSET: - >>> pass # no argument was provided to `value` - - """ - - pass - - -# Backward compatibility with the previous name for this flag -NoArgumentGiven = NOTSET diff --git a/pyomo/common/multithread.py b/pyomo/common/multithread.py index a2dace2be0f..b029029d1f3 100644 --- a/pyomo/common/multithread.py +++ b/pyomo/common/multithread.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/numeric_types.py b/pyomo/common/numeric_types.py index ba104203667..96fd228c43f 100644 --- a/pyomo/common/numeric_types.py +++ b/pyomo/common/numeric_types.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,6 @@ import logging import sys -from pyomo.common.dependencies import numpy_available from pyomo.common.deprecation import deprecated, relocated_module_attribute from pyomo.common.errors import TemplateExpressionError @@ -44,13 +43,12 @@ #: like numpy, which may be registered by users. #: #: Note that :data:`native_numeric_types` does NOT include -#: :py:`complex`, as that is not a valid constant in Pyomo numeric +#: :py:class:`complex`, as that is not a valid constant in Pyomo numeric #: expressions. native_numeric_types = {int, float} native_integer_types = {int} native_logical_types = {bool} native_complex_types = {complex} -pyomo_constant_types = set() # includes NumericConstant _native_boolean_types = {int, bool, str, bytes} relocated_module_attribute( @@ -62,6 +60,16 @@ "be treated as if they were bool (as was the case for the other " "native_*_types sets). Users likely should use native_logical_types.", ) +_pyomo_constant_types = set() # includes NumericConstant, _PythonCallbackFunctionID +relocated_module_attribute( + 'pyomo_constant_types', + 'pyomo.common.numeric_types._pyomo_constant_types', + version='6.7.2', + msg="The pyomo_constant_types set will be removed in the future: the set " + "contained only NumericConstant and _PythonCallbackFunctionID, and provided " + "no meaningful value to clients or walkers. Users should likely handle " + "these types in the same manner as immutable Params.", +) #: Python set used to identify numeric constants and related native @@ -90,8 +98,8 @@ def RegisterNumericType(new_type: type): Parameters ---------- - new_type: type - The new numeric type (e.g, numpy.float64) + new_type : type + The new numeric type (e.g, `numpy.float64`) """ native_numeric_types.add(new_type) @@ -114,8 +122,8 @@ def RegisterIntegerType(new_type: type): Parameters ---------- - new_type: type - The new integer type (e.g, numpy.int64) + new_type : type + The new integer type (e.g, `numpy.int64`) """ native_numeric_types.add(new_type) @@ -141,8 +149,8 @@ def RegisterBooleanType(new_type: type): Parameters ---------- - new_type: type - The new logical type (e.g, numpy.bool_) + new_type : type + The new logical type (e.g, `numpy.bool_`) """ _native_boolean_types.add(new_type) @@ -163,8 +171,8 @@ def RegisterComplexType(new_type: type): Parameters ---------- - new_type: type - The new complex type (e.g, numpy.complex128) + new_type : type + The new complex type (e.g, `numpy.complex128`) """ native_types.add(new_type) @@ -184,8 +192,8 @@ def RegisterLogicalType(new_type: type): Parameters ---------- - new_type: type - The new logical type (e.g, numpy.bool_) + new_type : type + The new logical type (e.g, `numpy.bool_`) """ _native_boolean_types.add(new_type) @@ -194,6 +202,67 @@ def RegisterLogicalType(new_type: type): nonpyomo_leaf_types.add(new_type) +def check_if_native_type(obj): + if isinstance(obj, (str, bytes)): + native_types.add(obj.__class__) + return True + if check_if_logical_type(obj): + return True + if check_if_numeric_type(obj): + return True + return False + + +def check_if_logical_type(obj): + """Test if the argument behaves like a logical type. + + We check for "logical types" by checking if the type returns sane + results for Boolean operators (``^``, ``|``, ``&``) and if it maps + ``1`` and ``2`` both to the same equivalent instance. If that + works, then we register the type in :py:attr:`native_logical_types`. + + """ + obj_class = obj.__class__ + # Do not re-evaluate known native types + if obj_class in native_types: + return obj_class in native_logical_types + + try: + # It is not an error if you can't initialize the type from an + # int, but if you can, it should map !0 to True + if obj_class(1) != obj_class(2): + return False + except: + pass + + try: + # Native logical types *must* be hashable + hash(obj) + # Native logical types must honor standard Boolean operators + if all( + ( + obj_class(False) != obj_class(True), + obj_class(False) ^ obj_class(False) == obj_class(False), + obj_class(False) ^ obj_class(True) == obj_class(True), + obj_class(True) ^ obj_class(False) == obj_class(True), + obj_class(True) ^ obj_class(True) == obj_class(False), + obj_class(False) | obj_class(False) == obj_class(False), + obj_class(False) | obj_class(True) == obj_class(True), + obj_class(True) | obj_class(False) == obj_class(True), + obj_class(True) | obj_class(True) == obj_class(True), + obj_class(False) & obj_class(False) == obj_class(False), + obj_class(False) & obj_class(True) == obj_class(False), + obj_class(True) & obj_class(False) == obj_class(False), + obj_class(True) & obj_class(True) == obj_class(True), + ) + ): + RegisterLogicalType(obj_class) + return True + except: + pass + return False + + def check_if_numeric_type(obj): """Test if the argument behaves like a numeric type. @@ -208,46 +277,55 @@ def check_if_numeric_type(obj): if obj_class in native_types: return obj_class in native_numeric_types - if 'numpy' in obj_class.__module__: - # trigger the resolution of numpy_available and check if this - # type was automatically registered - bool(numpy_available) - if obj_class in native_types: - return obj_class in native_numeric_types - try: obj_plus_0 = obj + 0 obj_p0_class = obj_plus_0.__class__ - # ensure that the object is comparable to 0 in a meaningful way - # (among other things, this prevents numpy.ndarray objects from - # being added to native_numeric_types) + # Native numeric types *must* be hashable + hash(obj) + except: + return False + if obj_p0_class is not obj_class and obj_p0_class not in native_numeric_types: + return False + # + # Check if the numeric type behaves like a complex type + # + try: + if 1.41 < abs(obj_class(1j + 1)) < 1.42: + RegisterComplexType(obj_class) + return False + except: + pass + # + # Ensure that the object is comparable to 0 in a meaningful way + # + try: if not ((obj < 0) ^ (obj >= 0)): return False - # Native types *must* be hashable - hash(obj) except: return False - if obj_p0_class is obj_class or obj_p0_class in native_numeric_types: - # - # If we get here, this is a reasonably well-behaving - # numeric type: add it to the native numeric types - # so that future lookups will be faster. - # - RegisterNumericType(obj_class) - # - # Generate a warning, since Pyomo's management of third-party - # numeric types is more robust when registering explicitly. - # - logger.warning( - f"""Dynamically registering the following numeric type: + # + # If we get here, this is a reasonably well-behaving + # numeric type: add it to the native numeric types + # so that future lookups will be faster. + # + RegisterNumericType(obj_class) + try: + if obj_class(0.4) == obj_class(0): + RegisterIntegerType(obj_class) + except: + pass + # + # Generate a warning, since Pyomo's management of third-party + # numeric types is more robust when registering explicitly. + # + logger.warning( + f"""Dynamically registering the following numeric type: {obj_class.__module__}.{obj_class.__name__} Dynamic registration is supported for convenience, but there are known limitations to this approach. We recommend explicitly registering numeric types using RegisterNumericType() or RegisterIntegerType().""" - ) - return True - else: - return False + ) + return True def value(obj, exception=True): @@ -274,22 +352,10 @@ def value(obj, exception=True): """ if obj.__class__ in native_types: return obj - if obj.__class__ in pyomo_constant_types: - # - # I'm commenting this out for now, but I think we should never expect - # to see a numeric constant with value None. - # - # if exception and obj.value is None: - # raise ValueError( - # "No value for uninitialized NumericConstant object %s" - # % (obj.name,)) - return obj.value # # Test if we have a duck typed Pyomo expression # - try: - obj.is_numeric_type() - except AttributeError: + if not hasattr(obj, 'is_numeric_type'): # # TODO: Historically we checked for new *numeric* types and # raised exceptions for anything else. That is inconsistent @@ -304,7 +370,7 @@ def value(obj, exception=True): return None raise TypeError( "Cannot evaluate object with unknown type: %s" % obj.__class__.__name__ - ) from None + ) # # Evaluate the expression object # diff --git a/pyomo/common/plugin_base.py b/pyomo/common/plugin_base.py index 75b8657d1a9..f33bff09026 100644 --- a/pyomo/common/plugin_base.py +++ b/pyomo/common/plugin_base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/plugins.py b/pyomo/common/plugins.py index ed44f8bf776..bb008ffacc9 100644 --- a/pyomo/common/plugins.py +++ b/pyomo/common/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/pyomo_typing.py b/pyomo/common/pyomo_typing.py index 22ec3480842..35f3567432a 100644 --- a/pyomo/common/pyomo_typing.py +++ b/pyomo/common/pyomo_typing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/shutdown.py b/pyomo/common/shutdown.py index a96a6bc04fc..1741993dcce 100644 --- a/pyomo/common/shutdown.py +++ b/pyomo/common/shutdown.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/sorting.py b/pyomo/common/sorting.py index 4f78a7892b8..bb5ea13f55e 100644 --- a/pyomo/common/sorting.py +++ b/pyomo/common/sorting.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tee.py b/pyomo/common/tee.py index 500f7b6f58d..fdafcc39847 100644 --- a/pyomo/common/tee.py +++ b/pyomo/common/tee.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,13 +16,15 @@ # the U.S. Government retains certain rights in this software. # ___________________________________________________________________________ # +import collections.abc import io import logging import os import sys import threading import time -from io import StringIO + +from pyomo.common.log import LoggingIntercept, LogStream _poll_interval = 0.0001 _poll_rampup_limit = 0.099 @@ -35,6 +37,7 @@ _poll_timeout = 3 # 15 rounds: 0.0001 * 2**15 == 3.2768 _poll_timeout_deadlock = 200 # 21 rounds: 0.0001 * 2**21 == 209.7152 +_noop = lambda: None _mswindows = sys.platform.startswith('win') try: if _mswindows: @@ -50,6 +53,53 @@ logger = logging.getLogger(__name__) +class _SignalFlush(object): + def __init__(self, ostream, handle): + super().__setattr__('_ostream', ostream) + super().__setattr__('_handle', handle) + + def flush(self): + self._ostream.flush() + self._handle.flush = True + + def __getattr__(self, attr): + return getattr(self._ostream, attr) + + def __setattr__(self, attr, val): + return setattr(self._ostream, attr, val) + + +class _AutoFlush(_SignalFlush): + def write(self, data): + self._ostream.write(data) + self.flush() + + def writelines(self, data): + self._ostream.writelines(data) + self.flush() + + +class _fd_closer(object): + """A context manager to handle closing a specified file descriptor + + Ideally we would use `os.fdopen(... closefd=True)`; however, it + appears that Python ignores `closefd` on Windows. This would + eventually lead to the process exceeding the maximum number of open + files (see Pyomo/pyomo#3587). So, we will explicitly manage closing + the file descriptors that we open using this context manager. + + """ + + def __init__(self, fd): + self.fd = fd + + def __enter__(self): + return self.fd + + def __exit__(self, et, ev, tb): + os.close(self.fd) + + class redirect_fd(object): """Redirect a file descriptor to a new file or file descriptor. @@ -67,13 +117,15 @@ class redirect_fd(object): fd: int The file descriptor to redirect - output: int or str + output: int or str or None The new output target for `fd`: either another valid file - descriptor (int) or a string with the file to open. + descriptor (int) or a string with the file to open. If `None`, + then the fd is redirected to `os.devnull`. synchronize: bool If True, and `fd` is 1 or 2, then update `sys.stdout` or `sys.stderr` to also point to the new file descriptor + """ def __init__(self, fd=1, output=None, synchronize=True): @@ -90,8 +142,10 @@ def __init__(self, fd=1, output=None, synchronize=True): def __enter__(self): if self.std: - # important: flush the current file buffer when redirecting - getattr(sys, self.std).flush() + # We used to flush original_file here. We have removed that + # because the std* streams are flushed by capture_output. + # Flushing again here caused intermittent errors due to + # closed file handles on OSX self.original_file = getattr(sys, self.std) # Duplicate the original standard file descriptor(file # descriptor 1 or 2) to a different file descriptor number @@ -104,39 +158,22 @@ def __enter__(self): out_fd = os.open(self.target, os.O_WRONLY) # Duplicate the file descriptor for the opened file, closing and - # overwriting the value for stdout (file descriptor 1). Only - # make the new FD inheritable if it is stdout/stderr + # overwriting/replacing the original fd. Only make the new FD + # inheritable if it is stdout/stderr os.dup2(out_fd, self.fd, inheritable=bool(self.std)) # We no longer need this original file descriptor - if out_fd is not self.target: + if not isinstance(self.target, int): os.close(out_fd) - if self.std: - if self.synchronize: - # Cause Python's stdout to point to our new file - fd = self.fd - else: - # IF we are not synchronizing the std file object with - # the redirected file descriptor, and IF the current - # file object is pointing to the original file - # descriptor that we just redirected, then we want to - # retarget the std file to the original (duplicated) - # target file descriptor. This allows, e.g. Python to - # still write to stdout when we redirect fd=1 to - # /dev/null - try: - old_std_fd = getattr(sys, self.std).fileno() - fd = self.original_fd if old_std_fd == self.fd else None - except (io.UnsupportedOperation, AttributeError): - fd = None - if fd is not None: - self.target_file = os.fdopen(fd, 'w', closefd=False) - setattr(sys, self.std, self.target_file) + if self.synchronize and self.std: + # Cause Python's stdout to point to our new file + self.target_file = os.fdopen(self.fd, 'w', closefd=False) + setattr(sys, self.std, self.target_file) return self - def __exit__(self, t, v, traceback): + def __exit__(self, et, ev, tb): # Close output: this either closes the new file that we opened, # or else the new file that points to the original (duplicated) # file descriptor @@ -152,56 +189,240 @@ def __exit__(self, t, v, traceback): class capture_output(object): - """ - Drop-in substitute for PyUtilib's capture_output. - Takes in a StringIO, file-like object, or filename and temporarily - redirects output to a string buffer. + """Context manager to capture output sent to sys.stdout and sys.stderr + + This is a drop-in substitute for PyUtilib's capture_output to + temporarily redirect output to the provided stream or file. + + Parameters + ---------- + output : io.TextIOBase, Sequence[io.TextIOBase], TeeStream, str, or None + + Output stream where all captured stdout/stderr data is sent. If + a ``str`` is provided, it is used as a file name and opened + (potentially overwriting any existing file). If ``None``, a + :class:`io.StringIO` object is created and used. + + capture_fd : bool + + If True, we will also redirect the process file descriptors + ``1`` (stdout), ``2`` (stderr), and the file descriptors from + ``sys.stdout.fileno()`` and ``sys.stderr.fileno()`` to the + ``output``. This is useful for capturing output emitted + directly to the process stdout / stderr by external compiled + modules. + + Capturing and redirecting the file descriptors can cause loops + in the output stream (where one of the `output` streams points + to a file descriptor we just captured). + :py:class:`capture_output` will attempt to locate + :py:class:`io.IOBase` streams in `output` that point to file + descriptors that we just captured and replace them with + temporary streams that point to (copies of) the original file + descriptor. In addition, :py:class:`capture_output` will look + for :py:class:`~pyomo.common.log.LogStream` objects and will + attempt to locate :py:class:`logging.StreamHandle` objects that + would output to a redirected file descriptor and temporarily + redirect those handlers to (copies of) the original file + descriptor. + + Note that this process will cover the most common cases, but is + by no means perfect. Use of other output classes or customized + log handlers may still result in output loops (usually + manifesting in an error message about text being left in the + output buffer). + + Returns + ------- + io.TextIOBase + + This is the output stream object where all data is sent. + """ def __init__(self, output=None, capture_fd=False): if output is None: - output = StringIO() + output = io.StringIO() self.output = output - self.output_file = None self.old = None self.tee = None self.capture_fd = capture_fd - self.fd_redirect = None + self.context_stack = [] - def __enter__(self): - if isinstance(self.output, str): - self.output_stream = open(self.output, 'w') + def _enter_context(self, cm, prior_to=None): + """Add the context manager to the context stack and return the result + from calling the context manager's `__enter__()` + + """ + if prior_to is None: + self.context_stack.append(cm) else: - self.output_stream = self.output + self.context_stack.insert(self.context_stack.index(prior_to), cm) + return cm.__enter__() + + def _exit_context_stack(self, et, ev, tb): + """Flush the context stack, calling __exit__() on all context managers + + One would usually use the contextlib.ExitStack to implement/manage + the collection of context managers we are putting together. The + problem is that ExitStack will only call the __exit__ handlers + up to the first one that returns an exception. As we are + expecting the possibility of one of the CMs here to raise an + exception (usually from TeeStream when joining the reader + threads), we will explicitly implement the stack management here + so that we will guarantee that all __exit__ handlers will always + be called. + + """ + FAIL = [] + while self.context_stack: + try: + cm = self.context_stack.pop() + cm.__exit__(et, ev, tb) + except: + _stack = self.context_stack + FAIL.append(f"{sys.exc_info()[1]} ({len(_stack)+1}: {cm}@{id(cm):x})") + return FAIL + + def __enter__(self): self.old = (sys.stdout, sys.stderr) - self.tee = TeeStream(self.output_stream) - self.tee.__enter__() + old_fd = [] + for stream in self.old: + stream.flush() + try: + old_fd.append(stream.fileno()) + except (AttributeError, OSError): + old_fd.append(None) + try: + # We have an issue where we are (very aggressively) + # commandeering the terminal. This is what we intend, but the + # side effect is that any errors generated by this module (e.g., + # because the user gave us an invalid output stream) get + # completely suppressed. So, we will make an exception to the + # output that we are catching and let messages logged to THIS + # logger to still be emitted to the original stderr. + if self.capture_fd: + # Because we are also commandeering the FD that underlies + # sys.stderr, we cannot just write to that stream and + # instead will open a new stream to the "original" FD + # (Note that we need to duplicate that FD, as we will + # overwrite it when we get to redirect_fd below). If + # sys.stderr doesn't have a file descriptor, we will + # fall back on the process stderr (FD=2). + # + # Note that we would like to use closefd=True, but can't + # (see _fd_closer docs) + log_stream = self._enter_context( + os.fdopen( + self._enter_context(_fd_closer(os.dup(old_fd[1] or 2))), + mode="w", + closefd=False, + ) + ) + else: + log_stream = self.old[1] + self._enter_context(LoggingIntercept(log_stream, logger=logger, level=None)) + + if isinstance(self.output, str): + self.output_stream = self._enter_context(open(self.output, 'w')) + else: + self.output_stream = self.output + if isinstance(self.output, TeeStream): + self.tee = self._enter_context(self.output) + elif isinstance(self.output_stream, collections.abc.Sequence): + self.tee = self._enter_context(TeeStream(*self.output_stream)) + else: + self.tee = self._enter_context(TeeStream(self.output_stream)) + fd_redirect = {} + if self.capture_fd: + tee_fd = (self.tee.STDOUT.fileno(), self.tee.STDERR.fileno()) + for i in range(2): + # Redirect the standard process file descriptor (1 or 2) + fd_redirect[i + 1] = self._enter_context( + redirect_fd(i + 1, tee_fd[i], synchronize=False) + ) + # Redirect the file descriptor currently associated with + # sys.stdout / sys.stderr + fd = old_fd[i] + if fd and fd not in fd_redirect: + fd_redirect[fd] = self._enter_context( + redirect_fd(fd, tee_fd[i], synchronize=False) + ) + # We need to make sure that we didn't just capture the FD + # that underlies a stream that we are outputting to. Note + # that when capture_fd==False, normal streams will be left + # alone, but the lastResort _StderrHandler() will still be + # replaced (needed because that handler uses the *current* + # value of sys.stderr) + ostreams = [] + for stream in self.tee.ostreams: + if isinstance(stream, LogStream): + for handler_redirect in stream.redirect_streams(fd_redirect): + self._enter_context(handler_redirect, prior_to=self.tee) + else: + try: + fd = stream.fileno() + except (AttributeError, OSError): + fd = None + if fd in fd_redirect: + # We just redirected this file descriptor so + # we can capture the output. This makes a + # loop that we really want to break. Undo + # the redirect by pointing our output stream + # back to the original file descriptor. + # + # Note that we would like to use closefd=True, but can't + # (see _fd_closer docs) + stream = self._enter_context( + os.fdopen( + self._enter_context( + _fd_closer(os.dup(fd_redirect[fd].original_fd)), + prior_to=self.tee, + ), + mode="w", + closefd=False, + ), + prior_to=self.tee, + ) + ostreams.append(stream) + self.tee.ostreams = ostreams + except: + # Note: we will ignore any exceptions raised while exiting + # the context managers and just reraise the original + # exception. + self._exit_context_stack(*sys.exc_info()) + raise sys.stdout = self.tee.STDOUT sys.stderr = self.tee.STDERR - if self.capture_fd: - self.fd_redirect = ( - redirect_fd(1, sys.stdout.fileno()), - redirect_fd(2, sys.stderr.fileno()), - ) - self.fd_redirect[0].__enter__() - self.fd_redirect[1].__enter__() - return self.output_stream + buf = self.tee.ostreams + if len(buf) == 1: + buf = buf[0] + return buf def __exit__(self, et, ev, tb): - if self.fd_redirect is not None: - self.fd_redirect[1].__exit__(et, ev, tb) - self.fd_redirect[0].__exit__(et, ev, tb) - self.fd_redirect = None - FAIL = self.tee.STDOUT is not sys.stdout - self.tee.__exit__(et, ev, tb) - if self.output_stream is not self.output: - self.output_stream.close() + # Check that we were nested correctly + FAIL = [] + if self.tee._stdout is not None and self.tee.STDOUT is not sys.stdout: + FAIL.append( + 'Captured output (%s) does not match sys.stdout (%s).' + % (self.tee._stdout, sys.stdout) + ) + if self.tee._stderr is not None and self.tee.STDERR is not sys.stderr: + FAIL.append( + 'Captured output (%s) does not match sys.stderr (%s).' + % (self.tee._stdout, sys.stdout) + ) + # Exit all context managers. This includes + # - Restore any file descriptors we commandeered + # - Close / join the TeeStream + # - Close any opened files + FAIL.extend(self._exit_context_stack(et, ev, tb)) sys.stdout, sys.stderr = self.old self.old = None self.tee = None self.output_stream = None if FAIL: - raise RuntimeError('Captured output does not match sys.stdout.') + raise RuntimeError("\n".join(FAIL)) def __del__(self): if self.tee is not None: @@ -217,9 +438,22 @@ def reset(self): class _StreamHandle(object): + """A stream handler object used by TeeStream + + This handler holds the two sides of the pipe used to communicate + output generated by the main thread out to the handler thread (which + passes the output on to the TeeStream's output streams). + + Note that this class is intimately tied to TeeStream and relies on + that class for certain termination / cleanup events (including + flushing and closing buffers) + + """ + def __init__(self, mode, buffering, encoding, newline): self.buffering = buffering self.newlines = newline + self.flush = False self.read_pipe, self.write_pipe = os.pipe() if not buffering and 'b' not in mode: # While we support "unbuffered" behavior in text mode, @@ -233,6 +467,13 @@ def __init__(self, mode, buffering, encoding, newline): newline=newline, closefd=False, ) + if not self.buffering and buffering: + # We want this stream to be unbuffered, but Python doesn't + # allow it for text streams. Mock up an unbuffered stream + # using AutoFlush + self.write_file = _AutoFlush(self.write_file, self) + else: + self.write_file = _SignalFlush(self.write_file, self) self.decoder_buffer = b'' try: self.encoding = encoding or self.write_file.encoding @@ -243,9 +484,6 @@ def __init__(self, mode, buffering, encoding, newline): else: self.output_buffer = b'' - def __repr__(self): - return "%s(%s)" % (self.buffering, id(self)) - def fileno(self): return self.read_pipe @@ -253,7 +491,9 @@ def close(self): # Close both the file and the underlying file descriptor. Note # that this may get called more than once. if self.write_file is not None: - self.write_file.close() + if not self.write_file.closed: + self.write_file.flush() + self.write_file.close() self.write_file = None if self.write_pipe is not None: @@ -266,19 +506,14 @@ def close(self): self.write_pipe = None def finalize(self, ostreams): + # Note that this expects to be called by TeeStream *after* + # TeeStream has called close(), so the output_buffer should have + # been flushed and emptied. self.decodeIncomingBuffer() if ostreams: - # Turn off buffering for the final write - self.buffering = 0 - self.writeOutputBuffer(ostreams) + self.writeOutputBuffer(ostreams, True) os.close(self.read_pipe) - if self.output_buffer: - logger.error( - "Stream handle closed with a partial line " - "in the output buffer that was not emitted to the " - "output stream(s):\n\t'%s'" % (self.output_buffer,) - ) if self.decoder_buffer: logger.error( "Stream handle closed with un-decoded characters " @@ -307,10 +542,10 @@ def decodeIncomingBuffer(self): self.output_buffer += chars self.decoder_buffer = self.decoder_buffer[raw_len:] - def writeOutputBuffer(self, ostreams): + def writeOutputBuffer(self, ostreams, flush): if not self.encoding: ostring, self.output_buffer = self.output_buffer, b'' - elif self.buffering == 1: + elif self.buffering > 0 and not flush: EOL = self.output_buffer.rfind(self.newlines or '\n') + 1 ostring = self.output_buffer[:EOL] self.output_buffer = self.output_buffer[EOL:] @@ -324,41 +559,80 @@ def writeOutputBuffer(self, ostreams): try: written = stream.write(ostring) except: - written = 0 - if written and not self.buffering: - stream.flush() + my_repr = "<%s.%s @ %s>" % ( + stream.__class__.__module__, + stream.__class__.__name__, + hex(id(stream)), + ) + if my_repr in ostring: + # In the case of nested capture_outputs, all the + # handlers are left on the logger. We want to make + # sure that we don't create an infinite loop by + # re-printing a message *this* object generated. + continue + et, e, tb = sys.exc_info() + msg = "Error writing to output stream %s:\n %s: %s\n" % ( + my_repr, + et.__name__, + e, + ) + if getattr(stream, 'closed', False): + msg += "Output stream closed before all output was written to it." + else: + msg += "Is this a writeable TextIOBase object?" + logger.error( + f"{msg}\nThe following was left in the output buffer:\n" + f" {ostring!r}" + ) + continue + if flush or (written and not self.buffering): + getattr(stream, 'flush', _noop)() # Note: some derived file-like objects fail to return the # number of characters written (and implicitly return None). # If we get None, we will just assume that everything was # fine (as opposed to tossing the incomplete write error). if written is not None and written != len(ostring): + my_repr = "<%s.%s @ %s>" % ( + stream.__class__.__module__, + stream.__class__.__name__, + hex(id(stream)), + ) + if my_repr in ostring[written:]: + continue logger.error( - "Output stream (%s) closed before all output was " - "written to it. The following was left in " - "the output buffer:\n\t%r" % (stream, ostring[written:]) + "Incomplete write to output stream %s.\nThe following was " + "left in the output buffer:\n %r" % (my_repr, ostring[written:]) ) class TeeStream(object): - def __init__(self, *ostreams, encoding=None): + def __init__(self, *ostreams, encoding=None, buffering=-1): self.ostreams = ostreams self.encoding = encoding + self.buffering = buffering self._stdout = None self._stderr = None self._handles = [] self._active_handles = [] self._threads = [] + self._enter_count = 0 @property def STDOUT(self): if self._stdout is None: - self._stdout = self.open(buffering=1) + b = self.buffering + if b == -1: + b = 1 + self._stdout = self.open(buffering=b) return self._stdout @property def STDERR(self): if self._stderr is None: - self._stderr = self.open(buffering=0) + b = self.buffering + if b == -1: + b = 0 + self._stderr = self.open(buffering=b) return self._stderr def open(self, mode='w', buffering=-1, encoding=None, newline=None): @@ -393,6 +667,7 @@ def close(self, in_exception=False): # Join all stream processing threads _poll = _poll_interval + FAIL = False while True: for th in self._threads: th.join(_poll) @@ -403,16 +678,21 @@ def close(self, in_exception=False): if _poll_timeout <= _poll < 2 * _poll_timeout: if in_exception: # We are already processing an exception: no reason - # to trigger another, nor to deadlock for an extended time + # to trigger another, nor to deadlock for an + # extended time. Silently clean everything up + # (because emitting logger messages could trigger + # yet another exception and mask the true cause). break logger.warning( "Significant delay observed waiting to join reader " "threads, possible output stream deadlock" ) elif _poll >= _poll_timeout_deadlock: - raise RuntimeError( - "TeeStream: deadlock observed joining reader threads" - ) + logger.error("TeeStream: deadlock observed joining reader threads") + # Defer raising the exception until after we have + # cleaned things up + FAIL = True + break for h in list(self._handles): h.finalize(self.ostreams) @@ -422,16 +702,22 @@ def close(self, in_exception=False): self._active_handles.clear() self._stdout = None self._stderr = None + if FAIL: + raise RuntimeError("TeeStream: deadlock observed joining reader threads") def __enter__(self): + self._enter_count += 1 return self def __exit__(self, et, ev, tb): + if not self._enter_count: + raise RuntimeError("TeeStream: exiting a context that was not entered") + self._enter_count -= 1 self.close(et is not None) def __del__(self): # Implement __del__ to guarantee that file descriptors are closed - # ... but only if we are not called by the GC in the handler thread + # ... but only if we are not called by the GC in a handler thread if threading.current_thread() not in self._threads: self.close() @@ -454,15 +740,21 @@ def _start(self, handle): def _streamReader(self, handle): while True: new_data = os.read(handle.read_pipe, io.DEFAULT_BUFFER_SIZE) - if not new_data: + if handle.flush: + flush = True + handle.flush = False + else: + flush = False + if new_data: + handle.decoder_buffer += new_data + elif not flush: break - handle.decoder_buffer += new_data # At this point, we have new data sitting in the # handle.decoder_buffer handle.decodeIncomingBuffer() # Now, output whatever we have decoded to the output streams - handle.writeOutputBuffer(self.ostreams) + handle.writeOutputBuffer(self.ostreams, flush) # # print("STREAM READER: DONE") @@ -473,6 +765,7 @@ def _mergedReader(self): _fast_poll_ct = _poll_rampup new_data = '' # something not None while handles: + flush = False if new_data is None: # For performance reasons, we use very aggressive # polling at the beginning (_poll_interval) and then @@ -492,6 +785,9 @@ def _mergedReader(self): if _mswindows: for handle in list(handles): try: + if handle.flush: + flush = True + handle.flush = False pipe = get_osfhandle(handle.read_pipe) numAvail = PeekNamedPipe(pipe, 0)[1] if numAvail: @@ -500,8 +796,8 @@ def _mergedReader(self): break except: handles.remove(handle) - new_data = None - if new_data is None: + new_data = '' # not None so the poll interval doesn't increase + if new_data is None and not flush: # PeekNamedPipe is non-blocking; to avoid swamping # the core, sleep for a "short" amount of time time.sleep(_poll) @@ -515,22 +811,32 @@ def _mergedReader(self): # deadlocks when handles are added while select() is # waiting ready_handles = select(list(handles), noop, noop, _poll)[0] - if not ready_handles: - new_data = None - continue + if ready_handles: + handle = ready_handles[0] + new_data = os.read(handle.read_pipe, io.DEFAULT_BUFFER_SIZE) + if new_data: + handle.decoder_buffer += new_data + else: + handles.remove(handle) + new_data = '' # not None so the poll interval doesn't increase + else: + for handle in handles: + if handle.flush: + new_data = '' + break + else: + new_data = None + continue - handle = ready_handles[0] - new_data = os.read(handle.read_pipe, io.DEFAULT_BUFFER_SIZE) - if not new_data: - handles.remove(handle) - continue - handle.decoder_buffer += new_data + if handle.flush: + flush = True + handle.flush = False # At this point, we have new data sitting in the # handle.decoder_buffer handle.decodeIncomingBuffer() # Now, output whatever we have decoded to the output streams - handle.writeOutputBuffer(self.ostreams) + handle.writeOutputBuffer(self.ostreams, flush) # # print("MERGED READER: DONE") diff --git a/pyomo/common/tempfiles.py b/pyomo/common/tempfiles.py index b9dface71b2..7d8c81cd641 100644 --- a/pyomo/common/tempfiles.py +++ b/pyomo/common/tempfiles.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -100,24 +100,25 @@ def __del__(self): def shutdown(self, remove=True): if not self._context_stack: return - if any(ctx.tempfiles for ctx in self._context_stack): - logger.error( - "Temporary files created through TempfileManager " - "contexts have not been deleted (observed during " - "TempfileManager instance shutdown).\n" - "Undeleted entries:\n\t" - + "\n\t".join( - fname if isinstance(fname, str) else fname.decode() - for ctx in self._context_stack - for fd, fname in ctx.tempfiles + if logger is not None: + if any(ctx.tempfiles for ctx in self._context_stack): + logger.error( + "Temporary files created through TempfileManager " + "contexts have not been deleted (observed during " + "TempfileManager instance shutdown).\n" + "Undeleted entries:\n\t" + + "\n\t".join( + fname if isinstance(fname, str) else fname.decode() + for ctx in self._context_stack + for fd, fname in ctx.tempfiles + ) + ) + if self._context_stack: + logger.warning( + "TempfileManagerClass instance: un-popped tempfile " + "contexts still exist during TempfileManager instance " + "shutdown" ) - ) - if self._context_stack: - logger.warning( - "TempfileManagerClass instance: un-popped tempfile " - "contexts still exist during TempfileManager instance " - "shutdown" - ) self.clear_tempfiles(remove) # Delete the stack so that subsequent operations generate an # exception @@ -252,6 +253,13 @@ def __init__(self, manager): self.manager = weakref.ref(manager) self.tempfiles = [] self.tempdir = None + # Create a local reference from the TempfileContext to the os + # and shutil modules so that this object is deleted before the + # os and shutil modules are deallocated (since + # TempfileContext.__del__ can call methods in those modules + # through TempfileContext.release()). + self.os = os + self.shutil = shutil def __del__(self): self.release() @@ -410,11 +418,11 @@ def release(self, remove=True): remove: bool If ``True``, delete all managed files / directories """ - if remove: + if remove and self.tempfiles: for fd, name in reversed(self.tempfiles): if fd is not None: try: - os.close(fd) + self.os.close(fd) except OSError: pass self._remove_filesystem_object(name) @@ -443,11 +451,11 @@ def _resolve_tempdir(self, dir=None): return None def _remove_filesystem_object(self, name): - if not os.path.exists(name): + if not self.os.path.exists(name): return - if os.path.isfile(name) or os.path.islink(name): + if self.os.path.isfile(name) or self.os.path.islink(name): try: - os.remove(name) + self.os.remove(name) except WindowsError: # Sometimes Windows doesn't release the # file lock immediately when the process @@ -455,7 +463,7 @@ def _remove_filesystem_object(self, name): # second and try again. try: time.sleep(1) - os.remove(name) + self.os.remove(name) except WindowsError: if deletion_errors_are_fatal: raise @@ -465,8 +473,8 @@ def _remove_filesystem_object(self, name): logger = logging.getLogger(__name__) logger.warning("Unable to delete temporary file %s" % (name,)) return - assert os.path.isdir(name) - shutil.rmtree(name, ignore_errors=not deletion_errors_are_fatal) + assert self.os.path.isdir(name) + self.shutil.rmtree(name, ignore_errors=not deletion_errors_are_fatal) # The global Pyomo TempfileManager instance diff --git a/pyomo/common/tests/__init__.py b/pyomo/common/tests/__init__.py index d8d8856e52f..1304f66162b 100644 --- a/pyomo/common/tests/__init__.py +++ b/pyomo/common/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/config_plugin.py b/pyomo/common/tests/config_plugin.py index 6aebc40806a..50846504460 100644 --- a/pyomo/common/tests/config_plugin.py +++ b/pyomo/common/tests/config_plugin.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/dep_mod.py b/pyomo/common/tests/dep_mod.py index f6add596ed4..f6fdaab14d2 100644 --- a/pyomo/common/tests/dep_mod.py +++ b/pyomo/common/tests/dep_mod.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,8 +13,8 @@ __version__ = '1.5' -numpy, numpy_available = attempt_import('numpy', defer_check=True) +numpy, numpy_available = attempt_import('numpy', defer_import=True) bogus_nonexisting_module, bogus_nonexisting_module_available = attempt_import( - 'bogus_nonexisting_module', alt_names=['bogus_nem'], defer_check=True + 'bogus_nonexisting_module', alt_names=['bogus_nem'], defer_import=True ) diff --git a/pyomo/common/tests/dep_mod_except.py b/pyomo/common/tests/dep_mod_except.py index 16936996eeb..31616f79e78 100644 --- a/pyomo/common/tests/dep_mod_except.py +++ b/pyomo/common/tests/dep_mod_except.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/deps.py b/pyomo/common/tests/deps.py index d00281553f4..876858fd125 100644 --- a/pyomo/common/tests/deps.py +++ b/pyomo/common/tests/deps.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,15 +23,16 @@ bogus_nonexisting_module_available as has_bogus_nem, ) -bogus, bogus_available = attempt_import('nonexisting.module.bogus', defer_check=True) +bogus, bogus_available = attempt_import('nonexisting.module.bogus', defer_import=True) pkl_test, pkl_available = attempt_import( - 'nonexisting.module.pickle_test', deferred_submodules=['submod'], defer_check=True + 'nonexisting.module.pickle_test', deferred_submodules=['submod'], defer_import=True ) pyo, pyo_available = attempt_import( 'pyomo', alt_names=['pyo'], + defer_import=True, deferred_submodules={'version': None, 'common.tests.dep_mod': ['dm']}, ) diff --git a/pyomo/common/tests/import_ex.py b/pyomo/common/tests/import_ex.py index 73375bdc819..b305ec6ec6e 100644 --- a/pyomo/common/tests/import_ex.py +++ b/pyomo/common/tests/import_ex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/mod.py b/pyomo/common/tests/mod.py new file mode 100644 index 00000000000..495aae457df --- /dev/null +++ b/pyomo/common/tests/mod.py @@ -0,0 +1,17 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +# + +# This is a simple module used as part of testing import callbacks + + +class Foo(object): + data = 42 diff --git a/pyomo/common/tests/moved.py b/pyomo/common/tests/moved.py new file mode 100644 index 00000000000..55912a76213 --- /dev/null +++ b/pyomo/common/tests/moved.py @@ -0,0 +1,17 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +# + +# This is a simple module used as part of testing moved_module() + + +class Bar(object): + data = 42 diff --git a/pyomo/common/tests/relo_mod.py b/pyomo/common/tests/relo_mod.py index 4881caba671..266ac913206 100644 --- a/pyomo/common/tests/relo_mod.py +++ b/pyomo/common/tests/relo_mod.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/relo_mod_new.py b/pyomo/common/tests/relo_mod_new.py index 0f59f3beebc..d616574aa99 100644 --- a/pyomo/common/tests/relo_mod_new.py +++ b/pyomo/common/tests/relo_mod_new.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/relocated.py b/pyomo/common/tests/relocated.py index 90cb28c23ba..b7572f7db36 100644 --- a/pyomo/common/tests/relocated.py +++ b/pyomo/common/tests/relocated.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_bunch.py b/pyomo/common/tests/test_bunch.py index 8c10df83005..70149761486 100644 --- a/pyomo/common/tests/test_bunch.py +++ b/pyomo/common/tests/test_bunch.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_component_map.py b/pyomo/common/tests/test_component_map.py index 7cd4ec2c458..13cb49c06e1 100644 --- a/pyomo/common/tests/test_component_map.py +++ b/pyomo/common/tests/test_component_map.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -49,6 +49,27 @@ def test_tuple(self): self.assertIn((1, (2, m.v)), m.cm) self.assertNotIn((1, (2, m.v)), i.cm) + def test_hasher(self): + m = ComponentMap() + a = 'str' + m[a] = 5 + self.assertTrue(m.hasher.hashable(a)) + self.assertTrue(m.hasher.hashable(str)) + self.assertEqual(m._dict, {a: (a, 5)}) + del m[a] + + m.hasher.hashable(a, False) + m[a] = 5 + self.assertFalse(m.hasher.hashable(a)) + self.assertFalse(m.hasher.hashable(str)) + self.assertEqual(m._dict, {id(a): (a, 5)}) + + class TMP: + pass + + with self.assertRaises(KeyError): + m.hasher.hashable(TMP) + class TestDefaultComponentMap(unittest.TestCase): def test_default_component_map(self): diff --git a/pyomo/common/tests/test_config.py b/pyomo/common/tests/test_config.py index 0bbed43423d..da87e5fe340 100644 --- a/pyomo/common/tests/test_config.py +++ b/pyomo/common/tests/test_config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -88,6 +88,7 @@ def _display(obj, *args): class GlobalClass(object): "test class for test_known_types" + pass @@ -481,9 +482,7 @@ def __repr__(self): None, IsInstance(int, TestClass, document_full_base_names=True) ), ) - self.assertRegex( - c.get("val3").domain_name(), r"IsInstance\(int, .*\.TestClass\)" - ) + self.assertRegex(c.get("val3").domain_name(), r"IsInstance\[int, TestClass\]") c.val3 = 2 self.assertEqual(c.val3, 2) exc_str = ( @@ -499,7 +498,7 @@ def __repr__(self): None, IsInstance(int, TestClass, document_full_base_names=False) ), ) - self.assertEqual(c.get("val4").domain_name(), "IsInstance(int, TestClass)") + self.assertEqual(c.get("val4").domain_name(), "IsInstance[int, TestClass]") c.val4 = 2 self.assertEqual(c.val4, 2) exc_str = ( @@ -1175,7 +1174,6 @@ def _validateTemplate(self, config, reference_template, **kwds): test = config.generate_yaml_template(**kwds) width = kwds.get('width', 80) indent = kwds.get('indent_spacing', 2) - sys.stdout.write(test) for l in test.splitlines(): self.assertLessEqual(len(l), width) if l.strip().startswith("#"): @@ -1337,7 +1335,6 @@ def test_display_default(self): response time: 60.0 """ test = _display(self.config) - sys.stdout.write(test) self.assertEqual(test, reference) def test_display_list(self): @@ -1376,18 +1373,15 @@ def test_display_list(self): self.config['scenarios'].append() self.config['scenarios'].append({'merlion': True, 'detection': []}) test = _display(self.config) - sys.stdout.write(test) self.assertEqual(test, reference) def test_display_userdata_default(self): test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(test, "") def test_display_userdata_list(self): self.config['scenarios'].append() test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual( test, """scenarios: @@ -1399,7 +1393,6 @@ def test_display_userdata_list_nonDefault(self): self.config['scenarios'].append() self.config['scenarios'].append({'merlion': True, 'detection': []}) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual( test, """scenarios: @@ -1414,7 +1407,6 @@ def test_display_userdata_add_block(self): self.config.add("foo", ConfigValue(0, int, None, None)) self.config.add("bar", ConfigDict()) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual( test, """foo: 0 @@ -1426,7 +1418,6 @@ def test_display_userdata_add_block_nonDefault(self): self.config.add("foo", ConfigValue(0, int, None, None)) self.config.add("bar", ConfigDict(implicit=True)).add("baz", ConfigDict()) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual( test, """foo: 0 @@ -1435,42 +1426,48 @@ def test_display_userdata_add_block_nonDefault(self): """, ) + def test_display_nondata_type(self): + class NOOP(object): + def __getattr__(self, attr): + def noop(*args, **kwargs): + pass + + return noop + + cfg = ConfigDict() + cfg.declare('callback', ConfigValue(default=NOOP)) + self.assertEqual(_display(cfg), "callback: \n") + def test_display_userdata_declare_block(self): self.config.declare("foo", ConfigValue(0, int, None, None)) self.config.declare("bar", ConfigDict()) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(test, "") def test_display_userdata_declare_block_nonDefault(self): self.config.declare("foo", ConfigValue(0, int, None, None)) self.config.declare("bar", ConfigDict(implicit=True)).add("baz", ConfigDict()) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(test, "bar:\n baz:\n") def test_unusedUserValues_default(self): test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual(test, "") def test_unusedUserValues_scalar(self): self.config['scenario']['merlion'] = True test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual(test, "scenario.merlion") def test_unusedUserValues_list(self): self.config['scenarios'].append() test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual(test, """scenarios[0]""") def test_unusedUserValues_list_nonDefault(self): self.config['scenarios'].append() self.config['scenarios'].append({'merlion': True, 'detection': []}) test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual( test, """scenarios[0] @@ -1485,7 +1482,6 @@ def test_unusedUserValues_list_nonDefault_listAccessed(self): for x in self.config['scenarios']: pass test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual( test, """scenarios[0] @@ -1499,7 +1495,6 @@ def test_unusedUserValues_list_nonDefault_itemAccessed(self): self.config['scenarios'].append({'merlion': True, 'detection': []}) self.config['scenarios'][1]['merlion'] test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual( test, """scenarios[0] @@ -1509,52 +1504,43 @@ def test_unusedUserValues_list_nonDefault_itemAccessed(self): def test_unusedUserValues_add_topBlock(self): self.config.add('foo', ConfigDict()) test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual(test, "foo") test = '\n'.join(x.name(True) for x in self.config.foo.unused_user_values()) - sys.stdout.write(test) self.assertEqual(test, "foo") def test_unusedUserValues_add_subBlock(self): self.config['scenario'].add('foo', ConfigDict()) test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual(test, """scenario.foo""") def test_unusedUserValues_declare_topBlock(self): self.config.declare('foo', ConfigDict()) test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual(test, "") def test_unusedUserValues_declare_subBlock(self): self.config['scenario'].declare('foo', ConfigDict()) test = '\n'.join(x.name(True) for x in self.config.unused_user_values()) - sys.stdout.write(test) self.assertEqual(test, "") def test_UserValues_default(self): test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual(test, "") def test_UserValues_scalar(self): self.config['scenario']['merlion'] = True test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual(test, "scenario.merlion") def test_UserValues_list(self): self.config['scenarios'].append() test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual(test, """scenarios[0]""") def test_UserValues_list_nonDefault(self): self.config['scenarios'].append() self.config['scenarios'].append({'merlion': True, 'detection': []}) test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual( test, """scenarios[0] @@ -1569,7 +1555,6 @@ def test_UserValues_list_nonDefault_listAccessed(self): for x in self.config['scenarios']: pass test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual( test, """scenarios[0] @@ -1583,7 +1568,6 @@ def test_UserValues_list_nonDefault_itemAccessed(self): self.config['scenarios'].append({'merlion': True, 'detection': []}) self.config['scenarios'][1]['merlion'] test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual( test, """scenarios[0] @@ -1595,34 +1579,28 @@ def test_UserValues_list_nonDefault_itemAccessed(self): def test_UserValues_add_topBlock(self): self.config.add('foo', ConfigDict()) test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual(test, "foo") test = '\n'.join(x.name(True) for x in self.config.foo.user_values()) - sys.stdout.write(test) self.assertEqual(test, "foo") def test_UserValues_add_subBlock(self): self.config['scenario'].add('foo', ConfigDict()) test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual(test, """scenario.foo""") def test_UserValues_declare_topBlock(self): self.config.declare('foo', ConfigDict()) test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual(test, "") def test_UserValues_declare_subBlock(self): self.config['scenario'].declare('foo', ConfigDict()) test = '\n'.join(x.name(True) for x in self.config.user_values()) - sys.stdout.write(test) self.assertEqual(test, "") @unittest.skipIf(not yaml_available, "Test requires PyYAML") def test_parseDisplayAndValue_default(self): test = _display(self.config) - sys.stdout.write(test) self.assertEqual(yaml_load(test), self.config.value()) @unittest.skipIf(not yaml_available, "Test requires PyYAML") @@ -1630,20 +1608,17 @@ def test_parseDisplayAndValue_list(self): self.config['scenarios'].append() self.config['scenarios'].append({'merlion': True, 'detection': []}) test = _display(self.config) - sys.stdout.write(test) self.assertEqual(yaml_load(test), self.config.value()) @unittest.skipIf(not yaml_available, "Test requires PyYAML") def test_parseDisplay_userdata_default(self): test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(yaml_load(test), None) @unittest.skipIf(not yaml_available, "Test requires PyYAML") def test_parseDisplay_userdata_list(self): self.config['scenarios'].append() test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(yaml_load(test), {'scenarios': [None]}) @unittest.skipIf(not yaml_available, "Test requires PyYAML") @@ -1651,7 +1626,6 @@ def test_parseDisplay_userdata_list_nonDefault(self): self.config['scenarios'].append() self.config['scenarios'].append({'merlion': True, 'detection': []}) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual( yaml_load(test), {'scenarios': [None, {'merlion': True, 'detection': []}]} ) @@ -1661,7 +1635,6 @@ def test_parseDisplay_userdata_add_block(self): self.config.add("foo", ConfigValue(0, int, None, None)) self.config.add("bar", ConfigDict()) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(yaml_load(test), {'foo': 0, 'bar': None}) @unittest.skipIf(not yaml_available, "Test requires PyYAML") @@ -1669,7 +1642,6 @@ def test_parseDisplay_userdata_add_block_nonDefault(self): self.config.add("foo", ConfigValue(0, int, None, None)) self.config.add("bar", ConfigDict(implicit=True)).add("baz", ConfigDict()) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(yaml_load(test), {'bar': {'baz': None}, 'foo': 0}) @unittest.skipIf(not yaml_available, "Test requires PyYAML") @@ -1677,7 +1649,6 @@ def test_parseDisplay_userdata_add_block(self): self.config.declare("foo", ConfigValue(0, int, None, None)) self.config.declare("bar", ConfigDict()) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(yaml_load(test), None) @unittest.skipIf(not yaml_available, "Test requires PyYAML") @@ -1685,7 +1656,6 @@ def test_parseDisplay_userdata_add_block_nonDefault(self): self.config.declare("foo", ConfigValue(0, int, None, None)) self.config.declare("bar", ConfigDict(implicit=True)).add("baz", ConfigDict()) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual(yaml_load(test), {'bar': {'baz': None}}) def test_value_ConfigValue(self): @@ -1889,11 +1859,24 @@ def test_default_function(self): c.reset() self.assertEqual(c.value(), 10) + c = ConfigValue(default=lambda x: 10 * x, domain=int) with self.assertRaisesRegex(TypeError, r"\(\) .* argument"): - c = ConfigValue(default=lambda x: 10 * x, domain=int) + c.value() - with self.assertRaisesRegex(ValueError, 'invalid value for configuration'): - c = ConfigValue('a', domain=int) + c = ConfigValue('a', domain=int) + with self.assertRaisesRegex( + ValueError, '(?s)invalid value for configuration.*casting a' + ): + c.value() + + # Test that if both the default and the result from calling the + # default raise exceptions, the propagated exception is from + # castig the original default: + c = ConfigValue(default=lambda: 'a', domain=int) + with self.assertRaisesRegex( + ValueError, "(?s)invalid value for configuration.*lambda" + ): + c.value() def test_set_default(self): c = ConfigValue() @@ -2098,7 +2081,6 @@ def test_generate_custom_documentation(self): "generate_documentation is deprecated.", LOG, ) - self.maxDiff = None # print(test) self.assertEqual(test, reference) @@ -2113,7 +2095,6 @@ def test_generate_custom_documentation(self): ) ) self.assertEqual(LOG.getvalue(), "") - self.maxDiff = None # print(test) self.assertEqual(test, reference) @@ -2159,7 +2140,6 @@ def test_generate_custom_documentation(self): "generate_documentation is deprecated.", LOG, ) - self.maxDiff = None # print(test) self.assertEqual(test, reference) @@ -2424,7 +2404,6 @@ def test_list_manipulation(self): self.config['scenarios'].append({'merlion': True, 'detection': []}) self.assertEqual(len(self.config['scenarios']), 3) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual( test, """scenarios: @@ -2438,7 +2417,6 @@ def test_list_manipulation(self): self.config['scenarios'][0] = {'merlion': True, 'detection': []} self.assertEqual(len(self.config['scenarios']), 3) test = _display(self.config, 'userdata') - sys.stdout.write(test) self.assertEqual( test, """scenarios: @@ -2452,7 +2430,6 @@ def test_list_manipulation(self): """, ) test = _display(self.config['scenarios']) - sys.stdout.write(test) self.assertEqual( test, """- @@ -2577,7 +2554,6 @@ def test_argparse_help_implicit_disable(self): parser = argparse.ArgumentParser(prog='tester') self.config.initialize_argparse(parser) help = parser.format_help() - self.maxDiff = None self.assertIn( """ -h, --help show this help message and exit @@ -2772,7 +2748,9 @@ def test_getattr_setattr(self): ): config.baz = 10 - with self.assertRaisesRegex(AttributeError, "Unknown attribute 'baz'"): + with self.assertRaisesRegex( + AttributeError, "'ConfigDict' object has no attribute 'baz'" + ): a = config.baz def test_nonString_keys(self): @@ -2919,6 +2897,18 @@ def test_call_options(self): self.assertEqual(mod_copy._description, "new description") self.assertEqual(mod_copy._visibility, 0) + def test_template_nondata(self): + class NOOP(object): + def __getattr__(self, attr): + def noop(*args, **kwargs): + pass + + return noop + + cfg = ConfigDict() + cfg.declare('callback', ConfigValue(default=NOOP, description="docstr")) + self._validateTemplate(cfg, "callback: # docstr\n") + def test_pickle(self): def anon_domain(domain): def cast(x): @@ -3106,8 +3096,6 @@ def test_declare_from(self): cfg2.declare_from({}) def test_docstring_decorator(self): - self.maxDiff = None - @document_kwargs_from_configdict('CONFIG') class ExampleClass(object): CONFIG = ExampleConfig() @@ -3126,16 +3114,19 @@ def fcn(self): Keyword Arguments ----------------- option_1: int, default=5 + The first configuration option solver_options: dict, optional solver_option_1: float, default=1 + [DEVELOPER option] The first solver configuration option solver_option_2: float, default=1 + The second solver configuration option With a very long line containing wrappable text in a long, silly @@ -3144,6 +3135,7 @@ def fcn(self): #) with two bullets solver_option_3: float, default=1 + The third solver configuration option This has a leading newline and a very long line containing @@ -3155,6 +3147,7 @@ def fcn(self): #) with two bullets option_2: int, default=5 + The second solver configuration option with a very long line containing wrappable text in a long, silly paragraph with little actual information.""" @@ -3165,11 +3158,13 @@ def fcn(self): Keyword Arguments ----------------- option_1: int, default=5 + The first configuration option solver_options: dict, optional solver_option_2: float, default=1 + The second solver configuration option With a very long line containing wrappable text in a long, silly @@ -3178,6 +3173,7 @@ def fcn(self): #) with two bullets solver_option_3: float, default=1 + The third solver configuration option This has a leading newline and a very long line containing @@ -3189,6 +3185,7 @@ def fcn(self): #) with two bullets option_2: int, default=5 + The second solver configuration option with a very long line containing wrappable text in a long, silly paragraph with little actual information.""" @@ -3198,11 +3195,13 @@ def fcn(self): Keyword Arguments ----------------- option_1: int, default=5 + The first configuration option solver_options: dict, optional solver_option_2: float, default=1 + The second solver configuration option With a very long line containing wrappable text in a long, silly paragraph with little actual information. @@ -3210,6 +3209,7 @@ def fcn(self): #) with two bullets solver_option_3: float, default=1 + The third solver configuration option This has a leading newline and a very long line containing wrappable text in a long, silly paragraph with little actual information. @@ -3219,6 +3219,7 @@ def fcn(self): #) with two bullets option_2: int, default=5 + The second solver configuration option with a very long line containing wrappable text in a long, silly paragraph with little actual information.""" with LoggingIntercept() as LOG: self.assertEqual(add_docstring_list("", ExampleClass.CONFIG), ref) @@ -3265,6 +3266,91 @@ def __init__( OUT.getvalue().replace('null', 'None'), ) + def test_domain_name(self): + cfg = ConfigDict() + + cfg.declare('none', ConfigValue()) + self.assertEqual(cfg.get('none').domain_name(), '') + + def fcn(val): + return val + + cfg.declare('fcn', ConfigValue(domain=fcn)) + self.assertEqual(cfg.get('fcn').domain_name(), 'fcn') + + fcn.domain_name = 'custom fcn' + self.assertEqual(cfg.get('fcn').domain_name(), 'custom fcn') + + class functor: + def __call__(self, val): + return val + + cfg.declare('functor', ConfigValue(domain=functor())) + self.assertEqual(cfg.get('functor').domain_name(), 'functor') + + class cfunctor: + def __call__(self, val): + return val + + def domain_name(self): + return 'custom functor' + + cfg.declare('cfunctor', ConfigValue(domain=cfunctor())) + self.assertEqual(cfg.get('cfunctor').domain_name(), 'custom functor') + + cfg.declare('type', ConfigValue(domain=int)) + self.assertEqual(cfg.get('type').domain_name(), 'int') + + def test_deferred_initialization(self): + class Accumulator(object): + def __init__(self): + self.data = [] + + def __call__(self, val): + self.data.append(val) + return val + + record = Accumulator() + + cfg = ConfigDict() + cfg.declare('a', ConfigValue(5, record)) + self.assertEqual(record.data, []) + self.assertEqual(cfg.a, 5) + self.assertEqual(record.data, [5]) + + # Test that assignment bypasses the default value + cfg.declare('b', ConfigValue(6, record)) + self.assertEqual(record.data, [5]) + cfg.b = 10 + self.assertEqual(record.data, [5, 10]) + self.assertEqual(cfg.b, 10) + self.assertEqual(record.data, [5, 10]) + + # But resetting it will trigger the default + cfg.get('b').reset() + self.assertEqual(record.data, [5, 10, 6]) + self.assertEqual(cfg.b, 6) + + record.data = [] + cfg.declare('la', ConfigList(['a', 'b'], ConfigValue(7, record))) + self.assertEqual(record.data, []) + self.assertEqual(cfg.la.value(), ['a', 'b']) + self.assertEqual(record.data, [7, 'a', 'b']) + + # Test that assignment bypasses the default value + record.data = [] + cfg.declare('lb', ConfigList(['a', 'b'], record)) + self.assertEqual(record.data, []) + cfg.lb = [10, 11] + self.assertEqual(record.data, [10, 11]) + self.assertEqual(cfg.lb.value(), [10, 11]) + self.assertEqual(record.data, [10, 11]) + + # But resetting it will trigger the default + cfg.get('lb').reset() + self.assertEqual(record.data, [10, 11, 'a', 'b']) + self.assertEqual(cfg.lb.value(), ['a', 'b']) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/common/tests/test_dependencies.py b/pyomo/common/tests/test_dependencies.py index 30822a4f81f..7d3c28a1522 100644 --- a/pyomo/common/tests/test_dependencies.py +++ b/pyomo/common/tests/test_dependencies.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,6 +10,8 @@ # ___________________________________________________________________________ import inspect +import sys +from importlib.machinery import PathFinder from io import StringIO import pyomo.common.unittest as unittest @@ -24,9 +26,11 @@ UnavailableClass, _DeferredAnd, _DeferredOr, + _DeferredImportCallbackFinder, check_min_version, dill, dill_available, + mpi4py_available, ) import pyomo.common.tests.dep_mod as dep_mod @@ -45,7 +49,7 @@ def test_import_error(self): module_obj, module_available = attempt_import( '__there_is_no_module_named_this__', 'Testing import of a non-existent module', - defer_check=False, + defer_import=False, ) self.assertFalse(module_available) with self.assertRaisesRegex( @@ -85,7 +89,7 @@ def test_pickle(self): def test_import_success(self): module_obj, module_available = attempt_import( - 'ply', 'Testing import of ply', defer_check=False + 'ply', 'Testing import of ply', defer_import=False ) self.assertTrue(module_available) import ply @@ -123,7 +127,7 @@ def test_imported_deferred_import(self): def test_min_version(self): mod, avail = attempt_import( - 'pyomo.common.tests.dep_mod', minimum_version='1.0', defer_check=False + 'pyomo.common.tests.dep_mod', minimum_version='1.0', defer_import=False ) self.assertTrue(avail) self.assertTrue(inspect.ismodule(mod)) @@ -131,7 +135,7 @@ def test_min_version(self): self.assertFalse(check_min_version(mod, '2.0')) mod, avail = attempt_import( - 'pyomo.common.tests.dep_mod', minimum_version='2.0', defer_check=False + 'pyomo.common.tests.dep_mod', minimum_version='2.0', defer_import=False ) self.assertFalse(avail) self.assertIs(type(mod), ModuleUnavailable) @@ -146,7 +150,7 @@ def test_min_version(self): 'pyomo.common.tests.dep_mod', error_message="Failed import", minimum_version='2.0', - defer_check=False, + defer_import=False, ) self.assertFalse(avail) self.assertIs(type(mod), ModuleUnavailable) @@ -159,10 +163,10 @@ def test_min_version(self): # Verify check_min_version works with deferred imports - mod, avail = attempt_import('pyomo.common.tests.dep_mod', defer_check=True) + mod, avail = attempt_import('pyomo.common.tests.dep_mod', defer_import=True) self.assertTrue(check_min_version(mod, '1.0')) - mod, avail = attempt_import('pyomo.common.tests.dep_mod', defer_check=True) + mod, avail = attempt_import('pyomo.common.tests.dep_mod', defer_import=True) self.assertFalse(check_min_version(mod, '2.0')) # Verify check_min_version works when called directly @@ -174,10 +178,10 @@ def test_min_version(self): self.assertFalse(check_min_version(mod, '1.0')) def test_and_or(self): - mod0, avail0 = attempt_import('ply', defer_check=True) - mod1, avail1 = attempt_import('pyomo.common.tests.dep_mod', defer_check=True) + mod0, avail0 = attempt_import('ply', defer_import=True) + mod1, avail1 = attempt_import('pyomo.common.tests.dep_mod', defer_import=True) mod2, avail2 = attempt_import( - 'pyomo.common.tests.dep_mod', minimum_version='2.0', defer_check=True + 'pyomo.common.tests.dep_mod', minimum_version='2.0', defer_import=True ) _and = avail0 & avail1 @@ -209,7 +213,7 @@ def test_and_or(self): _and_or = avail0 & avail1 | avail2 self.assertTrue(_and_or) - # Verify operator prescedence + # Verify operator precedence _or_and = avail0 | avail2 & avail2 self.assertTrue(_or_and) _or_and = (avail0 | avail2) & avail2 @@ -233,11 +237,11 @@ def test_callbacks(self): def _record_avail(module, avail): ans.append(avail) - mod0, avail0 = attempt_import('ply', defer_check=True, callback=_record_avail) + mod0, avail0 = attempt_import('ply', defer_import=True, callback=_record_avail) mod1, avail1 = attempt_import( 'pyomo.common.tests.dep_mod', minimum_version='2.0', - defer_check=True, + defer_import=True, callback=_record_avail, ) @@ -247,10 +251,74 @@ def _record_avail(module, avail): self.assertFalse(avail1) self.assertEqual(ans, [True, False]) + def test_callback_on_import(self): + sys.modules.pop('pyomo.common.tests.mod', None) + ans = [] + + class ImpFinder(object): + # This is an "imp" module-style finder (deprecated in Python + # 3.4 and removed in Python 3.12, but Google Collab still + # defines finders like this) + match = '' + + def find_module(self, fullname, path=None): + if fullname != self.match: + ans.append('pass') + return None + ans.append('load') + spec = PathFinder().find_spec(fullname, path) + return spec.loader + + def load_module(self, name): + pass + + def _callback(module, avail): + ans.append(len(ans)) + + attempt_import('pyomo.common.tests.mod', defer_import=True, callback=_callback) + self.assertEqual(ans, []) + import pyomo.common.tests.mod as m + + self.assertEqual(ans, [0]) + self.assertEqual(m.Foo.data, 42) + + sys.modules.pop('pyomo.common.tests.mod', None) + del m + attempt_import('pyomo.common.tests.mod', defer_import=True, callback=_callback) + + try: + # Test deferring to an imp-style finder that does not match + # the target module name + _finder = ImpFinder() + sys.meta_path.insert( + sys.meta_path.index(_DeferredImportCallbackFinder) + 1, _finder + ) + import pyomo.common.tests.mod as m + + self.assertEqual(ans, [0, 'pass', 2]) + self.assertEqual(m.Foo.data, 42) + + sys.modules.pop('pyomo.common.tests.mod', None) + del m + attempt_import( + 'pyomo.common.tests.mod', defer_import=True, callback=_callback + ) + + # Test deferring to an imp-style finder that DOES match the + # target module name + _finder.match = 'pyomo.common.tests.mod' + + import pyomo.common.tests.mod as m + + self.assertEqual(ans, [0, 'pass', 2, 'load', 4]) + self.assertEqual(m.Foo.data, 42) + finally: + sys.meta_path.remove(_finder) + def test_import_exceptions(self): mod, avail = attempt_import( 'pyomo.common.tests.dep_mod_except', - defer_check=True, + defer_import=True, only_catch_importerror=True, ) with self.assertRaisesRegex(ValueError, "cannot import module"): @@ -260,7 +328,7 @@ def test_import_exceptions(self): mod, avail = attempt_import( 'pyomo.common.tests.dep_mod_except', - defer_check=True, + defer_import=True, only_catch_importerror=False, ) self.assertFalse(avail) @@ -268,7 +336,7 @@ def test_import_exceptions(self): mod, avail = attempt_import( 'pyomo.common.tests.dep_mod_except', - defer_check=True, + defer_import=True, catch_exceptions=(ImportError, ValueError), ) self.assertFalse(avail) @@ -280,7 +348,7 @@ def test_import_exceptions(self): ): mod, avail = attempt_import( 'pyomo.common.tests.dep_mod_except', - defer_check=True, + defer_import=True, only_catch_importerror=True, catch_exceptions=(ImportError,), ) @@ -288,7 +356,7 @@ def test_import_exceptions(self): def test_generate_warning(self): mod, avail = attempt_import( 'pyomo.common.tests.dep_mod_except', - defer_check=True, + defer_import=True, only_catch_importerror=False, ) @@ -324,7 +392,7 @@ def test_generate_warning(self): def test_log_warning(self): mod, avail = attempt_import( 'pyomo.common.tests.dep_mod_except', - defer_check=True, + defer_import=True, only_catch_importerror=False, ) log = StringIO() @@ -366,9 +434,9 @@ def test_importer(self): def _importer(): attempted_import.append(True) - return attempt_import('pyomo.common.tests.dep_mod', defer_check=False)[0] + return attempt_import('pyomo.common.tests.dep_mod', defer_import=False)[0] - mod, avail = attempt_import('foo', importer=_importer, defer_check=True) + mod, avail = attempt_import('foo', importer=_importer, defer_import=True) self.assertEqual(attempted_import, []) self.assertIsInstance(mod, DeferredImportModule) @@ -401,17 +469,17 @@ def test_deferred_submodules(self): self.assertTrue(inspect.ismodule(deps.dm)) with self.assertRaisesRegex( - ValueError, "deferred_submodules is only valid if defer_check==True" + ValueError, "deferred_submodules is only valid if defer_import==True" ): mod, mod_available = attempt_import( 'nonexisting.module', - defer_check=False, + defer_import=False, deferred_submodules={'submod': None}, ) mod, mod_available = attempt_import( 'nonexisting.module', - defer_check=True, + defer_import=True, deferred_submodules={'submod.subsubmod': None}, ) self.assertIs(type(mod), DeferredImportModule) @@ -427,7 +495,7 @@ def test_UnavailableClass(self): module_obj, module_available = attempt_import( '__there_is_no_module_named_this__', 'Testing import of a non-existent module', - defer_check=False, + defer_import=False, ) class A_Class(UnavailableClass(module_obj)): @@ -450,6 +518,12 @@ class A_Class(UnavailableClass(module_obj)): ): A_Class.method() + @unittest.pytest.mark.mpi + def test_mpi4py_available(self): + from mpi4py import MPI + + self.assertTrue(bool(mpi4py_available)) + if __name__ == '__main__': unittest.main() diff --git a/pyomo/common/tests/test_deprecated.py b/pyomo/common/tests/test_deprecated.py index 377e229c775..a44f9058a5e 100644 --- a/pyomo/common/tests/test_deprecated.py +++ b/pyomo/common/tests/test_deprecated.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,24 +10,29 @@ # ___________________________________________________________________________ # """Testing for deprecated function.""" +import logging import sys +from importlib import import_module +from importlib.machinery import ModuleSpec +from io import StringIO + +import pyomo.common import pyomo.common.unittest as unittest from pyomo.common import DeveloperError from pyomo.common.deprecation import ( deprecated, deprecation_warning, + moved_module, relocated_module_attribute, + MovedModuleFinder, + MovedModuleLoader, RenamedClass, _import_object, ) from pyomo.common.log import LoggingIntercept -from io import StringIO - -import logging - logger = logging.getLogger('local') @@ -529,7 +534,10 @@ class DeprecatedClassSubclass(DeprecatedClass): out = StringIO() with LoggingIntercept(out): - class DeprecatedClassSubSubclass(DeprecatedClassSubclass): + class otherClass: + pass + + class DeprecatedClassSubSubclass(DeprecatedClassSubclass, otherClass): attr = 'DeprecatedClassSubSubclass' self.assertEqual(out.getvalue(), "") @@ -657,5 +665,122 @@ class DeprecatedClass(metaclass=RenamedClass): __renamed__new_class__ = NewClass +class TestMoved(unittest.TestCase): + def test_finder(self): + mod_name = 'pyomo.common.deprecation_tester' + finder = MovedModuleFinder() + self.assertNotIn(mod_name, finder.mapping) + self.assertIsNone(finder.find_spec(mod_name, pyomo.common.__path__)) + + moved_module(mod_name, __name__, version='1.2.3') + try: + self.assertIn(mod_name, finder.mapping) + spec = finder.find_spec(mod_name, pyomo.common.__path__) + self.assertIs(type(spec), ModuleSpec) + self.assertEqual(spec.name, mod_name) + self.assertIs(type(spec.loader), MovedModuleLoader) + self.assertEqual(spec.origin, __file__) + finally: + del finder.mapping[mod_name] + + def test_declaration(self): + try: + _old = 'pyomo.common.tests.old_moved' + _new = 'pyomo.common.tests.moved' + # 1st registration is OK + N = len(MovedModuleFinder.mapping) + self.assertNotIn(_old, MovedModuleFinder.mapping) + moved_module(_old, _new, version='1.2') + self.assertIn(_old, MovedModuleFinder.mapping) + self.assertEqual(N + 1, len(MovedModuleFinder.mapping)) + # duplicate registration is OK + moved_module(_old, _new, version='1.2') + self.assertIn(_old, MovedModuleFinder.mapping) + self.assertEqual(N + 1, len(MovedModuleFinder.mapping)) + _conflict = 'pyomo.something.else' + with self.assertRaisesRegex( + RuntimeError, + "(?s)Duplicate module alias declaration.\n" + f"\toriginal: {_old} -> {_new}\n" + f"\tconflict: {_old} -> {_conflict}\n", + ): + moved_module(_old, _conflict, version='1.2') + self.assertIn(_old, MovedModuleFinder.mapping) + self.assertEqual(N + 1, len(MovedModuleFinder.mapping)) + finally: + del MovedModuleFinder.mapping[_old] + + def test_loader(self): + mod_name = 'pyomo.common.deprecation_tester' + try: + moved_module(mod_name, __name__, version='1.2.3') + with LoggingIntercept() as LOG: + import pyomo.common.deprecation_tester + self.assertRegex( + LOG.getvalue().replace('\n', ' ').strip(), + "DEPRECATED: The 'pyomo.common.deprecation_tester' module has been " + "moved to 'pyomo.common.tests.test_deprecated'. Please update your " + r"import. \(deprecated in 1.2.3\) \(called from [^)]+\)", + ) + self.assertIs(pyomo.common.deprecation_tester.TestMoved, TestMoved) + finally: + del MovedModuleFinder.mapping[mod_name] + + try: + moved_module(mod_name, __name__, msg=None, version='1.2.3') + with LoggingIntercept() as LOG: + import pyomo.common.deprecation_tester + self.assertEqual(LOG.getvalue(), "") + self.assertIs(pyomo.common.deprecation_tester.TestMoved, TestMoved) + finally: + del MovedModuleFinder.mapping[mod_name] + + try: + moved_module( + 'pyomo.common.tests.old_moved', + 'pyomo.common.tests.moved', + version='1.2', + ) + self.assertNotIn('pyomo.common.tests.moved', sys.modules) + self.assertNotIn('pyomo.common.tests.old_moved', sys.modules) + with LoggingIntercept() as LOG: + import pyomo.common.tests.old_moved + self.assertRegex( + LOG.getvalue().replace('\n', ' ').strip(), + "DEPRECATED: The 'pyomo.common.tests.old_moved' module has been " + "moved to 'pyomo.common.tests.moved'. Please update your " + r"import. \(deprecated in 1.2\) \(called from [^)]+\)", + ) + self.assertIn('pyomo.common.tests.moved', sys.modules) + self.assertIn('pyomo.common.tests.old_moved', sys.modules) + self.assertIs( + sys.modules['pyomo.common.tests.moved'], + sys.modules['pyomo.common.tests.old_moved'], + ) + finally: + del MovedModuleFinder.mapping['pyomo.common.tests.old_moved'] + del sys.modules['pyomo.common.tests.old_moved'] + del sys.modules['pyomo.common.tests.moved'] + + def test_archive_importable(self): + import pyomo.environ + + # Check that all modules in the _archive directory are importable. + for old_name, info in MovedModuleFinder.mapping.items(): + if '._archive.' in info.new_name: + with LoggingIntercept() as LOG: + m = import_module(info.old_name) + self.assertIn('DEPRECATED', LOG.getvalue()) + # We expect every module in _archive to be deprecated + # (and to state that in the module docstring): + self.assertIn('deprecated', m.__doc__) + self.assertEqual(m.__name__, info.new_name) + # Remove these modules from sys.modules (some other + # modules have tests for deprecation paths that rely on + # these modules not having already been imported) + del sys.modules[info.old_name] + del sys.modules[info.new_name] + + if __name__ == '__main__': unittest.main() diff --git a/pyomo/common/tests/test_download.py b/pyomo/common/tests/test_download.py index 87108be1c59..05287c80cfb 100644 --- a/pyomo/common/tests/test_download.py +++ b/pyomo/common/tests/test_download.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,19 +9,22 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import io import os import platform import re import shutil -import tempfile import subprocess +import tarfile +import tempfile import pyomo.common.unittest as unittest import pyomo.common.envvar as envvar from pyomo.common import DeveloperError -from pyomo.common.fileutils import this_file +from pyomo.common.fileutils import this_file, Executable from pyomo.common.download import FileDownloader, distro_available +from pyomo.common.log import LoggingIntercept from pyomo.common.tee import capture_output @@ -170,7 +173,8 @@ def test_get_os_version(self): self.assertTrue(v.replace('.', '').startswith(dist_ver)) if ( - subprocess.run( + Executable('lsb_release').available() + and subprocess.run( ['lsb_release'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -203,7 +207,7 @@ def test_get_os_version(self): self.assertEqual(_os, 'win') self.assertEqual(_norm, _os + ''.join(_ver.split('.')[:2])) else: - self.assertEqual(ans, '') + self.assertEqual(_os, '') self.assertEqual((_os, _ver), FileDownloader._os_version) # Exercise the fetch from CACHE @@ -242,7 +246,7 @@ def test_get_files_requires_set_destination(self): ): f.get_gzipped_binary_file('bogus') - def test_get_test_binary_file(self): + def test_get_text_binary_file(self): tmpdir = tempfile.mkdtemp() try: f = FileDownloader() @@ -263,3 +267,66 @@ def test_get_test_binary_file(self): self.assertEqual(os.path.getsize(target), len(os.linesep)) finally: shutil.rmtree(tmpdir) + + def test_get_tar_archive(self): + tmpdir = tempfile.mkdtemp() + try: + f = FileDownloader() + + # Mock retrieve_url so network connections are not necessary + buf = io.BytesIO() + with tarfile.open(mode="w:gz", fileobj=buf) as TAR: + info = tarfile.TarInfo('b/lnk') + info.size = 0 + info.type = tarfile.SYMTYPE + info.linkname = envvar.PYOMO_CONFIG_DIR + TAR.addfile(info) + for fname in ('a', 'b/c', 'b/d', '/root', 'b/lnk/test'): + info = tarfile.TarInfo(fname) + info.size = 0 + info.type = tarfile.REGTYPE + info.mode = 0o644 + info.mtime = info.uid = info.gid = 0 + info.uname = info.gname = 'root' + TAR.addfile(info) + f.retrieve_url = lambda url: buf.getvalue() + + with self.assertRaisesRegex( + DeveloperError, + r"(?s)target file name has not been initialized " + r"with set_destination_filename".replace(' ', r'\s+'), + ): + f.get_tar_archive(None, 1) + + _tmp = os.path.join(tmpdir, 'a_file') + with open(_tmp, 'w'): + pass + f.set_destination_filename(_tmp) + with self.assertRaisesRegex( + RuntimeError, + r"Target directory \(.*a_file\) exists, but is not a directory", + ): + f.get_tar_archive(None, 1) + + f.set_destination_filename(tmpdir) + with LoggingIntercept() as LOG: + f.get_tar_archive(None, 1) + + self.assertEqual( + LOG.getvalue().strip(), + """ +Skipping file (a) in tar archive due to dirOffset. +malformed or potentially insecure filename (/root). Skipping file. +potentially insecure filename (lnk/test) resolves outside target directory. Skipping file. +""".strip(), + ) + for f in ('c', 'd'): + fname = os.path.join(tmpdir, f) + self.assertTrue(os.path.exists(fname)) + self.assertTrue(os.path.isfile(fname)) + for f in ('lnk',): + fname = os.path.join(tmpdir, f) + self.assertTrue(os.path.exists(fname)) + self.assertTrue(os.path.islink(fname)) + finally: + shutil.rmtree(tmpdir) diff --git a/pyomo/common/tests/test_enums.py b/pyomo/common/tests/test_enums.py new file mode 100644 index 00000000000..b5264a50825 --- /dev/null +++ b/pyomo/common/tests/test_enums.py @@ -0,0 +1,97 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import enum + +import pyomo.common.unittest as unittest + +from pyomo.common.enums import ExtendedEnumType, ObjectiveSense + + +class ProblemSense(enum.IntEnum, metaclass=ExtendedEnumType): + __base_enum__ = ObjectiveSense + + unknown = 0 + + +class TestExtendedEnumType(unittest.TestCase): + def test_members(self): + self.assertEqual( + list(ProblemSense), + [ProblemSense.unknown, ObjectiveSense.minimize, ObjectiveSense.maximize], + ) + + def test_isinstance(self): + self.assertIsInstance(ProblemSense.unknown, ProblemSense) + self.assertIsInstance(ProblemSense.minimize, ProblemSense) + self.assertIsInstance(ProblemSense.maximize, ProblemSense) + + self.assertTrue(ProblemSense.__instancecheck__(ProblemSense.unknown)) + self.assertTrue(ProblemSense.__instancecheck__(ProblemSense.minimize)) + self.assertTrue(ProblemSense.__instancecheck__(ProblemSense.maximize)) + + def test_getattr(self): + self.assertIs(ProblemSense.unknown, ProblemSense.unknown) + self.assertIs(ProblemSense.minimize, ObjectiveSense.minimize) + self.assertIs(ProblemSense.maximize, ObjectiveSense.maximize) + + def test_hasattr(self): + self.assertTrue(hasattr(ProblemSense, 'unknown')) + self.assertTrue(hasattr(ProblemSense, 'minimize')) + self.assertTrue(hasattr(ProblemSense, 'maximize')) + + def test_call(self): + self.assertIs(ProblemSense(0), ProblemSense.unknown) + self.assertIs(ProblemSense(1), ObjectiveSense.minimize) + self.assertIs(ProblemSense(-1), ObjectiveSense.maximize) + + self.assertIs(ProblemSense('unknown'), ProblemSense.unknown) + self.assertIs(ProblemSense('minimize'), ObjectiveSense.minimize) + self.assertIs(ProblemSense('maximize'), ObjectiveSense.maximize) + + with self.assertRaisesRegex(ValueError, "'foo' is not a valid ProblemSense"): + ProblemSense('foo') + with self.assertRaisesRegex(ValueError, "2 is not a valid ProblemSense"): + ProblemSense(2) + + def test_contains(self): + self.assertIn(ProblemSense.unknown, ProblemSense) + self.assertIn(ProblemSense.minimize, ProblemSense) + self.assertIn(ProblemSense.maximize, ProblemSense) + + self.assertNotIn(ProblemSense.unknown, ObjectiveSense) + self.assertIn(ProblemSense.minimize, ObjectiveSense) + self.assertIn(ProblemSense.maximize, ObjectiveSense) + + +class TestObjectiveSense(unittest.TestCase): + def test_members(self): + self.assertEqual( + list(ObjectiveSense), [ObjectiveSense.minimize, ObjectiveSense.maximize] + ) + + def test_hasattr(self): + self.assertTrue(hasattr(ProblemSense, 'minimize')) + self.assertTrue(hasattr(ProblemSense, 'maximize')) + + def test_call(self): + self.assertIs(ObjectiveSense(1), ObjectiveSense.minimize) + self.assertIs(ObjectiveSense(-1), ObjectiveSense.maximize) + + self.assertIs(ObjectiveSense('minimize'), ObjectiveSense.minimize) + self.assertIs(ObjectiveSense('maximize'), ObjectiveSense.maximize) + + with self.assertRaisesRegex(ValueError, "'foo' is not a valid ObjectiveSense"): + ObjectiveSense('foo') + + def test_str(self): + self.assertEqual(str(ObjectiveSense.minimize), 'minimize') + self.assertEqual(str(ObjectiveSense.maximize), 'maximize') diff --git a/pyomo/common/tests/test_env.py b/pyomo/common/tests/test_env.py index 93802fc40bb..98012c4dac3 100644 --- a/pyomo/common/tests/test_env.py +++ b/pyomo/common/tests/test_env.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_errors.py b/pyomo/common/tests/test_errors.py index 67a200e84e3..07ac2d85c05 100644 --- a/pyomo/common/tests/test_errors.py +++ b/pyomo/common/tests/test_errors.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_fileutils.py b/pyomo/common/tests/test_fileutils.py index 068360b55cb..ddbbda30b7d 100644 --- a/pyomo/common/tests/test_fileutils.py +++ b/pyomo/common/tests/test_fileutils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_flags.py b/pyomo/common/tests/test_flags.py new file mode 100644 index 00000000000..8c49b6c3da6 --- /dev/null +++ b/pyomo/common/tests/test_flags.py @@ -0,0 +1,49 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import sys + +import pyomo.common.unittest as unittest + +from pyomo.common.flags import NOTSET, in_testing_environment, building_documentation + + +class TestFlags(unittest.TestCase): + + def test_NOTSET(self): + self.assertTrue(in_testing_environment()) + self.assertFalse(building_documentation()) + + self.assertEqual(str(NOTSET), 'NOTSET') + self.assertNotIn('sphinx', sys.modules) + self.assertEqual(repr(NOTSET), 'pyomo.common.flags.NOTSET') + self.assertIsNone(in_testing_environment.state) + + try: + sys.modules['sphinx'] = sys.modules[__name__] + self.assertTrue(in_testing_environment()) + self.assertTrue(building_documentation()) + self.assertEqual(repr(NOTSET), 'NOTSET') + + in_testing_environment(False) + self.assertFalse(in_testing_environment()) + self.assertTrue(building_documentation()) + self.assertEqual(repr(NOTSET), 'NOTSET') + finally: + del sys.modules['sphinx'] + in_testing_environment(None) + self.assertIsNone(in_testing_environment.state) + + def test_singleton(self): + # This tests that the type is a "singleton", and that any + # attempts to construct an instance will return the class + self.assertIs(NOTSET(), NOTSET) + self.assertIs(NOTSET(), NOTSET()) diff --git a/pyomo/common/tests/test_formatting.py b/pyomo/common/tests/test_formatting.py index 29db26676ab..0798a80589c 100644 --- a/pyomo/common/tests/test_formatting.py +++ b/pyomo/common/tests/test_formatting.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_gc.py b/pyomo/common/tests/test_gc.py index 176010b8d0d..a9a0346706c 100644 --- a/pyomo/common/tests/test_gc.py +++ b/pyomo/common/tests/test_gc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_log.py b/pyomo/common/tests/test_log.py index 64691c0015a..43e85af1308 100644 --- a/pyomo/common/tests/test_log.py +++ b/pyomo/common/tests/test_log.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -511,7 +511,6 @@ def test_verbatim(self): "\n" " quote block\n" ) - self.maxDiff = None self.assertEqual(self.stream.getvalue(), ans) @@ -527,6 +526,12 @@ def test_log_stream(self): ls.write("line 1\nline 2\n") self.assertEqual(OUT.getvalue(), "INFO: line 1\nINFO: line 2\n") + with LI as OUT: + # empty writes do not generate log records + ls.write("") + ls.flush() + self.assertEqual(OUT.getvalue(), "") + with LI as OUT: ls.write("line 1\nline 2") self.assertEqual(OUT.getvalue(), "INFO: line 1\n") @@ -545,6 +550,18 @@ def test_log_stream(self): # Exiting the context manager flushes the LogStream self.assertEqual(OUT.getvalue(), "INFO: line 1\nINFO: line 2\n") + def test_loggerAdapter(self): + class Adapter(logging.LoggerAdapter): + def process(self, msg, kwargs): + return '[%s] %s' % (self.extra['foo'], msg), kwargs + + adapter = Adapter(logging.getLogger('pyomo'), {"foo": 42}) + ls = LogStream(logging.INFO, adapter) + LI = LoggingIntercept(level=logging.INFO, formatter=pyomo_formatter) + with LI as OUT: + ls.write("hello, world\n") + self.assertEqual(OUT.getvalue(), "INFO: [42] hello, world\n") + class TestPreformatted(unittest.TestCase): def test_preformatted_api(self): @@ -559,3 +576,46 @@ def test_preformatted_api(self): self.assertIs(msg.msg, ref) self.assertEqual(str(msg), '2') self.assertEqual(repr(msg), "Preformatted(2)") + + +class TestLoggingIntercept(unittest.TestCase): + def test_init(self): + li = LoggingIntercept() + self.assertEqual(li.module, 'root') + + li = LoggingIntercept(module='pyomo.core') + self.assertEqual(li.module, 'pyomo.core') + + li = LoggingIntercept(logger=logger) + self.assertEqual(li.module, 'pyomo.common.log.testing') + + with self.assertRaisesRegex( + ValueError, "LoggingIntercept: only one of 'module' and 'logger' is allowed" + ): + li = LoggingIntercept(module='pyomo', logger=logger) + + def test_propagate(self): + self.assertEqual(logger.propagate, True) + with LoggingIntercept(logger=logger): + self.assertEqual(logger.propagate, False) + self.assertEqual(logger.propagate, True) + + def test_propagate(self): + self.assertEqual(logger.level, 30) + try: + with LoggingIntercept(logger=logger, level=None): + self.assertEqual(logger.level, 30) + self.assertEqual(logger.level, 30) + with LoggingIntercept(logger=logger, level=40): + self.assertEqual(logger.level, 40) + self.assertEqual(logger.level, 30) + + logger.setLevel(40) + with LoggingIntercept(logger=logger, level=None): + self.assertEqual(logger.level, 40) + self.assertEqual(logger.level, 40) + with LoggingIntercept(logger=logger, level=30): + self.assertEqual(logger.level, 30) + self.assertEqual(logger.level, 40) + finally: + logger.setLevel(30) diff --git a/pyomo/common/tests/test_modeling.py b/pyomo/common/tests/test_modeling.py index 97bef76c2c0..30d50899c49 100644 --- a/pyomo/common/tests/test_modeling.py +++ b/pyomo/common/tests/test_modeling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,10 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import sys - import pyomo.common.unittest as unittest from pyomo.environ import ConcreteModel, Var -from pyomo.common.modeling import unique_component_name, NOTSET +from pyomo.common.modeling import unique_component_name class TestModeling(unittest.TestCase): @@ -48,8 +46,3 @@ def test_unique_component_name(self): self.assertEqual(name[:2], 'y_') self.assertIn(name[2], '0123456789') self.assertIn(name[3], '0123456789') - - def test_NOTSET(self): - self.assertEqual(str(NOTSET), 'NOTSET') - assert 'sphinx' not in sys.modules - self.assertEqual(repr(NOTSET), 'pyomo.common.modeling.NOTSET') diff --git a/pyomo/common/tests/test_multithread.py b/pyomo/common/tests/test_multithread.py index fa1a46fa25f..a8a1c67f07d 100644 --- a/pyomo/common/tests/test_multithread.py +++ b/pyomo/common/tests/test_multithread.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_numeric_types.py b/pyomo/common/tests/test_numeric_types.py new file mode 100644 index 00000000000..2a751f6569a --- /dev/null +++ b/pyomo/common/tests/test_numeric_types.py @@ -0,0 +1,219 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.numeric_types as nt +import pyomo.common.unittest as unittest + +from pyomo.common.dependencies import numpy, numpy_available +from pyomo.core.expr import LinearExpression +from pyomo.environ import Var + +_type_sets = ( + 'native_types', + 'native_numeric_types', + 'native_logical_types', + 'native_integer_types', + 'native_complex_types', +) + + +class TestNativeTypes(unittest.TestCase): + def setUp(self): + bool(numpy_available) + for s in _type_sets: + setattr(self, s, set(getattr(nt, s))) + getattr(nt, s).clear() + + def tearDown(self): + for s in _type_sets: + getattr(nt, s).clear() + getattr(nt, s).update(getattr(self, s)) + + def test_check_if_native_type(self): + self.assertEqual(nt.native_types, set()) + self.assertEqual(nt.native_logical_types, set()) + self.assertEqual(nt.native_numeric_types, set()) + self.assertEqual(nt.native_integer_types, set()) + self.assertEqual(nt.native_complex_types, set()) + + self.assertTrue(nt.check_if_native_type("a")) + self.assertIn(str, nt.native_types) + self.assertNotIn(str, nt.native_logical_types) + self.assertNotIn(str, nt.native_numeric_types) + self.assertNotIn(str, nt.native_integer_types) + self.assertNotIn(str, nt.native_complex_types) + + self.assertTrue(nt.check_if_native_type(1)) + self.assertIn(int, nt.native_types) + self.assertNotIn(int, nt.native_logical_types) + self.assertIn(int, nt.native_numeric_types) + self.assertIn(int, nt.native_integer_types) + self.assertNotIn(int, nt.native_complex_types) + + self.assertTrue(nt.check_if_native_type(1.5)) + self.assertIn(float, nt.native_types) + self.assertNotIn(float, nt.native_logical_types) + self.assertIn(float, nt.native_numeric_types) + self.assertNotIn(float, nt.native_integer_types) + self.assertNotIn(float, nt.native_complex_types) + + self.assertTrue(nt.check_if_native_type(True)) + self.assertIn(bool, nt.native_types) + self.assertIn(bool, nt.native_logical_types) + self.assertNotIn(bool, nt.native_numeric_types) + self.assertNotIn(bool, nt.native_integer_types) + self.assertNotIn(bool, nt.native_complex_types) + + self.assertFalse(nt.check_if_native_type(slice(None, None, None))) + self.assertNotIn(slice, nt.native_types) + self.assertNotIn(slice, nt.native_logical_types) + self.assertNotIn(slice, nt.native_numeric_types) + self.assertNotIn(slice, nt.native_integer_types) + self.assertNotIn(slice, nt.native_complex_types) + + def test_check_if_logical_type(self): + self.assertEqual(nt.native_types, set()) + self.assertEqual(nt.native_logical_types, set()) + self.assertEqual(nt.native_numeric_types, set()) + self.assertEqual(nt.native_integer_types, set()) + self.assertEqual(nt.native_complex_types, set()) + + self.assertFalse(nt.check_if_logical_type("a")) + self.assertNotIn(str, nt.native_types) + self.assertNotIn(str, nt.native_logical_types) + self.assertNotIn(str, nt.native_numeric_types) + self.assertNotIn(str, nt.native_integer_types) + self.assertNotIn(str, nt.native_complex_types) + + self.assertFalse(nt.check_if_logical_type("a")) + + self.assertTrue(nt.check_if_logical_type(True)) + self.assertIn(bool, nt.native_types) + self.assertIn(bool, nt.native_logical_types) + self.assertNotIn(bool, nt.native_numeric_types) + self.assertNotIn(bool, nt.native_integer_types) + self.assertNotIn(bool, nt.native_complex_types) + + self.assertTrue(nt.check_if_logical_type(True)) + + self.assertFalse(nt.check_if_logical_type(1)) + self.assertNotIn(int, nt.native_types) + self.assertNotIn(int, nt.native_logical_types) + self.assertNotIn(int, nt.native_numeric_types) + self.assertNotIn(int, nt.native_integer_types) + self.assertNotIn(int, nt.native_complex_types) + + if numpy_available: + self.assertTrue(nt.check_if_logical_type(numpy.bool_(1))) + self.assertIn(numpy.bool_, nt.native_types) + self.assertIn(numpy.bool_, nt.native_logical_types) + self.assertNotIn(numpy.bool_, nt.native_numeric_types) + self.assertNotIn(numpy.bool_, nt.native_integer_types) + self.assertNotIn(numpy.bool_, nt.native_complex_types) + + def test_check_if_numeric_type(self): + self.assertEqual(nt.native_types, set()) + self.assertEqual(nt.native_logical_types, set()) + self.assertEqual(nt.native_numeric_types, set()) + self.assertEqual(nt.native_integer_types, set()) + self.assertEqual(nt.native_complex_types, set()) + + self.assertFalse(nt.check_if_numeric_type("a")) + self.assertFalse(nt.check_if_numeric_type("a")) + self.assertNotIn(str, nt.native_types) + self.assertNotIn(str, nt.native_logical_types) + self.assertNotIn(str, nt.native_numeric_types) + self.assertNotIn(str, nt.native_integer_types) + self.assertNotIn(str, nt.native_complex_types) + + self.assertFalse(nt.check_if_numeric_type(True)) + self.assertFalse(nt.check_if_numeric_type(True)) + self.assertNotIn(bool, nt.native_types) + self.assertNotIn(bool, nt.native_logical_types) + self.assertNotIn(bool, nt.native_numeric_types) + self.assertNotIn(bool, nt.native_integer_types) + self.assertNotIn(bool, nt.native_complex_types) + + self.assertTrue(nt.check_if_numeric_type(1)) + self.assertTrue(nt.check_if_numeric_type(1)) + self.assertIn(int, nt.native_types) + self.assertNotIn(int, nt.native_logical_types) + self.assertIn(int, nt.native_numeric_types) + self.assertIn(int, nt.native_integer_types) + self.assertNotIn(int, nt.native_complex_types) + + self.assertTrue(nt.check_if_numeric_type(1.5)) + self.assertTrue(nt.check_if_numeric_type(1.5)) + self.assertIn(float, nt.native_types) + self.assertNotIn(float, nt.native_logical_types) + self.assertIn(float, nt.native_numeric_types) + self.assertNotIn(float, nt.native_integer_types) + self.assertNotIn(float, nt.native_complex_types) + + self.assertFalse(nt.check_if_numeric_type(1j)) + self.assertIn(complex, nt.native_types) + self.assertNotIn(complex, nt.native_logical_types) + self.assertNotIn(complex, nt.native_numeric_types) + self.assertNotIn(complex, nt.native_integer_types) + self.assertIn(complex, nt.native_complex_types) + + v = Var() + v.construct() + self.assertFalse(nt.check_if_numeric_type(v)) + self.assertNotIn(type(v), nt.native_types) + self.assertNotIn(type(v), nt.native_logical_types) + self.assertNotIn(type(v), nt.native_numeric_types) + self.assertNotIn(type(v), nt.native_integer_types) + self.assertNotIn(type(v), nt.native_complex_types) + + e = LinearExpression([1]) + self.assertFalse(nt.check_if_numeric_type(e)) + self.assertNotIn(type(e), nt.native_types) + self.assertNotIn(type(e), nt.native_logical_types) + self.assertNotIn(type(e), nt.native_numeric_types) + self.assertNotIn(type(e), nt.native_integer_types) + self.assertNotIn(type(e), nt.native_complex_types) + + if numpy_available: + self.assertFalse(nt.check_if_numeric_type(numpy.bool_(1))) + self.assertNotIn(numpy.bool_, nt.native_types) + self.assertNotIn(numpy.bool_, nt.native_logical_types) + self.assertNotIn(numpy.bool_, nt.native_numeric_types) + self.assertNotIn(numpy.bool_, nt.native_integer_types) + self.assertNotIn(numpy.bool_, nt.native_complex_types) + + self.assertFalse(nt.check_if_numeric_type(numpy.array([1]))) + self.assertNotIn(numpy.ndarray, nt.native_types) + self.assertNotIn(numpy.ndarray, nt.native_logical_types) + self.assertNotIn(numpy.ndarray, nt.native_numeric_types) + self.assertNotIn(numpy.ndarray, nt.native_integer_types) + self.assertNotIn(numpy.ndarray, nt.native_complex_types) + + self.assertTrue(nt.check_if_numeric_type(numpy.float64(1))) + self.assertIn(numpy.float64, nt.native_types) + self.assertNotIn(numpy.float64, nt.native_logical_types) + self.assertIn(numpy.float64, nt.native_numeric_types) + self.assertNotIn(numpy.float64, nt.native_integer_types) + self.assertNotIn(numpy.float64, nt.native_complex_types) + + self.assertTrue(nt.check_if_numeric_type(numpy.int64(1))) + self.assertIn(numpy.int64, nt.native_types) + self.assertNotIn(numpy.int64, nt.native_logical_types) + self.assertIn(numpy.int64, nt.native_numeric_types) + self.assertIn(numpy.int64, nt.native_integer_types) + self.assertNotIn(numpy.int64, nt.native_complex_types) + + self.assertFalse(nt.check_if_numeric_type(numpy.complex128(1))) + self.assertIn(numpy.complex128, nt.native_types) + self.assertNotIn(numpy.complex128, nt.native_logical_types) + self.assertNotIn(numpy.complex128, nt.native_numeric_types) + self.assertNotIn(numpy.complex128, nt.native_integer_types) + self.assertIn(numpy.complex128, nt.native_complex_types) diff --git a/pyomo/common/tests/test_orderedset.py b/pyomo/common/tests/test_orderedset.py index 8f944e66bd7..b1aacbd049b 100644 --- a/pyomo/common/tests/test_orderedset.py +++ b/pyomo/common/tests/test_orderedset.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_plugin.py b/pyomo/common/tests/test_plugin.py index 54431334d5b..76044e50610 100644 --- a/pyomo/common/tests/test_plugin.py +++ b/pyomo/common/tests/test_plugin.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_sorting.py b/pyomo/common/tests/test_sorting.py index 7fbefda6a19..5bd3c3399d8 100644 --- a/pyomo/common/tests/test_sorting.py +++ b/pyomo/common/tests/test_sorting.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_tee.py b/pyomo/common/tests/test_tee.py index a5c6ee894b2..2316a7dbc3d 100644 --- a/pyomo/common/tests/test_tee.py +++ b/pyomo/common/tests/test_tee.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,19 +10,79 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import gc +import itertools +import logging import os +import platform import time import sys from io import StringIO, BytesIO -from pyomo.common.log import LoggingIntercept +from pyomo.common.log import LoggingIntercept, LogStream import pyomo.common.unittest as unittest from pyomo.common.tempfiles import TempfileManager import pyomo.common.tee as tee +class timestamper: + """A 'TextIO'-like object that records the time when data was written to + the stream.""" + + def __init__(self): + self.buf = [] + self.error = "" + + def write(self, data): + for line in data.splitlines(): + line = line.strip() + if not line: + continue + self.buf.append((time.time(), float(line))) + + def writelines(self, data): + for line in data: + self.write(line.strip()) + + def flush(self): + pass + + def check(self, *bases): + """Map the recorded times to {0, 1} based on the range of times + recorded: anything in the first half of the range is mapped to + 0, and anything in the second half is mapped to 1. This + "discretizes" the times so that we can reliably compare to + baselines. + + """ + + n = list(itertools.chain(*self.buf)) + mid = (min(n) + max(n)) / 2.0 + result = [tuple(0 if i < mid else 1 for i in _) for _ in self.buf] + if result not in bases: + base = ' or '.join(str(_) for _ in bases) + self.error = f"result {result} != baseline {base}\nRaw timing: {self.buf}" + return False + return True + + class TestTeeStream(unittest.TestCase): + def setUp(self): + self.reenable_gc = gc.isenabled() + gc.disable() + gc.collect() + # Set a short switch interval so that the threading tests behave + # as expected + self.switchinterval = sys.getswitchinterval() + sys.setswitchinterval(tee._poll_interval / 100) + + def tearDown(self): + sys.setswitchinterval(self.switchinterval) + if self.reenable_gc: + gc.enable() + gc.collect() + def test_stdout(self): a = StringIO() b = StringIO() @@ -39,6 +99,28 @@ def test_err_and_out_are_different(self): self.assertIs(err, t.STDERR) self.assertIsNot(out, err) + def test_signal_flush(self): + a = StringIO() + with tee.TeeStream(a) as t: + out = t.STDOUT + self.assertIs(type(out), tee._SignalFlush) + out.write("out1\n") + out.writelines(["out2\n", "out3\n"]) + self.assertEqual(a.getvalue(), "out1\nout2\nout3\n") + with tee.TeeStream(a) as t: + err = t.STDERR + self.assertIs(type(err), tee._AutoFlush) + err.write("err1\n") + err.writelines(["err2\n", "err3\n"]) + self.assertEqual(a.getvalue(), "out1\nout2\nout3\nerr1\nerr2\nerr3\n") + # Note: 'is not writable' appears to work for all platforms + # except PyPy, where the exception is "readonly attribute + # 'name'" + with self.assertRaisesRegex( + AttributeError, '.*(is not writable)|(readonly attribute)' + ): + tee.TeeStream().STDOUT.name = 'foo' + @unittest.skipIf( not tee._peek_available, "Requires the _mergedReader, but _peek_available==False", @@ -56,7 +138,8 @@ def test_merge_out_and_err(self): # This is a slightly nondeterministic (on Windows), so a # flush() and short pause should help t.STDOUT.write("Hello\nWorld") - t.STDOUT.flush() + # NOTE: do not flush: we will test flush in the next test + # t.STDOUT.flush() time.sleep(tee._poll_interval * 100) t.STDERR.write("interrupting\ncow") t.STDERR.flush() @@ -75,6 +158,38 @@ def test_merge_out_and_err(self): self.assertIn(a.getvalue(), acceptable_results) self.assertEqual(b.getvalue(), a.getvalue()) + def test_merge_out_and_err_flush(self): + # Test that the STDERR/STDOUT streams are merged correctly + # (i.e., STDOUT is line buffered and STDERR is not). This merge + # logic is only applicable when using the merged reader (i.e., + # _peek_available is True) + a = StringIO() + b = StringIO() + # make sure this doesn't accidentally become a very long wait + assert tee._poll_interval <= 0.1 + with tee.TeeStream(a, b) as t: + # This is a slightly nondeterministic (on Windows), so a + # flush() and short pause should help + t.STDOUT.write("Hello\nWorld") + t.STDOUT.flush() + time.sleep(tee._poll_interval * 100) + t.STDERR.write("interrupting\ncow") + t.STDERR.flush() + # For determinism, it is important that the STDERR message + # appears in the output stream before we start shutting down + # the TeeStream (which will dump the OUT and ERR in an + # arbitrary order) + start_time = time.time() + while 'cow' not in a.getvalue() and time.time() - start_time < 1: + time.sleep(tee._poll_interval) + acceptable_results = { + "Hello\nWorldinterrupting\ncow", # expected + "interrupting\ncowHello\nWorld", # Windows occasionally puts + # all error before stdout + } + self.assertIn(a.getvalue(), acceptable_results) + self.assertEqual(b.getvalue(), a.getvalue()) + def test_merged_out_and_err_without_peek(self): a = StringIO() b = StringIO() @@ -113,9 +228,6 @@ def test_decoder_and_buffer_errors(self): os.write(t.STDOUT.fileno(), bytes_ref[:-1]) self.assertEqual( log.getvalue(), - "Stream handle closed with a partial line in the output buffer " - "that was not emitted to the output stream(s):\n" - "\t'Hello, '\n" "Stream handle closed with un-decoded characters in the decoder " "buffer that was not emitted to the output stream(s):\n" "\tb'\\xc2'\n", @@ -127,12 +239,84 @@ def test_decoder_and_buffer_errors(self): with tee.TeeStream(out) as t: out.close() t.STDOUT.write("hi\n") + _id = hex(id(out)) + self.assertRegex( + log.getvalue(), + f"Error writing to output stream <_?io.StringIO @ {_id}>:" + r"\n.*\nOutput stream closed before all output was written to it.\n" + r"The following was left in the output buffer:\n 'hi\\n'\n$", + ) + + # TeeStream expects stream-like objects + out = logging.getLogger() + log = StringIO() + with LoggingIntercept(log): + with tee.TeeStream(out) as t: + t.STDOUT.write("hi\n") + _id = hex(id(out)) + self.assertRegex( + log.getvalue(), + f"Error writing to output stream :" + r"\n.*\nIs this a writeable TextIOBase object\?\n" + r"The following was left in the output buffer:\n 'hi\\n'\n$", + ) + + # Catch partial writes + class fake_stream: + def write(self, data): + return 1 + + out = fake_stream() + log = StringIO() + with LoggingIntercept(log): + with tee.TeeStream(out) as t: + t.STDOUT.write("hi\n") + _id = hex(id(out)) self.assertRegex( log.getvalue(), - r"^Output stream \(<.*?>\) closed before all output was written " - r"to it. The following was left in the output buffer:\n\t'hi\\n'\n$", + f"Incomplete write to output stream <.*fake_stream @ {_id}>." + r"\nThe following was left in the output buffer:\n 'i\\n'\n$", ) + def test_context_mismatch(self): + with self.assertRaisesRegex( + RuntimeError, "TeeStream: exiting a context that was not entered" + ): + with tee.TeeStream() as t: + t.__exit__(None, None, None) + + def test_handle_prematurely_closed(self): + # Close the TextIO object + with LoggingIntercept() as LOG: + with tee.TeeStream() as t: + t.STDOUT.close() + self.assertEqual(LOG.getvalue(), "") + + # Close the underlying file descriptor + with LoggingIntercept() as LOG: + with tee.TeeStream() as t: + os.close(t.STDOUT.fileno()) + self.assertEqual(LOG.getvalue(), "") + + +class TestCapture(unittest.TestCase): + def setUp(self): + self.streams = sys.stdout, sys.stderr + self.reenable_gc = gc.isenabled() + gc.disable() + gc.collect() + # Set a short switch interval so that the threading tests behave + # as expected + self.switchinterval = sys.getswitchinterval() + sys.setswitchinterval(tee._poll_interval / 100) + + def tearDown(self): + sys.stdout, sys.stderr = self.streams + sys.setswitchinterval(self.switchinterval) + if self.reenable_gc: + gc.enable() + gc.collect() + def test_capture_output(self): out = StringIO() with tee.capture_output(out) as OUT: @@ -161,47 +345,534 @@ def test_capture_output_logfile_string(self): result = f.read() self.assertEqual('HELLO WORLD\n', result) + # Check that an exception when entering the context will cleanly + # unroll the context stack + logfile = os.path.join('path', 'to', 'nonexisting', 'file.txt') + T = tee.capture_output(logfile) + with self.assertRaises(FileNotFoundError): + T.__enter__() + self.assertEqual(T.context_stack, []) + + def test_capture_to_logger(self): + logger = logging.getLogger('_pyomo_no_logger') + lstream = LogStream(logging.WARNING, logger) + orig = logger.propagate, logger.handlers + try: + logger.propagate = False + logger.handlers = [] + with LoggingIntercept(module='_pyomo_no_logger') as LOG: + with tee.capture_output(lstream, capture_fd=False): + sys.stderr.write("hi!\n") + sys.stderr.flush() + self.assertEqual(LOG.getvalue(), "hi!\n") + + # test that we handle the lastResort logger correctly + _lastResort = logging.lastResort + with tee.capture_output() as OUT: + with tee.capture_output(lstream, capture_fd=False): + self.assertIsNot(_lastResort, logging.lastResort) + sys.stderr.write("hi?\n") + self.assertEqual(OUT.getvalue(), "hi?\n") + + # test that we allow redirect-to-logger out + with tee.capture_output() as OUT: + logger.addHandler(logging.NullHandler()) + logger.addHandler(logging.StreamHandler(sys.stderr)) + with tee.capture_output(lstream, capture_fd=False): + sys.stderr.write("hi.\n") + self.assertEqual(OUT.getvalue(), "hi.\n") + logger.handlers.clear() + + # test a sub-logger + lstream = LogStream( + logging.WARNING, logging.getLogger('_pyomo_no_logger.foo') + ) + with tee.capture_output() as OUT: + logger.addHandler(logging.NullHandler()) + logger.addHandler(logging.StreamHandler(sys.stderr)) + with tee.capture_output(lstream, capture_fd=False): + sys.stderr.write("hi,\n") + self.assertEqual(OUT.getvalue(), "hi,\n") + finally: + logger.propagate, logger.handlers = orig + + def test_capture_fd_to_logger(self): + logger = logging.getLogger('_pyomo_no_logger') + lstream = LogStream(logging.WARNING, logger) + orig = logger.propagate, logger.handlers + try: + logger.propagate = False + logger.handlers = [] + with LoggingIntercept(module='_pyomo_no_logger') as LOG: + with tee.capture_output(lstream, capture_fd=True): + sys.stderr.write("hi!\n") + sys.stderr.flush() + self.assertEqual(LOG.getvalue(), "hi!\n") + + # test that we handle the lastResort logger correctly + _lastResort = logging.lastResort + with tee.capture_output() as OUT: + with tee.capture_output(lstream, capture_fd=True): + self.assertIsNot(_lastResort, logging.lastResort) + sys.stderr.write("hi?\n") + self.assertEqual(OUT.getvalue(), "hi?\n") + + # test that we allow redirect-to-logger out + with tee.capture_output() as OUT: + logger.addHandler(logging.NullHandler()) + logger.addHandler(logging.StreamHandler(sys.stderr)) + with tee.capture_output(lstream, capture_fd=True): + sys.stderr.write("hi.\n") + self.assertEqual(OUT.getvalue(), "hi.\n") + logger.handlers.clear() + + # test a sub-logger + lstream = LogStream( + logging.WARNING, logging.getLogger('_pyomo_no_logger.foo') + ) + with tee.capture_output() as OUT: + logger.addHandler(logging.NullHandler()) + logger.addHandler(logging.StreamHandler(sys.stderr)) + with tee.capture_output(lstream, capture_fd=True): + sys.stderr.write("hi,\n") + self.assertEqual(OUT.getvalue(), "hi,\n") + finally: + logger.propagate, logger.handlers = orig + + def test_capture_to_logger_adapter(self): + class Adapter(logging.LoggerAdapter): + def process(self, msg, kwargs): + return '[%s] %s' % (self.extra['foo'], msg), kwargs + + logger = logging.getLogger('_pyomo_no_logger') + adapter = Adapter(logger, {"foo": 42}) + lstream = LogStream(logging.WARNING, adapter) + orig = logger.propagate, logger.handlers + try: + logger.propagate = False + logger.handlers = [] + with LoggingIntercept(module='_pyomo_no_logger') as LOG: + with tee.capture_output(lstream, capture_fd=False): + sys.stderr.write("hi!\n") + sys.stderr.flush() + self.assertEqual(LOG.getvalue(), "[42] hi!\n") + + # test that we handle the lastResort logger correctly + _lastResort = logging.lastResort + with tee.capture_output() as OUT: + with tee.capture_output(lstream, capture_fd=False): + self.assertIsNot(_lastResort, logging.lastResort) + sys.stderr.write("hi?\n") + self.assertEqual(OUT.getvalue(), "[42] hi?\n") + + # test that we allow redirect-to-logger out + with tee.capture_output() as OUT: + logger.addHandler(logging.NullHandler()) + logger.addHandler(logging.StreamHandler(sys.stderr)) + with tee.capture_output(lstream, capture_fd=False): + sys.stderr.write("hi.\n") + self.assertEqual(OUT.getvalue(), "[42] hi.\n") + logger.handlers.clear() + finally: + logger.propagate, logger.handlers = orig + + def test_capture_fd_to_logger_adapter(self): + class Adapter(logging.LoggerAdapter): + def process(self, msg, kwargs): + return '[%s] %s' % (self.extra['foo'], msg), kwargs + + logger = logging.getLogger('_pyomo_no_logger') + adapter = Adapter(logger, {"foo": 42}) + lstream = LogStream(logging.WARNING, adapter) + orig = logger.propagate, logger.handlers + try: + logger.propagate = False + logger.handlers = [] + with LoggingIntercept(module='_pyomo_no_logger') as LOG: + with tee.capture_output(lstream, capture_fd=True): + sys.stderr.write("hi!\n") + sys.stderr.flush() + self.assertEqual(LOG.getvalue(), "[42] hi!\n") + + # test that we handle the lastResort logger correctly + _lastResort = logging.lastResort + with tee.capture_output() as OUT: + with tee.capture_output(lstream, capture_fd=True): + self.assertIsNot(_lastResort, logging.lastResort) + sys.stderr.write("hi?\n") + self.assertEqual(OUT.getvalue(), "[42] hi?\n") + + # test that we allow redirect-to-logger out + with tee.capture_output() as OUT: + logger.addHandler(logging.NullHandler()) + logger.addHandler(logging.StreamHandler(sys.stderr)) + with tee.capture_output(lstream, capture_fd=True): + sys.stderr.write("hi.\n") + self.assertEqual(OUT.getvalue(), "[42] hi.\n") + logger.handlers.clear() + finally: + logger.propagate, logger.handlers = orig + + def test_no_fileno_stdout(self): + T = tee.capture_output() + with T: + self.assertEqual(len(T.context_stack), 2) + T = tee.capture_output(capture_fd=True) + # out & err point to something other than fd 1 and 2 + sys.stdout = os.fdopen(os.dup(1), closefd=True) + sys.stderr = os.fdopen(os.dup(2), closefd=True) + with sys.stdout, sys.stderr: + with T: + self.assertEqual(len(T.context_stack), 8) + # out & err point to fd 1 and 2 + sys.stdout = os.fdopen(1, closefd=False) + sys.stderr = os.fdopen(2, closefd=False) + with sys.stdout, sys.stderr: + with T: + self.assertEqual(len(T.context_stack), 6) + # out & err have no fileno + sys.stdout = StringIO() + sys.stderr = StringIO() + with sys.stdout, sys.stderr: + with T: + self.assertEqual(len(T.context_stack), 6) + def test_capture_output_stack_error(self): OUT1 = StringIO() OUT2 = StringIO() old = (sys.stdout, sys.stderr) + old_fd = os.dup(1), os.dup(2) try: a = tee.capture_output(OUT1) a.setup() b = tee.capture_output(OUT2) b.setup() with self.assertRaisesRegex( - RuntimeError, 'Captured output does not match sys.stdout' + RuntimeError, 'Captured output .* does not match sys.stdout' ): a.reset() - b.tee = None finally: + # Clear b so that it doesn't call __exit__ and corrupt stdout/stderr + b.tee = None + os.dup2(old_fd[0], 1) + os.dup2(old_fd[1], 2) sys.stdout, sys.stderr = old + logging.getLogger('pyomo.common.tee').handlers.clear() + + def test_capture_output_invalid_ostream(self): + # Test that capture_output does not suppress errors from the tee + # module + _id = hex(id(15)) + with tee.capture_output(capture_fd=True) as OUT: + with tee.capture_output(15): + sys.stderr.write("hi\n") + self.assertEqual( + OUT.getvalue(), + f"Error writing to output stream :\n" + " AttributeError: 'int' object has no attribute 'write'\n" + "Is this a writeable TextIOBase object?\n" + "The following was left in the output buffer:\n 'hi\\n'\n", + ) + + with tee.capture_output(capture_fd=True) as OUT: + with tee.capture_output(15, capture_fd=True): + print("hi") + self.assertEqual( + OUT.getvalue(), + f"Error writing to output stream :\n" + " AttributeError: 'int' object has no attribute 'write'\n" + "Is this a writeable TextIOBase object?\n" + "The following was left in the output buffer:\n 'hi\\n'\n", + ) + + def test_exit_on_del(self): + # This is a weird "feature", but because things like the pyomo + # script will create and "enter" a capture_output object without + # using a context manager, it is possible that the object can be + # deleted without calling __exit__. Check that the context + # stack is correctly unwound + T = tee.capture_output() + T.__enter__() + stack = T.context_stack + self.assertGreater(len(stack), 0) + del T + # This is a bit tricky: for cpython, T should be immediately + # deallocated (including calling __del__) through reference + # counting. pypy is trickier: because it lacks + # reference-counting, it must rely on the GC. We have seen + # cases on GHA where a single call to gc.collect() was sometimes + # insufficient to ensure that T was collected (but unable to + # reproduce it locally). We will try up to 4 times (1 more than + # the number of generations in the GC) + remaining_attempts = 4 + while len(stack) and remaining_attempts: + gc.collect() + time.sleep(((4 - remaining_attempts) / 4.0) ** 2) + remaining_attempts -= 1 + try: + self.assertEqual(len(stack), 0) + except: + # We still want to unwind the context managers if the test fails: + while stack: + try: + stack.pop().__exit__(None, None, None) + except: + pass + raise def test_deadlock(self): class MockStream(object): def write(self, data): - time.sleep(0.2) + # This test will kill the write before this sleep is + # done, so a long sleep won't impact suite run time. + time.sleep(1) + + def flush(self): + pass _save = tee._poll_timeout, tee._poll_timeout_deadlock tee._poll_timeout = tee._poll_interval * 2**5 # 0.0032 tee._poll_timeout_deadlock = tee._poll_interval * 2**7 # 0.0128 - try: with LoggingIntercept() as LOG, self.assertRaisesRegex( RuntimeError, 'deadlock' ): with tee.TeeStream(MockStream()) as t: - err = t.STDERR - err.write('*') + t.STDERR.write('*') self.assertEqual( 'Significant delay observed waiting to join reader ' - 'threads, possible output stream deadlock\n', + 'threads, possible output stream deadlock\n' + 'TeeStream: deadlock observed joining reader threads\n', LOG.getvalue(), ) finally: tee._poll_timeout, tee._poll_timeout_deadlock = _save + _save = tee._poll_timeout, tee._poll_timeout_deadlock + tee._poll_timeout = tee._poll_interval * 2**5 # 0.0032 + tee._poll_timeout_deadlock = tee._poll_interval * 2**7 # 0.0128 + try: + with LoggingIntercept() as LOG, self.assertRaisesRegex( + ValueError, 'testing' + ): + with tee.TeeStream(MockStream()) as t: + t.STDERR.write('*') + raise ValueError('testing') + self.assertEqual("", LOG.getvalue()) + finally: + tee._poll_timeout, tee._poll_timeout_deadlock = _save + + +class BufferTester(object): + def setUp(self): + sys.stdout.flush() + sys.stderr.flush() + self.reenable_gc = gc.isenabled() + gc.disable() + gc.collect() + # Set a short switch interval so that the threading tests behave + # as expected + self.switchinterval = sys.getswitchinterval() + sys.setswitchinterval(tee._poll_interval) + self.dt = 0.1 + + def tearDown(self): + sys.setswitchinterval(self.switchinterval) + if self.reenable_gc: + gc.enable() + gc.collect() + + def test_buffered_stdout(self): + # Test 1: short messages to STDOUT are buffered + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}\n") + with tee.TeeStream(ts, ts) as t, tee.capture_output(t.STDOUT, capture_fd=fd): + sys.stdout.write(f"{time.time()}\n") + time.sleep(self.dt) + ts.write(f"{time.time()}\n") + baseline = [ + [(0, 0), (1, 0), (1, 0), (1, 1)], + # TODO: [JDS] The stdout channel appears to sometimes be no + # longer buffered. I am not exactly sure why (my guess is + # because the underlying pipe is not buffered), but as it is + # generally not a problem to not buffer, we will put off + # "fixing" it. + [(0, 0), (0, 0), (0, 0), (1, 1)], + ] + if not ts.check(*baseline): + self.fail(ts.error) + + def test_buffered_stdout_flush(self, retry=True): + # Test 2: short messages to STDOUT that are flushed are flushed + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}\n") + with tee.TeeStream(ts, ts) as t, tee.capture_output(t.STDOUT, capture_fd=fd): + sys.stdout.write(f"{time.time()}\n") + sys.stdout.flush() + time.sleep(self.dt) + ts.write(f"{time.time()}\n") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + # FIXME: We don't know why, but this test will + # intermittently fail. For the moment, we will just wait a + # little and give it a second chance with a longer delay. + if retry: + time.sleep(self.dt) + self.dt *= 2.5 + self.test_buffered_stdout_flush(False) + elif platform.python_implementation().lower().startswith('pypy'): + # TODO: For some reason, some part of the flush logic is + # not reliable under pypy. + pass + else: + self.fail(ts.error) + + def test_buffered_stdout_long_message(self): + # Test 3: long messages to STDOUT fill the buffer and are flushed + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.TeeStream(ts, ts) as t, tee.capture_output(t.STDOUT, capture_fd=fd): + sys.stdout.write(f"{time.time()}" + ' ' * 4096 + "\n") + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stdout_embed_TeeStream(self): + # Test 4: short messages captured directly to TeeStream are not + # buffered. + # + # TODO: [JDS] I am not exactly sure why this is not buffered (my + # guess is because the underlying pipe is not buffered), but as + # it is generally not a problem to not buffer, we will put off + # "fixing" it. + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.capture_output(tee.TeeStream(ts, ts), capture_fd=fd): + sys.stdout.write(f"{time.time()}\n") + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stdout_flush_embed_TeeStream(self): + # Test 5: short messages captured directly to TeeStream that are + # flushed are flushed + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.capture_output(tee.TeeStream(ts, ts), capture_fd=fd): + sys.stdout.write(f"{time.time()}\n") + sys.stdout.flush() + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stdout_long_message_embed_TeeStream(self): + # Test 6: long messages captured directly to TeeStream fill the + # buffer and are flushed + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.capture_output(tee.TeeStream(ts, ts), capture_fd=fd): + sys.stdout.write(f"{time.time()}" + ' ' * 4096 + "\n") + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stderr(self): + # Test 1: short messages to STDERR are buffered, unless we are + # capturing the underlying file descriptor, in which case they + # are buffered. + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.TeeStream(ts, ts) as t, tee.capture_output(t.STDOUT, capture_fd=fd): + sys.stderr.write(f"{time.time()}\n") + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stderr_flush(self): + # Test 2: short messages to STDERR that are flushed are flushed + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.TeeStream(ts, ts) as t, tee.capture_output(t.STDOUT, capture_fd=fd): + sys.stderr.write(f"{time.time()}\n") + sys.stderr.flush() + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stderr_long_message(self): + # Test 3: long messages to STDERR fill the buffer and are flushed + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.TeeStream(ts, ts) as t, tee.capture_output(t.STDOUT, capture_fd=fd): + sys.stderr.write(f"{time.time()}" + ' ' * 4096 + "\n") + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stderr_embed_TeeStream(self): + # Test 4: short messages captured directly to TeeStream are not + # buffered, unless we are capturing the underlying file + # descriptor, in which case they are buffered. + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.capture_output(tee.TeeStream(ts, ts), capture_fd=fd): + sys.stderr.write(f"{time.time()}\n") + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stderr_flush_embed_TeeStream(self): + # Test 5: short messages captured directly to TeeStream that are + # flushed are flushed + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.capture_output(tee.TeeStream(ts, ts), capture_fd=fd): + sys.stderr.write(f"{time.time()}\n") + sys.stderr.flush() + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + def test_buffered_stderr_long_message_embed_TeeStream(self): + # Test 6: long messages captured directly to TeeStream fill the + # buffer and are flushed + fd = self.capture_fd + ts = timestamper() + ts.write(f"{time.time()}") + with tee.capture_output(tee.TeeStream(ts, ts), capture_fd=fd): + sys.stderr.write(f"{time.time()}" + ' ' * 4096 + "\n") + time.sleep(self.dt) + ts.write(f"{time.time()}") + if not ts.check([(0, 0), (0, 0), (0, 0), (1, 1)]): + self.fail(ts.error) + + +class TestBuffering_noCapture(BufferTester, unittest.TestCase): + capture_fd = False + + +class TestBuffering_capture(BufferTester, unittest.TestCase): + capture_fd = True + class TestFileDescriptor(unittest.TestCase): def setUp(self): @@ -249,7 +920,7 @@ def test_redirect_no_synchronize_stdout(self): with os.fdopen(r, 'r') as FILE: os.close(w) os.close(1) - self.assertEqual(FILE.read(), "to_stdout_1\nto_stdout_2\nto_fd1_2\n") + self.assertEqual(FILE.read(), "to_stdout_2\nto_fd1_2\n") # Pytest's default capture method causes failures for the following # two tests. This re-implementation of the capfd fixture allows @@ -340,6 +1011,38 @@ def test_capture_output_fd(self): os.close(w) self.assertEqual(FILE.read(), "to_stdout_2\nto_fd1_2\n") + def test_nested_capture_output(self): + OUT2 = StringIO() + r, w = os.pipe() + os.dup2(w, 1) + sys.stdout = stdout0 = os.fdopen(1, 'w', closefd=False) + with tee.capture_output((sys.stdout, StringIO()), capture_fd=True) as (_, OUT1): + stdout1 = sys.stdout + self.assertIsNot(stdout0, stdout1) + with tee.capture_output((sys.stdout, OUT2), capture_fd=True): + stdout2 = sys.stdout + self.assertIsNot(stdout1, stdout2) + sys.stdout.write("to_stdout_1\n") + sys.stdout.flush() + with os.fdopen(1, 'w', closefd=False) as F: + F.write("to_fd1_1\n") + F.flush() + + sys.stdout.write("to_stdout_2\n") + sys.stdout.flush() + with os.fdopen(1, 'w', closefd=False) as F: + F.write("to_fd1_2\n") + F.flush() + + self.assertEqual(OUT1.getvalue(), "to_stdout_1\nto_fd1_1\n") + self.assertEqual(OUT2.getvalue(), "to_stdout_1\nto_fd1_1\n") + with os.fdopen(r, 'r') as FILE: + os.close(1) + os.close(w) + self.assertEqual( + FILE.read(), "to_stdout_1\nto_fd1_1\nto_stdout_2\nto_fd1_2\n" + ) + if __name__ == '__main__': unittest.main() diff --git a/pyomo/common/tests/test_tempfile.py b/pyomo/common/tests/test_tempfile.py index c49aa8c6771..85aa2fca3c4 100644 --- a/pyomo/common/tests/test_tempfile.py +++ b/pyomo/common/tests/test_tempfile.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_timing.py b/pyomo/common/tests/test_timing.py index 0a4224c5476..c75fc3fafd9 100644 --- a/pyomo/common/tests/test_timing.py +++ b/pyomo/common/tests/test_timing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -35,7 +35,7 @@ Any, TransformationFactory, ) -from pyomo.core.base.var import _VarData +from pyomo.core.base.var import VarData class _pseudo_component(Var): @@ -47,8 +47,13 @@ class TestTiming(unittest.TestCase): def setUp(self): self.reenable_gc = gc.isenabled() gc.disable() + # Set a long switch interval to discourage context switches + # during these tests + self.switchinterval = sys.getswitchinterval() + sys.setswitchinterval(10) def tearDown(self): + sys.setswitchinterval(self.switchinterval) if self.reenable_gc: gc.enable() gc.collect() @@ -62,7 +67,7 @@ def test_raw_construction_timer(self): ) v = Var() v.construct() - a = ConstructionTimer(_VarData(v)) + a = ConstructionTimer(VarData(v)) self.assertRegex( str(a), r"ConstructionTimer object for Var ScalarVar\[NOTSET\]; " @@ -107,7 +112,6 @@ def test_report_timing(self): m.y = Var(Any, dense=False) xfrm.apply_to(m) result = out.getvalue().strip() - self.maxDiff = None for l, r in zip(result.splitlines(), ref.splitlines()): self.assertRegex(str(l.strip()), str(r.strip())) finally: @@ -122,7 +126,6 @@ def test_report_timing(self): m.y = Var(Any, dense=False) xfrm.apply_to(m) result = os.getvalue().strip() - self.maxDiff = None for l, r in zip(result.splitlines(), ref.splitlines()): self.assertRegex(str(l.strip()), str(r.strip())) finally: @@ -135,7 +138,6 @@ def test_report_timing(self): m.y = Var(Any, dense=False) xfrm.apply_to(m) result = os.getvalue().strip() - self.maxDiff = None for l, r in zip(result.splitlines(), ref.splitlines()): self.assertRegex(str(l.strip()), str(r.strip())) self.assertEqual(buf.getvalue().strip(), "") @@ -172,7 +174,6 @@ def test_report_timing_context_manager(self): xfrm.apply_to(m) self.assertEqual(OUT.getvalue(), "") result = OS.getvalue().strip() - self.maxDiff = None for l, r in zip_longest(result.splitlines(), ref.splitlines()): self.assertRegex(str(l.strip()), str(r.strip())) # Active reporting is False: the previous log should not have changed @@ -184,7 +185,7 @@ def test_report_timing_context_manager(self): def test_TicTocTimer_tictoc(self): SLEEP = 0.1 - RES = 0.02 # resolution (seconds): 1/5 the sleep + RES = 0.01 # resolution (seconds): 1/10 the sleep # Note: pypy on GHA occasionally has timing # differences of >0.04s @@ -194,6 +195,11 @@ def test_TicTocTimer_tictoc(self): # if sys.platform == 'darwin': # RES *= 2 + # Note: the above RES heuristics were determined before the + # current handling of "now" within the tic/toc timer. They are + # probably overly conservative now, but tightening them doesn't + # really improve the quality of the tests. + abs_time = time.perf_counter() timer = TicTocTimer() @@ -273,7 +279,12 @@ def test_TicTocTimer_tictoc(self): def test_TicTocTimer_context_manager(self): SLEEP = 0.1 - RES = 0.05 # resolution (seconds): 1/2 the sleep + RES = 0.01 # resolution (seconds): 1/10 the sleep + + # Note: the above RES heuristic was determined before the + # current handling of "now" within the tic/toc timer. It is + # probably overly conservative now, but tightening them doesn't + # really improve the quality of the tests. abs_time = time.perf_counter() with TicTocTimer() as timer: diff --git a/pyomo/common/tests/test_typing.py b/pyomo/common/tests/test_typing.py index e65effe7f29..6f03e6308e3 100644 --- a/pyomo/common/tests/test_typing.py +++ b/pyomo/common/tests/test_typing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/common/tests/test_unittest.py b/pyomo/common/tests/test_unittest.py index 9344853b737..0ba42a50e06 100644 --- a/pyomo/common/tests/test_unittest.py +++ b/pyomo/common/tests/test_unittest.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,7 +13,6 @@ import multiprocessing import os import time -from io import StringIO import pyomo.common.unittest as unittest from pyomo.common.log import LoggingIntercept @@ -190,7 +189,7 @@ def test_timeout(self): @unittest.timeout(0.01) def test_timeout_timeout(self): time.sleep(1) - self.assertEqual(0, 1) + self.assertEqual(0, 0) @unittest.timeout(10) def test_timeout_skip(self): @@ -218,8 +217,7 @@ def test_bound_function(self): if multiprocessing.get_start_method() == 'fork': self.bound_function() return - LOG = StringIO() - with LoggingIntercept(LOG): + with LoggingIntercept() as LOG: with self.assertRaises((TypeError, EOFError, AttributeError)): self.bound_function() self.assertIn("platform that does not support 'fork'", LOG.getvalue()) @@ -234,7 +232,7 @@ def test_bound_function_require_fork(self): self.bound_function_require_fork() return with self.assertRaisesRegex( - unittest.SkipTest, "timeout requires unavailable fork interface" + unittest.SkipTest, r"timeout\(\) requires unavailable fork interface" ): self.bound_function_require_fork() diff --git a/pyomo/common/timing.py b/pyomo/common/timing.py index d502b38d12d..b08b2bd07dc 100644 --- a/pyomo/common/timing.py +++ b/pyomo/common/timing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -347,6 +347,11 @@ class was constructed). Note: timing logged using `level` level (int): an optional logging output level. """ + # Note: important to do this first so that we don't add a random + # amount of time for I/O operations or extracting the stack. + # This helps ensure that the timing tests are less fragile. + now = default_timer() + if msg is _NotSpecified: msg = 'File "%s", line %s in %s' % traceback.extract_stack(limit=2)[0][:3] if args and msg is not None and '%' not in msg: @@ -365,11 +370,10 @@ class was constructed). Note: timing logged using `level` if args: logger, *args = args - now = default_timer() if self._start_count or self._lastTime is None: ans = self._cumul if self._lastTime: - ans += default_timer() - self._lastTime + ans += now - self._lastTime if msg is not None: fmt = "[%8.2f|%4d] %s" data = (ans, self._start_count, msg) diff --git a/pyomo/common/unittest.py b/pyomo/common/unittest.py index 9a21b35faa8..a332e38deee 100644 --- a/pyomo/common/unittest.py +++ b/pyomo/common/unittest.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -32,7 +32,6 @@ # specifically later from unittest import * import unittest as _unittest -import pytest as pytest from pyomo.common.collections import Mapping, Sequence from pyomo.common.dependencies import attempt_import, check_min_version @@ -43,6 +42,11 @@ from unittest import mock +# We defer this import so that we don't add a hard dependence on pytest. +# Note that importing test modules may cause this import to be resolved +# (and then enforce a strict dependence on pytest) +pytest, pytest_available = attempt_import('pytest') + def _defaultFormatter(msg, default): return msg or default @@ -111,8 +115,10 @@ def assertStructuredAlmostEqual( values) The relative error is computed for numerical values as - `abs(first - second) / max(abs(first), abs(second))`, - only when first != second (thereby avoiding divide-by-zero errors). + + `abs(first - second) / max(abs(first), abs(second))` + + only when `first != second` (thereby avoiding divide-by-zero errors). Items (entries other than Sequence / Mapping containers, matching strings, and items that satisfy `first is second`) are passed to the @@ -123,37 +129,47 @@ def assertStructuredAlmostEqual( Parameters ---------- - first: + first : the first value to compare - second: + + second : the second value to compare - places: int + + places : int `first` and `second` are considered equivalent if their difference is between `places` decimal places; equivalent to `abstol = 10**-places` (included for compatibility with assertAlmostEqual) - msg: str + + msg : str the message to raise on failure - delta: float + + delta : float alias for `abstol` - abstol: float + + abstol : float the absolute tolerance. `first` and `second` are considered equivalent if their absolute difference is less than `abstol` - reltol: float + + reltol : float the relative tolerance. `first` and `second` are considered equivalent if their absolute difference divided by the largest of `first` and `second` is less than `reltol` - allow_second_superset: bool + + allow_second_superset : bool If True, then extra entries in containers found on second will not trigger a failure. - item_callback: function + + item_callback : function items (other than Sequence / Mapping containers, matching strings, and items satisfying `is`) are passed to this callback to generate the (nominally floating point) value to use for comparison. - exception: Exception + + exception : Exception exception to raise when `first` is not 'almost equal' to `second`. - formatter: function + + formatter : function callback for generating the final failure message (for compatibility with unittest) @@ -292,11 +308,11 @@ def _assertStructuredAlmostEqual( raise exception(msg) -def _runner(q, qualname): +def _runner(pipe, qualname): "Utility wrapper for running functions, used by timeout()" resultType = _RunnerResult.call - if q in _runner.data: - fcn, args, kwargs = _runner.data[q] + if pipe in _runner.data: + fcn, args, kwargs = _runner.data[pipe] elif isinstance(qualname, str): # Use unittest to instantiate the TestCase and run it resultType = _RunnerResult.unittest @@ -312,11 +328,10 @@ def fcn(): else: qualname, fcn, args, kwargs = qualname _runner.data[qualname] = None - OUT = StringIO() try: - with capture_output(OUT): + with capture_output() as OUT: result = fcn(*args, **kwargs) - q.put((resultType, result, OUT.getvalue())) + pipe.send((resultType, result, OUT.getvalue())) except: import traceback @@ -325,7 +340,7 @@ def fcn(): e = etype( "%s\nOriginal traceback:\n%s" % (e, ''.join(traceback.format_tb(tb))) ) - q.put((_RunnerResult.exception, e, OUT.getvalue())) + pipe.send((_RunnerResult.exception, e, OUT.getvalue())) finally: _runner.data.pop(qualname) @@ -349,7 +364,7 @@ def timeout(seconds, require_fork=False, timeout_raises=TimeoutError): using multiprocessing to execute the function in a forked process. If the wrapped function raises an exception, then the exception will be re-raised in this process. If the function times out, a - :python:`TimeoutError` will be raised. + :class:`TimeoutError` will be raised. Note that as this method uses multiprocessing, the wrapped function should NOT spawn any subprocesses. The timeout is implemented using @@ -371,21 +386,27 @@ def timeout(seconds, require_fork=False, timeout_raises=TimeoutError): Examples -------- - >>> import pyomo.common.unittest as unittest - >>> @unittest.timeout(1) - ... def test_function(): - ... return 42 - >>> test_function() - 42 - - >>> @unittest.timeout(0.01) - ... def test_function(): - ... while 1: - ... pass - >>> test_function() - Traceback (most recent call last): - ... - TimeoutError: test timed out after 0.01 seconds + .. doctest:: + :skipif: multiprocessing.get_start_method() != 'fork' + + >>> import pyomo.common.unittest as unittest + >>> @unittest.timeout(1) + ... def test_function(): + ... return 42 + >>> test_function() + 42 + + .. doctest:: + :skipif: multiprocessing.get_start_method() != 'fork' + + >>> @unittest.timeout(0.01) + ... def test_function(): + ... while 1: + ... pass + >>> test_function() + Traceback (most recent call last): + ... + TimeoutError: test timed out after 0.01 seconds """ import functools @@ -396,18 +417,24 @@ def timeout_decorator(fcn): @functools.wraps(fcn) def test_timer(*args, **kwargs): qualname = '%s.%s' % (fcn.__module__, fcn.__qualname__) + # If qualname is in the data dict, then we are in the child + # process and are being asked to run the wrapped function. if qualname in _runner.data: return fcn(*args, **kwargs) + # Parent process: spawn a subprocess to execute the wrapped + # function and monitor for timeout if require_fork and multiprocessing.get_start_method() != 'fork': - raise _unittest.SkipTest("timeout requires unavailable fork interface") + raise _unittest.SkipTest( + "timeout() requires unavailable fork interface" + ) - q = multiprocessing.Queue() + pipe_recv, pipe_send = multiprocessing.Pipe(False) if multiprocessing.get_start_method() == 'fork': # Option 1: leverage fork if possible. This minimizes # the reliance on serialization and ensures that the # wrapped function operates in the same environment. - _runner.data[q] = (fcn, args, kwargs) - runner_args = (q, qualname) + _runner.data[pipe_send] = (fcn, args, kwargs) + runner_arg = qualname elif ( args and fcn.__name__.startswith('test') @@ -417,36 +444,41 @@ def test_timer(*args, **kwargs): # unittest in the child process with this function as # the sole target. This ensures that things like setUp # and tearDown are correctly called. - runner_args = (q, qualname) + runner_arg = qualname else: # Option 3: attempt to serialize the function and all # arguments and send them to the (spawned) child # process. The wrapped function cannot count on any # environment configuration that it does not set up # itself. - runner_args = (q, (qualname, test_timer, args, kwargs)) - test_proc = multiprocessing.Process(target=_runner, args=runner_args) + runner_arg = (qualname, test_timer, args, kwargs) + test_proc = multiprocessing.Process( + target=_runner, args=(pipe_send, runner_arg) + ) + # Set daemon: if the parent process is killed, the child + # process should be killed and collected. test_proc.daemon = True try: test_proc.start() except: - if type(runner_args[1]) is tuple: + if type(runner_arg) is tuple: logging.getLogger(__name__).error( - "Exception raised spawning timeout subprocess " + "Exception raised spawning timeout() subprocess " "on a platform that does not support 'fork'. " "It is likely that either the wrapped function or " "one of its arguments is not serializable" ) raise try: - resultType, result, stdout = q.get(True, seconds) - except queue.Empty: - test_proc.terminate() - raise timeout_raises( - "test timed out after %s seconds" % (seconds,) - ) from None + if pipe_recv.poll(seconds): + resultType, result, stdout = pipe_recv.recv() + else: + test_proc.terminate() + raise timeout_raises( + "test timed out after %s seconds" % (seconds,) + ) from None finally: - _runner.data.pop(q, None) + _runner.data.pop(pipe_send, None) sys.stdout.write(stdout) test_proc.join() if resultType == _RunnerResult.call: @@ -489,14 +521,35 @@ class TestCase(_unittest.TestCase): This class derives from unittest.TestCase and provides the following additional functionality: - - additional assertions: - * :py:meth:`assertStructuredAlmostEqual` - unittest.TestCase documentation - ------------------------------- + * additional assertions: + - :py:meth:`~TestCase.assertStructuredAlmostEqual` + - :py:meth:`assertExpressionsEqual` + - :py:meth:`assertExpressionsStructurallyEqual` + + * updated assertions: + - :py:meth:`assertRaisesRegex` + + :py:class:`unittest.TestCase` documentation + ------------------------------------------- """ - __doc__ += _unittest.TestCase.__doc__ + # Note that the current unittest.TestCase documentation generates + # sphinx warnings. We will clean up that documentation to suppress + # the warnings. + __doc__ += ( + re.sub( + r'^( +)(\* +[^:]+:) *', + r'\n\1\2\n\1 ', + _unittest.TestCase.__doc__.rstrip(), + flags=re.M, + ) + + "\n\n" + ) + + # By default, we always want to spend the time to create the full + # diff of the test reault and the baseline + maxDiff = None def assertStructuredAlmostEqual( self, @@ -510,6 +563,8 @@ def assertStructuredAlmostEqual( allow_second_superset=False, item_callback=_floatOrCall, ): + # Note: __doc__ copied from assertStructuredAlmostEqual below + # assertStructuredAlmostEqual( first=first, second=second, @@ -533,18 +588,28 @@ def assertRaisesRegex(self, expected_exception, expected_regex, *args, **kwargs) normalizes all consecutive whitespace in the exception message to a single space before checking the regular expression. - Args: - expected_exception: Exception class expected to be raised. - expected_regex: Regex (re.Pattern object or string) expected - to be found in error message. - args: Function to be called and extra positional args. - kwargs: Extra kwargs. - msg: Optional message used in case of failure. Can only be used - when assertRaisesRegex is used as a context manager. - normalize_whitespace: Optional bool that, if True, collapses - consecutive whitespace (including newlines) into a - single space before checking against the regular - expression + Parameters + ---------- + expected_exception : Exception + Exception class expected to be raised. + + expected_regex : `re.Pattern` or str + Regular expression expected to be found in error message. + + *args : + Function to be called and extra positional args. + + **kwargs : + Extra keyword args. + + msg : str + Optional message used in case of failure. Can only be used + when assertRaisesRegex is used as a context manager. + + normalize_whitespace : bool, default=False + If True, collapses consecutive whitespace (including + newlines) into a single space before checking against the + regular expression """ normalize_whitespace = kwargs.pop('normalize_whitespace', False) @@ -556,6 +621,29 @@ def assertRaisesRegex(self, expected_exception, expected_regex, *args, **kwargs) return context.handle('assertRaisesRegex', args, kwargs) def assertExpressionsEqual(self, a, b, include_named_exprs=True, places=None): + """Assert that two Pyomo expressions are equal. + + This converts the expressions `a` and `b` into prefix notation + and then compares the resulting lists. All nodes in the tree + are compared using py:meth:`assertEqual` (or + py:meth:`assertAlmostEqual`) + + Parameters + ---------- + a: ExpressionBase or native type + + b: ExpressionBase or native type + + include_named_exprs : bool + If True (the default), the comparison expands all named + expressions when generating the prefix notation + + places : float + Number of decimal places required for equality of floating + point numbers in the expression. If None (the default), the + expressions must be exactly equal. + + """ from pyomo.core.expr.compare import assertExpressionsEqual return assertExpressionsEqual(self, a, b, include_named_exprs, places) @@ -563,6 +651,30 @@ def assertExpressionsEqual(self, a, b, include_named_exprs=True, places=None): def assertExpressionsStructurallyEqual( self, a, b, include_named_exprs=True, places=None ): + """Assert that two Pyomo expressions are structurally equal. + + This converts the expressions `a` and `b` into prefix notation + and then compares the resulting lists. Operators and + (non-native type) leaf nodes in the prefix representation are + converted to strings before comparing (so that things like + variables can be compared across clones or pickles) + + Parameters + ---------- + a: ExpressionBase or native type + + b: ExpressionBase or native type + + include_named_exprs: bool + If True (the default), the comparison expands all named + expressions when generating the prefix notation + + places: float + Number of decimal places required for equality of floating + point numbers in the expression. If None (the default), the + expressions must be exactly equal. + + """ from pyomo.core.expr.compare import assertExpressionsStructurallyEqual return assertExpressionsStructurallyEqual( @@ -570,6 +682,11 @@ def assertExpressionsStructurallyEqual( ) +TestCase.assertStructuredAlmostEqual.__doc__ = re.sub( + 'exception :.*', '', assertStructuredAlmostEqual.__doc__, flags=re.S +) + + class BaselineTestDriver(object): """Generic driver for performing baseline tests in bulk @@ -631,7 +748,7 @@ def initialize_dependencies(self): cls.package_modules = {} packages_used = set(sum(list(cls.package_dependencies.values()), [])) for package_ in packages_used: - pack, pack_avail = attempt_import(package_, defer_check=False) + pack, pack_avail = attempt_import(package_, defer_import=False) cls.package_available[package_] = pack_avail cls.package_modules[package_] = pack @@ -769,16 +886,18 @@ def filter_fcn(self, line): # next 6 patterns ignore entries in pstats reports: 'function calls', 'List reduced', - '.py:', + '.py:', # timing/profiling output ' {built-in method', ' {method', ' {pyomo.core.expr.numvalue.as_numeric}', + ' {gurobipy.', ): if field in line: return True return False def filter_file_contents(self, lines, abstol=None): + _numpy_scalar_re = re.compile(r'np.(int|float)\d+\(([^\)]+)\)') filtered = [] deprecated = None for line in lines: @@ -803,6 +922,15 @@ def filter_file_contents(self, lines, abstol=None): item_list = [] items = line.strip().split() for i in items: + # Split up lists, dicts, and sets + while i and i[0] in '[{': + item_list.append(i[0]) + i = i[1:] + tail = [] + while i and i[-1] in ',:]}': + tail.append(i[-1]) + i = i[:-1] + # A few substitutions to get tests passing on pypy3 if ".inf" in i: i = i.replace(".inf", "inf") @@ -810,9 +938,19 @@ def filter_file_contents(self, lines, abstol=None): i = i.replace("null", "None") try: - item_list.append(float(i)) + # Numpy 2.x changed the repr for scalars. Convert + # the new scalar reprs back to the original (which + # were indistinguishable from python floats/ints) + np_match = _numpy_scalar_re.match(i) + if np_match: + item_list.append(float(np_match.group(2))) + else: + item_list.append(float(i)) except: item_list.append(i) + if tail: + tail.reverse() + item_list.extend(tail) # We can get printed results objects where the baseline is # exactly 0 (and omitted) and the test is slightly non-zero. @@ -820,12 +958,13 @@ def filter_file_contents(self, lines, abstol=None): # results objects and remote them if they are within # tolerance of 0 if ( - len(item_list) == 2 - and item_list[0] == 'Value:' - and type(item_list[1]) is float - and abs(item_list[1]) < (abstol or 0) - and len(filtered[-1]) == 1 - and filtered[-1][0][-1] == ':' + len(item_list) == 3 + and item_list[0] == 'Value' + and item_list[1] == ':' + and type(item_list[2]) is float + and abs(item_list[2]) < (abstol or 0) + and len(filtered[-1]) == 2 + and filtered[-1][1] == ':' ): filtered.pop() else: @@ -833,7 +972,7 @@ def filter_file_contents(self, lines, abstol=None): return filtered - def compare_baseline(self, test_output, baseline, abstol=1e-6, reltol=None): + def compare_baseline(self, test_output, baseline, abstol=1e-6, reltol=1e-8): # Filter files independently and then compare filtered contents out_filtered = self.filter_file_contents( test_output.strip().split('\n'), abstol diff --git a/pyomo/contrib/__init__.py b/pyomo/contrib/__init__.py index a4a626013c4..e8ba473eab1 100644 --- a/pyomo/contrib/__init__.py +++ b/pyomo/contrib/__init__.py @@ -1,10 +1,25 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ + +# +# declare deprecation paths for removed modules and attributes +# +from pyomo.common.deprecation import moved_module + +moved_module( + "pyomo.contrib.simplemodel", + "pyomocontrib_simplemodel", + msg="The use of pyomo.contrib.simplemodel is deprecated. " + "This capability is now supported in the pyomocontrib_simplemodel " + "package, which is included in the pyomo_community distribution.", + version='5.6.9', +) +del moved_module diff --git a/pyomo/contrib/alternative_solutions/README.md b/pyomo/contrib/alternative_solutions/README.md new file mode 100644 index 00000000000..b6e387aceee --- /dev/null +++ b/pyomo/contrib/alternative_solutions/README.md @@ -0,0 +1,8 @@ +# alternative_solutions + +pyomo.contrib.alternative_solutions is a collection of functions that +that generate a set of alternative (near-)optimal solutions +(AOS). These functions rely on a pyomo solver to search for solutions, +and they iteratively adapt the search process to find a variety of +alternative solutions. + diff --git a/pyomo/contrib/alternative_solutions/__init__.py b/pyomo/contrib/alternative_solutions/__init__.py new file mode 100644 index 00000000000..ead886ae0f8 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/__init__.py @@ -0,0 +1,20 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.contrib.alternative_solutions.aos_utils import logcontext +from pyomo.contrib.alternative_solutions.solution import Solution +from pyomo.contrib.alternative_solutions.solnpool import gurobi_generate_solutions +from pyomo.contrib.alternative_solutions.balas import enumerate_binary_solutions +from pyomo.contrib.alternative_solutions.obbt import ( + obbt_analysis, + obbt_analysis_bounds_and_solutions, +) +from pyomo.contrib.alternative_solutions.lp_enum import enumerate_linear_solutions diff --git a/pyomo/contrib/alternative_solutions/aos_utils.py b/pyomo/contrib/alternative_solutions/aos_utils.py new file mode 100644 index 00000000000..c2efbf934b3 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/aos_utils.py @@ -0,0 +1,304 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging + +logger = logging.getLogger(__name__) + +from contextlib import contextmanager + +from pyomo.common.dependencies import numpy as numpy, numpy_available + +if numpy_available: + import numpy.random + from numpy.linalg import norm + +import pyomo.environ as pyo +from pyomo.common.modeling import unique_component_name +from pyomo.common.collections import ComponentSet +import pyomo.util.vars_from_expressions as vfe + + +@contextmanager +def logcontext(level): + """ + This context manager is used to dynamically set the specified logging level + and then execute a block of code using that logging level. When the context is + deleted, the logging level is reset to the original value. + + Examples + -------- + >>> with logcontext(logging.INFO): + ... logging.debug("This will not be printed") + ... logging.info("This will be printed") + + """ + logger = logging.getLogger() + current_level = logger.getEffectiveLevel() + logger.setLevel(level) + try: + yield + finally: + logger.setLevel(current_level) + + +def get_active_objective(model): + """ + Finds and returns the active objective function for a model. Currently + assume that there is exactly one active objective. + """ + + active_objs = list(model.component_data_objects(pyo.Objective, active=True)) + assert ( + len(active_objs) == 1 + ), "Model has {} active objective functions, exactly one is required.".format( + len(active_objs) + ) + + return active_objs[0] + + +def _add_aos_block(model, name="_aos_block"): + """Adds an alternative optimal solution block with a unique name.""" + aos_block = pyo.Block() + model.add_component(unique_component_name(model, name), aos_block) + return aos_block + + +def _add_objective_constraint( + aos_block, objective, objective_value, rel_opt_gap, abs_opt_gap +): + """ + Adds a relative and/or absolute objective function constraint to the + specified block. + """ + + assert ( + rel_opt_gap is None or rel_opt_gap >= 0.0 + ), "rel_opt_gap must be None or >= 0.0" + assert ( + abs_opt_gap is None or abs_opt_gap >= 0.0 + ), "abs_opt_gap must be None or >= 0.0" + + objective_constraints = [] + + objective_is_min = objective.is_minimizing() + objective_expr = objective.expr + + objective_sense = -1 + if objective_is_min: + objective_sense = 1 + + if rel_opt_gap is not None: + objective_cutoff = objective_value + objective_sense * rel_opt_gap * abs( + objective_value + ) + + if objective_is_min: + aos_block.optimality_tol_rel = pyo.Constraint( + expr=objective_expr <= objective_cutoff + ) + else: + aos_block.optimality_tol_rel = pyo.Constraint( + expr=objective_expr >= objective_cutoff + ) + objective_constraints.append(aos_block.optimality_tol_rel) + + if abs_opt_gap is not None: + objective_cutoff = objective_value + objective_sense * abs_opt_gap + + if objective_is_min: + aos_block.optimality_tol_abs = pyo.Constraint( + expr=objective_expr <= objective_cutoff + ) + else: + aos_block.optimality_tol_abs = pyo.Constraint( + expr=objective_expr >= objective_cutoff + ) + objective_constraints.append(aos_block.optimality_tol_abs) + + return objective_constraints + + +if numpy_available: + rng = numpy.random.default_rng(9283749387) +else: + rng = None + + +def _set_numpy_rng(seed): + global rng + rng = numpy.random.default_rng(seed) + + +def _get_random_direction(num_dimensions, iterations=1000, min_norm=1e-4): + """ + Get a unit vector of dimension num_dimensions by sampling from and + normalizing a standard multivariate Gaussian distribution. + """ + for idx in range(iterations): + samples = rng.normal(size=num_dimensions) + samples_norm = norm(samples) + if samples_norm > min_norm: + return samples / samples_norm + raise Exception( # pragma: no cover + ( + "Generated {} sequential Gaussian draws with a norm of " + "less than {}.".format(iterations, min_norm) + ) + ) + + +def _filter_model_variables( + variable_set, + var_generator, + include_continuous=True, + include_binary=True, + include_integer=True, + include_fixed=False, +): + """ + Filters variables from a variable generator and adds them to a set. + """ + for var in var_generator: + if var in variable_set or (var.is_fixed() and not include_fixed): + continue + if ( + (var.is_continuous() and include_continuous) + or (var.is_binary() and include_binary) + or (var.is_integer() and include_integer) + ): + variable_set.add(var) + + +def get_model_variables( + model, + components=None, + include_continuous=True, + include_binary=True, + include_integer=True, + include_fixed=False, +): + """Gathers and returns all variables or a subset of variables from a + Pyomo model. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model. + components: None or a collection of Pyomo components + The components from which variables should be collected. None + indicates that all variables will be included. Alternatively, a + collection of Pyomo Blocks, Constraints, or Variables (indexed or + non-indexed) from which variables will be gathered can be provided. + If a Block is provided, all variables associated with constraints + in that that block and its sub-blocks will be returned. To exclude + sub-blocks, a tuple element with the format (Block, False) can be + used. + include_continuous : boolean + Boolean indicating that continuous variables should be included. + include_binary : boolean + Boolean indicating that binary variables should be included. + include_integer : boolean + Boolean indicating that integer variables should be included. + include_fixed : boolean + Boolean indicating that fixed variables should be included. + + Returns + ------- + variable_set + A Pyomo ComponentSet containing _GeneralVarData variables. + + """ + + component_list = (pyo.Objective, pyo.Constraint) + variable_set = ComponentSet() + if components == None: + var_generator = vfe.get_vars_from_components( + model, component_list, include_fixed=include_fixed + ) + _filter_model_variables( + variable_set, + var_generator, + include_continuous, + include_binary, + include_integer, + include_fixed, + ) + else: + for comp in components: + if hasattr(comp, "ctype") and comp.ctype == pyo.Block: + blocks = comp.values() if comp.is_indexed() else (comp,) + for item in blocks: + variables = vfe.get_vars_from_components( + item, component_list, include_fixed=include_fixed + ) + _filter_model_variables( + variable_set, + variables, + include_continuous, + include_binary, + include_integer, + include_fixed, + ) + elif ( + isinstance(comp, tuple) + and hasattr(comp[0], "ctype") + and comp[0].ctype == pyo.Block + ): + block = comp[0] + descend_into = pyo.Block if comp[1] else False + blocks = block.values() if block.is_indexed() else (block,) + for item in blocks: + variables = vfe.get_vars_from_components( + item, + component_list, + include_fixed=include_fixed, + descend_into=descend_into, + ) + _filter_model_variables( + variable_set, + variables, + include_continuous, + include_binary, + include_integer, + include_fixed, + ) + elif hasattr(comp, "ctype") and comp.ctype in component_list: + constraints = comp.values() if comp.is_indexed() else (comp,) + for item in constraints: + variables = pyo.expr.identify_variables( + item.expr, include_fixed=include_fixed + ) + _filter_model_variables( + variable_set, + variables, + include_continuous, + include_binary, + include_integer, + include_fixed, + ) + elif hasattr(comp, "ctype") and comp.ctype == pyo.Var: + variables = comp.values() if comp.is_indexed() else (comp,) + _filter_model_variables( + variable_set, + variables, + include_continuous, + include_binary, + include_integer, + include_fixed, + ) + else: # pragma: no cover + logger.info( + ("No variables added for unrecognized component {}.").format(comp) + ) + + return variable_set diff --git a/pyomo/contrib/alternative_solutions/balas.py b/pyomo/contrib/alternative_solutions/balas.py new file mode 100644 index 00000000000..e0de7a8f392 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/balas.py @@ -0,0 +1,260 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging + +logger = logging.getLogger(__name__) + +import pyomo.environ as pyo +from pyomo.common.collections import ComponentSet +from pyomo.contrib.alternative_solutions import Solution +import pyomo.contrib.alternative_solutions.aos_utils as aos_utils + + +def enumerate_binary_solutions( + model, + *, + num_solutions=10, + variables=None, + rel_opt_gap=None, + abs_opt_gap=None, + search_mode="optimal", + solver="gurobi", + solver_options={}, + tee=False, + seed=None, +): + """ + Finds alternative optimal solutions for a binary problem using no-good + cuts. + + This function implements a no-good cuts technique inheriting from + Balas's work on Canonical Cuts [BJ72]_. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model + num_solutions : int + The maximum number of solutions to generate. + variables: None or a collection of Pyomo _GeneralVarData variables + The variables for which bounds will be generated. None indicates + that all variables will be included. Alternatively, a collection of + _GenereralVarData variables can be provided. + rel_opt_gap : float or None + The relative optimality gap for the original objective for which + variable bounds will be found. None indicates that a relative gap + constraint will not be added to the model. + abs_opt_gap : float or None + The absolute optimality gap for the original objective for which + variable bounds will be found. None indicates that an absolute gap + constraint will not be added to the model. + search_mode : 'optimal', 'random', or 'hamming' + Indicates the mode that is used to generate alternative solutions. + The optimal mode finds the next best solution. The random mode + finds an alternative solution in the direction of a random ray. The + hamming mode iteratively finds solution that maximize the hamming + distance from previously discovered solutions. + solver : string + The solver to be used. + solver_options : dict + Solver option-value pairs to be passed to the solver. + tee : boolean + Boolean indicating that the solver output should be displayed. + seed : int + Optional integer seed for the numpy random number generator + + Returns + ------- + solutions + A list of Solution objects. + [Solution] + + """ + logger.info("STARTING NO-GOOD CUT ANALYSIS") + + assert search_mode in [ + "optimal", + "random", + "hamming", + ], 'search mode must be "optimal", "random", or "hamming".' + + if seed is not None: + aos_utils._set_numpy_rng(seed) + + all_variables = aos_utils.get_model_variables(model, include_fixed=True) + if variables == None: + binary_variables = [ + var for var in all_variables if var.is_binary() and not var.is_fixed() + ] + logger.debug( + "Analysis using %d binary variables: %s" + % (len(binary_variables), " ".join(var.name for var in binary_variables)) + ) + else: + binary_variables = ComponentSet() + non_binary_variables = [] + for var in variables: + if var.is_binary(): + binary_variables.add(var) + else: # pragma: no cover + non_binary_variables.append(var.name) + if len(non_binary_variables) > 0: + logger.warn( + ( + "Warning: The following non-binary variables were included" + "in the variable list and will be ignored:" + ) + ) + logger.warn(", ".join(non_binary_variables)) + + orig_objective = aos_utils.get_active_objective(model) + + if len(binary_variables) == 0: + logger.warn("No binary variables found!") + + # + # Setup solver + # + opt = pyo.SolverFactory(solver) + opt.available() + for parameter, value in solver_options.items(): + opt.options[parameter] = value + # + # Appsi-specific configurations + # + use_appsi = False + if "appsi" in solver: + use_appsi = True + opt.update_config.update_constraints = False + opt.update_config.check_for_new_or_removed_constraints = True + opt.update_config.check_for_new_or_removed_vars = False + opt.update_config.check_for_new_or_removed_params = False + opt.update_config.update_vars = False + opt.update_config.update_params = False + opt.update_config.update_named_expressions = False + opt.update_config.treat_fixed_vars_as_params = False + + if search_mode == "hamming": + opt.update_config.check_for_new_objective = True + opt.update_config.update_objective = True + elif search_mode == "random": + opt.update_config.check_for_new_objective = True + opt.update_config.update_objective = False + else: + opt.update_config.check_for_new_objective = False + opt.update_config.update_objective = False + + # + # Initial solve of the model + # + logger.info("Performing initial solve of model.") + results = opt.solve(model, tee=tee, load_solutions=False) + status = results.solver.status + if not pyo.check_optimal_termination(results): + condition = results.solver.termination_condition + raise Exception( + ( + "No-good cut analysis cannot be applied, " + "SolverStatus = {}, " + "TerminationCondition = {}" + ).format(status.value, condition.value) + ) + + model.solutions.load_from(results) + orig_objective_value = pyo.value(orig_objective) + logger.info("Found optimal solution, value = {}.".format(orig_objective_value)) + solutions = [Solution(model, all_variables, objective=orig_objective)] + # + # Return just this solution if there are no binary variables + # + if len(binary_variables) == 0: + return solutions + + aos_block = aos_utils._add_aos_block(model, name="_balas") + logger.info("Added block {} to the model.".format(aos_block)) + aos_block.no_good_cuts = pyo.ConstraintList() + aos_utils._add_objective_constraint( + aos_block, orig_objective, orig_objective_value, rel_opt_gap, abs_opt_gap + ) + + if search_mode in ["random", "hamming"]: + orig_objective.deactivate() + + solution_number = 2 + while solution_number <= num_solutions: + + expr = 0 + for var in binary_variables: + if var.value > 0.5: + expr += 1 - var + else: + expr += var + + aos_block.no_good_cuts.add(expr=expr >= 1) + + if search_mode == "hamming": + if hasattr(aos_block, "hamming_objective"): + aos_block.hamming_objective.expr += expr + if use_appsi and opt.update_config.check_for_new_objective: + opt.update_config.check_for_new_objective = False + else: + aos_block.hamming_objective = pyo.Objective( + expr=expr, sense=pyo.maximize + ) + + elif search_mode == "random": + if hasattr(aos_block, "random_objective"): + aos_block.del_component("random_objective") + vector = aos_utils._get_random_direction(len(binary_variables)) + idx = 0 + expr = 0 + for var in binary_variables: + expr += vector[idx] * var + idx += 1 + aos_block.random_objective = pyo.Objective(expr=expr, sense=pyo.maximize) + + results = opt.solve(model, tee=tee, load_solutions=False) + status = results.solver.status + condition = results.solver.termination_condition + if pyo.check_optimal_termination(results): + model.solutions.load_from(results) + orig_obj_value = pyo.value(orig_objective) + logger.info( + "Iteration {}: objective = {}".format(solution_number, orig_obj_value) + ) + solutions.append(Solution(model, all_variables, objective=orig_objective)) + solution_number += 1 + elif ( + condition == pyo.TerminationCondition.infeasibleOrUnbounded + or condition == pyo.TerminationCondition.infeasible + ): + logger.info( + "Iteration {}: Infeasible, no additional binary solutions.".format( + solution_number + ) + ) + break + else: # pragma: no cover + logger.info( + ( + "Iteration {}: Unexpected condition, SolverStatus = {}, " + "TerminationCondition = {}" + ).format(solution_number, status.value, condition.value) + ) + break + + aos_block.deactivate() + orig_objective.activate() + + logger.info("COMPLETED NO-GOOD CUT ANALYSIS") + + return solutions diff --git a/pyomo/contrib/alternative_solutions/lp_enum.py b/pyomo/contrib/alternative_solutions/lp_enum.py new file mode 100644 index 00000000000..b943314a708 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/lp_enum.py @@ -0,0 +1,330 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging + +logger = logging.getLogger(__name__) + +import pyomo.environ as pyo +from pyomo.contrib.alternative_solutions import ( + aos_utils, + shifted_lp, + solution, + solnpool, +) +from pyomo.contrib import appsi + + +def enumerate_linear_solutions( + model, + *, + num_solutions=10, + rel_opt_gap=None, + abs_opt_gap=None, + zero_threshold=1e-5, + search_mode="optimal", + solver="gurobi", + solver_options={}, + tee=False, + seed=None, +): + """ + Finds alternative optimal solutions a (mixed-integer) linear program. + + This function implements the technique described here: + + S. Lee, C. Phalakornkule, M.M. Domach, and I.E. Grossmann, + "Recursive MILP model for finding all the alternative optima in LP + models for metabolic networks", Computers and Chemical Engineering, + 24 (2000) 711-716. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model + num_solutions : int + The maximum number of solutions to generate. + rel_opt_gap : float or None + The relative optimality gap for the original objective for which + variable bounds will be found. None indicates that a relative gap + constraint will not be added to the model. + abs_opt_gap : float or None + The absolute optimality gap for the original objective for which + variable bounds will be found. None indicates that an absolute gap + constraint will not be added to the model. + zero_threshold: float + The threshold for which a continuous variables' value is considered + to be equal to zero. + search_mode : 'optimal', 'random', or 'norm' + Indicates the mode that is used to generate alternative solutions. + The optimal mode finds the next best solution. The random mode + finds an alternative solution in the direction of a random ray. The + norm mode iteratively finds solution that maximize the L2 distance + from previously discovered solutions. + solver : string + The solver to be used. + solver_options : dict + Solver option-value pairs to be passed to the solver. + tee : boolean + Boolean indicating that the solver output should be displayed. + seed : int + Optional integer seed for the numpy random number generator + + Returns + ------- + solutions + A list of Solution objects. + [Solution] + """ + logger.info("STARTING LP ENUMERATION ANALYSIS") + + assert search_mode in [ + "optimal", + "random", + "norm", + ], 'search mode must be "optimal", "random", or "norm".' + # TODO: Implement the random and norm objectives. I think it is sufficient + # to only consider the cb.var_lower variables in the objective for these two + # cases. The cb.var_upper variables are directly linked to these to diversity + # in one implies diversity in the other. Diversity in the cb.basic_slack + # variables doesn't really matter since we only really care about diversity + # in the original problem and not in the slack space (I think) + + all_variables = aos_utils.get_model_variables(model) + # else: + # binary_variables = ComponentSet() + # non_binary_variables = [] + # for var in variables: + # if var.is_binary(): + # binary_variables.append(var) + # else: + # non_binary_variables.append(var.name) + # if len(non_binary_variables) > 0: + # logger.warn(('Warning: The following non-binary variables were included' + # 'in the variable list and will be ignored:')) + # logger.warn(", ".join(non_binary_variables)) + # all_variables = aos_utils.get_model_variables(model, None, + # include_fixed=True) + + # TODO: Relax this if possible - Should allow for the mixed-binary case + for var in all_variables: + assert var.is_continuous(), "Model must be an LP" + + use_appsi = False + if "appsi" in solver: + use_appsi = True + opt = appsi.solvers.Gurobi() + opt.config.load_solution = False + opt.config.stream_solver = tee + opt.update_config.check_for_new_or_removed_constraints = True + opt.update_config.update_constraints = False + opt.update_config.check_for_new_or_removed_vars = True + opt.update_config.check_for_new_or_removed_params = False + opt.update_config.update_vars = False + opt.update_config.update_params = False + opt.update_config.update_named_expressions = False + opt.update_config.treat_fixed_vars_as_params = False + + if search_mode == "norm": + opt.update_config.check_for_new_objective = True + opt.update_config.update_objective = True + elif search_mode == "random": + opt.update_config.check_for_new_objective = True + opt.update_config.update_objective = False + else: + opt.update_config.check_for_new_objective = False + opt.update_config.update_objective = False + for parameter, value in solver_options.items(): + opt.gurobi_options[parameter] = value + else: + opt = pyo.SolverFactory(solver) + opt.available() + for parameter, value in solver_options.items(): + opt.options[parameter] = value + if solver == "gurobi": + # Disable gurobi heuristics, which can return + # solutions not at a vertex + opt.options["Heuristics"] = 0.0 + + logger.info("Performing initial solve of model.") + + if use_appsi: + results = opt.solve(model) + condition = results.termination_condition + optimal_tc = appsi.base.TerminationCondition.optimal + else: + results = opt.solve(model, tee=tee, load_solutions=False) + condition = results.solver.termination_condition + optimal_tc = pyo.TerminationCondition.optimal + if condition != optimal_tc: + raise Exception( + ( + "Model could not be solved. LP enumeration analysis " + "cannot be applied, " + "TerminationCondition = {}" + ).format(condition.value) + ) + if use_appsi: + results.solution_loader.load_vars(solution_number=0) + else: + model.solutions.load_from(results) + + orig_objective = aos_utils.get_active_objective(model) + orig_objective_value = pyo.value(orig_objective) + logger.info("Found optimal solution, value = {}.".format(orig_objective_value)) + + aos_block = aos_utils._add_aos_block(model, name="_lp_enum") + aos_utils._add_objective_constraint( + aos_block, orig_objective, orig_objective_value, rel_opt_gap, abs_opt_gap + ) + logger.info("Added block {} to the model.".format(aos_block)) + + canon_block = shifted_lp.get_shifted_linear_model(model) + cb = canon_block + + # Set K + cb.iteration = pyo.Set(pyo.PositiveIntegers) + + # w variables + cb.basic_lower = pyo.Var(pyo.Any, domain=pyo.Binary, dense=False) + cb.basic_upper = pyo.Var(pyo.Any, domain=pyo.Binary, dense=False) + cb.basic_slack = pyo.Var(pyo.Any, domain=pyo.Binary, dense=False) + + # w upper bounds constraints (Eqn (3)) + cb.bound_lower = pyo.Constraint(pyo.Any) + cb.bound_upper = pyo.Constraint(pyo.Any) + cb.bound_slack = pyo.Constraint(pyo.Any) + + # non-zero basic variable no-good cut set + cb.cut_set = pyo.Constraint(pyo.PositiveIntegers) + + # [ (continuous, binary, constraint) ] + variable_groups = [ + (cb.var_lower, cb.basic_lower, cb.bound_lower), + (cb.var_upper, cb.basic_upper, cb.bound_upper), + (cb.slack_vars, cb.basic_slack, cb.bound_slack), + ] + + solution_number = 1 + solutions = [] + while solution_number <= num_solutions: + logger.info("Solving Iteration {}: ".format(solution_number), end="") + + if logger.isEnabledFor(logging.DEBUG): + model.pprint() + if use_appsi: + results = opt.solve(model) + condition = results.termination_condition + else: + results = opt.solve(cb, tee=tee, load_solutions=False) + condition = results.solver.termination_condition + + if condition == optimal_tc: + if use_appsi: + results.solution_loader.load_vars(solution_number=0) + else: + model.solutions.load_from(results) + + for var, index in cb.var_map.items(): + var.set_value(var.lb + cb.var_lower[index].value) + sol = solution.Solution(model, all_variables, objective=orig_objective) + solutions.append(sol) + orig_objective_value = sol.objective[1] + + if logger.isEnabledFor(logging.INFO): + logger.info("Solved, objective = {}".format(orig_objective_value)) + for var, index in cb.var_map.items(): + logger.info( + "{} = {}".format(var.name, var.lb + cb.var_lower[index].value) + ) + if logger.isEnabledFor(logging.DEBUG): + model.display() + + if hasattr(cb, "force_out"): + cb.del_component("force_out") + if hasattr(cb, "link_in_out"): + cb.del_component("link_in_out") + if hasattr(cb, "basic_last_lower"): + cb.del_component("basic_last_lower") + if hasattr(cb, "basic_last_upper"): + cb.del_component("basic_last_upper") + if hasattr(cb, "basic_last_slack"): + cb.del_component("basic_last_slack") + + cb.link_in_out = pyo.Constraint(pyo.Any) + # y variables + cb.basic_last_lower = pyo.Var(pyo.Any, domain=pyo.Binary, dense=False) + cb.basic_last_upper = pyo.Var(pyo.Any, domain=pyo.Binary, dense=False) + cb.basic_last_slack = pyo.Var(pyo.Any, domain=pyo.Binary, dense=False) + basic_last_list = [ + cb.basic_last_lower, + cb.basic_last_upper, + cb.basic_last_slack, + ] + + # Number of variables with non-zero values + num_non_zero = 0 + # This expression is used to ensure that at least one of the non-zero basic + # variables in the previous solution is selected. + force_out_expr = -1 + # This expression is used to ensure that at most (# non-zero basic variables)-1 + # binary choice variables can be selected. + non_zero_basic_expr = 1 + for idx in range(len(variable_groups)): + continuous_var, binary_var, constraint = variable_groups[idx] + for var in continuous_var: + if continuous_var[var].value > zero_threshold: + num_non_zero += 1 + + # Eqn (3): if binary choice variable is not selected, then + # continuous variable is zero. + constraint[var] = ( + continuous_var[var] + <= continuous_var[var].ub * binary_var[var] + ) + non_zero_basic_expr += binary_var[var] + basic_var = basic_last_list[idx][var] + force_out_expr += basic_var + # Eqn (4): if binary choice variable is selected, then + # basic variable is zero + cb.link_in_out[var] = basic_var + binary_var[var] <= 1 + # Eqn (1): at least one of the non-zero basic variables in the + # previous solution is selected + cb.force_out = pyo.Constraint(expr=force_out_expr >= 0) + # Eqn (2): At most (# non-zero basic variables)-1 binary choice + # variables can be selected + cb.cut_set[solution_number] = non_zero_basic_expr <= num_non_zero + + solution_number += 1 + elif ( + condition == pyo.TerminationCondition.infeasibleOrUnbounded + or condition == pyo.TerminationCondition.infeasible + ): + logger.info("Infeasible, all alternative solutions have been found.") + break + else: + logger.info( + ( + "Unexpected solver condition. Stopping LP enumeration. " + "SolverStatus = {}, TerminationCondition = {}" + ).format(results.solver.status.value, condition.value) + ) + break + if logger.isEnabledFor(logging.DEBUG): + logger.debug("") + logger.debug("=" * 80) + logger.debug("") + + model.del_component("aos_block") + + logger.info("COMPLETED LP ENUMERATION ANALYSIS") + + return solutions diff --git a/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py b/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py new file mode 100644 index 00000000000..680599eda8b --- /dev/null +++ b/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py @@ -0,0 +1,235 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging + +logger = logging.getLogger(__name__) + +from pyomo.common.dependencies import attempt_import + +gurobipy, gurobi_available = attempt_import("gurobipy") + +import pyomo.environ as pyo +import pyomo.common.errors +from pyomo.contrib.alternative_solutions import aos_utils, shifted_lp, solution +from pyomo.contrib import appsi + + +class NoGoodCutGenerator: + def __init__( + self, + model, + variable_groups, + zero_threshold, + orig_model, + all_variables, + orig_objective, + num_solutions, + ): + self.model = model + self.zero_threshold = zero_threshold + self.variable_groups = variable_groups + self.variables = aos_utils.get_model_variables(model) + self.orig_model = orig_model + self.all_variables = all_variables + self.orig_objective = orig_objective + self.solutions = [] + self.num_solutions = num_solutions + + def cut_generator_callback(self, cb_m, cb_opt, cb_where): + if cb_where == gurobipy.GRB.Callback.MIPSOL: + cb_opt.cbGetSolution(vars=self.variables) + logger.info("***FOUND SOLUTION***") + + for var, index in self.model.var_map.items(): + var.set_value(var.lb + self.model.var_lower[index].value) + sol = solution.Solution( + self.orig_model, self.all_variables, objective=self.orig_objective + ) + self.solutions.append(sol) + + if len(self.solutions) >= self.num_solutions: + cb_opt._solver_model.terminate() + num_non_zero = 0 + non_zero_basic_expr = 1 + for idx in range(len(self.variable_groups)): + continuous_var, binary_var = self.variable_groups[idx] + for var in continuous_var: + if continuous_var[var].value > self.zero_threshold: + num_non_zero += 1 + non_zero_basic_expr += binary_var[var] + # TODO: JLG - If we want to add the mixed binary case, I think we + # need to do it here. Essentially we would want to continue to + # build up the num_non_zero as follows + # for binary in binary_vars: + # if binary.value > 0.5: + # num_non_zero += 1 - binary + # else: + # num_non_zero += binary + new_con = self.model.cl.add(non_zero_basic_expr <= num_non_zero) + cb_opt.cbLazy(new_con) + + +def enumerate_linear_solutions_soln_pool( + model, + num_solutions=10, + rel_opt_gap=None, + abs_opt_gap=None, + zero_threshold=1e-5, + solver_options={}, + tee=False, +): + """ + Finds alternative optimal solutions for a (mixed-binary) linear program + using Gurobi's solution pool feature. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model + num_solutions : int + The maximum number of solutions to generate. + variables: None or a collection of Pyomo _GeneralVarData variables + The variables for which bounds will be generated. None indicates + that all variables will be included. Alternatively, a collection of + _GenereralVarData variables can be provided. + rel_opt_gap : float or None + The relative optimality gap for the original objective for which + variable bounds will be found. None indicates that a relative gap + constraint will not be added to the model. + abs_opt_gap : float or None + The absolute optimality gap for the original objective for which + variable bounds will be found. None indicates that an absolute gap + constraint will not be added to the model. + zero_threshold: float + The threshold for which a continuous variables' value is considered + to be equal to zero. + solver_options : dict + Solver option-value pairs to be passed to the solver. + tee : boolean + Boolean indicating that the solver output should be displayed. + + Returns + ------- + solutions + A list of Solution objects. + [Solution] + """ + logger.info("STARTING LP ENUMERATION ANALYSIS USING GUROBI SOLUTION POOL") + # + # Setup gurobi + # + if not gurobi_available: + raise pyomo.common.errors.ApplicationError(f"Solver (gurobi) not available") + + all_variables = aos_utils.get_model_variables(model) + for var in all_variables: + if var.is_integer(): + raise pyomo.common.errors.ApplicationError( + f"The enumerate_linear_solutions_soln_pool() function cannot be used with models that contain discrete variables" + ) + + opt = pyo.SolverFactory("gurobi") + if not opt.available(exception_flag=False): + raise ValueError(solver + " is not available") + for parameter, value in solver_options.items(): + opt.options[parameter] = value + + logger.info("Performing initial solve of model.") + results = opt.solve(model, tee=tee) + status = results.solver.status + condition = results.solver.termination_condition + if condition != pyo.TerminationCondition.optimal: + raise Exception( + ( + "Model could not be solve. LP enumeration analysis " + "cannot be applied, SolverStatus = {}, " + "TerminationCondition = {}" + ).format(status.value, condition.value) + ) + + orig_objective = aos_utils.get_active_objective(model) + orig_objective_value = pyo.value(orig_objective) + logger.info("Found optimal solution, value = {}.".format(orig_objective_value)) + + aos_block = aos_utils._add_aos_block(model, name="_lp_enum") + logger.info("Added block {} to the model.".format(aos_block)) + aos_utils._add_objective_constraint( + aos_block, orig_objective, orig_objective_value, rel_opt_gap, abs_opt_gap + ) + + canonical_block = shifted_lp.get_shifted_linear_model(model) + cb = canonical_block + lower_index = list(cb.var_lower.keys()) + upper_index = list(cb.var_upper.keys()) + + # w variables + cb.basic_lower = pyo.Var(lower_index, domain=pyo.Binary) + cb.basic_upper = pyo.Var(upper_index, domain=pyo.Binary) + cb.basic_slack = pyo.Var(cb.slack_index, domain=pyo.Binary) + + # w upper bounds constraints + def bound_lower_rule(m, var_index): + return ( + m.var_lower[var_index] + <= m.var_lower[var_index].ub * m.basic_lower[var_index] + ) + + cb.bound_lower = pyo.Constraint(lower_index, rule=bound_lower_rule) + + def bound_upper_rule(m, var_index): + return ( + m.var_upper[var_index] + <= m.var_upper[var_index].ub * m.basic_upper[var_index] + ) + + cb.bound_upper = pyo.Constraint(upper_index, rule=bound_upper_rule) + + def bound_slack_rule(m, var_index): + return ( + m.slack_vars[var_index] + <= m.slack_vars[var_index].ub * m.basic_slack[var_index] + ) + + cb.bound_slack = pyo.Constraint(cb.slack_index, rule=bound_slack_rule) + + cb.cl = pyo.ConstraintList() + + # TODO: If we go the mixed binary route we also want to list the binary variables + variable_groups = [ + (cb.var_lower, cb.basic_lower), + (cb.var_upper, cb.basic_upper), + (cb.slack_vars, cb.basic_slack), + ] + cut_generator = NoGoodCutGenerator( + cb, + variable_groups, + zero_threshold, + model, + all_variables, + orig_objective, + num_solutions, + ) + + opt = appsi.solvers.Gurobi() + for parameter, value in solver_options.items(): + opt.gurobi_options[parameter] = value + opt.config.stream_solver = True + opt.config.load_solution = False + opt.gurobi_options["LazyConstraints"] = 1 + opt.set_instance(cb) + opt.set_callback(cut_generator.cut_generator_callback) + opt.solve(cb) + + aos_block.deactivate() + logger.info("COMPLETED LP ENUMERATION ANALYSIS") + + return cut_generator.solutions diff --git a/pyomo/contrib/alternative_solutions/obbt.py b/pyomo/contrib/alternative_solutions/obbt.py new file mode 100644 index 00000000000..3a546347619 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/obbt.py @@ -0,0 +1,355 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging + +logger = logging.getLogger(__name__) + +import pyomo.environ as pyo +from pyomo.contrib.alternative_solutions import aos_utils +from pyomo.contrib.alternative_solutions import Solution +from pyomo.contrib import appsi + + +def obbt_analysis( + model, + *, + variables=None, + rel_opt_gap=None, + abs_opt_gap=None, + refine_discrete_bounds=False, + warmstart=True, + solver="gurobi", + solver_options={}, + tee=False, +): + """ + Calculates the bounds on each variable by solving a series of min and max + optimization problems where each variable is used as the objective function + This can be applied to any class of problem supported by the selected + solver. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model. + variables: None or a collection of Pyomo _GeneralVarData variables + The variables for which bounds will be generated. None indicates + that all variables will be included. Alternatively, a collection of + _GenereralVarData variables can be provided. + rel_opt_gap : float or None + The relative optimality gap for the original objective for which + variable bounds will be found. None indicates that a relative gap + constraint will not be added to the model. + abs_opt_gap : float or None + The absolute optimality gap for the original objective for which + variable bounds will be found. None indicates that an absolute gap + constraint will not be added to the model. + refine_discrete_bounds : boolean + Boolean indicating that new constraints should be added to the + model at each iteration to tighten the bounds for discrete + variables. + warmstart : boolean + Boolean indicating that the solver should be warmstarted from the + best previously discovered solution. + solver : string + The solver to be used. + solver_options : dict + Solver option-value pairs to be passed to the solver. + tee : boolean + Boolean indicating that the solver output should be displayed. + + Returns + ------- + variable_ranges + A Pyomo ComponentMap containing the bounds for each variable. + {variable: (lower_bound, upper_bound)}. An exception is raised when + the solver encountered an issue. + """ + bounds, solns = obbt_analysis_bounds_and_solutions( + model, + variables=variables, + rel_opt_gap=rel_opt_gap, + abs_opt_gap=abs_opt_gap, + refine_discrete_bounds=refine_discrete_bounds, + warmstart=warmstart, + solver=solver, + solver_options=solver_options, + tee=tee, + ) + return bounds + + +def obbt_analysis_bounds_and_solutions( + model, + *, + variables=None, + rel_opt_gap=None, + abs_opt_gap=None, + refine_discrete_bounds=False, + warmstart=True, + solver="gurobi", + solver_options={}, + tee=False, +): + """ + Calculates the bounds on each variable by solving a series of min and max + optimization problems where each variable is used as the objective function + This can be applied to any class of problem supported by the selected + solver. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model. + variables: None or a collection of Pyomo _GeneralVarData variables + The variables for which bounds will be generated. None indicates + that all variables will be included. Alternatively, a collection of + _GenereralVarData variables can be provided. + rel_opt_gap : float or None + The relative optimality gap for the original objective for which + variable bounds will be found. None indicates that a relative gap + constraint will not be added to the model. + abs_opt_gap : float or None + The absolute optimality gap for the original objective for which + variable bounds will be found. None indicates that an absolute gap + constraint will not be added to the model. + refine_discrete_bounds : boolean + Boolean indicating that new constraints should be added to the + model at each iteration to tighten the bounds for discrete + variables. + warmstart : boolean + Boolean indicating that the solver should be warmstarted from the + best previously discovered solution. + solver : string + The solver to be used. + solver_options : dict + Solver option-value pairs to be passed to the solver. + tee : boolean + Boolean indicating that the solver output should be displayed. + + Returns + ------- + variable_ranges + A Pyomo ComponentMap containing the bounds for each variable. + {variable: (lower_bound, upper_bound)}. An exception is raised when + the solver encountered an issue. + solutions + [Solution] + """ + + # TODO - parallelization + + logger.info("STARTING OBBT ANALYSIS") + + if warmstart: + assert ( + variables == None + ), "Cannot restrict variable list when warmstart is specified" + all_variables = aos_utils.get_model_variables(model, include_fixed=False) + if variables == None: + variable_list = all_variables + else: + variable_list = list(variables) + if warmstart: + solutions = pyo.ComponentMap() + for var in all_variables: + solutions[var] = [] + + num_vars = len(variable_list) + logger.info( + "Analyzing {} variables ({} total solves).".format(num_vars, 2 * num_vars) + ) + orig_objective = aos_utils.get_active_objective(model) + + use_appsi = False + if "appsi" in solver: + opt = appsi.solvers.Gurobi() + for parameter, value in solver_options.items(): + opt.gurobi_options[parameter] = value + opt.config.stream_solver = tee + opt.config.load_solution = False + results = opt.solve(model) + condition = results.termination_condition + optimal_tc = appsi.base.TerminationCondition.optimal + infeas_or_unbdd_tc = appsi.base.TerminationCondition.infeasibleOrUnbounded + unbdd_tc = appsi.base.TerminationCondition.unbounded + use_appsi = True + else: + opt = pyo.SolverFactory(solver) + opt.available() + for parameter, value in solver_options.items(): + opt.options[parameter] = value + try: + results = opt.solve( + model, warmstart=warmstart, tee=tee, load_solutions=False + ) + except ValueError: + # An exception occurs if the solver does not recognize the warmstart option + results = opt.solve(model, tee=tee, load_solutions=False) + condition = results.solver.termination_condition + optimal_tc = pyo.TerminationCondition.optimal + infeas_or_unbdd_tc = pyo.TerminationCondition.infeasibleOrUnbounded + unbdd_tc = pyo.TerminationCondition.unbounded + logger.info("Performing initial solve of model.") + + if condition != optimal_tc: + raise RuntimeError( + ("OBBT cannot be applied, " "TerminationCondition = {}").format( + condition.value + ) + ) + if use_appsi: + results.solution_loader.load_vars(solution_number=0) + else: + model.solutions.load_from(results) + if warmstart: + _add_solution(solutions) + orig_objective_value = pyo.value(orig_objective) + logger.info("Found optimal solution, value = {}.".format(orig_objective_value)) + aos_block = aos_utils._add_aos_block(model, name="_obbt") + # placeholder for objective + aos_block.var_objective = pyo.Objective(expr=0) + logger.info("Added block {} to the model.".format(aos_block)) + obj_constraints = aos_utils._add_objective_constraint( + aos_block, orig_objective, orig_objective_value, rel_opt_gap, abs_opt_gap + ) + if refine_discrete_bounds: + aos_block.bound_constraints = pyo.ConstraintList() + new_constraint = False + if len(obj_constraints) > 0: + new_constraint = True + orig_objective.deactivate() + + if use_appsi: + opt.update_config.check_for_new_or_removed_constraints = new_constraint + opt.update_config.check_for_new_or_removed_vars = False + opt.update_config.check_for_new_or_removed_params = False + opt.update_config.check_for_new_objective = True + opt.update_config.update_constraints = False + opt.update_config.update_vars = False + opt.update_config.update_params = False + opt.update_config.update_named_expressions = False + opt.update_config.update_objective = True + opt.update_config.treat_fixed_vars_as_params = False + + variable_bounds = pyo.ComponentMap() + solns = [Solution(model, all_variables, objective=orig_objective)] + + senses = [(pyo.minimize, "LB"), (pyo.maximize, "UB")] + + iteration = 1 + total_iterations = len(senses) * num_vars + for idx in range(len(senses)): + sense = senses[idx][0] + bound_dir = senses[idx][1] + + for var in variable_list: + if idx == 0: + variable_bounds[var] = [None, None] + + aos_block.var_objective.expr = var + aos_block.var_objective.sense = sense + + if warmstart: + _update_values(var, bound_dir, solutions) + + if use_appsi: + opt.update_config.check_for_new_or_removed_constraints = new_constraint + if use_appsi: + opt.config.stream_solver = tee + results = opt.solve(model) + condition = results.termination_condition + else: + try: + results = opt.solve( + model, warmstart=warmstart, tee=tee, load_solutions=False + ) + except ValueError: + # An exception occurs if the solver does not recognize the warmstart option + results = opt.solve(model, tee=tee, load_solutions=False) + condition = results.solver.termination_condition + new_constraint = False + + if condition == optimal_tc: + if use_appsi: + results.solution_loader.load_vars(solution_number=0) + else: + model.solutions.load_from(results) + solns.append(Solution(model, all_variables, objective=orig_objective)) + + if warmstart: + _add_solution(solutions) + obj_val = pyo.value(var) + variable_bounds[var][idx] = obj_val + + if refine_discrete_bounds and not var.is_continuous(): + if sense == pyo.minimize and var.lb < obj_val: + aos_block.bound_constraints.add(var >= obj_val) + new_constraint = True + + if sense == pyo.maximize and var.ub > obj_val: + aos_block.bound_constraints.add(var <= obj_val) + new_constraint = True + + # An infeasibleOrUnbounded status code will imply the problem is + # unbounded since feasibility has been established previously + elif condition == infeas_or_unbdd_tc or condition == unbdd_tc: + if sense == pyo.minimize: + variable_bounds[var][idx] = float("-inf") + else: + variable_bounds[var][idx] = float("inf") + else: # pragma: no cover + logger.warn( + ( + "Unexpected condition for the variable {} {} problem." + "TerminationCondition = {}" + ).format(var.name, bound_dir, condition.value) + ) + + var_value = variable_bounds[var][idx] + logger.info( + "Iteration {}/{}: {}_{} = {}".format( + iteration, total_iterations, var.name, bound_dir, var_value + ) + ) + + if idx == 1: + variable_bounds[var] = tuple(variable_bounds[var]) + + iteration += 1 + + aos_block.deactivate() + orig_objective.activate() + + logger.info("COMPLETED OBBT ANALYSIS") + + return variable_bounds, solns + + +def _add_solution(solutions): + """Add the current variable values to the solution list.""" + for var in solutions: + solutions[var].append(pyo.value(var)) + + +def _update_values(var, bound_dir, solutions): + """ + Set the values of all variables to the best solution seen previously for + the current objective function. + """ + if bound_dir == "LB": + value = min(solutions[var]) + else: + value = max(solutions[var]) + idx = solutions[var].index(value) + for variable in solutions: + variable.set_value(solutions[variable][idx]) diff --git a/pyomo/contrib/alternative_solutions/shifted_lp.py b/pyomo/contrib/alternative_solutions/shifted_lp.py new file mode 100644 index 00000000000..aff59a1aa99 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/shifted_lp.py @@ -0,0 +1,229 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.environ as pyo +from pyomo.common.collections import ComponentMap +from pyomo.gdp.util import clone_without_expression_components +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.contrib.alternative_solutions import aos_utils + + +def _get_unique_name(collection, name): + """Create a unique name for an item that will be added to a collection.""" + if name not in collection: + return name + else: + i = 1 + while "{}_{}".format(name, i) not in collection: + i += 1 + return "{}_{}".format(name, i) + + +def _set_slack_ub(expression, slack_var): + """ + Use FBBT to compute an upper bound for a slack variable on an equality + expression.""" + slack_lb, slack_ub = compute_bounds_on_expr(expression) + assert slack_ub >= 0 + slack_var.setub(slack_ub) + + +def get_shifted_linear_model(model, block=None): + """ + Converts an (MI)LP with bounded (discrete and) continuous variables + (l <= x <= u) into a standard form where where all continuous variables + are non-negative reals and all constraints are equalities. For a pure LP of + the form, + + .. math:: + + min/max cx + s.t. + A_1 * x = b_1 + A_2 * x <= b_2 + l <= x <= u + + a problem of the form, + + .. math:: + + min/max c'z + s.t. + Bz = q + z >= 0 + + will be created and added to the returned block. z consists of var_lower + and var_upper variables that are substituted into the original x variables, + and slack_vars that are used to convert the original inequalities to + equalities. Bounds are provided on all variables in z. For MILPs, only the + continuous part of the problem is converted. + + See Lee, Sangbum., C. Phalakornkule, M. Domach, I. Grossmann, Recursive + MILP model for finding all the alternate optima in LP models for metabolic + networks, Computers & Chemical Engineering, Volume 24, Issues 2–7, 2000, + page 712 for additional details. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model + block : Block + The Pyomo block that the new model should be added to. + + Returns + ------- + block + The block that holds the reformulated model. + """ + + # Gather all variables and confirm the model is bounded + all_vars = aos_utils.get_model_variables(model) + new_vars = {} + all_vars_new = {} + var_map = ComponentMap() + var_range = {} + for var in all_vars: + assert var.lb is not None, ( + "Variable {} does not have a " + "lower bound. All variables must be " + "bounded.".format(var.name) + ) + assert var.ub is not None, ( + "Variable {} does not have an " + "upper bound. All variables must be " + "bounded.".format(var.name) + ) + if var.is_continuous(): + var_name = _get_unique_name(new_vars.keys(), var.name) + new_vars[var_name] = var + all_vars_new[var_name] = var + var_map[var] = var_name + var_range[var_name] = (0, var.ub - var.lb) + else: + all_vars_new[var.name] = var + + if block is None: + block = model + shifted_lp = aos_utils._add_aos_block(block, name="_shifted_lp") + + # Replace original variables with shifted lower and upper variables + shifted_lp.var_lower = pyo.Var( + new_vars.keys(), domain=pyo.NonNegativeReals, bounds=var_range + ) + shifted_lp.var_upper = pyo.Var( + new_vars.keys(), domain=pyo.NonNegativeReals, bounds=var_range + ) + + # Link the shifted lower and upper variables + def link_vars_rule(m, var_index): + return ( + m.var_lower[var_index] + m.var_upper[var_index] == m.var_upper[var_index].ub + ) + + shifted_lp.link_vars = pyo.Constraint(new_vars.keys(), rule=link_vars_rule) + + # Map the lower and upper variables to the original variables and their + # lower bounds. This will be used to substitute x with var_lower + x.lb. + var_lower_map = {id(var): shifted_lp.var_lower[i] for i, var in new_vars.items()} + var_lower_bounds = {id(var): var.lb for var in new_vars.values()} + var_zeros = {id(var): 0 for var in all_vars_new.values()} + + # Substitute the new s variables into the objective function + # The c_fix_zeros calculation is used to find any constant terms that exist + # in the objective expression to avoid double counting + active_objective = aos_utils.get_active_objective(model) + c_var_lower = clone_without_expression_components( + active_objective.expr, substitute=var_lower_map + ) + c_fix_lower = clone_without_expression_components( + active_objective.expr, substitute=var_lower_bounds + ) + c_fix_zeros = clone_without_expression_components( + active_objective.expr, substitute=var_zeros + ) + shifted_lp.objective = pyo.Objective( + expr=c_var_lower - c_fix_zeros + c_fix_lower, + name=active_objective.name + "_shifted", + sense=active_objective.sense, + ) + + # Identify all of the shifted constraints and associated slack variables + # that will need to be created + new_constraints = {} + constraint_map = ComponentMap() + constraint_type = {} + slacks = [] + for constraint in model.component_data_objects(pyo.Constraint, active=True): + if constraint.parent_block() == shifted_lp: + continue + if constraint.equality: + constraint_name = constraint.name + "_equal" + constraint_name = _get_unique_name(new_constraints.keys(), constraint.name) + new_constraints[constraint_name] = constraint + constraint_map[constraint] = constraint_name + constraint_type[constraint_name] = 0 + else: + if constraint.lb is not None: + constraint_name = constraint.name + "_lower" + constraint_name = _get_unique_name( + new_constraints.keys(), constraint.name + ) + new_constraints[constraint_name] = constraint + constraint_map[constraint] = constraint_name + constraint_type[constraint_name] = -1 + slacks.append(constraint_name) + if constraint.ub is not None: + constraint_name = constraint.name + "_upper" + constraint_name = _get_unique_name( + new_constraints.keys(), constraint.name + ) + new_constraints[constraint_name] = constraint + constraint_map[constraint] = constraint_name + constraint_type[constraint_name] = 1 + slacks.append(constraint_name) + shifted_lp.constraint_index = pyo.Set(initialize=new_constraints.keys()) + shifted_lp.slack_index = pyo.Set(initialize=slacks) + shifted_lp.slack_vars = pyo.Var(shifted_lp.slack_index, domain=pyo.NonNegativeReals) + shifted_lp.constraints = pyo.Constraint(shifted_lp.constraint_index) + + for constraint_name, constraint in new_constraints.items(): + # The c_fix_zeros calculation is used to find any constant terms that + # exist in the constraint expression to avoid double counting + a_sub_var_lower = clone_without_expression_components( + constraint.body, substitute=var_lower_map + ) + a_sub_fix_lower = clone_without_expression_components( + constraint.body, substitute=var_lower_bounds + ) + a_sub_fix_zeros = clone_without_expression_components( + constraint.body, substitute=var_zeros + ) + b_lower = constraint.lb + b_upper = constraint.ub + con_type = constraint_type[constraint_name] + if con_type == 0: + expr = a_sub_var_lower - a_sub_fix_zeros + a_sub_fix_lower - b_lower == 0 + elif con_type == -1: + expr_rhs = a_sub_var_lower - a_sub_fix_zeros + a_sub_fix_lower - b_lower + expr = shifted_lp.slack_vars[constraint_name] == expr_rhs + _set_slack_ub(expr_rhs, shifted_lp.slack_vars[constraint_name]) + elif con_type == 1: + expr_rhs = b_upper - a_sub_var_lower + a_sub_fix_zeros - a_sub_fix_lower + expr = shifted_lp.slack_vars[constraint_name] == expr_rhs + _set_slack_ub(expr_rhs, shifted_lp.slack_vars[constraint_name]) + shifted_lp.constraints[constraint_name] = expr + + shifted_lp.var_map = var_map + shifted_lp.new_vars = new_vars + shifted_lp.constraint_map = constraint_map + shifted_lp.new_constraints = new_constraints + + return shifted_lp diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py new file mode 100644 index 00000000000..5c75a6261c3 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -0,0 +1,108 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging + +logger = logging.getLogger(__name__) + +from pyomo.common.dependencies import attempt_import +from pyomo.common.errors import ApplicationError + +from pyomo.contrib import appsi +import pyomo.contrib.alternative_solutions.aos_utils as aos_utils +from pyomo.contrib.alternative_solutions import Solution + + +def gurobi_generate_solutions( + model, + *, + num_solutions=10, + rel_opt_gap=None, + abs_opt_gap=None, + solver_options={}, + tee=False, +): + """ + Finds alternative optimal solutions for discrete variables using Gurobi's + built-in Solution Pool capability. See the Gurobi Solution Pool + documentation for additional details. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model. + num_solutions : int + The maximum number of solutions to generate. This parameter maps to + the PoolSolutions parameter in Gurobi. + rel_opt_gap : non-negative float or None + The relative optimality gap for allowable alternative solutions. + None implies that there is no limit on the relative optimality gap + (i.e. that any feasible solution can be considered by Gurobi). + This parameter maps to the PoolGap parameter in Gurobi. + abs_opt_gap : non-negative float or None + The absolute optimality gap for allowable alternative solutions. + None implies that there is no limit on the absolute optimality gap + (i.e. that any feasible solution can be considered by Gurobi). + This parameter maps to the PoolGapAbs parameter in Gurobi. + solver_options : dict + Solver option-value pairs to be passed to the Gurobi solver. + tee : boolean + Boolean indicating that the solver output should be displayed. + + Returns + ------- + solutions + A list of Solution objects. [Solution] + """ + # + # Setup gurobi + # + opt = appsi.solvers.Gurobi() + if not opt.available(): + raise ApplicationError("Solver (gurobi) not available") + + opt.config.stream_solver = tee + opt.config.load_solution = False + opt.gurobi_options["PoolSolutions"] = num_solutions + opt.gurobi_options["PoolSearchMode"] = 2 + if rel_opt_gap is not None: + opt.gurobi_options["PoolGap"] = rel_opt_gap + if abs_opt_gap is not None: + opt.gurobi_options["PoolGapAbs"] = abs_opt_gap + for parameter, value in solver_options.items(): + opt.gurobi_options[parameter] = value + # + # Run gurobi + # + results = opt.solve(model) + condition = results.termination_condition + if not (condition == appsi.base.TerminationCondition.optimal): + raise ApplicationError( + "Model cannot be solved, " "TerminationCondition = {}" + ).format(condition.value) + # + # Collect solutions + # + solution_count = opt.get_model_attr("SolCount") + variables = aos_utils.get_model_variables(model, include_fixed=True) + solutions = [] + for i in range(solution_count): + # + # Load the i-th solution into the model + # + results.solution_loader.load_vars(solution_number=i) + # + # Pull the solution from the model into a Solution object, + # and append to our list of solutions + # + solutions.append(Solution(model, variables)) + + return solutions diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py new file mode 100644 index 00000000000..7022e7741ce --- /dev/null +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -0,0 +1,158 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import json +import pyomo.environ as pyo +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.contrib.alternative_solutions import aos_utils + + +class Solution: + """ + A class to store solutions from a Pyomo model. + + Attributes + ---------- + variables : ComponentMap + A map between Pyomo variables and their values for a solution. + fixed_vars : ComponentSet + The set of Pyomo variables that are fixed in a solution. + objective : ComponentMap + A map between Pyomo objectives and their values for a solution. + + Methods + ------- + pprint(): + Prints a solution. + get_variable_name_values(self, ignore_fixed_vars=False): + Get a dictionary of variable name-variable value pairs. + get_fixed_variable_names(self): + Get a list of fixed-variable names. + get_objective_name_values(self): + Get a dictionary of objective name-objective value pairs. + """ + + def __init__(self, model, variable_list, include_fixed=True, objective=None): + """ + Constructs a Pyomo Solution object. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model. + variable_list: A collection of Pyomo _GenereralVarData variables + The variables for which the solution will be stored. + include_fixed : boolean + Boolean indicating that fixed variables should be added to the + solution. + objective: None or Objective + The objective functions for which the value will be saved. None + indicates that the active objective should be used, but a + different objective can be stored as well. + """ + + self.variables = ComponentMap() + self.fixed_vars = ComponentSet() + for var in variable_list: + is_fixed = var.is_fixed() + if is_fixed: + self.fixed_vars.add(var) + if include_fixed or not is_fixed: + self.variables[var] = pyo.value(var) + + if objective is None: + objective = aos_utils.get_active_objective(model) + self.objective = (objective, pyo.value(objective)) + + @property + def objective_value(self): + """ + Returns + ------- + The value of the objective. + """ + return self.objective[1] + + def pprint(self, round_discrete=True, sort_keys=True, indent=4): + """ + Print the solution variables and objective values. + + Parameters + ---------- + rounded_discrete : boolean + If True, then round discrete variable values before printing. + """ + print( + self.to_string( + round_discrete=round_discrete, sort_keys=sort_keys, indent=indent + ) + ) # pragma: no cover + + def to_string(self, round_discrete=True, sort_keys=True, indent=4): + return json.dumps( + self.to_dict(round_discrete=round_discrete), + sort_keys=sort_keys, + indent=indent, + ) + + def to_dict(self, round_discrete=True): + ans = {} + ans["objective"] = str(self.objective[0]) + ans["objective_value"] = self.objective[1] + soln = {} + for variable, value in self.variables.items(): + val = self._round_variable_value(variable, value, round_discrete) + soln[variable.name] = val + ans["solution"] = soln + ans["fixed_variables"] = [str(v) for v in self.fixed_vars] + return ans + + def __str__(self): + return self.to_string() + + __repn__ = __str__ + + def get_variable_name_values(self, include_fixed=True, round_discrete=True): + """ + Get a dictionary of variable name-variable value pairs. + + Parameters + ---------- + include_fixed : boolean + If True, then include fixed variables in the dictionary. + round_discrete : boolean + If True, then round discrete variable values in the dictionary. + + Returns + ------- + Dictionary mapping variable names to variable values. + """ + return { + var.name: self._round_variable_value(var, val, round_discrete) + for var, val in self.variables.items() + if include_fixed or not var in self.fixed_vars + } + + def get_fixed_variable_names(self): + """ + Get a list of fixed-variable names. + + Returns + ------- + A list of the variable names that are fixed. + """ + return [var.name for var in self.fixed_vars] + + def _round_variable_value(self, variable, value, round_discrete=True): + """ + Returns a rounded value unless the variable is discrete or rounded_discrete is False. + """ + return value if not round_discrete or variable.is_continuous() else round(value) diff --git a/doc/OnlineDocs/modeling_extensions/__init__.py b/pyomo/contrib/alternative_solutions/tests/__init__.py similarity index 95% rename from doc/OnlineDocs/modeling_extensions/__init__.py rename to pyomo/contrib/alternative_solutions/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/doc/OnlineDocs/modeling_extensions/__init__.py +++ b/pyomo/contrib/alternative_solutions/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/alternative_solutions/tests/test_aos_utils.py b/pyomo/contrib/alternative_solutions/tests/test_aos_utils.py new file mode 100644 index 00000000000..481e79f68ca --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_aos_utils.py @@ -0,0 +1,300 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common import unittest + +from pyomo.common.dependencies import numpy as numpy, numpy_available + +import pyomo.environ as pyo +import pyomo.common.unittest as unittest +from pyomo.common.collections import ComponentSet + +import pyomo.contrib.alternative_solutions.aos_utils as au + + +class TestAOSUtilsUnit(unittest.TestCase): + + def get_multiple_objective_model(self): + """Create a simple model with three objectives.""" + m = pyo.ConcreteModel() + m.b1 = pyo.Block() + m.b2 = pyo.Block() + m.x = pyo.Var() + m.y = pyo.Var() + m.b1.o = pyo.Objective(expr=m.x) + m.b2.o = pyo.Objective([0, 1]) + m.b2.o[0] = pyo.Objective(expr=m.y) + m.b2.o[1] = pyo.Objective(expr=m.x + m.y) + return m + + def test_multiple_objectives(self): + """Check that an error is thrown with multiple objectives.""" + m = self.get_multiple_objective_model() + assert_text = ( + "Model has 3 active objective functions, exactly one " "is required." + ) + with self.assertRaisesRegex(AssertionError, assert_text): + au.get_active_objective(m) + + def test_no_objectives(self): + """Check that an error is thrown with no objectives.""" + m = self.get_multiple_objective_model() + m.b1.o.deactivate() + m.b2.o.deactivate() + assert_text = ( + "Model has 0 active objective functions, exactly one " "is required." + ) + with self.assertRaisesRegex(AssertionError, assert_text): + au.get_active_objective(m) + + def test_one_objective(self): + """ + Check that the active objective is returned, when there is just one + objective. + """ + m = self.get_multiple_objective_model() + m.b1.o.deactivate() + m.b2.o[0].deactivate() + self.assertEqual(m.b2.o[1], au.get_active_objective(m)) + + def test_aos_block(self): + """Ensure that an alternative solution block is added.""" + m = self.get_multiple_objective_model() + block_name = "test_block" + b = au._add_aos_block(m, block_name) + self.assertEqual(b.name, block_name) + self.assertEqual(b.ctype, pyo.Block) + + def get_simple_model(self, sense=pyo.minimize): + """Create a simple 2d linear program with an objective.""" + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.o = pyo.Objective(expr=m.x + m.y, sense=sense) + return m + + def test_no_obj_constraint(self): + """Ensure that no objective constraints are added.""" + m = self.get_simple_model() + cons = au._add_objective_constraint(m, m.o, 2, None, None) + self.assertEqual(cons, []) + self.assertEqual(m.find_component("optimality_tol_rel"), None) + self.assertEqual(m.find_component("optimality_tol_abs"), None) + + def test_min_rel_obj_constraint(self): + """Ensure that the correct relative objective constraint is added.""" + m = self.get_simple_model() + cons = au._add_objective_constraint(m, m.o, 2, 0.1, None) + self.assertEqual(len(cons), 1) + self.assertEqual(m.find_component("optimality_tol_rel"), cons[0]) + self.assertEqual(m.find_component("optimality_tol_abs"), None) + self.assertEqual(2.2, cons[0].upper) + self.assertEqual(None, cons[0].lower) + + def test_min_abs_obj_constraint(self): + """Ensure that the correct absolute objective constraint is added.""" + m = self.get_simple_model() + cons = au._add_objective_constraint(m, m.o, 2, None, 1) + self.assertEqual(len(cons), 1) + self.assertEqual(m.find_component("optimality_tol_rel"), None) + self.assertEqual(m.find_component("optimality_tol_abs"), cons[0]) + self.assertEqual(3, cons[0].upper) + self.assertEqual(None, cons[0].lower) + + def test_min_both_obj_constraint(self): + m = self.get_simple_model() + cons = au._add_objective_constraint(m, m.o, -10, 0.3, 5) + self.assertEqual(len(cons), 2) + self.assertEqual(m.find_component("optimality_tol_rel"), cons[0]) + self.assertEqual(m.find_component("optimality_tol_abs"), cons[1]) + self.assertEqual(-7, cons[0].upper) + self.assertEqual(None, cons[0].lower) + self.assertEqual(-5, cons[1].upper) + self.assertEqual(None, cons[1].lower) + + def test_max_both_obj_constraint(self): + """ + Ensure that the correct relative and absolute objective constraints are + added. + """ + m = self.get_simple_model(sense=pyo.maximize) + cons = au._add_objective_constraint(m, m.o, -1, 0.3, 1) + self.assertEqual(len(cons), 2) + self.assertEqual(m.find_component("optimality_tol_rel"), cons[0]) + self.assertEqual(m.find_component("optimality_tol_abs"), cons[1]) + self.assertEqual(None, cons[0].upper) + self.assertEqual(-1.3, cons[0].lower) + self.assertEqual(None, cons[1].upper) + self.assertEqual(-2, cons[1].lower) + + def test_max_both_obj_constraint2(self): + """ + Ensure that the correct relative and absolute objective constraints are + added. + """ + m = self.get_simple_model(sense=pyo.maximize) + cons = au._add_objective_constraint(m, m.o, 20, 0.5, 11) + self.assertEqual(len(cons), 2) + self.assertEqual(m.find_component("optimality_tol_rel"), cons[0]) + self.assertEqual(m.find_component("optimality_tol_abs"), cons[1]) + self.assertEqual(None, cons[0].upper) + self.assertEqual(10, cons[0].lower) + self.assertEqual(None, cons[1].upper) + self.assertEqual(9, cons[1].lower) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_random_direction(self): + """ + Ensure that _get_random_direction returns a normal vector. + """ + from numpy.linalg import norm + + vector = au._get_random_direction(10) + self.assertAlmostEqual(1.0, norm(vector)) + + def get_var_model(self): + """ + Create a model with multiple variables that are nested over several + layers of blocks. + """ + + indices = [0, 1, 2, 3] + + m = pyo.ConcreteModel() + + m.b1 = pyo.Block() + m.b2 = pyo.Block() + m.b1.sb1 = pyo.Block() + m.b2.sb2 = pyo.Block() + + m.x = pyo.Var(domain=pyo.Reals) + m.b1.y = pyo.Var(domain=pyo.Binary) + m.b2.z = pyo.Var(domain=pyo.Integers) + + m.x_f = pyo.Var(domain=pyo.Reals) + m.b1.y_f = pyo.Var(domain=pyo.Binary) + m.b2.z_f = pyo.Var(domain=pyo.Integers) + m.x_f.fix(0) + m.b1.y_f.fix(0) + m.b2.z_f.fix(0) + + m.b1.sb1.x_l = pyo.Var(indices, domain=pyo.Reals) + m.b1.sb1.y_l = pyo.Var(indices, domain=pyo.Binary) + m.b2.sb2.z_l = pyo.Var(indices, domain=pyo.Integers) + + m.b1.sb1.x_l[3].fix(0) + m.b1.sb1.y_l[3].fix(0) + m.b2.sb2.z_l[3].fix(0) + + vars_minus_x = ( + [m.b1.y, m.b2.z, m.x_f, m.b1.y_f, m.b2.z_f] + + [m.b1.sb1.x_l[i] for i in indices] + + [m.b1.sb1.y_l[i] for i in indices] + + [m.b2.sb2.z_l[i] for i in indices] + ) + + m.con = pyo.Constraint(expr=sum(v for v in vars_minus_x) <= 1) + m.b1.con = pyo.Constraint(expr=m.b1.y <= 1) + m.b1.sb1.con = pyo.Constraint(expr=m.b1.sb1.y_l[0] <= 1) + m.obj = pyo.Objective(expr=m.x) + + m.all_vars = ComponentSet([m.x] + vars_minus_x) + m.unfixed_vars = ComponentSet([var for var in m.all_vars if not var.is_fixed()]) + + return m + + def test_get_all_variables_unfixed(self): + """Check that all unfixed variables are gathered.""" + m = self.get_var_model() + var = au.get_model_variables(m) + self.assertEqual(var, m.unfixed_vars) + + def test_get_all_variables(self): + """Check that all fixed and unfixed variables are gathered.""" + m = self.get_var_model() + var = au.get_model_variables(m, include_fixed=True) + self.assertEqual(var, m.all_vars) + + def test_get_all_continuous(self): + """Check that all continuous variables are gathered.""" + m = self.get_var_model() + var = au.get_model_variables( + m, include_continuous=True, include_binary=False, include_integer=False + ) + continuous_vars = ComponentSet( + var for var in m.unfixed_vars if var.is_continuous() + ) + self.assertEqual(var, continuous_vars) + + def test_get_all_binary(self): + """Check that all binary variables are gathered.""" + m = self.get_var_model() + var = au.get_model_variables( + m, include_continuous=False, include_binary=True, include_integer=False + ) + binary_vars = ComponentSet(var for var in m.unfixed_vars if var.is_binary()) + self.assertEqual(var, binary_vars) + + def test_get_all_integer(self): + """Check that all integer variables are gathered.""" + m = self.get_var_model() + var = au.get_model_variables( + m, include_continuous=False, include_binary=False, include_integer=True + ) + continuous_vars = ComponentSet( + var for var in m.unfixed_vars if var.is_integer() + ) + self.assertEqual(var, continuous_vars) + + def test_get_specific_vars(self): + """Check that all variables from a list are gathered.""" + m = self.get_var_model() + components = [m.x, m.b1.sb1.y_l[0], m.b2.sb2.z_l] + var = au.get_model_variables(m, components=components) + specific_vars = ComponentSet( + [m.x, m.b1.sb1.y_l[0], m.b2.sb2.z_l[0], m.b2.sb2.z_l[1], m.b2.sb2.z_l[2]] + ) + self.assertEqual(var, specific_vars) + + def test_get_block_vars1(self): + """ + Check that all variables from block are gathered (without + descending into subblocks). + """ + m = self.get_var_model() + components = [m.b2.sb2.z_l, (m.b1, False)] + var = au.get_model_variables(m, components=components) + specific_vars = ComponentSet( + [m.b1.y, m.b2.sb2.z_l[0], m.b2.sb2.z_l[1], m.b2.sb2.z_l[2]] + ) + self.assertEqual(var, specific_vars) + + def test_get_block_vars2(self): + """ + Check that all variables from block are gathered (without + descending into subblocks). + """ + m = self.get_var_model() + components = [m.b1] + var = au.get_model_variables(m, components=components) + specific_vars = ComponentSet([m.b1.y, m.b1.sb1.y_l[0]]) + self.assertEqual(var, specific_vars) + + def test_get_constraint_vars(self): + """Check that all variables constraints and objectives are gathered.""" + m = self.get_var_model() + components = [m.con, m.obj] + var = au.get_model_variables(m, components=components) + self.assertEqual(var, m.unfixed_vars) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/alternative_solutions/tests/test_balas.py b/pyomo/contrib/alternative_solutions/tests/test_balas.py new file mode 100644 index 00000000000..984cde09a79 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_balas.py @@ -0,0 +1,154 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from collections import Counter + +from pyomo.common.dependencies import numpy as numpy, numpy_available + +if numpy_available: + from numpy.testing import assert_array_almost_equal + +from pyomo.common import unittest +import pyomo.opt + +from pyomo.contrib.alternative_solutions import enumerate_binary_solutions +import pyomo.contrib.alternative_solutions.tests.test_cases as tc + +solvers = list(pyomo.opt.check_available_solvers("glpk", "gurobi", "appsi_gurobi")) +pytestmark = unittest.pytest.mark.parametrize("mip_solver", solvers) + + +@unittest.pytest.mark.default +class TestBalasUnit: + + def test_bad_solver(self, mip_solver): + """ + Confirm that an exception is thrown with a bad solver name. + """ + m = tc.get_triangle_ip() + try: + enumerate_binary_solutions(m, solver="unknown_solver") + except pyomo.common.errors.ApplicationError as e: + pass + + def test_ip_feasibility(self, mip_solver): + """ + Enumerate solutions for an ip: triangle_ip. + + Check that there is just one solution when the # of binary variables is 0. + """ + m = tc.get_triangle_ip() + results = enumerate_binary_solutions(m, num_solutions=100, solver=mip_solver) + assert len(results) == 1 + assert results[0].objective_value == unittest.pytest.approx(5) + + @unittest.skipIf(True, "Ignoring fragile test for solver timeout.") + def test_no_time(self, mip_solver): + """ + Enumerate solutions for an ip: triangle_ip. + + Check that something sensible happens when the solver times out. + """ + m = tc.get_triangle_ip() + with unittest.pytest.raises(Exception): + results = enumerate_binary_solutions( + m, num_solutions=100, solver=mip_solver, solver_options={"TimeLimit": 0} + ) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_knapsack_all(self, mip_solver): + """ + Enumerate solutions for a binary problem: knapsack + + """ + m = tc.get_aos_test_knapsack( + 1, weights=[3, 4, 6, 5], values=[2, 3, 1, 4], capacity=8 + ) + results = enumerate_binary_solutions(m, num_solutions=100, solver=mip_solver) + objectives = list( + sorted((round(result.objective[1], 2) for result in results), reverse=True) + ) + assert_array_almost_equal(objectives, m.ranked_solution_values) + unique_solns_by_obj = [val for val in Counter(objectives).values()] + assert_array_almost_equal(unique_solns_by_obj, m.num_ranked_solns) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_knapsack_x0_x1(self, mip_solver): + """ + Enumerate solutions for a binary problem: knapsack + + Check that we only see 4 solutions that enumerate alternatives of x[1] and x[1] + """ + m = tc.get_aos_test_knapsack( + 1, weights=[3, 4, 6, 5], values=[2, 3, 1, 4], capacity=8 + ) + results = enumerate_binary_solutions( + m, num_solutions=100, solver=mip_solver, variables=[m.x[0], m.x[1]] + ) + objectives = list( + sorted((round(result.objective[1], 2) for result in results), reverse=True) + ) + assert_array_almost_equal(objectives, [6, 5, 4, 3]) + unique_solns_by_obj = [val for val in Counter(objectives).values()] + assert_array_almost_equal(unique_solns_by_obj, [1, 1, 1, 1]) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_knapsack_optimal_3(self, mip_solver): + """ + Enumerate solutions for a binary problem: knapsack + + """ + m = tc.get_aos_test_knapsack( + 1, weights=[3, 4, 6, 5], values=[2, 3, 1, 4], capacity=8 + ) + results = enumerate_binary_solutions(m, num_solutions=3, solver=mip_solver) + objectives = list( + sorted((round(result.objective[1], 2) for result in results), reverse=True) + ) + assert_array_almost_equal(objectives, m.ranked_solution_values[:3]) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_knapsack_hamming_3(self, mip_solver): + """ + Enumerate solutions for a binary problem: knapsack + + """ + m = tc.get_aos_test_knapsack( + 1, weights=[3, 4, 6, 5], values=[2, 3, 1, 4], capacity=8 + ) + results = enumerate_binary_solutions( + m, num_solutions=3, solver=mip_solver, search_mode="hamming" + ) + objectives = list( + sorted((round(result.objective[1], 2) for result in results), reverse=True) + ) + assert_array_almost_equal(objectives, [6, 3, 1]) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_knapsack_random_3(self, mip_solver): + """ + Enumerate solutions for a binary problem: knapsack + + """ + m = tc.get_aos_test_knapsack( + 1, weights=[3, 4, 6, 5], values=[2, 3, 1, 4], capacity=8 + ) + results = enumerate_binary_solutions( + m, num_solutions=3, solver=mip_solver, search_mode="random", seed=1118798374 + ) + objectives = list( + sorted((round(result.objective[1], 2) for result in results), reverse=True) + ) + assert_array_almost_equal(objectives, [6, 5, 4]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/alternative_solutions/tests/test_case.xlsx b/pyomo/contrib/alternative_solutions/tests/test_case.xlsx new file mode 100644 index 00000000000..4fa4ee1045a Binary files /dev/null and b/pyomo/contrib/alternative_solutions/tests/test_case.xlsx differ diff --git a/pyomo/contrib/alternative_solutions/tests/test_cases.py b/pyomo/contrib/alternative_solutions/tests/test_cases.py new file mode 100644 index 00000000000..5a5f2479af8 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_cases.py @@ -0,0 +1,438 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from itertools import product +from math import ceil, floor +from collections import Counter + +from pyomo.common.dependencies import numpy as np + +import pyomo.environ as pyo + +""" +This script has collection of test cases that can be used to enumerate solutions. +That is, simple problems where the alternative solutions can be found manually. +""" + + +def _is_satisfied(constraint, feasibility_tol=1e-6): + value = pyo.value(constraint.body) + if constraint.has_lb() and value < constraint.lb - feasibility_tol: + return False + if constraint.has_ub() and value > constraint.ub + feasibility_tol: + return False + return True + + +def get_2d_diamond_problem(discrete_x=False, discrete_y=False): + """ + Simple 2d problem where the feasible is diamond-shaped. + """ + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.Integers if discrete_x else pyo.Reals, bounds=(-10, 10)) + m.y = pyo.Var(within=pyo.Integers if discrete_y else pyo.Reals, bounds=(-10, 10)) + + m.o = pyo.Objective(expr=m.x + m.y, sense=pyo.maximize) + + m.c1 = pyo.Constraint(expr=-4 / 5 * m.x - 4 <= m.y) + m.c2 = pyo.Constraint(expr=5 / 9 * m.x - 5 <= m.y) + m.c3 = pyo.Constraint(expr=2 / 9 * m.x + 2 >= m.y) + m.c4 = pyo.Constraint(expr=-1 / 2 * m.x + 3 >= m.y) + + # Continuous exteme points and bounds + m.extreme_points = { + (0.737704918, -4.590163934), + (-5.869565217, 0.695652174), + (1.384615385, 2.307692308), + (7.578947368, -0.789473684), + } + + m.continuous_bounds = pyo.ComponentMap() + m.continuous_bounds[m.x] = (-5.869565217, 7.578947368) + m.continuous_bounds[m.y] = (-4.590163934, 2.307692308) + + # Continuous exteme points and bounds for the case where an objective + # constraint is added within a 100% relative gap of optimality or an + # absolute gap of 6.789473684 + + m.extreme_points_cut = { + (45 / 14, -45 / 14), + (-18 / 11, 18 / 11), + (1.384615385, 2.307692308), + (7.578947368, -0.789473684), + } + + m.continuous_bounds_cut = pyo.ComponentMap() + m.continuous_bounds_cut[m.x] = (-18 / 11, 7.578947368) + m.continuous_bounds_cut[m.y] = (-45 / 14, 2.307692308) + + # Discrete feasible solutions and bounds + feasible_sols = [] + x_lower_bound = None + x_upper_bound = None + y_lower_bound = None + y_upper_bound = None + + x_lower = ceil(m.continuous_bounds[m.x][0]) + x_upper = floor(m.continuous_bounds[m.x][1]) + y_lower = ceil(m.continuous_bounds[m.y][0]) + y_upper = floor(m.continuous_bounds[m.y][1]) + cons = [m.c1, m.c2, m.c3, m.c4] + for x_value in range(x_lower, x_upper + 1): + for y_value in range(y_lower, y_upper + 1): + m.x.set_value(x_value) + m.y.set_value(y_value) + is_feasible = True + for con in cons: + if not _is_satisfied(con): + is_feasible = False + break + if is_feasible: + if x_lower_bound is None or x_value < x_lower_bound: + x_lower_bound = x_value + if x_upper_bound is None or x_value > x_upper_bound: + x_upper_bound = x_value + if y_lower_bound is None or y_value < y_lower_bound: + y_lower_bound = y_value + if y_upper_bound is None or y_value > y_upper_bound: + y_upper_bound = y_value + feasible_sols.append(((x_value, y_value), x_value + y_value)) + m.discrete_feasible = sorted(feasible_sols, key=lambda sol: sol[1], reverse=True) + m.discrete_bounds = pyo.ComponentMap() + m.discrete_bounds[m.x] = (x_lower_bound, x_upper_bound) + m.discrete_bounds[m.y] = (y_lower_bound, y_upper_bound) + + return m + + +def get_3d_polyhedron_problem(): + """ + Simple 3d polyhedron that is expressed using all types of linear constraints + """ + m = pyo.ConcreteModel() + m.x = pyo.Var([0, 1, 2], within=pyo.Reals) + m.x[0].setlb(-1) + m.x[0].setub(1) + m.x[1].setlb(-2) + m.x[1].setub(2) + m.x[2].setlb(1) + m.x[2].setub(2) + + def _constraint_switch_rule(m, i): + if i == 0: + return m.x[0] + m.x[1] <= 2 + elif i == 1: + return -m.x[0] + m.x[1] <= 2 + elif i == 2: + return m.x[0] + m.x[1] >= -2 + elif i == 3: + return -m.x[0] + m.x[1] >= -2 + elif i == 4: + return m.x[0] + m.x[1] + m.x[2] == 4 + + m.c = pyo.Constraint([i for i in range(5)], rule=_constraint_switch_rule) + + m.o = pyo.Objective(expr=m.x[0] + m.x[2], sense=pyo.maximize) + return m + + +def get_2d_unbounded_problem(): + """ + Simple 2d problem where the feasible region is unbounded, but the problem + has an optimal solution. + """ + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.Reals) + m.y = pyo.Var(within=pyo.Reals) + + m.o = pyo.Objective(expr=m.y - m.x) + + m.c1 = pyo.Constraint(expr=m.x <= 4) + m.c2 = pyo.Constraint(expr=m.y >= 2) + + m.extreme_points = {(4, 2)} + + m.continuous_bounds = pyo.ComponentMap() + m.continuous_bounds[m.x] = (float("-inf"), 4) + m.continuous_bounds[m.y] = (2, float("inf")) + return m + + +def get_2d_degenerate_lp(): + """ + Simple 2d problem that includes a redundant constraint such that three + constraints are active at optimality. + """ + m = pyo.ConcreteModel() + + m.x = pyo.Var(within=pyo.Reals, bounds=(-1, 3)) + m.y = pyo.Var(within=pyo.Reals, bounds=(-3, 2)) + + m.obj = pyo.Objective(expr=m.x + 2 * m.y, sense=pyo.maximize) + + m.con1 = pyo.Constraint(expr=m.x + m.y <= 3) + m.con2 = pyo.Constraint(expr=m.x + 2 * m.y <= 5) + m.con3 = pyo.Constraint(expr=m.x + m.y >= -1) + + return m + + +def get_triangle_ip(): + """ + Simple 2d discrete problem where the feasible region looks like a 90-45-45 + right triangle and the optimal solutions fall along the hypotenuse, where + x + y == 5. Alternative near-optimal have integer objective values from 0 to 4. + """ + var_max = 5 + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.NonNegativeIntegers, bounds=(0, var_max)) + m.y = pyo.Var(within=pyo.NonNegativeIntegers, bounds=(0, var_max)) + + m.o = pyo.Objective(expr=m.x + m.y, sense=pyo.maximize) + m.c = pyo.Constraint(expr=m.x + m.y <= var_max) + + # + # Enumerate all feasible solutions + # + feasible_sols = [] + for i in range(var_max + 1): + for j in range(var_max + 1): + if i + j <= var_max: + feasible_sols.append(((i, j), i + j)) + feasible_sols = sorted(feasible_sols, key=lambda sol: sol[1], reverse=True) + m.feasible_sols = feasible_sols + # + # Count of solutions from best to worst + # + m.num_ranked_solns = [6, 5, 4, 3, 2, 1] + + return m + + +def get_implied_bound_ip(): + """ + 2d discrete problem where the bounds of z are impled by x and y. This + facilitate testing cases where the impled bounds are tighter than the + given bounds for the variable. + """ + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.NonNegativeIntegers, bounds=(0, 5)) + m.y = pyo.Var(within=pyo.NonNegativeIntegers, bounds=(0, 5)) + m.z = pyo.Var(within=pyo.NonNegativeIntegers, bounds=(0, 5)) + + m.o = pyo.Objective(expr=m.x + m.z) + + m.c1 = pyo.Constraint(expr=m.x + m.y == 3) + m.c2 = pyo.Constraint(expr=m.x + m.y + m.z <= 5) + m.c3 = pyo.Constraint(expr=m.x + m.y + m.z >= 4) + + m.var_bounds = pyo.ComponentMap() + m.var_bounds[m.x] = (0, 3) + m.var_bounds[m.y] = (0, 3) + m.var_bounds[m.z] = (1, 2) + + return m + + +def get_aos_test_knapsack( + var_max, weights, values, capacity=None, capacity_fraction=1.0 +): + """ + Creates a knapsack problem, given arrays of weights and values, and + returns all feasible solutions. The capacity represents the percent of the + total max weight that can be selected (sum weights * var_max). The var_max + parameter sets the upper bound on all variables, the max number of times + they can be selected. + """ + assert len(weights) == len(values), "weights and values must be the same length." + assert ( + 0 <= capacity_fraction and capacity_fraction <= 1 + ), "capacity_fraction must be between 0 and 1." + + num_vars = len(weights) + if capacity is None: + capacity = sum(weights) * var_max * capacity_fraction + + m = pyo.ConcreteModel() + m.i = pyo.RangeSet(0, num_vars - 1) + + if var_max == 1: + m.x = pyo.Var(m.i, within=pyo.Binary) + else: + m.x = pyo.Var(m.i, within=pyo.NonNegativeIntegers, bounds=(0, var_max)) + + m.o = pyo.Objective(expr=sum(values[i] * m.x[i] for i in m.i), sense=pyo.maximize) + + m.c = pyo.Constraint(expr=sum(weights[i] * m.x[i] for i in m.i) <= capacity) + + var_domain = range(var_max + 1) + all_combos = product(var_domain, repeat=num_vars) + + feasible_sols = [] + for sol in all_combos: + if np.dot(sol, weights) <= capacity: + feasible_sols.append((sol, np.dot(sol, values))) + feasible_sols = sorted(feasible_sols, key=lambda sol: sol[1], reverse=True) + m.ranked_solution_values = list(sorted([v for x, v in feasible_sols], reverse=True)) + m.num_ranked_solns = list(Counter([v for x, v in feasible_sols]).values()) + return m + + +def get_pentagonal_lp(): + """ + Pentagonal LP + """ + var_max = 5 + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.Reals, bounds=(0, 2 * var_max)) + m.y = pyo.Var(within=pyo.Reals, bounds=(0, 2 * var_max)) + m.z = pyo.Var(within=pyo.NonNegativeReals, bounds=(0, 2 * var_max)) + m.o = pyo.Objective(expr=m.z, sense=pyo.minimize) + + base_points = np.array( + [ + [var_max, 2 * var_max, 0], + [2 * var_max, var_max, 0], + [3.0 * var_max / 2.0, 0, 0], + [var_max / 2.0, 0, 0], + [0, var_max, 0], + ] + ) + apex_point = np.array([var_max, var_max, var_max]) + + m.c = pyo.ConstraintList() + for i in range(5): + vec_1 = base_points[i] - apex_point + vec_2 = base_points[(i + 1) % var_max] - base_points[i] + n = np.cross(vec_1, vec_2) + m.c.add( + n[0] * (m.x - apex_point[0]) + + n[1] * (m.y - apex_point[1]) + + n[2] * (m.z - apex_point[2]) + >= 0 + ) + + return m + + +def get_pentagonal_pyramid_mip(): + """ + Pentagonal pyramid with integer coordinates in the first two dimensions and + a third continuous dimension. + """ + var_max = 5 + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.Integers, bounds=(-var_max, var_max)) + m.y = pyo.Var(within=pyo.Integers, bounds=(-var_max, var_max)) + m.z = pyo.Var(within=pyo.NonNegativeReals, bounds=(0, var_max)) + m.o = pyo.Objective(expr=m.z, sense=pyo.maximize) + + base_points = np.array( + [ + [0, var_max, 0], + [var_max, 0, 0], + [var_max / 2.0, -var_max, 0], + [-var_max / 2.0, -var_max, 0], + [-var_max, 0, 0], + ] + ) + apex_point = np.array([0, 0, var_max]) + + m.c = pyo.ConstraintList() + for i in range(5): + vec_1 = base_points[i] - apex_point + vec_2 = base_points[(i + 1) % var_max] - base_points[i] + n = np.cross(vec_1, vec_2) + m.c.add( + n[0] * (m.x - apex_point[0]) + + n[1] * (m.y - apex_point[1]) + + n[2] * (m.z - apex_point[2]) + >= 0 + ) + # + # Count of solutions from best to worst + # + m.num_ranked_solns = [1, 4, 2, 8, 2, 12, 4, 16, 4, 20] + return m + + +def get_indexed_pentagonal_pyramid_mip(): + """ + Pentagonal pyramid with integer coordinates in the first two dimensions and + a third continuous dimension. + """ + var_max = 5 + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2], within=pyo.Integers, bounds=(-var_max, var_max)) + m.z = pyo.Var(within=pyo.NonNegativeReals, bounds=(0, var_max)) + m.o = pyo.Objective(expr=m.z, sense=pyo.maximize) + base_points = np.array( + [ + [0, var_max, 0], + [var_max, 0, 0], + [var_max / 2.0, -var_max, 0], + [-var_max / 2.0, -var_max, 0], + [-var_max, 0, 0], + ] + ) + apex_point = np.array([0, 0, var_max]) + + def _con_rule(m, i): + vec_1 = base_points[i] - apex_point + vec_2 = base_points[(i + 1) % var_max] - base_points[i] + n = np.cross(vec_1, vec_2) + expr = ( + n[0] * (m.x[1] - apex_point[0]) + + n[1] * (m.x[2] - apex_point[1]) + + n[2] * (m.z - apex_point[2]) + ) + return expr >= 0 + + m.c = pyo.Constraint([i for i in range(5)], rule=_con_rule) + m.num_ranked_solns = [1, 4, 2, 8, 2, 12, 4, 16, 4, 20] + return m + + +def get_bloated_pentagonal_pyramid_mip(): + """ + Pentagonal pyramid with integer coordinates in the first two dimensions and + a third continuous dimension. Bounds are artificially widened for obbt testing purposes + """ + var_max = 5 + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.Integers, bounds=(-2 * var_max, 2 * var_max)) + m.y = pyo.Var(within=pyo.Integers, bounds=(-2 * var_max, var_max)) + m.z = pyo.Var(within=pyo.NonNegativeReals, bounds=(0, 2 * var_max)) + m.var_bounds = pyo.ComponentMap() + m.o = pyo.Objective(expr=m.z, sense=pyo.maximize) + base_points = np.array( + [ + [0, var_max, 0], + [var_max, 0, 0], + [var_max / 2.0, -var_max, 0], + [-var_max / 2.0, -var_max, 0], + [-var_max, 0, 0], + ] + ) + apex_point = np.array([0, 0, var_max]) + + m.c = pyo.ConstraintList() + for i in range(5): + vec_1 = base_points[i] - apex_point + vec_2 = base_points[(i + 1) % var_max] - base_points[i] + n = np.cross(vec_1, vec_2) + m.c.add( + n[0] * (m.x - apex_point[0]) + + n[1] * (m.y - apex_point[1]) + + n[2] * (m.z - apex_point[2]) + >= 0 + ) + return m diff --git a/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py b/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py new file mode 100644 index 00000000000..27e6fe0cfb1 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py @@ -0,0 +1,107 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import numpy as numpy, numpy_available + +import pyomo.environ as pyo +from pyomo.common import unittest +import pyomo.opt + +import pyomo.contrib.alternative_solutions.tests.test_cases as tc +from pyomo.contrib.alternative_solutions import lp_enum + +# +# Find available solvers. Just use GLPK if it's available. +# +solvers = list( + pyomo.opt.check_available_solvers("glpk", "gurobi") +) # , "appsi_gurobi")) +pytestmark = unittest.pytest.mark.parametrize("mip_solver", solvers) + +timelimit = {"gurobi": "TimeLimit", "appsi_gurobi": "TimeLimit", "glpk": "tmlim"} + + +@unittest.pytest.mark.default +class TestLPEnum: + + def test_bad_solver(self, mip_solver): + """ + Confirm that an exception is thrown with a bad solver name. + """ + m = tc.get_3d_polyhedron_problem() + try: + lp_enum.enumerate_linear_solutions(m, solver="unknown_solver") + except pyomo.common.errors.ApplicationError as e: + pass + + @unittest.skipIf(True, "Ignoring fragile test for solver timeout.") + def test_no_time(self, mip_solver): + """ + Check that the correct bounds are found for a discrete problem where + more restrictive bounds are implied by the constraints. + """ + m = tc.get_3d_polyhedron_problem() + with unittest.pytest.raises(Exception): + lp_enum.enumerate_linear_solutions( + m, solver=mip_solver, solver_options={timelimit[mip_solver]: 0} + ) + + def test_3d_polyhedron(self, mip_solver): + m = tc.get_3d_polyhedron_problem() + m.o.deactivate() + m.obj = pyo.Objective(expr=m.x[0] + m.x[1] + m.x[2]) + + sols = lp_enum.enumerate_linear_solutions(m, solver=mip_solver) + assert len(sols) == 2 + for s in sols: + assert s.objective_value == unittest.pytest.approx(4) + + def test_3d_polyhedron(self, mip_solver): + m = tc.get_3d_polyhedron_problem() + m.o.deactivate() + m.obj = pyo.Objective(expr=m.x[0] + 2 * m.x[1] + 3 * m.x[2]) + + sols = lp_enum.enumerate_linear_solutions(m, solver=mip_solver) + assert len(sols) == 2 + for s in sols: + assert s.objective_value == unittest.pytest.approx( + 9 + ) or s.objective_value == unittest.pytest.approx(10) + + def test_2d_diamond_problem(self, mip_solver): + m = tc.get_2d_diamond_problem() + sols = lp_enum.enumerate_linear_solutions(m, solver=mip_solver, num_solutions=2) + assert len(sols) == 2 + for s in sols: + print(s) + assert sols[0].objective_value == unittest.pytest.approx(6.789473684210527) + assert sols[1].objective_value == unittest.pytest.approx(3.6923076923076916) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_pentagonal_pyramid(self, mip_solver): + n = tc.get_pentagonal_pyramid_mip() + n.o.sense = pyo.minimize + n.x.domain = pyo.Reals + n.y.domain = pyo.Reals + + sols = lp_enum.enumerate_linear_solutions(n, solver=mip_solver, tee=False) + for s in sols: + print(s) + assert len(sols) == 6 + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_pentagon(self, mip_solver): + n = tc.get_pentagonal_lp() + + sols = lp_enum.enumerate_linear_solutions(n, solver=mip_solver) + for s in sols: + print(s) + assert len(sols) == 6 diff --git a/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py new file mode 100644 index 00000000000..c46466779e1 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py @@ -0,0 +1,48 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import numpy_available +from pyomo.common import unittest + +import pyomo.contrib.alternative_solutions.tests.test_cases as tc +from pyomo.contrib.alternative_solutions import lp_enum +from pyomo.contrib.alternative_solutions import lp_enum_solnpool +from pyomo.opt import check_available_solvers + +import pyomo.environ as pyo + +# lp_enum_solnpool uses both 'gurobi' and 'appsi_gurobi' +gurobi_available = len(check_available_solvers('gurobi', 'appsi_gurobi')) == 2 + +# +# TODO: Setup detailed tests here +# + + +@unittest.skipUnless(gurobi_available, "Gurobi MIP solver not available") +@unittest.skipUnless(numpy_available, "NumPy not found") +class TestLPEnumSolnpool(unittest.TestCase): + + def test_here(self): + n = tc.get_pentagonal_pyramid_mip() + n.x.domain = pyo.Reals + n.y.domain = pyo.Reals + + try: + sols = lp_enum_solnpool.enumerate_linear_solutions_soln_pool(n, tee=True) + except pyomo.common.errors.ApplicationError as e: + sols = [] + + # TODO - Confirm how solnpools deal with duplicate solutions + if gurobi_available: + assert len(sols) == 7 + else: + assert len(sols) == 0 diff --git a/pyomo/contrib/alternative_solutions/tests/test_obbt.py b/pyomo/contrib/alternative_solutions/tests/test_obbt.py new file mode 100644 index 00000000000..ac40c31a1f4 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_obbt.py @@ -0,0 +1,245 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import math + +from pyomo.common.dependencies import numpy as numpy, numpy_available + +if numpy_available: + from numpy.testing import assert_array_almost_equal + +import pyomo.environ as pyo +from pyomo.common import unittest + +import pyomo.opt +from pyomo.contrib.alternative_solutions import ( + obbt_analysis_bounds_and_solutions, + obbt_analysis, +) +import pyomo.contrib.alternative_solutions.tests.test_cases as tc + +solvers = list(pyomo.opt.check_available_solvers("glpk", "gurobi", "appsi_gurobi")) +pytestmark = unittest.pytest.mark.parametrize("mip_solver", solvers) + +timelimit = {"gurobi": "TimeLimit", "appsi_gurobi": "TimeLimit", "glpk": "tmlim"} + + +@unittest.pytest.mark.default +class TestOBBTUnit: + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_bad_solver(self, mip_solver): + """ + Confirm that an exception is thrown with a bad solver name. + """ + m = tc.get_2d_diamond_problem() + try: + obbt_analysis(m, solver="unknown_solver") + except pyomo.common.errors.ApplicationError as e: + pass + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_obbt_analysis(self, mip_solver): + """ + Check that the correct bounds are found for a continuous problem. + """ + m = tc.get_2d_diamond_problem() + all_bounds = obbt_analysis(m, solver=mip_solver) + assert all_bounds.keys() == m.continuous_bounds.keys() + for var, bounds in all_bounds.items(): + assert_array_almost_equal(bounds, m.continuous_bounds[var]) + + def test_obbt_error1(self, mip_solver): + """ + ERROR: Cannot restrict variable list when warmstart is specified + """ + m = tc.get_2d_diamond_problem() + with unittest.pytest.raises(AssertionError): + obbt_analysis_bounds_and_solutions(m, variables=[m.x], solver=mip_solver) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_obbt_some_vars(self, mip_solver): + """ + Check that the correct bounds are found for a continuous problem. + """ + m = tc.get_2d_diamond_problem() + all_bounds, solns = obbt_analysis_bounds_and_solutions( + m, variables=[m.x], warmstart=False, solver=mip_solver + ) + assert len(all_bounds) == 1 + assert len(solns) == 2 * len(all_bounds) + 1 + for var, bounds in all_bounds.items(): + assert_array_almost_equal(bounds, m.continuous_bounds[var]) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_obbt_continuous(self, mip_solver): + """ + Check that the correct bounds are found for a continuous problem. + """ + m = tc.get_2d_diamond_problem() + all_bounds, solns = obbt_analysis_bounds_and_solutions(m, solver=mip_solver) + assert len(solns) == 2 * len(all_bounds) + 1 + assert all_bounds.keys() == m.continuous_bounds.keys() + for var, bounds in all_bounds.items(): + assert_array_almost_equal(bounds, m.continuous_bounds[var]) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_rel_objective(self, mip_solver): + """ + Check that relative mip gap constraints are added for a mip with indexed vars and constraints + """ + m = tc.get_indexed_pentagonal_pyramid_mip() + all_bounds, solns = obbt_analysis_bounds_and_solutions( + m, rel_opt_gap=0.5, solver=mip_solver + ) + assert len(solns) == 2 * len(all_bounds) + 1 + assert m._obbt.optimality_tol_rel.lb == unittest.pytest.approx(2.5) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_abs_objective(self, mip_solver): + """ + Check that absolute mip gap constraints are added + """ + m = tc.get_pentagonal_pyramid_mip() + all_bounds, solns = obbt_analysis_bounds_and_solutions( + m, abs_opt_gap=1.99, solver=mip_solver + ) + assert len(solns) == 2 * len(all_bounds) + 1 + assert m._obbt.optimality_tol_abs.lb == unittest.pytest.approx(3.01) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_obbt_warmstart(self, mip_solver): + """ + Check that warmstarting works. + """ + m = tc.get_2d_diamond_problem() + m.x.value = 0 + m.y.value = 0 + all_bounds, solns = obbt_analysis_bounds_and_solutions( + m, solver=mip_solver, warmstart=True, tee=False + ) + assert len(solns) == 2 * len(all_bounds) + 1 + assert all_bounds.keys() == m.continuous_bounds.keys() + for var, bounds in all_bounds.items(): + assert_array_almost_equal(bounds, m.continuous_bounds[var]) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_obbt_mip(self, mip_solver): + """ + Check that bound tightening only occurs for continuous variables + that can be tightened. + """ + m = tc.get_bloated_pentagonal_pyramid_mip() + all_bounds, solns = obbt_analysis_bounds_and_solutions( + m, solver=mip_solver, tee=False + ) + assert len(solns) == 2 * len(all_bounds) + 1 + bounds_tightened = False + bounds_not_tightned = False + for var, bounds in all_bounds.items(): + if bounds[0] > var.lb: + bounds_tightened = True + else: + bounds_not_tightened = True + if bounds[1] < var.ub: + bounds_tightened = True + else: + bounds_not_tightened = True + assert bounds_tightened + assert bounds_not_tightened + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_obbt_unbounded(self, mip_solver): + """ + Check that the correct bounds are found for an unbounded problem. + """ + m = tc.get_2d_unbounded_problem() + all_bounds, solns = obbt_analysis_bounds_and_solutions(m, solver=mip_solver) + assert all_bounds.keys() == m.continuous_bounds.keys() + num = 1 + for var, bounds in all_bounds.items(): + if not math.isinf(bounds[0]): + num += 1 + if not math.isinf(bounds[1]): + num += 1 + assert_array_almost_equal(bounds, m.continuous_bounds[var]) + assert len(solns) == num + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_bound_tightening(self, mip_solver): + """ + Check that the correct bounds are found for a discrete problem where + more restrictive bounds are implied by the constraints. + """ + m = tc.get_implied_bound_ip() + all_bounds, solns = obbt_analysis_bounds_and_solutions(m, solver=mip_solver) + assert len(solns) == 2 * len(all_bounds) + 1 + assert all_bounds.keys() == m.var_bounds.keys() + for var, bounds in all_bounds.items(): + assert_array_almost_equal(bounds, m.var_bounds[var]) + + @unittest.skipIf(True, "Ignoring fragile test for solver timeout.") + def test_no_time(self, mip_solver): + """ + Check that the correct bounds are found for a discrete problem where + more restrictive bounds are implied by the constraints. + """ + m = tc.get_implied_bound_ip() + with unittest.pytest.raises(RuntimeError): + obbt_analysis_bounds_and_solutions( + m, solver=mip_solver, solver_options={timelimit[mip_solver]: 0} + ) + + def test_bound_refinement(self, mip_solver): + """ + Check that the correct bounds are found for a discrete problem where + more restrictive bounds are implied by the constraints and constraints + are added. + """ + m = tc.get_implied_bound_ip() + all_bounds, solns = obbt_analysis_bounds_and_solutions( + m, solver=mip_solver, refine_discrete_bounds=True + ) + assert len(solns) == 2 * len(all_bounds) + 1 + for var, bounds in all_bounds.items(): + if m.var_bounds[var][0] > var.lb: + match = False + for idx in m._obbt.bound_constraints: + const = m._obbt.bound_constraints[idx] + if var is const.body and bounds[0] == const.lb: + match = True + break + assert match, "Constraint not found for {} lower bound {}".format( + var, bounds[0] + ) + if m.var_bounds[var][1] < var.ub: + match = False + for idx in m._obbt.bound_constraints: + const = m._obbt.bound_constraints[idx] + if var is const.body and bounds[1] == const.ub: + match = True + break + assert match, "Constraint not found for {} upper bound {}".format( + var, bounds[1] + ) + + def test_obbt_infeasible(self, mip_solver): + """ + Check that code catches cases where the problem is infeasible. + """ + m = tc.get_2d_diamond_problem() + m.infeasible_constraint = pyo.Constraint(expr=m.x >= 10) + with unittest.pytest.raises(Exception): + obbt_analysis_bounds_and_solutions(m, solver=mip_solver) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/alternative_solutions/tests/test_shifted_lp.py b/pyomo/contrib/alternative_solutions/tests/test_shifted_lp.py new file mode 100644 index 00000000000..0b13bc75b03 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_shifted_lp.py @@ -0,0 +1,68 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import numpy as numpy, numpy_available + +if numpy_available: + from numpy.testing import assert_array_almost_equal + +import pyomo.environ as pyo +import pyomo.opt +from pyomo.common import unittest + +import pyomo.contrib.alternative_solutions.tests.test_cases as tc +from pyomo.contrib.alternative_solutions import shifted_lp + +# TODO: add checks that confirm the shifted constraints make sense + +# +# Find available solvers. Just use GLPK if it's available. +# +solvers = list(pyomo.opt.check_available_solvers("glpk", "gurobi")) +if "glpk" in solvers: + solver = ["glpk"] +pytestmark = unittest.pytest.mark.parametrize("lp_solver", solvers) + + +@unittest.pytest.mark.default +class TestShiftedIP: + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_abs_objective(self, lp_solver): + m = tc.get_indexed_pentagonal_pyramid_mip() + m.x.domain = pyo.Reals + + opt = pyo.SolverFactory(lp_solver) + old_results = opt.solve(m, tee=False) + old_obj = pyo.value(m.o) + + new_model = shifted_lp.get_shifted_linear_model(m) + new_results = opt.solve(new_model, tee=False) + new_obj = pyo.value(new_model.objective) + + assert old_obj == unittest.pytest.approx(new_obj) + + def test_polyhedron(self, lp_solver): + m = tc.get_3d_polyhedron_problem() + + opt = pyo.SolverFactory(lp_solver) + old_results = opt.solve(m, tee=False) + old_obj = pyo.value(m.o) + + new_model = shifted_lp.get_shifted_linear_model(m) + new_results = opt.solve(new_model, tee=False) + new_obj = pyo.value(new_model.objective) + + assert old_obj == unittest.pytest.approx(new_obj) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py new file mode 100644 index 00000000000..5fef32facc9 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -0,0 +1,143 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from collections import Counter + +from pyomo.common.dependencies import numpy as np, numpy_available +from pyomo.common import unittest +from pyomo.contrib.alternative_solutions import gurobi_generate_solutions +from pyomo.contrib.appsi.solvers import Gurobi + +import pyomo.contrib.alternative_solutions.tests.test_cases as tc + +gurobipy_available = Gurobi().available() + + +@unittest.skipIf(not gurobipy_available, "Gurobi MIP solver not available") +class TestSolnPoolUnit(unittest.TestCase): + """ + Cases to cover: + + LP feasibility (for an LP just one solution should be returned since gurobi cannot enumerate over continuous vars) + + Pass at least one solver option to make sure that work, e.g. time limit + + We need a utility to check that a two sets of solutions are the same. + Maybe this should be an AOS utility since it may be a thing we will want to do often. + """ + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_ip_feasibility(self): + """ + Enumerate all solutions for an ip: triangle_ip. + + Check that the correct number of alternate solutions are found. + """ + m = tc.get_triangle_ip() + results = gurobi_generate_solutions(m, num_solutions=100) + objectives = [round(result.objective[1], 2) for result in results] + actual_solns_by_obj = m.num_ranked_solns + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_ip_num_solutions(self): + """ + Enumerate 8 solutions for an ip: triangle_ip. + + Check that the correct number of alternate solutions are found. + """ + m = tc.get_triangle_ip() + results = gurobi_generate_solutions(m, num_solutions=8) + assert len(results) == 8 + objectives = [round(result.objective[1], 2) for result in results] + actual_solns_by_obj = [6, 2] + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_feasibility(self): + """ + Enumerate all solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that the correct number of alternate solutions are found. + """ + m = tc.get_indexed_pentagonal_pyramid_mip() + results = gurobi_generate_solutions(m, num_solutions=100) + objectives = [round(result.objective[1], 2) for result in results] + actual_solns_by_obj = m.num_ranked_solns + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_rel_feasibility(self): + """ + Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that only solutions within a relative tolerance of 0.2 are + found. + """ + m = tc.get_pentagonal_pyramid_mip() + results = gurobi_generate_solutions(m, num_solutions=100, rel_opt_gap=0.2) + objectives = [round(result.objective[1], 2) for result in results] + actual_solns_by_obj = m.num_ranked_solns[0:2] + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_rel_feasibility_options(self): + """ + Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that only solutions within a relative tolerance of 0.2 are + found. + """ + m = tc.get_pentagonal_pyramid_mip() + results = gurobi_generate_solutions( + m, num_solutions=100, solver_options={"PoolGap": 0.2} + ) + objectives = [round(result.objective[1], 2) for result in results] + actual_solns_by_obj = m.num_ranked_solns[0:2] + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_abs_feasibility(self): + """ + Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that only solutions within an absolute tolerance of 1.99 are + found. + """ + m = tc.get_pentagonal_pyramid_mip() + results = gurobi_generate_solutions(m, num_solutions=100, abs_opt_gap=1.99) + objectives = [round(result.objective[1], 2) for result in results] + actual_solns_by_obj = m.num_ranked_solns[0:3] + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(True, "Ignoring fragile test for solver timeout.") + def test_mip_no_time(self): + """ + Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that no solutions are returned with a timelimit of 0. + """ + m = tc.get_pentagonal_pyramid_mip() + # Use quiet=False to test error message + results = gurobi_generate_solutions( + m, num_solutions=100, solver_options={"TimeLimit": 0.0}, quiet=False + ) + assert len(results) == 0 + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/alternative_solutions/tests/test_solution.py b/pyomo/contrib/alternative_solutions/tests/test_solution.py new file mode 100644 index 00000000000..961068420be --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_solution.py @@ -0,0 +1,93 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.opt +import pyomo.environ as pyo +import pyomo.common.unittest as unittest +import pyomo.contrib.alternative_solutions.aos_utils as au +from pyomo.contrib.alternative_solutions import Solution + +mip_solver = "gurobi" +mip_available = pyomo.opt.check_available_solvers(mip_solver) + + +class TestSolutionUnit(unittest.TestCase): + + def get_model(self): + """ + Simple model with all variable types and fixed variables to test the + Solution code. + """ + m = pyo.ConcreteModel() + m.x = pyo.Var(domain=pyo.NonNegativeReals) + m.y = pyo.Var(domain=pyo.Binary) + m.z = pyo.Var(domain=pyo.NonNegativeIntegers) + m.f = pyo.Var(domain=pyo.Reals) + + m.f.fix(1) + m.obj = pyo.Objective(expr=m.x + m.y + m.z + m.f, sense=pyo.maximize) + + m.con_x = pyo.Constraint(expr=m.x <= 1.5) + m.con_y = pyo.Constraint(expr=m.y <= 1) + m.con_z = pyo.Constraint(expr=m.z <= 3) + return m + + @unittest.skipUnless(mip_available, "MIP solver not available") + def test_solution(self): + """ + Create a Solution Object, call its functions, and ensure the correct + data is returned. + """ + model = self.get_model() + opt = pyo.SolverFactory(mip_solver) + opt.solve(model) + all_vars = au.get_model_variables(model, include_fixed=True) + + solution = Solution(model, all_vars, include_fixed=False) + sol_str = """{ + "fixed_variables": [ + "f" + ], + "objective": "obj", + "objective_value": 6.5, + "solution": { + "x": 1.5, + "y": 1, + "z": 3 + } +}""" + assert str(solution) == sol_str + + solution = Solution(model, all_vars) + sol_str = """{ + "fixed_variables": [ + "f" + ], + "objective": "obj", + "objective_value": 6.5, + "solution": { + "f": 1, + "x": 1.5, + "y": 1, + "z": 3 + } +}""" + assert solution.to_string(round_discrete=True) == sol_str + + sol_val = solution.get_variable_name_values( + include_fixed=True, round_discrete=True + ) + self.assertEqual(set(sol_val.keys()), {"x", "y", "z", "f"}) + self.assertEqual(set(solution.get_fixed_variable_names()), {"f"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/ampl_function_demo/__init__.py b/pyomo/contrib/ampl_function_demo/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/ampl_function_demo/__init__.py +++ b/pyomo/contrib/ampl_function_demo/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/ampl_function_demo/build.py b/pyomo/contrib/ampl_function_demo/build.py index 764a613b3d7..10b9667aded 100644 --- a/pyomo/contrib/ampl_function_demo/build.py +++ b/pyomo/contrib/ampl_function_demo/build.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/ampl_function_demo/plugins.py b/pyomo/contrib/ampl_function_demo/plugins.py index 5a200174c43..b06dc08614c 100644 --- a/pyomo/contrib/ampl_function_demo/plugins.py +++ b/pyomo/contrib/ampl_function_demo/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/ampl_function_demo/src/CMakeLists.txt b/pyomo/contrib/ampl_function_demo/src/CMakeLists.txt index 67efc13d3c8..447a9897a3b 100644 --- a/pyomo/contrib/ampl_function_demo/src/CMakeLists.txt +++ b/pyomo/contrib/ampl_function_demo/src/CMakeLists.txt @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,8 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -cmake_minimum_required(VERSION 3.0) -# CMake 3.0 needed by AMPL/asl build +cmake_minimum_required(VERSION 3.0...3.31) +# This was developed against CMake 3.0, and appears to comply with 3.5 PROJECT( ampl_function_demo ) diff --git a/pyomo/contrib/ampl_function_demo/src/FindASL.cmake b/pyomo/contrib/ampl_function_demo/src/FindASL.cmake index 8bbc048fa6e..73c263e5d20 100644 --- a/pyomo/contrib/ampl_function_demo/src/FindASL.cmake +++ b/pyomo/contrib/ampl_function_demo/src/FindASL.cmake @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,21 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -cmake_minimum_required(VERSION 3.0) -# Minimum version inherited from AMPL/asl +cmake_minimum_required(VERSION 3.0...3.31) +# CMake 3.0 added GIT_SUBMODULES to ExternalProject_ADD, and without it +# the Ampl/MP checkout fails because one of the submodules (gecode) is a +# private repository. +# +# CMake will complain/fail if we don't explicitly acknowledge 3.5 +# compatibility. AMPL/asl has moved their min version to 3.5. include(ExternalProject) # Dependencies that we manage / can install -SET(AMPLASL_TAG "9fb7cb8e4f68ed1c3bc066d191e63698b7d7d1d2" CACHE STRING +SET(AMPLASL_TAG "ae937db9bd1169ec2c4cb8d75196f67cdcb8041b" CACHE STRING "AMPL/asl git tag/branch to checkout and build") -# 9fb7cb8e4f68ed1c3bc066d191e63698b7d7d1d2 corresponds to ASLdate = 20211109 +# 9fb7cb8e4f68ed1c3bc066d191e63698b7d7d1d2: ASLdate=20211109 +# ae937db9bd1169ec2c4cb8d75196f67cdcb8041b: v1.0.1 ASLdate = 20241202/20241122 OPTION(BUILD_AMPLASL "Download and build AMPL/asl ${AMPLASL_TAG} from GitHub" OFF) diff --git a/pyomo/contrib/ampl_function_demo/src/functions.c b/pyomo/contrib/ampl_function_demo/src/functions.c index e87af745aea..fd4372693e7 100644 --- a/pyomo/contrib/ampl_function_demo/src/functions.c +++ b/pyomo/contrib/ampl_function_demo/src/functions.c @@ -1,6 +1,6 @@ /* ___________________________________________________________________________ * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/ampl_function_demo/tests/__init__.py b/pyomo/contrib/ampl_function_demo/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/ampl_function_demo/tests/__init__.py +++ b/pyomo/contrib/ampl_function_demo/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/ampl_function_demo/tests/test_ampl_function_demo.py b/pyomo/contrib/ampl_function_demo/tests/test_ampl_function_demo.py index 39890494d55..196d6b2f4e9 100644 --- a/pyomo/contrib/ampl_function_demo/tests/test_ampl_function_demo.py +++ b/pyomo/contrib/ampl_function_demo/tests/test_ampl_function_demo.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/__init__.py b/pyomo/contrib/appsi/__init__.py index 2f06fc89e70..a9ae3d21d58 100644 --- a/pyomo/contrib/appsi/__init__.py +++ b/pyomo/contrib/appsi/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/base.py b/pyomo/contrib/appsi/base.py index 201e5975ac9..0f7cfda4437 100644 --- a/pyomo/contrib/appsi/base.py +++ b/pyomo/contrib/appsi/base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,6 +11,10 @@ import abc import enum +import os +import re +import weakref + from typing import ( Sequence, Dict, @@ -21,21 +25,22 @@ Tuple, MutableMapping, ) -from pyomo.core.base.constraint import _GeneralConstraintData, Constraint -from pyomo.core.base.sos import _SOSConstraintData, SOSConstraint -from pyomo.core.base.var import _GeneralVarData, Var -from pyomo.core.base.param import _ParamData, Param -from pyomo.core.base.block import _BlockData, Block -from pyomo.core.base.objective import _GeneralObjectiveData + +from pyomo.common.config import ConfigDict, ConfigValue, NonNegativeFloat +from pyomo.common.errors import ApplicationError +from pyomo.common.enums import IntEnum +from pyomo.common.factory import Factory +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.base.constraint import ConstraintData, Constraint +from pyomo.core.base.sos import SOSConstraintData, SOSConstraint +from pyomo.core.base.var import VarData, Var +from pyomo.core.base.param import ParamData, Param +from pyomo.core.base.block import BlockData, Block +from pyomo.core.base.objective import ObjectiveData from pyomo.common.collections import ComponentMap from .utils.get_objective import get_objective from .utils.collect_vars_and_named_exprs import collect_vars_and_named_exprs -from pyomo.common.timing import HierarchicalTimer -from pyomo.common.config import ConfigDict, ConfigValue, NonNegativeFloat -from pyomo.common.errors import ApplicationError from pyomo.opt.base import SolverFactory as LegacySolverFactory -from pyomo.common.factory import Factory -import os from pyomo.opt.results.results_ import SolverResults as LegacySolverResults from pyomo.opt.results.solution import ( Solution as LegacySolution, @@ -47,7 +52,6 @@ ) from pyomo.core.kernel.objective import minimize from pyomo.core.base import SymbolMap -import weakref from .cmodel import cmodel, cmodel_available from pyomo.core.staleflag import StaleFlagManager from pyomo.core.expr.numvalue import NumericConstant @@ -97,6 +101,9 @@ class TerminationCondition(enum.Enum): class SolverConfig(ConfigDict): """ + Common configuration options for all APPSI solver interfaces + + Attributes ---------- time_limit: float @@ -132,12 +139,14 @@ def __init__( ) self.declare('time_limit', ConfigValue(domain=NonNegativeFloat)) + self.declare('warmstart', ConfigValue(domain=bool)) self.declare('stream_solver', ConfigValue(domain=bool)) self.declare('load_solution', ConfigValue(domain=bool)) self.declare('symbolic_solver_labels', ConfigValue(domain=bool)) self.declare('report_timing', ConfigValue(domain=bool)) self.time_limit: Optional[float] = None + self.warmstart: bool = False self.stream_solver: bool = False self.load_solution: bool = True self.symbolic_solver_labels: bool = False @@ -146,6 +155,8 @@ def __init__( class MIPSolverConfig(SolverConfig): """ + Configuration options common to all MIP solvers + Attributes ---------- mip_gap: float @@ -179,9 +190,7 @@ def __init__( class SolutionLoaderBase(abc.ABC): - def load_vars( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> NoReturn: + def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: """ Load the solution of the primal variables into the value attribute of the variables. @@ -197,8 +206,8 @@ def load_vars( @abc.abstractmethod def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to var value. @@ -216,8 +225,8 @@ def get_primals( pass def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: """ Returns a dictionary mapping constraint to dual value. @@ -235,8 +244,8 @@ def get_duals( raise NotImplementedError(f'{type(self)} does not support the get_duals method') def get_slacks( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: """ Returns a dictionary mapping constraint to slack. @@ -256,8 +265,8 @@ def get_slacks( ) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to reduced cost. @@ -303,8 +312,8 @@ def __init__( self._reduced_costs = reduced_costs def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if self._primals is None: raise RuntimeError( 'Solution loader does not currently have a valid solution. Please ' @@ -319,8 +328,8 @@ def get_primals( return primals def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: if self._duals is None: raise RuntimeError( 'Solution loader does not currently have valid duals. Please ' @@ -336,8 +345,8 @@ def get_duals( return duals def get_slacks( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: if self._slacks is None: raise RuntimeError( 'Solution loader does not currently have valid slacks. Please ' @@ -353,8 +362,8 @@ def get_slacks( return slacks def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if self._reduced_costs is None: raise RuntimeError( 'Solution loader does not currently have valid reduced costs. Please ' @@ -372,6 +381,8 @@ def get_reduced_costs( class Results(object): """ + Base class for all APPSI solver results + Attributes ---------- termination_condition: TerminationCondition @@ -387,13 +398,15 @@ class Results(object): For solvers that do not provide an objective bound, this should be -inf (minimization) or inf (maximization) + Example + ------- Here is an example workflow: - >>> import pyomo.environ as pe + >>> import pyomo.environ as pyo >>> from pyomo.contrib import appsi - >>> m = pe.ConcreteModel() - >>> m.x = pe.Var() - >>> m.obj = pe.Objective(expr=m.x**2) + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var() + >>> m.obj = pyo.Objective(expr=m.x**2) >>> opt = appsi.solvers.Ipopt() >>> opt.config.load_solution = False >>> results = opt.solve(m) #doctest:+SKIP @@ -429,6 +442,8 @@ def __str__(self): class UpdateConfig(ConfigDict): """ + Config options common to all persistent solvers + Attributes ---------- check_for_new_or_removed_constraints: bool @@ -596,7 +611,7 @@ def __init__( class Solver(abc.ABC): - class Availability(enum.IntEnum): + class Availability(IntEnum): NotFound = 0 BadVersion = -1 BadLicense = -2 @@ -621,20 +636,20 @@ def __str__(self): return self.name @abc.abstractmethod - def solve(self, model: _BlockData, timer: HierarchicalTimer = None) -> Results: + def solve(self, model: BlockData, timer: HierarchicalTimer = None) -> Results: """ Solve a Pyomo model. Parameters ---------- - model: _BlockData + model: BlockData The Pyomo model to be solved timer: HierarchicalTimer An option timer for reporting timing Returns ------- - results: Results + results: ~pyomo.contrib.appsi.base.Results A results object """ pass @@ -683,7 +698,7 @@ def config(self): Returns ------- - SolverConfig + ~pyomo.contrib.appsi.base.SolverConfig An object for configuring pyomo solve options such as the time limit. These options are mostly independent of the solver. """ @@ -708,9 +723,7 @@ class PersistentSolver(Solver): def is_persistent(self): return True - def load_vars( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> NoReturn: + def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: """ Load the solution of the primal variables into the value attribute of the variables. @@ -726,13 +739,13 @@ def load_vars( @abc.abstractmethod def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: pass def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: """ Declare sign convention in docstring here. @@ -752,8 +765,8 @@ def get_duals( ) def get_slacks( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: """ Parameters ---------- @@ -771,8 +784,8 @@ def get_slacks( ) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: """ Parameters ---------- @@ -799,43 +812,43 @@ def set_instance(self, model): pass @abc.abstractmethod - def add_variables(self, variables: List[_GeneralVarData]): + def add_variables(self, variables: List[VarData]): pass @abc.abstractmethod - def add_params(self, params: List[_ParamData]): + def add_params(self, params: List[ParamData]): pass @abc.abstractmethod - def add_constraints(self, cons: List[_GeneralConstraintData]): + def add_constraints(self, cons: List[ConstraintData]): pass @abc.abstractmethod - def add_block(self, block: _BlockData): + def add_block(self, block: BlockData): pass @abc.abstractmethod - def remove_variables(self, variables: List[_GeneralVarData]): + def remove_variables(self, variables: List[VarData]): pass @abc.abstractmethod - def remove_params(self, params: List[_ParamData]): + def remove_params(self, params: List[ParamData]): pass @abc.abstractmethod - def remove_constraints(self, cons: List[_GeneralConstraintData]): + def remove_constraints(self, cons: List[ConstraintData]): pass @abc.abstractmethod - def remove_block(self, block: _BlockData): + def remove_block(self, block: BlockData): pass @abc.abstractmethod - def set_objective(self, obj: _GeneralObjectiveData): + def set_objective(self, obj: ObjectiveData): pass @abc.abstractmethod - def update_variables(self, variables: List[_GeneralVarData]): + def update_variables(self, variables: List[VarData]): pass @abc.abstractmethod @@ -857,20 +870,20 @@ def get_primals(self, vars_to_load=None): return self._solver.get_primals(vars_to_load=vars_to_load) def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return self._solver.get_duals(cons_to_load=cons_to_load) def get_slacks( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return self._solver.get_slacks(cons_to_load=cons_to_load) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return self._solver.get_reduced_costs(vars_to_load=vars_to_load) @@ -954,10 +967,10 @@ def set_instance(self, model): self.set_objective(None) @abc.abstractmethod - def _add_variables(self, variables: List[_GeneralVarData]): + def _add_variables(self, variables: List[VarData]): pass - def add_variables(self, variables: List[_GeneralVarData]): + def add_variables(self, variables: List[VarData]): for v in variables: if id(v) in self._referenced_variables: raise ValueError( @@ -975,19 +988,19 @@ def add_variables(self, variables: List[_GeneralVarData]): self._add_variables(variables) @abc.abstractmethod - def _add_params(self, params: List[_ParamData]): + def _add_params(self, params: List[ParamData]): pass - def add_params(self, params: List[_ParamData]): + def add_params(self, params: List[ParamData]): for p in params: self._params[id(p)] = p self._add_params(params) @abc.abstractmethod - def _add_constraints(self, cons: List[_GeneralConstraintData]): + def _add_constraints(self, cons: List[ConstraintData]): pass - def _check_for_new_vars(self, variables: List[_GeneralVarData]): + def _check_for_new_vars(self, variables: List[VarData]): new_vars = dict() for v in variables: v_id = id(v) @@ -995,7 +1008,7 @@ def _check_for_new_vars(self, variables: List[_GeneralVarData]): new_vars[v_id] = v self.add_variables(list(new_vars.values())) - def _check_to_remove_vars(self, variables: List[_GeneralVarData]): + def _check_to_remove_vars(self, variables: List[VarData]): vars_to_remove = dict() for v in variables: v_id = id(v) @@ -1004,14 +1017,14 @@ def _check_to_remove_vars(self, variables: List[_GeneralVarData]): vars_to_remove[v_id] = v self.remove_variables(list(vars_to_remove.values())) - def add_constraints(self, cons: List[_GeneralConstraintData]): + def add_constraints(self, cons: List[ConstraintData]): all_fixed_vars = dict() for con in cons: if con in self._named_expressions: raise ValueError( 'constraint {name} has already been added'.format(name=con.name) ) - self._active_constraints[con] = (con.lower, con.body, con.upper) + self._active_constraints[con] = con.expr if self.use_extensions and cmodel_available: tmp = cmodel.prep_for_repn(con.body, self._expr_types) else: @@ -1034,10 +1047,10 @@ def add_constraints(self, cons: List[_GeneralConstraintData]): v.fix() @abc.abstractmethod - def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + def _add_sos_constraints(self, cons: List[SOSConstraintData]): pass - def add_sos_constraints(self, cons: List[_SOSConstraintData]): + def add_sos_constraints(self, cons: List[SOSConstraintData]): for con in cons: if con in self._vars_referenced_by_con: raise ValueError( @@ -1054,10 +1067,10 @@ def add_sos_constraints(self, cons: List[_SOSConstraintData]): self._add_sos_constraints(cons) @abc.abstractmethod - def _set_objective(self, obj: _GeneralObjectiveData): + def _set_objective(self, obj: ObjectiveData): pass - def set_objective(self, obj: _GeneralObjectiveData): + def set_objective(self, obj: ObjectiveData): if self._objective is not None: for v in self._vars_referenced_by_obj: self._referenced_variables[id(v)][2] = None @@ -1132,10 +1145,10 @@ def add_block(self, block): self.set_objective(obj) @abc.abstractmethod - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): pass - def remove_constraints(self, cons: List[_GeneralConstraintData]): + def remove_constraints(self, cons: List[ConstraintData]): self._remove_constraints(cons) for con in cons: if con not in self._named_expressions: @@ -1154,10 +1167,10 @@ def remove_constraints(self, cons: List[_GeneralConstraintData]): del self._vars_referenced_by_con[con] @abc.abstractmethod - def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): pass - def remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def remove_sos_constraints(self, cons: List[SOSConstraintData]): self._remove_sos_constraints(cons) for con in cons: if con not in self._vars_referenced_by_con: @@ -1174,10 +1187,10 @@ def remove_sos_constraints(self, cons: List[_SOSConstraintData]): del self._vars_referenced_by_con[con] @abc.abstractmethod - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): pass - def remove_variables(self, variables: List[_GeneralVarData]): + def remove_variables(self, variables: List[VarData]): self._remove_variables(variables) for v in variables: v_id = id(v) @@ -1198,10 +1211,10 @@ def remove_variables(self, variables: List[_GeneralVarData]): del self._vars[v_id] @abc.abstractmethod - def _remove_params(self, params: List[_ParamData]): + def _remove_params(self, params: List[ParamData]): pass - def remove_params(self, params: List[_ParamData]): + def remove_params(self, params: List[ParamData]): self._remove_params(params) for p in params: del self._params[id(p)] @@ -1246,10 +1259,10 @@ def remove_block(self, block): ) @abc.abstractmethod - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): pass - def update_variables(self, variables: List[_GeneralVarData]): + def update_variables(self, variables: List[VarData]): for v in variables: self._vars[id(v)] = ( v, @@ -1334,12 +1347,12 @@ def update(self, timer: HierarchicalTimer = None): for c in self._vars_referenced_by_con.keys(): if c not in current_cons_dict and c not in current_sos_dict: if (c.ctype is Constraint) or ( - c.ctype is None and isinstance(c, _GeneralConstraintData) + c.ctype is None and isinstance(c, ConstraintData) ): old_cons.append(c) else: assert (c.ctype is SOSConstraint) or ( - c.ctype is None and isinstance(c, _SOSConstraintData) + c.ctype is None and isinstance(c, SOSConstraintData) ) old_sos.append(c) self.remove_constraints(old_cons) @@ -1367,40 +1380,13 @@ def update(self, timer: HierarchicalTimer = None): cons_to_remove_and_add = dict() need_to_set_objective = False if config.update_constraints: - cons_to_update = list() - sos_to_update = list() for c in current_cons_dict.keys(): - if c not in new_cons_set: - cons_to_update.append(c) + if c not in new_cons_set and c.expr is not self._active_constraints[c]: + cons_to_remove_and_add[c] = None + sos_to_update = [] for c in current_sos_dict.keys(): if c not in new_sos_set: sos_to_update.append(c) - for c in cons_to_update: - lower, body, upper = self._active_constraints[c] - new_lower, new_body, new_upper = c.lower, c.body, c.upper - if new_body is not body: - cons_to_remove_and_add[c] = None - continue - if new_lower is not lower: - if ( - type(new_lower) is NumericConstant - and type(lower) is NumericConstant - and new_lower.value == lower.value - ): - pass - else: - cons_to_remove_and_add[c] = None - continue - if new_upper is not upper: - if ( - type(new_upper) is NumericConstant - and type(upper) is NumericConstant - and new_upper.value == upper.value - ): - pass - else: - cons_to_remove_and_add[c] = None - continue self.remove_sos_constraints(sos_to_update) self.add_sos_constraints(sos_to_update) timer.stop('cons') @@ -1529,7 +1515,7 @@ def update(self, timer: HierarchicalTimer = None): class LegacySolverInterface(object): def solve( self, - model: _BlockData, + model: BlockData, tee: bool = False, load_solutions: bool = True, logfile: Optional[str] = None, @@ -1541,6 +1527,7 @@ def solve( options: Optional[Dict] = None, keepfiles: bool = False, symbolic_solver_labels: bool = False, + warmstart: bool = False, ): original_config = self.config self.config = self.config() @@ -1548,6 +1535,7 @@ def solve( self.config.load_solution = load_solutions self.config.symbolic_solver_labels = symbolic_solver_labels self.config.time_limit = timelimit + self.config.warmstart = warmstart self.config.report_timing = report_timing if solver_io is not None: raise NotImplementedError('Still working on this') @@ -1665,7 +1653,7 @@ def license_is_valid(self) -> bool: @property def options(self): - for solver_name in ['gurobi', 'ipopt', 'cplex', 'cbc', 'highs']: + for solver_name in ['gurobi', 'ipopt', 'cplex', 'cbc', 'highs', 'maingo']: if hasattr(self, solver_name + '_options'): return getattr(self, solver_name + '_options') raise NotImplementedError('Could not find the correct options') @@ -1673,7 +1661,7 @@ def options(self): @options.setter def options(self, val): found = False - for solver_name in ['gurobi', 'ipopt', 'cplex', 'cbc', 'highs']: + for solver_name in ['gurobi', 'ipopt', 'cplex', 'cbc', 'highs', 'maingo']: if hasattr(self, solver_name + '_options'): setattr(self, solver_name + '_options', val) found = True diff --git a/pyomo/contrib/appsi/build.py b/pyomo/contrib/appsi/build.py index b3d78467f01..3412fb9fc42 100644 --- a/pyomo/contrib/appsi/build.py +++ b/pyomo/contrib/appsi/build.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,15 +16,6 @@ import tempfile -def handleReadonly(function, path, excinfo): - excvalue = excinfo[1] - if excvalue.errno == errno.EACCES: - os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # 0777 - function(path) - else: - raise - - def get_appsi_extension(in_setup=False, appsi_root=None): from pybind11.setup_helpers import Pybind11Extension @@ -66,6 +57,7 @@ def build_appsi(args=[]): from setuptools import Distribution from pybind11.setup_helpers import build_ext import pybind11.setup_helpers + from pyomo.common.cmake_builder import handleReadonly from pyomo.common.envvar import PYOMO_CONFIG_DIR from pyomo.common.fileutils import this_file_dir diff --git a/pyomo/contrib/appsi/cmodel/__init__.py b/pyomo/contrib/appsi/cmodel/__init__.py index cc2aec28241..51978672d27 100644 --- a/pyomo/contrib/appsi/cmodel/__init__.py +++ b/pyomo/contrib/appsi/cmodel/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/cmodel_bindings.cpp b/pyomo/contrib/appsi/cmodel/src/cmodel_bindings.cpp index 6acc1d79845..3fc2d621dd4 100644 --- a/pyomo/contrib/appsi/cmodel/src/cmodel_bindings.cpp +++ b/pyomo/contrib/appsi/cmodel/src/cmodel_bindings.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -63,7 +63,8 @@ PYBIND11_MODULE(appsi_cmodel, m) { m.def("appsi_exprs_from_pyomo_exprs", &appsi_exprs_from_pyomo_exprs); m.def("appsi_expr_from_pyomo_expr", &appsi_expr_from_pyomo_expr); m.def("prep_for_repn", &prep_for_repn); - py::class_(m, "PyomoExprTypes").def(py::init<>()); + py::class_(m, "PyomoExprTypes", py::module_local()) + .def(py::init<>()); py::class_>(m, "Node") .def("is_variable_type", &Node::is_variable_type) .def("is_param_type", &Node::is_param_type) @@ -165,7 +166,7 @@ PYBIND11_MODULE(appsi_cmodel, m) { .def(py::init<>()) .def("write", &LPWriter::write) .def("get_solve_cons", &LPWriter::get_solve_cons); - py::enum_(m, "ExprType") + py::enum_(m, "ExprType", py::module_local()) .value("py_float", ExprType::py_float) .value("var", ExprType::var) .value("param", ExprType::param) diff --git a/pyomo/contrib/appsi/cmodel/src/common.cpp b/pyomo/contrib/appsi/cmodel/src/common.cpp index 6f8002cb50e..6681c3f7a45 100644 --- a/pyomo/contrib/appsi/cmodel/src/common.cpp +++ b/pyomo/contrib/appsi/cmodel/src/common.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/common.hpp b/pyomo/contrib/appsi/cmodel/src/common.hpp index 9edc9571a4d..ad196010e23 100644 --- a/pyomo/contrib/appsi/cmodel/src/common.hpp +++ b/pyomo/contrib/appsi/cmodel/src/common.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/expression.cpp b/pyomo/contrib/appsi/cmodel/src/expression.cpp index 234ef47e86f..cf2e7b9f189 100644 --- a/pyomo/contrib/appsi/cmodel/src/expression.cpp +++ b/pyomo/contrib/appsi/cmodel/src/expression.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -1548,7 +1548,10 @@ appsi_operator_from_pyomo_expr(py::handle expr, py::handle var_map, break; } case param: { - res = param_map[expr_types.id(expr)].cast>(); + if (expr.attr("parent_component")().attr("mutable").cast()) + res = param_map[expr_types.id(expr)].cast>(); + else + res = std::make_shared(expr.attr("value").cast()); break; } case product: { @@ -1789,7 +1792,8 @@ int build_expression_tree(py::handle pyomo_expr, if (expr_types.expr_type_map[py::type::of(pyomo_expr)].cast() == named_expr) - pyomo_expr = pyomo_expr.attr("expr"); + return build_expression_tree(pyomo_expr.attr("expr"), appsi_expr, var_map, + param_map, expr_types); if (appsi_expr->is_leaf()) { ; diff --git a/pyomo/contrib/appsi/cmodel/src/expression.hpp b/pyomo/contrib/appsi/cmodel/src/expression.hpp index 0c0777ef468..dbc3f49c3e0 100644 --- a/pyomo/contrib/appsi/cmodel/src/expression.hpp +++ b/pyomo/contrib/appsi/cmodel/src/expression.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -680,10 +680,10 @@ class PyomoExprTypes { expr_type_map[np_float32] = py_float; expr_type_map[np_float64] = py_float; expr_type_map[ScalarVar] = var; - expr_type_map[_GeneralVarData] = var; + expr_type_map[VarData] = var; expr_type_map[AutoLinkedBinaryVar] = var; expr_type_map[ScalarParam] = param; - expr_type_map[_ParamData] = param; + expr_type_map[ParamData] = param; expr_type_map[MonomialTermExpression] = product; expr_type_map[ProductExpression] = product; expr_type_map[NPV_ProductExpression] = product; @@ -700,7 +700,7 @@ class PyomoExprTypes { expr_type_map[UnaryFunctionExpression] = unary_func; expr_type_map[NPV_UnaryFunctionExpression] = unary_func; expr_type_map[LinearExpression] = linear; - expr_type_map[_GeneralExpressionData] = named_expr; + expr_type_map[ExpressionData] = named_expr; expr_type_map[ScalarExpression] = named_expr; expr_type_map[Integral] = named_expr; expr_type_map[ScalarIntegral] = named_expr; @@ -728,12 +728,12 @@ class PyomoExprTypes { py::type np_float64 = np.attr("float64"); py::object ScalarParam = py::module_::import("pyomo.core.base.param").attr("ScalarParam"); - py::object _ParamData = - py::module_::import("pyomo.core.base.param").attr("_ParamData"); + py::object ParamData = + py::module_::import("pyomo.core.base.param").attr("ParamData"); py::object ScalarVar = py::module_::import("pyomo.core.base.var").attr("ScalarVar"); - py::object _GeneralVarData = - py::module_::import("pyomo.core.base.var").attr("_GeneralVarData"); + py::object VarData = + py::module_::import("pyomo.core.base.var").attr("VarData"); py::object AutoLinkedBinaryVar = py::module_::import("pyomo.gdp.disjunct").attr("AutoLinkedBinaryVar"); py::object numeric_expr = py::module_::import("pyomo.core.expr.numeric_expr"); @@ -765,8 +765,8 @@ class PyomoExprTypes { py::object NumericConstant = py::module_::import("pyomo.core.expr.numvalue").attr("NumericConstant"); py::object expr_module = py::module_::import("pyomo.core.base.expression"); - py::object _GeneralExpressionData = - expr_module.attr("_GeneralExpressionData"); + py::object ExpressionData = + expr_module.attr("ExpressionData"); py::object ScalarExpression = expr_module.attr("ScalarExpression"); py::object ScalarIntegral = py::module_::import("pyomo.dae.integral").attr("ScalarIntegral"); diff --git a/pyomo/contrib/appsi/cmodel/src/fbbt_model.cpp b/pyomo/contrib/appsi/cmodel/src/fbbt_model.cpp index bd8d7dbf854..b88cfa447b7 100644 --- a/pyomo/contrib/appsi/cmodel/src/fbbt_model.cpp +++ b/pyomo/contrib/appsi/cmodel/src/fbbt_model.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -205,7 +205,7 @@ void process_fbbt_constraints(FBBTModel *model, PyomoExprTypes &expr_types, py::handle con_body; for (py::handle c : cons) { - lower_body_upper = active_constraints[c]; + lower_body_upper = c.attr("to_bounded_expression")(); con_lb = lower_body_upper[0]; con_body = lower_body_upper[1]; con_ub = lower_body_upper[2]; diff --git a/pyomo/contrib/appsi/cmodel/src/fbbt_model.hpp b/pyomo/contrib/appsi/cmodel/src/fbbt_model.hpp index ca1980a797b..88c2dd4c504 100644 --- a/pyomo/contrib/appsi/cmodel/src/fbbt_model.hpp +++ b/pyomo/contrib/appsi/cmodel/src/fbbt_model.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/interval.cpp b/pyomo/contrib/appsi/cmodel/src/interval.cpp index 1d9b3a6f82e..7d35bc5b6ef 100644 --- a/pyomo/contrib/appsi/cmodel/src/interval.cpp +++ b/pyomo/contrib/appsi/cmodel/src/interval.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/interval.hpp b/pyomo/contrib/appsi/cmodel/src/interval.hpp index a57f107f8db..40840aa4f82 100644 --- a/pyomo/contrib/appsi/cmodel/src/interval.hpp +++ b/pyomo/contrib/appsi/cmodel/src/interval.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/lp_writer.cpp b/pyomo/contrib/appsi/cmodel/src/lp_writer.cpp index 68baf2b8ae8..4e5cc570b9b 100644 --- a/pyomo/contrib/appsi/cmodel/src/lp_writer.cpp +++ b/pyomo/contrib/appsi/cmodel/src/lp_writer.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -289,7 +289,7 @@ void process_lp_constraints(py::list cons, py::object writer) { py::object nonlinear_expr; PyomoExprTypes expr_types = PyomoExprTypes(); for (py::handle c : cons) { - lower_body_upper = active_constraints[c]; + lower_body_upper = c.attr("to_bounded_expression")(); cname = getSymbol(c, labeler); repn = generate_standard_repn( lower_body_upper[1], "compute_values"_a = false, "quadratic"_a = true); diff --git a/pyomo/contrib/appsi/cmodel/src/lp_writer.hpp b/pyomo/contrib/appsi/cmodel/src/lp_writer.hpp index 0b2e2882510..6df2cce8453 100644 --- a/pyomo/contrib/appsi/cmodel/src/lp_writer.hpp +++ b/pyomo/contrib/appsi/cmodel/src/lp_writer.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/model_base.cpp b/pyomo/contrib/appsi/cmodel/src/model_base.cpp index b0ae4013b32..870f68d7e63 100644 --- a/pyomo/contrib/appsi/cmodel/src/model_base.cpp +++ b/pyomo/contrib/appsi/cmodel/src/model_base.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/model_base.hpp b/pyomo/contrib/appsi/cmodel/src/model_base.hpp index a47f1d14a0b..80eb71a40c3 100644 --- a/pyomo/contrib/appsi/cmodel/src/model_base.hpp +++ b/pyomo/contrib/appsi/cmodel/src/model_base.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/src/nl_writer.cpp b/pyomo/contrib/appsi/cmodel/src/nl_writer.cpp index 8de6cc74ab4..f7d3d2698ff 100644 --- a/pyomo/contrib/appsi/cmodel/src/nl_writer.cpp +++ b/pyomo/contrib/appsi/cmodel/src/nl_writer.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -527,7 +527,7 @@ void process_nl_constraints(NLWriter *nl_writer, PyomoExprTypes &expr_types, py::handle repn_nonlinear_expr; for (py::handle c : cons) { - lower_body_upper = active_constraints[c]; + lower_body_upper = c.attr("to_bounded_expression")(); repn = generate_standard_repn( lower_body_upper[1], "compute_values"_a = false, "quadratic"_a = false); _const = appsi_expr_from_pyomo_expr(repn.attr("constant"), var_map, diff --git a/pyomo/contrib/appsi/cmodel/src/nl_writer.hpp b/pyomo/contrib/appsi/cmodel/src/nl_writer.hpp index b7439875301..deeef5b121d 100644 --- a/pyomo/contrib/appsi/cmodel/src/nl_writer.hpp +++ b/pyomo/contrib/appsi/cmodel/src/nl_writer.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/tests/__init__.py b/pyomo/contrib/appsi/cmodel/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/appsi/cmodel/tests/__init__.py +++ b/pyomo/contrib/appsi/cmodel/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/cmodel/tests/test_import.py b/pyomo/contrib/appsi/cmodel/tests/test_import.py index 76eda902ac0..97f79d17594 100644 --- a/pyomo/contrib/appsi/cmodel/tests/test_import.py +++ b/pyomo/contrib/appsi/cmodel/tests/test_import.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/examples/__init__.py b/pyomo/contrib/appsi/examples/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/appsi/examples/__init__.py +++ b/pyomo/contrib/appsi/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/examples/getting_started.py b/pyomo/contrib/appsi/examples/getting_started.py index 6bc42d1d377..85f24ab8799 100644 --- a/pyomo/contrib/appsi/examples/getting_started.py +++ b/pyomo/contrib/appsi/examples/getting_started.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.contrib import appsi from pyomo.common.timing import HierarchicalTimer @@ -18,14 +18,14 @@ def main(plot=True, n_points=200): import numpy as np # create a Pyomo model - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.p = pe.Param(initialize=1, mutable=True) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.p = pyo.Param(initialize=1, mutable=True) - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y >= (m.x + 1) ** 2) - m.c2 = pe.Constraint(expr=m.y >= (m.x - m.p) ** 2) + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y >= (m.x + 1) ** 2) + m.c2 = pyo.Constraint(expr=m.y >= (m.x - m.p) ** 2) opt = appsi.solvers.Cplex() # create an APPSI solver interface opt.config.load_solution = False # modify the config options diff --git a/pyomo/contrib/appsi/examples/tests/__init__.py b/pyomo/contrib/appsi/examples/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/appsi/examples/tests/__init__.py +++ b/pyomo/contrib/appsi/examples/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/examples/tests/test_examples.py b/pyomo/contrib/appsi/examples/tests/test_examples.py index a7608d36b98..c4edd18877b 100644 --- a/pyomo/contrib/appsi/examples/tests/test_examples.py +++ b/pyomo/contrib/appsi/examples/tests/test_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,6 @@ from pyomo.contrib.appsi.examples import getting_started import pyomo.common.unittest as unittest -import pyomo.environ as pe from pyomo.contrib.appsi.cmodel import cmodel_available from pyomo.contrib import appsi diff --git a/pyomo/contrib/appsi/fbbt.py b/pyomo/contrib/appsi/fbbt.py index 8b6cc52d2aa..0a17fcd5c52 100644 --- a/pyomo/contrib/appsi/fbbt.py +++ b/pyomo/contrib/appsi/fbbt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,18 +18,20 @@ ) from .cmodel import cmodel, cmodel_available from typing import List, Optional -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.param import _ParamData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.sos import _SOSConstraintData -from pyomo.core.base.objective import _GeneralObjectiveData, minimize, maximize -from pyomo.core.base.block import _BlockData +from pyomo.core.base.var import VarData +from pyomo.core.base.param import ParamData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.sos import SOSConstraintData +from pyomo.core.base.objective import ObjectiveData, minimize, maximize +from pyomo.core.base.block import BlockData from pyomo.core.base import SymbolMap, TextLabeler from pyomo.common.errors import InfeasibleConstraintException class IntervalConfig(ConfigDict): """ + Configuration options for the FBBT IntervalTightener + Attributes ---------- feasibility_tol: float @@ -121,7 +123,7 @@ def set_instance(self, model, symbolic_solver_labels: Optional[bool] = None): if self._objective is None: self.set_objective(None) - def _add_variables(self, variables: List[_GeneralVarData]): + def _add_variables(self, variables: List[VarData]): if self._symbolic_solver_labels: set_name = True symbol_map = self._symbol_map @@ -143,7 +145,7 @@ def _add_variables(self, variables: List[_GeneralVarData]): False, ) - def _add_params(self, params: List[_ParamData]): + def _add_params(self, params: List[ParamData]): cparams = cmodel.create_params(len(params)) for ndx, p in enumerate(params): cp = cparams[ndx] @@ -154,7 +156,7 @@ def _add_params(self, params: List[_ParamData]): cp = cparams[ndx] cp.name = self._symbol_map.getSymbol(p, self._param_labeler) - def _add_constraints(self, cons: List[_GeneralConstraintData]): + def _add_constraints(self, cons: List[ConstraintData]): cmodel.process_fbbt_constraints( self._cmodel, self._pyomo_expr_types, @@ -169,13 +171,13 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): for c, cc in self._con_map.items(): cc.name = self._symbol_map.getSymbol(c, self._con_labeler) - def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + def _add_sos_constraints(self, cons: List[SOSConstraintData]): if len(cons) != 0: raise NotImplementedError( 'IntervalTightener does not support SOS constraints' ) - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): if self._symbolic_solver_labels: for c in cons: self._symbol_map.removeSymbol(c) @@ -184,13 +186,13 @@ def _remove_constraints(self, cons: List[_GeneralConstraintData]): self._cmodel.remove_constraint(cc) del self._rcon_map[cc] - def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): if len(cons) != 0: raise NotImplementedError( 'IntervalTightener does not support SOS constraints' ) - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): if self._symbolic_solver_labels: for v in variables: self._symbol_map.removeSymbol(v) @@ -198,14 +200,14 @@ def _remove_variables(self, variables: List[_GeneralVarData]): cvar = self._var_map.pop(id(v)) del self._rvar_map[cvar] - def _remove_params(self, params: List[_ParamData]): + def _remove_params(self, params: List[ParamData]): if self._symbolic_solver_labels: for p in params: self._symbol_map.removeSymbol(p) for p in params: del self._param_map[id(p)] - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): cmodel.process_pyomo_vars( self._pyomo_expr_types, variables, @@ -224,13 +226,13 @@ def update_params(self): cp = self._param_map[p_id] cp.value = p.value - def set_objective(self, obj: _GeneralObjectiveData): + def set_objective(self, obj: ObjectiveData): if self._symbolic_solver_labels: if self._objective is not None: self._symbol_map.removeSymbol(self._objective) super().set_objective(obj) - def _set_objective(self, obj: _GeneralObjectiveData): + def _set_objective(self, obj: ObjectiveData): if obj is None: ce = cmodel.Constant(0) sense = 0 @@ -275,7 +277,7 @@ def _deactivate_satisfied_cons(self): c.deactivate() def perform_fbbt( - self, model: _BlockData, symbolic_solver_labels: Optional[bool] = None + self, model: BlockData, symbolic_solver_labels: Optional[bool] = None ): if model is not self._model: self.set_instance(model, symbolic_solver_labels=symbolic_solver_labels) @@ -304,7 +306,7 @@ def perform_fbbt( self._deactivate_satisfied_cons() return n_iter - def perform_fbbt_with_seed(self, model: _BlockData, seed_var: _GeneralVarData): + def perform_fbbt_with_seed(self, model: BlockData, seed_var: VarData): if model is not self._model: self.set_instance(model) else: diff --git a/pyomo/contrib/appsi/plugins.py b/pyomo/contrib/appsi/plugins.py index fbe81484eba..16f6945db6d 100644 --- a/pyomo/contrib/appsi/plugins.py +++ b/pyomo/contrib/appsi/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ from pyomo.common.extensions import ExtensionBuilderFactory from .base import SolverFactory -from .solvers import Gurobi, Ipopt, Cbc, Cplex, Highs +from .solvers import Gurobi, Ipopt, Cbc, Cplex, Highs, MAiNGO from .build import AppsiBuilder @@ -30,3 +30,6 @@ def load(): SolverFactory.register(name='highs', doc='Automated persistent interface to Highs')( Highs ) + SolverFactory.register( + name='maingo', doc='Automated persistent interface to MAiNGO' + )(MAiNGO) diff --git a/pyomo/contrib/appsi/solvers/__init__.py b/pyomo/contrib/appsi/solvers/__init__.py index c03523a69d4..fc4bb12e33e 100644 --- a/pyomo/contrib/appsi/solvers/__init__.py +++ b/pyomo/contrib/appsi/solvers/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,3 +15,4 @@ from .cplex import Cplex from .highs import Highs from .wntr import Wntr, WntrResults +from .maingo import MAiNGO diff --git a/pyomo/contrib/appsi/solvers/cbc.py b/pyomo/contrib/appsi/solvers/cbc.py index 7f04ffbfce7..9b8fa3e0e57 100644 --- a/pyomo/contrib/appsi/solvers/cbc.py +++ b/pyomo/contrib/appsi/solvers/cbc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -26,11 +26,11 @@ import math from pyomo.common.collections import ComponentMap from typing import Optional, Sequence, NoReturn, List, Mapping -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.block import _BlockData -from pyomo.core.base.param import _ParamData -from pyomo.core.base.objective import _GeneralObjectiveData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.block import BlockData +from pyomo.core.base.param import ParamData +from pyomo.core.base.objective import ObjectiveData from pyomo.common.timing import HierarchicalTimer from pyomo.common.tee import TeeStream import sys @@ -164,34 +164,34 @@ def symbol_map(self): def set_instance(self, model): self._writer.set_instance(model) - def add_variables(self, variables: List[_GeneralVarData]): + def add_variables(self, variables: List[VarData]): self._writer.add_variables(variables) - def add_params(self, params: List[_ParamData]): + def add_params(self, params: List[ParamData]): self._writer.add_params(params) - def add_constraints(self, cons: List[_GeneralConstraintData]): + def add_constraints(self, cons: List[ConstraintData]): self._writer.add_constraints(cons) - def add_block(self, block: _BlockData): + def add_block(self, block: BlockData): self._writer.add_block(block) - def remove_variables(self, variables: List[_GeneralVarData]): + def remove_variables(self, variables: List[VarData]): self._writer.remove_variables(variables) - def remove_params(self, params: List[_ParamData]): + def remove_params(self, params: List[ParamData]): self._writer.remove_params(params) - def remove_constraints(self, cons: List[_GeneralConstraintData]): + def remove_constraints(self, cons: List[ConstraintData]): self._writer.remove_constraints(cons) - def remove_block(self, block: _BlockData): + def remove_block(self, block: BlockData): self._writer.remove_block(block) - def set_objective(self, obj: _GeneralObjectiveData): + def set_objective(self, obj: ObjectiveData): self._writer.set_objective(obj) - def update_variables(self, variables: List[_GeneralVarData]): + def update_variables(self, variables: List[VarData]): self._writer.update_variables(variables) def update_params(self): @@ -440,8 +440,8 @@ def _check_and_escape_options(): return results def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if ( self._last_results_object is None or self._last_results_object.best_feasible_objective is None @@ -477,8 +477,8 @@ def get_duals(self, cons_to_load=None): return {c: self._dual_sol[c] for c in cons_to_load} def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if ( self._last_results_object is None or self._last_results_object.termination_condition diff --git a/pyomo/contrib/appsi/solvers/cplex.py b/pyomo/contrib/appsi/solvers/cplex.py index 1b7ab5000d2..9061f063ab7 100644 --- a/pyomo/contrib/appsi/solvers/cplex.py +++ b/pyomo/contrib/appsi/solvers/cplex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,11 +22,11 @@ import math from pyomo.common.collections import ComponentMap from typing import Optional, Sequence, NoReturn, List, Mapping, Dict -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.block import _BlockData -from pyomo.core.base.param import _ParamData -from pyomo.core.base.objective import _GeneralObjectiveData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.block import BlockData +from pyomo.core.base.param import ParamData +from pyomo.core.base.objective import ObjectiveData from pyomo.common.timing import HierarchicalTimer import sys import time @@ -179,34 +179,34 @@ def update_config(self): def set_instance(self, model): self._writer.set_instance(model) - def add_variables(self, variables: List[_GeneralVarData]): + def add_variables(self, variables: List[VarData]): self._writer.add_variables(variables) - def add_params(self, params: List[_ParamData]): + def add_params(self, params: List[ParamData]): self._writer.add_params(params) - def add_constraints(self, cons: List[_GeneralConstraintData]): + def add_constraints(self, cons: List[ConstraintData]): self._writer.add_constraints(cons) - def add_block(self, block: _BlockData): + def add_block(self, block: BlockData): self._writer.add_block(block) - def remove_variables(self, variables: List[_GeneralVarData]): + def remove_variables(self, variables: List[VarData]): self._writer.remove_variables(variables) - def remove_params(self, params: List[_ParamData]): + def remove_params(self, params: List[ParamData]): self._writer.remove_params(params) - def remove_constraints(self, cons: List[_GeneralConstraintData]): + def remove_constraints(self, cons: List[ConstraintData]): self._writer.remove_constraints(cons) - def remove_block(self, block: _BlockData): + def remove_block(self, block: BlockData): self._writer.remove_block(block) - def set_objective(self, obj: _GeneralObjectiveData): + def set_objective(self, obj: ObjectiveData): self._writer.set_objective(obj) - def update_variables(self, variables: List[_GeneralVarData]): + def update_variables(self, variables: List[VarData]): self._writer.update_variables(variables) def update_params(self): @@ -362,8 +362,8 @@ def _postsolve(self, timer: HierarchicalTimer, solve_time): return results def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if ( self._cplex_model.solution.get_solution_type() == self._cplex_model.solution.type.none @@ -389,8 +389,8 @@ def get_primals( return res def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: if ( self._cplex_model.solution.get_solution_type() == self._cplex_model.solution.type.none @@ -440,8 +440,8 @@ def get_duals( return res def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if ( self._cplex_model.solution.get_solution_type() == self._cplex_model.solution.type.none diff --git a/pyomo/contrib/appsi/solvers/gurobi.py b/pyomo/contrib/appsi/solvers/gurobi.py index 1e18862e3bd..6bf1d39fd5f 100644 --- a/pyomo/contrib/appsi/solvers/gurobi.py +++ b/pyomo/contrib/appsi/solvers/gurobi.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,10 +23,10 @@ from pyomo.common.config import ConfigValue, NonNegativeInt from pyomo.core.kernel.objective import minimize, maximize from pyomo.core.base import SymbolMap, NumericLabeler, TextLabeler -from pyomo.core.base.var import Var, _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.sos import _SOSConstraintData -from pyomo.core.base.param import _ParamData +from pyomo.core.base.var import Var, VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.sos import SOSConstraintData +from pyomo.core.base.param import ParamData from pyomo.core.expr.numvalue import value, is_constant, is_fixed, native_numeric_types from pyomo.repn import generate_standard_repn from pyomo.core.expr.numeric_expr import NPV_MaxExpression, NPV_MinExpression @@ -367,25 +367,24 @@ def _solve(self, timer: HierarchicalTimer): if self.config.stream_solver: ostreams.append(sys.stdout) - with TeeStream(*ostreams) as t: - with capture_output(output=t.STDOUT, capture_fd=False): - config = self.config - options = self.gurobi_options + with capture_output(output=TeeStream(*ostreams), capture_fd=False): + config = self.config + options = self.gurobi_options - self._solver_model.setParam('LogToConsole', 1) - self._solver_model.setParam('LogFile', config.logfile) + self._solver_model.setParam('LogToConsole', 1) + self._solver_model.setParam('LogFile', config.logfile) - if config.time_limit is not None: - self._solver_model.setParam('TimeLimit', config.time_limit) - if config.mip_gap is not None: - self._solver_model.setParam('MIPGap', config.mip_gap) + if config.time_limit is not None: + self._solver_model.setParam('TimeLimit', config.time_limit) + if config.mip_gap is not None: + self._solver_model.setParam('MIPGap', config.mip_gap) - for key, option in options.items(): - self._solver_model.setParam(key, option) + for key, option in options.items(): + self._solver_model.setParam(key, option) - timer.start('optimize') - self._solver_model.optimize(self._callback) - timer.stop('optimize') + timer.start('optimize') + self._solver_model.optimize(self._callback) + timer.stop('optimize') self._needs_updated = False return self._postsolve(timer) @@ -458,7 +457,7 @@ def _process_domain_and_bounds( return lb, ub, vtype - def _add_variables(self, variables: List[_GeneralVarData]): + def _add_variables(self, variables: List[VarData]): var_names = list() vtypes = list() lbs = list() @@ -489,7 +488,7 @@ def _add_variables(self, variables: List[_GeneralVarData]): self._vars_added_since_update.update(variables) self._needs_updated = True - def _add_params(self, params: List[_ParamData]): + def _add_params(self, params: List[ParamData]): pass def _reinit(self): @@ -579,7 +578,7 @@ def _get_expr_from_pyomo_expr(self, expr): mutable_quadratic_coefficients, ) - def _add_constraints(self, cons: List[_GeneralConstraintData]): + def _add_constraints(self, cons: List[ConstraintData]): for con in cons: conname = self._symbol_map.getSymbol(con, self._labeler) ( @@ -709,7 +708,7 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): self._constraints_added_since_update.update(cons) self._needs_updated = True - def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + def _add_sos_constraints(self, cons: List[SOSConstraintData]): for con in cons: conname = self._symbol_map.getSymbol(con, self._labeler) level = con.level @@ -735,7 +734,7 @@ def _add_sos_constraints(self, cons: List[_SOSConstraintData]): self._constraints_added_since_update.update(cons) self._needs_updated = True - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): for con in cons: if con in self._constraints_added_since_update: self._update_gurobi_model() @@ -749,7 +748,7 @@ def _remove_constraints(self, cons: List[_GeneralConstraintData]): self._mutable_quadratic_helpers.pop(con, None) self._needs_updated = True - def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): for con in cons: if con in self._constraints_added_since_update: self._update_gurobi_model() @@ -759,7 +758,7 @@ def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): del self._pyomo_sos_to_solver_sos_map[con] self._needs_updated = True - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): for var in variables: v_id = id(var) if var in self._vars_added_since_update: @@ -771,10 +770,10 @@ def _remove_variables(self, variables: List[_GeneralVarData]): self._mutable_bounds.pop(v_id, None) self._needs_updated = True - def _remove_params(self, params: List[_ParamData]): + def _remove_params(self, params: List[ParamData]): pass - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): for var in variables: var_id = id(var) if var_id not in self._pyomo_var_to_solver_var_map: @@ -1195,7 +1194,7 @@ def set_linear_constraint_attr(self, con, attr, val): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be modified. attr: str @@ -1221,7 +1220,7 @@ def set_var_attr(self, var, attr, val): Parameters ---------- - var: pyomo.core.base.var._GeneralVarData + var: pyomo.core.base.var.VarData The pyomo var for which the corresponding gurobi var attribute should be modified. attr: str @@ -1256,7 +1255,7 @@ def get_var_attr(self, var, attr): Parameters ---------- - var: pyomo.core.base.var._GeneralVarData + var: pyomo.core.base.var.VarData The pyomo var for which the corresponding gurobi var attribute should be retrieved. attr: str @@ -1272,7 +1271,7 @@ def get_linear_constraint_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be retrieved. attr: str @@ -1288,7 +1287,7 @@ def get_sos_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.sos._SOSConstraintData + con: pyomo.core.base.sos.SOSConstraintData The pyomo SOS constraint for which the corresponding gurobi SOS constraint attribute should be retrieved. attr: str @@ -1304,7 +1303,7 @@ def get_quadratic_constraint_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be retrieved. attr: str @@ -1378,15 +1377,15 @@ def set_callback(self, func=None): as an MILP using extended cutting planes in callbacks. >>> from gurobipy import GRB # doctest:+SKIP - >>> import pyomo.environ as pe + >>> import pyomo.environ as pyo >>> from pyomo.core.expr.taylor_series import taylor_series_expansion >>> from pyomo.contrib import appsi >>> - >>> m = pe.ConcreteModel() - >>> m.x = pe.Var(bounds=(0, 4)) - >>> m.y = pe.Var(within=pe.Integers, bounds=(0, None)) - >>> m.obj = pe.Objective(expr=2*m.x + m.y) - >>> m.cons = pe.ConstraintList() # for the cutting planes + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var(bounds=(0, 4)) + >>> m.y = pyo.Var(within=pyo.Integers, bounds=(0, None)) + >>> m.obj = pyo.Objective(expr=2*m.x + m.y) + >>> m.cons = pyo.ConstraintList() # for the cutting planes >>> >>> def _add_cut(xval): ... # a function to generate the cut @@ -1425,7 +1424,7 @@ def cbCut(self, con): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The cut to add """ if not con.active: @@ -1510,7 +1509,7 @@ def cbLazy(self, con): """ Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The lazy constraint to add """ if not con.active: diff --git a/pyomo/contrib/appsi/solvers/highs.py b/pyomo/contrib/appsi/solvers/highs.py index 3612b9d5014..edaafa5d1d4 100644 --- a/pyomo/contrib/appsi/solvers/highs.py +++ b/pyomo/contrib/appsi/solvers/highs.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,10 +20,10 @@ from pyomo.common.log import LogStream from pyomo.core.kernel.objective import minimize, maximize from pyomo.core.base import SymbolMap -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.sos import _SOSConstraintData -from pyomo.core.base.param import _ParamData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.sos import SOSConstraintData +from pyomo.core.base.param import ParamData from pyomo.core.expr.numvalue import value, is_constant from pyomo.repn import generate_standard_repn from pyomo.core.expr.numeric_expr import NPV_MaxExpression, NPV_MinExpression @@ -176,11 +176,19 @@ def available(self): return self.Availability.NotFound def version(self): - version = ( - highspy.HIGHS_VERSION_MAJOR, - highspy.HIGHS_VERSION_MINOR, - highspy.HIGHS_VERSION_PATCH, - ) + try: + version = ( + highspy.HIGHS_VERSION_MAJOR, + highspy.HIGHS_VERSION_MINOR, + highspy.HIGHS_VERSION_PATCH, + ) + except AttributeError: + # Older versions of Highs do not have the above attributes + # and the solver version can only be obtained by making + # an instance of the solver class. + tmp = highspy.Highs() + version = (tmp.versionMajor(), tmp.versionMinor(), tmp.versionPatch()) + return version @property @@ -213,6 +221,29 @@ def symbol_map(self): return SymbolMap() # raise RuntimeError('Highs interface does not have a symbol map') + def warm_start_capable(self): + return True + + def _warm_start(self): + # Collect all variable values + col_value = np.zeros(len(self._pyomo_var_to_solver_var_map)) + has_values = False + + for var_id, col_ndx in self._pyomo_var_to_solver_var_map.items(): + var = self._vars[var_id][0] + if var.value is not None: + col_value[col_ndx] = value(var) + has_values = True + + if has_values: + solution = highspy.HighsSolution() + solution.col_value = col_value + solution.value_valid = True + solution.dual_valid = False + + # Set the solution as a MIP start + self._solver_model.setSolution(solution) + def _solve(self, timer: HierarchicalTimer): config = self.config options = self.highs_options @@ -225,22 +256,27 @@ def _solve(self, timer: HierarchicalTimer): if self.config.stream_solver: ostreams.append(sys.stdout) - with TeeStream(*ostreams) as t: - with capture_output(output=t.STDOUT, capture_fd=True): - self._solver_model.setOptionValue('log_to_console', True) - if config.logfile != '': - self._solver_model.setOptionValue('log_file', config.logfile) + with capture_output(output=TeeStream(*ostreams), capture_fd=True): + self._solver_model.setOptionValue('log_to_console', True) + if config.logfile != '': + self._solver_model.setOptionValue('log_file', config.logfile) - if config.time_limit is not None: - self._solver_model.setOptionValue('time_limit', config.time_limit) - if config.mip_gap is not None: - self._solver_model.setOptionValue('mip_rel_gap', config.mip_gap) + if config.time_limit is not None: + self._solver_model.setOptionValue('time_limit', config.time_limit) + if config.mip_gap is not None: + self._solver_model.setOptionValue('mip_rel_gap', config.mip_gap) - for key, option in options.items(): - self._solver_model.setOptionValue(key, option) - timer.start('optimize') - self._solver_model.run() - timer.stop('optimize') + for key, option in options.items(): + self._solver_model.setOptionValue(key, option) + + if config.warmstart: + self._warm_start() + timer.start('optimize') + ostreams[-1].write("RUN!\n") + if self.version()[:2] >= (1, 8): + self._solver_model.HandleKeyboardInterrupt = True + self._solver_model.run() + timer.stop('optimize') return self._postsolve(timer) @@ -308,7 +344,7 @@ def _process_domain_and_bounds(self, var_id): return lb, ub, vtype - def _add_variables(self, variables: List[_GeneralVarData]): + def _add_variables(self, variables: List[VarData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -335,7 +371,7 @@ def _add_variables(self, variables: List[_GeneralVarData]): len(vtypes), np.array(indices), np.array(vtypes) ) - def _add_params(self, params: List[_ParamData]): + def _add_params(self, params: List[ParamData]): pass def _reinit(self): @@ -364,19 +400,18 @@ def set_instance(self, model): ] if self.config.stream_solver: ostreams.append(sys.stdout) - with TeeStream(*ostreams) as t: - with capture_output(output=t.STDOUT, capture_fd=True): - self._reinit() - self._model = model - if self.use_extensions and cmodel_available: - self._expr_types = cmodel.PyomoExprTypes() - - self._solver_model = highspy.Highs() - self.add_block(model) - if self._objective is None: - self.set_objective(None) - - def _add_constraints(self, cons: List[_GeneralConstraintData]): + with capture_output(output=TeeStream(*ostreams), capture_fd=True): + self._reinit() + self._model = model + if self.use_extensions and cmodel_available: + self._expr_types = cmodel.PyomoExprTypes() + + self._solver_model = highspy.Highs() + self.add_block(model) + if self._objective is None: + self.set_objective(None) + + def _add_constraints(self, cons: List[ConstraintData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -456,13 +491,13 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): np.array(coef_values, dtype=np.double), ) - def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + def _add_sos_constraints(self, cons: List[SOSConstraintData]): if cons: raise NotImplementedError( 'Highs interface does not support SOS constraints' ) - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -473,7 +508,7 @@ def _remove_constraints(self, cons: List[_GeneralConstraintData]): indices_to_remove.append(con_ndx) self._mutable_helpers.pop(con, None) self._solver_model.deleteRows( - len(indices_to_remove), np.array(indices_to_remove) + len(indices_to_remove), np.sort(np.array(indices_to_remove)) ) con_ndx = 0 new_con_map = dict() @@ -487,13 +522,13 @@ def _remove_constraints(self, cons: List[_GeneralConstraintData]): {v: k for k, v in self._pyomo_con_to_solver_con_map.items()} ) - def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): if cons: raise NotImplementedError( 'Highs interface does not support SOS constraints' ) - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -515,10 +550,10 @@ def _remove_variables(self, variables: List[_GeneralVarData]): self._pyomo_var_to_solver_var_map.clear() self._pyomo_var_to_solver_var_map.update(new_var_map) - def _remove_params(self, params: List[_ParamData]): + def _remove_params(self, params: List[ParamData]): pass - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() diff --git a/pyomo/contrib/appsi/solvers/ipopt.py b/pyomo/contrib/appsi/solvers/ipopt.py index 29e74f81c98..601b830b7f2 100644 --- a/pyomo/contrib/appsi/solvers/ipopt.py +++ b/pyomo/contrib/appsi/solvers/ipopt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -28,11 +28,11 @@ from pyomo.core.expr.numvalue import value from pyomo.core.expr.visitor import replace_expressions from typing import Optional, Sequence, NoReturn, List, Mapping -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.block import _BlockData -from pyomo.core.base.param import _ParamData -from pyomo.core.base.objective import _GeneralObjectiveData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.block import BlockData +from pyomo.core.base.param import ParamData +from pyomo.core.base.objective import ObjectiveData from pyomo.common.timing import HierarchicalTimer from pyomo.common.tee import TeeStream import sys @@ -147,6 +147,7 @@ def __init__(self, only_child_vars=False): self._primal_sol = ComponentMap() self._reduced_costs = ComponentMap() self._last_results_object: Optional[Results] = None + self._version_timeout = 2 def available(self): if self.config.executable.path() is None: @@ -158,7 +159,7 @@ def available(self): def version(self): results = subprocess.run( [str(self.config.executable), '--version'], - timeout=1, + timeout=self._version_timeout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, @@ -227,34 +228,34 @@ def set_instance(self, model): self._writer.config.symbolic_solver_labels = self.config.symbolic_solver_labels self._writer.set_instance(model) - def add_variables(self, variables: List[_GeneralVarData]): + def add_variables(self, variables: List[VarData]): self._writer.add_variables(variables) - def add_params(self, params: List[_ParamData]): + def add_params(self, params: List[ParamData]): self._writer.add_params(params) - def add_constraints(self, cons: List[_GeneralConstraintData]): + def add_constraints(self, cons: List[ConstraintData]): self._writer.add_constraints(cons) - def add_block(self, block: _BlockData): + def add_block(self, block: BlockData): self._writer.add_block(block) - def remove_variables(self, variables: List[_GeneralVarData]): + def remove_variables(self, variables: List[VarData]): self._writer.remove_variables(variables) - def remove_params(self, params: List[_ParamData]): + def remove_params(self, params: List[ParamData]): self._writer.remove_params(params) - def remove_constraints(self, cons: List[_GeneralConstraintData]): + def remove_constraints(self, cons: List[ConstraintData]): self._writer.remove_constraints(cons) - def remove_block(self, block: _BlockData): + def remove_block(self, block: BlockData): self._writer.remove_block(block) - def set_objective(self, obj: _GeneralObjectiveData): + def set_objective(self, obj: ObjectiveData): self._writer.set_objective(obj) - def update_variables(self, variables: List[_GeneralVarData]): + def update_variables(self, variables: List[VarData]): self._writer.update_variables(variables) def update_params(self): @@ -513,8 +514,8 @@ def _apply_solver(self, timer: HierarchicalTimer): return results def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if ( self._last_results_object is None or self._last_results_object.best_feasible_objective is None @@ -533,9 +534,7 @@ def get_primals( res[v] = self._primal_sol[v] return res - def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ): + def get_duals(self, cons_to_load: Optional[Sequence[ConstraintData]] = None): if ( self._last_results_object is None or self._last_results_object.termination_condition @@ -552,8 +551,8 @@ def get_duals( return {c: self._dual_sol[c] for c in cons_to_load} def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if ( self._last_results_object is None or self._last_results_object.termination_condition @@ -568,3 +567,24 @@ def get_reduced_costs( return ComponentMap((k, v) for k, v in self._reduced_costs.items()) else: return ComponentMap((v, self._reduced_costs[v]) for v in vars_to_load) + + def has_linear_solver(self, linear_solver): + import pyomo.core as AML + from pyomo.common.tee import capture_output + + m = AML.ConcreteModel() + m.x = AML.Var() + m.o = AML.Objective(expr=(m.x - 2) ** 2) + with capture_output() as OUT: + solver = self.__class__() + solver.config.stream_solver = True + solver.config.load_solution = False + solver.ipopt_options['linear_solver'] = linear_solver + try: + solver.solve(m) + except FileNotFoundError: + # The APPSI interface always tries to open the SOL file, + # and will generate a FileNotFoundError if ipopt didn't + # generate one + return False + return 'running with linear solver' in OUT.getvalue() diff --git a/pyomo/contrib/appsi/solvers/maingo.py b/pyomo/contrib/appsi/solvers/maingo.py new file mode 100644 index 00000000000..8273061be78 --- /dev/null +++ b/pyomo/contrib/appsi/solvers/maingo.py @@ -0,0 +1,549 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from collections import namedtuple +import logging +import math +import sys +from typing import Optional, List, Dict + +from pyomo.contrib.appsi.base import ( + PersistentSolver, + Results, + TerminationCondition, + MIPSolverConfig, + PersistentBase, + PersistentSolutionLoader, +) +from pyomo.contrib.appsi.cmodel import cmodel, cmodel_available +from pyomo.common.collections import ComponentMap +from pyomo.common.config import ( + ConfigValue, + ConfigDict, + NonNegativeInt, + NonNegativeFloat, +) +from pyomo.common.dependencies import attempt_import +from pyomo.common.errors import PyomoException +from pyomo.common.log import LogStream +from pyomo.common.tee import capture_output, TeeStream +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.base import SymbolMap, NumericLabeler, TextLabeler +from pyomo.core.base.constraint import _GeneralConstraintData +from pyomo.core.base.expression import ScalarExpression +from pyomo.core.base.param import _ParamData +from pyomo.core.base.sos import _SOSConstraintData +from pyomo.core.base.var import Var, ScalarVar, _GeneralVarData +import pyomo.core.expr.expr_common as common +import pyomo.core.expr as EXPR +from pyomo.core.expr.numvalue import ( + value, + is_constant, + is_fixed, + native_numeric_types, + native_types, + nonpyomo_leaf_types, +) +from pyomo.core.kernel.objective import minimize, maximize +from pyomo.core.staleflag import StaleFlagManager +from pyomo.repn.util import valid_expr_ctypes_minlp + + +logger = logging.getLogger(__name__) +MaingoVar = namedtuple("MaingoVar", "type name lb ub init") +maingopy, maingopy_available = attempt_import("maingopy") +# Note that importing maingo_solvermodel will trigger the import of +# maingopy, so we defer that import using attempt_import (which will +# always succeed, even if maingopy is not available) +maingo_solvermodel = attempt_import("pyomo.contrib.appsi.solvers.maingo_solvermodel")[0] + + +class MAiNGOConfig(MIPSolverConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super(MAiNGOConfig, self).__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.tolerances: ConfigDict = self.declare( + 'tolerances', ConfigDict(implicit=True) + ) + + self.tolerances.epsilonA: Optional[float] = self.tolerances.declare( + 'epsilonA', + ConfigValue( + domain=NonNegativeFloat, + default=1e-5, + description="Absolute optimality tolerance", + ), + ) + self.tolerances.epsilonR: Optional[float] = self.tolerances.declare( + 'epsilonR', + ConfigValue( + domain=NonNegativeFloat, + default=1e-5, + description="Relative optimality tolerance", + ), + ) + self.tolerances.deltaEq: Optional[float] = self.tolerances.declare( + 'deltaEq', + ConfigValue( + domain=NonNegativeFloat, default=1e-6, description="Equality tolerance" + ), + ) + + self.tolerances.deltaIneq: Optional[float] = self.tolerances.declare( + 'deltaIneq', + ConfigValue( + domain=NonNegativeFloat, + default=1e-6, + description="Inequality tolerance", + ), + ) + self.declare("logfile", ConfigValue(domain=str, default="")) + self.declare("solver_output_logger", ConfigValue(default=logger)) + self.declare( + "log_level", ConfigValue(domain=NonNegativeInt, default=logging.INFO) + ) + + +class MAiNGOSolutionLoader(PersistentSolutionLoader): + def load_vars(self, vars_to_load=None): + self._assert_solution_still_valid() + self._solver.load_vars(vars_to_load=vars_to_load) + + def get_primals(self, vars_to_load=None): + self._assert_solution_still_valid() + return self._solver.get_primals(vars_to_load=vars_to_load) + + +class MAiNGOResults(Results): + def __init__(self, solver): + super(MAiNGOResults, self).__init__() + self.wallclock_time = None + self.cpu_time = None + self.globally_optimal = None + self.solution_loader = MAiNGOSolutionLoader(solver=solver) + + +class MAiNGO(PersistentBase, PersistentSolver): + """ + Interface to MAiNGO + """ + + _available = None + + def __init__(self, only_child_vars=False): + super(MAiNGO, self).__init__(only_child_vars=only_child_vars) + self._config = MAiNGOConfig() + self._solver_options = dict() + self._solver_model = None + self._mymaingo = None + self._symbol_map = SymbolMap() + self._labeler = None + self._maingo_vars = [] + self._objective = None + self._cons = [] + self._pyomo_var_to_solver_var_id_map = dict() + self._last_results_object: Optional[MAiNGOResults] = None + + def available(self): + if self._available is None: + if maingopy_available: + MAiNGO._available = True + else: + MAiNGO._available = MAiNGO.Availability.NotFound + return self._available + + def version(self): + import pkg_resources + + version = pkg_resources.get_distribution('maingopy').version + + return tuple(int(k) for k in version.split('.')) + + @property + def config(self) -> MAiNGOConfig: + return self._config + + @config.setter + def config(self, val: MAiNGOConfig): + self._config = val + + @property + def maingo_options(self): + """ + A dictionary mapping solver options to values for those options. These + are solver specific. + + Returns + ------- + dict + A dictionary mapping solver options to values for those options + """ + return self._solver_options + + @maingo_options.setter + def maingo_options(self, val: Dict): + self._solver_options = val + + @property + def symbol_map(self): + return self._symbol_map + + def _solve(self, timer: HierarchicalTimer): + ostreams = [ + LogStream( + level=self.config.log_level, logger=self.config.solver_output_logger + ) + ] + if self.config.stream_solver: + ostreams.append(sys.stdout) + + with capture_output(output=TeeStream(*ostreams), capture_fd=False): + config = self.config + options = self.maingo_options + + self._mymaingo = maingopy.MAiNGO(self._solver_model) + + self._mymaingo.set_option("loggingDestination", 2) + self._mymaingo.set_log_file_name(config.logfile) + self._mymaingo.set_option("epsilonA", config.tolerances.epsilonA) + self._mymaingo.set_option("epsilonR", config.tolerances.epsilonR) + self._mymaingo.set_option("deltaEq", config.tolerances.deltaEq) + self._mymaingo.set_option("deltaIneq", config.tolerances.deltaIneq) + + if config.time_limit is not None: + self._mymaingo.set_option("maxTime", config.time_limit) + if config.mip_gap is not None: + self._mymaingo.set_option("epsilonR", config.mip_gap) + for key, option in options.items(): + self._mymaingo.set_option(key, option) + + timer.start("MAiNGO solve") + self._mymaingo.solve() + timer.stop("MAiNGO solve") + + return self._postsolve(timer) + + def solve(self, model, timer: HierarchicalTimer = None): + StaleFlagManager.mark_all_as_stale() + + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + if timer is None: + timer = HierarchicalTimer() + if model is not self._model: + timer.start("set_instance") + self.set_instance(model) + timer.stop("set_instance") + else: + timer.start("Update") + self.update(timer=timer) + timer.stop("Update") + res = self._solve(timer) + self._last_results_object = res + if self.config.report_timing: + logger.info("\n" + str(timer)) + return res + + def _process_domain_and_bounds(self, var): + _v, _lb, _ub, _fixed, _domain_interval, _value = self._vars[id(var)] + lb, ub, step = _domain_interval + + if _fixed: + lb = _value + ub = _value + else: + if lb is None and _lb is None: + logger.warning( + "No lower bound for variable " + + var.getname() + + " set. Using -1e10 instead. Please consider setting a valid lower bound." + ) + if ub is None and _ub is None: + logger.warning( + "No upper bound for variable " + + var.getname() + + " set. Using +1e10 instead. Please consider setting a valid upper bound." + ) + + if _lb is None: + _lb = -1e10 + if _ub is None: + _ub = 1e10 + if lb is None: + lb = -1e10 + if ub is None: + ub = 1e10 + + lb = max(value(_lb), lb) + ub = min(value(_ub), ub) + + if step == 0: + vtype = maingopy.VT_CONTINUOUS + elif step == 1: + if lb == 0 and ub == 1: + vtype = maingopy.VT_BINARY + else: + vtype = maingopy.VT_INTEGER + else: + raise ValueError( + f"Unrecognized domain step: {step} (should be either 0 or 1)" + ) + + return lb, ub, vtype + + def _add_variables(self, variables: List[_GeneralVarData]): + for var in variables: + varname = self._symbol_map.getSymbol(var, self._labeler) + lb, ub, vtype = self._process_domain_and_bounds(var) + self._maingo_vars.append( + MaingoVar(name=varname, type=vtype, lb=lb, ub=ub, init=var.value) + ) + self._pyomo_var_to_solver_var_id_map[id(var)] = len(self._maingo_vars) - 1 + + def _add_params(self, params: List[_ParamData]): + pass + + def _reinit(self): + saved_config = self.config + saved_options = self.maingo_options + saved_update_config = self.update_config + self.__init__(only_child_vars=self._only_child_vars) + self.config = saved_config + self.maingo_options = saved_options + self.update_config = saved_update_config + + def set_instance(self, model): + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + if not self.available(): + c = self.__class__ + raise PyomoException( + f"Solver {c.__module__}.{c.__qualname__} is not available " + f"({self.available()})." + ) + self._reinit() + self._model = model + if self.use_extensions and cmodel_available: + self._expr_types = cmodel.PyomoExprTypes() + + if self.config.symbolic_solver_labels: + self._labeler = TextLabeler() + else: + self._labeler = NumericLabeler("x") + + self.add_block(model) + + self._solver_model = maingo_solvermodel.SolverModel( + var_list=self._maingo_vars, + con_list=self._cons, + objective=self._objective, + idmap=self._pyomo_var_to_solver_var_id_map, + logger=logger, + ) + + def _add_constraints(self, cons: List[_GeneralConstraintData]): + self._cons += cons + + def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + if len(cons) >= 1: + raise NotImplementedError( + "MAiNGO does not currently support SOS constraints." + ) + pass + + def _remove_constraints(self, cons: List[_GeneralConstraintData]): + for con in cons: + self._cons.remove(con) + + def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + if len(cons) >= 1: + raise NotImplementedError( + "MAiNGO does not currently support SOS constraints." + ) + pass + + def _remove_variables(self, variables: List[_GeneralVarData]): + removed_maingo_vars = [] + for var in variables: + varname = self._symbol_map.getSymbol(var, self._labeler) + del self._maingo_vars[self._pyomo_var_to_solver_var_id_map[id(var)]] + removed_maingo_vars += [self._pyomo_var_to_solver_var_id_map[id(var)]] + del self._pyomo_var_to_solver_var_id_map[id(var)] + + # Update _pyomo_var_to_solver_var_id_map to account for removed variables + for pyomo_var, maingo_var_id in self._pyomo_var_to_solver_var_id_map.items(): + num_removed = 0 + for removed_var in removed_maingo_vars: + if removed_var <= maingo_var_id: + num_removed += 1 + self._pyomo_var_to_solver_var_id_map[pyomo_var] = ( + maingo_var_id - num_removed + ) + + def _remove_params(self, params: List[_ParamData]): + pass + + def _update_variables(self, variables: List[_GeneralVarData]): + for var in variables: + if id(var) not in self._pyomo_var_to_solver_var_id_map: + raise ValueError( + 'The Var provided to update_var needs to be added first: {0}'.format( + var + ) + ) + lb, ub, vtype = self._process_domain_and_bounds(var) + self._maingo_vars[self._pyomo_var_to_solver_var_id_map[id(var)]] = ( + MaingoVar(name=var.name, type=vtype, lb=lb, ub=ub, init=var.value) + ) + + def update_params(self): + vars = [var[0] for var in self._vars.values()] + self._update_variables(vars) + + def _set_objective(self, obj): + + if not obj.sense in {minimize, maximize}: + raise ValueError("Objective sense is not recognized: {0}".format(obj.sense)) + self._objective = obj + + def _postsolve(self, timer: HierarchicalTimer): + config = self.config + + mprob = self._mymaingo + status = mprob.get_status() + results = MAiNGOResults(solver=self) + results.wallclock_time = mprob.get_wallclock_solution_time() + results.cpu_time = mprob.get_cpu_solution_time() + + if status in {maingopy.GLOBALLY_OPTIMAL, maingopy.FEASIBLE_POINT}: + results.termination_condition = TerminationCondition.optimal + results.globally_optimal = True + if status == maingopy.FEASIBLE_POINT: + results.globally_optimal = False + logger.warning( + "MAiNGO found a feasible solution but did not prove its global optimality." + ) + elif status == maingopy.INFEASIBLE: + results.termination_condition = TerminationCondition.infeasible + else: + results.termination_condition = TerminationCondition.unknown + + results.best_feasible_objective = None + results.best_objective_bound = None + if self._objective is not None: + try: + if self._objective.sense == maximize: + results.best_feasible_objective = -mprob.get_objective_value() + else: + results.best_feasible_objective = mprob.get_objective_value() + except: + results.best_feasible_objective = None + try: + if self._objective.sense == maximize: + results.best_objective_bound = -mprob.get_final_LBD() + else: + results.best_objective_bound = mprob.get_final_LBD() + except: + if self._objective.sense == maximize: + results.best_objective_bound = math.inf + else: + results.best_objective_bound = -math.inf + + if results.best_feasible_objective is not None and not math.isfinite( + results.best_feasible_objective + ): + results.best_feasible_objective = None + + timer.start("load solution") + if config.load_solution: + if results.termination_condition is TerminationCondition.optimal: + if not results.globally_optimal: + logger.warning( + "Loading a feasible but suboptimal solution. " + "Please set load_solution=False and check " + "results.termination_condition and " + "results.found_feasible_solution() before loading a solution." + ) + self.load_vars() + else: + raise RuntimeError( + "A feasible solution was not found, so no solution can be loaded." + "Please set opt.config.load_solution=False and check " + "results.termination_condition and " + "results.best_feasible_objective before loading a solution." + ) + timer.stop("load solution") + + return results + + def load_vars(self, vars_to_load=None): + for v, val in self.get_primals(vars_to_load=vars_to_load).items(): + v.set_value(val, skip_validation=True) + StaleFlagManager.mark_all_as_stale(delayed=True) + + def get_primals(self, vars_to_load=None): + if not self._mymaingo.get_status() in { + maingopy.GLOBALLY_OPTIMAL, + maingopy.FEASIBLE_POINT, + }: + raise RuntimeError( + "Solver does not currently have a valid solution." + "Please check the termination condition." + ) + + var_id_map = self._pyomo_var_to_solver_var_id_map + ref_vars = self._referenced_variables + if vars_to_load is None: + vars_to_load = var_id_map.keys() + else: + vars_to_load = [id(v) for v in vars_to_load] + + maingo_var_ids_to_load = [ + var_id_map[pyomo_var_id] for pyomo_var_id in vars_to_load + ] + + solution_point = self._mymaingo.get_solution_point() + vals = [solution_point[var_id] for var_id in maingo_var_ids_to_load] + + res = ComponentMap() + for var_id, val in zip(vars_to_load, vals): + using_cons, using_sos, using_obj = ref_vars[var_id] + if using_cons or using_sos or (using_obj is not None): + res[self._vars[var_id][0]] = val + return res + + def get_reduced_costs(self, vars_to_load=None): + raise ValueError("MAiNGO does not support returning Reduced Costs") + + def get_duals(self, cons_to_load=None): + raise ValueError("MAiNGO does not support returning Duals") + + def update(self, timer: HierarchicalTimer = None): + super(MAiNGO, self).update(timer=timer) + self._solver_model = maingo_solvermodel.SolverModel( + var_list=self._maingo_vars, + con_list=self._cons, + objective=self._objective, + idmap=self._pyomo_var_to_solver_var_id_map, + logger=logger, + ) diff --git a/pyomo/contrib/appsi/solvers/maingo_solvermodel.py b/pyomo/contrib/appsi/solvers/maingo_solvermodel.py new file mode 100644 index 00000000000..4df79445d25 --- /dev/null +++ b/pyomo/contrib/appsi/solvers/maingo_solvermodel.py @@ -0,0 +1,283 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import math + +from pyomo.common.dependencies import attempt_import +from pyomo.core.base.var import ScalarVar +from pyomo.core.base.expression import ScalarExpression +import pyomo.core.expr.expr_common as common +import pyomo.core.expr as EXPR +from pyomo.core.expr.numvalue import ( + value, + is_constant, + is_fixed, + native_numeric_types, + native_types, + nonpyomo_leaf_types, +) +from pyomo.core.kernel.objective import minimize, maximize +from pyomo.repn.util import valid_expr_ctypes_minlp + + +maingopy, maingopy_available = attempt_import("maingopy") + +_plusMinusOne = {1, -1} + +LEFT_TO_RIGHT = common.OperatorAssociativity.LEFT_TO_RIGHT +RIGHT_TO_LEFT = common.OperatorAssociativity.RIGHT_TO_LEFT + + +class ToMAiNGOVisitor(EXPR.ExpressionValueVisitor): + def __init__(self, variables, idmap): + super(ToMAiNGOVisitor, self).__init__() + self.variables = variables + self.idmap = idmap + self._pyomo_func_to_maingo_func = { + "log": maingopy.log, + "log10": ToMAiNGOVisitor.maingo_log10, + "sin": maingopy.sin, + "cos": maingopy.cos, + "tan": maingopy.tan, + "cosh": maingopy.cosh, + "sinh": maingopy.sinh, + "tanh": maingopy.tanh, + "asin": maingopy.asin, + "acos": maingopy.acos, + "atan": maingopy.atan, + "exp": maingopy.exp, + "sqrt": maingopy.sqrt, + "asinh": ToMAiNGOVisitor.maingo_asinh, + "acosh": ToMAiNGOVisitor.maingo_acosh, + "atanh": ToMAiNGOVisitor.maingo_atanh, + } + + @classmethod + def maingo_log10(cls, x): + return maingopy.log(x) / math.log(10) + + @classmethod + def maingo_asinh(cls, x): + return maingopy.log(x + maingopy.sqrt(maingopy.pow(x, 2) + 1)) + + @classmethod + def maingo_acosh(cls, x): + return maingopy.log(x + maingopy.sqrt(maingopy.pow(x, 2) - 1)) + + @classmethod + def maingo_atanh(cls, x): + return 0.5 * maingopy.log(x + 1) - 0.5 * maingopy.log(1 - x) + + def visit(self, node, values): + """Visit nodes that have been expanded""" + for i, val in enumerate(values): + arg = node._args_[i] + + if arg is None: + values[i] = "Undefined" + elif arg.__class__ in native_numeric_types: + pass + elif arg.__class__ in nonpyomo_leaf_types: + values[i] = val + else: + parens = False + if arg.is_expression_type() and node.PRECEDENCE is not None: + if arg.PRECEDENCE is None: + pass + elif node.PRECEDENCE < arg.PRECEDENCE: + parens = True + elif node.PRECEDENCE == arg.PRECEDENCE: + if i == 0: + parens = node.ASSOCIATIVITY != LEFT_TO_RIGHT + elif i == len(node._args_) - 1: + parens = node.ASSOCIATIVITY != RIGHT_TO_LEFT + else: + parens = True + if parens: + values[i] = val + + if node.__class__ in EXPR.NPV_expression_types: + return value(node) + + if node.__class__ in {EXPR.ProductExpression, EXPR.MonomialTermExpression}: + return values[0] * values[1] + + if node.__class__ in {EXPR.SumExpression}: + return sum(values) + + if node.__class__ in {EXPR.PowExpression}: + return maingopy.pow(values[0], values[1]) + + if node.__class__ in {EXPR.DivisionExpression}: + return values[0] / values[1] + + if node.__class__ in {EXPR.NegationExpression}: + return -values[0] + + if node.__class__ in {EXPR.AbsExpression}: + return maingopy.abs(values[0]) + + if node.__class__ in {EXPR.UnaryFunctionExpression}: + pyomo_func = node.getname() + maingo_func = self._pyomo_func_to_maingo_func[pyomo_func] + return maingo_func(values[0]) + + if node.__class__ in {ScalarExpression}: + return values[0] + + raise ValueError(f"Unknown function expression encountered: {node.getname()}") + + def visiting_potential_leaf(self, node): + """ + Visiting a potential leaf. + + Return True if the node is not expanded. + """ + if node.__class__ in native_types: + return True, node + + if node.is_expression_type(): + if node.__class__ is EXPR.MonomialTermExpression: + return True, self._monomial_to_maingo(node) + if node.__class__ is EXPR.LinearExpression: + return True, self._linear_to_maingo(node) + return False, None + + if node.is_component_type(): + if node.ctype not in valid_expr_ctypes_minlp: + # Make sure all components in active constraints + # are basic ctypes we know how to deal with. + raise RuntimeError( + "Unallowable component '%s' of type %s found in an active " + "constraint or objective.\nMAiNGO cannot export " + "expressions with this component type." + % (node.name, node.ctype.__name__) + ) + + if node.is_fixed(): + return True, node() + else: + assert node.is_variable_type() + maingo_var_id = self.idmap[id(node)] + maingo_var = self.variables[maingo_var_id] + return True, maingo_var + + def _monomial_to_maingo(self, node): + const, var = node.args + if const.__class__ not in native_types: + const = value(const) + if var.is_fixed(): + return const * var.value + if not const: + return 0 + maingo_var = self._var_to_maingo(var) + if const in _plusMinusOne: + if const < 0: + return -maingo_var + else: + return maingo_var + return const * maingo_var + + def _var_to_maingo(self, var): + maingo_var_id = self.idmap[id(var)] + maingo_var = self.variables[maingo_var_id] + return maingo_var + + def _linear_to_maingo(self, node): + values = [ + ( + self._monomial_to_maingo(arg) + if (arg.__class__ is EXPR.MonomialTermExpression) + else ( + value(arg) + if arg.__class__ in native_numeric_types + else ( + self._var_to_maingo(arg) + if arg.is_variable_type() + else value(arg) + ) + ) + ) + for arg in node.args + ] + return sum(values) + + +class SolverModel(maingopy.MAiNGOmodel if maingopy_available else object): + def __init__(self, var_list, objective, con_list, idmap, logger): + maingopy.MAiNGOmodel.__init__(self) + self._var_list = var_list + self._con_list = con_list + self._objective = objective + self._idmap = idmap + self._logger = logger + self._no_objective = False + + if self._objective is None: + self._logger.warning("No objective given, setting a dummy objective of 1.") + self._no_objective = True + + def build_maingo_objective(self, obj, visitor): + if self._no_objective: + return visitor.variables[-1] + maingo_obj = visitor.dfs_postorder_stack(obj.expr) + if obj.sense == maximize: + return -1 * maingo_obj + return maingo_obj + + def build_maingo_constraints(self, cons, visitor): + eqs = [] + ineqs = [] + for con in cons: + if con.equality: + eqs += [visitor.dfs_postorder_stack(con.body - con.lower)] + elif con.has_ub() and con.has_lb(): + ineqs += [visitor.dfs_postorder_stack(con.body - con.upper)] + ineqs += [visitor.dfs_postorder_stack(con.lower - con.body)] + elif con.has_ub(): + ineqs += [visitor.dfs_postorder_stack(con.body - con.upper)] + elif con.has_lb(): + ineqs += [visitor.dfs_postorder_stack(con.lower - con.body)] + else: + raise ValueError( + "Constraint does not have a lower " + "or an upper bound: {0} \n".format(con) + ) + return eqs, ineqs + + def get_variables(self): + vars = [ + maingopy.OptimizationVariable( + maingopy.Bounds(var.lb, var.ub), var.type, var.name + ) + for var in self._var_list + ] + if self._no_objective: + vars += [maingopy.OptimizationVariable(maingopy.Bounds(1, 1), "dummy_obj")] + return vars + + def get_initial_point(self): + initial = [ + var.init if not var.init is None else (var.lb + var.ub) / 2.0 + for var in self._var_list + ] + if self._no_objective: + initial += [1] + return initial + + def evaluate(self, maingo_vars): + visitor = ToMAiNGOVisitor(maingo_vars, self._idmap) + result = maingopy.EvaluationContainer() + result.objective = self.build_maingo_objective(self._objective, visitor) + eqs, ineqs = self.build_maingo_constraints(self._con_list, visitor) + result.eq = eqs + result.ineq = ineqs + return result diff --git a/pyomo/contrib/appsi/solvers/tests/__init__.py b/pyomo/contrib/appsi/solvers/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/appsi/solvers/tests/__init__.py +++ b/pyomo/contrib/appsi/solvers/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/solvers/tests/test_gurobi_persistent.py b/pyomo/contrib/appsi/solvers/tests/test_gurobi_persistent.py index 2f674a2eb6a..028c31fb06a 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_gurobi_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_gurobi_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ from pyomo.common.errors import PyomoException import pyomo.common.unittest as unittest -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.contrib.appsi.solvers.gurobi import Gurobi from pyomo.contrib.appsi.base import TerminationCondition from pyomo.core.expr.numeric_expr import LinearExpression @@ -89,17 +89,17 @@ def create_pmedian_model(): (10, 6): 1.9115931008709737, } - model = pe.ConcreteModel() - model.N = pe.Param(initialize=10) - model.Locations = pe.RangeSet(1, model.N) - model.P = pe.Param(initialize=3) - model.M = pe.Param(initialize=6) - model.Customers = pe.RangeSet(1, model.M) - model.d = pe.Param( - model.Locations, model.Customers, initialize=d_dict, within=pe.Reals + model = pyo.ConcreteModel() + model.N = pyo.Param(initialize=10) + model.Locations = pyo.RangeSet(1, model.N) + model.P = pyo.Param(initialize=3) + model.M = pyo.Param(initialize=6) + model.Customers = pyo.RangeSet(1, model.M) + model.d = pyo.Param( + model.Locations, model.Customers, initialize=d_dict, within=pyo.Reals ) - model.x = pe.Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) - model.y = pe.Var(model.Locations, within=pe.Binary) + model.x = pyo.Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) + model.y = pyo.Var(model.Locations, within=pyo.Binary) def rule(model): return sum( @@ -108,39 +108,39 @@ def rule(model): for m in model.Customers ) - model.obj = pe.Objective(rule=rule) + model.obj = pyo.Objective(rule=rule) def rule(model, m): return (sum(model.x[n, m] for n in model.Locations), 1.0) - model.single_x = pe.Constraint(model.Customers, rule=rule) + model.single_x = pyo.Constraint(model.Customers, rule=rule) def rule(model, n, m): return (None, model.x[n, m] - model.y[n], 0.0) - model.bound_y = pe.Constraint(model.Locations, model.Customers, rule=rule) + model.bound_y = pyo.Constraint(model.Locations, model.Customers, rule=rule) def rule(model): return (sum(model.y[n] for n in model.Locations) - model.P, 0.0) - model.num_facilities = pe.Constraint(rule=rule) + model.num_facilities = pyo.Constraint(rule=rule) return model class TestGurobiPersistentSimpleLPUpdates(unittest.TestCase): def setUp(self): - self.m = pe.ConcreteModel() + self.m = pyo.ConcreteModel() m = self.m - m.x = pe.Var() - m.y = pe.Var() - m.p1 = pe.Param(mutable=True) - m.p2 = pe.Param(mutable=True) - m.p3 = pe.Param(mutable=True) - m.p4 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.x + m.y) - m.c1 = pe.Constraint(expr=m.y - m.p1 * m.x >= m.p2) - m.c2 = pe.Constraint(expr=m.y - m.p3 * m.x >= m.p4) + m.x = pyo.Var() + m.y = pyo.Var() + m.p1 = pyo.Param(mutable=True) + m.p2 = pyo.Param(mutable=True) + m.p3 = pyo.Param(mutable=True) + m.p4 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.x + m.y) + m.c1 = pyo.Constraint(expr=m.y - m.p1 * m.x >= m.p2) + m.c2 = pyo.Constraint(expr=m.y - m.p3 * m.x >= m.p4) def get_solution(self): try: @@ -191,51 +191,66 @@ def test_lp(self): class TestGurobiPersistent(unittest.TestCase): def test_nonconvex_qcp_objective_bound_1(self): - # the goal of this test is to ensure we can get an objective bound - # for nonconvex but continuous problems even if a feasible solution - # is not found + # the goal of this test is to ensure we can get an objective + # bound for nonconvex but continuous problems even if a feasible + # solution is not found # - # This is a fragile test because it could fail if Gurobi's algorithms improve - # (e.g., a heuristic solution is found before an objective bound of -8 is reached - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-5, 5)) - m.y = pe.Var(bounds=(-5, 5)) - m.obj = pe.Objective(expr=-m.x**2 - m.y) - m.c1 = pe.Constraint(expr=m.y <= -2 * m.x + 1) - m.c2 = pe.Constraint(expr=m.y <= m.x - 2) + # This is a fragile test because it could fail if Gurobi's + # algorithms improve (e.g., a heuristic solution is found before + # an objective bound of -8 is reached + # + # Update: as of Gurobi 11, this test no longer tests the + # intended behavior (the solver has improved) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-5, 5)) + m.y = pyo.Var(bounds=(-5, 5)) + m.obj = pyo.Objective(expr=-m.x**2 - m.y) + m.c1 = pyo.Constraint(expr=m.y <= -2 * m.x + 1) + m.c2 = pyo.Constraint(expr=m.y <= m.x - 2) opt = Gurobi() opt.gurobi_options['nonconvex'] = 2 opt.gurobi_options['BestBdStop'] = -8 opt.config.load_solution = False res = opt.solve(m) - self.assertEqual(res.best_feasible_objective, None) + if opt.version() < (11, 0): + self.assertEqual(res.best_feasible_objective, None) + else: + self.assertEqual(res.best_feasible_objective, -4) self.assertAlmostEqual(res.best_objective_bound, -8) def test_nonconvex_qcp_objective_bound_2(self): - # the goal of this test is to ensure we can best_objective_bound properly - # for nonconvex but continuous problems when the solver terminates with a nonzero gap + # the goal of this test is to ensure we can best_objective_bound + # properly for nonconvex but continuous problems when the solver + # terminates with a nonzero gap + # + # This is a fragile test because it could fail if Gurobi's + # algorithms change # - # This is a fragile test because it could fail if Gurobi's algorithms change - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-5, 5)) - m.y = pe.Var(bounds=(-5, 5)) - m.obj = pe.Objective(expr=-m.x**2 - m.y) - m.c1 = pe.Constraint(expr=m.y <= -2 * m.x + 1) - m.c2 = pe.Constraint(expr=m.y <= m.x - 2) + # Update: as of Gurobi 11, this test no longer tests the + # intended behavior (the solver has improved) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-5, 5)) + m.y = pyo.Var(bounds=(-5, 5)) + m.obj = pyo.Objective(expr=-m.x**2 - m.y) + m.c1 = pyo.Constraint(expr=m.y <= -2 * m.x + 1) + m.c2 = pyo.Constraint(expr=m.y <= m.x - 2) opt = Gurobi() opt.gurobi_options['nonconvex'] = 2 opt.gurobi_options['MIPGap'] = 0.5 res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, -4) - self.assertAlmostEqual(res.best_objective_bound, -6) + if opt.version() < (11, 0): + self.assertAlmostEqual(res.best_objective_bound, -6) + else: + self.assertAlmostEqual(res.best_objective_bound, -4) def test_range_constraints(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.xl = pe.Param(initialize=-1, mutable=True) - m.xu = pe.Param(initialize=1, mutable=True) - m.c = pe.Constraint(expr=pe.inequality(m.xl, m.x, m.xu)) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.xl = pyo.Param(initialize=-1, mutable=True) + m.xu = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Constraint(expr=pyo.inequality(m.xl, m.x, m.xu)) + m.obj = pyo.Objective(expr=m.x) opt = Gurobi() opt.set_instance(m) @@ -247,7 +262,7 @@ def test_range_constraints(self): self.assertAlmostEqual(m.x.value, -3) del m.obj - m.obj = pe.Objective(expr=m.x, sense=pe.maximize) + m.obj = pyo.Objective(expr=m.x, sense=pyo.maximize) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 1) @@ -257,14 +272,14 @@ def test_range_constraints(self): self.assertAlmostEqual(m.x.value, 3) def test_quadratic_constraint_with_params(self): - m = pe.ConcreteModel() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=1, mutable=True) - m.c = pe.Param(initialize=1, mutable=True) - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.con = pe.Constraint(expr=m.y >= m.a * m.x**2 + m.b * m.x + m.c) + m = pyo.ConcreteModel() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Param(initialize=1, mutable=True) + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.con = pyo.Constraint(expr=m.y >= m.a * m.x**2 + m.b * m.x + m.c) opt = Gurobi() res = opt.solve(m) @@ -283,12 +298,12 @@ def test_quadratic_constraint_with_params(self): ) def test_quadratic_objective(self): - m = pe.ConcreteModel() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=1, mutable=True) - m.c = pe.Param(initialize=1, mutable=True) - m.x = pe.Var() - m.obj = pe.Objective(expr=m.a * m.x**2 + m.b * m.x + m.c) + m = pyo.ConcreteModel() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Param(initialize=1, mutable=True) + m.x = pyo.Var() + m.obj = pyo.Objective(expr=m.a * m.x**2 + m.b * m.x + m.c) opt = Gurobi() res = opt.solve(m) @@ -309,9 +324,9 @@ def test_quadratic_objective(self): ) def test_var_bounds(self): - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-1, 1)) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, 1)) + m.obj = pyo.Objective(expr=m.x) opt = Gurobi() res = opt.solve(m) @@ -322,7 +337,7 @@ def test_var_bounds(self): self.assertAlmostEqual(m.x.value, -3) del m.obj - m.obj = pe.Objective(expr=m.x, sense=pe.maximize) + m.obj = pyo.Objective(expr=m.x, sense=pyo.maximize) opt = Gurobi() res = opt.solve(m) @@ -333,14 +348,14 @@ def test_var_bounds(self): self.assertAlmostEqual(m.x.value, 3) def test_fixed_var(self): - m = pe.ConcreteModel() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=1, mutable=True) - m.c = pe.Param(initialize=1, mutable=True) - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.con = pe.Constraint(expr=m.y >= m.a * m.x**2 + m.b * m.x + m.c) + m = pyo.ConcreteModel() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Param(initialize=1, mutable=True) + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.con = pyo.Constraint(expr=m.y >= m.a * m.x**2 + m.b * m.x + m.c) m.x.fix(1) opt = Gurobi() @@ -361,10 +376,10 @@ def test_fixed_var(self): ) def test_linear_constraint_attr(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c = pe.Constraint(expr=m.x + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c = pyo.Constraint(expr=m.x + m.y == 1) opt = Gurobi() opt.set_instance(m) @@ -372,19 +387,19 @@ def test_linear_constraint_attr(self): self.assertEqual(opt.get_linear_constraint_attr(m.c, 'Lazy'), 1) def test_quadratic_constraint_attr(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c = pe.Constraint(expr=m.y >= m.x**2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c = pyo.Constraint(expr=m.y >= m.x**2) opt = Gurobi() opt.set_instance(m) self.assertEqual(opt.get_quadratic_constraint_attr(m.c, 'QCRHS'), 0) def test_var_attr(self): - m = pe.ConcreteModel() - m.x = pe.Var(within=pe.Binary) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.Binary) + m.obj = pyo.Objective(expr=m.x) opt = Gurobi() opt.set_instance(m) @@ -392,11 +407,11 @@ def test_var_attr(self): self.assertEqual(opt.get_var_attr(m.x, 'Start'), 1) def test_callback(self): - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(0, 4)) - m.y = pe.Var(within=pe.Integers, bounds=(0, None)) - m.obj = pe.Objective(expr=2 * m.x + m.y) - m.cons = pe.ConstraintList() + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 4)) + m.y = pyo.Var(within=pyo.Integers, bounds=(0, None)) + m.obj = pyo.Objective(expr=2 * m.x + m.y) + m.cons = pyo.ConstraintList() def _add_cut(xval): m.x.value = xval @@ -424,11 +439,11 @@ def _my_callback(cb_m, cb_opt, cb_where): def test_nonconvex(self): if gurobipy.GRB.VERSION_MAJOR < 9: raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c = pe.Constraint(expr=m.y == (m.x - 1) ** 2 - 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c = pyo.Constraint(expr=m.y == (m.x - 1) ** 2 - 2) opt = Gurobi() opt.gurobi_options['nonconvex'] = 2 opt.solve(m) @@ -438,12 +453,12 @@ def test_nonconvex(self): def test_nonconvex2(self): if gurobipy.GRB.VERSION_MAJOR < 9: raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=0 <= -m.y + (m.x - 1) ** 2 - 2) - m.c2 = pe.Constraint(expr=0 >= -m.y + (m.x - 1) ** 2 - 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=0 <= -m.y + (m.x - 1) ** 2 - 2) + m.c2 = pyo.Constraint(expr=0 >= -m.y + (m.x - 1) ** 2 - 2) opt = Gurobi() opt.gurobi_options['nonconvex'] = 2 opt.solve(m) @@ -459,11 +474,11 @@ def test_solution_number(self): num_solutions = opt.get_model_attr('SolCount') self.assertEqual(num_solutions, 3) res.solution_loader.load_vars(solution_number=0) - self.assertAlmostEqual(pe.value(m.obj.expr), 6.431184939357673) + self.assertAlmostEqual(pyo.value(m.obj.expr), 6.431184939357673) res.solution_loader.load_vars(solution_number=1) - self.assertAlmostEqual(pe.value(m.obj.expr), 6.584793218502477) + self.assertAlmostEqual(pyo.value(m.obj.expr), 6.584793218502477) res.solution_loader.load_vars(solution_number=2) - self.assertAlmostEqual(pe.value(m.obj.expr), 6.592304628123309) + self.assertAlmostEqual(pyo.value(m.obj.expr), 6.592304628123309) def test_zero_time_limit(self): m = create_pmedian_model() @@ -495,11 +510,11 @@ def setUp(self): self.opt = opt def test_basics(self): - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-10, 10)) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y >= 2 * m.x + 1) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y >= 2 * m.x + 1) opt = self.opt opt.set_instance(m) @@ -516,7 +531,7 @@ def test_basics(self): duals = opt.get_duals() self.assertAlmostEqual(duals[m.c1], -0.4) - m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + m.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) opt.add_constraints([m.c2]) self.assertEqual(opt.get_model_attr('NumVars'), 2) self.assertEqual(opt.get_model_attr('NumConstrs'), 2) @@ -560,7 +575,7 @@ def test_basics(self): self.assertEqual(opt.get_var_attr(m.x, 'LB'), -5) self.assertEqual(opt.get_var_attr(m.x, 'UB'), 5) - m.c2 = pe.Constraint(expr=m.y >= m.x**2) + m.c2 = pyo.Constraint(expr=m.y >= m.x**2) opt.add_constraints([m.c2]) self.assertEqual(opt.get_model_attr('NumVars'), 2) self.assertEqual(opt.get_model_attr('NumConstrs'), 1) @@ -572,7 +587,7 @@ def test_basics(self): self.assertEqual(opt.get_model_attr('NumConstrs'), 1) self.assertEqual(opt.get_model_attr('NumQConstrs'), 0) - m.z = pe.Var() + m.z = pyo.Var() opt.add_variables([m.z]) self.assertEqual(opt.get_model_attr('NumVars'), 3) opt.remove_variables([m.z]) @@ -580,12 +595,12 @@ def test_basics(self): self.assertEqual(opt.get_model_attr('NumVars'), 2) def test_update1(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c1 = pe.Constraint(expr=m.z >= m.x**2 + m.y**2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c1 = pyo.Constraint(expr=m.z >= m.x**2 + m.y**2) opt = self.opt opt.set_instance(m) @@ -601,12 +616,12 @@ def test_update1(self): self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) def test_update2(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c2 = pe.Constraint(expr=m.x + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c2 = pyo.Constraint(expr=m.x + m.y == 1) opt = self.opt opt.config.symbolic_solver_labels = True @@ -623,18 +638,18 @@ def test_update2(self): self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) def test_update3(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c1 = pe.Constraint(expr=m.z >= m.x**2 + m.y**2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c1 = pyo.Constraint(expr=m.z >= m.x**2 + m.y**2) opt = self.opt opt.set_instance(m) opt.update() self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) - m.c2 = pe.Constraint(expr=m.y >= m.x**2) + m.c2 = pyo.Constraint(expr=m.y >= m.x**2) opt.add_constraints([m.c2]) self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) opt.remove_constraints([m.c2]) @@ -642,18 +657,18 @@ def test_update3(self): self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) def test_update4(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c1 = pe.Constraint(expr=m.z >= m.x + m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c1 = pyo.Constraint(expr=m.z >= m.x + m.y) opt = self.opt opt.set_instance(m) opt.update() self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) - m.c2 = pe.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y >= m.x) opt.add_constraints([m.c2]) self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) opt.remove_constraints([m.c2]) @@ -661,12 +676,12 @@ def test_update4(self): self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) def test_update5(self): - m = pe.ConcreteModel() - m.a = pe.Set(initialize=[1, 2, 3], ordered=True) - m.x = pe.Var(m.a, within=pe.Binary) - m.y = pe.Var(within=pe.Binary) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.SOSConstraint(var=m.x, sos=1) + m = pyo.ConcreteModel() + m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) + m.x = pyo.Var(m.a, within=pyo.Binary) + m.y = pyo.Var(within=pyo.Binary) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.SOSConstraint(var=m.x, sos=1) opt = self.opt opt.set_instance(m) @@ -682,18 +697,18 @@ def test_update5(self): self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) def test_update6(self): - m = pe.ConcreteModel() - m.a = pe.Set(initialize=[1, 2, 3], ordered=True) - m.x = pe.Var(m.a, within=pe.Binary) - m.y = pe.Var(within=pe.Binary) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.SOSConstraint(var=m.x, sos=1) + m = pyo.ConcreteModel() + m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) + m.x = pyo.Var(m.a, within=pyo.Binary) + m.y = pyo.Var(within=pyo.Binary) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.SOSConstraint(var=m.x, sos=1) opt = self.opt opt.set_instance(m) opt.update() self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) - m.c2 = pe.SOSConstraint(var=m.x, sos=2) + m.c2 = pyo.SOSConstraint(var=m.x, sos=2) opt.add_sos_constraints([m.c2]) self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) opt.remove_sos_constraints([m.c2]) @@ -701,9 +716,9 @@ def test_update6(self): self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) def test_update7(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() opt = self.opt orig_only_child_vars = opt._only_child_vars diff --git a/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py b/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py index b26f45ff2cc..7770d40c06a 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,7 +13,7 @@ import sys import pyomo.common.unittest as unittest -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.common.log import LoggingIntercept from pyomo.common.tee import capture_output @@ -28,16 +28,16 @@ class TestBugs(unittest.TestCase): def test_mutable_params_with_remove_cons(self): - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-10, 10)) - m.y = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var() - m.p1 = pe.Param(mutable=True) - m.p2 = pe.Param(mutable=True) + m.p1 = pyo.Param(mutable=True) + m.p2 = pyo.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= m.x + m.p1) - m.c2 = pe.Constraint(expr=m.y >= -m.x + m.p2) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x + m.p1) + m.c2 = pyo.Constraint(expr=m.y >= -m.x + m.p2) m.p1.value = 1 m.p2.value = 1 @@ -52,19 +52,19 @@ def test_mutable_params_with_remove_cons(self): self.assertAlmostEqual(res.best_feasible_objective, -8) def test_mutable_params_with_remove_vars(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() - m.p1 = pe.Param(mutable=True) - m.p2 = pe.Param(mutable=True) + m.p1 = pyo.Param(mutable=True) + m.p2 = pyo.Param(mutable=True) m.y.setlb(m.p1) m.y.setub(m.p2) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= m.x + 1) - m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x + 1) + m.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) m.p1.value = -10 m.p2.value = 10 @@ -80,6 +80,43 @@ def test_mutable_params_with_remove_vars(self): res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, -9) + def test_fix_and_unfix(self): + # Tests issue https://github.com/Pyomo/pyomo/issues/3127 + + m = pyo.ConcreteModel() + m.x = pyo.Var(domain=pyo.Binary) + m.y = pyo.Var(domain=pyo.Binary) + m.fx = pyo.Var(domain=pyo.NonNegativeReals) + m.fy = pyo.Var(domain=pyo.NonNegativeReals) + m.c1 = pyo.Constraint(expr=m.fx <= m.x) + m.c2 = pyo.Constraint(expr=m.fy <= m.y) + m.c3 = pyo.Constraint(expr=m.x + m.y <= 1) + + m.obj = pyo.Objective(expr=m.fx * 0.5 + m.fy * 0.4, sense=pyo.maximize) + + opt = Highs() + + # solution 1 has m.x == 1 and m.y == 0 + r = opt.solve(m) + self.assertAlmostEqual(m.fx.value, 1, places=5) + self.assertAlmostEqual(m.fy.value, 0, places=5) + self.assertAlmostEqual(r.best_feasible_objective, 0.5, places=5) + + # solution 2 has m.x == 0 and m.y == 1 + m.y.fix(1) + r = opt.solve(m) + self.assertAlmostEqual(m.fx.value, 0, places=5) + self.assertAlmostEqual(m.fy.value, 1, places=5) + self.assertAlmostEqual(r.best_feasible_objective, 0.4, places=5) + + # solution 3 should be equal solution 1 + m.y.unfix() + m.x.fix(1) + r = opt.solve(m) + self.assertAlmostEqual(m.fx.value, 1, places=5) + self.assertAlmostEqual(m.fy.value, 0, places=5) + self.assertAlmostEqual(r.best_feasible_objective, 0.5, places=5) + def test_capture_highs_output(self): # tests issue #3003 # @@ -87,13 +124,13 @@ def test_capture_highs_output(self): # first time that a model is instantiated. We need to test this # in a subprocess to trigger that output. model = [ - 'import pyomo.environ as pe', - 'm = pe.ConcreteModel()', - 'm.x = pe.Var(domain=pe.NonNegativeReals)', - 'm.y = pe.Var(domain=pe.NonNegativeReals)', - 'm.obj = pe.Objective(expr=m.x + m.y, sense=pe.maximize)', - 'm.c1 = pe.Constraint(expr=m.x <= 10)', - 'm.c2 = pe.Constraint(expr=m.y <= 5)', + 'import pyomo.environ as pyo', + 'm = pyo.ConcreteModel()', + 'm.x = pyo.Var(domain=pyo.NonNegativeReals)', + 'm.y = pyo.Var(domain=pyo.NonNegativeReals)', + 'm.obj = pyo.Objective(expr=m.x + m.y, sense=pyo.maximize)', + 'm.c1 = pyo.Constraint(expr=m.x <= 10)', + 'm.c2 = pyo.Constraint(expr=m.y <= 5)', 'from pyomo.contrib.appsi.solvers.highs import Highs', 'result = Highs().solve(m)', 'print(m.x.value, m.y.value)', @@ -118,3 +155,31 @@ def test_capture_highs_output(self): self.assertIn("HiGHS run time", OUT.getvalue()) ref = "10.0 5.0\n" self.assertEqual(ref, OUT.getvalue()[-len(ref) :]) + + def test_warm_start(self): + m = pyo.ConcreteModel() + + # decision variables + m.x1 = pyo.Var(domain=pyo.Integers, name="x1", bounds=(0, 10)) + m.x2 = pyo.Var(domain=pyo.Reals, name="x2", bounds=(0, 10)) + m.x3 = pyo.Var(domain=pyo.Binary, name="x3") + + # objective function + m.OBJ = pyo.Objective(expr=(3 * m.x1 + 2 * m.x2 + 4 * m.x3), sense=pyo.maximize) + + # constraints + m.C1 = pyo.Constraint(expr=m.x1 + m.x2 <= 9) + m.C2 = pyo.Constraint(expr=3 * m.x1 + m.x2 <= 18) + m.C3 = pyo.Constraint(expr=m.x1 <= 7) + m.C4 = pyo.Constraint(expr=m.x2 <= 6) + + # MIP start + m.x1 = 4 + m.x2 = 4.5 + m.x3 = True + + # solving process + with capture_output() as output: + pyo.SolverFactory("appsi_highs").solve(m, tee=True, warmstart=True) + log = output.getvalue() + self.assertIn("MIP start solution is feasible, objective value is 25", log) diff --git a/pyomo/contrib/appsi/solvers/tests/test_ipopt_persistent.py b/pyomo/contrib/appsi/solvers/tests/test_ipopt_persistent.py index 8e6473a6b01..40fa56a35a8 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_ipopt_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_ipopt_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.environ as pe +import pyomo.environ as pyo import pyomo.common.unittest as unittest from pyomo.contrib.appsi.cmodel import cmodel_available from pyomo.common.gsl import find_GSL @@ -22,37 +22,37 @@ def test_external_function(self): if not DLL: self.skipTest('Could not find the amplgls.dll library') - opt = pe.SolverFactory('appsi_ipopt') + opt = pyo.SolverFactory('appsi_ipopt') if not opt.available(exception_flag=False): raise unittest.SkipTest - m = pe.ConcreteModel() - m.hypot = pe.ExternalFunction(library=DLL, function='gsl_hypot') - m.x = pe.Var(bounds=(-10, 10), initialize=2) - m.y = pe.Var(initialize=2) + m = pyo.ConcreteModel() + m.hypot = pyo.ExternalFunction(library=DLL, function='gsl_hypot') + m.x = pyo.Var(bounds=(-10, 10), initialize=2) + m.y = pyo.Var(initialize=2) e = 2 * m.hypot(m.x, m.x * m.y) - m.c = pe.Constraint(expr=e == 2.82843) - m.obj = pe.Objective(expr=m.x) + m.c = pyo.Constraint(expr=e == 2.82843) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) - pe.assert_optimal_termination(res) - self.assertAlmostEqual(pe.value(m.c.body) - pe.value(m.c.lower), 0) + pyo.assert_optimal_termination(res) + self.assertAlmostEqual(pyo.value(m.c.body) - pyo.value(m.c.lower), 0) def test_external_function_in_objective(self): DLL = find_GSL() if not DLL: self.skipTest('Could not find the amplgls.dll library') - opt = pe.SolverFactory('appsi_ipopt') + opt = pyo.SolverFactory('appsi_ipopt') if not opt.available(exception_flag=False): raise unittest.SkipTest - m = pe.ConcreteModel() - m.hypot = pe.ExternalFunction(library=DLL, function='gsl_hypot') - m.x = pe.Var(bounds=(1, 10), initialize=2) - m.y = pe.Var(bounds=(1, 10), initialize=2) + m = pyo.ConcreteModel() + m.hypot = pyo.ExternalFunction(library=DLL, function='gsl_hypot') + m.x = pyo.Var(bounds=(1, 10), initialize=2) + m.y = pyo.Var(bounds=(1, 10), initialize=2) e = 2 * m.hypot(m.x, m.x * m.y) - m.obj = pe.Objective(expr=e) + m.obj = pyo.Objective(expr=e) res = opt.solve(m) - pe.assert_optimal_termination(res) + pyo.assert_optimal_termination(res) self.assertAlmostEqual(m.x.value, 1) self.assertAlmostEqual(m.y.value, 1) diff --git a/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py b/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py index af615d1ed8b..e5ec6550e58 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py +++ b/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.common.dependencies import attempt_import import pyomo.common.unittest as unittest @@ -17,7 +17,7 @@ parameterized = parameterized.parameterized from pyomo.contrib.appsi.base import TerminationCondition, Results, PersistentSolver from pyomo.contrib.appsi.cmodel import cmodel_available -from pyomo.contrib.appsi.solvers import Gurobi, Ipopt, Cplex, Cbc, Highs +from pyomo.contrib.appsi.solvers import Gurobi, Ipopt, Cplex, Cbc, Highs, MAiNGO from typing import Type from pyomo.core.expr.numeric_expr import LinearExpression import os @@ -36,11 +36,23 @@ ('cplex', Cplex), ('cbc', Cbc), ('highs', Highs), + ('maingo', MAiNGO), ] -mip_solvers = [('gurobi', Gurobi), ('cplex', Cplex), ('cbc', Cbc), ('highs', Highs)] -nlp_solvers = [('ipopt', Ipopt)] -qcp_solvers = [('gurobi', Gurobi), ('ipopt', Ipopt), ('cplex', Cplex)] -miqcqp_solvers = [('gurobi', Gurobi), ('cplex', Cplex)] +mip_solvers = [ + ('gurobi', Gurobi), + ('cplex', Cplex), + ('cbc', Cbc), + ('highs', Highs), + ('maingo', MAiNGO), +] +nlp_solvers = [('ipopt', Ipopt), ('maingo', MAiNGO)] +qcp_solvers = [ + ('gurobi', Gurobi), + ('ipopt', Ipopt), + ('cplex', Cplex), + ('maingo', MAiNGO), +] +miqcqp_solvers = [('gurobi', Gurobi), ('cplex', Cplex), ('maingo', MAiNGO)] only_child_vars_options = [True, False] @@ -98,17 +110,17 @@ def test_remove_variable_and_objective( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(2, None)) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(2, None)) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 2) del m.x del m.obj - m.x = pe.Var(bounds=(2, None)) - m.obj = pe.Objective(expr=m.x) + m.x = pyo.Var(bounds=(2, None)) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 2) @@ -120,13 +132,13 @@ def test_stale_vars( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= m.x) - m.c2 = pe.Constraint(expr=m.y >= -m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y >= -m.x) m.x.value = 1 m.y.value = 1 m.z.value = 1 @@ -165,21 +177,23 @@ def test_range_constraint( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.obj = pe.Objective(expr=m.x) - m.c = pe.Constraint(expr=(-1, m.x, 1)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.obj = pyo.Objective(expr=m.x) + m.c = pyo.Constraint(expr=(-1, m.x, 1)) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, -1) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c], 1) - m.obj.sense = pe.maximize + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c], 1) + m.obj.sense = pyo.maximize res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 1) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c], 1) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c], 1) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_reduced_costs( @@ -188,17 +202,18 @@ def test_reduced_costs( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-1, 1)) - m.y = pe.Var(bounds=(-2, 2)) - m.obj = pe.Objective(expr=3 * m.x + 4 * m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, 1)) + m.y = pyo.Var(bounds=(-2, 2)) + m.obj = pyo.Objective(expr=3 * m.x + 4 * m.y) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, -1) self.assertAlmostEqual(m.y.value, -2) - rc = opt.get_reduced_costs() - self.assertAlmostEqual(rc[m.x], 3) - self.assertAlmostEqual(rc[m.y], 4) + if opt_class != MAiNGO: + rc = opt.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], 3) + self.assertAlmostEqual(rc[m.y], 4) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_reduced_costs2( @@ -207,20 +222,22 @@ def test_reduced_costs2( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-1, 1)) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, 1)) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, -1) - rc = opt.get_reduced_costs() - self.assertAlmostEqual(rc[m.x], 1) - m.obj.sense = pe.maximize + if opt_class != MAiNGO: + rc = opt.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], 1) + m.obj.sense = pyo.maximize res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 1) - rc = opt.get_reduced_costs() - self.assertAlmostEqual(rc[m.x], 1) + if opt_class != MAiNGO: + rc = opt.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], 1) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_param_changes( @@ -229,16 +246,16 @@ def test_param_changes( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) - m.c2 = pe.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) + m.c2 = pyo.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -252,9 +269,10 @@ def test_param_changes( self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertAlmostEqual(res.best_feasible_objective, m.y.value) self.assertTrue(res.best_objective_bound <= m.y.value) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_immutable_param( @@ -267,16 +285,16 @@ def test_immutable_param( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(initialize=-1) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) - m.c2 = pe.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(initialize=-1) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) + m.c2 = pyo.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) params_to_test = [(1, 2, 1), (1, 2, 1), (1, 3, 1)] for a1, b1, b2 in params_to_test: @@ -290,9 +308,10 @@ def test_immutable_param( self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertAlmostEqual(res.best_feasible_objective, m.y.value) self.assertTrue(res.best_objective_bound <= m.y.value) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_equality( @@ -301,16 +320,16 @@ def test_equality( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y == m.a1 * m.x + m.b1) - m.c2 = pe.Constraint(expr=m.y == m.a2 * m.x + m.b2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y == m.a1 * m.x + m.b1) + m.c2 = pyo.Constraint(expr=m.y == m.a2 * m.x + m.b2) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -324,9 +343,10 @@ def test_equality( self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertAlmostEqual(res.best_feasible_objective, m.y.value) self.assertTrue(res.best_objective_bound <= m.y.value) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], -a1 / (a2 - a1)) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], -a1 / (a2 - a1)) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_linear_expression( @@ -335,22 +355,22 @@ def test_linear_expression( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) e = LinearExpression( constant=m.b1, linear_coefs=[-1, m.a1], linear_vars=[m.y, m.x] ) - m.c1 = pe.Constraint(expr=e == 0) + m.c1 = pyo.Constraint(expr=e == 0) e = LinearExpression( constant=m.b2, linear_coefs=[-1, m.a2], linear_vars=[m.y, m.x] ) - m.c2 = pe.Constraint(expr=e == 0) + m.c2 = pyo.Constraint(expr=e == 0) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -371,15 +391,15 @@ def test_no_objective( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.c1 = pe.Constraint(expr=m.y == m.a1 * m.x + m.b1) - m.c2 = pe.Constraint(expr=m.y == m.a2 * m.x + m.b2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.c1 = pyo.Constraint(expr=m.y == m.a1 * m.x + m.b1) + m.c2 = pyo.Constraint(expr=m.y == m.a2 * m.x + m.b2) opt.config.stream_solver = True params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] @@ -394,9 +414,10 @@ def test_no_objective( self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertEqual(res.best_feasible_objective, None) self.assertEqual(res.best_objective_bound, None) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c1], 0) - self.assertAlmostEqual(duals[m.c2], 0) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c1], 0) + self.assertAlmostEqual(duals[m.c2], 0) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_add_remove_cons( @@ -405,39 +426,41 @@ def test_add_remove_cons( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() a1 = -1 a2 = 1 b1 = 1 b2 = 2 a3 = 1 b3 = 3 - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= a1 * m.x + b1) - m.c2 = pe.Constraint(expr=m.y >= a2 * m.x + b2) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= a1 * m.x + b1) + m.c2 = pyo.Constraint(expr=m.y >= a2 * m.x + b2) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertAlmostEqual(res.best_feasible_objective, m.y.value) self.assertTrue(res.best_objective_bound <= m.y.value) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) - m.c3 = pe.Constraint(expr=m.y >= a3 * m.x + b3) + m.c3 = pyo.Constraint(expr=m.y >= a3 * m.x + b3) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, (b3 - b1) / (a1 - a3)) self.assertAlmostEqual(m.y.value, a1 * (b3 - b1) / (a1 - a3) + b1) self.assertAlmostEqual(res.best_feasible_objective, m.y.value) self.assertTrue(res.best_objective_bound <= m.y.value) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a3 - a1))) - self.assertAlmostEqual(duals[m.c2], 0) - self.assertAlmostEqual(duals[m.c3], a1 / (a3 - a1)) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a3 - a1))) + self.assertAlmostEqual(duals[m.c2], 0) + self.assertAlmostEqual(duals[m.c3], a1 / (a3 - a1)) del m.c3 res = opt.solve(m) @@ -446,9 +469,10 @@ def test_add_remove_cons( self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertAlmostEqual(res.best_feasible_objective, m.y.value) self.assertTrue(res.best_objective_bound <= m.y.value) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_results_infeasible( @@ -457,12 +481,12 @@ def test_results_infeasible( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= m.x) - m.c2 = pe.Constraint(expr=m.y <= m.x - 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y <= m.x - 1) with self.assertRaises(Exception): res = opt.solve(m) opt.config.load_solution = False @@ -487,37 +511,39 @@ def test_results_infeasible( RuntimeError, '.*does not currently have a valid solution.*' ): res.solution_loader.load_vars() - with self.assertRaisesRegex( - RuntimeError, '.*does not currently have valid duals.*' - ): - res.solution_loader.get_duals() - with self.assertRaisesRegex( - RuntimeError, '.*does not currently have valid reduced costs.*' - ): - res.solution_loader.get_reduced_costs() + if opt_class != MAiNGO: + with self.assertRaisesRegex( + RuntimeError, '.*does not currently have valid duals.*' + ): + res.solution_loader.get_duals() + with self.assertRaisesRegex( + RuntimeError, '.*does not currently have valid reduced costs.*' + ): + res.solution_loader.get_reduced_costs() @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_duals(self, name: str, opt_class: Type[PersistentSolver], only_child_vars): opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y - m.x >= 0) - m.c2 = pe.Constraint(expr=m.y + m.x - 2 >= 0) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y - m.x >= 0) + m.c2 = pyo.Constraint(expr=m.y + m.x - 2 >= 0) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 1) self.assertAlmostEqual(m.y.value, 1) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.c1], 0.5) - self.assertAlmostEqual(duals[m.c2], 0.5) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.c1], 0.5) + self.assertAlmostEqual(duals[m.c2], 0.5) - duals = opt.get_duals(cons_to_load=[m.c1]) - self.assertAlmostEqual(duals[m.c1], 0.5) - self.assertNotIn(m.c2, duals) + duals = opt.get_duals(cons_to_load=[m.c1]) + self.assertAlmostEqual(duals[m.c1], 0.5) + self.assertNotIn(m.c2, duals) @parameterized.expand(input=_load_tests(qcp_solvers, only_child_vars_options)) def test_mutable_quadratic_coefficient( @@ -526,13 +552,13 @@ def test_mutable_quadratic_coefficient( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=-1, mutable=True) - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c = pe.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=-1, mutable=True) + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c = pyo.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0.41024548525899274, 4) @@ -550,15 +576,15 @@ def test_mutable_quadratic_objective( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=-1, mutable=True) - m.c = pe.Param(initialize=1, mutable=True) - m.d = pe.Param(initialize=1, mutable=True) - m.obj = pe.Objective(expr=m.x**2 + m.c * m.y**2 + m.d * m.x) - m.ccon = pe.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=-1, mutable=True) + m.c = pyo.Param(initialize=1, mutable=True) + m.d = pyo.Param(initialize=1, mutable=True) + m.obj = pyo.Objective(expr=m.x**2 + m.c * m.y**2 + m.d * m.x) + m.ccon = pyo.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0.2719178742733325, 4) @@ -579,17 +605,17 @@ def test_fixed_vars( opt.update_config.treat_fixed_vars_as_params = treat_fixed_vars_as_params if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() m.x.fix(0) - m.y = pe.Var() + m.y = pyo.Var() a1 = 1 a2 = -1 b1 = 1 b2 = 2 - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= a1 * m.x + b1) - m.c2 = pe.Constraint(expr=m.y >= a2 * m.x + b2) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= a1 * m.x + b1) + m.c2 = pyo.Constraint(expr=m.y >= a2 * m.x + b2) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) @@ -618,17 +644,17 @@ def test_fixed_vars_2( opt.update_config.treat_fixed_vars_as_params = True if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() m.x.fix(0) - m.y = pe.Var() + m.y = pyo.Var() a1 = 1 a2 = -1 b1 = 1 b2 = 2 - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= a1 * m.x + b1) - m.c2 = pe.Constraint(expr=m.y >= a2 * m.x + b2) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= a1 * m.x + b1) + m.c2 = pyo.Constraint(expr=m.y >= a2 * m.x + b2) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) @@ -657,11 +683,11 @@ def test_fixed_vars_3( opt.update_config.treat_fixed_vars_as_params = True if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y) - m.c1 = pe.Constraint(expr=m.x == 2 / m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y) + m.c1 = pyo.Constraint(expr=m.x == 2 / m.y) m.y.fix(1) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 2) @@ -672,13 +698,13 @@ def test_fixed_vars_4( ): opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) opt.update_config.treat_fixed_vars_as_params = True - if not opt.available(): + if not opt.available() or opt_class == MAiNGO: raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.x == 2 / m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.x == 2 / m.y) m.y.fix(1) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 2) @@ -698,18 +724,18 @@ def test_mutable_param_with_range( import numpy as np except: raise unittest.SkipTest('numpy is not available') - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(initialize=0, mutable=True) - m.a2 = pe.Param(initialize=0, mutable=True) - m.b1 = pe.Param(initialize=0, mutable=True) - m.b2 = pe.Param(initialize=0, mutable=True) - m.c1 = pe.Param(initialize=0, mutable=True) - m.c2 = pe.Param(initialize=0, mutable=True) - m.obj = pe.Objective(expr=m.y) - m.con1 = pe.Constraint(expr=(m.b1, m.y - m.a1 * m.x, m.c1)) - m.con2 = pe.Constraint(expr=(m.b2, m.y - m.a2 * m.x, m.c2)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(initialize=0, mutable=True) + m.a2 = pyo.Param(initialize=0, mutable=True) + m.b1 = pyo.Param(initialize=0, mutable=True) + m.b2 = pyo.Param(initialize=0, mutable=True) + m.c1 = pyo.Param(initialize=0, mutable=True) + m.c2 = pyo.Param(initialize=0, mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.con1 = pyo.Constraint(expr=(m.b1, m.y - m.a1 * m.x, m.c1)) + m.con2 = pyo.Constraint(expr=(m.b2, m.y - m.a2 * m.x, m.c2)) np.random.seed(0) params_to_test = [ @@ -720,7 +746,7 @@ def test_mutable_param_with_range( np.random.uniform(-5, 2.5), np.random.uniform(2.5, 10), np.random.uniform(2.5, 10), - pe.minimize, + pyo.minimize, ), ( np.random.uniform(0, 10), @@ -729,7 +755,7 @@ def test_mutable_param_with_range( np.random.uniform(-5, 2.5), np.random.uniform(2.5, 10), np.random.uniform(2.5, 10), - pe.maximize, + pyo.maximize, ), ( np.random.uniform(0, 10), @@ -738,7 +764,7 @@ def test_mutable_param_with_range( np.random.uniform(-5, 2.5), np.random.uniform(2.5, 10), np.random.uniform(2.5, 10), - pe.minimize, + pyo.minimize, ), ( np.random.uniform(0, 10), @@ -747,7 +773,7 @@ def test_mutable_param_with_range( np.random.uniform(-5, 2.5), np.random.uniform(2.5, 10), np.random.uniform(2.5, 10), - pe.maximize, + pyo.maximize, ), ] for a1, a2, b1, b2, c1, c2, sense in params_to_test: @@ -760,22 +786,24 @@ def test_mutable_param_with_range( m.obj.sense = sense res: Results = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) - if sense is pe.minimize: + if sense is pyo.minimize: self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2), 6) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1, 6) self.assertAlmostEqual(res.best_feasible_objective, m.y.value, 6) self.assertTrue(res.best_objective_bound <= m.y.value + 1e-12) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) - self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) + self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) else: self.assertAlmostEqual(m.x.value, (c2 - c1) / (a1 - a2), 6) self.assertAlmostEqual(m.y.value, a1 * (c2 - c1) / (a1 - a2) + c1, 6) self.assertAlmostEqual(res.best_feasible_objective, m.y.value, 6) self.assertTrue(res.best_objective_bound >= m.y.value - 1e-12) - duals = opt.get_duals() - self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) - self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) + if opt_class != MAiNGO: + duals = opt.get_duals() + self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) + self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_add_and_remove_vars( @@ -784,9 +812,9 @@ def test_add_and_remove_vars( opt = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.y = pe.Var(bounds=(-1, None)) - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.y = pyo.Var(bounds=(-1, None)) + m.obj = pyo.Objective(expr=m.y) opt.update_config.update_params = False opt.update_config.update_vars = False opt.update_config.update_constraints = False @@ -799,13 +827,13 @@ def test_add_and_remove_vars( self.assertEqual(res.termination_condition, TerminationCondition.optimal) opt.load_vars() self.assertAlmostEqual(m.y.value, -1) - m.x = pe.Var() + m.x = pyo.Var() a1 = 1 a2 = -1 b1 = 2 b2 = 1 - m.c1 = pe.Constraint(expr=(0, m.y - a1 * m.x - b1, None)) - m.c2 = pe.Constraint(expr=(None, -m.y + a2 * m.x + b2, 0)) + m.c1 = pyo.Constraint(expr=(0, m.y - a1 * m.x - b1, None)) + m.c2 = pyo.Constraint(expr=(None, -m.y + a2 * m.x + b2, 0)) if only_child_vars: opt.add_variables([m.x]) opt.add_constraints([m.c1, m.c2]) @@ -831,25 +859,25 @@ def test_exp(self, name: str, opt_class: Type[PersistentSolver], only_child_vars opt = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y >= pe.exp(m.x)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y >= pyo.exp(m.x)) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, -0.42630274815985264) - self.assertAlmostEqual(m.y.value, 0.6529186341994245) + self.assertAlmostEqual(m.x.value, -0.42630274815985264, 6) + self.assertAlmostEqual(m.y.value, 0.6529186341994245, 6) @parameterized.expand(input=_load_tests(nlp_solvers, only_child_vars_options)) def test_log(self, name: str, opt_class: Type[PersistentSolver], only_child_vars): opt = opt_class(only_child_vars=only_child_vars) - if not opt.available(): + if not opt.available() or opt_class == MAiNGO: raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(initialize=1) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y <= pe.log(m.x)) + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y <= pyo.log(m.x)) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0.6529186341994245) self.assertAlmostEqual(m.y.value, -0.42630274815985264) @@ -861,18 +889,18 @@ def test_with_numpy( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) a1 = 1 b1 = 3 a2 = -2 b2 = 1 - m.c1 = pe.Constraint( + m.c1 = pyo.Constraint( expr=(numpy.float64(0), m.y - numpy.int64(1) * m.x - numpy.float32(3), None) ) - m.c2 = pe.Constraint( + m.c2 = pyo.Constraint( expr=( None, -m.y + numpy.int32(-2) * m.x + numpy.float64(1), @@ -891,12 +919,12 @@ def test_bounds_with_params( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.y = pe.Var() - m.p = pe.Param(mutable=True) + m = pyo.ConcreteModel() + m.y = pyo.Var() + m.p = pyo.Param(mutable=True) m.y.setlb(m.p) m.p.value = 1 - m.obj = pe.Objective(expr=m.y) + m.obj = pyo.Objective(expr=m.y) res = opt.solve(m) self.assertAlmostEqual(m.y.value, 1) m.p.value = -1 @@ -904,7 +932,7 @@ def test_bounds_with_params( self.assertAlmostEqual(m.y.value, -1) m.y.setlb(None) m.y.setub(m.p) - m.obj.sense = pe.maximize + m.obj.sense = pyo.maximize m.p.value = 5 res = opt.solve(m) self.assertAlmostEqual(m.y.value, 5) @@ -913,11 +941,32 @@ def test_bounds_with_params( self.assertAlmostEqual(m.y.value, 4) m.y.setub(None) m.y.setlb(m.p) - m.obj.sense = pe.minimize + m.obj.sense = pyo.minimize m.p.value = 3 res = opt.solve(m) self.assertAlmostEqual(m.y.value, 3) + @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) + def test_bounds_with_immutable_params( + self, name: str, opt_class: Type[PersistentSolver], only_child_vars + ): + # this test is for issue #2574 + opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) + if not opt.available(): + raise unittest.SkipTest + m = pyo.ConcreteModel() + m.p = pyo.Param(mutable=False, initialize=1) + m.q = pyo.Param([1, 2], mutable=False, initialize=10) + m.y = pyo.Var() + m.y.setlb(m.p) + m.y.setub(m.q[1]) + m.obj = pyo.Objective(expr=m.y) + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, 1) + m.y.setlb(m.q[2]) + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, 10) + @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_solution_loader( self, name: str, opt_class: Type[PersistentSolver], only_child_vars @@ -925,12 +974,12 @@ def test_solution_loader( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(1, None)) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=(0, m.y - m.x, None)) - m.c2 = pe.Constraint(expr=(0, m.y - m.x + 1, None)) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(1, None)) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=(0, m.y - m.x, None)) + m.c2 = pyo.Constraint(expr=(0, m.y - m.x + 1, None)) opt.config.load_solution = False res = opt.solve(m) self.assertIsNone(m.x.value) @@ -952,31 +1001,32 @@ def test_solution_loader( self.assertNotIn(m.x, primals) self.assertIn(m.y, primals) self.assertAlmostEqual(primals[m.y], 1) - reduced_costs = res.solution_loader.get_reduced_costs() - self.assertIn(m.x, reduced_costs) - self.assertIn(m.y, reduced_costs) - self.assertAlmostEqual(reduced_costs[m.x], 1) - self.assertAlmostEqual(reduced_costs[m.y], 0) - reduced_costs = res.solution_loader.get_reduced_costs([m.y]) - self.assertNotIn(m.x, reduced_costs) - self.assertIn(m.y, reduced_costs) - self.assertAlmostEqual(reduced_costs[m.y], 0) - duals = res.solution_loader.get_duals() - self.assertIn(m.c1, duals) - self.assertIn(m.c2, duals) - self.assertAlmostEqual(duals[m.c1], 1) - self.assertAlmostEqual(duals[m.c2], 0) - duals = res.solution_loader.get_duals([m.c1]) - self.assertNotIn(m.c2, duals) - self.assertIn(m.c1, duals) - self.assertAlmostEqual(duals[m.c1], 1) + if opt_class != MAiNGO: + reduced_costs = res.solution_loader.get_reduced_costs() + self.assertIn(m.x, reduced_costs) + self.assertIn(m.y, reduced_costs) + self.assertAlmostEqual(reduced_costs[m.x], 1) + self.assertAlmostEqual(reduced_costs[m.y], 0) + reduced_costs = res.solution_loader.get_reduced_costs([m.y]) + self.assertNotIn(m.x, reduced_costs) + self.assertIn(m.y, reduced_costs) + self.assertAlmostEqual(reduced_costs[m.y], 0) + duals = res.solution_loader.get_duals() + self.assertIn(m.c1, duals) + self.assertIn(m.c2, duals) + self.assertAlmostEqual(duals[m.c1], 1) + self.assertAlmostEqual(duals[m.c2], 0) + duals = res.solution_loader.get_duals([m.c1]) + self.assertNotIn(m.c2, duals) + self.assertIn(m.c1, duals) + self.assertAlmostEqual(duals[m.c1], 1) @parameterized.expand(input=_load_tests(all_solvers, only_child_vars_options)) def test_time_limit( self, name: str, opt_class: Type[PersistentSolver], only_child_vars ): opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) - if not opt.available(): + if not opt.available() or opt_class == MAiNGO: raise unittest.SkipTest from sys import platform @@ -984,10 +1034,10 @@ def test_time_limit( raise unittest.SkipTest N = 30 - m = pe.ConcreteModel() - m.jobs = pe.Set(initialize=list(range(N))) - m.tasks = pe.Set(initialize=list(range(N))) - m.x = pe.Var(m.jobs, m.tasks, bounds=(0, 1)) + m = pyo.ConcreteModel() + m.jobs = pyo.Set(initialize=list(range(N))) + m.tasks = pyo.Set(initialize=list(range(N))) + m.x = pyo.Var(m.jobs, m.tasks, bounds=(0, 1)) random.seed(0) coefs = list() @@ -999,10 +1049,10 @@ def test_time_limit( obj_expr = LinearExpression( linear_coefs=coefs, linear_vars=lin_vars, constant=0 ) - m.obj = pe.Objective(expr=obj_expr, sense=pe.maximize) + m.obj = pyo.Objective(expr=obj_expr, sense=pyo.maximize) - m.c1 = pe.Constraint(m.jobs) - m.c2 = pe.Constraint(m.tasks) + m.c1 = pyo.Constraint(m.jobs) + m.c2 = pyo.Constraint(m.tasks) for j in m.jobs: expr = LinearExpression( linear_coefs=[1] * N, @@ -1040,33 +1090,34 @@ def test_objective_changes( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c1 = pe.Constraint(expr=m.y >= m.x + 1) - m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.y >= m.x + 1) + m.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) + m.obj = pyo.Objective(expr=m.y) res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 1) - m.obj = pe.Objective(expr=2 * m.y) + m.obj = pyo.Objective(expr=2 * m.y) res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 2) m.obj.expr = 3 * m.y res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 3) - m.obj.sense = pe.maximize + m.obj.sense = pyo.maximize opt.config.load_solution = False res = opt.solve(m) - self.assertIn( - res.termination_condition, - { - TerminationCondition.unbounded, - TerminationCondition.infeasibleOrUnbounded, - }, - ) - m.obj.sense = pe.minimize + if opt_class != MAiNGO: + self.assertIn( + res.termination_condition, + { + TerminationCondition.unbounded, + TerminationCondition.infeasibleOrUnbounded, + }, + ) + m.obj.sense = pyo.minimize opt.config.load_solution = True - m.obj = pe.Objective(expr=m.x * m.y) + m.obj = pyo.Objective(expr=m.x * m.y) m.x.fix(2) res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 6, 6) @@ -1082,8 +1133,8 @@ def test_objective_changes( m.y.unfix() m.x.setlb(None) m.x.setub(None) - m.e = pe.Expression(expr=2) - m.obj = pe.Objective(expr=m.e * m.y) + m.e = pyo.Expression(expr=2) + m.obj = pyo.Objective(expr=m.e * m.y) res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 2) m.e.expr = 3 @@ -1101,9 +1152,9 @@ def test_domain( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(1, None), domain=pe.NonNegativeReals) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(1, None), domain=pyo.NonNegativeReals) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 1) m.x.setlb(-1) @@ -1113,10 +1164,10 @@ def test_domain( res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 1) m.x.setlb(-1) - m.x.domain = pe.Reals + m.x.domain = pyo.Reals res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, -1) - m.x.domain = pe.NonNegativeReals + m.x.domain = pyo.NonNegativeReals res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 0) @@ -1127,19 +1178,19 @@ def test_domain_with_integers( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-1, None), domain=pe.NonNegativeIntegers) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, None), domain=pyo.NonNegativeIntegers) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 0) m.x.setlb(0.5) res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 1) m.x.setlb(-5.5) - m.x.domain = pe.Integers + m.x.domain = pyo.Integers res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, -5) - m.x.domain = pe.Binary + m.x.domain = pyo.Binary res = opt.solve(m) self.assertAlmostEqual(res.best_feasible_objective, 0) m.x.setlb(0.5) @@ -1153,26 +1204,26 @@ def test_fixed_binaries( opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(domain=pe.Binary) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c = pe.Constraint(expr=m.y >= m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(domain=pyo.Binary) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c = pyo.Constraint(expr=m.y >= m.x) m.x.fix(0) res = opt.solve(m) - self.assertAlmostEqual(res.best_feasible_objective, 0) + self.assertAlmostEqual(res.best_feasible_objective, 0, 5) m.x.fix(1) res = opt.solve(m) - self.assertAlmostEqual(res.best_feasible_objective, 1) + self.assertAlmostEqual(res.best_feasible_objective, 1, 5) opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) opt.update_config.treat_fixed_vars_as_params = False m.x.fix(0) res = opt.solve(m) - self.assertAlmostEqual(res.best_feasible_objective, 0) + self.assertAlmostEqual(res.best_feasible_objective, 0, 5) m.x.fix(1) res = opt.solve(m) - self.assertAlmostEqual(res.best_feasible_objective, 1) + self.assertAlmostEqual(res.best_feasible_objective, 1, 5) @parameterized.expand(input=_load_tests(mip_solvers, only_child_vars_options)) def test_with_gdp( @@ -1182,30 +1233,30 @@ def test_with_gdp( if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-10, 10)) - m.y = pe.Var(bounds=(-10, 10)) - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var(bounds=(-10, 10)) + m.obj = pyo.Objective(expr=m.y) m.d1 = gdp.Disjunct() - m.d1.c1 = pe.Constraint(expr=m.y >= m.x + 2) - m.d1.c2 = pe.Constraint(expr=m.y >= -m.x + 2) + m.d1.c1 = pyo.Constraint(expr=m.y >= m.x + 2) + m.d1.c2 = pyo.Constraint(expr=m.y >= -m.x + 2) m.d2 = gdp.Disjunct() - m.d2.c1 = pe.Constraint(expr=m.y >= m.x + 1) - m.d2.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + m.d2.c1 = pyo.Constraint(expr=m.y >= m.x + 1) + m.d2.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) m.disjunction = gdp.Disjunction(expr=[m.d2, m.d1]) - pe.TransformationFactory("gdp.bigm").apply_to(m) + pyo.TransformationFactory("gdp.bigm").apply_to(m) res = opt.solve(m) - self.assertAlmostEqual(res.best_feasible_objective, 1) - self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1) + self.assertAlmostEqual(res.best_feasible_objective, 1, 6) + self.assertAlmostEqual(m.x.value, 0, 6) + self.assertAlmostEqual(m.y.value, 1, 6) opt: PersistentSolver = opt_class(only_child_vars=only_child_vars) opt.use_extensions = True res = opt.solve(m) - self.assertAlmostEqual(res.best_feasible_objective, 1) - self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1) + self.assertAlmostEqual(res.best_feasible_objective, 1, 6) + self.assertAlmostEqual(m.x.value, 0, 6) + self.assertAlmostEqual(m.y.value, 1, 6) @parameterized.expand(input=all_solvers) def test_variables_elsewhere(self, name: str, opt_class: Type[PersistentSolver]): @@ -1213,13 +1264,13 @@ def test_variables_elsewhere(self, name: str, opt_class: Type[PersistentSolver]) if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.b = pe.Block() - m.b.obj = pe.Objective(expr=m.y) - m.b.c1 = pe.Constraint(expr=m.y >= m.x + 2) - m.b.c2 = pe.Constraint(expr=m.y >= -m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.b = pyo.Block() + m.b.obj = pyo.Objective(expr=m.y) + m.b.c1 = pyo.Constraint(expr=m.y >= m.x + 2) + m.b.c2 = pyo.Constraint(expr=m.y >= -m.x) res = opt.solve(m.b) self.assertEqual(res.termination_condition, TerminationCondition.optimal) @@ -1240,16 +1291,16 @@ def test_variables_elsewhere2(self, name: str, opt_class: Type[PersistentSolver] if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= m.x) - m.c2 = pe.Constraint(expr=m.y >= -m.x) - m.c3 = pe.Constraint(expr=m.y >= m.z + 1) - m.c4 = pe.Constraint(expr=m.y >= -m.z + 1) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y >= -m.x) + m.c3 = pyo.Constraint(expr=m.y >= m.z + 1) + m.c4 = pyo.Constraint(expr=m.y >= -m.z + 1) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) @@ -1275,13 +1326,13 @@ def test_bug_1(self, name: str, opt_class: Type[PersistentSolver], only_child_va if not opt.available(): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(3, 7)) - m.y = pe.Var(bounds=(-10, 10)) - m.p = pe.Param(mutable=True, initialize=0) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(3, 7)) + m.y = pyo.Var(bounds=(-10, 10)) + m.p = pyo.Param(mutable=True, initialize=0) - m.obj = pe.Objective(expr=m.y) - m.c = pe.Constraint(expr=m.y >= m.p * m.x) + m.obj = pyo.Objective(expr=m.y) + m.c = pyo.Constraint(expr=m.y >= m.p * m.x) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) @@ -1304,11 +1355,11 @@ def test_bug_2(self, name: str, opt_class: Type[PersistentSolver], only_child_va raise unittest.SkipTest opt.update_config.treat_fixed_vars_as_params = fixed_var_option - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-10, 10)) - m.y = pe.Var() - m.obj = pe.Objective(expr=3 * m.y - m.x) - m.c = pe.Constraint(expr=m.y >= m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=3 * m.y - m.x) + m.c = pyo.Constraint(expr=m.y >= m.x) m.x.fix(1) res = opt.solve(m) @@ -1325,20 +1376,21 @@ def test_bug_2(self, name: str, opt_class: Type[PersistentSolver], only_child_va class TestLegacySolverInterface(unittest.TestCase): @parameterized.expand(input=all_solvers) def test_param_updates(self, name: str, opt_class: Type[PersistentSolver]): - opt = pe.SolverFactory('appsi_' + name) + opt = pyo.SolverFactory('appsi_' + name) if not opt.available(exception_flag=False): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) - m.c2 = pe.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) - m.dual = pe.Suffix(direction=pe.Suffix.IMPORT) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) + m.c2 = pyo.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) + if opt_class != MAiNGO: + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -1347,26 +1399,30 @@ def test_param_updates(self, name: str, opt_class: Type[PersistentSolver]): m.b1.value = b1 m.b2.value = b2 res = opt.solve(m) - pe.assert_optimal_termination(res) + pyo.assert_optimal_termination(res) self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) - self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(m.dual[m.c2], a1 / (a2 - a1)) + if opt_class != MAiNGO: + self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(m.dual[m.c2], a1 / (a2 - a1)) @parameterized.expand(input=all_solvers) def test_load_solutions(self, name: str, opt_class: Type[PersistentSolver]): - opt = pe.SolverFactory('appsi_' + name) + opt = pyo.SolverFactory('appsi_' + name) if not opt.available(exception_flag=False): raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.obj = pe.Objective(expr=m.x) - m.c = pe.Constraint(expr=(-1, m.x, 1)) - m.dual = pe.Suffix(direction=pe.Suffix.IMPORT) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.obj = pyo.Objective(expr=m.x) + m.c = pyo.Constraint(expr=(-1, m.x, 1)) + if opt_class != MAiNGO: + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) res = opt.solve(m, load_solutions=False) - pe.assert_optimal_termination(res) + pyo.assert_optimal_termination(res) self.assertIsNone(m.x.value) - self.assertNotIn(m.c, m.dual) + if opt_class != MAiNGO: + self.assertNotIn(m.c, m.dual) m.solutions.load_from(res) self.assertAlmostEqual(m.x.value, -1) - self.assertAlmostEqual(m.dual[m.c], 1) + if opt_class != MAiNGO: + self.assertAlmostEqual(m.dual[m.c], 1) diff --git a/pyomo/contrib/appsi/solvers/tests/test_wntr_persistent.py b/pyomo/contrib/appsi/solvers/tests/test_wntr_persistent.py index 6fb25bfb529..33b78062fa7 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_wntr_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_wntr_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.environ as pe +import pyomo.environ as pyo import pyomo.common.unittest as unittest from pyomo.contrib.appsi.base import TerminationCondition, Results, PersistentSolver from pyomo.contrib.appsi.solvers.wntr import Wntr, wntr_available @@ -22,10 +22,10 @@ @unittest.skipUnless(wntr_available, 'wntr is not available') class TestWntrPersistent(unittest.TestCase): def test_param_updates(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.p = pe.Param(initialize=1, mutable=True) - m.c = pe.Constraint(expr=m.x == m.p) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.p = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Constraint(expr=m.x == m.p) opt = Wntr() opt.wntr_options.update(_default_wntr_options) res = opt.solve(m) @@ -38,11 +38,11 @@ def test_param_updates(self): self.assertAlmostEqual(m.x.value, 2) def test_remove_add_constraint(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c1 = pe.Constraint(expr=m.y == (m.x - 1) ** 2) - m.c2 = pe.Constraint(expr=m.y == pe.exp(m.x)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.y == (m.x - 1) ** 2) + m.c2 = pyo.Constraint(expr=m.y == pyo.exp(m.x)) opt = Wntr() opt.config.symbolic_solver_labels = True opt.wntr_options.update(_default_wntr_options) @@ -52,7 +52,7 @@ def test_remove_add_constraint(self): self.assertAlmostEqual(m.y.value, 1) del m.c2 - m.c2 = pe.Constraint(expr=m.y == pe.log(m.x)) + m.c2 = pyo.Constraint(expr=m.y == pyo.log(m.x)) m.x.value = 0.5 m.y.value = 0.5 res = opt.solve(m) @@ -61,10 +61,10 @@ def test_remove_add_constraint(self): self.assertAlmostEqual(m.y.value, 0) def test_fixed_var(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c1 = pe.Constraint(expr=m.y == (m.x - 1) ** 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.y == (m.x - 1) ** 2) m.x.fix(0.5) opt = Wntr() opt.wntr_options.update(_default_wntr_options) @@ -74,7 +74,7 @@ def test_fixed_var(self): self.assertAlmostEqual(m.y.value, 0.25) m.x.unfix() - m.c2 = pe.Constraint(expr=m.y == pe.exp(m.x)) + m.c2 = pyo.Constraint(expr=m.y == pyo.exp(m.x)) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 0) @@ -88,15 +88,15 @@ def test_fixed_var(self): self.assertAlmostEqual(m.y.value, 0.25) def test_remove_variables_params(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() m.z.fix(0) - m.px = pe.Param(mutable=True, initialize=1) - m.py = pe.Param(mutable=True, initialize=1) - m.c1 = pe.Constraint(expr=m.x == m.px) - m.c2 = pe.Constraint(expr=m.y == m.py) + m.px = pyo.Param(mutable=True, initialize=1) + m.py = pyo.Param(mutable=True, initialize=1) + m.c1 = pyo.Constraint(expr=m.x == m.px) + m.c2 = pyo.Constraint(expr=m.y == m.py) opt = Wntr() opt.wntr_options.update(_default_wntr_options) res = opt.solve(m) @@ -122,11 +122,11 @@ def test_remove_variables_params(self): self.assertAlmostEqual(m.x.value, 3) def test_get_primals(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c1 = pe.Constraint(expr=m.y == (m.x - 1) ** 2) - m.c2 = pe.Constraint(expr=m.y == pe.exp(m.x)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.y == (m.x - 1) ** 2) + m.c2 = pyo.Constraint(expr=m.y == pyo.exp(m.x)) opt = Wntr() opt.config.load_solution = False opt.wntr_options.update(_default_wntr_options) @@ -139,9 +139,9 @@ def test_get_primals(self): self.assertAlmostEqual(primals[m.y], 1) def test_operators(self): - m = pe.ConcreteModel() - m.x = pe.Var(initialize=1) - m.c1 = pe.Constraint(expr=2 / m.x == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1) + m.c1 = pyo.Constraint(expr=2 / m.x == 1) opt = Wntr() opt.wntr_options.update(_default_wntr_options) res = opt.solve(m) @@ -150,44 +150,44 @@ def test_operators(self): del m.c1 m.x.value = 0 - m.c1 = pe.Constraint(expr=pe.sin(m.x) == math.sin(math.pi / 4)) + m.c1 = pyo.Constraint(expr=pyo.sin(m.x) == math.sin(math.pi / 4)) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, math.pi / 4) del m.c1 - m.c1 = pe.Constraint(expr=pe.cos(m.x) == 0) + m.c1 = pyo.Constraint(expr=pyo.cos(m.x) == 0) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, math.pi / 2) del m.c1 - m.c1 = pe.Constraint(expr=pe.tan(m.x) == 1) + m.c1 = pyo.Constraint(expr=pyo.tan(m.x) == 1) m.x.value = 0 res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, math.pi / 4) del m.c1 - m.c1 = pe.Constraint(expr=pe.asin(m.x) == math.asin(0.5)) + m.c1 = pyo.Constraint(expr=pyo.asin(m.x) == math.asin(0.5)) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 0.5) del m.c1 - m.c1 = pe.Constraint(expr=pe.acos(m.x) == math.acos(0.6)) + m.c1 = pyo.Constraint(expr=pyo.acos(m.x) == math.acos(0.6)) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 0.6) del m.c1 - m.c1 = pe.Constraint(expr=pe.atan(m.x) == math.atan(0.5)) + m.c1 = pyo.Constraint(expr=pyo.atan(m.x) == math.atan(0.5)) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 0.5) del m.c1 - m.c1 = pe.Constraint(expr=pe.sqrt(m.x) == math.sqrt(0.6)) + m.c1 = pyo.Constraint(expr=pyo.sqrt(m.x) == math.sqrt(0.6)) res = opt.solve(m) self.assertEqual(res.termination_condition, TerminationCondition.optimal) self.assertAlmostEqual(m.x.value, 0.6) diff --git a/pyomo/contrib/appsi/solvers/wntr.py b/pyomo/contrib/appsi/solvers/wntr.py index 00c0598c687..e5c9232eb30 100644 --- a/pyomo/contrib/appsi/solvers/wntr.py +++ b/pyomo/contrib/appsi/solvers/wntr.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -39,10 +39,10 @@ from pyomo.common.collections import ComponentMap from pyomo.core.expr.numvalue import native_numeric_types from typing import Dict, Optional, List -from pyomo.core.base.block import _BlockData -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.param import _ParamData -from pyomo.core.base.constraint import _GeneralConstraintData +from pyomo.core.base.block import BlockData +from pyomo.core.base.var import VarData +from pyomo.core.base.param import ParamData +from pyomo.core.base.constraint import ConstraintData from pyomo.common.timing import HierarchicalTimer from pyomo.core.base import SymbolMap, NumericLabeler, TextLabeler from pyomo.common.dependencies import attempt_import @@ -178,7 +178,7 @@ def _solve(self, timer: HierarchicalTimer): ) return results - def solve(self, model: _BlockData, timer: HierarchicalTimer = None) -> Results: + def solve(self, model: BlockData, timer: HierarchicalTimer = None) -> Results: StaleFlagManager.mark_all_as_stale() if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -239,7 +239,7 @@ def set_instance(self, model): self.add_block(model) - def _add_variables(self, variables: List[_GeneralVarData]): + def _add_variables(self, variables: List[VarData]): aml = wntr.sim.aml.aml for var in variables: varname = self._symbol_map.getSymbol(var, self._labeler) @@ -270,7 +270,7 @@ def _add_variables(self, variables: List[_GeneralVarData]): ) self._needs_updated = True - def _add_params(self, params: List[_ParamData]): + def _add_params(self, params: List[ParamData]): aml = wntr.sim.aml.aml for p in params: pname = self._symbol_map.getSymbol(p, self._labeler) @@ -278,7 +278,7 @@ def _add_params(self, params: List[_ParamData]): setattr(self._solver_model, pname, wntr_p) self._pyomo_param_to_solver_param_map[id(p)] = wntr_p - def _add_constraints(self, cons: List[_GeneralConstraintData]): + def _add_constraints(self, cons: List[ConstraintData]): aml = wntr.sim.aml.aml for con in cons: if not con.equality: @@ -294,7 +294,7 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): self._pyomo_con_to_solver_con_map[con] = wntr_con self._needs_updated = True - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): for con in cons: solver_con = self._pyomo_con_to_solver_con_map[con] delattr(self._solver_model, solver_con.name) @@ -302,7 +302,7 @@ def _remove_constraints(self, cons: List[_GeneralConstraintData]): del self._pyomo_con_to_solver_con_map[con] self._needs_updated = True - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): for var in variables: v_id = id(var) solver_var = self._pyomo_var_to_solver_var_map[v_id] @@ -314,7 +314,7 @@ def _remove_variables(self, variables: List[_GeneralVarData]): del self._solver_model._wntr_fixed_var_cons[v_id] self._needs_updated = True - def _remove_params(self, params: List[_ParamData]): + def _remove_params(self, params: List[ParamData]): for p in params: p_id = id(p) solver_param = self._pyomo_param_to_solver_param_map[p_id] @@ -322,7 +322,7 @@ def _remove_params(self, params: List[_ParamData]): self._symbol_map.removeSymbol(p) del self._pyomo_param_to_solver_param_map[p_id] - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): aml = wntr.sim.aml.aml for var in variables: v_id = id(var) diff --git a/pyomo/contrib/appsi/tests/__init__.py b/pyomo/contrib/appsi/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/appsi/tests/__init__.py +++ b/pyomo/contrib/appsi/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/tests/test_base.py b/pyomo/contrib/appsi/tests/test_base.py index e537cc0f219..2b2a4055e5a 100644 --- a/pyomo/contrib/appsi/tests/test_base.py +++ b/pyomo/contrib/appsi/tests/test_base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ from pyomo.common import unittest from pyomo.contrib import appsi -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.core.base.var import ScalarVar @@ -42,11 +42,11 @@ def test_uninitialized(self): res.solution_loader.get_slacks() def test_results(self): - m = pe.ConcreteModel() + m = pyo.ConcreteModel() m.x = ScalarVar() m.y = ScalarVar() - m.c1 = pe.Constraint(expr=m.x == 1) - m.c2 = pe.Constraint(expr=m.y == 2) + m.c1 = pyo.Constraint(expr=m.x == 1) + m.c2 = pyo.Constraint(expr=m.y == 2) primals = dict() primals[id(m.x)] = (m.x, 1) diff --git a/pyomo/contrib/appsi/tests/test_fbbt.py b/pyomo/contrib/appsi/tests/test_fbbt.py index a3f520e7bd6..d70e5bd4147 100644 --- a/pyomo/contrib/appsi/tests/test_fbbt.py +++ b/pyomo/contrib/appsi/tests/test_fbbt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -151,3 +151,16 @@ def test_named_exprs(self): for x in m.x.values(): self.assertAlmostEqual(x.lb, 0) self.assertAlmostEqual(x.ub, 0) + + def test_named_exprs_nest(self): + # test for issue #3184 + m = pe.ConcreteModel() + m.x = pe.Var() + m.e = pe.Expression(expr=m.x + 1) + m.f = pe.Expression(expr=m.e) + m.c = pe.Constraint(expr=(0, m.f, 0)) + it = appsi.fbbt.IntervalTightener() + it.perform_fbbt(m) + for x in m.x.values(): + self.assertAlmostEqual(x.lb, -1) + self.assertAlmostEqual(x.ub, -1) diff --git a/pyomo/contrib/appsi/tests/test_interval.py b/pyomo/contrib/appsi/tests/test_interval.py index 2184f69621a..87c21838ebf 100644 --- a/pyomo/contrib/appsi/tests/test_interval.py +++ b/pyomo/contrib/appsi/tests/test_interval.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/tests/test_ipopt.py b/pyomo/contrib/appsi/tests/test_ipopt.py new file mode 100644 index 00000000000..6ce20ac5f56 --- /dev/null +++ b/pyomo/contrib/appsi/tests/test_ipopt.py @@ -0,0 +1,42 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common import unittest +from pyomo.contrib.appsi.solvers import ipopt + + +ipopt_available = ipopt.Ipopt().available() + + +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestIpoptInterface(unittest.TestCase): + def test_has_linear_solver(self): + opt = ipopt.Ipopt() + self.assertTrue( + any( + map( + opt.has_linear_solver, + [ + 'mumps', + 'ma27', + 'ma57', + 'ma77', + 'ma86', + 'ma97', + 'pardiso', + 'pardisomkl', + 'spral', + 'wsmp', + ], + ) + ) + ) + self.assertFalse(opt.has_linear_solver('bogus_linear_solver')) diff --git a/pyomo/contrib/appsi/utils/__init__.py b/pyomo/contrib/appsi/utils/__init__.py index e1278431835..b09d8153d56 100644 --- a/pyomo/contrib/appsi/utils/__init__.py +++ b/pyomo/contrib/appsi/utils/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/utils/collect_vars_and_named_exprs.py b/pyomo/contrib/appsi/utils/collect_vars_and_named_exprs.py index 4e117b04094..4ad2cdf4a3a 100644 --- a/pyomo/contrib/appsi/utils/collect_vars_and_named_exprs.py +++ b/pyomo/contrib/appsi/utils/collect_vars_and_named_exprs.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/utils/get_objective.py b/pyomo/contrib/appsi/utils/get_objective.py index 110c0188d16..bd51bb848e7 100644 --- a/pyomo/contrib/appsi/utils/get_objective.py +++ b/pyomo/contrib/appsi/utils/get_objective.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/utils/tests/__init__.py b/pyomo/contrib/appsi/utils/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/appsi/utils/tests/__init__.py +++ b/pyomo/contrib/appsi/utils/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/utils/tests/test_collect_vars_and_named_exprs.py b/pyomo/contrib/appsi/utils/tests/test_collect_vars_and_named_exprs.py index 62f98728850..365d45a7085 100644 --- a/pyomo/contrib/appsi/utils/tests/test_collect_vars_and_named_exprs.py +++ b/pyomo/contrib/appsi/utils/tests/test_collect_vars_and_named_exprs.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ from pyomo.common import unittest -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.contrib.appsi.utils import collect_vars_and_named_exprs from pyomo.contrib.appsi.cmodel import cmodel, cmodel_available from typing import Callable @@ -19,11 +19,11 @@ class TestCollectVarsAndNamedExpressions(unittest.TestCase): def basics_helper(self, collector: Callable, *args): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.E = pe.Expression(expr=2 * m.z + 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.E = pyo.Expression(expr=2 * m.z + 1) m.y.fix(3) e = m.x * m.y + m.x * m.E named_exprs, var_list, fixed_vars, external_funcs = collector(e, *args) @@ -44,13 +44,13 @@ def external_func_helper(self, collector: Callable, *args): if not DLL: self.skipTest('Could not find amplgsl.dll library') - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.hypot = pe.ExternalFunction(library=DLL, function='gsl_hypot') + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.hypot = pyo.ExternalFunction(library=DLL, function='gsl_hypot') func = m.hypot(m.x, m.x * m.y) - m.E = pe.Expression(expr=2 * func) + m.E = pyo.Expression(expr=2 * func) m.y.fix(3) e = m.z + m.x * m.E named_exprs, var_list, fixed_vars, external_funcs = collector(e, *args) diff --git a/pyomo/contrib/appsi/writers/__init__.py b/pyomo/contrib/appsi/writers/__init__.py index 18f90e8aa96..66ce94614ef 100644 --- a/pyomo/contrib/appsi/writers/__init__.py +++ b/pyomo/contrib/appsi/writers/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/writers/config.py b/pyomo/contrib/appsi/writers/config.py index 32d45325e96..8d7d3ffe6b4 100644 --- a/pyomo/contrib/appsi/writers/config.py +++ b/pyomo/contrib/appsi/writers/config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/writers/lp_writer.py b/pyomo/contrib/appsi/writers/lp_writer.py index 9984cb7465d..85292c3623d 100644 --- a/pyomo/contrib/appsi/writers/lp_writer.py +++ b/pyomo/contrib/appsi/writers/lp_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,12 +10,12 @@ # ___________________________________________________________________________ from typing import List -from pyomo.core.base.param import _ParamData -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.objective import _GeneralObjectiveData -from pyomo.core.base.sos import _SOSConstraintData -from pyomo.core.base.block import _BlockData +from pyomo.core.base.param import ParamData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.base.sos import SOSConstraintData +from pyomo.core.base.block import BlockData from pyomo.repn.standard_repn import generate_standard_repn from pyomo.core.expr.numvalue import value from pyomo.contrib.appsi.base import PersistentBase @@ -77,7 +77,7 @@ def set_instance(self, model): if self._objective is None: self.set_objective(None) - def _add_variables(self, variables: List[_GeneralVarData]): + def _add_variables(self, variables: List[VarData]): cmodel.process_pyomo_vars( self._expr_types, variables, @@ -91,7 +91,7 @@ def _add_variables(self, variables: List[_GeneralVarData]): False, ) - def _add_params(self, params: List[_ParamData]): + def _add_params(self, params: List[ParamData]): cparams = cmodel.create_params(len(params)) for ndx, p in enumerate(params): cp = cparams[ndx] @@ -99,36 +99,36 @@ def _add_params(self, params: List[_ParamData]): cp.value = p.value self._pyomo_param_to_solver_param_map[id(p)] = cp - def _add_constraints(self, cons: List[_GeneralConstraintData]): + def _add_constraints(self, cons: List[ConstraintData]): cmodel.process_lp_constraints(cons, self) - def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + def _add_sos_constraints(self, cons: List[SOSConstraintData]): if len(cons) != 0: raise NotImplementedError('LP writer does not yet support SOS constraints') - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): for c in cons: cc = self._pyomo_con_to_solver_con_map.pop(c) self._writer.remove_constraint(cc) self._symbol_map.removeSymbol(c) del self._solver_con_to_pyomo_con_map[cc] - def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): if len(cons) != 0: raise NotImplementedError('LP writer does not yet support SOS constraints') - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): for v in variables: cvar = self._pyomo_var_to_solver_var_map.pop(id(v)) del self._solver_var_to_pyomo_var_map[cvar] self._symbol_map.removeSymbol(v) - def _remove_params(self, params: List[_ParamData]): + def _remove_params(self, params: List[ParamData]): for p in params: del self._pyomo_param_to_solver_param_map[id(p)] self._symbol_map.removeSymbol(p) - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): cmodel.process_pyomo_vars( self._expr_types, variables, @@ -147,7 +147,7 @@ def update_params(self): cp = self._pyomo_param_to_solver_param_map[p_id] cp.value = p.value - def _set_objective(self, obj: _GeneralObjectiveData): + def _set_objective(self, obj: ObjectiveData): cobj = cmodel.process_lp_objective( self._expr_types, obj, @@ -167,7 +167,7 @@ def _set_objective(self, obj: _GeneralObjectiveData): cobj.name = cname self._writer.objective = cobj - def write(self, model: _BlockData, filename: str, timer: HierarchicalTimer = None): + def write(self, model: BlockData, filename: str, timer: HierarchicalTimer = None): if timer is None: timer = HierarchicalTimer() if model is not self._model: diff --git a/pyomo/contrib/appsi/writers/nl_writer.py b/pyomo/contrib/appsi/writers/nl_writer.py index bd24a86216a..f4230d6ab2a 100644 --- a/pyomo/contrib/appsi/writers/nl_writer.py +++ b/pyomo/contrib/appsi/writers/nl_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,12 +10,12 @@ # ___________________________________________________________________________ from typing import List -from pyomo.core.base.param import _ParamData -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.objective import _GeneralObjectiveData -from pyomo.core.base.sos import _SOSConstraintData -from pyomo.core.base.block import _BlockData +from pyomo.core.base.param import ParamData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.base.sos import SOSConstraintData +from pyomo.core.base.block import BlockData from pyomo.repn.standard_repn import generate_standard_repn from pyomo.core.expr.numvalue import value from pyomo.contrib.appsi.base import PersistentBase @@ -78,7 +78,7 @@ def set_instance(self, model): self.set_objective(None) self._set_pyomo_amplfunc_env() - def _add_variables(self, variables: List[_GeneralVarData]): + def _add_variables(self, variables: List[VarData]): if self.config.symbolic_solver_labels: set_name = True symbol_map = self._symbol_map @@ -100,7 +100,7 @@ def _add_variables(self, variables: List[_GeneralVarData]): False, ) - def _add_params(self, params: List[_ParamData]): + def _add_params(self, params: List[ParamData]): cparams = cmodel.create_params(len(params)) for ndx, p in enumerate(params): cp = cparams[ndx] @@ -111,7 +111,7 @@ def _add_params(self, params: List[_ParamData]): cp = cparams[ndx] cp.name = self._symbol_map.getSymbol(p, self._param_labeler) - def _add_constraints(self, cons: List[_GeneralConstraintData]): + def _add_constraints(self, cons: List[ConstraintData]): cmodel.process_nl_constraints( self._writer, self._expr_types, @@ -126,11 +126,11 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): for c, cc in self._pyomo_con_to_solver_con_map.items(): cc.name = self._symbol_map.getSymbol(c, self._con_labeler) - def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + def _add_sos_constraints(self, cons: List[SOSConstraintData]): if len(cons) != 0: raise NotImplementedError('NL writer does not support SOS constraints') - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): if self.config.symbolic_solver_labels: for c in cons: self._symbol_map.removeSymbol(c) @@ -140,11 +140,11 @@ def _remove_constraints(self, cons: List[_GeneralConstraintData]): self._writer.remove_constraint(cc) del self._solver_con_to_pyomo_con_map[cc] - def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): if len(cons) != 0: raise NotImplementedError('NL writer does not support SOS constraints') - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): if self.config.symbolic_solver_labels: for v in variables: self._symbol_map.removeSymbol(v) @@ -153,7 +153,7 @@ def _remove_variables(self, variables: List[_GeneralVarData]): cvar = self._pyomo_var_to_solver_var_map.pop(id(v)) del self._solver_var_to_pyomo_var_map[cvar] - def _remove_params(self, params: List[_ParamData]): + def _remove_params(self, params: List[ParamData]): if self.config.symbolic_solver_labels: for p in params: self._symbol_map.removeSymbol(p) @@ -161,7 +161,7 @@ def _remove_params(self, params: List[_ParamData]): for p in params: del self._pyomo_param_to_solver_param_map[id(p)] - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): cmodel.process_pyomo_vars( self._expr_types, variables, @@ -180,7 +180,7 @@ def update_params(self): cp = self._pyomo_param_to_solver_param_map[p_id] cp.value = p.value - def _set_objective(self, obj: _GeneralObjectiveData): + def _set_objective(self, obj: ObjectiveData): if obj is None: const = cmodel.Constant(0) lin_vars = list() @@ -232,7 +232,7 @@ def _set_objective(self, obj: _GeneralObjectiveData): cobj.sense = sense self._writer.objective = cobj - def write(self, model: _BlockData, filename: str, timer: HierarchicalTimer = None): + def write(self, model: BlockData, filename: str, timer: HierarchicalTimer = None): if timer is None: timer = HierarchicalTimer() if model is not self._model: diff --git a/pyomo/contrib/appsi/writers/tests/__init__.py b/pyomo/contrib/appsi/writers/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/appsi/writers/tests/__init__.py +++ b/pyomo/contrib/appsi/writers/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/appsi/writers/tests/test_nl_writer.py b/pyomo/contrib/appsi/writers/tests/test_nl_writer.py index c6005afceb2..2e535bc1787 100644 --- a/pyomo/contrib/appsi/writers/tests/test_nl_writer.py +++ b/pyomo/contrib/appsi/writers/tests/test_nl_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ import pyomo.common.unittest as unittest from pyomo.common.tempfiles import TempfileManager -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.contrib import appsi from pyomo.contrib.appsi.cmodel import cmodel_available import os @@ -20,12 +20,12 @@ @unittest.skipUnless(cmodel_available, 'appsi extensions are not available') class TestNLWriter(unittest.TestCase): def test_all_vars_fixed(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y >= pe.exp(m.x)) - m.c2 = pe.Constraint(expr=m.y >= (m.x - 1) ** 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y >= pyo.exp(m.x)) + m.c2 = pyo.Constraint(expr=m.y >= (m.x - 1) ** 2) m.x.fix(1) m.y.fix(2) writer = appsi.writers.NLWriter() @@ -46,11 +46,11 @@ def _write_and_check_header(self, m, correct_lines): self.assertTrue(line.startswith(correct_lines[ndx])) def test_header_1(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y) - m.c = pe.Constraint(expr=m.x + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y) + m.c = pyo.Constraint(expr=m.x + m.y == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -66,11 +66,11 @@ def test_header_1(self): self._write_and_check_header(m, correct_lines) def test_header_2(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y) - m.c = pe.Constraint(expr=m.x + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y) + m.c = pyo.Constraint(expr=m.x + m.y == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -86,11 +86,11 @@ def test_header_2(self): self._write_and_check_header(m, correct_lines) def test_header_3(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y) - m.c = pe.Constraint(expr=m.x**2 + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y) + m.c = pyo.Constraint(expr=m.x**2 + m.y == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -106,11 +106,11 @@ def test_header_3(self): self._write_and_check_header(m, correct_lines) def test_header_4(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y) - m.c = pe.Constraint(expr=m.x**2 + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y) + m.c = pyo.Constraint(expr=m.x**2 + m.y == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -126,11 +126,11 @@ def test_header_4(self): self._write_and_check_header(m, correct_lines) def test_header_5(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c = pe.Constraint(expr=m.x**2 + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c = pyo.Constraint(expr=m.x**2 + m.y == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -146,11 +146,11 @@ def test_header_5(self): self._write_and_check_header(m, correct_lines) def test_header_6(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y) - m.c = pe.Constraint(expr=m.x**2 + m.y**2 == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y) + m.c = pyo.Constraint(expr=m.x**2 + m.y**2 == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -166,11 +166,11 @@ def test_header_6(self): self._write_and_check_header(m, correct_lines) def test_header_7(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y) - m.c = pe.Constraint(expr=m.x + m.y**2 == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y) + m.c = pyo.Constraint(expr=m.x + m.y**2 == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -186,11 +186,11 @@ def test_header_7(self): self._write_and_check_header(m, correct_lines) def test_header_8(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y) - m.c = pe.Constraint(expr=m.x**2 + m.y**2 == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y) + m.c = pyo.Constraint(expr=m.x**2 + m.y**2 == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -206,11 +206,11 @@ def test_header_8(self): self._write_and_check_header(m, correct_lines) def test_header_9(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y**2) - m.c = pe.Constraint(expr=m.x + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y**2) + m.c = pyo.Constraint(expr=m.x + m.y == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -226,11 +226,11 @@ def test_header_9(self): self._write_and_check_header(m, correct_lines) def test_header_10(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y**2) - m.c = pe.Constraint(expr=m.x + m.y**2 == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y**2) + m.c = pyo.Constraint(expr=m.x + m.y**2 == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -246,11 +246,11 @@ def test_header_10(self): self._write_and_check_header(m, correct_lines) def test_header_11(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y**2) - m.c = pe.Constraint(expr=m.x**2 + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y**2) + m.c = pyo.Constraint(expr=m.x**2 + m.y == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -266,11 +266,11 @@ def test_header_11(self): self._write_and_check_header(m, correct_lines) def test_header_12(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y**2) - m.c = pe.Constraint(expr=m.x**2 + m.y**2 == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y**2) + m.c = pyo.Constraint(expr=m.x**2 + m.y**2 == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -286,11 +286,11 @@ def test_header_12(self): self._write_and_check_header(m, correct_lines) def test_header_13(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y) - m.c = pe.Constraint(expr=m.x + m.y**2 == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y) + m.c = pyo.Constraint(expr=m.x + m.y**2 == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -306,11 +306,11 @@ def test_header_13(self): self._write_and_check_header(m, correct_lines) def test_header_14(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c = pe.Constraint(expr=m.x + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c = pyo.Constraint(expr=m.x + m.y == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -326,11 +326,11 @@ def test_header_14(self): self._write_and_check_header(m, correct_lines) def test_header_15(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c = pe.Constraint(expr=m.x + m.y**2 == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c = pyo.Constraint(expr=m.x + m.y**2 == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', @@ -346,11 +346,11 @@ def test_header_15(self): self._write_and_check_header(m, correct_lines) def test_header_16(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c = pe.Constraint(expr=m.x**2 + m.y**2 == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c = pyo.Constraint(expr=m.x**2 + m.y**2 == 1) correct_lines = [ 'g3 1 1 0', '2 1 1 0 1', diff --git a/pyomo/contrib/benders/__init__.py b/pyomo/contrib/benders/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/benders/__init__.py +++ b/pyomo/contrib/benders/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/benders/benders_cuts.py b/pyomo/contrib/benders/benders_cuts.py index cf96ba26164..e11987aa018 100644 --- a/pyomo/contrib/benders/benders_cuts.py +++ b/pyomo/contrib/benders/benders_cuts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,71 +9,91 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.core.base.block import _BlockData, declare_custom_block -import pyomo.environ as pyo -from pyomo.solvers.plugins.solvers.persistent_solver import PersistentSolver -from pyomo.core.expr.visitor import identify_variables +import logging + from pyomo.common.collections import ComponentSet +from pyomo.common.dependencies import ( + mpi4py, + mpi4py_available, + numpy as np, + numpy_available, +) +from pyomo.core.base.block import BlockData, declare_custom_block +from pyomo.core.expr.visitor import identify_variables +from pyomo.solvers.plugins.solvers.persistent_solver import PersistentSolver -try: - from mpi4py import MPI +import pyomo.environ as pyo - mpi4py_available = True -except: - mpi4py_available = False -try: - import numpy as np +MPI = mpi4py.MPI +logger = logging.getLogger(__name__) - numpy_available = True -except: - numpy_available = False -import logging +# Note: because of the LaTeX math, it is critical that this is a raw string. +__doc__ = r"""General purpose Benders Cut Generator. -logger = logging.getLogger(__name__) +It is easier to understand this code after reading Grothey, Leyffer, +and McKinnon "A note on feasibility in Benders Decomposition" [GLM99]_ +Original problem: -""" -It is easier to understand this code after reading "A note on feasibility in Benders Decomposition" by -Grothey et al. +.. math:: + :nowrap: -Original problem: + \[\begin{array}{ll} + \min & f(x, y) + h0(y) \\ + s.t. & g(x, y) <= 0 \\ + & h(y) <= 0 + \end{array}\] + +where y are the complicating variables. Reformulate to + +.. math:: + :nowrap: + + \[\begin{array}{ll} + \min & h0(y) + \eta \\ + s.t. & g(x, y) <= 0 \\ + & f(x, y) <= \eta \\ + & h(y) <= 0 + \end{array}\] -min f(x, y) + h0(y) -s.t. - g(x, y) <= 0 - h(y) <= 0 - -where y are the complicating variables. Reformulate to - -min h0(y) + eta -s.t. - g(x, y) <= 0 - f(x, y) <= eta - h(y) <= 0 - Root problem must be of the form -min h0(y) + eta -s.t. - h(y) <= 0 - benders cuts - -where the last constraint will be generated automatically with BendersCutGenerators. The BendersCutGenerators -must be handed a subproblem of the form - -min f(x, y) -s.t. - g(x, y) <= 0 - -except the constraints don't actually have to be in this form. The subproblem will automatically be transformed to - -min _z -s.t. - g(x, y) - z <= 0 (alpha) - f(x, y) - eta - z <= 0 (beta) - y - y_k = 0 (gamma) - eta - eta_k = 0 (delta) +.. math:: + :nowrap: + + \[\begin{array}{ll} + \min & h0(y) + \eta \\ + s.t. & h(y) <= 0 \\ + & \{benders\ cuts\} + \end{array}\] + +where the last constraint will be generated automatically with +BendersCutGenerators. The BendersCutGenerators must be handed a +subproblem of the form + +.. math:: + :nowrap: + + \[\begin{array}{ll} + \min & f(x, y) \\ + s.t. & g(x, y) <= 0 + \end{array}\] + +except the constraints don't actually have to be in this form. The +subproblem will automatically be transformed to + +.. math:: + :nowrap: + + \[\begin{array}{lll} + \min & z & \\ + s.t. & g(x, y) - z <= 0 & (\alpha) \\ + & f(x, y) - \eta - z <= 0 & (\beta) \\ + & y - y_k = 0 & (\gamma) \\ + & \eta - \eta_k = 0 & (\delta) + \end{array}\] + """ @@ -166,13 +186,13 @@ def _setup_subproblem(b, root_vars, relax_subproblem_cons): @declare_custom_block(name='BendersCutGenerator') -class BendersCutGeneratorData(_BlockData): +class BendersCutGeneratorData(BlockData): def __init__(self, component): if not mpi4py_available: raise ImportError('BendersCutGenerator requires mpi4py.') if not numpy_available: raise ImportError('BendersCutGenerator requires numpy.') - _BlockData.__init__(self, component) + BlockData.__init__(self, component) self.num_subproblems_by_rank = 0 # np.zeros(self.comm.Get_size()) self.subproblems = list() diff --git a/pyomo/contrib/benders/examples/__init__.py b/pyomo/contrib/benders/examples/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/benders/examples/__init__.py +++ b/pyomo/contrib/benders/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/benders/examples/farmer.py b/pyomo/contrib/benders/examples/farmer.py index 47cdb3511a3..d932caa4520 100644 --- a/pyomo/contrib/benders/examples/farmer.py +++ b/pyomo/contrib/benders/examples/farmer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/benders/examples/grothey_ex.py b/pyomo/contrib/benders/examples/grothey_ex.py index 27d37cac124..eac5d264327 100644 --- a/pyomo/contrib/benders/examples/grothey_ex.py +++ b/pyomo/contrib/benders/examples/grothey_ex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/benders/tests/__init__.py b/pyomo/contrib/benders/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/benders/tests/__init__.py +++ b/pyomo/contrib/benders/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/benders/tests/test_benders.py b/pyomo/contrib/benders/tests/test_benders.py index d985f886c10..9e45f383959 100644 --- a/pyomo/contrib/benders/tests/test_benders.py +++ b/pyomo/contrib/benders/tests/test_benders.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/community_detection/__init__.py b/pyomo/contrib/community_detection/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/community_detection/__init__.py +++ b/pyomo/contrib/community_detection/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/community_detection/community_graph.py b/pyomo/contrib/community_detection/community_graph.py index 889940b5996..89111406db0 100644 --- a/pyomo/contrib/community_detection/community_graph.py +++ b/pyomo/contrib/community_detection/community_graph.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -123,7 +123,7 @@ def generate_model_graph( # Create a list of the variable numbers that occur in the given constraint equation numbered_variables_in_constraint_equation = [ component_number_map[constraint_variable] - for constraint_variable in identify_variables(model_constraint.body) + for constraint_variable in identify_variables(model_constraint.expr) ] # Update constraint_variable_map diff --git a/pyomo/contrib/community_detection/detection.py b/pyomo/contrib/community_detection/detection.py index 5bf8187a243..b81e5927407 100644 --- a/pyomo/contrib/community_detection/detection.py +++ b/pyomo/contrib/community_detection/detection.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -31,7 +31,7 @@ Objective, ConstraintList, ) -from pyomo.core.base.objective import _GeneralObjectiveData +from pyomo.core.base.objective import ObjectiveData from pyomo.core.expr.visitor import replace_expressions, identify_variables from pyomo.contrib.community_detection.community_graph import generate_model_graph from pyomo.common.dependencies import networkx as nx @@ -580,7 +580,7 @@ def visualize_model_graph( pos = nx.spring_layout(model_graph) # Define color_map - color_map = plt.cm.get_cmap('viridis', len(numbered_community_map)) + color_map = plt.get_cmap('viridis', len(numbered_community_map)) # Create the figure and draw the graph fig = plt.figure() @@ -616,9 +616,7 @@ def visualize_model_graph( subtitle_font_size = 11 plt.title(subtitle_naming_dict[type_of_graph], fontsize=subtitle_font_size) - if filename is None: - plt.show() - else: + if filename is not None: plt.savefig(filename) plt.close() @@ -750,7 +748,7 @@ def generate_structured_model(self): # Check to see whether 'stored_constraint' is actually an objective (since constraints and objectives # grouped together) if self.with_objective and isinstance( - stored_constraint, (_GeneralObjectiveData, Objective) + stored_constraint, (ObjectiveData, Objective) ): # If the constraint is actually an objective, we add it to the block as an objective new_objective = Objective( diff --git a/pyomo/contrib/community_detection/event_log.py b/pyomo/contrib/community_detection/event_log.py index 767ff0f50f5..a6a8f08ecde 100644 --- a/pyomo/contrib/community_detection/event_log.py +++ b/pyomo/contrib/community_detection/event_log.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -""" Logger function for community_graph.py """ +"""Logger function for community_graph.py""" from logging import getLogger from pyomo.core import Constraint, Objective, Var diff --git a/pyomo/contrib/community_detection/plugins.py b/pyomo/contrib/community_detection/plugins.py index 229b7255a27..30cf1b79f56 100644 --- a/pyomo/contrib/community_detection/plugins.py +++ b/pyomo/contrib/community_detection/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/community_detection/tests/__init__.py b/pyomo/contrib/community_detection/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/community_detection/tests/__init__.py +++ b/pyomo/contrib/community_detection/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/community_detection/tests/test_detection.py b/pyomo/contrib/community_detection/tests/test_detection.py index 6a43ea1b61a..5202c289dba 100644 --- a/pyomo/contrib/community_detection/tests/test_detection.py +++ b/pyomo/contrib/community_detection/tests/test_detection.py @@ -4,7 +4,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/cp/__init__.py b/pyomo/contrib/cp/__init__.py index ed45344fb95..f09d3410e7c 100644 --- a/pyomo/contrib/cp/__init__.py +++ b/pyomo/contrib/cp/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,11 +17,21 @@ IntervalVarPresence, ) from pyomo.contrib.cp.repn.docplex_writer import DocplexWriter, CPOptimizerSolver +from pyomo.contrib.cp.sequence_var import SequenceVar +from pyomo.contrib.cp.scheduling_expr.sequence_expressions import ( + no_overlap, + first_in_sequence, + last_in_sequence, + before_in_sequence, + predecessor_to, +) +from pyomo.contrib.cp.scheduling_expr.scheduling_logic import ( + alternative, + spans, + synchronize, +) from pyomo.contrib.cp.scheduling_expr.step_function_expressions import ( AlwaysIn, Step, Pulse, ) - -# register logical_to_disjunctive transformation -import pyomo.contrib.cp.transform.logical_to_disjunctive_program diff --git a/pyomo/contrib/cp/interval_var.py b/pyomo/contrib/cp/interval_var.py index 953b859ea20..02281efa0b2 100644 --- a/pyomo/contrib/cp/interval_var.py +++ b/pyomo/contrib/cp/interval_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,6 +11,7 @@ from pyomo.common.collections import ComponentSet from pyomo.common.pyomo_typing import overload +from pyomo.contrib.cp.scheduling_expr.scheduling_logic import SpanExpression from pyomo.contrib.cp.scheduling_expr.precedence_expressions import ( BeforeExpression, AtExpression, @@ -18,12 +19,13 @@ from pyomo.core import Integers, value from pyomo.core.base import Any, ScalarVar, ScalarBooleanVar -from pyomo.core.base.block import _BlockData, Block +from pyomo.core.base.block import BlockData, Block from pyomo.core.base.component import ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index from pyomo.core.base.indexed_component import IndexedComponent, UnindexedComponent_set from pyomo.core.base.initializer import BoundInitializer, Initializer from pyomo.core.expr import GetItemExpression +from pyomo.core.expr.logical_expr import _flattened class IntervalVarTimePoint(ScalarVar): @@ -49,7 +51,7 @@ class IntervalVarStartTime(IntervalVarTimePoint): """This class defines a single variable denoting a start time point of an IntervalVar""" - def __init__(self): + def __init__(self, *args, **kwd): super().__init__(domain=Integers, ctype=IntervalVarStartTime) @@ -57,7 +59,7 @@ class IntervalVarEndTime(IntervalVarTimePoint): """This class defines a single variable denoting an end time point of an IntervalVar""" - def __init__(self): + def __init__(self, *args, **kwd): super().__init__(domain=Integers, ctype=IntervalVarEndTime) @@ -67,7 +69,7 @@ class IntervalVarLength(ScalarVar): __slots__ = () - def __init__(self): + def __init__(self, *args, **kwd): super().__init__(domain=Integers, ctype=IntervalVarLength) def get_associated_interval_var(self): @@ -80,21 +82,23 @@ class IntervalVarPresence(ScalarBooleanVar): __slots__ = () - def __init__(self): + def __init__(self, *args, **kwd): + # TODO: adding args and kwd above made Reference work, but we + # probably shouldn't just swallow them, right? super().__init__(ctype=IntervalVarPresence) def get_associated_interval_var(self): return self.parent_block() -class IntervalVarData(_BlockData): +class IntervalVarData(BlockData): """This class defines the abstract interface for a single interval variable.""" # We will put our four variables on this, and everything else is off limits. _Block_reserved_words = Any def __init__(self, component=None): - _BlockData.__init__(self, component) + BlockData.__init__(self, component) with self._declare_reserved_components(): self.is_present = IntervalVarPresence() @@ -122,6 +126,9 @@ def optional(self, val): else: self.is_present.fix(True) + def spans(self, *args): + return SpanExpression([self] + list(_flattened(args))) + @ModelComponentFactory.register("Interval variables for scheduling.") class IntervalVar(Block): @@ -199,8 +206,6 @@ def _getitem_when_not_present(self, index): class ScalarIntervalVar(IntervalVarData, IntervalVar): def __init__(self, *args, **kwds): - self._suppress_ctypes = set() - IntervalVarData.__init__(self, self) IntervalVar.__init__(self, *args, **kwds) self._data[None] = self diff --git a/pyomo/contrib/cp/plugins.py b/pyomo/contrib/cp/plugins.py index b0f7c84eb65..01b84a2c8c4 100644 --- a/pyomo/contrib/cp/plugins.py +++ b/pyomo/contrib/cp/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/cp/repn/__init__.py b/pyomo/contrib/cp/repn/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/cp/repn/__init__.py +++ b/pyomo/contrib/cp/repn/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/cp/repn/docplex_writer.py b/pyomo/contrib/cp/repn/docplex_writer.py index 8356a1e752f..c8ef259f847 100644 --- a/pyomo/contrib/cp/repn/docplex_writer.py +++ b/pyomo/contrib/cp/repn/docplex_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -30,10 +30,27 @@ IntervalVarData, IndexedIntervalVar, ) +from pyomo.contrib.cp.sequence_var import ( + SequenceVar, + ScalarSequenceVar, + SequenceVarData, +) +from pyomo.contrib.cp.scheduling_expr.scheduling_logic import ( + AlternativeExpression, + SpanExpression, + SynchronizeExpression, +) from pyomo.contrib.cp.scheduling_expr.precedence_expressions import ( BeforeExpression, AtExpression, ) +from pyomo.contrib.cp.scheduling_expr.sequence_expressions import ( + NoOverlapExpression, + FirstInSequenceExpression, + LastInSequenceExpression, + BeforeInSequenceExpression, + PredecessorToExpression, +) from pyomo.contrib.cp.scheduling_expr.step_function_expressions import ( AlwaysIn, StepAt, @@ -60,16 +77,17 @@ ) from pyomo.core.base.boolean_var import ( ScalarBooleanVar, - _GeneralBooleanVarData, + BooleanVarData, IndexedBooleanVar, ) -from pyomo.core.base.expression import ScalarExpression, _GeneralExpressionData -from pyomo.core.base.param import IndexedParam, ScalarParam, _ParamData -from pyomo.core.base.var import ScalarVar, _GeneralVarData, IndexedVar +from pyomo.core.base.expression import ScalarExpression, ExpressionData +from pyomo.core.base.param import IndexedParam, ScalarParam, ParamData +from pyomo.core.base.var import ScalarVar, VarData, IndexedVar import pyomo.core.expr as EXPR from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, identify_variables from pyomo.core.base import Set, RangeSet from pyomo.core.base.set import SetProduct +from pyomo.repn.util import ExitNodeDispatcher from pyomo.opt import WriterFactory, SolverFactory, TerminationCondition, SolverResults ### FIXME: Remove the following as soon as non-active components no @@ -449,6 +467,7 @@ def _create_docplex_interval_var(visitor, interval_var): nm = interval_var.name if visitor.symbolic_solver_labels else None cpx_interval_var = cp.interval_var(name=nm) visitor.var_map[id(interval_var)] = cpx_interval_var + visitor.pyomo_to_docplex[interval_var] = cpx_interval_var # Figure out if it exists if interval_var.is_present.fixed and not interval_var.is_present.value: @@ -491,6 +510,19 @@ def _create_docplex_interval_var(visitor, interval_var): return cpx_interval_var +def _create_docplex_sequence_var(visitor, sequence_var): + nm = sequence_var.name if visitor.symbolic_solver_labels else None + + cpx_seq_var = cp.sequence_var( + name=nm, + vars=[ + _get_docplex_interval_var(visitor, v) for v in sequence_var.interval_vars + ], + ) + visitor.var_map[id(sequence_var)] = cpx_seq_var + return cpx_seq_var + + def _get_docplex_interval_var(visitor, interval_var): # We might already have the interval_var and just need to retrieve it if id(interval_var) in visitor.var_map: @@ -501,6 +533,25 @@ def _get_docplex_interval_var(visitor, interval_var): return cpx_interval_var +def _get_docplex_sequence_var(visitor, sequence_var): + if id(sequence_var) in visitor.var_map: + cpx_seq_var = visitor.var_map[id(sequence_var)] + else: + cpx_seq_var = _create_docplex_sequence_var(visitor, sequence_var) + visitor.cpx.add(cpx_seq_var) + return cpx_seq_var + + +def _before_sequence_var(visitor, child): + _id = id(child) + if _id not in visitor.var_map: + cpx_seq_var = _get_docplex_sequence_var(visitor, child) + visitor.var_map[_id] = cpx_seq_var + visitor.pyomo_to_docplex[child] = cpx_seq_var + + return False, (_GENERAL, visitor.var_map[_id]) + + def _before_interval_var(visitor, child): _id = id(child) if _id not in visitor.var_map: @@ -564,22 +615,22 @@ def _before_interval_var_presence(visitor, child): def _handle_step_at_node(visitor, node): - return cp.step_at(node._time, node._height) + return False, (_GENERAL, cp.step_at(node._time, node._height)) def _handle_step_at_start_node(visitor, node): cpx_var = _get_docplex_interval_var(visitor, node._time) - return cp.step_at_start(cpx_var, node._height) + return False, (_GENERAL, cp.step_at_start(cpx_var, node._height)) def _handle_step_at_end_node(visitor, node): cpx_var = _get_docplex_interval_var(visitor, node._time) - return cp.step_at_end(cpx_var, node._height) + return False, (_GENERAL, cp.step_at_end(cpx_var, node._height)) def _handle_pulse_node(visitor, node): cpx_var = _get_docplex_interval_var(visitor, node._interval_var) - return cp.pulse(cpx_var, node._height) + return False, (_GENERAL, cp.pulse(cpx_var, node._height)) def _handle_negated_step_function_node(visitor, node): @@ -590,9 +641,9 @@ def _handle_cumulative_function(visitor, node): expr = 0 for arg in node.args: if arg.__class__ is NegatedStepFunction: - expr -= _handle_negated_step_function_node(visitor, arg) + expr -= _handle_negated_step_function_node(visitor, arg)[1][1] else: - expr += _step_function_handles[arg.__class__](visitor, arg) + expr += _step_function_handles[arg.__class__](visitor, arg)[1][1] return False, (_GENERAL, expr) @@ -658,7 +709,7 @@ def _handle_monomial_expr(visitor, node, arg1, arg2): # simplifications (necessary in part for the unit tests) if arg2[1].__class__ in EXPR.native_types: return _GENERAL, arg1[1] * arg2[1] - elif arg1[1] == 1: + elif arg1[1].__class__ in EXPR.native_types and arg1[1] == 1: return arg2 return (_GENERAL, cp.times(_get_int_valued_expr(arg1), _get_int_valued_expr(arg2))) @@ -910,48 +961,91 @@ def _handle_always_in_node(visitor, node, cumul_func, lb, ub, start, end): ) +def _handle_no_overlap_expression_node(visitor, node, seq_var): + return _GENERAL, cp.no_overlap(seq_var[1]) + + +def _handle_first_in_sequence_expression_node(visitor, node, interval_var, seq_var): + return _GENERAL, cp.first(seq_var[1], interval_var[1]) + + +def _handle_last_in_sequence_expression_node(visitor, node, interval_var, seq_var): + return _GENERAL, cp.last(seq_var[1], interval_var[1]) + + +def _handle_before_in_sequence_expression_node( + visitor, node, before_var, after_var, seq_var +): + return _GENERAL, cp.before(seq_var[1], before_var[1], after_var[1]) + + +def _handle_predecessor_to_expression_node( + visitor, node, before_var, after_var, seq_var +): + return _GENERAL, cp.previous(seq_var[1], before_var[1], after_var[1]) + + +def _handle_span_expression_node(visitor, node, *args): + return _GENERAL, cp.span(args[0][1], [arg[1] for arg in args[1:]]) + + +def _handle_alternative_expression_node(visitor, node, *args): + return _GENERAL, cp.alternative(args[0][1], [arg[1] for arg in args[1:]]) + + +def _handle_synchronize_expression_node(visitor, node, *args): + return _GENERAL, cp.synchronize(args[0][1], [arg[1] for arg in args[1:]]) + + +_operator_handles = { + EXPR.GetItemExpression: _handle_getitem, + EXPR.GetAttrExpression: _handle_getattr, + EXPR.CallExpression: _handle_call, + EXPR.NegationExpression: _handle_negation_node, + EXPR.ProductExpression: _handle_product_node, + EXPR.DivisionExpression: _handle_division_node, + EXPR.PowExpression: _handle_pow_node, + EXPR.AbsExpression: _handle_abs_node, + EXPR.MonomialTermExpression: _handle_monomial_expr, + EXPR.SumExpression: _handle_sum_node, + EXPR.MinExpression: _handle_min_node, + EXPR.MaxExpression: _handle_max_node, + EXPR.NotExpression: _handle_not_node, + EXPR.EquivalenceExpression: _handle_equivalence_node, + EXPR.ImplicationExpression: _handle_implication_node, + EXPR.AndExpression: _handle_and_node, + EXPR.OrExpression: _handle_or_node, + EXPR.XorExpression: _handle_xor_node, + EXPR.ExactlyExpression: _handle_exactly_node, + EXPR.AtMostExpression: _handle_at_most_node, + EXPR.AtLeastExpression: _handle_at_least_node, + EXPR.AllDifferentExpression: _handle_all_diff_node, + EXPR.CountIfExpression: _handle_count_if_node, + EXPR.EqualityExpression: _handle_equality_node, + EXPR.NotEqualExpression: _handle_not_equal_node, + EXPR.InequalityExpression: _handle_inequality_node, + EXPR.RangedExpression: _handle_ranged_inequality_node, + BeforeExpression: _handle_before_expression_node, + AtExpression: _handle_at_expression_node, + AlwaysIn: _handle_always_in_node, + ExpressionData: _handle_named_expression_node, + ScalarExpression: _handle_named_expression_node, + NoOverlapExpression: _handle_no_overlap_expression_node, + FirstInSequenceExpression: _handle_first_in_sequence_expression_node, + LastInSequenceExpression: _handle_last_in_sequence_expression_node, + BeforeInSequenceExpression: _handle_before_in_sequence_expression_node, + PredecessorToExpression: _handle_predecessor_to_expression_node, + SpanExpression: _handle_span_expression_node, + AlternativeExpression: _handle_alternative_expression_node, + SynchronizeExpression: _handle_synchronize_expression_node, +} + + class LogicalToDoCplex(StreamBasedExpressionVisitor): - _operator_handles = { - EXPR.GetItemExpression: _handle_getitem, - EXPR.Structural_GetItemExpression: _handle_getitem, - EXPR.Numeric_GetItemExpression: _handle_getitem, - EXPR.Boolean_GetItemExpression: _handle_getitem, - EXPR.GetAttrExpression: _handle_getattr, - EXPR.Structural_GetAttrExpression: _handle_getattr, - EXPR.Numeric_GetAttrExpression: _handle_getattr, - EXPR.Boolean_GetAttrExpression: _handle_getattr, - EXPR.CallExpression: _handle_call, - EXPR.NegationExpression: _handle_negation_node, - EXPR.ProductExpression: _handle_product_node, - EXPR.DivisionExpression: _handle_division_node, - EXPR.PowExpression: _handle_pow_node, - EXPR.AbsExpression: _handle_abs_node, - EXPR.MonomialTermExpression: _handle_monomial_expr, - EXPR.SumExpression: _handle_sum_node, - EXPR.LinearExpression: _handle_sum_node, - EXPR.MinExpression: _handle_min_node, - EXPR.MaxExpression: _handle_max_node, - EXPR.NotExpression: _handle_not_node, - EXPR.EquivalenceExpression: _handle_equivalence_node, - EXPR.ImplicationExpression: _handle_implication_node, - EXPR.AndExpression: _handle_and_node, - EXPR.OrExpression: _handle_or_node, - EXPR.XorExpression: _handle_xor_node, - EXPR.ExactlyExpression: _handle_exactly_node, - EXPR.AtMostExpression: _handle_at_most_node, - EXPR.AtLeastExpression: _handle_at_least_node, - EXPR.AllDifferentExpression: _handle_all_diff_node, - EXPR.CountIfExpression: _handle_count_if_node, - EXPR.EqualityExpression: _handle_equality_node, - EXPR.NotEqualExpression: _handle_not_equal_node, - EXPR.InequalityExpression: _handle_inequality_node, - EXPR.RangedExpression: _handle_ranged_inequality_node, - BeforeExpression: _handle_before_expression_node, - AtExpression: _handle_at_expression_node, - AlwaysIn: _handle_always_in_node, - _GeneralExpressionData: _handle_named_expression_node, - ScalarExpression: _handle_named_expression_node, - } + exit_node_dispatcher = ExitNodeDispatcher(_operator_handles) + # NOTE: Because of indirection, we can encounter indexed Params and Vars in + # expressions + _var_handles = { IntervalVarStartTime: _before_interval_var_start_time, IntervalVarEndTime: _before_interval_var_end_time, @@ -960,17 +1054,19 @@ class LogicalToDoCplex(StreamBasedExpressionVisitor): ScalarIntervalVar: _before_interval_var, IntervalVarData: _before_interval_var, IndexedIntervalVar: _before_indexed_interval_var, + ScalarSequenceVar: _before_sequence_var, + SequenceVarData: _before_sequence_var, ScalarVar: _before_var, - _GeneralVarData: _before_var, + VarData: _before_var, IndexedVar: _before_indexed_var, ScalarBooleanVar: _before_boolean_var, - _GeneralBooleanVarData: _before_boolean_var, + BooleanVarData: _before_boolean_var, IndexedBooleanVar: _before_indexed_boolean_var, - _GeneralExpressionData: _before_named_expression, + ExpressionData: _before_named_expression, ScalarExpression: _before_named_expression, - IndexedParam: _before_indexed_param, # Because of indirection + IndexedParam: _before_indexed_param, ScalarParam: _before_param, - _ParamData: _before_param, + ParamData: _before_param, } def __init__(self, cpx_model, symbolic_solver_labels=False): @@ -1004,7 +1100,7 @@ def beforeChild(self, node, child, child_idx): return True, None def exitNode(self, node, data): - return self._operator_handles[node.__class__](self, node, *data) + return self.exit_node_dispatcher[node.__class__](self, node, *data) finalizeResult = None @@ -1016,6 +1112,9 @@ def collect_valid_components(model, active=True, sort=None, valid=set(), targets unrecognized = {} components = {k: [] for k in targets} for obj in model.component_data_objects(active=True, descend_into=True, sort=sort): + # HACK around #3045 + if not hasattr(obj, 'ctype'): + continue ctype = obj.ctype if ctype in components: components[ctype].append(obj) @@ -1066,7 +1165,13 @@ def write(self, model, **options): RangeSet, Port, }, - targets={Objective, Constraint, LogicalConstraint, IntervalVar}, + targets={ + Objective, + Constraint, + LogicalConstraint, + IntervalVar, + SequenceVar, + }, ) if unknown: raise ValueError( @@ -1295,6 +1400,10 @@ def solve(self, model, **kwds): ) else: sol = sol.get_value() + if py_var.ctype is SequenceVar: + # They don't actually have values--the IntervalVars will get + # set. + continue if py_var.ctype is IntervalVar: if len(sol) == 0: # The interval_var is absent diff --git a/pyomo/contrib/cp/scheduling_expr/__init__.py b/pyomo/contrib/cp/scheduling_expr/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/cp/scheduling_expr/__init__.py +++ b/pyomo/contrib/cp/scheduling_expr/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/cp/scheduling_expr/precedence_expressions.py b/pyomo/contrib/cp/scheduling_expr/precedence_expressions.py index 1dec02bba23..1ad7500bae8 100644 --- a/pyomo/contrib/cp/scheduling_expr/precedence_expressions.py +++ b/pyomo/contrib/cp/scheduling_expr/precedence_expressions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,6 +13,8 @@ class PrecedenceExpression(BooleanExpression): + PRECEDENCE = None + def nargs(self): return 3 @@ -21,13 +23,13 @@ def delay(self): return self._args_[2] def _to_string_impl(self, values, relation): - delay = int(values[2]) - if delay == 0: + delay = values[2] + if delay == '0': first = values[0] - elif delay > 0: - first = "%s + %s" % (values[0], delay) + elif delay[0] in '-+': + first = "%s %s %s" % (values[0], delay[0], delay[1:]) else: - first = "%s - %s" % (values[0], abs(delay)) + first = "%s + %s" % (values[0], delay) return "%s %s %s" % (first, relation, values[1]) diff --git a/pyomo/contrib/cp/scheduling_expr/scheduling_logic.py b/pyomo/contrib/cp/scheduling_expr/scheduling_logic.py new file mode 100644 index 00000000000..0a8bed8f309 --- /dev/null +++ b/pyomo/contrib/cp/scheduling_expr/scheduling_logic.py @@ -0,0 +1,72 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +from pyomo.core.expr.logical_expr import NaryBooleanExpression, _flattened + + +class SpanExpression(NaryBooleanExpression): + """ + Expression over IntervalVars representing that the first arg spans all the + following args in the schedule. The first arg is absent if and only if all + the others are absent. + + args: + args (tuple): Child nodes, of type IntervalVar + """ + + def _to_string(self, values, verbose, smap): + return "%s.spans(%s)" % (values[0], ", ".join(values[1:])) + + +class AlternativeExpression(NaryBooleanExpression): + """ + Expression over IntervalVars representing that if the first arg is present, + then exactly one of the following args must be present. The first arg is + absent if and only if all the others are absent. + """ + + # [ESJ 4/4/24]: docplex takes an optional 'cardinality' argument with this + # too--it generalized to "exactly n" of the intervals have to exist, + # basically. It would be nice to include this eventually, but this is + # probably fine for now. + + def _to_string(self, values, verbose, smap): + return "alternative(%s, [%s])" % (values[0], ", ".join(values[1:])) + + +class SynchronizeExpression(NaryBooleanExpression): + """ + Expression over IntervalVars synchronizing the first argument with all of the + following arguments. That is, if the first argument is present, the remaining + arguments start and end at the same time as it. + """ + + def _to_string(self, values, verbose, smap): + return "synchronize(%s, [%s])" % (values[0], ", ".join(values[1:])) + + +def spans(*args): + """Creates a new SpanExpression""" + + return SpanExpression(list(_flattened(args))) + + +def alternative(*args): + """Creates a new AlternativeExpression""" + + return AlternativeExpression(list(_flattened(args))) + + +def synchronize(*args): + """Creates a new SynchronizeExpression""" + + return SynchronizeExpression(list(_flattened(args))) diff --git a/pyomo/contrib/cp/scheduling_expr/sequence_expressions.py b/pyomo/contrib/cp/scheduling_expr/sequence_expressions.py new file mode 100644 index 00000000000..d522da9b588 --- /dev/null +++ b/pyomo/contrib/cp/scheduling_expr/sequence_expressions.py @@ -0,0 +1,173 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.core.expr.logical_expr import BooleanExpression + + +class NoOverlapExpression(BooleanExpression): + """ + Expression representing that none of the IntervalVars in a SequenceVar overlap + (if they are scheduled) + + args: + args (tuple): Child node of type SequenceVar + """ + + def nargs(self): + return 1 + + def _to_string(self, values, verbose, smap): + return "no_overlap(%s)" % values[0] + + +class FirstInSequenceExpression(BooleanExpression): + """ + Expression representing that the specified IntervalVar is the first in the + sequence specified by SequenceVar (if it is scheduled) + + args: + args (tuple): Child nodes, the first of type IntervalVar, the second of type + SequenceVar + """ + + def nargs(self): + return 2 + + def _to_string(self, values, verbose, smap): + return "first_in(%s, %s)" % (values[0], values[1]) + + +class LastInSequenceExpression(BooleanExpression): + """ + Expression representing that the specified IntervalVar is the last in the + sequence specified by SequenceVar (if it is scheduled) + + args: + args (tuple): Child nodes, the first of type IntervalVar, the second of type + SequenceVar + """ + + def nargs(self): + return 2 + + def _to_string(self, values, verbose, smap): + return "last_in(%s, %s)" % (values[0], values[1]) + + +class BeforeInSequenceExpression(BooleanExpression): + """ + Expression representing that one IntervalVar occurs before another in the + sequence specified by the given SequenceVar (if both are scheduled) + + args: + args (tuple): Child nodes, the IntervalVar that must be before, the + IntervalVar that must be after, and the SequenceVar + """ + + def nargs(self): + return 3 + + def _to_string(self, values, verbose, smap): + return "before_in(%s, %s, %s)" % (values[0], values[1], values[2]) + + +class PredecessorToExpression(BooleanExpression): + """ + Expression representing that one IntervalVar is a direct predecessor to another + in the sequence specified by the given SequenceVar (if both are scheduled) + + args: + args (tuple): Child nodes, the predecessor IntervalVar, the successor + IntervalVar, and the SequenceVar + """ + + def nargs(self): + return 3 + + def _to_string(self, values, verbose, smap): + return "predecessor_to(%s, %s, %s)" % (values[0], values[1], values[2]) + + +def no_overlap(sequence_var): + """ + Creates a new NoOverlapExpression + + Requires that none of the scheduled intervals in the SequenceVar overlap each other + + args: + sequence_var: A SequenceVar + """ + return NoOverlapExpression((sequence_var,)) + + +def first_in_sequence(interval_var, sequence_var): + """ + Creates a new FirstInSequenceExpression + + Requires that 'interval_var' be the first in the sequence specified by + 'sequence_var' if it is scheduled + + args: + interval_var (IntervalVar): The activity that should be scheduled first + if it is scheduled at all + sequence_var (SequenceVar): The sequence of activities + """ + return FirstInSequenceExpression((interval_var, sequence_var)) + + +def last_in_sequence(interval_var, sequence_var): + """ + Creates a new LastInSequenceExpression + + Requires that 'interval_var' be the last in the sequence specified by + 'sequence_var' if it is scheduled + + args: + interval_var (IntervalVar): The activity that should be scheduled last + if it is scheduled at all + sequence_var (SequenceVar): The sequence of activities + """ + + return LastInSequenceExpression((interval_var, sequence_var)) + + +def before_in_sequence(before_var, after_var, sequence_var): + """ + Creates a new BeforeInSequenceExpression + + Requires that 'before_var' be scheduled to start before 'after_var' in the + sequence specified bv 'sequence_var', if both are scheduled + + args: + before_var (IntervalVar): The activity that should be scheduled earlier in + the sequence + after_var (IntervalVar): The activity that should be scheduled later in the + sequence + sequence_var (SequenceVar): The sequence of activities + """ + return BeforeInSequenceExpression((before_var, after_var, sequence_var)) + + +def predecessor_to(before_var, after_var, sequence_var): + """ + Creates a new PredecessorToExpression + + Requires that 'before_var' be a direct predecessor to 'after_var' in the + sequence specified by 'sequence_var', if both are scheduled + + args: + before_var (IntervalVar): The activity that should be scheduled as the + predecessor + after_var (IntervalVar): The activity that should be scheduled as the + successor + sequence_var (SequenceVar): The sequence of activities + """ + return PredecessorToExpression((before_var, after_var, sequence_var)) diff --git a/pyomo/contrib/cp/scheduling_expr/step_function_expressions.py b/pyomo/contrib/cp/scheduling_expr/step_function_expressions.py index b75306f72c9..27a952b551f 100644 --- a/pyomo/contrib/cp/scheduling_expr/step_function_expressions.py +++ b/pyomo/contrib/cp/scheduling_expr/step_function_expressions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,9 +15,9 @@ IntervalVarStartTime, IntervalVarEndTime, ) -from pyomo.core.base.component import Component from pyomo.core.expr.base import ExpressionBase from pyomo.core.expr.logical_expr import BooleanExpression +from pyomo.core.expr.numeric_expr import SumExpression def _sum_two_units(_self, _other): @@ -120,6 +120,7 @@ class StepFunction(ExpressionBase): """ __slots__ = () + PRECEDENCE = None def __add__(self, other): return _generate_sum_expression(self, other) @@ -288,6 +289,7 @@ class CumulativeFunction(StepFunction): """ __slots__ = ('_args_', '_nargs') + PRECEDENCE = SumExpression.PRECEDENCE def __init__(self, args, nargs=None): # We make sure args are a list because we might add to them later, if @@ -364,7 +366,7 @@ def nargs(self): return 5 def _to_string(self, values, verbose, smap): - return "(%s).within(bounds=(%s, %s), times=(%s, %s))" % ( + return "%s.within(bounds=(%s, %s), times=(%s, %s))" % ( values[0], values[1], values[2], diff --git a/pyomo/contrib/cp/sequence_var.py b/pyomo/contrib/cp/sequence_var.py new file mode 100644 index 00000000000..57f555d36bf --- /dev/null +++ b/pyomo/contrib/cp/sequence_var.py @@ -0,0 +1,151 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging + +from pyomo.common.log import is_debug_set +from pyomo.common.modeling import NOTSET +from pyomo.contrib.cp import IntervalVar +from pyomo.core import ModelComponentFactory +from pyomo.core.base.component import ActiveComponentData +from pyomo.core.base.global_set import UnindexedComponent_index +from pyomo.core.base.indexed_component import ActiveIndexedComponent +from pyomo.core.base.initializer import Initializer + +import sys +from weakref import ref as weakref_ref + +logger = logging.getLogger(__name__) + + +class SequenceVarData(ActiveComponentData): + """This class defines the abstract interface for a single sequence variable.""" + + __slots__ = ('interval_vars',) + + def __init__(self, component=None): + # in-lining ActiveComponentData and ComponentData constructors, as is + # traditional: + self._component = weakref_ref(component) if (component is not None) else None + self._index = NOTSET + self._active = True + + # This thing is really just an ordered set of interval vars that we can + # write constraints over. + self.interval_vars = [] + + def set_value(self, expr): + # We'll demand expr be a list for now--it needs to be ordered so this + # doesn't seem like too much to ask + if not hasattr(expr, '__iter__'): + raise ValueError( + "'expr' for SequenceVar must be a list of IntervalVars. " + "Encountered type '%s' constructing '%s'" % (type(expr), self.name) + ) + for v in expr: + if not hasattr(v, 'ctype') or v.ctype is not IntervalVar: + raise ValueError( + "The SequenceVar 'expr' argument must be a list of " + "IntervalVars. The 'expr' for SequenceVar '%s' included " + "an object of type '%s'" % (self.name, type(v)) + ) + self.interval_vars.append(v) + + +@ModelComponentFactory.register("Sequences of IntervalVars") +class SequenceVar(ActiveIndexedComponent): + _ComponentDataClass = SequenceVarData + + def __new__(cls, *args, **kwds): + if cls != SequenceVar: + return super(SequenceVar, cls).__new__(cls) + if args == (): + return ScalarSequenceVar.__new__(ScalarSequenceVar) + else: + return IndexedSequenceVar.__new__(IndexedSequenceVar) + + def __init__(self, *args, **kwargs): + self._init_rule = Initializer(kwargs.pop('rule', None)) + self._init_expr = kwargs.pop('expr', None) + kwargs.setdefault('ctype', SequenceVar) + super(SequenceVar, self).__init__(*args, **kwargs) + + if self._init_expr is not None and self._init_rule is not None: + raise ValueError( + "Cannot specify both rule= and expr= for SequenceVar %s" % (self.name,) + ) + + def _getitem_when_not_present(self, index): + if index is None and not self.is_indexed(): + obj = self._data[index] = self + else: + obj = self._data[index] = self._ComponentDataClass(component=self) + parent = self.parent_block() + obj._index = index + + if self._init_rule is not None: + obj.set_value(self._init_rule(parent, index)) + if self._init_expr is not None: + obj.set_value(self._init_expr) + + return obj + + def construct(self, data=None): + """ + Construct the SequenceVarData objects for this SequenceVar + """ + if self._constructed: + return + self._constructed = True + + if is_debug_set(logger): + logger.debug("Constructing SequenceVar %s" % self.name) + + # Initialize index in case we hit the exception below + index = None + try: + if not self.is_indexed(): + self._getitem_when_not_present(None) + if self._init_rule is not None: + for index in self.index_set(): + self._getitem_when_not_present(index) + except Exception: + err = sys.exc_info()[1] + logger.error( + "Rule failed when initializing sequence variable for " + "SequenceVar %s with index %s:\n%s: %s" + % (self.name, str(index), type(err).__name__, err) + ) + raise + + def _pprint(self): + """Print component information.""" + headers = [ + ("Size", len(self)), + ("Index", self._index_set if self.is_indexed() else None), + ] + return ( + headers, + self._data.items(), + ("IntervalVars",), + lambda k, v: ['[' + ', '.join(iv.name for iv in v.interval_vars) + ']'], + ) + + +class ScalarSequenceVar(SequenceVarData, SequenceVar): + def __init__(self, *args, **kwds): + SequenceVarData.__init__(self, component=self) + SequenceVar.__init__(self, *args, **kwds) + self._index = UnindexedComponent_index + + +class IndexedSequenceVar(SequenceVar): + pass diff --git a/pyomo/contrib/cp/tests/__init__.py b/pyomo/contrib/cp/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/cp/tests/__init__.py +++ b/pyomo/contrib/cp/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/cp/tests/test_docplex_walker.py b/pyomo/contrib/cp/tests/test_docplex_walker.py index 8e0e8c6955e..b336e6b1d79 100644 --- a/pyomo/contrib/cp/tests/test_docplex_walker.py +++ b/pyomo/contrib/cp/tests/test_docplex_walker.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,12 +11,16 @@ import pyomo.common.unittest as unittest -from pyomo.contrib.cp import IntervalVar -from pyomo.contrib.cp.scheduling_expr.step_function_expressions import ( - AlwaysIn, - Step, - Pulse, +from pyomo.contrib.cp import ( + IntervalVar, + SequenceVar, + no_overlap, + first_in_sequence, + last_in_sequence, + alternative, + synchronize, ) +from pyomo.contrib.cp.scheduling_expr.step_function_expressions import Step, Pulse from pyomo.contrib.cp.repn.docplex_writer import docplex_available, LogicalToDoCplex from pyomo.core.base.range import NumericRange @@ -46,8 +50,6 @@ Integers, inequality, Expression, - Reals, - Set, Param, ) @@ -98,6 +100,10 @@ def test_write_addition(self): expr[1].equals(cpx_x + cp.start_of(cpx_i) + cp.length_of(cpx_i2)) ) + self.assertIs(visitor.pyomo_to_docplex[m.x], cpx_x) + self.assertIs(visitor.pyomo_to_docplex[m.i], cpx_i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], cpx_i2) + def test_write_subtraction(self): m = self.get_model() m.a.domain = Binary @@ -113,6 +119,9 @@ def test_write_subtraction(self): self.assertTrue(expr[1].equals(x + (-1 * a1))) + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + def test_write_product(self): m = self.get_model() m.a.domain = PositiveIntegers @@ -128,6 +137,9 @@ def test_write_product(self): self.assertTrue(expr[1].equals(x * (a1 + 1))) + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + def test_write_floating_point_division(self): m = self.get_model() m.a.domain = NonNegativeIntegers @@ -143,6 +155,9 @@ def test_write_floating_point_division(self): self.assertTrue(expr[1].equals(x / (a1 + 1))) + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + def test_write_power_expression(self): m = self.get_model() m.c = Constraint(expr=m.x**2 <= 3) @@ -154,6 +169,8 @@ def test_write_power_expression(self): # .equals checks the equality of two expressions in docplex. self.assertTrue(expr[1].equals(cpx_x**2)) + self.assertIs(visitor.pyomo_to_docplex[m.x], cpx_x) + def test_write_absolute_value_expression(self): m = self.get_model() m.a.domain = NegativeIntegers @@ -167,6 +184,8 @@ def test_write_absolute_value_expression(self): self.assertTrue(expr[1].equals(cp.abs(a1) + 1)) + self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + def test_write_min_expression(self): m = self.get_model() m.a.domain = NonPositiveIntegers @@ -178,6 +197,7 @@ def test_write_min_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] + self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) self.assertTrue(expr[1].equals(cp.min(a[i] for i in m.I))) @@ -192,6 +212,7 @@ def test_write_max_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] + self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) self.assertTrue(expr[1].equals(cp.max(a[i] for i in m.I))) @@ -209,6 +230,35 @@ def test_expression_with_mutable_param(self): self.assertTrue(expr[1].equals(4 * x)) + def test_monomial_expressions(self): + m = ConcreteModel() + m.x = Var(domain=Integers, bounds=(1, 4)) + m.p = Param(initialize=4, mutable=True) + + visitor = self.get_visitor() + + const_expr = 3 * m.x + nested_expr = (1 / m.p) * m.x + pow_expr = (m.p ** (0.5)) * m.x + + e = m.x * 4 + expr = visitor.walk_expression((e, e, 0)) + self.assertIn(id(m.x), visitor.var_map) + x = visitor.var_map[id(m.x)] + self.assertTrue(expr[1].equals(4 * x)) + + e = 1.0 * m.x + expr = visitor.walk_expression((e, e, 0)) + self.assertTrue(expr[1].equals(x)) + + e = (1 / m.p) * m.x + expr = visitor.walk_expression((e, e, 0)) + self.assertTrue(expr[1].equals(cp.float_div(1, 4) * x)) + + e = (m.p ** (0.5)) * m.x + expr = visitor.walk_expression((e, e, 0)) + self.assertTrue(expr[1].equals(cp.power(4, 0.5) * x)) + @unittest.skipIf(not docplex_available, "docplex is not available") class TestCPExpressionWalker_LogicalExpressions(CommonTest): @@ -226,6 +276,14 @@ def test_write_logical_and(self): self.assertTrue(expr[1].equals(cp.logical_and(b, b2b))) + # ESJ: This is ludicrous, but I don't know how to get the args of a CP + # expression, so testing that we were correct in the pyomo to docplex + # map by checking that we can build an expression that is the same as b + # (because b is actually "b == 1" since docplex doesn't believe in + # Booleans) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertTrue(b2b.equals(visitor.pyomo_to_docplex[m.b2['b']] == 1)) + def test_write_logical_or(self): m = self.get_model() m.c = LogicalConstraint(expr=m.b.lor(m.i.is_present)) @@ -239,6 +297,9 @@ def test_write_logical_or(self): self.assertTrue(expr[1].equals(cp.logical_or(b, cp.presence_of(i)))) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + def test_write_xor(self): m = self.get_model() m.c = LogicalConstraint(expr=m.b.xor(m.i2[2].start_time >= 5)) @@ -256,6 +317,9 @@ def test_write_xor(self): expr[1].equals(cp.count([b, cp.less_or_equal(5, cp.start_of(i22))], 1) == 1) ) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + def test_write_logical_not(self): m = self.get_model() m.c = LogicalConstraint(expr=~m.b2['a']) @@ -267,6 +331,8 @@ def test_write_logical_not(self): self.assertTrue(expr[1].equals(cp.logical_not(b2a))) + self.assertTrue(b2a.equals(visitor.pyomo_to_docplex[m.b2['a']] == 1)) + def test_equivalence(self): m = self.get_model() m.c = LogicalConstraint(expr=equivalent(~m.b2['a'], m.b)) @@ -280,18 +346,8 @@ def test_equivalence(self): self.assertTrue(expr[1].equals(cp.equal(cp.logical_not(b2a), b))) - def test_implication(self): - m = self.get_model() - m.c = LogicalConstraint(expr=m.b2['a'].implies(~m.b)) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.expr, m.c, 0)) - - self.assertIn(id(m.b), visitor.var_map) - self.assertIn(id(m.b2['a']), visitor.var_map) - b = visitor.var_map[id(m.b)] - b2a = visitor.var_map[id(m.b2['a'])] - - self.assertTrue(expr[1].equals(cp.if_then(b2a, cp.logical_not(b)))) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertTrue(b2a.equals(visitor.pyomo_to_docplex[m.b2['a']] == 1)) def test_equality(self): m = self.get_model() @@ -308,6 +364,9 @@ def test_equality(self): self.assertTrue(expr[1].equals(cp.if_then(b, cp.equal(a3, 4)))) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_docplex[m.a[3]], a3) + def test_inequality(self): m = self.get_model() m.a.domain = Integers @@ -325,6 +384,10 @@ def test_inequality(self): self.assertTrue(expr[1].equals(cp.if_then(b, cp.less_or_equal(a4, a3)))) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_docplex[m.a[3]], a3) + self.assertIs(visitor.pyomo_to_docplex[m.a[4]], a4) + def test_ranged_inequality(self): m = self.get_model() m.a.domain = Integers @@ -355,6 +418,10 @@ def test_not_equal(self): self.assertTrue(expr[1].equals(cp.if_then(b, a3 != a4))) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_docplex[m.a[3]], a3) + self.assertIs(visitor.pyomo_to_docplex[m.a[4]], a4) + def test_exactly_expression(self): m = self.get_model() m.a.domain = Integers @@ -367,6 +434,7 @@ def test_exactly_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] + self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) self.assertTrue( expr[1].equals(cp.equal(cp.count([a[i] == 4 for i in m.I], 1), 3)) @@ -384,6 +452,7 @@ def test_atleast_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] + self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) self.assertTrue( expr[1].equals( @@ -403,6 +472,7 @@ def test_atmost_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] + self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) self.assertTrue( expr[1].equals(cp.less_or_equal(cp.count([a[i] == 4 for i in m.I], 1), 3)) @@ -421,6 +491,7 @@ def test_all_diff_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] + self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) self.assertTrue(expr[1].equals(cp.all_diff(a[i] for i in m.I))) @@ -437,6 +508,7 @@ def test_count_if_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] + self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) self.assertTrue(expr[1].equals(cp.count((a[i] == i for i in m.I), 1) == 5)) @@ -455,6 +527,9 @@ def test_interval_var_is_present(self): self.assertTrue(expr[1].equals(cp.if_then(cp.presence_of(i), a1 == 5))) + self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + def test_interval_var_is_present_indirection(self): m = self.get_model() m.a.domain = Integers @@ -488,6 +563,11 @@ def test_interval_var_is_present_indirection(self): ) ) + self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + def test_is_present_indirection_and_length(self): m = self.get_model() m.y = Var(domain=Integers, bounds=[1, 2]) @@ -522,6 +602,10 @@ def test_is_present_indirection_and_length(self): ) ) + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + def test_handle_getattr_lor(self): m = self.get_model() m.y = Var(domain=Integers, bounds=(1, 2)) @@ -553,6 +637,11 @@ def test_handle_getattr_lor(self): ) ) + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + def test_handle_getattr_xor(self): m = self.get_model() m.y = Var(domain=Integers, bounds=(1, 2)) @@ -591,6 +680,11 @@ def test_handle_getattr_xor(self): ) ) + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + def test_handle_getattr_equivalent_to(self): m = self.get_model() m.y = Var(domain=Integers, bounds=(1, 2)) @@ -622,6 +716,11 @@ def test_handle_getattr_equivalent_to(self): ) ) + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + def test_logical_or_on_indirection(self): m = ConcreteModel() m.b = BooleanVar([2, 3, 4, 5]) @@ -651,6 +750,11 @@ def test_logical_or_on_indirection(self): ) ) + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertTrue(b3.equals(visitor.pyomo_to_docplex[m.b[3]] == 1)) + self.assertTrue(b4.equals(visitor.pyomo_to_docplex[m.b[4]] == 1)) + self.assertTrue(b5.equals(visitor.pyomo_to_docplex[m.b[5]] == 1)) + def test_logical_xor_on_indirection(self): m = ConcreteModel() m.b = BooleanVar([2, 3, 4, 5]) @@ -685,6 +789,10 @@ def test_logical_xor_on_indirection(self): ) ) + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertTrue(b3.equals(visitor.pyomo_to_docplex[m.b[3]] == 1)) + self.assertTrue(b5.equals(visitor.pyomo_to_docplex[m.b[5]] == 1)) + def test_using_precedence_expr_as_boolean_expr(self): m = self.get_model() e = m.b.implies(m.i2[2].start_time.before(m.i2[1].start_time)) @@ -704,6 +812,10 @@ def test_using_precedence_expr_as_boolean_expr(self): expr[1].equals(cp.if_then(b, cp.start_of(i22) + 0 <= cp.start_of(i21))) ) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + def test_using_precedence_expr_as_boolean_expr_positive_delay(self): m = self.get_model() e = m.b.implies(m.i2[2].start_time.before(m.i2[1].start_time, delay=4)) @@ -723,6 +835,10 @@ def test_using_precedence_expr_as_boolean_expr_positive_delay(self): expr[1].equals(cp.if_then(b, cp.start_of(i22) + 4 <= cp.start_of(i21))) ) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + def test_using_precedence_expr_as_boolean_expr_negative_delay(self): m = self.get_model() e = m.b.implies(m.i2[2].start_time.at(m.i2[1].start_time, delay=-3)) @@ -742,6 +858,10 @@ def test_using_precedence_expr_as_boolean_expr_negative_delay(self): expr[1].equals(cp.if_then(b, cp.start_of(i22) + (-3) == cp.start_of(i21))) ) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + @unittest.skipIf(not docplex_available, "docplex is not available") class TestCPExpressionWalker_IntervalVars(CommonTest): @@ -755,6 +875,7 @@ def test_interval_var_fixed_presences_correct(self): i = visitor.var_map[id(m.i)] # Check that docplex knows it's optional self.assertTrue(i.is_optional()) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) # Now fix it to absent m.i.is_present.fix(False) @@ -765,8 +886,10 @@ def test_interval_var_fixed_presences_correct(self): self.assertIn(id(m.i2[1]), visitor.var_map) i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertIn(id(m.i), visitor.var_map) i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) # Check that we passed on the presence info to docplex self.assertTrue(i.is_absent()) @@ -785,6 +908,7 @@ def test_interval_var_fixed_length(self): self.assertIn(id(m.i), visitor.var_map) i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) self.assertTrue(i.is_optional()) self.assertEqual(i.get_length(), (4, 4)) @@ -802,12 +926,85 @@ def test_interval_var_fixed_start_and_end(self): self.assertIn(id(m.i), visitor.var_map) i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) self.assertFalse(i.is_optional()) self.assertEqual(i.get_start(), (3, 3)) self.assertEqual(i.get_end(), (6, 6)) +@unittest.skipIf(not docplex_available, "docplex is not available") +class TestCPExpressionWalker_SequenceVars(CommonTest): + def get_model(self): + m = super().get_model() + m.seq = SequenceVar(expr=[m.i, m.i2[1], m.i2[2]]) + + return m + + def check_scalar_sequence_var(self, m, visitor): + self.assertIn(id(m.seq), visitor.var_map) + seq = visitor.var_map[id(m.seq)] + self.assertIs(visitor.pyomo_to_docplex[m.seq], seq) + + i = visitor.var_map[id(m.i)] + i21 = visitor.var_map[id(m.i2[1])] + i22 = visitor.var_map[id(m.i2[2])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + + ivs = seq.get_interval_variables() + self.assertEqual(len(ivs), 3) + self.assertIs(ivs[0], i) + self.assertIs(ivs[1], i21) + self.assertIs(ivs[2], i22) + + return seq, i, i21, i22 + + def test_scalar_sequence_var(self): + m = self.get_model() + + visitor = self.get_visitor() + expr = visitor.walk_expression((m.seq, m.seq, 0)) + self.check_scalar_sequence_var(m, visitor) + + def test_no_overlap(self): + m = self.get_model() + e = no_overlap(m.seq) + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + seq, i, i21, i22 = self.check_scalar_sequence_var(m, visitor) + self.assertTrue(expr[1].equals(cp.no_overlap(seq))) + + def test_first_in_sequence(self): + m = self.get_model() + e = first_in_sequence(m.i2[1], m.seq) + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + seq, i, i21, i22 = self.check_scalar_sequence_var(m, visitor) + self.assertTrue(expr[1].equals(cp.first(seq, i21))) + + def test_before_in_sequence(self): + m = self.get_model() + e = last_in_sequence(m.i, m.seq) + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + seq, i, i21, i22 = self.check_scalar_sequence_var(m, visitor) + self.assertTrue(expr[1].equals(cp.last(seq, i))) + + def test_last_in_sequence(self): + m = self.get_model() + e = last_in_sequence(m.i2[1], m.seq) + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + seq, i, i21, i22 = self.check_scalar_sequence_var(m, visitor) + self.assertTrue(expr[1].equals(cp.last(seq, i21))) + + @unittest.skipIf(not docplex_available, "docplex is not available") class TestCPExpressionWalker_PrecedenceExpressions(CommonTest): def test_start_before_start(self): @@ -821,6 +1018,8 @@ def test_start_before_start(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.start_before_start(i, i21, 0))) @@ -835,6 +1034,8 @@ def test_start_before_end(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.start_before_end(i, i21, 3))) @@ -849,6 +1050,8 @@ def test_end_before_start(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.end_before_start(i, i21, -2))) @@ -863,6 +1066,8 @@ def test_end_before_end(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.end_before_end(i, i21, 6))) @@ -877,6 +1082,8 @@ def test_start_at_start(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.start_at_start(i, i21, 0))) @@ -891,6 +1098,8 @@ def test_start_at_end(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.start_at_end(i, i21, 3))) @@ -905,6 +1114,8 @@ def test_end_at_start(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.end_at_start(i, i21, -2))) @@ -919,6 +1130,8 @@ def test_end_at_end(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.end_at_end(i, i21, 6))) @@ -943,6 +1156,10 @@ def test_indirection_before_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) self.assertTrue( expr[1].equals( @@ -969,6 +1186,10 @@ def test_indirection_after_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) self.assertTrue( expr[1].equals( @@ -996,6 +1217,10 @@ def test_indirection_at_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) self.assertTrue( expr[1].equals( @@ -1023,6 +1248,10 @@ def test_before_indirection_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) self.assertTrue( expr[1].equals( @@ -1048,6 +1277,10 @@ def test_after_indirection_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) self.assertTrue( expr[1].equals( @@ -1073,6 +1306,10 @@ def test_at_indirection_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i], i) self.assertTrue( expr[1].equals( @@ -1107,6 +1344,13 @@ def test_double_indirection_before_constraint(self): i33 = visitor.var_map[id(m.i3[1, 3])] i34 = visitor.var_map[id(m.i3[1, 4])] i35 = visitor.var_map[id(m.i3[1, 5])] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 3]], i33) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 4]], i34) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 5]], i35) self.assertTrue( expr[1].equals( @@ -1144,6 +1388,13 @@ def test_double_indirection_after_constraint(self): i33 = visitor.var_map[id(m.i3[1, 3])] i34 = visitor.var_map[id(m.i3[1, 4])] i35 = visitor.var_map[id(m.i3[1, 5])] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 3]], i33) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 4]], i34) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 5]], i35) self.assertTrue( expr[1].equals( @@ -1179,6 +1430,13 @@ def test_double_indirection_at_constraint(self): i33 = visitor.var_map[id(m.i3[1, 3])] i34 = visitor.var_map[id(m.i3[1, 4])] i35 = visitor.var_map[id(m.i3[1, 5])] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 3]], i33) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 4]], i34) + self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 5]], i35) self.assertTrue( expr[1].equals( @@ -1226,10 +1484,91 @@ def param_rule(m, i): self.assertIn(id(m.a), visitor.var_map) x = visitor.var_map[id(m.x)] a = visitor.var_map[id(m.a)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_docplex[m.a], a) self.assertTrue(expr[1].equals(cp.element([2, 4, 6], 0 + 1 * (x - 1) // 2) / a)) +@unittest.skipIf(not docplex_available, "docplex is not available") +class TestCPExpressionWalker_HierarchicalScheduling(CommonTest): + def get_model(self): + m = ConcreteModel() + + def start_rule(m, i): + return 2 * i + + def length_rule(m, i): + return i + + m.iv = IntervalVar( + [1, 2, 3], start=start_rule, length=length_rule, optional=True + ) + m.whole_enchilada = IntervalVar() + + return m + + def test_spans(self): + m = self.get_model() + e = m.whole_enchilada.spans(m.iv[i] for i in [1, 2, 3]) + + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + self.assertIn(id(m.whole_enchilada), visitor.var_map) + whole_enchilada = visitor.var_map[id(m.whole_enchilada)] + self.assertIs(visitor.pyomo_to_docplex[m.whole_enchilada], whole_enchilada) + + iv = {} + for i in [1, 2, 3]: + self.assertIn(id(m.iv[i]), visitor.var_map) + iv[i] = visitor.var_map[id(m.iv[i])] + + self.assertTrue( + expr[1].equals(cp.span(whole_enchilada, [iv[i] for i in [1, 2, 3]])) + ) + + def test_alternative(self): + m = self.get_model() + e = alternative(m.whole_enchilada, [m.iv[i] for i in [1, 2, 3]]) + + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + self.assertIn(id(m.whole_enchilada), visitor.var_map) + whole_enchilada = visitor.var_map[id(m.whole_enchilada)] + self.assertIs(visitor.pyomo_to_docplex[m.whole_enchilada], whole_enchilada) + + iv = {} + for i in [1, 2, 3]: + self.assertIn(id(m.iv[i]), visitor.var_map) + iv[i] = visitor.var_map[id(m.iv[i])] + + self.assertTrue( + expr[1].equals(cp.alternative(whole_enchilada, [iv[i] for i in [1, 2, 3]])) + ) + + def test_synchronize(self): + m = self.get_model() + e = synchronize(m.whole_enchilada, [m.iv[i] for i in [1, 2, 3]]) + + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + self.assertIn(id(m.whole_enchilada), visitor.var_map) + whole_enchilada = visitor.var_map[id(m.whole_enchilada)] + self.assertIs(visitor.pyomo_to_docplex[m.whole_enchilada], whole_enchilada) + + iv = {} + for i in [1, 2, 3]: + self.assertIn(id(m.iv[i]), visitor.var_map) + iv[i] = visitor.var_map[id(m.iv[i])] + + self.assertTrue( + expr[1].equals(cp.synchronize(whole_enchilada, [iv[i] for i in [1, 2, 3]])) + ) + + @unittest.skipIf(not docplex_available, "docplex is not available") class TestCPExpressionWalker_CumulFuncExpressions(CommonTest): def test_always_in(self): @@ -1251,6 +1590,9 @@ def test_always_in(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) self.assertTrue( expr[1].equals( @@ -1266,6 +1608,24 @@ def test_always_in(self): ) ) + def test_always_in_single_pulse(self): + # This is a bit silly as you can tell whether or not it is feasible + # structurally, but there's no reason it couldn't happen. + m = self.get_model() + f = Pulse((m.i, 3)) + m.c = LogicalConstraint(expr=f.within((0, 3), (0, 10))) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.expr, m.c, 0)) + + self.assertIn(id(m.i), visitor.var_map) + + i = visitor.var_map[id(m.i)] + self.assertIs(visitor.pyomo_to_docplex[m.i], i) + + self.assertTrue( + expr[1].equals(cp.always_in(cp.pulse(i, 3), interval=(0, 10), min=0, max=3)) + ) + @unittest.skipIf(not docplex_available, "docplex is not available") class TestCPExpressionWalker_NamedExpressions(CommonTest): @@ -1279,6 +1639,7 @@ def test_named_expression(self): self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) self.assertTrue(expr[1].equals(x**2 + 7)) @@ -1292,6 +1653,7 @@ def test_repeated_named_expression(self): self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) self.assertTrue(expr[1].equals(x**2 + 7 + (-1) * (8 * (x**2 + 7)))) @@ -1322,6 +1684,7 @@ def test_fixed_integer_var(self): self.assertIn(id(m.a[2]), visitor.var_map) a2 = visitor.var_map[id(m.a[2])] + self.assertIs(visitor.pyomo_to_docplex[m.a[2]], a2) self.assertTrue(expr[1].equals(3 + a2)) @@ -1336,6 +1699,7 @@ def test_fixed_boolean_var(self): self.assertIn(id(m.b2['b']), visitor.var_map) b2b = visitor.var_map[id(m.b2['b'])] + self.assertTrue(b2b.equals(visitor.pyomo_to_docplex[m.b2['b']] == 1)) self.assertTrue(expr[1].equals(cp.logical_or(False, cp.logical_and(True, b2b)))) @@ -1349,13 +1713,16 @@ def test_indirection_single_index(self): self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) a = [] # only need indices 6, 7, and 8 from a, since that's what x is capable # of selecting. for idx in [6, 7, 8]: v = m.a[idx] self.assertIn(id(v), visitor.var_map) - a.append(visitor.var_map[id(v)]) + cpx_v = visitor.var_map[id(v)] + self.assertIs(visitor.pyomo_to_docplex[v], cpx_v) + a.append(cpx_v) # since x is between 6 and 8, we subtract 6 from it for it to be the # right index self.assertTrue(expr[1].equals(cp.element(a, 0 + 1 * (x - 6) // 1))) @@ -1373,8 +1740,10 @@ def test_indirection_multi_index_second_constant(self): for i in [6, 7, 8]: self.assertIn(id(m.z[i, 3]), visitor.var_map) z[i, 3] = visitor.var_map[id(m.z[i, 3])] + self.assertIs(visitor.pyomo_to_docplex[m.z[i, 3]], z[i, 3]) self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) self.assertTrue( expr[1].equals( @@ -1395,8 +1764,11 @@ def test_indirection_multi_index_first_constant(self): for i in [6, 7, 8]: self.assertIn(id(m.z[3, i]), visitor.var_map) z[3, i] = visitor.var_map[id(m.z[3, i])] + self.assertIs(visitor.pyomo_to_docplex[m.z[3, i]], z[3, i]) + self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) self.assertTrue( expr[1].equals( @@ -1418,8 +1790,11 @@ def test_indirection_multi_index_neither_constant_same_var(self): for j in [6, 7, 8]: self.assertIn(id(m.z[i, j]), visitor.var_map) z[i, j] = visitor.var_map[id(m.z[i, j])] + self.assertIs(visitor.pyomo_to_docplex[m.z[i, j]], z[i, j]) + self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) self.assertTrue( expr[1].equals( @@ -1443,12 +1818,17 @@ def test_indirection_multi_index_neither_constant_diff_vars(self): z = {} for i in [6, 7, 8]: for j in [1, 3, 5]: - self.assertIn(id(m.z[i, 3]), visitor.var_map) + self.assertIn(id(m.z[i, j]), visitor.var_map) z[i, j] = visitor.var_map[id(m.z[i, j])] + self.assertIs(visitor.pyomo_to_docplex[m.z[i, j]], z[i, j]) + self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIn(id(m.y), visitor.var_map) y = visitor.var_map[id(m.y)] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) self.assertTrue( expr[1].equals( @@ -1473,10 +1853,14 @@ def test_indirection_expression_index(self): for i in range(1, 8): self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] + self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] + self.assertIs(visitor.pyomo_to_docplex[m.x], x) self.assertIn(id(m.y), visitor.var_map) y = visitor.var_map[id(m.y)] + self.assertIs(visitor.pyomo_to_docplex[m.y], y) self.assertTrue( expr[1].equals( diff --git a/pyomo/contrib/cp/tests/test_docplex_writer.py b/pyomo/contrib/cp/tests/test_docplex_writer.py index b5f30f24440..3ea781e1146 100644 --- a/pyomo/contrib/cp/tests/test_docplex_writer.py +++ b/pyomo/contrib/cp/tests/test_docplex_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,16 @@ import pyomo.common.unittest as unittest from pyomo.common.fileutils import Executable -from pyomo.contrib.cp import IntervalVar, Pulse, Step, AlwaysIn +from pyomo.contrib.cp import ( + IntervalVar, + SequenceVar, + Pulse, + Step, + AlwaysIn, + first_in_sequence, + predecessor_to, + no_overlap, +) from pyomo.contrib.cp.repn.docplex_writer import LogicalToDoCplex from pyomo.environ import ( all_different, @@ -360,7 +369,6 @@ def test_matching_problem(self): results.solver.termination_condition, TerminationCondition.optimal ) self.assertEqual(value(m.obj), perfect) - m.person_name.pprint() self.assertEqual(value(m.person_name['P1']), 0) self.assertEqual(value(m.person_name['P2']), 1) self.assertEqual(value(m.person_name['P3']), 2) @@ -392,3 +400,25 @@ def test_matching_problem(self): results.solver.termination_condition, TerminationCondition.optimal ) self.assertEqual(value(m.obj), perfect) + + def test_scheduling_with_sequence_vars(self): + m = ConcreteModel() + m.Steps = Set(initialize=[1, 2, 3]) + + def length_rule(m, j): + return 2 * j + + m.i = IntervalVar(m.Steps, start=(0, 12), end=(0, 12), length=length_rule) + m.seq = SequenceVar(expr=[m.i[j] for j in m.Steps]) + m.first = LogicalConstraint(expr=first_in_sequence(m.i[1], m.seq)) + m.seq_order1 = LogicalConstraint(expr=predecessor_to(m.i[1], m.i[2], m.seq)) + m.seq_order2 = LogicalConstraint(expr=predecessor_to(m.i[2], m.i[3], m.seq)) + m.no_ovlerpa = LogicalConstraint(expr=no_overlap(m.seq)) + + results = SolverFactory('cp_optimizer').solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.feasible + ) + self.assertEqual(value(m.i[1].start_time), 0) + self.assertEqual(value(m.i[2].start_time), 2) + self.assertEqual(value(m.i[3].start_time), 6) diff --git a/pyomo/contrib/cp/tests/test_interval_var.py b/pyomo/contrib/cp/tests/test_interval_var.py index 1645258d98a..974ab7669e8 100644 --- a/pyomo/contrib/cp/tests/test_interval_var.py +++ b/pyomo/contrib/cp/tests/test_interval_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,7 +17,7 @@ IntervalVarPresence, ) from pyomo.core.expr import GetItemExpression, GetAttrExpression -from pyomo.environ import ConcreteModel, Integers, Set, value, Var +from pyomo.environ import ConcreteModel, Integers, Reference, Set, value, Var class TestScalarIntervalVar(unittest.TestCase): @@ -217,5 +217,24 @@ def test_index_by_expr(self): self.assertIs(thing2.args[0], thing1) self.assertEqual(thing2.args[1], 'start_time') - # TODO: But this is where it dies. expr1 = m.act[m.i, 2].start_time.before(m.act[m.i**2, 1].end_time) + + def test_reference(self): + m = ConcreteModel() + m.act = IntervalVar([1, 2], end=[0, 10], optional=True) + + thing = Reference(m.act[:].is_present) + self.assertIs(thing[1], m.act[1].is_present) + self.assertIs(thing[2], m.act[2].is_present) + + thing = Reference(m.act[:].start_time) + self.assertIs(thing[1], m.act[1].start_time) + self.assertIs(thing[2], m.act[2].start_time) + + thing = Reference(m.act[:].end_time) + self.assertIs(thing[1], m.act[1].end_time) + self.assertIs(thing[2], m.act[2].end_time) + + thing = Reference(m.act[:].length) + self.assertIs(thing[1], m.act[1].length) + self.assertIs(thing[2], m.act[2].length) diff --git a/pyomo/contrib/cp/tests/test_logical_to_disjunctive.py b/pyomo/contrib/cp/tests/test_logical_to_disjunctive.py index 3f66aa57726..79574c23777 100755 --- a/pyomo/contrib/cp/tests/test_logical_to_disjunctive.py +++ b/pyomo/contrib/cp/tests/test_logical_to_disjunctive.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -200,6 +200,41 @@ def test_equivalence(self): assertExpressionsEqual(self, m.cons[10].expr, m.z[5] >= 1) + def test_equivalent_to_True(self): + m = self.make_model() + e = m.a.equivalent_to(True) + + visitor = LogicalToDisjunctiveVisitor() + m.cons = visitor.constraints + m.z = visitor.z_vars + + visitor.walk_expression(e) + + self.assertIs(m.a.get_associated_binary(), m.z[1]) + self.assertEqual(len(m.z), 4) + self.assertEqual(len(m.cons), 10) + + # z[2] == !a v True + assertExpressionsEqual( + self, m.cons[1].expr, (1 - m.z[2]) + (1 - m.z[1]) + 1 >= 1 + ) + assertExpressionsEqual(self, m.cons[2].expr, 1 - (1 - m.z[1]) + m.z[2] >= 1) + assertExpressionsEqual(self, m.cons[3].expr, m.z[2] + (1 - 1) >= 1) + + # z[3] == a v ! c + assertExpressionsEqual(self, m.cons[4].expr, (1 - m.z[3]) + m.z[1] >= 1) + assertExpressionsEqual(self, m.cons[5].expr, m.z[3] + (1 - m.z[1]) >= 1) + assertExpressionsEqual(self, m.cons[6].expr, m.z[3] + 1 >= 1) + + # z[4] == z[2] ^ z[3] + assertExpressionsEqual(self, m.cons[7].expr, m.z[4] <= m.z[2]) + assertExpressionsEqual(self, m.cons[8].expr, m.z[4] <= m.z[3]) + assertExpressionsEqual( + self, m.cons[9].expr, 1 - m.z[4] <= 2 - (m.z[2] + m.z[3]) + ) + + assertExpressionsEqual(self, m.cons[10].expr, m.z[4] >= 1) + def test_xor(self): m = self.make_model() e = m.a.xor(m.b) @@ -263,8 +298,6 @@ def test_at_most(self): # z3 = a ^ b assertExpressionsEqual(self, m.cons[1].expr, m.z[3] <= a) assertExpressionsEqual(self, m.cons[2].expr, m.z[3] <= b) - m.cons.pprint() - print(m.cons[3].expr) assertExpressionsEqual(self, m.cons[3].expr, 1 - m.z[3] <= 2 - sum([a, b])) # atmost in disjunctive form diff --git a/pyomo/contrib/cp/tests/test_precedence_constraints.py b/pyomo/contrib/cp/tests/test_precedence_constraints.py index 0a84a4d1960..bcc423a827d 100644 --- a/pyomo/contrib/cp/tests/test_precedence_constraints.py +++ b/pyomo/contrib/cp/tests/test_precedence_constraints.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,7 +15,7 @@ BeforeExpression, AtExpression, ) -from pyomo.environ import ConcreteModel, LogicalConstraint +from pyomo.environ import ConcreteModel, LogicalConstraint, Param class TestPrecedenceRelationships(unittest.TestCase): @@ -173,3 +173,17 @@ def test_end_after_end(self): self.assertEqual(m.c.expr.delay, 0) self.assertEqual(str(m.c.expr), "b.end_time <= a.end_time") + + def test_end_before_start_param_delay(self): + m = self.get_model() + m.PrepTime = Param(initialize=5) + m.c = LogicalConstraint( + expr=m.a.end_time.before(m.b.start_time, delay=m.PrepTime) + ) + self.assertIsInstance(m.c.expr, BeforeExpression) + self.assertEqual(len(m.c.expr.args), 3) + self.assertIs(m.c.expr.args[0], m.a.end_time) + self.assertIs(m.c.expr.args[1], m.b.start_time) + self.assertIs(m.c.expr.delay, m.PrepTime) + + self.assertEqual(str(m.c.expr), "a.end_time + PrepTime <= b.start_time") diff --git a/pyomo/contrib/cp/tests/test_sequence_expressions.py b/pyomo/contrib/cp/tests/test_sequence_expressions.py new file mode 100644 index 00000000000..87d3e0e7185 --- /dev/null +++ b/pyomo/contrib/cp/tests/test_sequence_expressions.py @@ -0,0 +1,175 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +from pyomo.contrib.cp.interval_var import IntervalVar +from pyomo.contrib.cp.scheduling_expr.scheduling_logic import ( + AlternativeExpression, + SpanExpression, + SynchronizeExpression, + alternative, + spans, + synchronize, +) +from pyomo.contrib.cp.scheduling_expr.sequence_expressions import ( + NoOverlapExpression, + FirstInSequenceExpression, + LastInSequenceExpression, + BeforeInSequenceExpression, + PredecessorToExpression, + no_overlap, + predecessor_to, + before_in_sequence, + first_in_sequence, + last_in_sequence, +) +from pyomo.contrib.cp.sequence_var import SequenceVar +from pyomo.environ import ConcreteModel, LogicalConstraint, Set + + +class TestSequenceVarExpressions(unittest.TestCase): + def get_model(self): + m = ConcreteModel() + m.S = Set(initialize=range(3)) + m.i = IntervalVar(m.S, start=(0, 5)) + m.seq = SequenceVar(expr=[m.i[j] for j in m.S]) + + return m + + def test_no_overlap(self): + m = self.get_model() + m.c = LogicalConstraint(expr=no_overlap(m.seq)) + e = m.c.expr + + self.assertIsInstance(e, NoOverlapExpression) + self.assertEqual(e.nargs(), 1) + self.assertEqual(len(e.args), 1) + self.assertIs(e.args[0], m.seq) + + self.assertEqual(str(e), "no_overlap(seq)") + + def test_first_in_sequence(self): + m = self.get_model() + m.c = LogicalConstraint(expr=first_in_sequence(m.i[2], m.seq)) + e = m.c.expr + + self.assertIsInstance(e, FirstInSequenceExpression) + self.assertEqual(e.nargs(), 2) + self.assertEqual(len(e.args), 2) + self.assertIs(e.args[0], m.i[2]) + self.assertIs(e.args[1], m.seq) + + self.assertEqual(str(e), "first_in(i[2], seq)") + + def test_last_in_sequence(self): + m = self.get_model() + m.c = LogicalConstraint(expr=last_in_sequence(m.i[0], m.seq)) + e = m.c.expr + + self.assertIsInstance(e, LastInSequenceExpression) + self.assertEqual(e.nargs(), 2) + self.assertEqual(len(e.args), 2) + self.assertIs(e.args[0], m.i[0]) + self.assertIs(e.args[1], m.seq) + + self.assertEqual(str(e), "last_in(i[0], seq)") + + def test_before_in_sequence(self): + m = self.get_model() + m.c = LogicalConstraint(expr=before_in_sequence(m.i[1], m.i[0], m.seq)) + e = m.c.expr + + self.assertIsInstance(e, BeforeInSequenceExpression) + self.assertEqual(e.nargs(), 3) + self.assertEqual(len(e.args), 3) + self.assertIs(e.args[0], m.i[1]) + self.assertIs(e.args[1], m.i[0]) + self.assertIs(e.args[2], m.seq) + + self.assertEqual(str(e), "before_in(i[1], i[0], seq)") + + def test_predecessor_in_sequence(self): + m = self.get_model() + m.c = LogicalConstraint(expr=predecessor_to(m.i[0], m.i[1], m.seq)) + e = m.c.expr + + self.assertIsInstance(e, PredecessorToExpression) + self.assertEqual(e.nargs(), 3) + self.assertEqual(len(e.args), 3) + self.assertIs(e.args[0], m.i[0]) + self.assertIs(e.args[1], m.i[1]) + self.assertIs(e.args[2], m.seq) + + self.assertEqual(str(e), "predecessor_to(i[0], i[1], seq)") + + +class TestHierarchicalSchedulingExpressions(unittest.TestCase): + def make_model(self): + m = ConcreteModel() + + def start_rule(m, i): + return 2 * i + + def length_rule(m, i): + return i + + m.iv = IntervalVar( + [1, 2, 3], start=start_rule, length=length_rule, optional=True + ) + m.whole_enchilada = IntervalVar() + + return m + + def check_span_expression(self, m, e): + self.assertIsInstance(e, SpanExpression) + self.assertEqual(e.nargs(), 4) + self.assertEqual(len(e.args), 4) + self.assertIs(e.args[0], m.whole_enchilada) + for i in [1, 2, 3]: + self.assertIs(e.args[i], m.iv[i]) + + self.assertEqual(str(e), "whole_enchilada.spans(iv[1], iv[2], iv[3])") + + def test_spans(self): + m = self.make_model() + e = spans(m.whole_enchilada, [m.iv[i] for i in [1, 2, 3]]) + self.check_span_expression(m, e) + + def test_spans_method(self): + m = self.make_model() + e = m.whole_enchilada.spans(m.iv[i] for i in [1, 2, 3]) + self.check_span_expression(m, e) + + def test_alternative(self): + m = self.make_model() + e = alternative(m.whole_enchilada, [m.iv[i] for i in [1, 2, 3]]) + + self.assertIsInstance(e, AlternativeExpression) + self.assertEqual(e.nargs(), 4) + self.assertEqual(len(e.args), 4) + self.assertIs(e.args[0], m.whole_enchilada) + for i in [1, 2, 3]: + self.assertIs(e.args[i], m.iv[i]) + + self.assertEqual(str(e), "alternative(whole_enchilada, [iv[1], iv[2], iv[3]])") + + def test_synchronize(self): + m = self.make_model() + e = synchronize(m.whole_enchilada, [m.iv[i] for i in [1, 2, 3]]) + + self.assertIsInstance(e, SynchronizeExpression) + self.assertEqual(e.nargs(), 4) + self.assertEqual(len(e.args), 4) + self.assertIs(e.args[0], m.whole_enchilada) + for i in [1, 2, 3]: + self.assertIs(e.args[i], m.iv[i]) + + self.assertEqual(str(e), "synchronize(whole_enchilada, [iv[1], iv[2], iv[3]])") diff --git a/pyomo/contrib/cp/tests/test_sequence_var.py b/pyomo/contrib/cp/tests/test_sequence_var.py new file mode 100644 index 00000000000..db52c5bab80 --- /dev/null +++ b/pyomo/contrib/cp/tests/test_sequence_var.py @@ -0,0 +1,158 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from io import StringIO +import pyomo.common.unittest as unittest +from pyomo.contrib.cp.interval_var import IntervalVar +from pyomo.contrib.cp.sequence_var import SequenceVar, IndexedSequenceVar +from pyomo.environ import ConcreteModel, Set + + +class TestScalarSequenceVar(unittest.TestCase): + def test_initialize_with_no_data(self): + m = ConcreteModel() + m.i = SequenceVar() + + self.assertIsInstance(m.i, SequenceVar) + self.assertIsInstance(m.i.interval_vars, list) + self.assertEqual(len(m.i.interval_vars), 0) + + m.iv1 = IntervalVar() + m.iv2 = IntervalVar() + m.i.set_value(expr=[m.iv1, m.iv2]) + + self.assertIsInstance(m.i.interval_vars, list) + self.assertEqual(len(m.i.interval_vars), 2) + self.assertIs(m.i.interval_vars[0], m.iv1) + self.assertIs(m.i.interval_vars[1], m.iv2) + + def get_model(self): + m = ConcreteModel() + m.S = Set(initialize=range(3)) + m.i = IntervalVar(m.S, start=(0, 5)) + m.seq = SequenceVar(expr=[m.i[j] for j in m.S]) + + return m + + def test_initialize_with_expr(self): + m = self.get_model() + self.assertEqual(len(m.seq.interval_vars), 3) + for j in m.S: + self.assertIs(m.seq.interval_vars[j], m.i[j]) + + def test_pprint(self): + m = self.get_model() + buf = StringIO() + m.seq.pprint(ostream=buf) + self.assertEqual( + buf.getvalue().strip(), + """ +seq : Size=1, Index=None + Key : IntervalVars + None : [i[0], i[1], i[2]] + """.strip(), + ) + + def test_interval_vars_not_a_list(self): + m = self.get_model() + + with self.assertRaisesRegex( + ValueError, + "'expr' for SequenceVar must be a list of IntervalVars. " + "Encountered type '' constructing 'seq2'", + ): + m.seq2 = SequenceVar(expr=1) + + def test_interval_vars_list_includes_things_that_are_not_interval_vars(self): + m = self.get_model() + + with self.assertRaisesRegex( + ValueError, + "The SequenceVar 'expr' argument must be a list of " + "IntervalVars. The 'expr' for SequenceVar 'seq2' included " + "an object of type ''", + ): + m.seq2 = SequenceVar(expr=m.i) + + +class TestIndexedSequenceVar(unittest.TestCase): + def test_initialize_with_not_data(self): + m = ConcreteModel() + m.i = SequenceVar([1, 2]) + + self.assertIsInstance(m.i, IndexedSequenceVar) + for j in [1, 2]: + self.assertIsInstance(m.i[j].interval_vars, list) + self.assertEqual(len(m.i[j].interval_vars), 0) + + m.iv = IntervalVar() + m.iv2 = IntervalVar([0, 1]) + m.i[2] = [m.iv] + [m.iv2[i] for i in [0, 1]] + + self.assertEqual(len(m.i[2].interval_vars), 3) + self.assertEqual(len(m.i[1].interval_vars), 0) + self.assertIs(m.i[2].interval_vars[0], m.iv) + for i in [0, 1]: + self.assertIs(m.i[2].interval_vars[i + 1], m.iv2[i]) + + def make_model(self): + m = ConcreteModel() + m.alphabetic = Set(initialize=['a', 'b']) + m.numeric = Set(initialize=[1, 2]) + m.i = IntervalVar(m.alphabetic, m.numeric) + + def the_rule(m, j): + return [m.i[j, k] for k in m.numeric] + + m.seq = SequenceVar(m.alphabetic, rule=the_rule) + + return m + + def test_initialize_with_rule(self): + m = self.make_model() + + self.assertIsInstance(m.seq, IndexedSequenceVar) + self.assertEqual(len(m.seq), 2) + for j in m.alphabetic: + self.assertTrue(j in m.seq) + self.assertEqual(len(m.seq[j].interval_vars), 2) + for k in m.numeric: + self.assertIs(m.seq[j].interval_vars[k - 1], m.i[j, k]) + + def test_pprint(self): + m = self.make_model() + m.seq.pprint() + + buf = StringIO() + m.seq.pprint(ostream=buf) + self.assertEqual( + buf.getvalue().strip(), + """ +seq : Size=2, Index=alphabetic + Key : IntervalVars + a : [i[a,1], i[a,2]] + b : [i[b,1], i[b,2]]""".strip(), + ) + + def test_multidimensional_index(self): + m = self.make_model() + + @m.SequenceVar(m.alphabetic, m.numeric) + def s(m, i, j): + return [m.i[i, j]] + + self.assertIsInstance(m.s, IndexedSequenceVar) + self.assertEqual(len(m.s), 4) + for i in m.alphabetic: + for j in m.numeric: + self.assertTrue((i, j) in m.s) + self.assertEqual(len(m.s[i, j].interval_vars), 1) + self.assertIs(m.s[i, j].interval_vars[0], m.i[i, j]) diff --git a/pyomo/contrib/cp/tests/test_step_function_expressions.py b/pyomo/contrib/cp/tests/test_step_function_expressions.py index a7b30c1d4e6..f54e3e1f244 100644 --- a/pyomo/contrib/cp/tests/test_step_function_expressions.py +++ b/pyomo/contrib/cp/tests/test_step_function_expressions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/cp/transform/__init__.py b/pyomo/contrib/cp/transform/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/cp/transform/__init__.py +++ b/pyomo/contrib/cp/transform/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/cp/transform/logical_to_disjunctive_program.py b/pyomo/contrib/cp/transform/logical_to_disjunctive_program.py index e318e621e88..15191e2ba88 100644 --- a/pyomo/contrib/cp/transform/logical_to_disjunctive_program.py +++ b/pyomo/contrib/cp/transform/logical_to_disjunctive_program.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,6 @@ from pyomo.contrib.cp.transform.logical_to_disjunctive_walker import ( LogicalToDisjunctiveVisitor, ) -from pyomo.common.collections import ComponentMap from pyomo.common.modeling import unique_component_name from pyomo.common.config import ConfigDict, ConfigValue @@ -26,7 +25,7 @@ Transformation, NonNegativeIntegers, ) -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.core.base import SortComponents from pyomo.core.util import target_list from pyomo.gdp import Disjunct, Disjunction @@ -73,7 +72,7 @@ def _apply_to(self, model, **kwds): transBlocks = {} visitor = LogicalToDisjunctiveVisitor() for t in targets: - if t.ctype is Block or isinstance(t, _BlockData): + if t.ctype is Block or isinstance(t, BlockData): self._transform_block(t, model, visitor, transBlocks) elif t.ctype is LogicalConstraint: if t.is_indexed(): diff --git a/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py b/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py index d5f13e91535..157f02b6803 100644 --- a/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py +++ b/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,14 +9,10 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import collections - from pyomo.common.collections import ComponentMap from pyomo.common.errors import MouseTrap from pyomo.core.expr.expr_common import ExpressionType from pyomo.core.expr.visitor import StreamBasedExpressionVisitor -from pyomo.core.expr.numeric_expr import NumericExpression -from pyomo.core.expr.relational_expr import RelationalExpression import pyomo.core.expr as EXPR from pyomo.core.base import ( Binary, @@ -27,9 +23,9 @@ value, ) import pyomo.core.base.boolean_var as BV -from pyomo.core.base.expression import ScalarExpression, _GeneralExpressionData -from pyomo.core.base.param import ScalarParam, _ParamData -from pyomo.core.base.var import ScalarVar, _GeneralVarData +from pyomo.core.base.expression import ScalarExpression, ExpressionData +from pyomo.core.base.param import ScalarParam, ParamData +from pyomo.core.base.var import ScalarVar, VarData from pyomo.gdp.disjunct import AutoLinkedBooleanVar, Disjunct, Disjunction @@ -55,14 +51,7 @@ def _dispatch_var(visitor, node): def _dispatch_param(visitor, node): - if int(value(node)) == value(node): - return False, node - else: - raise ValueError( - "Found non-integer valued Param '%s' in a logical " - "expression. This cannot be written to a disjunctive " - "form." % node.name - ) + return False, node def _dispatch_expression(visitor, node): @@ -209,15 +198,15 @@ def _dispatch_atmost(visitor, node, *args): _before_child_dispatcher = {} _before_child_dispatcher[BV.ScalarBooleanVar] = _dispatch_boolean_var -_before_child_dispatcher[BV._GeneralBooleanVarData] = _dispatch_boolean_var +_before_child_dispatcher[BV.BooleanVarData] = _dispatch_boolean_var _before_child_dispatcher[AutoLinkedBooleanVar] = _dispatch_boolean_var -_before_child_dispatcher[_ParamData] = _dispatch_param +_before_child_dispatcher[ParamData] = _dispatch_param _before_child_dispatcher[ScalarParam] = _dispatch_param # for the moment, these are all just so we can get good error messages when we # don't handle them: _before_child_dispatcher[ScalarVar] = _dispatch_var -_before_child_dispatcher[_GeneralVarData] = _dispatch_var -_before_child_dispatcher[_GeneralExpressionData] = _dispatch_expression +_before_child_dispatcher[VarData] = _dispatch_var +_before_child_dispatcher[ExpressionData] = _dispatch_expression _before_child_dispatcher[ScalarExpression] = _dispatch_expression @@ -248,6 +237,12 @@ def initializeWalker(self, expr): def beforeChild(self, node, child, child_idx): if child.__class__ in EXPR.native_types: + if child.__class__ is bool: + # If we encounter a bool, we are going to need to treat it as + # binary explicitly because we are finally pedantic enough in the + # expression system to not allow some of the mixing we will need + # (like summing a LinearExpression with a bool) + return False, int(child) return False, child if child.is_numeric_type(): diff --git a/pyomo/contrib/cspline_external/__init__.py b/pyomo/contrib/cspline_external/__init__.py new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/cspline_external/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/cspline_external/build.py b/pyomo/contrib/cspline_external/build.py new file mode 100644 index 00000000000..4fc573d6710 --- /dev/null +++ b/pyomo/contrib/cspline_external/build.py @@ -0,0 +1,32 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import sys +from pyomo.common.cmake_builder import build_cmake_project + + +def build_cspline_external(user_args=[], parallel=None): + return build_cmake_project( + targets=["src"], + package_name="cspline_1d_external", + description="ASL external cubic spline interpolation function library", + user_args=["-DBUILD_AMPLASL_IF_NEEDED=ON"] + user_args, + parallel=parallel, + ) + + +class ASLCsplineExternalBuilder(object): + def __call__(self, parallel): + return build_cspline_external(parallel=parallel) + + +if __name__ == "__main__": + build_cspline_external(sys.argv[1:]) diff --git a/pyomo/contrib/cspline_external/cspline_parameters.py b/pyomo/contrib/cspline_external/cspline_parameters.py new file mode 100644 index 00000000000..1086fbc78d4 --- /dev/null +++ b/pyomo/contrib/cspline_external/cspline_parameters.py @@ -0,0 +1,315 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import numpy as np +import pyomo.environ as pyo + + +def _f_cubic(x, alpha, s=None): + """ + Cubic function: + y = a1 + a2 * x + a3 * x^2 + a4 * x^3 + + Optionally if s is provided it is a segment index. + y = a1[s] + a2[s] * x + a3[s] * x^2 + a4[s] * x^3 + + This is used to write constraints more compactly. + + Args: + x: x variable, numeric, numpy array, or Pyomo component + alpha: cubic parameters, numeric or Pyomo component + s: optional segment index + + Returns: + Pyomo expression, numpy array, or float + """ + if s is None: + return alpha[1] + alpha[2] * x + alpha[3] * x**2 + alpha[4] * x**3 + return alpha[s, 1] + alpha[s, 2] * x + alpha[s, 3] * x**2 + alpha[s, 4] * x**3 + + +def _fx_cubic(x, alpha, s=None): + """ + Cubic function first derivative: + dy/dx = a2 + 2 * a3 * x + 3 * a4 * x^2 + + Optionally if s is provided it is a segment index. + dy/dx = a2[s] + 2 * a3[s] * x + 3 * a4[s] * x^2 + + This is used to write constraints more compactly. + + Args: + x: x variable, numeric, numpy array, or Pyomo component + alpha: cubic parameters, numeric or Pyomo component + s: optional segment index + + Returns: + Pyomo expression, numpy array, or float + """ + if s is None: + return alpha[2] + 2 * alpha[3] * x + 3 * alpha[4] * x**2 + return alpha[s, 2] + 2 * alpha[s, 3] * x + 3 * alpha[s, 4] * x**2 + + +def _fxx_cubic(x, alpha, s=None): + """ + Cubic function second derivative: + d2y/dx2 = 2 * a3 + 6 * a4 * x + + Optionally if s is provided it is a segment index. + d2y/dx2 = 2 * a3[s] + 6 * a4[s] * x + + This is used to write constraints more compactly. + + Args: + x: x variable, numeric, numpy array, or Pyomo component + alpha: cubic parameters, numeric or Pyomo component + s: optional segment index + + Returns: + Pyomo expression, numpy array, or float + """ + if s is None: + return 2 * alpha[3] + 6 * alpha[4] * x + return 2 * alpha[s, 3] + 6 * alpha[s, 4] * x + + +class CsplineParameters: + def __init__(self, model=None, fptr=None): + """Cubic spline parameters class. This can be used to read and + write parameters or calculate cubic spline function values and + derivatives for testing. + """ + if model is not None and fptr is not None: + raise ValueError("Please specify at most one of model or fptr.") + if model is not None: + self.get_parameters_from_model(model) + elif fptr is not None: + self.get_parameters_from_file(fptr) + else: + self.knots = np.array([]) + self.a1 = np.array([]) + self.a2 = np.array([]) + self.a3 = np.array([]) + self.a4 = np.array([]) + + @property + def n_knots(self): + """Number of knots""" + return len(self.knots) + + @property + def n_segments(self): + """Number of segments""" + return len(self.knots) - 1 + + @property + def valid(self): + """Ensure that the number of knots and cubic parameters is valid""" + return ( + len(self.a1) == self.n_segments + and len(self.a2) == self.n_segments + and len(self.a3) == self.n_segments + and len(self.a4) == self.n_segments + ) + + def get_parameters_from_model(self, m): + """Read parameters from a Pyomo model used to calculate them""" + self.knots = [pyo.value(x) for x in m.x.values()] + self.a1 = [None] * len(m.seg_idx) + self.a2 = [None] * len(m.seg_idx) + self.a3 = [None] * len(m.seg_idx) + self.a4 = [None] * len(m.seg_idx) + for s in m.seg_idx: + self.a1[s - 1] = pyo.value(m.alpha[s, 1]) + self.a2[s - 1] = pyo.value(m.alpha[s, 2]) + self.a3[s - 1] = pyo.value(m.alpha[s, 3]) + self.a4[s - 1] = pyo.value(m.alpha[s, 4]) + self.knots = np.array(self.knots) + self.a1 = np.array(self.a1) + self.a2 = np.array(self.a2) + self.a3 = np.array(self.a3) + self.a4 = np.array(self.a4) + + def get_parameters_from_file(self, fptr): + """Read parameters from a file""" + # line 1: number of segments + ns = int(fptr.readline()) + # Make param lists + self.knots = [None] * (ns + 1) + self.a1 = [None] * ns + self.a2 = [None] * ns + self.a3 = [None] * ns + self.a4 = [None] * ns + # Read params + for i in range(ns + 1): + self.knots[i] = float(fptr.readline()) + for a in [self.a1, self.a2, self.a3, self.a4]: + for i in range(ns): + a[i] = float(fptr.readline()) + self.knots = np.array(self.knots) + self.a1 = np.array(self.a1) + self.a2 = np.array(self.a2) + self.a3 = np.array(self.a3) + self.a4 = np.array(self.a4) + + def write_parameters(self, fptr): + """Write parameters to a file""" + assert self.valid + fptr.write(f"{self.n_segments}\n") + for l in [self.knots, self.a1, self.a2, self.a3, self.a4]: + for x in l: + fptr.write(f"{x}\n") + + def segment(self, x): + """Get the spline segment containing x. + + Args: + x: location, float or numpy array + + Returns: + segment(s) containing x, if x is a numpy array a numpy + array of integers is returned otherwise return an integer + """ + s = np.searchsorted(self.knots, x) + # If x is past the last knot, use the last segment + # this could happen just due to round-off even if + # you don't intend to extrapolate + s[s >= self.n_segments] = self.n_segments - 1 + return s + + def f(self, x): + """Get f(x) + + Args: + x: location, numpy array float + + Returns: + f(x) numpy array if x is numpy array or float + """ + s = self.segment(x) + return self.a1[s] + self.a2[s] * x + self.a3[s] * x**2 + self.a4[s] * x**3 + + +def cubic_parameters_model( + x_data, + y_data, + x_knots=None, + end_point_constraint=True, + objective_form=False, + name="cubic spline parameters model", +): + """Create a Pyomo model to calculate parameters for a cubic spline. By default + this creates a square linear model, but optionally it can leave off the endpoint + second derivative constraints and add an objective function for fitting data + instead. The purpose of the alternative least squares form is to allow the spline + to be constrained in other ways that don't require a perfect data match. The knots + don't need to be the same as the x data to allow, for example, additional segments + for extrapolation. This is not the most computationally efficient way to calculate + parameters, but since it is used to precalculate parameters, speed is not important. + + Args: + x_data: sorted list of x data + y_data: list of y data + x_knots: optional sorted list of knots (default is to use x_data) + end_point_constraint: if True add constraint that second derivative = 0 at + endpoints (default=True) + objective_form: if True write a least squares objective rather than constraints + to match data (default=False) + name: optional model name + + Returns: + Pyomo ConcreteModel + """ + n_data = len(x_data) + assert n_data == len(y_data) + if x_knots is None: + n_knots = n_data + x_knots = x_data + else: + n_knots = len(x_knots) + + m = pyo.ConcreteModel(name=name) + # Sets of indexes for knots, segments, and data + m.knt_idx = pyo.RangeSet(n_knots) + m.seg_idx = pyo.RangeSet(n_knots - 1) + m.dat_idx = pyo.RangeSet(n_data) + + m.x_data = pyo.Param(m.dat_idx, initialize={i + 1: x for i, x in enumerate(x_data)}) + m.y_data = pyo.Param(m.dat_idx, initialize={i + 1: x for i, x in enumerate(y_data)}) + m.x = pyo.Param(m.knt_idx, initialize={i + 1: x for i, x in enumerate(x_knots)}) + m.alpha = pyo.Var(m.seg_idx, {1, 2, 3, 4}, initialize=1) + + # f_s(x) = f_s+1(x) + @m.Constraint(m.seg_idx) + def y_eqn(blk, s): + if s == m.seg_idx.last(): + return pyo.Constraint.Skip + return _f_cubic(m.x[s + 1], m.alpha, s) == _f_cubic(m.x[s + 1], m.alpha, s + 1) + + # f'_s(x) = f'_s+1(x) + @m.Constraint(m.seg_idx) + def yx_eqn(blk, s): + if s == m.seg_idx.last(): + return pyo.Constraint.Skip + return _fx_cubic(m.x[s + 1], m.alpha, s) == _fx_cubic( + m.x[s + 1], m.alpha, s + 1 + ) + + # f"_s(x) = f"_s+1(x) + @m.Constraint(m.seg_idx) + def yxx_eqn(blk, s): + if s == m.seg_idx.last(): + return pyo.Constraint.Skip + return _fxx_cubic(m.x[s + 1], m.alpha, s) == _fxx_cubic( + m.x[s + 1], m.alpha, s + 1 + ) + + # Identify segments used to predict y_data at each x_data. We use search in + # instead of a dict lookup, since we don't want to require the data to be at + # the knots, even though that is almost always the case. + idx = np.searchsorted(x_knots, x_data) + + if end_point_constraint: + add_endpoint_second_derivative_constraints(m) + + # Expression for difference between data and prediction + @m.Expression(m.dat_idx) + def ydiff(blk, d): + s = idx[d - 1] + 1 + if s >= m.seg_idx.last(): + s -= 1 + return m.y_data[d] - _f_cubic(m.x_data[d], m.alpha, s) + + if objective_form: + # least squares objective + m.obj = pyo.Objective(expr=sum(m.ydiff[d] ** 2 for d in m.dat_idx)) + else: + + @m.Constraint(m.dat_idx) + def match_data(blk, d): + return m.ydiff[d] == 0 + + return m + + +def add_endpoint_second_derivative_constraints(m): + """Usually cubic splines use the endpoint constraints that the second + derivative is zero. This function adds those constraints to a model + """ + + @m.Constraint([m.seg_idx.first(), m.seg_idx.last()]) + def yxx_endpoint_eqn(blk, s): + if s == m.seg_idx.last(): + j = m.knt_idx.last() + else: + j = s + return _fxx_cubic(m.x[j], m.alpha, s) == 0 diff --git a/pyomo/solvers/tests/mip/test_mip.py b/pyomo/contrib/cspline_external/plugins.py similarity index 66% rename from pyomo/solvers/tests/mip/test_mip.py rename to pyomo/contrib/cspline_external/plugins.py index 58cdfe9f7de..a96d3efaba9 100644 --- a/pyomo/solvers/tests/mip/test_mip.py +++ b/pyomo/contrib/cspline_external/plugins.py @@ -1,23 +1,17 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -# -# Tests driven by test_mip.yml -# - -import os -from os.path import abspath, dirname -currdir = dirname(abspath(__file__)) + os.sep +from pyomo.common.extensions import ExtensionBuilderFactory +from pyomo.contrib.cspline_external.build import ASLCsplineExternalBuilder -import pyomo.common.unittest as unittest -if __name__ == "__main__": - unittest.main() +def load(): + ExtensionBuilderFactory.register("cspline_external")(ASLCsplineExternalBuilder) diff --git a/pyomo/contrib/cspline_external/src/CMakeLists.txt b/pyomo/contrib/cspline_external/src/CMakeLists.txt new file mode 100644 index 00000000000..9d080fc37c9 --- /dev/null +++ b/pyomo/contrib/cspline_external/src/CMakeLists.txt @@ -0,0 +1,43 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +cmake_minimum_required(VERSION 3.0...3.31) +# This was developed against CMake 3.0, and appears to comply with 3.5 + +PROJECT( cspline_external ) + +INCLUDE( + "${CMAKE_CURRENT_SOURCE_DIR}/../../ampl_function_demo/src/FindASL.cmake") + +# Targets in this project +OPTION(BUILD_EXTERNAL_FCN_LIBRARY + "Build the ASL external function example library" ON) + +IF( BUILD_EXTERNAL_FCN_LIBRARY ) + ADD_LIBRARY( cspline_external SHARED "functions.cpp" ) + TARGET_LINK_LIBRARIES( cspline_external + PUBLIC ${ASL_LIBRARY} ${CMAKE_DL_LIBS}) + TARGET_INCLUDE_DIRECTORIES( cspline_external + PUBLIC ${ASL_INCLUDE_DIR} + INTERFACE . ) + # If you need a CPP directive defined when building the library (e.g., + # for managing __declspec(dllimport) under Windows, uncomment the + # following: + #TARGET_COMPILE_DEFINITIONS( cspline_external PRIVATE BUILDING_ASL_DEMO ) + #SET_TARGET_PROPERTIES( cspline_external PROPERTIES ENABLE_EXPORTS 1 ) + INSTALL( TARGETS cspline_external LIBRARY DESTINATION lib + RUNTIME DESTINATION lib ) + IF( BUILD_AMPLASL ) + # If we are building AMPL/asl (from FindASL), it is possible that we + # are linking against it, so we will add the appropriate dependency + add_dependencies(cspline_external ampl_asl) + ENDIF() +ENDIF() diff --git a/pyomo/contrib/cspline_external/src/functions.cpp b/pyomo/contrib/cspline_external/src/functions.cpp new file mode 100644 index 00000000000..428759dcb7f --- /dev/null +++ b/pyomo/contrib/cspline_external/src/functions.cpp @@ -0,0 +1,185 @@ +/* ___________________________________________________________________________ + * Pyomo: Python Optimization Modeling Objects + * Copyright (c) 2008-2025 + * National Technology and Engineering Solutions of Sandia, LLC + * Under the terms of Contract DE-NA0003525 with National Technology and + * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain + * rights in this software. + * This software is distributed under the 3-clause BSD License. + * ___________________________________________________________________________ +*/ +#include +#include +#include +#include +#include +#include +#include + +#include "funcadd.h" + +// Don't want to have the overhead of reading parameter files +// each time, so keep params as long as library remains loaded + +// Number of loaded curves +unsigned int n_cspline = 0; + +// Parameter file path to unsigned in index map +std::unordered_map idx_cspline; + +// Knots for each curve +std::vector< std::vector > knots_cspline; + +// Cubic segment parameters for each curve +std::vector< std::vector > a1_cspline; +std::vector< std::vector > a2_cspline; +std::vector< std::vector > a3_cspline; +std::vector< std::vector > a4_cspline; + +// Number of segments in each curve +std::vector< unsigned int > n_seg_cspline; + +// The data file format is a text file with one parameter per line +// Line 1: Number of parameters +// nsegs + 1 lines for knots +// nsegs lines for a1 +// nsegs lines for a2 +// nsegs lines for a3 +// nsegs lines for a4 + +// Function to read parameter file if not read and store params +static int read_parameters_cspline(std::string file_path) { + try{ //if read already return curve index and done. + return idx_cspline.at(file_path); + } + catch(std::out_of_range const&){} // Not read so read it. + unsigned int i, idx, n; //loop counter, curve index, and number of segs + + // Set index for cspline param file and increment curve count + idx_cspline[file_path] = n_cspline; + idx = n_cspline; + ++n_cspline; + + // open the parameter file for input + // Assume if there is a newline in the file_path, that it is actually + // a string with the file contents. + std::ifstream file_stream; + std::istringstream string_stream(file_path); + + bool is_fname = false; + if(file_path.find('\n') == std::string::npos){ + file_stream.open(file_path); + is_fname = true; + } + + std::istream& param_file = is_fname ? + static_cast(file_stream) + : static_cast(string_stream); + + // get the number of segments and size to vectors + param_file >> n; + n_seg_cspline.resize(n_cspline); + n_seg_cspline[idx] = n; + knots_cspline.resize(n_cspline); + a1_cspline.resize(n_cspline); + a2_cspline.resize(n_cspline); + a3_cspline.resize(n_cspline); + a4_cspline.resize(n_cspline); + knots_cspline[idx].resize(n + 1); + a1_cspline[idx].resize(n); + a2_cspline[idx].resize(n); + a3_cspline[idx].resize(n); + a4_cspline[idx].resize(n); + + // get the knots + for(i=0; i < n + 1; ++i){ + param_file >> knots_cspline[idx][i]; + } + + // get the a1 params + for(i=0; i < n; ++i){ + param_file >> a1_cspline[idx][i]; + } + + // get the a2 params + for(i=0; i < n; ++i){ + param_file >> a2_cspline[idx][i]; + } + + // get the a3 params + for(i=0; i < n; ++i){ + param_file >> a3_cspline[idx][i]; + } + + // get the a4 params + for(i=0; i < n; ++i){ + param_file >> a4_cspline[idx][i]; + } + + file_stream.close(); + + // Returns curve index + return idx; +} + +// Calculate the cspline 1D function value and derivatives +extern real cspline(arglist *al) { + // Get the data file path argument + const char* data_file = al->sa[-(al->at[1]+1)]; + // Get the function input + real x = al->ra[al->at[0]]; + // Get parameters, read file if needed + unsigned int idx = read_parameters_cspline(data_file); + size_t seg; // segment index + real a1, a2, a3, a4; // segment parameters + + // Find segment index + auto lit = std::lower_bound(knots_cspline[idx].begin(), knots_cspline[idx].end(), x); + seg = lit - knots_cspline[idx].begin(); + + // If off the curve on the low side use first seg (0) otherwise + // subtract 1 to go from knot index to segment index (i.e. seg is + // initially how many knots are below, so 0 is less than first knot, + // 1 is above first knot..., so > 0 we need to decrement to get the + // segment index) + if(seg > 0) --seg; + // If off the curve on the high side, use last segment + if(seg >= n_seg_cspline[idx]) seg = n_seg_cspline[idx] - 1; + + // Get the parameters for the correct segment + a1 = a1_cspline[idx][seg]; + a2 = a2_cspline[idx][seg]; + a3 = a3_cspline[idx][seg]; + a4 = a4_cspline[idx][seg]; + + // Compute the first derivative, if requested. + if (al->derivs!=NULL) { + al->derivs[0] = a2 + 2*a3*x + 3*a4*x*x; + // Compute the second derivative, if requested. + if (al->hes!=NULL) { + al->hes[0] = 2*a3 + 6*a4*x; + } + } + + return a1 + a2*x + a3*x*x + a4*x*x*x; +} + +// Register external functions defined in this library with the ASL +void funcadd(AmplExports *ae){ + // Arguments for addfunc (this is not fully detailed; see funcadd.h) + // 1) Name of function in AMPL + // 2) Function pointer to C function + // 3) see FUNCADD_TYPE enum in funcadd.h + // 4) Number of arguments + // >=0 indicates a fixed number of arguments + // < 0 indicates a variable length list (requiring at least -(n+1) + // arguments) + // 5) Void pointer to function info + addfunc( + "cspline", + (rfunc)cspline, + FUNCADD_REAL_VALUED|FUNCADD_STRING_ARGS, + 2, + NULL + ); +} diff --git a/pyomo/contrib/cspline_external/test/__init__.py b/pyomo/contrib/cspline_external/test/__init__.py new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/cspline_external/test/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/cspline_external/test/test_external_function.py b/pyomo/contrib/cspline_external/test/test_external_function.py new file mode 100644 index 00000000000..fe083705691 --- /dev/null +++ b/pyomo/contrib/cspline_external/test/test_external_function.py @@ -0,0 +1,168 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +import os +import platform +import pyomo.common.unittest as unittest +import pyomo.environ as pyo +from pyomo.common.fileutils import find_library, this_file_dir + +_lib = find_library("cspline_external") +is_pypy = platform.python_implementation().lower().startswith("pypy") + + +@unittest.skipIf(is_pypy, "Cannot evaluate external functions under pypy") +@unittest.skipIf(not _lib, "cspline library is not available.") +class CsplineExternal1DTest(unittest.TestCase): + + def test_function_call(self): + """Test that the cspline behaves as expected""" + # The parameters for the function call are stored in + # this file. The file is only read once by the external + # function on the first call. + params = os.path.join(this_file_dir(), "test_params.txt") + # this is that data that was used to generate the params + x_data = [1, 2, 3, 4, 5] + y_data = [2, 3, 5, 2, 1] + # Model with the cspline function + m = pyo.ConcreteModel() + m.f = pyo.ExternalFunction(library=_lib, function="cspline") + m.x = pyo.Var(initialize=2) + m.e = pyo.Expression(expr=m.f(m.x, params)) + # We know that spline should go through the data points + # and that f(x) f'(x) and f''(x) for the right and left + # segment at a knot should be the same. + for x, y in zip(x_data, y_data): + delta = 1e-5 + f, fx, fxx = m.f.evaluate_fgh(args=(x, params)) + f_left, fx_left, fxx_left = m.f.evaluate_fgh(args=(x - delta, params)) + f_right, fx_right, fxx_right = m.f.evaluate_fgh(args=(x + delta, params)) + self.assertAlmostEqual(f, y, 8) + # check left/right + self.assertAlmostEqual(f_left, y, 3) + self.assertAlmostEqual(f_right, y, 3) + # check left/right derivatives + self.assertAlmostEqual(fx_left[0], fx[0], 3) + self.assertAlmostEqual(fx_right[0], fx[0], 3) + self.assertAlmostEqual(fxx_left[0], fxx[0], 3) + self.assertAlmostEqual(fxx_right[0], fxx[0], 3) + # we know the endpoint constraints are that f''(0) = 0 + f, fx, fxx = m.f.evaluate_fgh(args=(x_data[0], params)) + self.assertAlmostEqual(fxx[0], 0, 8) + f, fx, fxx = m.f.evaluate_fgh(args=(x_data[-1], params)) + self.assertAlmostEqual(fxx[0], 0, 8) + # check a little more in model context + self.assertAlmostEqual(pyo.value(m.e), 3, 8) + + def test_function_call_form2(self): + """Test that the cspline can take the full parameter file contents in str""" + params = os.path.join(this_file_dir(), "test_params.txt") + with open(params, "r") as fptr: + params = fptr.read() + # this is that data that was used to generate the params + x_data = [1, 2, 3, 4, 5] + y_data = [2, 3, 5, 2, 1] + # Model with the cspline function + m = pyo.ConcreteModel() + m.f = pyo.ExternalFunction(library=_lib, function="cspline") + m.x = pyo.Var(initialize=2) + m.e = pyo.Expression(expr=m.f(m.x, params)) + # We know that spline should go through the data points + # and that f(x) f'(x) and f''(x) for the right and left + # segment at a knot should be the same. + for x, y in zip(x_data, y_data): + delta = 1e-5 + f, fx, fxx = m.f.evaluate_fgh(args=(x, params)) + f_left, fx_left, fxx_left = m.f.evaluate_fgh(args=(x - delta, params)) + f_right, fx_right, fxx_right = m.f.evaluate_fgh(args=(x + delta, params)) + self.assertAlmostEqual(f, y, 8) + # check left/right + self.assertAlmostEqual(f_left, y, 3) + self.assertAlmostEqual(f_right, y, 3) + # check left/right derivatives + self.assertAlmostEqual(fx_left[0], fx[0], 3) + self.assertAlmostEqual(fx_right[0], fx[0], 3) + self.assertAlmostEqual(fxx_left[0], fxx[0], 3) + self.assertAlmostEqual(fxx_right[0], fxx[0], 3) + # we know the endpoint constraints are that f''(0) = 0 + f, fx, fxx = m.f.evaluate_fgh(args=(x_data[0], params)) + self.assertAlmostEqual(fxx[0], 0, 8) + f, fx, fxx = m.f.evaluate_fgh(args=(x_data[-1], params)) + self.assertAlmostEqual(fxx[0], 0, 8) + # check a little more in model context + self.assertAlmostEqual(pyo.value(m.e), 3, 8) + + def test_ampl_derivatives(self): + # Make sure the function values and derivatives are right + # based on parameters. This time look at segment mid-points + + # The parameters for the function call are stored in + # this file. The file is only read once by the external + # function on the first call. + params = os.path.join(this_file_dir(), "test_params.txt") + # this is that data that was used to generate the params + x_data = [1, 2, 3, 4, 5] + y_data = [2, 3, 5, 2, 1] + # Model with the cspline function + m = pyo.ConcreteModel() + m.f = pyo.ExternalFunction(library=_lib, function="cspline") + # Read parameters + with open(params, "r") as fptr: + # line 1: number of knots + ns = int(fptr.readline()) + # Make param lists + knots = [None] * (ns + 1) + a = [None] * 4 + for j in range(4): + a[j] = [None] * ns + + # Read params + for i in range(ns + 1): + knots[i] = float(fptr.readline()) + for j in range(4): + for i in range(ns): + a[j][i] = float(fptr.readline()) + # Check the value calculated by from parameters to external + # function for each segment. Use the mid. + for i in range(ns): + x = (x_data[i] + x_data[i + 1]) / 2.0 + y = a[0][i] + a[1][i] * x + a[2][i] * x**2 + a[3][i] * x**3 + yx = a[1][i] + 2 * a[2][i] * x + 3 * a[3][i] * x**2 + yxx = 2 * a[2][i] + 6 * a[3][i] * x + f, fx, fxx = m.f.evaluate_fgh(args=(x, params)) + self.assertAlmostEqual(f, y, 8) + self.assertAlmostEqual(fx[0], yx, 8) + self.assertAlmostEqual(fxx[0], yxx, 8) + + def test_load_multiple_splines(self): + # The last test ensures that you can load multiple splines + # for a model without trouble. You should be able to use + # as many parameter set as you want. The external function + # only reads a file once as long as the library remains loaded. + + # first spline is the one we used before + params1 = os.path.join(this_file_dir(), "test_params.txt") + # second spline is a line with intercept = 0, slope = 1 + params2 = os.path.join(this_file_dir(), "test_params_line.txt") + # Model with the cspline function + m = pyo.ConcreteModel() + m.f = pyo.ExternalFunction(library=_lib, function="cspline") + m.x = pyo.Var(initialize=1) + m.e1 = pyo.Expression(expr=m.f(m.x, params1)) + m.e2 = pyo.Expression(expr=m.f(m.x, params2)) + + # Make sure both functions return correct value + self.assertAlmostEqual(pyo.value(m.e1), 2, 8) + self.assertAlmostEqual(pyo.value(m.e2), 1, 8) + + # make sure loading the second set of parameters + # didn't mess up the first + self.assertAlmostEqual(pyo.value(m.e1), 2, 8) + self.assertAlmostEqual(pyo.value(m.e2), 1, 8) diff --git a/pyomo/contrib/cspline_external/test/test_parameter_calculation.py b/pyomo/contrib/cspline_external/test/test_parameter_calculation.py new file mode 100644 index 00000000000..d62afd7fdf1 --- /dev/null +++ b/pyomo/contrib/cspline_external/test/test_parameter_calculation.py @@ -0,0 +1,70 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import io +import os + +from pyomo.contrib.cspline_external.cspline_parameters import ( + cubic_parameters_model, + CsplineParameters, +) +from pyomo.opt import check_available_solvers +from pyomo.common.dependencies import numpy as np, numpy_available +import pyomo.environ as pyo +from pyomo.common.fileutils import this_file_dir +import pyomo.common.unittest as unittest + + +@unittest.skipUnless( + check_available_solvers("ipopt"), "The 'ipopt' solver is not available" +) +@unittest.skipIf(not numpy_available, "numpy is not available.") +class CsplineExternalParamsTest(unittest.TestCase): + def test_param_gen(self): + x_data = [1, 2, 3, 4, 5] + y_data = [2, 3, 5, 2, 1] + + m = cubic_parameters_model(x_data, y_data) + + # This is a linear system of equations by default, so although ipopt + # is not needed, it works perfectly well. + solver_obj = pyo.SolverFactory("ipopt") + solver_obj.solve(m) + + params = CsplineParameters(model=m) + + # Make sure we have the expected number of parameters + assert params.n_knots == 5 + assert params.n_segments == 4 + assert params.valid + + # Make sure the predictions are correct + y_pred = params.f(np.array(x_data)) + for yd, yp in zip(y_data, y_pred): + self.assertAlmostEqual(yd, yp, 8) + + # Make sure reading and writing parameters + # to/from files works right. + + fptr = io.StringIO("") + params.write_parameters(fptr) + # Go to start of string stream + fptr.seek(0, os.SEEK_SET) + + params.knots = np.array([1, 3]) + assert not params.valid + params.get_parameters_from_file(fptr) + assert params.valid + + # Make sure the predictions are correct + y_pred = params.f(np.array(x_data)) + for yd, yp in zip(y_data, y_pred): + self.assertAlmostEqual(yd, yp, 8) diff --git a/pyomo/contrib/cspline_external/test/test_params.txt b/pyomo/contrib/cspline_external/test/test_params.txt new file mode 100644 index 00000000000..36620929582 --- /dev/null +++ b/pyomo/contrib/cspline_external/test/test_params.txt @@ -0,0 +1,22 @@ +4 +1 +2 +3 +4 +5 +1.000000000000001 +24.71428571428584 +-106.4285714285722 +115.28571428571576 +2.3214285714285774 +-33.25000000000018 +97.89285714285785 +-68.39285714285812 +-1.9821428571428679 +15.803571428571509 +-27.9107142857145 +13.660714285714493 +0.6607142857142894 +-2.3035714285714404 +2.55357142857145 +-0.9107142857142996 diff --git a/pyomo/contrib/cspline_external/test/test_params_line.txt b/pyomo/contrib/cspline_external/test/test_params_line.txt new file mode 100644 index 00000000000..3015890f0c6 --- /dev/null +++ b/pyomo/contrib/cspline_external/test/test_params_line.txt @@ -0,0 +1,7 @@ +1 +0 +1 +0 +1 +0 +0 \ No newline at end of file diff --git a/pyomo/contrib/doe/__init__.py b/pyomo/contrib/doe/__init__.py index e45aa3b44a3..0295d6eed79 100644 --- a/pyomo/contrib/doe/__init__.py +++ b/pyomo/contrib/doe/__init__.py @@ -1,14 +1,47 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from .measurements import MeasurementVariables, DesignVariables, VariablesWithIndices -from .doe import DesignOfExperiments, CalculationMode, ObjectiveLib, ModelOptionLib -from .scenario import ScenarioGenerator, FiniteDifferenceStep -from .result import FisherResults, GridSearchResult +from .doe import DesignOfExperiments, ObjectiveLib, FiniteDifferenceStep +from .utils import rescale_FIM + +# Deprecation errors for old Pyomo.DoE interface classes and structures +from pyomo.common.deprecation import deprecated + +deprecation_message = ( + "Pyomo.DoE has been refactored. The current interface utilizes Experiment " + "objects that label unknown parameters, experiment inputs, experiment outputs " + "and measurement error. This avoids fragile string-based naming. For " + "instructions on using the new interface, please see the Pyomo.DoE documentation " + "`https://pyomo.readthedocs.io/en/latest/explanation/analysis/doe/doe.html`" +) + + +@deprecated( + "Use of MeasurementVariables in Pyomo.DoE is no longer supported.", version='6.8.0' +) +class MeasurementVariables: + def __init__(self, *args): + raise RuntimeError(deprecation_message) + + +@deprecated( + "Use of DesignVariables in Pyomo.DoE is no longer supported.", version='6.8.0' +) +class DesignVariables: + def __init__(self, *args): + raise RuntimeError(deprecation_message) + + +@deprecated( + "Use of ModelOptionLib in Pyomo.DoE is no longer supported.", version='6.8.0' +) +class ModelOptionLib: + def __init__(self, *args): + raise RuntimeError(deprecation_message) diff --git a/pyomo/contrib/doe/doe.py b/pyomo/contrib/doe/doe.py index d2ba2f277d6..98319708476 100644 --- a/pyomo/contrib/doe/doe.py +++ b/pyomo/contrib/doe/doe.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,853 +25,782 @@ # publicly, and to permit other to do so. # ___________________________________________________________________________ - -from pyomo.common.dependencies import numpy as np, numpy_available - -import pyomo.environ as pyo -from pyomo.opt import SolverFactory -import pickle +from enum import Enum from itertools import permutations, product + +import json import logging -from enum import Enum +import math + +from pyomo.common.dependencies import ( + numpy as np, + numpy_available, + pandas as pd, + pathlib, + matplotlib as plt, +) +from pyomo.common.modeling import unique_component_name from pyomo.common.timing import TicTocTimer + from pyomo.contrib.sensitivity_toolbox.sens import get_dsdp -from pyomo.contrib.doe.scenario import ScenarioGenerator, FiniteDifferenceStep -from pyomo.contrib.doe.result import FisherResults, GridSearchResult +import pyomo.environ as pyo + +from pyomo.opt import SolverStatus + +# This small and positive tolerance is used when checking +# if the prior is negative definite or approximately +# indefinite. It is defined as a tolerance here to ensure +# consistency between the code below and the tests. The +# user should not need to adjust it. +_SMALL_TOLERANCE_DEFINITENESS = 1e-6 -class CalculationMode(Enum): - sequential_finite = "sequential_finite" - direct_kaug = "direct_kaug" +# This small and positive tolerance is used to check +# the FIM is approximately symmetric. It is defined as +# a tolerance here to ensure consistency between the code +# below and the tests. The user should not need to adjust it. +_SMALL_TOLERANCE_SYMMETRY = 1e-6 class ObjectiveLib(Enum): - det = "det" + determinant = "determinant" trace = "trace" + minimum_eigenvalue = "minimum_eigenvalue" zero = "zero" -class ModelOptionLib(Enum): - parmest = "parmest" - stage1 = "stage1" - stage2 = "stage2" +class FiniteDifferenceStep(Enum): + forward = "forward" + central = "central" + backward = "backward" class DesignOfExperiments: def __init__( self, - param_init, - design_vars, - measurement_vars, - create_model, - solver=None, + experiment=None, + fd_formula="central", + step=1e-3, + objective_option="determinant", + scale_constant_value=1.0, + scale_nominal_param_value=False, prior_FIM=None, - discretize_model=None, - args=None, + jac_initial=None, + fim_initial=None, + L_diagonal_lower_bound=1e-7, + solver=None, + tee=False, + get_labeled_model_args=None, + logger_level=logging.WARNING, + _Cholesky_option=True, + _only_compute_fim_lower=True, ): """ This package enables model-based design of experiments analysis with Pyomo. Both direct optimization and enumeration modes are supported. - NLP sensitivity tools, e.g., sipopt and k_aug, are supported to accelerate analysis via enumeration. - It can be applied to dynamic models, where design variables are controlled throughout the experiment. + + The package has been refactored from its original form as of August 24. See + the documentation for more information. Parameters ---------- - param_init: - A ``dictionary`` of parameter names and values. - If they defined as indexed Pyomo variable, put the variable name and index, such as 'theta["A1"]'. - design_vars: - A ``DesignVariables`` which contains the Pyomo variable names and their corresponding indices - and bounds for experiment degrees of freedom - measurement_vars: - A ``MeasurementVariables`` which contains the Pyomo variable names and their corresponding indices and - bounds for experimental measurements - create_model: - A Python ``function`` that returns a Concrete Pyomo model, similar to the interface for ``parmest`` - solver: - A ``solver`` object that User specified, default=None. - If not specified, default solver is IPOPT MA57. + experiment: + Experiment object that holds the model and labels all the components. The object + should have a ``get_labeled_model`` where a model is returned with the following + labeled sets: ``unknown_parameters``, ``experimental_inputs``, ``experimental_outputs`` + fd_formula: + Finite difference formula for computing the sensitivity matrix. Must be one of + [``central``, ``forward``, ``backward``], default: ``central`` + step: + Relative step size for the finite difference formula. + default: 1e-3 + objective_option: + String representation of the objective option. Current available options are: + ``determinant`` (for determinant, or D-optimality) and ``trace`` (for trace or + A-optimality) + scale_constant_value: + Constant scaling for the sensitivity matrix. Every element will be multiplied by this + scaling factor. + default: 1 + scale_nominal_param_value: + Boolean for whether or not to scale the sensitivity matrix by the nominal parameter + values. Every column of the sensitivity matrix will be divided by the respective + nominal parameter value. + default: False prior_FIM: - A 2D numpy array containing Fisher information matrix (FIM) for prior experiments. - The default None means there is no prior information. - discretize_model: - A user-specified ``function`` that discretizes the model. Only use with Pyomo.DAE, default=None - args: - Additional arguments for the create_model function. + 2D numpy array representing information from prior experiments. If no value is given, + the assumed prior will be a matrix of zeros. This matrix will be assumed to be scaled + as the user has specified (i.e., if scale_nominal_param_value is true, we will assume + the FIM provided here has been scaled by the parameter values) + jac_initial: + 2D numpy array as the initial values for the sensitivity matrix. + fim_initial: + 2D numpy array as the initial values for the FIM. + L_diagonal_lower_bound: + Lower bound for the values of the lower triangular Cholesky factorization matrix. + default: 1e-7 + solver: + A ``solver`` object specified by the user, default=None. + If not specified, default solver is set to IPOPT with MA57. + tee: + Solver option to be passed for verbose output. + get_labeled_model_args: + Additional arguments for the ``get_labeled_model`` function on the Experiment object. + _Cholesky_option: + Boolean value of whether or not to use the cholesky factorization to compute the + determinant for the D-optimality criteria. This parameter should not be changed + unless the user intends to make performance worse (i.e., compare an existing tool + that uses the full FIM to this algorithm) + _only_compute_fim_lower: + If True, only the lower triangle of the FIM is computed. This parameter should not + be changed unless the user intends to make performance worse (i.e., compare an + existing tool that uses the full FIM to this algorithm) + logger_level: + Specify the level of the logger. Change to logging.DEBUG for all messages. """ + if experiment is None: + raise ValueError("Experiment object must be provided to perform DoE.") + + # Check if the Experiment object has callable ``get_labeled_model`` function + if not hasattr(experiment, "get_labeled_model"): + raise ValueError( + "The experiment object must have a ``get_labeled_model`` function" + ) + + # Set the experiment object from the user + self.experiment = experiment + + # Set the finite difference and subsequent step size + self.fd_formula = FiniteDifferenceStep(fd_formula) + self.step = step + + # Set the objective type and scaling options: + self.objective_option = ObjectiveLib(objective_option) + + self.scale_constant_value = scale_constant_value + self.scale_nominal_param_value = scale_nominal_param_value - # parameters - self.param = param_init - # design variable name - self.design_name = design_vars.variable_names - self.design_vars = design_vars - self.create_model = create_model - self.args = args + # Set the prior FIM (will be checked upon model construction) + self.prior_FIM = prior_FIM + + # Set the initial values for the jacobian, fim, and L matrices + self.jac_initial = jac_initial + self.fim_initial = fim_initial - # create the measurement information object - self.measurement_vars = measurement_vars - self.measure_name = self.measurement_vars.variable_names + # Set the lower bound on the Cholesky lower triangular matrix + self.L_diagonal_lower_bound = L_diagonal_lower_bound # check if user-defined solver is given if solver: self.solver = solver # if not given, use default solver else: - self.solver = self._get_default_ipopt_solver() + solver = pyo.SolverFactory("ipopt") + solver.options["linear_solver"] = "ma57" + solver.options["halt_on_ampl_error"] = "yes" + solver.options["max_iter"] = 3000 + self.solver = solver - # check if discretization is needed - self.discretize_model = discretize_model + self.tee = tee - # check if there is prior info - if prior_FIM is None: - self.prior_FIM = np.zeros((len(self.param), len(self.param))) - else: - self.prior_FIM = prior_FIM - self._check_inputs() + # Set get_labeled_model_args as an empty dict if no arguments are passed + if get_labeled_model_args is None: + get_labeled_model_args = {} + self.get_labeled_model_args = get_labeled_model_args - # if print statements + # Revtrieve logger and set logging level self.logger = logging.getLogger(__name__) - self.logger.setLevel(level=logging.INFO) + self.logger.setLevel(level=logger_level) - def _check_inputs(self): - """ - Check if the prior FIM is N*N matrix, where N is the number of parameter - """ - if type(self.prior_FIM) != type(None): - if np.shape(self.prior_FIM)[0] != np.shape(self.prior_FIM)[1]: - raise ValueError('Found wrong prior information matrix shape.') - elif np.shape(self.prior_FIM)[0] != len(self.param): - raise ValueError('Found wrong prior information matrix shape.') + # Set the private options if passed (only developers should pass these) + self.Cholesky_option = _Cholesky_option + self.only_compute_fim_lower = _only_compute_fim_lower - def stochastic_program( - self, - if_optimize=True, - objective_option="det", - scale_nominal_param_value=False, - scale_constant_value=1, - optimize_opt=None, - if_Cholesky=False, - L_LB=1e-7, - L_initial=None, - jac_initial=None, - fim_initial=None, - formula="central", - step=0.001, - tee_opt=True, - ): + # model attribute to avoid rebuilding models + self.model = pyo.ConcreteModel() # Build empty model + + # Empty results object + self.results = {} + + # May need this attribute for more complicated structures? + # (i.e., no model rebuilding for large models with sequential) + self._built_scenarios = False + + # Perform doe + def run_doe(self, model=None, results_file=None): """ - Optimize DOE problem with design variables being the decisions. - The DOE model is formed invasively and all scenarios are computed simultaneously. - The function will first run a square problem with design variable being fixed at - the given initial points (Objective function being 0), then a square problem with - design variables being fixed at the given initial points (Objective function being Design optimality), - and then unfix the design variable and do the optimization. + Runs DoE for a single experiment estimation. Can save results in + a file based on the flag. Parameters ---------- - if_optimize: - if true, continue to do optimization. else, just run square problem with given design variable values - objective_option: - choose from the ObjectiveLib enum, - "det": maximizing the determinant with ObjectiveLib.det, - "trace": or the trace of the FIM with ObjectiveLib.trace - scale_nominal_param_value: - if True, the parameters are scaled by its own nominal value in param_init - scale_constant_value: - scale all elements in Jacobian matrix, default is 1. - optimize_opt: - A dictionary, keys are design variables, values are True or False deciding if this design variable will be optimized as DOF or not - if_Cholesky: - if True, Cholesky decomposition is used for Objective function for D-optimality. - L_LB: - L is the Cholesky decomposition matrix for FIM, i.e. FIM = L*L.T. - L_LB is the lower bound for every element in L. - if FIM is positive definite, the diagonal element should be positive, so we can set a LB like 1E-10 - L_initial: - initialize the L - jac_initial: - a matrix used to initialize jacobian matrix - fim_initial: - a matrix used to initialize FIM matrix - formula: - choose from "central", "forward", "backward", - which refers to the Enum FiniteDifferenceStep.central, .forward, or .backward - step: - Sensitivity perturbation step size, a fraction between [0,1]. default is 0.001 - tee_opt: - if True, IPOPT console output is printed - - Returns - ------- - analysis_square: result summary of the square problem solved at the initial point - analysis_optimize: result summary of the optimization problem solved + model: model to run the DoE, default: None (self.model) + results_file: string name of the file path to save the results + to in the form of a .json file + default: None --> don't save """ - # store inputs in object - self.design_values = self.design_vars.variable_names_value - self.optimize = if_optimize - self.objective_option = ObjectiveLib(objective_option) - self.scale_nominal_param_value = scale_nominal_param_value - self.scale_constant_value = scale_constant_value - self.Cholesky_option = if_Cholesky - self.L_LB = L_LB - self.L_initial = L_initial - self.jac_initial = jac_initial - self.fim_initial = fim_initial - self.formula = FiniteDifferenceStep(formula) - self.step = step - self.tee_opt = tee_opt - - # calculate how much the FIM element is scaled by a constant number - # FIM = Jacobian.T@Jacobian, the FIM is scaled by squared value the Jacobian is scaled - self.fim_scale_constant_value = self.scale_constant_value**2 + # Check results file name + if results_file is not None: + if type(results_file) not in [pathlib.Path, str]: + raise ValueError( + "``results_file`` must be either a Path object or a string." + ) + # Start timer sp_timer = TicTocTimer() sp_timer.tic(msg=None) + self.logger.info("Beginning experimental optimization.") - # build the large DOE pyomo model - m = self._create_doe_model(no_obj=True) - - # solve model, achieve results for square problem, and results for optimization problem - m, analysis_square = self._compute_stochastic_program(m, optimize_opt) - - if self.optimize: - analysis_optimize = self._optimize_stochastic_program(m) - dT = sp_timer.toc(msg=None) - self.logger.info("elapsed time: %0.1f" % dT) - return analysis_square, analysis_optimize - + # Model is none, set it to self.model + if model is None: + model = self.model else: - dT = sp_timer.toc(msg=None) - self.logger.info("elapsed time: %0.1f" % dT) - return analysis_square - - def _compute_stochastic_program(self, m, optimize_option): - """ - Solve the stochastic program problem as a square problem. - """ + # TODO: Add safe naming when a model is passed by the user. + # doe_block = pyo.Block() + # doe_block_name = unique_component_name(model, "design_of_experiments_block") + # model.add_component(doe_block_name, doe_block) + pass + + # ToDo: potentially work with this for more complicated models + # Create the full DoE model (build scenarios for F.D. scheme) + if not self._built_scenarios: + self.create_doe_model(model=model) + + # Add the objective function to the model + self.create_objective_function(model=model) + + # Track time required to build the DoE model + build_time = sp_timer.toc(msg=None) + self.logger.info( + "Successfully built the DoE model.\nBuild time: %0.1f seconds" % build_time + ) - # Solve square problem first - # result_square: solver result - result_square = self._solve_doe(m, fix=True, opt_option=optimize_option) - - # extract Jac - jac_square = self._extract_jac(m) - - # create result object - analysis_square = FisherResults( - list(self.param.keys()), - self.measurement_vars, - jacobian_info=None, - all_jacobian_info=jac_square, - prior_FIM=self.prior_FIM, - scale_constant_value=self.scale_constant_value, + # Solve the square problem first to initialize the fim and + # sensitivity constraints + # Deactivate objective expression and objective constraints (on a block), and fix design variables + model.objective.deactivate() + model.obj_cons.deactivate() + for comp in model.scenario_blocks[0].experiment_inputs: + comp.fix() + + # TODO: safeguard solver call to see if solver terminated successfully + # see below commented code: + # res = self.solver.solve(model, tee=self.tee, load_solutions=False) + # if pyo.check_optimal_termination(res): + # model.load_solution(res) + # else: + # # The solver was unsuccessful, might want to warn the user or terminate gracefully, etc. + model.dummy_obj = pyo.Objective(expr=0, sense=pyo.minimize) + self.solver.solve(model, tee=self.tee) + + # Track time to initialize the DoE model + initialization_time = sp_timer.toc(msg=None) + self.logger.info( + "Successfully initialized the DoE model.\nInitialization time: %0.1f seconds" + % initialization_time ) - # for simultaneous mode, FIM and Jacobian are extracted with extract_FIM() - analysis_square.result_analysis(result=result_square) - analysis_square.model = m + model.dummy_obj.deactivate() + + # Reactivate objective and unfix experimental design decisions + for comp in model.scenario_blocks[0].experiment_inputs: + comp.unfix() + model.objective.activate() + model.obj_cons.activate() + + # If the model has L, initialize it with the solved FIM + if hasattr(model, "L"): + # Get the FIM values + fim_vals = [ + pyo.value(model.fim[i, j]) + for i in model.parameter_names + for j in model.parameter_names + ] + fim_np = np.array(fim_vals).reshape( + (len(model.parameter_names), len(model.parameter_names)) + ) - self.analysis_square = analysis_square - return m, analysis_square + # Need to compute the full FIM before initializing the Cholesky factorization + if self.only_compute_fim_lower: + fim_np = fim_np + fim_np.T - np.diag(np.diag(fim_np)) + + # Check if the FIM is positive definite + # If not, add jitter to the diagonal + # to ensure positive definiteness + min_eig = np.min(np.linalg.eigvals(fim_np)) + + if min_eig < _SMALL_TOLERANCE_DEFINITENESS: + # Raise the minimum eigenvalue to at least _SMALL_TOLERANCE_DEFINITENESS + jitter = np.min( + [ + -min_eig + _SMALL_TOLERANCE_DEFINITENESS, + _SMALL_TOLERANCE_DEFINITENESS, + ] + ) + else: + # No jitter needed + jitter = 0 - def _optimize_stochastic_program(self, m): - """ - Solve the stochastic program problem as an optimization problem. - """ + # Add jitter to the diagonal to ensure positive definiteness + L_vals_sq = np.linalg.cholesky( + fim_np + jitter * np.eye(len(model.parameter_names)) + ) + for i, c in enumerate(model.parameter_names): + for j, d in enumerate(model.parameter_names): + model.L[c, d].value = L_vals_sq[i, j] - m = self._add_objective(m) + if hasattr(model, "determinant"): + model.determinant.value = np.linalg.det(np.array(self.get_FIM())) - result_doe = self._solve_doe(m, fix=False) + # Solve the full model, which has now been initialized with the square solve + res = self.solver.solve(model, tee=self.tee) - # extract Jac - jac_optimize = self._extract_jac(m) + # Track time used to solve the DoE model + solve_time = sp_timer.toc(msg=None) - # create result object - analysis_optimize = FisherResults( - list(self.param.keys()), - self.measurement_vars, - jacobian_info=None, - all_jacobian_info=jac_optimize, - prior_FIM=self.prior_FIM, + self.logger.info( + "Successfully optimized experiment.\nSolve time: %0.1f seconds" % solve_time + ) + self.logger.info( + "Total time for build, initialization, and solve: %0.1f seconds" + % (build_time + initialization_time + solve_time) ) - # for simultaneous mode, FIM and Jacobian are extracted with extract_FIM() - analysis_optimize.result_analysis(result=result_doe) - analysis_optimize.model = m - - return analysis_optimize - def compute_FIM( - self, - mode="direct_kaug", - FIM_store_name=None, - specified_prior=None, - tee_opt=True, - scale_nominal_param_value=False, - scale_constant_value=1, - store_output=None, - read_output=None, - extract_single_model=None, - formula="central", - step=0.001, - ): + # + fim_local = self.get_FIM() + + # Make sure stale results don't follow the DoE object instance + self.results = {} + + self.results["Solver Status"] = res.solver.status + self.results["Termination Condition"] = res.solver.termination_condition + + # Important quantities for optimal design + self.results["FIM"] = fim_local + self.results["Sensitivity Matrix"] = self.get_sensitivity_matrix() + self.results["Experiment Design"] = self.get_experiment_input_values() + self.results["Experiment Design Names"] = [ + str(pyo.ComponentUID(k, context=model.scenario_blocks[0])) + for k in model.scenario_blocks[0].experiment_inputs + ] + self.results["Experiment Outputs"] = self.get_experiment_output_values() + self.results["Experiment Output Names"] = [ + str(pyo.ComponentUID(k, context=model.scenario_blocks[0])) + for k in model.scenario_blocks[0].experiment_outputs + ] + self.results["Unknown Parameters"] = self.get_unknown_parameter_values() + self.results["Unknown Parameter Names"] = [ + str(pyo.ComponentUID(k, context=model.scenario_blocks[0])) + for k in model.scenario_blocks[0].unknown_parameters + ] + self.results["Measurement Error"] = self.get_measurement_error_values() + self.results["Measurement Error Names"] = [ + str(pyo.ComponentUID(k, context=model.scenario_blocks[0])) + for k in model.scenario_blocks[0].measurement_error + ] + + self.results["Prior FIM"] = [list(row) for row in list(self.prior_FIM)] + + # Saving some stats on the FIM for convenience + self.results["Objective expression"] = str(self.objective_option).split(".")[-1] + self.results["log10 A-opt"] = np.log10(np.trace(fim_local)) + self.results["log10 D-opt"] = np.log10(np.linalg.det(fim_local)) + self.results["log10 E-opt"] = np.log10(min(np.linalg.eig(fim_local)[0])) + self.results["FIM Condition Number"] = np.linalg.cond(fim_local) + + # Solve timing stats + self.results["Build Time"] = build_time + self.results["Initialization Time"] = initialization_time + self.results["Solve Time"] = solve_time + self.results["Wall-clock Time"] = build_time + initialization_time + solve_time + + # Settings used to generate the optimal DoE + self.results["Finite Difference Scheme"] = str(self.fd_formula).split(".")[-1] + self.results["Finite Difference Step"] = self.step + self.results["Nominal Parameter Scaling"] = self.scale_nominal_param_value + + # ToDo: Add more useful fields to the results object? + # ToDo: Add MetaData from the user to the results object? Or leave to the user? + + # If the user specifies to save the file, do it here as a json + if results_file is not None: + with open(results_file, "w") as file: + json.dump(self.results, file) + + # Perform multi-experiment doe (sequential, or ``greedy`` approach) + def run_multi_doe_sequential(self, N_exp=1): + raise NotImplementedError("Multiple experiment optimization not yet supported.") + + # Perform multi-experiment doe (simultaneous, optimal approach) + def run_multi_doe_simultaneous(self, N_exp=1): + raise NotImplementedError("Multiple experiment optimization not yet supported.") + + # Compute FIM for the DoE object + def compute_FIM(self, model=None, method="sequential"): """ - This function calculates the Fisher information matrix (FIM) using sensitivity information obtained - from two possible modes (defined by the CalculationMode Enum): - - 1. sequential_finite: sequentially solve square problems and use finite difference approximation - 2. direct_kaug: solve a single square problem then extract derivatives using NLP sensitivity theory + Computes the FIM for the experimental design that is + initialized from the experiment`s ``get_labeled_model()`` + function. Parameters ---------- - mode: - supports CalculationMode.sequential_finite or CalculationMode.direct_kaug - FIM_store_name: - if storing the FIM in a .csv or .txt, give the file name here as a string. - specified_prior: - a 2D numpy array providing alternate prior matrix, default is no prior. - tee_opt: - if True, IPOPT console output is printed - scale_nominal_param_value: - if True, the parameters are scaled by its own nominal value in param_init - scale_constant_value: - scale all elements in Jacobian matrix, default is 1. - store_output: - if storing the output (value stored in Var 'output_record') as a pickle file, give the file name here as a string. - read_output: - if reading the output (value for Var 'output_record') as a pickle file, give the file name here as a string. - extract_single_model: - if True, the solved model outputs for each scenario are all recorded as a .csv file. - The output file uses the name AB.csv, where string A is store_output input, B is the index of scenario. - scenario index is the number of the scenario outputs which is stored. - formula: - choose from the Enum FiniteDifferenceStep.central, .forward, or .backward. - This option is only used for CalculationMode.sequential_finite mode. - step: - Sensitivity perturbation step size, a fraction between [0,1]. default is 0.001 + model: model to compute FIM, default: None, (self.compute_FIM_model) + method: string to specify which method should be used + options are ``kaug`` and ``sequential`` Returns ------- - FIM_analysis: result summary object of this solve + computed FIM: 2D numpy array of the FIM """ + if model is None: + self.compute_FIM_model = self.experiment.get_labeled_model( + **self.get_labeled_model_args + ).clone() + model = self.compute_FIM_model + else: + # TODO: Add safe naming when a model is passed by the user. + # doe_block = pyo.Block() + # doe_block_name = unique_component_name(model, "design_of_experiments_block") + # model.add_component(doe_block_name, doe_block) + # self.compute_FIM_model = model + pass + + self.check_model_labels(model=model) + + # Set length values for the model features + self.n_parameters = len(model.unknown_parameters) + self.n_measurement_error = len(model.measurement_error) + self.n_experiment_inputs = len(model.experiment_inputs) + self.n_experiment_outputs = len(model.experiment_outputs) + + # Check FIM input, if it exists. Otherwise, set the prior_FIM attribute + if self.prior_FIM is None: + self.prior_FIM = np.zeros( + (len(model.unknown_parameters), len(model.unknown_parameters)) + ) + else: + self.check_model_FIM(FIM=self.prior_FIM) - # save inputs in object - self.design_values = self.design_vars.variable_names_value - self.scale_nominal_param_value = scale_nominal_param_value - self.scale_constant_value = scale_constant_value - self.formula = FiniteDifferenceStep(formula) - self.mode = CalculationMode(mode) - self.step = step + # TODO: Add a check to see if the model has an objective and deactivate it. + # This solve should only be a square solve without any obj function. - # This method only solves square problem - self.optimize = False - # Set the Objective Function to 0 helps solve square problem quickly - self.objective_option = ObjectiveLib.zero - self.tee_opt = tee_opt + if method == "sequential": + self._sequential_FIM(model=model) + self._computed_FIM = self.seq_FIM + elif method == "kaug": + self._kaug_FIM(model=model) + self._computed_FIM = self.kaug_FIM + else: + raise ValueError( + "The method provided, {}, must be either `sequential` or `kaug`".format( + method + ) + ) - self.FIM_store_name = FIM_store_name - self.specified_prior = specified_prior + return self._computed_FIM - # calculate how much the FIM element is scaled by a constant number - # As FIM~Jacobian.T@Jacobian, FIM is scaled twice the number the Q is scaled - self.fim_scale_constant_value = self.scale_constant_value**2 + # Use a sequential method to get the FIM + def _sequential_FIM(self, model=None): + """ + Used to compute the FIM using a sequential approach, + solving the model consecutively under each of the + finite difference scenarios to build the sensitivity + matrix to subsequently compute the FIM. - square_timer = TicTocTimer() - square_timer.tic(msg=None) - if self.mode == CalculationMode.sequential_finite: - FIM_analysis = self._sequential_finite( - read_output, extract_single_model, store_output + """ + # Build a single model instance + if model is None: + self.compute_FIM_model = self.experiment.get_labeled_model( + **self.get_labeled_model_args + ).clone() + model = self.compute_FIM_model + + # Create suffix to keep track of parameter scenarios + if hasattr(model, "parameter_scenarios"): + model.del_component(model.parameter_scenarios) + model.parameter_scenarios = pyo.Suffix(direction=pyo.Suffix.LOCAL) + + # Populate parameter scenarios, and scenario inds based on finite difference scheme + if self.fd_formula == FiniteDifferenceStep.central: + model.parameter_scenarios.update( + (2 * ind, k) for ind, k in enumerate(model.unknown_parameters.keys()) + ) + model.parameter_scenarios.update( + (2 * ind + 1, k) + for ind, k in enumerate(model.unknown_parameters.keys()) + ) + model.scenarios = range(len(model.unknown_parameters) * 2) + elif self.fd_formula in [ + FiniteDifferenceStep.forward, + FiniteDifferenceStep.backward, + ]: + model.parameter_scenarios.update( + (ind + 1, k) for ind, k in enumerate(model.unknown_parameters.keys()) + ) + model.scenarios = range(len(model.unknown_parameters) + 1) + else: + raise AttributeError( + "Finite difference option not recognized. Please contact the developers as you should not see this error." ) - elif self.mode == CalculationMode.direct_kaug: - FIM_analysis = self._direct_kaug() + # Fix design variables + for comp in model.experiment_inputs: + comp.fix() + + measurement_vals = [] + # In a loop..... + # Calculate measurement values for each scenario + for s in model.scenarios: + # Perturbation to be (1 + diff) * param_value + if self.fd_formula == FiniteDifferenceStep.central: + diff = self.step * ( + (-1) ** s + ) # Positive perturbation, even; negative, odd + elif self.fd_formula == FiniteDifferenceStep.backward: + diff = ( + self.step * -1 * (s != 0) + ) # Backward always negative perturbation; 0 at s = 0 + elif self.fd_formula == FiniteDifferenceStep.forward: + diff = self.step * (s != 0) # Forward always positive; 0 at s = 0 + + # If we are doing forward/backward, no change for s=0 + skip_param_update = ( + self.fd_formula + in [FiniteDifferenceStep.forward, FiniteDifferenceStep.backward] + ) and (s == 0) + if not skip_param_update: + param = model.parameter_scenarios[s] + # Update parameter values for the given finite difference scenario + param.set_value(model.unknown_parameters[param] * (1 + diff)) + else: + continue - dT = square_timer.toc(msg=None) - self.logger.info("elapsed time: %0.1f" % dT) + # Simulate the model + try: + res = self.solver.solve(model) + pyo.assert_optimal_termination(res) + except: + # TODO: Make error message more verbose, i.e., add unknown parameter values so the + # user can try to solve the model instance outside of the pyomo.DoE framework. + raise RuntimeError( + "Model from experiment did not solve appropriately. Make sure the model is well-posed." + ) - return FIM_analysis + # Reset value of parameter to default value before computing finite difference perturbation + param.set_value(model.unknown_parameters[param]) - def _sequential_finite(self, read_output, extract_single_model, store_output): - """Sequential_finite mode uses Pyomo Block to evaluate the sensitivity information.""" + # Extract the measurement values for the scenario and append + measurement_vals.append( + [pyo.value(k) for k, v in model.experiment_outputs.items()] + ) - # if measurements are provided - if read_output: - with open(read_output, 'rb') as f: - output_record = pickle.load(f) - f.close() - jac = self._finite_calculation(output_record) + # Use the measurement outputs to make the Q matrix + measurement_vals_np = np.array(measurement_vals).T - # if measurements are not provided - else: - mod = self._create_block() - - # dict for storing model outputs - output_record = {} - - # solve model - square_result = self._solve_doe(mod, fix=True) - - if extract_single_model: - mod_name = store_output + '.csv' - dataframe = extract_single_model(mod, square_result) - dataframe.to_csv(mod_name) - - # loop over blocks for results - for s in range(len(self.scenario_list)): - # loop over measurement item and time to store model measurements - output_iter = [] - - # extract variable values - for r in self.measure_name: - cuid = pyo.ComponentUID(r) - try: - var_up = cuid.find_component_on(mod.block[s]) - except: - raise ValueError( - f"measurement {r} cannot be found in the model." - ) - output_iter.append(pyo.value(var_up)) - - output_record[s] = output_iter - - output_record['design'] = self.design_values - - if store_output: - f = open(store_output, 'wb') - pickle.dump(output_record, f) - f.close() - - # calculate jacobian - jac = self._finite_calculation(output_record) - - # return all models formed - self.model = mod - - # Store the Jacobian information for access by users, not necessarily call result object to achieve jacobian information - # It is the overall set of Jacobian information, - # while in the result object the jacobian can be cut to achieve part of the FIM information - self.jac = jac - - # Assemble and analyze results - if self.specified_prior is None: - prior_in_use = self.prior_FIM - else: - prior_in_use = self.specified_prior - - FIM_analysis = FisherResults( - list(self.param.keys()), - self.measurement_vars, - jacobian_info=None, - all_jacobian_info=jac, - prior_FIM=prior_in_use, - store_FIM=self.FIM_store_name, - scale_constant_value=self.scale_constant_value, + self.seq_jac = np.zeros( + ( + len(model.experiment_outputs.items()), + len(model.unknown_parameters.items()), + ) ) - return FIM_analysis + # Counting variable for loop + i = 0 + + # Loop over parameter values and grab correct columns for finite difference calculation - def _direct_kaug(self): - # create model - mod = self.create_model(model_option=ModelOptionLib.parmest) + for k, v in model.unknown_parameters.items(): + curr_step = v * self.step - # discretize if needed - if self.discretize_model: - mod = self.discretize_model(mod, block=False) + if self.fd_formula == FiniteDifferenceStep.central: + col_1 = 2 * i + col_2 = 2 * i + 1 + curr_step *= 2 + elif self.fd_formula == FiniteDifferenceStep.forward: + col_1 = i + col_2 = 0 + elif self.fd_formula == FiniteDifferenceStep.backward: + col_1 = 0 + col_2 = i - # add objective function - mod.Obj = pyo.Objective(expr=0, sense=pyo.minimize) + # If scale_nominal_param_value is active, scale by nominal parameter value (v) + scale_factor = (1.0 / curr_step) * self.scale_constant_value + if self.scale_nominal_param_value: + scale_factor *= v - # set ub and lb to parameters - for par in self.param.keys(): - cuid = pyo.ComponentUID(par) - var = cuid.find_component_on(mod) - var.setlb(self.param[par]) - var.setub(self.param[par]) + # Calculate the column of the sensitivity matrix + self.seq_jac[:, i] = ( + measurement_vals_np[:, col_1] - measurement_vals_np[:, col_2] + ) * scale_factor - # generate parameter name list and value dictionary with index - var_name = list(self.param.keys()) + # Increment the count + i += 1 - # call k_aug get_dsdp function - square_result = self._solve_doe(mod, fix=True) - dsdp_re, col = get_dsdp( - mod, list(self.param.keys()), self.param, tee=self.tee_opt - ) + # ToDo: As more complex measurement error schemes are put in place, this needs to change + # Add independent (non-correlated) measurement error for FIM calculation + cov_y = np.zeros((len(model.measurement_error), len(model.measurement_error))) + count = 0 + for k, v in model.measurement_error.items(): + cov_y[count, count] = 1 / v + count += 1 + + # Compute and record FIM + self.seq_FIM = self.seq_jac.T @ cov_y @ self.seq_jac + self.prior_FIM + + # Use kaug to get FIM + def _kaug_FIM(self, model=None): + """ + Used to compute the FIM using kaug, a sensitivity-based + approach that directly computes the FIM. + + Parameters + ---------- + model: model to compute FIM, default: None, (self.compute_FIM_model) + + """ + # Remake compute_FIM_model if model is None. + # compute_FIM_model needs to be the right version for function to work. + if model is None: + self.compute_FIM_model = self.experiment.get_labeled_model( + **self.get_labeled_model_args + ).clone() + model = self.compute_FIM_model + + # add zero (dummy/placeholder) objective function + if not hasattr(model, "objective"): + model.objective = pyo.Objective(expr=0, sense=pyo.minimize) + + # Fix design variables to make the problem square + for comp in model.experiment_inputs: + comp.fix() + + self.solver.solve(model, tee=self.tee) + + # Probe the solved model for dsdp results (sensitivities s.t. parameters) + params_dict = {k.name: v for k, v in model.unknown_parameters.items()} + params_names = list(params_dict.keys()) + + dsdp_re, col = get_dsdp(model, params_names, params_dict, tee=self.tee) # analyze result dsdp_array = dsdp_re.toarray().T - self.dsdp = dsdp_array - self.dsdp = col + # store dsdp returned dsdp_extract = [] # get right lines from results measurement_index = [] # loop over measurement variables and their time points - for mname in self.measure_name: + for k, v in model.experiment_outputs.items(): + name = k.name try: - kaug_no = col.index(mname) + kaug_no = col.index(name) measurement_index.append(kaug_no) # get right line of dsdp dsdp_extract.append(dsdp_array[kaug_no]) except: # k_aug does not provide value for fixed variables - self.logger.debug('The variable is fixed: %s', mname) + self.logger.debug("The variable is fixed: %s", name) # produce the sensitivity for fixed variables - zero_sens = np.zeros(len(self.param)) + zero_sens = np.zeros(len(params_names)) # for fixed variables, the sensitivity are a zero vector dsdp_extract.append(zero_sens) # Extract and calculate sensitivity if scaled by constants or parameters. - # Convert sensitivity to a dictionary - jac = {} - for par in self.param.keys(): - jac[par] = [] + jac = [[] for k in params_names] for d in range(len(dsdp_extract)): - for p, par in enumerate(self.param.keys()): + for k, v in model.unknown_parameters.items(): + p = params_names.index(k.name) # Index of parameter in np array # if scaled by parameter value or constant value sensi = dsdp_extract[d][p] * self.scale_constant_value if self.scale_nominal_param_value: - sensi *= self.param[par] - jac[par].append(sensi) - - # check if another prior experiment FIM is provided other than the user-specified one - if self.specified_prior is None: - prior_in_use = self.prior_FIM - else: - prior_in_use = self.specified_prior - - # Assemble and analyze results - FIM_analysis = FisherResults( - list(self.param.keys()), - self.measurement_vars, - jacobian_info=None, - all_jacobian_info=jac, - prior_FIM=prior_in_use, - store_FIM=self.FIM_store_name, - scale_constant_value=self.scale_constant_value, - ) - - self.jac = jac - self.mod = mod - - return FIM_analysis - - def _create_block(self): - """ - Create a pyomo Concrete model and add blocks with different parameter perturbation scenarios. - - Returns - ------- - mod: Concrete Pyomo model - """ - - # create scenario information for block scenarios - scena_gen = ScenarioGenerator( - parameter_dict=self.param, formula=self.formula, step=self.step - ) - - self.scenario_data = scena_gen.ScenarioData - - # a list of dictionary, each one is a parameter dictionary with perturbed parameter values - self.scenario_list = self.scenario_data.scenario - # dictionary, keys are parameter name, values are a list of scenario index where this parameter is perturbed. - self.scenario_num = self.scenario_data.scena_num - # dictionary, keys are parameter name, values are the perturbation step - self.eps_abs = self.scenario_data.eps_abs - self.scena_gen = scena_gen + sensi *= v + jac[p].append(sensi) - # Create a global model - mod = pyo.ConcreteModel() + # record kaug jacobian + self.kaug_jac = np.array(jac).T - # Set for block/scenarios - mod.scenario = pyo.Set(initialize=self.scenario_data.scenario_indices) - - # Allow user to self-define complex design variables - self.create_model(mod=mod, model_option=ModelOptionLib.stage1) - - def block_build(b, s): - # create block scenarios - self.create_model(mod=b, model_option=ModelOptionLib.stage2) - - # fix parameter values to perturbed values - for par in self.param: - cuid = pyo.ComponentUID(par) - var = cuid.find_component_on(b) - var.fix(self.scenario_data.scenario[s][par]) - - mod.block = pyo.Block(mod.scenario, rule=block_build) - - # discretize the model - if self.discretize_model: - mod = self.discretize_model(mod) - - # force design variables in blocks to be equal to global design values - for name in self.design_name: - - def fix1(mod, s): - cuid = pyo.ComponentUID(name) - design_var_global = cuid.find_component_on(mod) - design_var = cuid.find_component_on(mod.block[s]) - return design_var == design_var_global - - con_name = "con" + name - mod.add_component(con_name, pyo.Constraint(mod.scenario, expr=fix1)) - - return mod - - def _finite_calculation(self, output_record): - """ - Calculate Jacobian for sequential_finite mode + # Compute FIM + if self.prior_FIM is None: + self.prior_FIM = np.zeros((len(params_names), len(params_names))) + else: + self.check_model_FIM(FIM=self.prior_FIM) - Parameters - ---------- - output_record: a dict of outputs, keys are scenario names, values are a list of measurements values - scena_gen: an object generated by Scenario_creator class + # Constructing the Covariance of the measurements for the FIM calculation + # The following assumes independent measurement error. + cov_y = np.zeros((len(model.measurement_error), len(model.measurement_error))) + count = 0 + for k, v in model.measurement_error.items(): + cov_y[count, count] = 1 / v + count += 1 - Returns - ------- - jac: Jacobian matrix, a dictionary, keys are parameter names, values are a list of jacobian values with respect to this parameter - """ - # dictionary form of jacobian - jac = {} - - # After collecting outputs from all scenarios, calculate sensitivity - for para in self.param.keys(): - # extract involved scenario No. for each parameter from scenario class - involved_s = self.scenario_data.scena_num[para] - - # each parameter has two involved scenarios - s1 = involved_s[0] # positive perturbation - s2 = involved_s[1] # negative perturbation - list_jac = [] - for i in range(len(output_record[s1])): - sensi = ( - (output_record[s1][i] - output_record[s2][i]) - / self.scenario_data.eps_abs[para] - * self.scale_constant_value - ) - if self.scale_nominal_param_value: - sensi *= self.param[para] - list_jac.append(sensi) - # get Jacobian dict, keys are parameter name, values are sensitivity info - jac[para] = list_jac + # ToDo: need to add a covariance matrix for measurements (sigma inverse) + # i.e., cov_y = self.cov_y or model.cov_y + # Still deciding where this would be best. - return jac + self.kaug_FIM = self.kaug_jac.T @ cov_y @ self.kaug_jac + self.prior_FIM - def _extract_jac(self, m): + # Create the DoE model (with ``scenarios`` from finite differencing scheme) + def create_doe_model(self, model=None): """ - Extract jacobian from the stochastic program - - Parameters - ---------- - m: solved stochastic program model + Add equations to compute sensitivities, FIM, and objective. + Builds the DoE model. Adds the scenarios, the sensitivity matrix + Q, the FIM, as well as the objective function to the model. - Returns - ------- - JAC: the overall jacobian as a dictionary - """ - # dictionary form of jacobian - jac = {} - # loop over parameters - for p in self.param.keys(): - jac_para = [] - for res in m.measured_variables: - jac_para.append(pyo.value(m.sensitivity_jacobian[p, res])) - jac[p] = jac_para - return jac - - def run_grid_search( - self, - design_ranges, - mode="sequential_finite", - tee_option=False, - scale_nominal_param_value=False, - scale_constant_value=1, - store_name=None, - read_name=None, - store_optimality_as_csv=None, - formula="central", - step=0.001, - ): - """ - Enumerate through full grid search for any number of design variables; - solve square problems sequentially to compute FIMs. - It calculates FIM with sensitivity information from two modes: + The function alters the ``model`` input. - 1. sequential_finite: Calculates a one scenario model multiple times for multiple scenarios. - Sensitivity info estimated by finite difference - 2. direct_kaug: calculate sensitivity by k_aug with direct sensitivity + In the single experiment case, ``model`` will be self.model. In the + multi-experiment case, ``model`` will be one experiment to be enumerated. Parameters ---------- - design_ranges: - a ``dict``, keys are design variable names, - values are a list of design variable values to go over - mode: - choose from CalculationMode.sequential_finite, .direct_kaug. - tee_option: - if solver console output is made - scale_nominal_param_value: - if True, the parameters are scaled by its own nominal value in param_init - scale_constant_value: - scale all elements in Jacobian matrix, default is 1. - store_name: - a string of file name. If not None, store results with this name. - It is a pickle file containing all measurement information after solving the - model with perturbations. - Since there are multiple experiments, results are numbered with a scalar number, - and the result for one grid is 'store_name(count).csv' (count is the number of count). - read_name: - a string of file name. If not None, read result files. - It should be a pickle file previously generated by store_name option. - Since there are multiple experiments, this string should be the common part of all files; - Real name of the file is "read_name(count)", where count is the number of the experiment. - store_optimality_as_csv: - if True, the design criterion values of grid search results stored with this file name as a csv - formula: - choose from FiniteDifferenceStep.central, .forward, or .backward. - This option is only used for CalculationMode.sequential_finite. - step: - Sensitivity perturbation step size, a fraction between [0,1]. default is 0.001 + model: model to add finite difference scenarios - Returns - ------- - figure_draw_object: a combined result object of class Grid_search_result """ - # Set the Objective Function to 0 helps solve square problem quickly - self.objective_option = ObjectiveLib.zero - self.store_optimality_as_csv = store_optimality_as_csv - - # calculate how much the FIM element is scaled - self.fim_scale_constant_value = scale_constant_value**2 - - # to store all FIM results - result_combine = {} - - # all lists of values of each design variable to go over - design_ranges_list = list(design_ranges.values()) - # design variable names to go over - design_dimension_names = list(design_ranges.keys()) - - # iteration 0 - count = 0 - failed_count = 0 - # how many sets of design variables will be run - total_count = 1 - for rng in design_ranges_list: - total_count *= len(rng) - - time_set = [] # record time for every iteration - - # generate combinations of design variable values to go over - search_design_set = product(*design_ranges_list) - - # loop over design value combinations - for design_set_iter in search_design_set: - # generate the design variable dictionary needed for running compute_FIM - # first copy value from design_values - design_iter = self.design_vars.variable_names_value.copy() - # update the controlled value of certain time points for certain design variables - for i, names in enumerate(design_dimension_names): - # if the element is a list, all design variables in this list share the same values - if type(names) is list or type(names) is tuple: - for n in names: - design_iter[n] = list(design_set_iter)[i] - else: - design_iter[names] = list(design_set_iter)[i] - - self.design_vars.variable_names_value = design_iter - iter_timer = TicTocTimer() - self.logger.info('=======Iteration Number: %s =====', count + 1) - self.logger.debug( - 'Design variable values of this iteration: %s', design_iter + if model is None: + model = self.model + else: + # TODO: Add safe naming when a model is passed by the user. + # doe_block = pyo.Block() + # doe_block_name = unique_component_name(model, "design_of_experiments_block") + # model.add_component(doe_block_name, doe_block) + pass + + # Developer recommendation: use the Cholesky decomposition for D-optimality + # The explicit formula is available for benchmarking purposes and is NOT recommended + if ( + self.only_compute_fim_lower + and self.objective_option == ObjectiveLib.determinant + and not self.Cholesky_option + ): + raise ValueError( + "Cannot compute determinant with explicit formula if only_compute_fim_lower is True." ) - iter_timer.tic(msg=None) - # generate store name - if store_name is None: - store_output_name = None - else: - store_output_name = store_name + str(count) - if read_name: - read_input_name = read_name + str(count) - else: - read_input_name = None - - # call compute_FIM to get FIM - try: - result_iter = self.compute_FIM( - mode=mode, - tee_opt=tee_option, - scale_nominal_param_value=scale_nominal_param_value, - scale_constant_value=scale_constant_value, - store_output=store_output_name, - read_output=read_input_name, - formula=formula, - step=step, - ) - - count += 1 - - result_iter.result_analysis() - - # iteration time - iter_t = iter_timer.toc(msg=None) - time_set.append(iter_t) - - # give run information at each iteration - self.logger.info('This is run %s out of %s.', count, total_count) - self.logger.info('The code has run %s seconds.', sum(time_set)) - self.logger.info( - 'Estimated remaining time: %s seconds', - (sum(time_set) / (count + 1) * (total_count - count - 1)), - ) - - # the combined result object are organized as a dictionary, keys are a tuple of the design variable values, values are a result object - result_combine[tuple(design_set_iter)] = result_iter + # Generate scenarios for finite difference formulae + self._generate_scenario_blocks(model=model) - except: - self.logger.warning( - ':::::::::::Warning: Cannot converge this run.::::::::::::' - ) - count += 1 - failed_count += 1 - self.logger.warning('failed count:', failed_count) - result_combine[tuple(design_set_iter)] = None - - # For user's access - self.all_fim = result_combine - - # Create figure drawing object - figure_draw_object = GridSearchResult( - design_ranges_list, - design_dimension_names, - result_combine, - store_optimality_name=store_optimality_as_csv, + # Set names for indexing sensitivity matrix (jacobian) and FIM + scen_block_ind = min( + [ + k.name.split(".").index("scenario_blocks[0]") + for k in model.scenario_blocks[0].unknown_parameters.keys() + ] + ) + model.parameter_names = pyo.Set( + initialize=[ + ".".join(k.name.split(".")[(scen_block_ind + 1) :]) + for k in model.scenario_blocks[0].unknown_parameters.keys() + ] + ) + model.output_names = pyo.Set( + initialize=[ + ".".join(k.name.split(".")[(scen_block_ind + 1) :]) + for k in model.scenario_blocks[0].experiment_outputs.keys() + ] ) - - self.logger.info('Overall wall clock time [s]: %s', sum(time_set)) - - return figure_draw_object - - def _create_doe_model(self, no_obj=True): - """ - Add equations to compute sensitivities, FIM, and objective. - - Parameters - ----------- - no_obj: if True, objective function is 0. - - Return - ------- - model: the DOE model - """ - model = self._create_block() - - # variables for jacobian and FIM - model.regression_parameters = pyo.Set(initialize=list(self.param.keys())) - model.measured_variables = pyo.Set(initialize=self.measure_name) def identity_matrix(m, i, j): if i == j: @@ -879,165 +808,447 @@ def identity_matrix(m, i, j): else: return 0 + ### Initialize the Jacobian if provided by the user + + # If the user provides an initial Jacobian, convert it to a dictionary + if self.jac_initial is not None: + dict_jac_initialize = {} + for i, bu in enumerate(model.output_names): + for j, un in enumerate(model.parameter_names): + # Jacobian is a numpy array, rows are experimental outputs, columns are unknown parameters + dict_jac_initialize[(bu, un)] = self.jac_initial[i][j] + + # Initialize the Jacobian matrix + def initialize_jac(m, i, j): + # If provided by the user, use the values now stored in the dictionary + if self.jac_initial is not None: + return dict_jac_initialize[(i, j)] + # Otherwise initialize to 0.1 (which is an arbitrary non-zero value) + else: + raise AttributeError( + "Jacobian being initialized when the jac_initial attribute is None. Please contact the developers as you should not see this error." + ) + model.sensitivity_jacobian = pyo.Var( - model.regression_parameters, model.measured_variables, initialize=0.1 + model.output_names, model.parameter_names, initialize=initialize_jac ) - if self.fim_initial: - dict_fim_initialize = {} - for i, bu in enumerate(model.regression_parameters): - for j, un in enumerate(model.regression_parameters): - dict_fim_initialize[(bu, un)] = self.fim_initial[i][j] + # Initialize the FIM + if self.fim_initial is not None: + dict_fim_initialize = { + (bu, un): self.fim_initial[i][j] + for i, bu in enumerate(model.parameter_names) + for j, un in enumerate(model.parameter_names) + } def initialize_fim(m, j, d): return dict_fim_initialize[(j, d)] - if self.fim_initial: + if self.fim_initial is not None: model.fim = pyo.Var( - model.regression_parameters, - model.regression_parameters, - initialize=initialize_fim, + model.parameter_names, model.parameter_names, initialize=initialize_fim ) else: model.fim = pyo.Var( - model.regression_parameters, - model.regression_parameters, - initialize=identity_matrix, + model.parameter_names, model.parameter_names, initialize=identity_matrix ) - # move the L matrix initial point to a dictionary - if type(self.L_initial) != type(None): - dict_cho = {} - for i, bu in enumerate(model.regression_parameters): - for j, un in enumerate(model.regression_parameters): - dict_cho[(bu, un)] = self.L_initial[i][j] - - # use the L dictionary to initialize L matrix - def init_cho(m, i, j): - return dict_cho[(i, j)] - + # To-Do: Look into this functionality..... # if cholesky, define L elements as variables - if self.Cholesky_option: - # Define elements of Cholesky decomposition matrix as Pyomo variables and either - # Initialize with L in L_initial - if type(self.L_initial) != type(None): - model.L_ele = pyo.Var( - model.regression_parameters, - model.regression_parameters, - initialize=init_cho, - ) - # or initialize with the identity matrix - else: - model.L_ele = pyo.Var( - model.regression_parameters, - model.regression_parameters, - initialize=identity_matrix, - ) + if self.Cholesky_option and self.objective_option == ObjectiveLib.determinant: + model.L = pyo.Var( + model.parameter_names, model.parameter_names, initialize=identity_matrix + ) # loop over parameter name - for i, c in enumerate(model.regression_parameters): - for j, d in enumerate(model.regression_parameters): + for i, c in enumerate(model.parameter_names): + for j, d in enumerate(model.parameter_names): # fix the 0 half of L matrix to be 0.0 if i < j: - model.L_ele[c, d].fix(0.0) + model.L[c, d].fix(0.0) # Give LB to the diagonal entries - if self.L_LB: + if self.L_diagonal_lower_bound: if c == d: - model.L_ele[c, d].setlb(self.L_LB) + model.L[c, d].setlb(self.L_diagonal_lower_bound) # jacobian rule - def jacobian_rule(m, p, n): + def jacobian_rule(m, n, p): """ m: Pyomo model - p: parameter - n: response + n: experimental output + p: unknown parameter """ + fd_step_mult = 1 cuid = pyo.ComponentUID(n) - var_up = cuid.find_component_on(m.block[self.scenario_num[p][0]]) - var_lo = cuid.find_component_on(m.block[self.scenario_num[p][1]]) + param_ind = m.parameter_names.data().index(p) + + # Different FD schemes lead to different scenarios for the computation + if self.fd_formula == FiniteDifferenceStep.central: + s1 = param_ind * 2 + s2 = param_ind * 2 + 1 + fd_step_mult = 2 + elif self.fd_formula == FiniteDifferenceStep.forward: + s1 = param_ind + 1 + s2 = 0 + elif self.fd_formula == FiniteDifferenceStep.backward: + s1 = 0 + s2 = param_ind + 1 + + var_up = cuid.find_component_on(m.scenario_blocks[s1]) + var_lo = cuid.find_component_on(m.scenario_blocks[s2]) + + param = m.parameter_scenarios[max(s1, s2)] + param_loc = pyo.ComponentUID(param).find_component_on(m.scenario_blocks[0]) + param_val = m.scenario_blocks[0].unknown_parameters[param_loc] + param_diff = param_val * fd_step_mult * self.step + if self.scale_nominal_param_value: return ( - m.sensitivity_jacobian[p, n] + m.sensitivity_jacobian[n, p] == (var_up - var_lo) - / self.eps_abs[p] - * self.param[p] + / param_diff + * param_val * self.scale_constant_value ) else: return ( - m.sensitivity_jacobian[p, n] - == (var_up - var_lo) / self.eps_abs[p] * self.scale_constant_value + m.sensitivity_jacobian[n, p] + == (var_up - var_lo) / param_diff * self.scale_constant_value ) # A constraint to calculate elements in Hessian matrix # transfer prior FIM to be Expressions - fim_initial_dict = {} - for i, bu in enumerate(model.regression_parameters): - for j, un in enumerate(model.regression_parameters): - fim_initial_dict[(bu, un)] = self.prior_FIM[i][j] + fim_initial_dict = { + (bu, un): self.prior_FIM[i][j] + for i, bu in enumerate(model.parameter_names) + for j, un in enumerate(model.parameter_names) + } def read_prior(m, i, j): return fim_initial_dict[(i, j)] - model.priorFIM = pyo.Expression( - model.regression_parameters, model.regression_parameters, rule=read_prior + model.prior_FIM = pyo.Expression( + model.parameter_names, model.parameter_names, rule=read_prior ) + # Off-diagonal elements are symmetric, so only half of the off-diagonal elements need to be specified. def fim_rule(m, p, q): """ m: Pyomo model - p: parameter - q: parameter + p: unknown parameter + q: unknown parameter """ - return ( - m.fim[p, q] - == sum( - 1 - / self.measurement_vars.variance[n] - * m.sensitivity_jacobian[p, n] - * m.sensitivity_jacobian[q, n] - for n in model.measured_variables + p_ind = list(m.parameter_names).index(p) + q_ind = list(m.parameter_names).index(q) + + # If the row is less than the column, skip the constraint + # This logic is consistent with making the FIM a lower + # triangular matrix (as is done later in this function) + if p_ind < q_ind: + if self.only_compute_fim_lower: + return pyo.Constraint.Skip + else: + return m.fim[p, q] == m.fim[q, p] + else: + return ( + m.fim[p, q] + == sum( + 1 + / m.scenario_blocks[0].measurement_error[ + pyo.ComponentUID(n).find_component_on(m.scenario_blocks[0]) + ] + * m.sensitivity_jacobian[n, p] + * m.sensitivity_jacobian[n, q] + for n in m.output_names + ) + + m.prior_FIM[p, q] ) - + m.priorFIM[p, q] * self.fim_scale_constant_value - ) model.jacobian_constraint = pyo.Constraint( - model.regression_parameters, model.measured_variables, rule=jacobian_rule + model.output_names, model.parameter_names, rule=jacobian_rule ) model.fim_constraint = pyo.Constraint( - model.regression_parameters, model.regression_parameters, rule=fim_rule + model.parameter_names, model.parameter_names, rule=fim_rule ) - return model + if self.only_compute_fim_lower: + # Fix the upper half of the FIM matrix elements to be 0.0. + # This eliminates extra variables and ensures the expected number of + # degrees of freedom in the optimization problem. + for ind_p, p in enumerate(model.parameter_names): + for ind_q, q in enumerate(model.parameter_names): + if ind_p < ind_q: + model.fim[p, q].fix(0.0) + + # Create scenario block structure + def _generate_scenario_blocks(self, model=None): + """ + Generates the modeling blocks corresponding to the scenarios for + the finite differencing scheme to compute the sensitivity jacobian + to compute the FIM. - def _add_objective(self, m): - def cholesky_imp(m, c, d): - """ - Calculate Cholesky L matrix using algebraic constraints - """ - # If it is the left bottom half of L - if list(self.param.keys()).index(c) >= list(self.param.keys()).index(d): - return m.fim[c, d] == sum( - m.L_ele[c, list(self.param.keys())[k]] - * m.L_ele[d, list(self.param.keys())[k]] - for k in range(list(self.param.keys()).index(d) + 1) + The function alters the ``model`` input. + + In the single experiment case, ``model`` will be self.model. In the + multi-experiment case, ``model`` will be one experiment to be enumerated. + + Parameters + ---------- + model: model to add finite difference scenarios + """ + # If model is none, assume it is self.model + if model is None: + model = self.model + + # Generate initial scenario to populate unknown parameter values + model.base_model = self.experiment.get_labeled_model( + **self.get_labeled_model_args + ).clone() + + # Check the model that labels are correct + self.check_model_labels(model=model.base_model) + + # Gather lengths of label structures for later use in the model build process + self.n_parameters = len(model.base_model.unknown_parameters) + self.n_measurement_error = len(model.base_model.measurement_error) + self.n_experiment_inputs = len(model.base_model.experiment_inputs) + self.n_experiment_outputs = len(model.base_model.experiment_outputs) + + if self.n_measurement_error != self.n_experiment_outputs: + raise ValueError( + "Number of experiment outputs, {}, and length of measurement error, {}, do not match. Please check model labeling.".format( + self.n_experiment_outputs, self.n_measurement_error ) - else: - # This is the empty half of L above the diagonal - return pyo.Constraint.Skip + ) - def trace_calc(m): - """ - Calculate FIM elements. Can scale each element with 1000 for performance - """ - return m.trace == sum(m.fim[j, j] for j in m.regression_parameters) + self.logger.info("Experiment output and measurement error lengths match.") - def det_general(m): - r"""Calculate determinant. Can be applied to FIM of any size. - det(A) = sum_{\sigma \in \S_n} (sgn(\sigma) * \Prod_{i=1}^n a_{i,\sigma_i}) - Use permutation() to get permutations, sgn() to get signature + # Check that the user input FIM and Jacobian are the correct dimension + if self.prior_FIM is not None: + self.check_model_FIM(FIM=self.prior_FIM) + else: + self.prior_FIM = np.zeros((self.n_parameters, self.n_parameters)) + if self.fim_initial is not None: + self.check_model_FIM(FIM=self.fim_initial) + else: + self.fim_initial = np.eye(self.n_parameters) + self.prior_FIM + if self.jac_initial is not None: + self.check_model_jac(self.jac_initial) + else: + self.jac_initial = np.eye(self.n_experiment_outputs, self.n_parameters) + + # Make a new Suffix to hold which scenarios are associated with parameters + model.parameter_scenarios = pyo.Suffix(direction=pyo.Suffix.LOCAL) + + # Populate parameter scenarios, and scenario inds based on finite difference scheme + if self.fd_formula == FiniteDifferenceStep.central: + model.parameter_scenarios.update( + (2 * ind, k) + for ind, k in enumerate(model.base_model.unknown_parameters.keys()) + ) + model.parameter_scenarios.update( + (2 * ind + 1, k) + for ind, k in enumerate(model.base_model.unknown_parameters.keys()) + ) + model.scenarios = range(len(model.base_model.unknown_parameters) * 2) + elif self.fd_formula in [ + FiniteDifferenceStep.forward, + FiniteDifferenceStep.backward, + ]: + model.parameter_scenarios.update( + (ind + 1, k) + for ind, k in enumerate(model.base_model.unknown_parameters.keys()) + ) + model.scenarios = range(len(model.base_model.unknown_parameters) + 1) + else: + raise AttributeError( + "Finite difference option not recognized. Please contact the developers as you should not see this error." + ) + + # TODO: Allow Params for `unknown_parameters` and `experiment_inputs` + # May need to make a new converter Param to Var that allows non-string names/references to be passed + # Waiting on updates to the parmest params_to_vars utility function..... + + # Run base model to get initialized model and check model function + for comp in model.base_model.experiment_inputs: + comp.fix() + + try: + res = self.solver.solve(model.base_model, tee=self.tee) + assert res.solver.termination_condition == "optimal" + self.logger.info("Model from experiment solved.") + except: + raise RuntimeError( + "Model from experiment did not solve appropriately. Make sure the model is well-posed." + ) + + for comp in model.base_model.experiment_inputs: + comp.unfix() + + # Generate blocks for finite difference scenarios + def build_block_scenarios(b, s): + # Generate model for the finite difference scenario + m = b.model() + b.transfer_attributes_from(m.base_model.clone()) + + # Forward/Backward difference have a stationary case (s == 0), no parameter to perturb + if self.fd_formula in [ + FiniteDifferenceStep.forward, + FiniteDifferenceStep.backward, + ]: + if s == 0: + return + + param = m.parameter_scenarios[s] + + # Perturbation to be (1 + diff) * param_value + if self.fd_formula == FiniteDifferenceStep.central: + diff = self.step * ( + (-1) ** s + ) # Positive perturbation, even; negative, odd + elif self.fd_formula == FiniteDifferenceStep.backward: + diff = self.step * -1 # Backward always negative perturbation + elif self.fd_formula == FiniteDifferenceStep.forward: + diff = self.step # Forward always positive + else: + # To-Do: add an error message for this as not being implemented yet + diff = 0 + pass + + # Update parameter values for the given finite difference scenario + pyo.ComponentUID(param, context=m.base_model).find_component_on( + b + ).set_value(m.base_model.unknown_parameters[param] * (1 + diff)) + res = self.solver.solve(b, tee=self.tee) + + model.scenario_blocks = pyo.Block(model.scenarios, rule=build_block_scenarios) + + # To-Do: this might have to change if experiment inputs have + # a different value in the Suffix (currently it is the CUID) + design_vars = [k for k, v in model.scenario_blocks[0].experiment_inputs.items()] + + # Add constraints to equate block design with global design: + for ind, d in enumerate(design_vars): + con_name = "global_design_eq_con_" + str(ind) + + # Constraint rule for global design constraints + def global_design_fixing(m, s): + if s == 0: + return pyo.Constraint.Skip + block_design_var = pyo.ComponentUID( + d, context=m.scenario_blocks[0] + ).find_component_on(m.scenario_blocks[s]) + return d == block_design_var + + model.add_component( + con_name, pyo.Constraint(model.scenarios, rule=global_design_fixing) + ) + + # Clean up the base model used to generate the scenarios + model.del_component(model.base_model) + + # ToDo: consider this logic? Multi-block systems need something more fancy + self._built_scenarios = True + + # Create objective function + def create_objective_function(self, model=None): + """ + Generates the objective function as an expression and as a + Pyomo Objective object + + The function alters the ``model`` input. + + In the single experiment case, ``model`` will be self.model. In the + multi-experiment case, ``model`` will be one experiment to be enumerated. + + Parameters + ---------- + model: model to add finite difference scenarios + """ + if model is None: + model = self.model + + if self.objective_option not in [ + ObjectiveLib.determinant, + ObjectiveLib.trace, + ObjectiveLib.zero, + ]: + raise AttributeError( + "Objective option not recognized. Please contact the developers as you should not see this error." + ) + + if not hasattr(model, "fim"): + raise RuntimeError( + "Model provided does not have variable `fim`. Please make sure the model is built properly before creating the objective." + ) + + small_number = 1e-10 + + # Make objective block for constraints connected to objective + model.obj_cons = pyo.Block() + + # Assemble the FIM matrix. This is helpful for initialization! + fim_vals = [ + model.fim[bu, un].value + for i, bu in enumerate(model.parameter_names) + for j, un in enumerate(model.parameter_names) + ] + fim = np.array(fim_vals).reshape( + len(model.parameter_names), len(model.parameter_names) + ) + + ### Initialize the Cholesky decomposition matrix + if self.Cholesky_option and self.objective_option == ObjectiveLib.determinant: + # Calculate the eigenvalues of the FIM matrix + eig = np.linalg.eigvals(fim) + + # If the smallest eigenvalue is (practically) negative, add a diagonal matrix to make it positive definite + small_number = 1e-10 + if min(eig) < small_number: + fim = fim + np.eye(len(model.parameter_names)) * ( + small_number - min(eig) + ) + + # Compute the Cholesky decomposition of the FIM matrix + L = np.linalg.cholesky(fim) + + # Initialize the Cholesky matrix + for i, c in enumerate(model.parameter_names): + for j, d in enumerate(model.parameter_names): + model.L[c, d].value = L[i, j] + + def cholesky_imp(b, c, d): + """ + Calculate Cholesky L matrix using algebraic constraints + """ + # If the row is greater than or equal to the column, we are in the + # lower triangle region of the L and FIM matrices. + # This region is where our equations are well-defined. + m = b.model() + if list(m.parameter_names).index(c) >= list(m.parameter_names).index(d): + return m.fim[c, d] == sum( + m.L[c, m.parameter_names.at(k + 1)] + * m.L[d, m.parameter_names.at(k + 1)] + for k in range(list(m.parameter_names).index(d) + 1) + ) + else: + # This is the empty half of L above the diagonal + return pyo.Constraint.Skip + + def trace_calc(b): + """ + Calculate FIM elements. Can scale each element with 1000 for performance """ - r_list = list(range(len(m.regression_parameters))) + m = b.model() + return m.trace == sum(m.fim[j, j] for j in m.parameter_names) + + def determinant_general(b): + r"""Calculate determinant. Can be applied to FIM of any size. + det(A) = \sum_{\sigma in \S_n} (sgn(\sigma) * \Prod_{i=1}^n a_{i,\sigma_i}) + Use permutation() to get permutations, sgn() to get signature + """ + m = b.model() + r_list = list(range(len(m.parameter_names))) # get all permutations object_p = permutations(r_list) list_p = list(object_p) @@ -1049,109 +1260,1033 @@ def det_general(m): x_order = list_p[i] # sigma_i is the value in the i-th position after the reordering \sigma for x in range(len(x_order)): - for y, element in enumerate(m.regression_parameters): + for y, element in enumerate(m.parameter_names): if x_order[x] == y: name_order.append(element) - # det(A) = sum_{\sigma \in \S_n} (sgn(\sigma) * \Prod_{i=1}^n a_{i,\sigma_i}) det_perm = sum( self._sgn(list_p[d]) - * sum( - m.fim[each, name_order[b]] - for b, each in enumerate(m.regression_parameters) + * math.prod( + m.fim[m.parameter_names.at(val + 1), m.parameter_names.at(ind + 1)] + for ind, val in enumerate(list_p[d]) ) for d in range(len(list_p)) ) - return m.det == det_perm + return m.determinant == det_perm - if self.Cholesky_option: - m.cholesky_cons = pyo.Constraint( - m.regression_parameters, m.regression_parameters, rule=cholesky_imp + if self.Cholesky_option and self.objective_option == ObjectiveLib.determinant: + model.obj_cons.cholesky_cons = pyo.Constraint( + model.parameter_names, model.parameter_names, rule=cholesky_imp ) - m.Obj = pyo.Objective( - expr=2 * sum(pyo.log(m.L_ele[j, j]) for j in m.regression_parameters), + model.objective = pyo.Objective( + expr=2 * sum(pyo.log10(model.L[j, j]) for j in model.parameter_names), sense=pyo.maximize, ) - # if not cholesky but determinant, calculating det and evaluate the OBJ with det - elif self.objective_option == ObjectiveLib.det: - m.det_rule = pyo.Constraint(rule=det_general) - m.Obj = pyo.Objective(expr=pyo.log(m.det), sense=pyo.maximize) - # if not determinant or cholesky, calculating the OBJ with trace + + elif self.objective_option == ObjectiveLib.determinant: + # if not cholesky but determinant, calculating det and evaluate the OBJ with det + model.determinant = pyo.Var( + initialize=np.linalg.det(fim), bounds=(small_number, None) + ) + model.obj_cons.determinant_rule = pyo.Constraint(rule=determinant_general) + model.objective = pyo.Objective( + expr=pyo.log10(model.determinant + 1e-6), sense=pyo.maximize + ) + elif self.objective_option == ObjectiveLib.trace: - m.trace_rule = pyo.Constraint(rule=trace_calc) - m.Obj = pyo.Objective(expr=pyo.log(m.trace), sense=pyo.maximize) + # if not determinant or cholesky, calculating the OBJ with trace + model.trace = pyo.Var(initialize=np.trace(fim), bounds=(small_number, None)) + model.obj_cons.trace_rule = pyo.Constraint(rule=trace_calc) + model.objective = pyo.Objective( + expr=pyo.log10(model.trace), sense=pyo.maximize + ) + elif self.objective_option == ObjectiveLib.zero: - m.Obj = pyo.Objective(expr=0) + # add dummy objective function + model.objective = pyo.Objective(expr=0) + + # Check to see if the model has all the required suffixes + def check_model_labels(self, model=None): + """ + Checks if the model contains the necessary suffixes for the + DoE model to be constructed automatically. + + Parameters + ---------- + model: model for suffix checking + + """ + # Check that experimental outputs exist + try: + outputs = [k.name for k, v in model.experiment_outputs.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"experiment_outputs".' + ) + + # Check that experimental inputs exist + try: + outputs = [k.name for k, v in model.experiment_inputs.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"experiment_inputs".' + ) + + # Check that unknown parameters exist + try: + outputs = [k.name for k, v in model.unknown_parameters.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"unknown_parameters".' + ) + + # Check that measurement errors exist + try: + outputs = [k.name for k, v in model.measurement_error.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"measurement_error".' + ) + + self.logger.info("Model has expected labels.") + + # Check the FIM shape against what is expected from the model. + def check_model_FIM(self, model=None, FIM=None): + """ + Checks if the specified matrix, FIM, matches the shape expected + from the model. This method should only be called after the + model has been probed for the length of the unknown parameter, + experiment input, experiment output, and measurement error + has been stored to the object. + + Parameters + ---------- + model: model for suffix checking, Default: None, (self.model) + FIM: FIM value to check on the model + """ + if model is None: + model = self.model + + if FIM.shape != (self.n_parameters, self.n_parameters): + raise ValueError( + "Shape of FIM provided should be n parameters by n parameters, or {} by {}, FIM provided has shape {} by {}".format( + self.n_parameters, self.n_parameters, FIM.shape[0], FIM.shape[1] + ) + ) + + # Compute the eigenvalues of the FIM + evals = np.linalg.eigvals(FIM) + + # Check if the FIM is positive definite + if np.min(evals) < -_SMALL_TOLERANCE_DEFINITENESS: + raise ValueError( + "FIM provided is not positive definite. It has one or more negative eigenvalue(s) less than -{:.1e}".format( + _SMALL_TOLERANCE_DEFINITENESS + ) + ) + + # Check if the FIM is symmetric + if not np.allclose(FIM, FIM.T, atol=_SMALL_TOLERANCE_SYMMETRY): + raise ValueError( + "FIM provided is not symmetric using absolute tolerance {}".format( + _SMALL_TOLERANCE_SYMMETRY + ) + ) + + self.logger.info( + "FIM provided matches expected dimensions from model and is approximately positive (semi) definite." + ) + + # Check the jacobian shape against what is expected from the model. + def check_model_jac(self, jac=None): + if jac.shape != (self.n_experiment_outputs, self.n_parameters): + raise ValueError( + "Shape of Jacobian provided should be n experiment outputs by n parameters, or {} by {}, Jacobian provided has shape {} by {}".format( + self.n_experiment_outputs, + self.n_parameters, + jac.shape[0], + jac.shape[1], + ) + ) + + self.logger.info("Jacobian provided matches expected dimensions from model.") + + # Update the FIM for the specified model + def update_FIM_prior(self, model=None, FIM=None): + """ + Updates the prior FIM on the model object. This may be useful when + running a loop and the user doesn't want to rebuild the model + because it is expensive to build/initialize. + + Parameters + ---------- + model: model where FIM prior is to be updated, Default: None, (self.model) + FIM: 2D np array to be the new FIM prior, Default: None + """ + if model is None: + model = self.model + + # Check FIM input + if FIM is None: + raise ValueError( + "FIM input for update_FIM_prior must be a 2D, square numpy array." + ) + + if not hasattr(model, "fim"): + raise RuntimeError( + "``fim`` is not defined on the model provided. Please build the model first." + ) + + self.check_model_FIM(model=model, FIM=FIM) + + # Update FIM prior + for ind1, p1 in enumerate(model.parameter_names): + for ind2, p2 in enumerate(model.parameter_names): + model.prior_FIM[p1, p2].set_value(FIM[ind1, ind2]) + + self.logger.info("FIM prior has been updated.") + + # ToDo: Add an update function for the parameter values? --> closed loop parameter estimation? + # Or leave this to the user????? + def update_unknown_parameter_values(self, model=None, param_vals=None): + raise NotImplementedError( + "Updating unknown parameter values not yet supported." + ) + + # Evaluates FIM and statistics for a full factorial space (same as run_grid_search) + def compute_FIM_full_factorial( + self, model=None, design_ranges=None, method="sequential" + ): + """ + Will run a simulation-based full factorial exploration of + the experimental input space (i.e., a ``grid search`` or + ``parameter sweep``) to understand how the FIM metrics + change as a function of the experimental design space. + + Parameters + ---------- + model: model to perform the full factorial exploration on + design_ranges: dict of lists, of the form {: [start, stop, numsteps]} + method: string to specify which method should be used + options are ``kaug`` and ``sequential`` + + """ + # Start timer + sp_timer = TicTocTimer() + sp_timer.tic(msg=None) + self.logger.info("Beginning Full Factorial Design.") + + # Make new model for factorial design + self.factorial_model = self.experiment.get_labeled_model( + **self.get_labeled_model_args + ).clone() + model = self.factorial_model + + # Permute the inputs to be aligned with the experiment input indices + design_ranges_enum = {k: np.linspace(*v) for k, v in design_ranges.items()} + design_map = { + ind: (k[0].name, k[0]) + for ind, k in enumerate(model.experiment_inputs.items()) + } + + # Make the full space + try: + valid_inputs = 0 + des_ranges = [] + for k, v in design_map.items(): + if v[0] in design_ranges_enum.keys(): + des_ranges.append(design_ranges_enum[v[0]]) + valid_inputs += 1 + assert valid_inputs > 0 + + factorial_points = product(*des_ranges) + except: + raise ValueError( + "Design ranges keys must be a subset of experimental design names." + ) + + # ToDo: Add more objective types? i.e., modified-E; G-opt; V-opt; etc? + # ToDo: Also, make this a result object, or more user friendly. + fim_factorial_results = {k.name: [] for k, v in model.experiment_inputs.items()} + fim_factorial_results.update( + { + "log10 D-opt": [], + "log10 A-opt": [], + "log10 E-opt": [], + "log10 ME-opt": [], + "solve_time": [], + } + ) + + successes = 0 + failures = 0 + total_points = np.prod( + np.array([len(v) for k, v in design_ranges_enum.items()]) + ) + time_set = [] + curr_point = 1 # Initial current point + for design_point in factorial_points: + # Fix design variables at fixed experimental design point + for i in range(len(design_point)): + design_map[i][1].fix(design_point[i]) + + # Timing and logging objects + self.logger.info("=======Iteration Number: %s =====", curr_point) + iter_timer = TicTocTimer() + iter_timer.tic(msg=None) + + # Compute FIM with given options + try: + curr_point = successes + failures + 1 + + # Logging information for each run + self.logger.info("This is run %s out of %s.", curr_point, total_points) + + # Attempt the FIM computation + self.compute_FIM(model=model, method=method) + successes += 1 + + # iteration time + iter_t = iter_timer.toc(msg=None) + time_set.append(iter_t) + + # More logging + self.logger.info( + "The code has run for %s seconds.", round(sum(time_set), 2) + ) + self.logger.info( + "Estimated remaining time: %s seconds", + round( + sum(time_set) / (curr_point) * (total_points - curr_point + 1), + 2, + ), + ) + except: + self.logger.warning( + ":::::::::::Warning: Cannot converge this run.::::::::::::" + ) + failures += 1 + self.logger.warning("failed count:", failures) + + self._computed_FIM = np.zeros(self.prior_FIM.shape) + + iter_t = iter_timer.toc(msg=None) + time_set.append(iter_t) + + FIM = self._computed_FIM + + # Compute and record metrics on FIM + D_opt = np.log10(np.linalg.det(FIM)) + A_opt = np.log10(np.trace(FIM)) + E_vals, E_vecs = np.linalg.eig(FIM) # Grab eigenvalues + E_ind = np.argmin(E_vals.real) # Grab index of minima to check imaginary + # Warn the user if there is a ``large`` imaginary component (should not be) + if abs(E_vals.imag[E_ind]) > 1e-8: + self.logger.warning( + "Eigenvalue has imaginary component greater than 1e-6, contact developers if this issue persists." + ) + + # If the real value is less than or equal to zero, set the E_opt value to nan + if E_vals.real[E_ind] <= 0: + E_opt = np.nan + else: + E_opt = np.log10(E_vals.real[E_ind]) - return m + ME_opt = np.log10(np.linalg.cond(FIM)) - def _fix_design(self, m, design_val, fix_opt=True, optimize_option=None): + # Append the values for each of the experiment inputs + for k, v in model.experiment_inputs.items(): + fim_factorial_results[k.name].append(pyo.value(k)) + + fim_factorial_results["log10 D-opt"].append(D_opt) + fim_factorial_results["log10 A-opt"].append(A_opt) + fim_factorial_results["log10 E-opt"].append(E_opt) + fim_factorial_results["log10 ME-opt"].append(ME_opt) + fim_factorial_results["solve_time"].append(time_set[-1]) + + self.fim_factorial_results = fim_factorial_results + + return self.fim_factorial_results + + # TODO: Overhaul plotting functions to not use strings + # TODO: Make the plotting functionalities work for >2 design features + def draw_factorial_figure( + self, + results=None, + sensitivity_design_variables=None, + fixed_design_variables=None, + full_design_variable_names=None, + title_text="", + xlabel_text="", + ylabel_text="", + figure_file_name=None, + font_axes=16, + font_tick=14, + log_scale=True, + ): + """ + Extract results needed for drawing figures from the results dictionary provided by + the ``compute_FIM_full_factorial`` function. + + Draw either the 1D sensitivity curve or 2D heatmap. + + Parameters + ---------- + results: dictionary, results dictionary from ``compute_FIM_full_factorial``, default: None (self.fim_factorial_results) + sensitivity_design_variables: a list, design variable names to draw sensitivity + fixed_design_variables: a dictionary, keys are the design variable names to be fixed, values are the value of it to be fixed. + full_design_variable_names: a list, all the design variables in the problem. + title_text: a string, name for the figure + xlabel_text: a string, label for the x-axis of the figure (default: last design variable name) + In a 1D sensitivity curve, it should be design variable by which the curve is drawn. + In a 2D heatmap, it should be the second design variable in the design_ranges + ylabel_text: a string, label for the y-axis of the figure (default: None (1D); first design variable name (2D)) + A 1D sensitivity curve does not need it. In a 2D heatmap, it should be the first design variable in the dv_ranges + figure_file_name: string or Path, path to save the figure as + font_axes: axes label font size + font_tick: tick label font size + log_scale: if True, the result matrix will be scaled by log10 + + """ + if results is None: + if not hasattr(self, "fim_factorial_results"): + raise RuntimeError( + "Results must be provided or the compute_FIM_full_factorial function must be run." + ) + results = self.fim_factorial_results + full_design_variable_names = [ + k.name for k, v in self.factorial_model.experiment_inputs.items() + ] + else: + if full_design_variable_names is None: + raise ValueError( + "If results object is provided, you must include all the design variable names." + ) + + des_names = full_design_variable_names + + # Inputs must exist for the function to do anything + # ToDo: Put in a default value function????? + if sensitivity_design_variables is None: + raise ValueError("``sensitivity_design_variables`` must be included.") + + if fixed_design_variables is None: + raise ValueError("``fixed_design_variables`` must be included.") + + # Check that the provided design variables are within the results object + check_des_vars = True + for k, v in fixed_design_variables.items(): + check_des_vars *= k in ([k2 for k2, v2 in results.items()]) + check_sens_vars = True + for k in sensitivity_design_variables: + check_sens_vars *= k in [k2 for k2, v2 in results.items()] + + if not check_des_vars: + raise ValueError( + "Fixed design variables do not all appear in the results object keys." + ) + if not check_sens_vars: + raise ValueError( + "Sensitivity design variables do not all appear in the results object keys." + ) + + # ToDo: Make it possible to plot pair-wise sensitivities for all variables + # e.g. a curve like low-dimensional posterior distributions + if len(sensitivity_design_variables) > 2: + raise NotImplementedError( + "Currently, only 1D and 2D sensitivity plotting is supported." + ) + + if len(fixed_design_variables.keys()) + len( + sensitivity_design_variables + ) != len(des_names): + raise ValueError( + "Error: All design variables that are not used to generate sensitivity plots must be fixed." + ) + + if type(results) is dict: + results_pd = pd.DataFrame(results) + else: + results_pd = results + + # generate a combination of logic sentences to filter the results of the DOF needed. + # an example filter: (self.store_all_results_dataframe["CA0"]==5). + if len(fixed_design_variables.keys()) != 0: + filter = "" + i = 0 + for k, v in fixed_design_variables.items(): + filter += "(results_pd['" + filter += str(k) + filter += "']==" + filter += str(v) + filter += ")" + if i < (len(fixed_design_variables.keys()) - 1): + filter += "&" + i += 1 + # extract results with other dimensions fixed + figure_result_data = results_pd.loc[eval(filter)] + + # if there is no other fixed dimensions + else: + figure_result_data = results_pd + + # Add attributes for drawing figures in later functions + self.figure_result_data = figure_result_data + self.figure_sens_des_vars = sensitivity_design_variables + self.figure_fixed_des_vars = fixed_design_variables + + # if one design variable name is given as DOF, draw 1D sensitivity curve + if len(self.figure_sens_des_vars) == 1: + self._curve1D( + title_text, + xlabel_text, + font_axes=font_axes, + font_tick=font_tick, + log_scale=log_scale, + figure_file_name=figure_file_name, + ) + # if two design variable names are given as DOF, draw 2D heatmaps + elif len(self.figure_sens_des_vars) == 2: + self._heatmap( + title_text, + xlabel_text, + ylabel_text, + font_axes=font_axes, + font_tick=font_tick, + log_scale=log_scale, + figure_file_name=figure_file_name, + ) + # ToDo: Add the multidimensional plotting + else: + pass + + def _curve1D( + self, + title_text, + xlabel_text, + font_axes=16, + font_tick=14, + figure_file_name=None, + log_scale=True, + ): + """ + Draw 1D sensitivity curves for all design criteria + + Parameters + ---------- + title_text: name of the figure, a string + xlabel_text: x label title, a string. + In a 1D sensitivity curve, it is the design variable by which the curve is drawn. + font_axes: axes label font size + font_tick: tick label font size + figure_file_name: string or Path, path to save the figure as + log_scale: if True, the result matrix will be scaled by log10 + + Returns + -------- + 4 Figures of 1D sensitivity curves for each criteria """ - Fix design variable + if figure_file_name is not None: + show_fig = False + else: + show_fig = True + + # extract the range of the DOF design variable + x_range = self.figure_result_data[self.figure_sens_des_vars[0]].values.tolist() + + # decide if the results are log scaled + if log_scale: + y_range_A = np.log10(self.figure_result_data["log10 A-opt"].values.tolist()) + y_range_D = np.log10(self.figure_result_data["log10 D-opt"].values.tolist()) + y_range_E = np.log10(self.figure_result_data["log10 E-opt"].values.tolist()) + y_range_ME = np.log10( + self.figure_result_data["log10 ME-opt"].values.tolist() + ) + else: + y_range_A = self.figure_result_data["log10 A-opt"].values.tolist() + y_range_D = self.figure_result_data["log10 D-opt"].values.tolist() + y_range_E = self.figure_result_data["log10 E-opt"].values.tolist() + y_range_ME = self.figure_result_data["log10 ME-opt"].values.tolist() + + # Draw A-optimality + fig = plt.pyplot.figure() + plt.pyplot.rc("axes", titlesize=font_axes) + plt.pyplot.rc("axes", labelsize=font_axes) + plt.pyplot.rc("xtick", labelsize=font_tick) + plt.pyplot.rc("ytick", labelsize=font_tick) + ax = fig.add_subplot(111) + params = {"mathtext.default": "regular"} + # plt.rcParams.update(params) + ax.plot(x_range, y_range_A) + ax.scatter(x_range, y_range_A) + ax.set_ylabel("$log_{10}$ Trace") + ax.set_xlabel(xlabel_text) + plt.pyplot.title(title_text + ": A-optimality") + if show_fig: + plt.pyplot.show() + else: + plt.pyplot.savefig( + pathlib.Path(figure_file_name + "_A_opt.png"), format="png", dpi=450 + ) + + # Draw D-optimality + fig = plt.pyplot.figure() + plt.pyplot.rc("axes", titlesize=font_axes) + plt.pyplot.rc("axes", labelsize=font_axes) + plt.pyplot.rc("xtick", labelsize=font_tick) + plt.pyplot.rc("ytick", labelsize=font_tick) + ax = fig.add_subplot(111) + params = {"mathtext.default": "regular"} + # plt.rcParams.update(params) + ax.plot(x_range, y_range_D) + ax.scatter(x_range, y_range_D) + ax.set_ylabel("$log_{10}$ Determinant") + ax.set_xlabel(xlabel_text) + plt.pyplot.title(title_text + ": D-optimality") + if show_fig: + plt.pyplot.show() + else: + plt.pyplot.savefig( + pathlib.Path(figure_file_name + "_D_opt.png"), format="png", dpi=450 + ) + + # Draw E-optimality + fig = plt.pyplot.figure() + plt.pyplot.rc("axes", titlesize=font_axes) + plt.pyplot.rc("axes", labelsize=font_axes) + plt.pyplot.rc("xtick", labelsize=font_tick) + plt.pyplot.rc("ytick", labelsize=font_tick) + ax = fig.add_subplot(111) + params = {"mathtext.default": "regular"} + # plt.rcParams.update(params) + ax.plot(x_range, y_range_E) + ax.scatter(x_range, y_range_E) + ax.set_ylabel("$log_{10}$ Minimal eigenvalue") + ax.set_xlabel(xlabel_text) + plt.pyplot.title(title_text + ": E-optimality") + if show_fig: + plt.pyplot.show() + else: + plt.pyplot.savefig( + pathlib.Path(figure_file_name + "_E_opt.png"), format="png", dpi=450 + ) + + # Draw Modified E-optimality + fig = plt.pyplot.figure() + plt.pyplot.rc("axes", titlesize=font_axes) + plt.pyplot.rc("axes", labelsize=font_axes) + plt.pyplot.rc("xtick", labelsize=font_tick) + plt.pyplot.rc("ytick", labelsize=font_tick) + ax = fig.add_subplot(111) + params = {"mathtext.default": "regular"} + # plt.rcParams.update(params) + ax.plot(x_range, y_range_ME) + ax.scatter(x_range, y_range_ME) + ax.set_ylabel("$log_{10}$ Condition number") + ax.set_xlabel(xlabel_text) + plt.pyplot.title(title_text + ": Modified E-optimality") + if show_fig: + plt.pyplot.show() + else: + plt.pyplot.savefig( + pathlib.Path(figure_file_name + "_ME_opt.png"), format="png", dpi=450 + ) + + def _heatmap( + self, + title_text, + xlabel_text, + ylabel_text, + font_axes=16, + font_tick=14, + figure_file_name=None, + log_scale=True, + ): + """ + Draw 2D heatmaps for all design criteria Parameters ---------- - m: model - design_val: design variable values dict - fix_opt: if True, fix. Else, unfix - optimize: a dictionary, keys are design variable name, values are True or False, deciding if this design variable is optimized as DOF this time + title_text: name of the figure, a string + xlabel_text: x label title, a string. + In a 2D heatmap, it should be the second design variable in the design_ranges + ylabel_text: y label title, a string. + In a 2D heatmap, it should be the first design variable in the dv_ranges + font_axes: axes label font size + font_tick: tick label font size + figure_file_name: string or Path, path to save the figure as + log_scale: if True, the result matrix will be scaled by log10 + + Returns + -------- + 4 Figures of 2D heatmap for each criteria + """ + if figure_file_name is not None: + show_fig = False + else: + show_fig = True + + des_names = [k for k, v in self.figure_fixed_des_vars.items()] + sens_ranges = {} + for i in self.figure_sens_des_vars: + sens_ranges[i] = list(self.figure_result_data[i].unique()) + + x_range = sens_ranges[self.figure_sens_des_vars[0]] + y_range = sens_ranges[self.figure_sens_des_vars[1]] + + # extract the design criteria values + A_range = self.figure_result_data["log10 A-opt"].values.tolist() + D_range = self.figure_result_data["log10 D-opt"].values.tolist() + E_range = self.figure_result_data["log10 E-opt"].values.tolist() + ME_range = self.figure_result_data["log10 ME-opt"].values.tolist() + + # reshape the design criteria values for heatmaps + cri_a = np.asarray(A_range).reshape(len(x_range), len(y_range)) + cri_d = np.asarray(D_range).reshape(len(x_range), len(y_range)) + cri_e = np.asarray(E_range).reshape(len(x_range), len(y_range)) + cri_e_cond = np.asarray(ME_range).reshape(len(x_range), len(y_range)) + + self.cri_a = cri_a + self.cri_d = cri_d + self.cri_e = cri_e + self.cri_e_cond = cri_e_cond + + # decide if log scaled + if log_scale: + hes_a = np.log10(self.cri_a) + hes_e = np.log10(self.cri_e) + hes_d = np.log10(self.cri_d) + hes_e2 = np.log10(self.cri_e_cond) + else: + hes_a = self.cri_a + hes_e = self.cri_e + hes_d = self.cri_d + hes_e2 = self.cri_e_cond + + # set heatmap x,y ranges + xLabel = x_range + yLabel = y_range + + # A-optimality + fig = plt.pyplot.figure() + plt.pyplot.rc("axes", titlesize=font_axes) + plt.pyplot.rc("axes", labelsize=font_axes) + plt.pyplot.rc("xtick", labelsize=font_tick) + plt.pyplot.rc("ytick", labelsize=font_tick) + ax = fig.add_subplot(111) + params = {"mathtext.default": "regular"} + plt.pyplot.rcParams.update(params) + ax.set_yticks(range(len(yLabel))) + ax.set_yticklabels(yLabel) + ax.set_ylabel(ylabel_text) + ax.set_xticks(range(len(xLabel))) + ax.set_xticklabels(xLabel) + ax.set_xlabel(xlabel_text) + im = ax.imshow(hes_a.T, cmap=plt.pyplot.cm.hot_r) + ba = plt.pyplot.colorbar(im) + ba.set_label("log10(trace(FIM))") + plt.pyplot.title(title_text + ": A-optimality") + if show_fig: + plt.pyplot.show() + else: + plt.pyplot.savefig( + pathlib.Path(figure_file_name + "_A_opt.png"), format="png", dpi=450 + ) + + # D-optimality + fig = plt.pyplot.figure() + plt.pyplot.rc("axes", titlesize=font_axes) + plt.pyplot.rc("axes", labelsize=font_axes) + plt.pyplot.rc("xtick", labelsize=font_tick) + plt.pyplot.rc("ytick", labelsize=font_tick) + ax = fig.add_subplot(111) + params = {"mathtext.default": "regular"} + plt.pyplot.rcParams.update(params) + ax.set_yticks(range(len(yLabel))) + ax.set_yticklabels(yLabel) + ax.set_ylabel(ylabel_text) + ax.set_xticks(range(len(xLabel))) + ax.set_xticklabels(xLabel) + ax.set_xlabel(xlabel_text) + im = ax.imshow(hes_d.T, cmap=plt.pyplot.cm.hot_r) + ba = plt.pyplot.colorbar(im) + ba.set_label("log10(det(FIM))") + plt.pyplot.title(title_text + ": D-optimality") + if show_fig: + plt.pyplot.show() + else: + plt.pyplot.savefig( + pathlib.Path(figure_file_name + "_D_opt.png"), format="png", dpi=450 + ) + + # E-optimality + fig = plt.pyplot.figure() + plt.pyplot.rc("axes", titlesize=font_axes) + plt.pyplot.rc("axes", labelsize=font_axes) + plt.pyplot.rc("xtick", labelsize=font_tick) + plt.pyplot.rc("ytick", labelsize=font_tick) + ax = fig.add_subplot(111) + params = {"mathtext.default": "regular"} + plt.pyplot.rcParams.update(params) + ax.set_yticks(range(len(yLabel))) + ax.set_yticklabels(yLabel) + ax.set_ylabel(ylabel_text) + ax.set_xticks(range(len(xLabel))) + ax.set_xticklabels(xLabel) + ax.set_xlabel(xlabel_text) + im = ax.imshow(hes_e.T, cmap=plt.pyplot.cm.hot_r) + ba = plt.pyplot.colorbar(im) + ba.set_label("log10(minimal eig(FIM))") + plt.pyplot.title(title_text + ": E-optimality") + if show_fig: + plt.pyplot.show() + else: + plt.pyplot.savefig( + pathlib.Path(figure_file_name + "_E_opt.png"), format="png", dpi=450 + ) + + # Modified E-optimality + fig = plt.pyplot.figure() + plt.pyplot.rc("axes", titlesize=font_axes) + plt.pyplot.rc("axes", labelsize=font_axes) + plt.pyplot.rc("xtick", labelsize=font_tick) + plt.pyplot.rc("ytick", labelsize=font_tick) + ax = fig.add_subplot(111) + params = {"mathtext.default": "regular"} + plt.pyplot.rcParams.update(params) + ax.set_yticks(range(len(yLabel))) + ax.set_yticklabels(yLabel) + ax.set_ylabel(ylabel_text) + ax.set_xticks(range(len(xLabel))) + ax.set_xticklabels(xLabel) + ax.set_xlabel(xlabel_text) + im = ax.imshow(hes_e2.T, cmap=plt.pyplot.cm.hot_r) + ba = plt.pyplot.colorbar(im) + ba.set_label("log10(cond(FIM))") + plt.pyplot.title(title_text + ": Modified E-optimality") + if show_fig: + plt.pyplot.show() + else: + plt.pyplot.savefig( + pathlib.Path(figure_file_name + "_ME_opt.png"), format="png", dpi=450 + ) + + # Gets the FIM from an existing model + def get_FIM(self, model=None): + """ + Gets the FIM values from the model specified + + Parameters + ---------- + model: model to grab FIM from, Default: None, (self.model) Returns ------- - m: model + FIM: 2D list representation of the FIM (can be cast to numpy) + """ - for name in self.design_name: - cuid = pyo.ComponentUID(name) - var = cuid.find_component_on(m) - if fix_opt: - var.fix(design_val[name]) - else: - if optimize_option is None: - var.unfix() - else: - if optimize_option[name]: - var.unfix() - return m - - def _get_default_ipopt_solver(self): - """Default solver""" - solver = SolverFactory('ipopt') - solver.options['linear_solver'] = 'ma57' - solver.options['halt_on_ampl_error'] = 'yes' - solver.options['max_iter'] = 3000 - return solver - - def _solve_doe(self, m, fix=False, opt_option=None): - """Solve DOE model. - If it's a square problem, fix design variable and solve. - Else, fix design variable and solve square problem firstly, then unfix them and solve the optimization problem + if model is None: + model = self.model + + if not hasattr(model, "fim"): + raise RuntimeError( + "Model provided does not have variable `fim`. Please make sure the model is built properly before calling `get_FIM`" + ) + + fim_vals = [ + pyo.value(model.fim[i, j]) + for i in model.parameter_names + for j in model.parameter_names + ] + fim_np = np.array(fim_vals).reshape( + (len(model.parameter_names), len(model.parameter_names)) + ) + + # FIM is a lower triangular matrix for the optimal DoE problem. + # Exploit symmetry to fill in the zeros. + for i in range(len(model.parameter_names)): + for j in range(len(model.parameter_names)): + if j < i: + fim_np[j, i] = fim_np[i, j] + + return [list(row) for row in list(fim_np)] + + # Gets the sensitivity matrix from an existing model + def get_sensitivity_matrix(self, model=None): + """ + Gets the sensitivity matrix (Q) values from the model specified. Parameters ---------- - m:model - fix: if true, solve two times (square first). Else, just solve the square problem - opt_option: a dictionary, keys are design variable name, values are True or False, - deciding if this design variable is optimized as DOF this time. - If None, all design variables are optimized as DOF this time. + model: model to grab Q from, Default: None, (self.model) Returns ------- - solver_results: solver results + Q: 2D list representation of the sensitivity matrix (can be cast to numpy) + """ - ### Solve square problem - mod = self._fix_design( - m, self.design_values, fix_opt=fix, optimize_option=opt_option + if model is None: + model = self.model + + if not hasattr(model, "sensitivity_jacobian"): + raise RuntimeError( + "Model provided does not have variable `sensitivity_jacobian`. Please make sure the model is built properly before calling `get_sensitivity_matrix`" + ) + + Q_vals = [ + pyo.value(model.sensitivity_jacobian[i, j]) + for i in model.output_names + for j in model.parameter_names + ] + Q_np = np.array(Q_vals).reshape( + (len(model.output_names), len(model.parameter_names)) ) - # if user gives solver, use this solver. if not, use default IPOPT solver - solver_result = self.solver.solve(mod, tee=self.tee_opt) + return [list(row) for row in list(Q_np)] + + # Gets the experiment input values from an existing model + def get_experiment_input_values(self, model=None): + """ + Gets the experiment input values (experimental design) + from the model specified. + + Parameters + ---------- + model: model to grab the experimental design from, + default: None, (self.model) + + Returns + ------- + d: 1D list of experiment input values (optimal or specified design) + + """ + if model is None: + model = self.model + + if not hasattr(model, "experiment_inputs"): + if not hasattr(model, "scenario_blocks"): + raise RuntimeError( + "Model provided does not have expected structure. Please make sure model is built properly before calling `get_experiment_input_values`" + ) + + d_vals = [ + pyo.value(k) + for k, v in model.scenario_blocks[0].experiment_inputs.items() + ] + else: + d_vals = [pyo.value(k) for k, v in model.experiment_inputs.items()] + + return d_vals + + # Gets the unknown parameter values from an existing model + def get_unknown_parameter_values(self, model=None): + """ + Gets the unknown parameter values (theta) + from the model specified. + + Parameters + ---------- + model: model to grab theta from, + default: None, (self.model) + + Returns + ------- + theta: 1D list of unknown parameter values at which this experiment was designed + + """ + if model is None: + model = self.model + + if not hasattr(model, "unknown_parameters"): + if not hasattr(model, "scenario_blocks"): + raise RuntimeError( + "Model provided does not have expected structure. Please make sure model is built properly before calling `get_unknown_parameter_values`" + ) + + theta_vals = [ + pyo.value(k) + for k, v in model.scenario_blocks[0].unknown_parameters.items() + ] + else: + theta_vals = [pyo.value(k) for k, v in model.unknown_parameters.items()] + + return theta_vals + + # Gets the experiment output values from an existing model + def get_experiment_output_values(self, model=None): + """ + Gets the experiment output values (y hat) + from the model specified. + + Parameters + ---------- + model: model to grab y hat from, + default: None, (self.model) + + Returns + ------- + y_hat: 1D list of experiment output values from the design experiment + + """ + if model is None: + model = self.model + + if not hasattr(model, "experiment_outputs"): + if not hasattr(model, "scenario_blocks"): + raise RuntimeError( + "Model provided does not have expected structure. Please make sure model is built properly before calling `get_experiment_output_values`" + ) + + y_hat_vals = [ + pyo.value(k) + for k, v in model.scenario_blocks[0].experiment_outputs.items() + ] + else: + y_hat_vals = [pyo.value(k) for k, v in model.experiment_outputs.items()] + + return y_hat_vals + + # ToDo: For more complicated error structures, this should become + # get cov_y, or so, and this method will be deprecated + # Gets the measurement error values from an existing model + def get_measurement_error_values(self, model=None): + """ + Gets the experiment output values (sigma) + from the model specified. + + Parameters + ---------- + model: model to grab sigma values from, + default: None, (self.model) + + Returns + ------- + sigma_diag: 1D list of measurement errors used to design the experiment + + """ + if model is None: + model = self.model + + if not hasattr(model, "measurement_error"): + if not hasattr(model, "scenario_blocks"): + raise RuntimeError( + "Model provided does not have expected structure. Please make sure model is built properly before calling `get_measurement_error_values`" + ) + + sigma_vals = [ + pyo.value(k) + for k, v in model.scenario_blocks[0].measurement_error.items() + ] + else: + sigma_vals = [pyo.value(k) for k, v in model.measurement_error.items()] - return solver_result + return sigma_vals + # Helper function for determinant calculation def _sgn(self, p): """ - This is a helper function for stochastic_program function to compute the determinant formula. - Give the signature of a permutation + This is a helper function for when constructing the determinant formula + without the Cholesky factorization. Parameters ----------- diff --git a/pyomo/contrib/doe/examples/__init__.py b/pyomo/contrib/doe/examples/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/doe/examples/__init__.py +++ b/pyomo/contrib/doe/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/doe/examples/dynamic.csv b/pyomo/contrib/doe/examples/dynamic.csv deleted file mode 100644 index f54d798bda3..00000000000 --- a/pyomo/contrib/doe/examples/dynamic.csv +++ /dev/null @@ -1,5 +0,0 @@ -A1,A2,E1,E2 -6.209381770954067,7.719297166025923,-12.835153102965977,-38.540492455469554 -7.719297166025923,20.53859118830565,-14.829065563786362,-99.2962499942191 --12.835153102965977,-14.829065563786362,26.869188945470434,74.5001011185848 --38.540492455469554,-99.2962499942191,74.5001011185848,484.97578893372025 diff --git a/pyomo/contrib/doe/examples/fim_5_300_500_scale.csv b/pyomo/contrib/doe/examples/fim_5_300_500_scale.csv deleted file mode 100644 index 77c0424aa13..00000000000 --- a/pyomo/contrib/doe/examples/fim_5_300_500_scale.csv +++ /dev/null @@ -1,5 +0,0 @@ -A1,A2,E1,E2 -28.678928056936364,5.412497388906993,-81.73674601413501,-24.023773235011475 -5.412497388906993,26.409350356572013,-12.418164773953235,-139.2399253159117 --81.73674601413501,-12.418164773953235,240.46276003997696,58.764228064029076 --24.023773235011475,-139.2399253159117,58.764228064029076,767.255845082616 diff --git a/pyomo/contrib/doe/examples/fim_5_300_scale.csv b/pyomo/contrib/doe/examples/fim_5_300_scale.csv deleted file mode 100644 index 381e916b9d4..00000000000 --- a/pyomo/contrib/doe/examples/fim_5_300_scale.csv +++ /dev/null @@ -1,5 +0,0 @@ -A1,A2,E1,E2 -22.529430237938822,1.8403431417002734,-70.23273336318343,-11.094329617631416 -1.8403431417002734,18.098481155262718,-5.7356503398877745,-109.15866135211135 --70.23273336318343,-5.7356503398877745,218.9419284259853,34.576808479575064 --11.094329617631416,-109.15866135211135,34.576808479575064,658.3764463408718 diff --git a/pyomo/contrib/doe/examples/fim_doe_tutorial.ipynb b/pyomo/contrib/doe/examples/fim_doe_tutorial.ipynb deleted file mode 100644 index 12d5a610db4..00000000000 --- a/pyomo/contrib/doe/examples/fim_doe_tutorial.ipynb +++ /dev/null @@ -1,1867 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Pyomo.DoE Tutorial: Reaction Kinetics Example \n", - "\n", - "Jialu Wang (jwang44@nd.edu), Alex Dowling (adowling@nd.edu), and Hailey Lynch (hlynch@nd.edu)\n", - "\n", - "University of Notre Dame\n", - "\n", - "This notebook demonstrates the main features of Pyomo.DoE (model-based design of experiments) using a reaction kinetics example. See [Wang and Dowling (2022), AIChE J.](https://aiche.onlinelibrary.wiley.com/doi/full/10.1002/aic.17813), for more information.\n", - "\n", - "The user will be able to learn concepts involved with model-based design of experiments (MBDoE) and practice using Pyomo.DoE from methodology in the notebook. Results will be interpreted throughout the notebook to connect the material with the Pyomo implementation.\n", - " " - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The general process that will follow throughout this notebook:\n", - "\n", - "Import Modules\n", - "\n", - "* Step 0: Import Pyomo and Pyomo.DoE Module\n", - "\n", - "Problem Statement\n", - "\n", - "* Step 1: Import Reaction Kinetics Example Mathematical Model\n", - "\n", - "Implementation in Pyomo\n", - "\n", - "* Step 2: Implement Mathematical Model in Pyomo\n", - "* Step 3: Define Inputs for the Model\n", - "\n", - "Methodology\n", - "\n", - "* Step 4: Method for Computing FIM\n", - "\n", - "* Step 5: Method for Optimization\n", - "\n", - "* Step 6: Method for Exploratory Analysis through Enumeration\n", - "\n", - "Visualizing Results\n", - "\n", - "* Step 7: Results through Heatmaps and Sensitivity Curves\n", - "\n", - "Key Takeaways\n", - "* MBDoE maximizes the information gained from experiments which reduces uncertainty (technical risk) and facilitates better decision-making.\n", - "\n", - "* FIM quantifies the information contained in a set of experiments (data) with respect to a mathematical model\n", - "\n", - "* MBDoE optimality criteria (e.g., A, D, E-optimal designs) compress the FIM into a scalar. The \"correct\" criterion depends on the DoE goal and model context.\n", - "\n", - "* Heatmaps provide visualizations of the most informative parameters using the MBDoE optimality criteria." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 0: Import Pyomo and Pyomo.DoE module" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Successfully loaded IDAES.\n" - ] - } - ], - "source": [ - "# Ipopt installer\n", - "import sys\n", - "\n", - "# If running on Google Colab, install Ipopt via IDAES\n", - "if \"google.colab\" in sys.modules:\n", - " !wget \"https://raw.githubusercontent.com/IDAES/idaes-pse/main/scripts/colab_helper.py\"\n", - " import colab_helper\n", - " colab_helper.install_idaes()\n", - " colab_helper.install_ipopt()\n", - "\n", - "# Otherwise, attempt to load IDAES which should include Ipopt and k_aug\n", - "# See https://idaes-pse.readthedocs.io/en/stable/tutorials/getting_started/index.html\n", - "# for instructions on running IDAES get-extensions\n", - "else:\n", - " try:\n", - " import idaes\n", - "\n", - " # Provided IDAES extensions are installed, importing IDAES provides access to\n", - " # Ipopt with HSL and k_aug which are needed for this example\n", - " print(\"Successfully loaded IDAES.\")\n", - " except:\n", - " print(\n", - " \"IDAES is not installed. Make sure you have independently installed Ipopt with HSL and k_aug.\"\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# Imports\n", - "import numpy as np\n", - "import pyomo.environ as pyo\n", - "from pyomo.dae import ContinuousSet, DerivativeVar\n", - "from pyomo.contrib.doe import (\n", - " ModelOptionLib,\n", - " DesignOfExperiments,\n", - " MeasurementVariables,\n", - " DesignVariables,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# Check if Ipopt is available\n", - "ipopt_available = pyo.SolverFactory(\"ipopt\").available()\n", - "if not (ipopt_available):\n", - " raise RuntimeError(\"This Pyomo.DoE example requires Ipopt.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 1: Import Reaction Kinetics Example Mathematical Model" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Consider two chemical reactions that convert molecule $A$ to desired product $B$ and a less valuable side-product $C$.\n", - "\n", - "$$A \\overset{k_1}{\\rightarrow} B \\overset{k_2}{\\rightarrow} C$$\n", - "\n", - "Our ultimate goal is to design a large-scale continuous reactor that maximizes the production of $B$. This general sequential reactions problem is widely applicable to CO$_2$ capture and industry more broadly (petrochemicals, pharmaceuticals, etc.).\n", - "\n", - "The rate laws for these two chemical reactions are:\n", - "\n", - "$$r_A = -k_1 C_A$$\n", - "\n", - "$$r_B = k_1 C_A - k_2 C_B$$\n", - "\n", - "$$r_C = k_2 C_B$$\n", - "\n", - "Here, $C_A$, $C_B$, and $C_C$ are the concentrations of each species. \n", - "\n", - "The rate constants $k_1$ and $k_2$ depend on temperature as follows:\n", - "\n", - "$$k_1 = A_1 \\exp{\\frac{-E_1}{R T}}$$\n", - "\n", - "$$k_2 = A_2 \\exp{\\frac{-E_2}{R T}}$$\n", - "\n", - "where:\n", - "* $A_1$ [$s^{-1}$], $A_2$ [$s^{-1}$] , $E_1$ [kJ/mol], and $E_2$ [kJ/mol] are fitted model parameters\n", - "* $R$ [J/molK] is the ideal-gas constant\n", - "* $T$ [K] is absolute temperature\n", - "\n", - "Using the Pyomo ecosystem, we would like to perform **uncertainty quantification** and **design of experiments** on a small-scale batch reactor to infer parameters $A_1$, $A_2$, $E_1$, and $E_2$." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Batch Reactor\n", - "\n", - "The concentrations in a batch reactor evolve with time and are modeled by the following differential equations:\n", - "\n", - "$$ \\frac{d C_A}{dt} = r_A = -k_1 C_A $$\n", - "\n", - "$$ \\frac{d C_B}{dt} = r_B = k_1 C_A - k_2 C_B $$\n", - "\n", - "$$ \\frac{d C_C}{dt} = r_C = k_2 C_B $$\n", - "\n", - "We have now established a linear system of differential equations. Next, we can write the initial conditions assuming the feed is only species $A$ such that:\n", - "\n", - "$$C_A(t=0) = C_{A0}, \\quad C_B(t=0) = 0, \\quad C_C(t=0) = 0$$\n", - "\n", - "When $k_1$ and $k_2$ are at constant temperature, it leads to the following analytic solution:\n", - "\n", - "$$C_A(t) = C_{A0} \\exp(-k_1 t)$$\n", - "\n", - "$$C_B(t) = \\frac{k_1}{k_2 - k_1} C_{A0} \\left[\\exp(-k_1 t) - \\exp(-k_2 t) \\right]$$\n", - "\n", - "$$C_C(t) = C_{A0} - \\frac{k_2}{k_2 - k_1} C_{A0} \\exp(-k_1 t) + \\frac{k_1}{k_2 - k_1} \\exp(-k_2 t) C_{A0} = C_{A0} - C_{A}(t) - C_{B}(t)$$" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Implement Mathematical Model in Pyomo" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The mathematical model is comprised of a system of differential-algebraic equations (DAEs) which will be solved using Pyomo.DAE." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "# Discretize using Pyomo.DAE\n", - "def disc_for_measure(m, nfe=32, block=True):\n", - " \"\"\"\n", - " Pyomo.DAE discretization\n", - "\n", - " Arguments\n", - " ---------\n", - " m: Pyomo model\n", - " nfe: number of finite elements b\n", - " block: if True, the input model has blocks\n", - " \"\"\"\n", - " # Discretization using collocation\n", - " discretizer = pyo.TransformationFactory(\"dae.collocation\")\n", - " if block:\n", - " for s in range(len(m.block)):\n", - " discretizer.apply_to(m.block[s], nfe=nfe, ncp=3, wrt=m.block[s].t)\n", - " else:\n", - " discretizer.apply_to(m, nfe=nfe, ncp=3, wrt=m.t)\n", - " return m" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Next, create the model." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "# Create model\n", - "def create_model(\n", - " mod=None,\n", - " model_option=\"stage2\",\n", - " control_time=[0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1],\n", - " control_val=None,\n", - " t_range=[0.0, 1],\n", - " CA_init=1,\n", - " C_init=0.1,\n", - "):\n", - " \"\"\"\n", - " This is an example user model provided to the DoE library.\n", - " It is a dynamic problem solved by Pyomo.DAE.\n", - "\n", - " Arguments\n", - " ---------\n", - " mod: Pyomo model. If None, a Pyomo concrete model is created\n", - " model_option: choose from the 3 options in model_option\n", - " if ModelOptionLib.parmest, create a process model.\n", - " if ModelOptionLib.stage1, create the global model.\n", - " if ModelOptionLib.stage2, add model variables and constraints for block.\n", - " control_time: a list of control timepoints\n", - " control_val: control design variable values T at corresponding timepoints\n", - " t_range: time range, hours\n", - " CA_init: time-independent design (control) variable, an initial value for CA\n", - " C_init: An initial value for C\n", - "\n", - " Return\n", - " ------\n", - " m: a Pyomo.DAE model\n", - " \"\"\"\n", - " # Parameter initialization; results from parameter estimation\n", - " theta = {\"A1\": 84.79, \"A2\": 371.72, \"E1\": 7.78, \"E2\": 15.05}\n", - "\n", - " # Model option\n", - " model_option = ModelOptionLib(model_option)\n", - "\n", - " if model_option == ModelOptionLib.parmest:\n", - " mod = pyo.ConcreteModel()\n", - " return_m = True\n", - " elif model_option == ModelOptionLib.stage1 or model_option == ModelOptionLib.stage2:\n", - " if not mod:\n", - " raise ValueError(\n", - " \"If model option is stage1 or stage2, a created model needs to be provided.\"\n", - " )\n", - " return_m = False\n", - " else:\n", - " raise ValueError(\n", - " \"model_option needs to be defined as parmest, stage1, or stage2.\"\n", - " )\n", - "\n", - " # Control value\n", - " if not control_val:\n", - " control_val = [300] * 9\n", - "\n", - " # Control time\n", - " controls = {}\n", - " for i, t in enumerate(control_time):\n", - " controls[t] = control_val[i]\n", - "\n", - " mod.t0 = pyo.Set(initialize=[0])\n", - " mod.t_con = pyo.Set(initialize=control_time)\n", - " mod.CA0 = pyo.Var(\n", - " mod.t0, initialize=CA_init, bounds=(1.0, 5.0), within=pyo.NonNegativeReals\n", - " ) # mol/L\n", - "\n", - " # Check if control_time is in time range\n", - " assert (\n", - " control_time[0] >= t_range[0] and control_time[-1] <= t_range[1]\n", - " ), \"control time is outside time range.\"\n", - "\n", - " if model_option == ModelOptionLib.stage1:\n", - " mod.T = pyo.Var(\n", - " mod.t_con,\n", - " initialize=controls,\n", - " bounds=(300, 700),\n", - " within=pyo.NonNegativeReals,\n", - " )\n", - " return\n", - "\n", - " else:\n", - " para_list = [\"A1\", \"A2\", \"E1\", \"E2\"]\n", - "\n", - " # Add variables\n", - " mod.CA_init = CA_init\n", - " mod.para_list = para_list\n", - "\n", - " # Timepoints\n", - " mod.t = ContinuousSet(bounds=t_range, initialize=control_time)\n", - "\n", - " # Time-dependent design variable; initialized with the first control value\n", - " def T_initial(m, t):\n", - " if t in m.t_con:\n", - " return controls[t]\n", - " else:\n", - " # Count how many control points are before the current t;\n", - " # Locate the nearest neighbouring control point before this t\n", - " neighbour_t = max(tc for tc in control_time if tc < t)\n", - " return controls[neighbour_t]\n", - "\n", - " mod.T = pyo.Var(\n", - " mod.t, initialize=T_initial, bounds=(300, 700), within=pyo.NonNegativeReals\n", - " )\n", - "\n", - " # Gas constant\n", - " mod.R = 8.31446261815324 # J / K / mole\n", - "\n", - " # Define variables as Var\n", - " mod.A1 = pyo.Var(initialize=theta[\"A1\"])\n", - " mod.A2 = pyo.Var(initialize=theta[\"A2\"])\n", - " mod.E1 = pyo.Var(initialize=theta[\"E1\"])\n", - " mod.E2 = pyo.Var(initialize=theta[\"E2\"])\n", - "\n", - " # Concentration variables under perturbation\n", - " mod.C_set = pyo.Set(initialize=[\"CA\", \"CB\", \"CC\"])\n", - " mod.C = pyo.Var(\n", - " mod.C_set, mod.t, initialize=C_init, within=pyo.NonNegativeReals\n", - " )\n", - "\n", - " # Time derivative of C\n", - " mod.dCdt = DerivativeVar(mod.C, wrt=mod.t)\n", - "\n", - " # Kinetic parameters\n", - " def kp1_init(m, t):\n", - " return m.A1 * pyo.exp(-m.E1 * 1000 / (m.R * m.T[t]))\n", - "\n", - " def kp2_init(m, t):\n", - " return m.A2 * pyo.exp(-m.E2 * 1000 / (m.R * m.T[t]))\n", - "\n", - " mod.kp1 = pyo.Var(mod.t, initialize=kp1_init)\n", - " mod.kp2 = pyo.Var(mod.t, initialize=kp2_init)\n", - "\n", - " def T_control(m, t):\n", - " \"\"\"\n", - " Time is discretized for numeric integration. A subset of these time points are control time points.\n", - " Temperature is constant within each control time point.\n", - "\n", - " TODO: replace this function with reduce_collocation_points\n", - " https://pyomo.readthedocs.io/en/stable/modeling_extensions/dae.html\n", - "\n", - " \"\"\"\n", - " if t in m.t_con:\n", - " return pyo.Constraint.Skip\n", - " else:\n", - " neighbour_t = max(tc for tc in control_time if tc < t)\n", - " return m.T[t] == m.T[neighbour_t]\n", - "\n", - " def cal_kp1(m, t):\n", - " \"\"\"\n", - " Create the perturbation parameter sets for A --> B reaction\n", - "\n", - " m: model\n", - " t: time\n", - " \"\"\"\n", - " # LHS: 1/h\n", - " # RHS: 1/h*(kJ/mol *1000J/kJ / (J/mol/K) / K)\n", - " return m.kp1[t] == m.A1 * pyo.exp(-m.E1 * 1000 / (m.R * m.T[t]))\n", - "\n", - " def cal_kp2(m, t):\n", - " \"\"\"\n", - " Create the perturbation parameter sets for B --> C reaction\n", - "\n", - " m: model\n", - " t: time\n", - " \"\"\"\n", - " # LHS: 1/h\n", - " # RHS: 1/h*(kJ/mol *1000J/kJ / (J/mol/K) / K)\n", - " return m.kp2[t] == m.A2 * pyo.exp(-m.E2 * 1000 / (m.R * m.T[t]))\n", - "\n", - " def dCdt_control(m, y, t):\n", - " \"\"\"\n", - " Calculate CA in Jacobian matrix analytically\n", - "\n", - " y: CA, CB, CC\n", - " t: timepoints\n", - " \"\"\"\n", - " if y == \"CA\":\n", - " return m.dCdt[y, t] == -m.kp1[t] * m.C[\"CA\", t]\n", - " elif y == \"CB\":\n", - " return m.dCdt[y, t] == m.kp1[t] * m.C[\"CA\", t] - m.kp2[t] * m.C[\"CB\", t]\n", - " elif y == \"CC\":\n", - " return pyo.Constraint.Skip\n", - "\n", - " def alge(m, t):\n", - " \"\"\"\n", - " The algebraic equation for mole balance\n", - "\n", - " z: m.pert\n", - " t: time\n", - " \"\"\"\n", - " return m.C[\"CA\", t] + m.C[\"CB\", t] + m.C[\"CC\", t] == m.CA0[0]\n", - "\n", - " # Control time\n", - " mod.T_rule = pyo.Constraint(mod.t, rule=T_control)\n", - "\n", - " # Calculating C, Jacobian, FIM\n", - " mod.k1_pert_rule = pyo.Constraint(mod.t, rule=cal_kp1)\n", - " mod.k2_pert_rule = pyo.Constraint(mod.t, rule=cal_kp2)\n", - " mod.dCdt_rule = pyo.Constraint(mod.C_set, mod.t, rule=dCdt_control)\n", - "\n", - " mod.alge_rule = pyo.Constraint(mod.t, rule=alge)\n", - "\n", - " # Boundary conditions\n", - " mod.C[\"CB\", 0.0].fix(0.0)\n", - " mod.C[\"CC\", 0.0].fix(0.0)\n", - "\n", - " if return_m:\n", - " return mod" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# The above models are alternately available in the examples folder:\n", - "# from pyomo.contrib.doe.examples.reactor_kinetics import create_model, disc_for_measure" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 3: Define Inputs for the Model" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": { - "scrolled": true, - "tags": [] - }, - "outputs": [], - "source": [ - "# Control time set [h]\n", - "t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]\n", - "# Define parameter nominal value\n", - "parameter_dict = {\"A1\": 84.79, \"A2\": 371.72, \"E1\": 7.78, \"E2\": 15.05}" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "scrolled": true, - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "measurement names: ['C[CA,0]', 'C[CA,0.125]', 'C[CA,0.25]', 'C[CA,0.375]', 'C[CA,0.5]', 'C[CA,0.625]', 'C[CA,0.75]', 'C[CA,0.875]', 'C[CA,1]', 'C[CB,0]', 'C[CB,0.125]', 'C[CB,0.25]', 'C[CB,0.375]', 'C[CB,0.5]', 'C[CB,0.625]', 'C[CB,0.75]', 'C[CB,0.875]', 'C[CB,1]', 'C[CC,0]', 'C[CC,0.125]', 'C[CC,0.25]', 'C[CC,0.375]', 'C[CC,0.5]', 'C[CC,0.625]', 'C[CC,0.75]', 'C[CC,0.875]', 'C[CC,1]']\n" - ] - } - ], - "source": [ - "# Pyomo.DoE defines measurements\n", - "# Measurements have at most 1 index besides the time index\n", - "variable_name = \"C\"\n", - "indices = {0: [\"CA\", \"CB\", \"CC\"], 1: t_control}\n", - "\n", - "# Measurement class\n", - "measure_class = MeasurementVariables()\n", - "measure_class.add_variables(variable_name, indices=indices, time_index_position=1)\n", - "print(\"measurement names:\", measure_class.variable_names)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": { - "scrolled": true, - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Design variable names: ['CA0[0]', 'T[0]', 'T[0.125]', 'T[0.25]', 'T[0.375]', 'T[0.5]', 'T[0.625]', 'T[0.75]', 'T[0.875]', 'T[1]']\n" - ] - } - ], - "source": [ - "# Design variables\n", - "design_gen = DesignVariables()\n", - "\n", - "var_C = \"CA0\"\n", - "indices_C = {0: [0]}\n", - "exp1_C = [5]\n", - "\n", - "# Add design variable\n", - "design_gen.add_variables(\n", - " var_C,\n", - " indices=indices_C,\n", - " time_index_position=0,\n", - " values=exp1_C,\n", - " lower_bounds=1,\n", - " upper_bounds=5,\n", - ")\n", - "\n", - "\n", - "var_T = \"T\"\n", - "indices_T = {0: t_control}\n", - "exp1_T = [470, 300, 300, 300, 300, 300, 300, 300, 300]\n", - "\n", - "design_gen.add_variables(\n", - " var_T,\n", - " indices=indices_T,\n", - " time_index_position=0,\n", - " values=exp1_T,\n", - " lower_bounds=300,\n", - " upper_bounds=700,\n", - ")\n", - "print(\"Design variable names:\", design_gen.variable_names)" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# Parameter dictionary\n", - "param_dict = {\"A1\": 84.79, \"A2\": 371.72, \"E1\": 7.78, \"E2\": 15.05}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 4: Method for Computing FIM \n", - "\n", - "This method computes an FIM-based MBDoE optimization problem with zero degrees of freedom." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Fisher Information Matrix (FIM)\n", - "The FIM measures the information content for the unknown parameters $\\theta$ from the model output $y_i$ given that:\n", - "\n", - "$$ y_i = f(\\psi_i, \\theta) $$\n", - "\n", - "where $\\psi$ is a design vector from a DAE system.\n", - "\n", - "In order to quantify the uncertainty of the estimated parameters for parameter estimation, consider the covariance matrix for the parameters:\n", - "\n", - "$$V(\\hat{\\theta},\\psi) = \\left[\\sum_{r}^{N_{r}}\\sum_{r'}^{N_{r}} \\tilde{\\sigma}_{(r,r')}Q_{r}^{T}Q_{r'}+V_{\\theta}(\\hat{\\theta})^{-1}\\right]^{-1}$$\n", - "\n", - "where:\n", - "* $\\hat{\\theta}$: estimated parameters\n", - "* $\\tilde{\\sigma}$: element in the inverse of the observational covariance matrix\n", - "* $r,$ $r'$: measurements\n", - "* $Q$: dynamic sensitivity\n", - "* $V_{\\theta}$: prior information\n", - "* $N_r$: number of measurements\n", - "\n", - "The inverse of $V$ estimates the FIM such that:\n", - "\n", - "$$V(\\hat{\\theta},\\psi) \\approx [M(\\hat{\\theta},\\psi)]^{-1}$$\n", - "\n", - "For sequential design of experiments, consider prior information such that after $N_e$ dynamic experiments, the FIM is calculated by:\n", - "$$M= \\sum_{k=1}^{N_e-1}M_k+M_{N_e}(\\hat{\\theta},\\psi_{N_e}) = K+M_{N_e}(\\hat{\\theta},\\psi_{N_e})$$\n", - "\n", - "where:\n", - "* $N_e - 1$: previous experiments\n", - "* $K$: constant matrix encoding information from all $N_e - 1$\n", - "\n", - "**Key Takeaway**:\n", - "A **large** FIM value denotes **more** information about $\\theta$ is gained from the model" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Model-Based Design of Experiments\n", - "The objective of MBDoE changes the conditions of one or more experiments based on a specific purpose such as:\n", - "\n", - "1. Model identification\n", - " * Discriminates between possible models while omitting inadequate models\n", - "2. Parameter estimation\n", - " * Improves parameter estimation precision\n", - "\n", - "**Key Takeaways:**\n", - "Given an estimate for an unknown parameter and one or more mathematical models, MBDoE:\n", - "1. Determines a set of experimental conditions to maximize the precision of the unknown model parameters\n", - "2. Discriminates between the given models\n", - "3. Or both (1) and (2)\n", - "\n", - "Currently, Pyomo.DoE supports MBDoE for parameter precision." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": { - "scrolled": true, - "tags": [] - }, - "outputs": [], - "source": [ - "# Choose from 'sequential_finite', 'direct_kaug'\n", - "# sensi_opt = \"direct_kaug\"\n", - "sensi_opt = \"sequential_finite\"\n", - "\n", - "# Define experiments\n", - "design_names = design_gen.variable_names\n", - "exp1 = [5, 470, 300, 300, 300, 300, 300, 300, 300, 300]\n", - "exp1_design_dict = dict(zip(design_names, exp1))\n", - "\n", - "# Update values\n", - "design_gen.update_values(exp1_design_dict)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": { - "scrolled": true, - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Ipopt 3.13.2: linear_solver=ma57\n", - "halt_on_ampl_error=yes\n", - "max_iter=3000\n", - "\n", - "\n", - "******************************************************************************\n", - "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", - " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", - " For more information visit http://projects.coin-or.org/Ipopt\n", - "\n", - "This version of Ipopt was compiled from source code available at\n", - " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", - " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", - " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", - "\n", - "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", - " for large-scale scientific computation. All technical papers, sales and\n", - " publicity material resulting from use of the HSL codes within IPOPT must\n", - " contain the following acknowledgement:\n", - " HSL, a collection of Fortran codes for large-scale scientific\n", - " computation. See http://www.hsl.rl.ac.uk.\n", - "******************************************************************************\n", - "\n", - "This is Ipopt version 3.13.2, running with linear solver ma57.\n", - "\n", - "Number of nonzeros in equality constraint Jacobian...: 25344\n", - "Number of nonzeros in inequality constraint Jacobian.: 0\n", - "Number of nonzeros in Lagrangian Hessian.............: 2320\n", - "\n", - "Total number of variables............................: 6968\n", - " variables with only lower bounds: 2312\n", - " variables with lower and upper bounds: 784\n", - " variables with only upper bounds: 0\n", - "Total number of equality constraints.................: 6968\n", - "Total number of inequality constraints...............: 0\n", - " inequality constraints with only lower bounds: 0\n", - " inequality constraints with lower and upper bounds: 0\n", - " inequality constraints with only upper bounds: 0\n", - "\n", - "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", - " 0 0.0000000e+00 1.67e+02 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", - "Reallocating memory for MA57: lfact (239448)\n", - " 1 0.0000000e+00 3.23e+01 3.85e+02 -1.0 1.67e+02 - 2.54e-03 9.90e-01f 1\n", - " 2 0.0000000e+00 6.16e+00 8.61e+01 -1.0 3.87e+01 - 1.11e-01 9.90e-01h 1\n", - " 3 0.0000000e+00 5.12e-02 9.97e+00 -1.0 4.16e+00 - 9.60e-01 9.90e-01h 1\n", - " 4 0.0000000e+00 1.20e-06 7.52e+01 -1.0 3.62e-02 - 9.97e-01 1.00e+00h 1\n", - "Reallocating memory for MA57: lfact (287125)\n", - " 5 0.0000000e+00 2.25e-13 1.00e-06 -1.0 7.68e-07 - 1.00e+00 1.00e+00h 1\n", - "\n", - "Number of Iterations....: 5\n", - "\n", - " (scaled) (unscaled)\n", - "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", - "Dual infeasibility......: 0.0000000000000000e+00 0.0000000000000000e+00\n", - "Constraint violation....: 9.3507738837536098e-14 2.2537527399890678e-13\n", - "Complementarity.........: 0.0000000000000000e+00 0.0000000000000000e+00\n", - "Overall NLP error.......: 9.3507738837536098e-14 2.2537527399890678e-13\n", - "\n", - "\n", - "Number of objective function evaluations = 6\n", - "Number of objective gradient evaluations = 6\n", - "Number of equality constraint evaluations = 6\n", - "Number of inequality constraint evaluations = 0\n", - "Number of equality constraint Jacobian evaluations = 6\n", - "Number of inequality constraint Jacobian evaluations = 0\n", - "Number of Lagrangian Hessian evaluations = 5\n", - "Total CPU secs in IPOPT (w/o function evaluations) = 0.468\n", - "Total CPU secs in NLP function evaluations = 0.017\n", - "\n", - "EXIT: Optimal Solution Found.\n", - "INFO: elapsed time: 2.9\n" - ] - } - ], - "source": [ - "# Create doe_object using DesignOfExperiments\n", - "doe_object = DesignOfExperiments(\n", - " parameter_dict, # dictionary of parameters\n", - " design_gen, # design variable\n", - " measure_class, # measurement variable\n", - " create_model, # model\n", - " discretize_model=disc_for_measure, # discretized model\n", - ")\n", - "\n", - "# Computing the FIM\n", - "result = doe_object.compute_FIM(\n", - " mode=sensi_opt, # solver option for sensitivity optimization\n", - " FIM_store_name=\"dynamic.csv\", # csv file that stores FIM data\n", - " read_output=None, # outputs from stored file; do not have to rerun since there are measurement values already\n", - " scale_nominal_param_value=True, # scale the Jacobian with the parameter values\n", - " formula=\"central\", # finite difference - central method\n", - ")\n", - "\n", - "# Results\n", - "result.result_analysis()" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "scrolled": true, - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "======Results Summary======\n", - "Four design criteria log10() value:\n", - "A-optimality: 2.989724462425373\n", - "D-optimality: 3.3010989022733894\n", - "E-optimality: -0.9193349136200465\n", - "Modified E-optimality: 3.87680755495709\n", - "[[ 17.22096879 13.67125453 -37.1471375 -68.68858407]\n", - " [ 13.67125453 34.5737961 -26.37449298 -170.10871631]\n", - " [ -37.1471375 -26.37449298 81.32448107 133.30724227]\n", - " [ -68.68858407 -170.10871631 133.30724227 843.49816474]]\n" - ] - } - ], - "source": [ - "# Results summary\n", - "print(\"======Results Summary======\")\n", - "print(\"Four design criteria log10() value:\")\n", - "print(\"A-optimality:\", np.log10(result.trace))\n", - "print(\"D-optimality:\", np.log10(result.det))\n", - "print(\"E-optimality:\", np.log10(result.min_eig))\n", - "print(\"Modified E-optimality:\", np.log10(result.cond))\n", - "print(result.FIM)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Optimality Conditions\n", - "**D-Optimality:** Maximizes the determinant of $M$ or minimizes the determinant of $V$ \t\n", - "* Computation: Determinant\n", - "* Geometric interpretation: Minimizes the volume of the confidence ellipsoid \n", - "\n", - "**A-Optimality:** Maximizes the trace of $M$ or minimizes the trace of $V$ \t\n", - "* Computation: Trace \t\n", - "* Geometric interpretation: Minimizes the dimensions of the enclosing box around the confidence ellipsoid \t\n", - "\n", - "**E-Optimality:** Minimizes the variance of the most uncertain parameter \t \n", - "* Computation: Eigenvalue \t\n", - "* Geometric interpretiation: Minimizes the size of the major axis of the confidence ellipsoid \n", - "\n", - "**Modified E-Optimality:** Reduces the correlations between parameters \t \n", - "* Computation: Condition number \t \n", - "* Geometric interpretation: Transforms the confidence ellipsoid into a round sphere " - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "['C[CB,0.125]', 'C[CB,0.25]', 'C[CB,0.5]', 'C[CB,0.75]', 'C[CB,0.875]', 'C[CC,0.125]', 'C[CC,0.25]', 'C[CC,0.5]', 'C[CC,0.75]', 'C[CC,0.875]']\n" - ] - } - ], - "source": [ - "# Choose a subset of measurements and get the results without resolving the model\n", - "sub_name = \"C\"\n", - "sub_indices = {0: [\"CB\", \"CC\"], 1: [0.125, 0.25, 0.5, 0.75, 0.875]}\n", - "\n", - "# Measurement subset\n", - "measure_subset = MeasurementVariables()\n", - "measure_subset.add_variables(sub_name, indices=sub_indices, time_index_position=1)\n", - "print(measure_subset.variable_names)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "======Subset Results Summary======\n", - "Four design criteria log10() value:\n", - "A-optimality: 2.7312606650205398\n", - "D-optimality: 1.8213450338458799\n", - "E-optimality: -1.430816119614162\n", - "Modified E-optimality: 4.147090377578492\n" - ] - } - ], - "source": [ - "# Subset results summary\n", - "sub_result = result.subset(measure_subset)\n", - "sub_result.result_analysis()\n", - "print(\"======Subset Results Summary======\")\n", - "print(\"Four design criteria log10() value:\")\n", - "print(\"A-optimality:\", np.log10(sub_result.trace))\n", - "print(\"D-optimality:\", np.log10(sub_result.det))\n", - "print(\"E-optimality:\", np.log10(sub_result.min_eig))\n", - "print(\"Modified E-optimality:\", np.log10(sub_result.cond))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 5: Method for Optimization\n", - "Gradient-based optimization with Ipopt using stochastic_program().\n", - "\n", - "We first fix the experiment design decisions and solve the simulation problem (zero degrees of freedom). This facilitates initialization.\n", - "\n", - "Next, we unfix the experiment design variables and resolve the optimization problem (positive number of degrees of freedom).\n", - "\n", - "This allows us to compute the best time-varying piecewise-constant temperature profile for the batch reactor experiment." - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [], - "source": [ - "# Experiment\n", - "exp1 = [5, 500, 300, 300, 300, 300, 300, 300, 300, 300]\n", - "exp1_design_dict = dict(zip(design_names, exp1))\n", - "design_gen.update_values(exp1_design_dict)\n", - "\n", - "# Add prior information (scaled FIM with T=500 and T=300 experiments)\n", - "prior = np.asarray(\n", - " [\n", - " [28.67892806, 5.41249739, -81.73674601, -24.02377324],\n", - " [5.41249739, 26.40935036, -12.41816477, -139.23992532],\n", - " [-81.73674601, -12.41816477, 240.46276004, 58.76422806],\n", - " [-24.02377324, -139.23992532, 58.76422806, 767.25584508],\n", - " ]\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Ipopt 3.13.2: linear_solver=ma57\n", - "halt_on_ampl_error=yes\n", - "max_iter=3000\n", - "\n", - "\n", - "******************************************************************************\n", - "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", - " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", - " For more information visit http://projects.coin-or.org/Ipopt\n", - "\n", - "This version of Ipopt was compiled from source code available at\n", - " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", - " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", - " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", - "\n", - "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", - " for large-scale scientific computation. All technical papers, sales and\n", - " publicity material resulting from use of the HSL codes within IPOPT must\n", - " contain the following acknowledgement:\n", - " HSL, a collection of Fortran codes for large-scale scientific\n", - " computation. See http://www.hsl.rl.ac.uk.\n", - "******************************************************************************\n", - "\n", - "This is Ipopt version 3.13.2, running with linear solver ma57.\n", - "\n", - "Number of nonzeros in equality constraint Jacobian...: 26424\n", - "Number of nonzeros in inequality constraint Jacobian.: 0\n", - "Number of nonzeros in Lagrangian Hessian.............: 2590\n", - "\n", - "Total number of variables............................: 7092\n", - " variables with only lower bounds: 2312\n", - " variables with lower and upper bounds: 784\n", - " variables with only upper bounds: 0\n", - "Total number of equality constraints.................: 7092\n", - "Total number of inequality constraints...............: 0\n", - " inequality constraints with only lower bounds: 0\n", - " inequality constraints with lower and upper bounds: 0\n", - " inequality constraints with only upper bounds: 0\n", - "\n", - "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", - " 0 0.0000000e+00 7.67e+02 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", - "Reallocating memory for MA57: lfact (296034)\n", - " 1 0.0000000e+00 6.31e+02 3.85e+02 -1.0 7.66e+02 - 2.54e-03 9.90e-01f 1\n", - " 2 0.0000000e+00 1.42e+02 1.31e+02 -1.0 9.10e+02 - 9.17e-02 9.90e-01h 1\n", - " 3 0.0000000e+00 2.07e+01 1.75e+01 -1.0 1.43e+02 - 9.37e-01 9.90e-01h 1\n", - " 4 0.0000000e+00 1.52e-03 1.41e+02 -1.0 1.86e+01 - 9.94e-01 1.00e+00h 1\n", - "Reallocating memory for MA57: lfact (393945)\n", - " 5 0.0000000e+00 4.55e-13 1.00e-06 -1.0 1.55e-03 - 1.00e+00 1.00e+00h 1\n", - "\n", - "Number of Iterations....: 5\n", - "\n", - " (scaled) (unscaled)\n", - "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", - "Dual infeasibility......: 0.0000000000000000e+00 0.0000000000000000e+00\n", - "Constraint violation....: 2.2737367544323206e-13 4.5474735088646412e-13\n", - "Complementarity.........: 0.0000000000000000e+00 0.0000000000000000e+00\n", - "Overall NLP error.......: 2.2737367544323206e-13 4.5474735088646412e-13\n", - "\n", - "\n", - "Number of objective function evaluations = 6\n", - "Number of objective gradient evaluations = 6\n", - "Number of equality constraint evaluations = 6\n", - "Number of inequality constraint evaluations = 0\n", - "Number of equality constraint Jacobian evaluations = 6\n", - "Number of inequality constraint Jacobian evaluations = 0\n", - "Number of Lagrangian Hessian evaluations = 5\n", - "Total CPU secs in IPOPT (w/o function evaluations) = 0.584\n", - "Total CPU secs in NLP function evaluations = 0.017\n", - "\n", - "EXIT: Optimal Solution Found.\n", - "Ipopt 3.13.2: linear_solver=ma57\n", - "halt_on_ampl_error=yes\n", - "max_iter=3000\n", - "\n", - "\n", - "******************************************************************************\n", - "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", - " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", - " For more information visit http://projects.coin-or.org/Ipopt\n", - "\n", - "This version of Ipopt was compiled from source code available at\n", - " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", - " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", - " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", - "\n", - "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", - " for large-scale scientific computation. All technical papers, sales and\n", - " publicity material resulting from use of the HSL codes within IPOPT must\n", - " contain the following acknowledgement:\n", - " HSL, a collection of Fortran codes for large-scale scientific\n", - " computation. See http://www.hsl.rl.ac.uk.\n", - "******************************************************************************\n", - "\n", - "This is Ipopt version 3.13.2, running with linear solver ma57.\n", - "\n", - "Number of nonzeros in equality constraint Jacobian...: 26544\n", - "Number of nonzeros in inequality constraint Jacobian.: 0\n", - "Number of nonzeros in Lagrangian Hessian.............: 2610\n", - "\n", - "Reallocating memory for MA57: lfact (273960)\n", - "Total number of variables............................: 7112\n", - " variables with only lower bounds: 2316\n", - " variables with lower and upper bounds: 794\n", - " variables with only upper bounds: 0\n", - "Total number of equality constraints.................: 7102\n", - "Total number of inequality constraints...............: 0\n", - " inequality constraints with only lower bounds: 0\n", - " inequality constraints with lower and upper bounds: 0\n", - " inequality constraints with only upper bounds: 0\n", - "\n", - "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", - " 0 -1.1850752e+01 7.70e+02 1.75e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", - "Reallocating memory for MA57: lfact (303416)\n", - " 1 -1.3675099e+01 2.82e+02 1.02e+00 -1.0 1.74e+01 - 5.78e-01 5.20e-01h 1\n", - " 2 -1.4089220e+01 2.17e+01 1.63e+00 -1.0 1.13e+01 - 9.61e-01 1.00e+00f 1\n", - " 3 -1.3921907e+01 1.69e+00 3.69e+00 -1.0 6.63e+01 - 9.32e-01 1.00e+00f 1\n", - " 4 -1.3336947e+01 2.31e+01 1.65e+01 -1.0 1.77e+02 - 1.00e+00 1.00e+00f 1\n", - " 5 -1.3222146e+01 1.86e+01 1.00e+01 -1.0 1.99e+02 - 1.00e+00 1.00e+00h 1\n", - " 6 -1.3243949e+01 2.13e-01 6.39e-01 -1.0 1.01e+01 - 1.00e+00 1.00e+00h 1\n", - " 7 -1.3252911e+01 1.32e-03 1.78e-02 -1.7 5.37e-01 - 1.00e+00 1.00e+00h 1\n", - " 8 -1.3275341e+01 5.50e-02 1.07e+00 -3.8 4.82e+00 - 9.24e-01 1.00e+00h 1\n", - "Reallocating memory for MA57: lfact (319294)\n", - " 9 -1.3682468e+01 1.73e+01 2.63e+01 -3.8 8.88e+01 - 5.41e-01 1.00e+00h 1\n", - "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", - " 10 -1.4419541e+01 8.07e+01 9.14e+01 -3.8 5.70e+02 - 2.57e-01 5.52e-01h 1\n", - " 11 -1.4227603e+01 2.35e+01 2.67e+00 -3.8 7.85e+01 - 7.14e-01 1.00e+00h 1\n", - " 12 -1.4224985e+01 3.88e-01 5.16e-01 -3.8 2.90e+01 - 1.00e+00 1.00e+00h 1\n", - " 13 -1.4226481e+01 2.67e-02 5.57e-03 -3.8 6.57e+00 - 1.00e+00 1.00e+00h 1\n", - " 14 -1.4226282e+01 1.91e-05 5.93e-06 -3.8 1.11e-01 - 1.00e+00 1.00e+00h 1\n", - " 15 -1.4292847e+01 1.06e+00 7.22e-01 -5.7 4.62e+01 - 7.74e-01 1.00e+00f 1\n", - "Reallocating memory for MA57: lfact (339585)\n", - " 16 -1.4306021e+01 5.87e-02 4.67e-02 -5.7 2.09e+01 - 1.00e+00 1.00e+00h 1\n", - " 17 -1.4307820e+01 1.18e-02 2.10e-03 -5.7 9.64e+00 - 1.00e+00 1.00e+00h 1\n", - " 18 -1.4307833e+01 3.56e-04 4.99e-06 -5.7 1.70e+00 - 1.00e+00 1.00e+00h 1\n", - " 19 -1.4307833e+01 2.54e-07 3.05e-09 -5.7 4.55e-02 - 1.00e+00 1.00e+00h 1\n", - "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", - " 20 -1.4309105e+01 8.75e-04 2.56e-04 -8.6 2.68e+00 - 9.92e-01 1.00e+00h 1\n", - "Reallocating memory for MA57: lfact (358657)\n", - "Reallocating memory for MA57: lfact (392921)\n", - " 21 -1.4309111e+01 2.81e-06 7.65e-08 -8.6 1.52e-01 - 1.00e+00 1.00e+00h 1\n", - " 22 -1.4309111e+01 8.54e-12 2.66e-08 -8.6 2.66e-04 -4.0 1.00e+00 1.00e+00h 1\n", - "Reallocating memory for MA57: lfact (450838)\n", - "Reallocating memory for MA57: lfact (477335)\n", - " 23 -1.4309111e+01 3.31e-12 5.52e-09 -8.6 1.66e-04 -4.5 1.00e+00 1.00e+00h 1\n", - "\n", - "Number of Iterations....: 23\n", - "\n", - " (scaled) (unscaled)\n", - "Objective...............: -1.4309111333867460e+01 -1.4309111333867460e+01\n", - "Dual infeasibility......: 5.5189781291491592e-09 5.5189781291491592e-09\n", - "Constraint violation....: 3.3140157285060923e-12 3.3140157285060923e-12\n", - "Complementarity.........: 2.5059035851932608e-09 2.5059035851932608e-09\n", - "Overall NLP error.......: 5.5189781291491592e-09 5.5189781291491592e-09\n", - "\n", - "\n", - "Number of objective function evaluations = 24\n", - "Number of objective gradient evaluations = 24\n", - "Number of equality constraint evaluations = 24\n", - "Number of inequality constraint evaluations = 0\n", - "Number of equality constraint Jacobian evaluations = 24\n", - "Number of inequality constraint Jacobian evaluations = 0\n", - "Number of Lagrangian Hessian evaluations = 23\n", - "Total CPU secs in IPOPT (w/o function evaluations) = 2.472\n", - "Total CPU secs in NLP function evaluations = 0.059\n", - "\n", - "EXIT: Optimal Solution Found.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO: elapsed time: 5.6\n" - ] - } - ], - "source": [ - "# Create doe_object using DesignOfExperiments\n", - "doe_object = DesignOfExperiments(\n", - " parameter_dict, # dictionary of parameters\n", - " design_gen, # design variable\n", - " measure_class, # measurement variable\n", - " create_model, # model\n", - " prior_FIM=prior, # FIM of prior experiments\n", - " discretize_model=disc_for_measure, # discretized model\n", - ")\n", - "\n", - "# Stochastic programming for optimization; see above for how the function solves twice\n", - "square_result, optimize_result = doe_object.stochastic_program(\n", - " if_optimize=True, # optimize\n", - " if_Cholesky=True, # use Cholesky decomposition\n", - " scale_nominal_param_value=True, # scale model parameter value\n", - " objective_option=\"det\", # objective option\n", - " L_initial=np.linalg.cholesky(prior), # initial Cholesky decomposition\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "======Results Summary======\n", - "This optimization is solved with status: converged\n", - "C solution: 5.0\n", - "T solution:\n", - "579.3896781590472\n", - "300.00008825998646\n", - "300.0001449066281\n", - "300.0002011134464\n", - "300.00026910716224\n", - "300.00037503303196\n", - "300.00058304040493\n", - "300.00119444148544\n", - "300.00410830698996\n", - "The result FIM is: \n", - " [[ 46.26165475 24.02303687 -111.13766257 -98.84248628]\n", - " [ 24.02303687 56.00005105 -41.78107762 -257.31551935]\n", - " [-111.13766257 -41.78107762 290.39184707 177.30569633]\n", - " [ -98.84248628 -257.31551935 177.30569633 1245.5926873 ]]\n", - "Four design criteria log10() value:\n", - "A-optimality: 3.2143791799119263\n", - "D-optimality: 6.214368093237916\n", - "E-optimality: 0.007877626397731468\n", - "Modified E-optimality: 3.1198074131681715\n" - ] - } - ], - "source": [ - "# Results summary\n", - "print(\"======Results Summary======\")\n", - "print(\"This optimization is solved with status:\", optimize_result.status)\n", - "print(\"C solution:\", pyo.value(optimize_result.model.CA0[0]))\n", - "print(\"T solution:\")\n", - "for t in t_control:\n", - " print(pyo.value(optimize_result.model.T[t]))\n", - "\n", - "print(\"The result FIM is: \\n\", optimize_result.FIM)\n", - "print(\"Four design criteria log10() value:\")\n", - "print(\"A-optimality:\", np.log10(optimize_result.trace))\n", - "print(\"D-optimality:\", np.log10(optimize_result.det))\n", - "print(\"E-optimality:\", np.log10(optimize_result.min_eig))\n", - "print(\"Modified E-optimality:\", np.log10(optimize_result.cond))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 6: Method for Exploratory Analysis through Enumeration\n", - "\n", - "This method conducts exploratory analysis using enumeration. \n", - "It allows a user to define any number (dimensions) of design variables." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Specify user inputs" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [], - "source": [ - "# Design variable ranges as lists\n", - "design_ranges = {\n", - " \"CA0[0]\": [1, 3, 5],\n", - " (\n", - " \"T[0]\",\n", - " \"T[0.125]\",\n", - " \"T[0.25]\",\n", - " \"T[0.375]\",\n", - " \"T[0.5]\",\n", - " \"T[0.625]\",\n", - " \"T[0.75]\",\n", - " \"T[0.875]\",\n", - " \"T[1]\",\n", - " ): [300, 500, 700],\n", - "}\n", - "\n", - "# Choose from 'sequential_finite', 'direct_kaug'\n", - "# sensi_opt = \"sequential_finite\"\n", - "sensi_opt = \"direct_kaug\"" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The prior information FIM:\n", - " [[22.52943024, 1.84034314, -70.23273336, -11.09432962], [1.84034314, 18.09848116, -5.73565034, -109.15866135], [-70.23273336, -5.73565034, 218.94192843, 34.57680848], [-11.09432962, -109.15866135, 34.57680848, 658.37644634]]\n", - "Prior Det: 1.9558434494323278e-08\n" - ] - } - ], - "source": [ - "# Add prior information\n", - "prior_pass = [\n", - " [22.52943024, 1.84034314, -70.23273336, -11.09432962],\n", - " [1.84034314, 18.09848116, -5.73565034, -109.15866135],\n", - " [-70.23273336, -5.73565034, 218.94192843, 34.57680848],\n", - " [-11.09432962, -109.15866135, 34.57680848, 658.37644634],\n", - "]\n", - "\n", - "# Print prior information\n", - "print(\"The prior information FIM:\\n\", prior_pass)\n", - "print(\"Prior Det:\", np.linalg.det(prior_pass))" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO: =======Iteration Number: 1 =====\n", - "INFO: elapsed time: 1.5\n", - "INFO: This is run 1 out of 9.\n", - "INFO: The code has run 1.4800095079999664 seconds.\n", - "INFO: Estimated remaining time: 5.180033277999883 seconds\n", - "INFO: =======Iteration Number: 2 =====\n", - "INFO: elapsed time: 0.8\n", - "INFO: This is run 2 out of 9.\n", - "INFO: The code has run 2.2786834119997366 seconds.\n", - "INFO: Estimated remaining time: 4.557366823999473 seconds\n", - "INFO: =======Iteration Number: 3 =====\n", - "INFO: elapsed time: 0.8\n", - "INFO: This is run 3 out of 9.\n", - "INFO: The code has run 3.0774706169995625 seconds.\n", - "INFO: Estimated remaining time: 3.846838271249453 seconds\n", - "INFO: =======Iteration Number: 4 =====\n", - "INFO: elapsed time: 0.6\n", - "INFO: This is run 4 out of 9.\n", - "INFO: The code has run 3.6389742199989996 seconds.\n", - "INFO: Estimated remaining time: 2.9111793759991995 seconds\n", - "INFO: =======Iteration Number: 5 =====\n", - "INFO: elapsed time: 0.8\n", - "INFO: This is run 5 out of 9.\n", - "INFO: The code has run 4.463020823998704 seconds.\n", - "INFO: Estimated remaining time: 2.231510411999352 seconds\n", - "INFO: =======Iteration Number: 6 =====\n", - "INFO: elapsed time: 0.8\n", - "INFO: This is run 6 out of 9.\n", - "INFO: The code has run 5.244051230999503 seconds.\n", - "INFO: Estimated remaining time: 1.4983003517141438 seconds\n", - "INFO: =======Iteration Number: 7 =====\n", - "INFO: elapsed time: 0.6\n", - "INFO: This is run 7 out of 9.\n", - "INFO: The code has run 5.848174373999427 seconds.\n", - "INFO: Estimated remaining time: 0.7310217967499284 seconds\n", - "INFO: =======Iteration Number: 8 =====\n", - "INFO: elapsed time: 0.7\n", - "INFO: This is run 8 out of 9.\n", - "INFO: The code has run 6.5564294039986635 seconds.\n", - "INFO: Estimated remaining time: 0.0 seconds\n", - "INFO: =======Iteration Number: 9 =====\n", - "INFO: elapsed time: 0.9\n", - "INFO: This is run 9 out of 9.\n", - "INFO: The code has run 7.479818032998082 seconds.\n", - "INFO: Estimated remaining time: -0.7479818032998082 seconds\n", - "INFO: Overall wall clock time [s]: 7.479818032998082\n" - ] - } - ], - "source": [ - "# Create doe_object using DesignOfExperiments\n", - "doe_object = DesignOfExperiments(\n", - " parameter_dict, # dictionary of parameters\n", - " design_gen, # design variable\n", - " measure_class, # measurement variable\n", - " create_model, # model\n", - " prior_FIM=prior_pass, # FIM of prior experiments\n", - " discretize_model=disc_for_measure, # discretized model\n", - ")\n", - "# Grid search\n", - "all_fim = doe_object.run_grid_search(\n", - " design_ranges, # range of design variables\n", - " mode=sensi_opt, # solver option for sensitivity\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 7: Results through Sensitivity Curves and Heatmaps" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1D Sensitivity Curve\n", - "\n", - "1D sensitivity curves can be drawn by one design variable and fixing other design variables." - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " CA0[0] \\\n", - "0 1.0 \n", - "1 1.0 \n", - "2 1.0 \n", - "3 3.0 \n", - "4 3.0 \n", - "5 3.0 \n", - "6 5.0 \n", - "7 5.0 \n", - "8 5.0 \n", - "\n", - " (T[0], T[0.125], T[0.25], T[0.375], T[0.5], T[0.625], T[0.75], T[0.875], T[1]) \\\n", - "0 300.0 \n", - "1 500.0 \n", - "2 700.0 \n", - "3 300.0 \n", - "4 500.0 \n", - "5 700.0 \n", - "6 300.0 \n", - "7 500.0 \n", - "8 700.0 \n", - "\n", - " A D E ME \n", - "0 918.207526 5.129865 0.002829 2.402240e+05 \n", - "1 917.979819 0.052028 0.000288 2.358410e+06 \n", - "2 917.951300 0.000610 0.000020 3.457451e+07 \n", - "3 920.297448 415.446336 0.025426 2.676791e+04 \n", - "4 918.248082 4.208215 0.002590 2.624381e+05 \n", - "5 917.991412 0.048511 0.000174 3.907348e+06 \n", - "6 924.477291 3205.559576 0.070438 9.688727e+03 \n", - "7 918.784607 32.467061 0.007192 9.455956e+04 \n", - "8 918.071634 0.373747 0.000482 1.408681e+06 \n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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KbZ169Zh4sSJUCgU+O677/DRRx8p93311VcAgAcPHsDV1bVM9RNRxWLSRUTV2smTJwttCw4ORteuXYvdXxHWrFmDSZMmQQiB5cuXY8aMGVqJg4g0h0kXEZGOWbVqFaZMmQIA+OGHH/DBBx9oOSIi0gQmXUREOuSnn37C1KlTlX9///33tRwREWkKB9ITEemIlStXKlu1Vq9ezYSLqIph0kVEpAMCAwMxffp0yGQyrFu3DuPHj9d2SESkYexeJCLSstjYWMycOROSJGHjxo0YPXq0tkMionLAli4iIi0TQij/fPjwoZajIaLywqSLiEjL6tatq5x3a+7cufjhhx+0HBERlQcmXUREOmDu3LmYO3cuAGD69OkaXTKIiHQDky4iIh3x1VdfYfr06RBCYMKECdixY4e2QyIiDWLSRUSkQ5YvX45x48YhLy8PI0eOxIEDB7QdEhFpCJMuIiIdIkkS1qxZg2HDhiEnJweDBw/G8ePHtR0WEWkAky4iIh0jk8kQFBSEfv36ITMzEwMGDMDp06e1HRYRqYlJFxGRDjIwMMD27dvRrVs3pKWloU+fPoiIiNB2WESkBiZdREQ6ysjICH/88Qc6dOiA58+fo0ePHoiMjNR2WERURpyRnojoH7p06aKcsLQ8jR07FmPHji2xjKmpKU6dOlXusRBR+WPSRURUji5evAgfHx8AwPz589G7d2+N1Dtv3jyEhIQgKytLI/URUflj0kVEVI5SUlIQFhYGAHjy5InG6r1+/bqyXiKqHCRREW3oRERERNUcB9ITERERVQAmXUREREQVgEkXERERUQVg0kVERERUAZh0EREREVUAJl1EREREFYBJFxEREVEFYNJFREREVAGYdBERERFVACZdRERERBWASRcRERFRBfg/49AlAORjCQEAAAAASUVORK5CYII=\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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IlJNlXYEB4CoqCiMGDECV65ceb7TF/ouFO2+TZs22L59O9zd3TU9nF5iaKLa5kh4PD757SZSsvJhKJXgw15NMTnQHYYGGjdgExFVmSrtCA4ATZo0wT///IO///4bR48eRUREBDIzM2FpaQkPDw/06dMHPXr00MahiEjH0nIKEHwwHL9djQMANKtnhW+G+8GrgUzHlRERVS6ttDQRW5qodjgTmYTZe27gSVoupBLg3UB3fNirKUwMDXRdGhFRhVR6S1N4eDju3bsHBwcHtG/f/qXbX7hwAUlJSWjSpAk8PT0rckgi0qHsfDm++Os2tl18CABwszXH8mF+8Hetef0TiYjKonZoys7ORu/evZGcnIwTJ06o9BghBN544w00aNAAd+/ehYmJidqFEpFuXH7wFLN2hyImJRsAMKaDK+b0aw5zY63N901EVC2o3WNzx44dePLkCSZMmICOHTuq9JiOHTti4sSJiI2Nxc6dO9UukoiqXm5BIZb8dRtD111ATEo2GshM8cs77RD8ujcDExHVSmqHpv3790MikeD9999X63EffvghhBDYu3evuockoioWFpeGAavPYt3p+xACGOrfEIdndEWnJmUPYEtEVNOp/efitWvXUL9+fTRv3lytxzVt2hROTk64du2auockoipSUKjAmhP38N3xSMgVAnaWJlgy2AdBnvV0XRoRkc6pHZqSk5Ph5+dXoYM1aNAAN27cqNBjiahyRSZkYNbuUNx4lAYAeMXHEYsH+sDGwljHlRER6Qe1Q5OpqSlycnIqdLCcnBwYG/MDmEifFCoENp6NxldH7yJfroDMzAifve6FAX4NOMkuEdEL1A5N9evXx71795CXl6fWXXB5eXm4d+8eXFxc1D0kEVWShynZ+Gh3KEIePAUAdGtmj6VDfFHP2lTHlRER6R+1O4J36dIFubm52LNnj1qP2717N3JyctClSxd1D0lEWiaEwC+XYtB35WmEPHgKC2MDfDnYB5vGtmFgIiIqg9qhaezYsRBCYM6cOYiNjVXpMQ8fPsTs2bMhkUgwZswYtYskIu2JT8vFmE3/4NN9YcjOL0S7RjY4/GFXvNnWhZfjiIjKoXZo6tixI4YOHYrHjx+jXbt22L17NxQKRanbKhQK7Nq1C+3bt0dCQgKGDBmCTp06aVw0EalPCIH91+LQe8UpnI5IgomhFP951RM7JraH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MHDgwDL3Y2Fhgfr16yM6OhqFhYUl+jUV9WUq6tv0byYmJsXuuNOmxIzyA5O62xEREZHmql1omjBhQqnLT58+jcjISAwYMAD29vZwc3N76b4CAwOxc+dOnDt3Dl27di227siRI8ptqpqDVemBsKLbERERkeYkQojyus5UG2PHjsXmzZtx4cIFtG/fvti65ORkJCcnw87ODnZ2dsrlJ06cQI8ePdClSxf873//g7GxMQDg77//RlBQELp06aLy4Jbp6emQyWRIS0tTdiKvqEKFQOelxxGflltqvyYJAEeZKc7O6QEDqUSjYxEREdVm6nx/V7s+TRWxevVqtGjRAqtXry62vHv37njnnXdw5swZtGrVCrNnz8aYMWPQv39/WFtb44cfftBJvQZSCRa+5gngeUB6UdG/F77mycBERERUhWpFaCrPunXrsGrVKkgkEqxatQp//vknXnvtNYSEhMDT01NndfX1ro8fRraGo6z4JThHmSl+GNkafb3r66gyIiKi2qnGXJ7TNW1enntRaSOCs4WJiIhIO9T5/q52HcFrGwOpBB3cbXVdBhERUa1X6y/PEREREamCoYmIiIhIBQxNRERERCpgaCIiIiJSAUMTERERkQoYmoiIiIhUwNBEREREpAKGJiIiIiIVMDQRERERqYAjgmtJ0Ww06enpOq6EiIiIVFX0va3KrHIMTVqSkZEBAHB2dtZxJURERKSujIwMyGSycrfhhL1aolAo8PjxY1hZWUEi0e6Euunp6XB2dkZsbKxWJwOmqsNzWL3x/FV/PIfVX2WdQyEEMjIy0KBBA0il5fdaYkuTlkilUjRs2LBSj2Ftbc03ezXHc1i98fxVfzyH1V9lnMOXtTAVYUdwIiIiIhUwNBERERGpgKGpGjAxMcHChQthYmKi61KogngOqzeev+qP57D604dzyI7gRERERCpgSxMRERGRChiaiIiIiFTA0ERERESkAoYmIiIiIhUwNFWB1NRUvP/+++jQoQMcHR1hYmICJycn9OjRA3v37i11vpv09HTMnDkTrq6uMDExgaurK2bOnFnu3Hbbt29H27ZtYWFhgbp16+KVV17B5cuXK/Op1VrLli2DRCKBRCLBxYsXS92G51C/uLm5Kc/Zv38mT55cYnueP/21b98+BAUFwdbWFmZmZmjUqBFGjBiB2NjYYtvxHOqXn3/+ucz3YNFP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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Extract criteria from FIM\n", - "all_fim.extract_criteria()\n", - "print(all_fim.store_all_results_dataframe)\n", - "\n", - "# Draw 1D sensitivity curve\n", - "# This problem has two degrees of freedom; to draw a 1D curve, it needs to fix one dimension\n", - "fixed = {\"'CA0[0]'\": 5.0}\n", - "\n", - "all_fim.figure_drawing(\n", - " fixed,\n", - " [\n", - " (\n", - " \"T[0]\",\n", - " \"T[0.125]\",\n", - " \"T[0.25]\",\n", - " \"T[0.375]\",\n", - " \"T[0.5]\",\n", - " \"T[0.625]\",\n", - " \"T[0.75]\",\n", - " \"T[0.875]\",\n", - " \"T[1]\",\n", - " )\n", - " ],\n", - " \"Reactor case\",\n", - " \"T [K]\",\n", - " \"$C_{A0}$ [M]\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Heatmaps\n", - "\n", - "Heatmaps can be drawn using two design variables and fixing other design variables." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Interpreting Heatmaps\n", - "\n", - "A heatmap shows the change of the objective function (the experimental information content) in the design region. \n", - "\n", - "Horizontal and vertical axes are two design variables, while the color of each grid shows the experimental information content.\n", - "\n", - "The color of each grid is based on a gradient of information. A darker color refers to an area with more information content whereas the lighter color refers to an area with less information content." - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "data": { - "image/png": "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cvD333+jXbt26NevH0xNTRXK/vXXXwAePxDg6elZbX2yxeU3btxQ2L9kyRLMnz8flZWVNfblyWAiPT0d5eXlaN++fZPlekpPT0dlZSV0dHRgb29fbZkePXoAgPyHqqpnn322Qe3W91oEQcA777yDVatW1Vruyc+sf//+eP7553Hy5ElYWVnB19cXL774Iry9vdGnTx+lheay+7lz504cO3as2vozMzMBKN/P5nLjxg0cOnQIgHJg9vLLL8PU1BS5ubmIi4vDK6+8olKdsntpbW0NAwODass09L7L9td0XmM4ODgo7evYsaNKx4uLixX2N+T71FwMDQ0xZswYbNy4Edu3b8esWbMAAHfu3MHevXuhra2N8ePHN3s/iFoLjpi1MbI8ZomJibh69SqOHj0KIyMjfPjhh4iOjlYoW1BQAAD4559/kJiYWO0me8KypKREfl5CQgI++eQTSCQSLFmyBKmpqSguLkZlZSUEQUBwcDAAoLy8XH6O7GlAWQ6rpiD7serYsWONr8Lp1KkTAKCoqKja4zX9sNemIdeyZcsWrFq1CgYGBli1ahXS0tLw4MEDCI+XG8hHj578zDQ0NPD7779j9uzZ0NPTw6+//ooPPvgAffv2hZ2dncITgcC/9zM9Pb3G+3n9+nUAivezJjk5OfIRmCe32p7Aq2rr1q2orKxEnz59lIJYbW1tjBkzRv75qEp23y0sLGosU9d9r+ncus5rDH19faV9T35vazsuCILC/oZ8n5rTlClTAPw7QgY8fuq1vLwcw4YNg5mZWYv0g6g14IhZGzdgwACsW7cOr732GmbPno1hw4bB2NgYwON/6QKPp5SqBm21kaU4+Oijj/Dxxx8rHa8uRYVsaq269A4NJev/P//8A0EQqg3Obt++rdB+U2jItcg+s5UrV2LatGlKx2tK62FqaoqIiAh88cUXOHv2LBISEhATE4NDhw4hICAAhoaGGD16NIB/P49169bJp+Qao7S0FImJiUr7tbRU/2tFFnCdPn261vdI/vrrrygsLJR/N2sju87c3Nway9R13//55x906dJFab+szqb8vjSHhn6fmounpyecnJxw+vRpnD9/Hj179uQ0JlENOGJGGDFiBF544QXk5eUhPDxcvr979+4AoJS7qS6yXGgeHh7VHn9ybZmMo6MjtLW1kZ+fj8uXL6vUTl0vhO7atSs0NDRQ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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAlEAAAHcCAYAAAD2uv9FAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAABqgUlEQVR4nO3deXhMZ/sH8O/JNtkjESRks0RiKZESW0hSYmuLoiVFSCzlpagqUdqgJaqtUrqofVe0tGgtRSyhQi2vtWLJYokgeyKR5fz+8Jt5M7KYLTkZ8/1c11zvm7M8z31mUnPneZ5zH0EURRFEREREpBYjqQMgIiIi0kdMooiIiIg0wCSKiIiISANMooiIiIg0wCSKiIiISANMooiIiIg0wCSKiIiISANMooiIiIg0wCSKiIiISANMooiIKlF0dDQEQUBgYKDUoVQoMDAQgiAgOjpaafusWbMgCAJmzZolSVxE1RmTKAPh4eEBQRCUXubm5qhfvz6GDBmC06dPSx2i2tLT0zFr1iwsWrRI6lBIQ2X9Xpb1WrNmjdShlmvWrFkGmWDEx8dj1qxZ1fqzIapsJlIHQFXL09MTtWvXBgBkZGTgxo0b2LhxI7Zs2YLVq1dj6NChEkeouvT0dMyePRvu7u6YNGmS1OGQFkr+XpalTp06VRiNembPng0A5SZSlpaW8PLygpubWxVGpTuOjo7w8vKCo6Oj0vb4+HjMnj0bAQEBGD58uDTBEUmMSZSB+fjjj5X+wUtLS8Po0aOxfft2jBs3Dm+88Qbs7e2lC5AM0vO/ly8TPz8/XLt2TeowNDZ+/HiMHz9e6jCIqiVO5xk4e3t7rFy5ElZWVsjKysL+/fulDomIiEgvMIki2NraonHjxgCeDdGXZd++fejduzfq1KkDmUwGFxcXhIWF4ebNm2Ue//fff2Pq1Klo3bo1ateuDZlMBldXVwwdOhSXL1+uMJ5///0Xo0ePRqNGjWBhYYGaNWvi1VdfRWRkJO7fvw8AGD58OOrXrw8ASEhIKLWG5nl79uxBjx494OjoCJlMhvr16+M///kPkpKSyoxBvlYnPj4ehw8fRs+ePeHo6Fjmwlttr0XuwIEDGD9+PFq2bAkHBweYm5ujYcOGGDt2LBITE8tsv7CwEIsXL4afnx9sbGwgk8lQt25ddOjQAZGRkUhPTy/znB9//BH+/v6oUaMGzM3N4e3tjZkzZyIzM1Pla6vOcnJy8Pnnn6NFixawsrKCra0t2rZti++++w6FhYWlji+5+LugoACzZ89G48aNYW5ujnr16mHcuHFITU1VOke+4Fru+d9B+X9L5S0sj4+PhyAI8PDwAACsWLECrVq1gqWlJerVq4cJEyYgKysLAFBUVISvv/4azZo1g4WFBVxcXBAREYGnT5+WupYnT55g8+bNGDRoELy8vGBtbQ1ra2v4+Pjg888/R05OjlrvZVkLywMDAxEUFAQAOHLkiNJ1y6+nXbt2EAQBv/zyS7ltf/XVVxAEAW+//bZaMRFVGyIZBHd3dxGAuHr16jL3e3l5iQDEb7/9ttS+iRMnigBEAGLt2rXFVq1aiba2tiIA0dbWVoyJiSl1TsOGDUUAYs2aNcXmzZuLLVu2FO3s7EQAooWFhXj48OEy49iwYYNoZmamOM7X11f09vYWZTKZUvxz584VW7duLQIQZTKZ2LFjR6VXSREREYr4XVxcxFdffVW0tLQUAYj29vbi6dOny32/5s2bJxoZGYn29vZimzZtRBcXl3Jj1/Ra5IyNjUVBEMTatWuLPj4+YvPmzUUrKyvF+3j58uVSffTv319xbQ0bNhTbtGkjurq6isbGxiIA8dy5c0rHZ2RkiJ07dxYBiEZGRqK7u7vYvHlzRZxNmjQRHzx4oNL16cKLfi81kZKSIr7yyiuKa2zRooXYpEkTxfsUHBwsPnnyROmcw4cPiwDEzp07i6+//roIQPT09BR9fHxEExMTEYDYqFEjpfdm5cqVYseOHRXtPv87eP/+faW2AwIClPq8ffu2CEB0d3cXJ0+erPgMmzdvrujztddeE4uKisS+ffsqPh8vLy9REAQRgBgaGlrq+o8dOyYCEE1MTEQXFxexdevWoqenp6JNX19fMTc3t9R5AQEBIoBSv9+RkZEiADEyMlKxbfz48WLz5s0V/waUvO4BAwaIoiiKy5YtEwGIb775ZrmflbyN3bt3l3sMUXXGJMpAVPRldf36dcU/sEePHlXa9+OPP4oAxPr16yv941pYWCh+/vnnisTk+S+ltWvXijdv3lTaVlBQIK5YsUI0MTERGzRoIBYVFSntP336tGhqaioCEKdOnSpmZ2cr9j19+lTcvHmzeOzYMcW2kl9C5dm1a5fiC2XDhg2K7RkZGeJbb70lAhA9PDxKfanI3y9jY2Nx9uzZYkFBgSiKolhcXCzm5eWV25+m1yKKz7507t69q7QtNzdXnDt3rghADAwMVNp35swZEYDo6uoqXrlyRWlfRkaGuHz5cjExMVFp+6BBg0QAYpcuXZQ+n9TUVLFfv34iAMWXYFWojCRKnlg2a9ZMvHHjhmL76dOnxTp16ig+k5LkiY6JiYloa2srHjp0SLEvISFBbNmyZbnvjTyJKs+LkigTExPRzs5O/OuvvxT7Ll68KNasWVMEIPbt21d0cXFRSogPHz6sSHyfT67j4+PFrVu3illZWUrb79+/Lw4YMEAEIM6aNatUnOokURVdl1xGRoZoaWkpmpiYlJmY//PPPyIA0cnJSSwsLCyzDaLqjkmUgSjryyojI0M8cOCA2LRpU8Vf0iXl5+eLTk5OorGxsXj27Nky25V/Ya1bt07lWIYMGSICKDWC1atXLxGAGB4erlI7qiRR8pGCiRMnltqXk5MjOjo6igDElStXKu2Tv18V/RVdEXWv5UX8/f1FAOKdO3cU2zZv3iwCED/44AOV2rhw4YLi/crMzCy1PycnR3R1dRUFQRDj4+N1EveLyN/nF73S0tJUau/69euKUZqyfme3bt0qAhCtrKyU3gN5QgBAXLhwYanz5O+dIAil/jjQNokCIH7zzTelzps+fbpi/44dO0rtlyfEZcVbntzcXNHMzEz09PQstU/XSZQoiuLQoUPLvb4JEyaIAMQpU6aoHD9RdcM1UQYmLCxMsXbBzs4OwcHBuHbtGgYOHIhdu3YpHXvy5EkkJyfD19cXrVq1KrO93r17A3i2LuJ5165dQ2RkJPr164fAwED4+/vD399fceyFCxcUxz558gQHDhwAAEydOlUn15qdnY2TJ08CAN5///1S+y0tLTFq1CgAKHdBfWhoqNr9anMtZ86cQUREBHr37o2AgADFe3b9+nUAwH//+1/Fsa6urgCAgwcPllqvU5YdO3YAAN555x3Y2NiU2m9paYmuXbtCFEUcO3ZMrbi15enpiY4dO5b7MjFR7UbiAwcOQBRF+Pv7l/k7279/f7i4uCAnJwcxMTGl9puZmWHkyJGltrdo0QL+/v4QRbFSbr4IDw8vtc3HxwcA4ODggL59+5baL7++W7duldpXXFyM3377DePGjUPPnj3RqVMn+Pv7Izg4GIIgIC4uDrm5uTq9hrLIr2vt2rVK2wsKCrB582YAeGnvyiTDwBIHBkZej0cURSQnJ+PWrVswNTVFmzZtSpU2uHjxIoBnC2D9/f3LbE++cPnu3btK26OiojBz5kwUFxeXG0vJL/4bN26goKAANWrUgJeXlyaXVsqNGzdQXFwMmUyGBg0alHlMs2bNAECRpDyvSZMmGvWr7rWIoojx48fj+++/r/C4ku9Z+/bt0bZtW5w6dQqurq4IDg5G586dERAQAF9f31IL7OWf544dO3DixIky209ISABQ+vOsbLoqcSD/HJs2bVrmfiMjI3h7e+POnTu4fv06evToobTfxcWlzAQTePa7cPz48XJ/VzRVq1Yt2NralrkdABo2bFjuecCzPxZKSk9PR69evRR/QJQnLS0NlpaWmoSssoCAADRs2BDnz5/Hf//7X7Ro0QIA8Mcff+Dhw4do3bq14r9BIn3EJMrAPP9lFRMTg759+2LKlCmoU6cOhgwZotiXkZEBAHj48CEePnxYYbtPnjxR/P+jR4/i448/hrGxMaKiotC7d2+4u7vD0tISgiBg5syZmDt3LgoKChTnyO8Kq1Gjhg6u8hn5l0utWrXKvGMP+F8RR/ldUM+zsrJSu19NrmX9+vX4/vvvYWVlhS+//BLBwcGoV68eLCwsAABDhgzBxo0bld4zIyMj/Pnnn5g9ezY2bNiA3377Db/99hsAwN3dHbNmzVL6rOWf540bN3Djxo0K4yn5eZYnOTkZAwYMKLW9VatWWLJkyQvPrwzyz1yVwp1lfeaanqeN8hIZ+e/si/aLoqi0ffLkyTh58iS8vLwwb948tGvXDo6OjjAzMwPwLFG8e/eu0u9SZREEAcOHD8cnn3yCtWvX4uuvvwbwv5EpjkKRvuN0noHr2LEjli9fDgCYOHGi0i3u1tbWAIDBgwdDfLZ+rtxXydv+N27cCAD46KOPEBERgaZNm8LKykrxj35ZZQXkf/2XdUu+puTxP3z4sNQXjdyDBw+U+tcFTa5F/p59/fXXGDt2rKIkglx5pRjs7e2xaNEiPHz4EOfOncPixYsRFBSEhIQEhIWFYfv27Ypj5e/H8uXLX/h5qvIYk7y8PMTExJR6yUe8pCC/xpSUlHKPqegzr+iPBXmbuvxd0bXCwkJs3boVAPDbb7+hX79+qFu3riKBKiwsRHJycpXGNHz4cBgZGWHjxo0oLCzE48ePsWfPHpiZmSEkJKRKYyHSNSZRhL59+6Jdu3ZITU3FwoULFdvlUyKXLl1Sqz15fZwOHTqUub/kWig5T09PmJmZIT09Hf/++69K/ZQ3uiTXqFEjGBkZIT8/v8x1IwAUNavkdbJ0QZNrqeg9KygowNWrVys8XxAE+Pj4YMKECTh06BAiIiIAQJEgA5p/nuXx8PB4YUJd1eSf45UrV8rcX1xcrKgeXtZnnpSUVGp6TE7+Gejyd0XXHj58iJycHDg4OJQ5lXzp0iUUFRXppK8X/fcn5+LiguDgYDx48AB79+7Fpk2b8PTpU/Tu3RsODg46iYVIKkyiCAAUX7rffvut4kukU6dOcHR0xIULF9T6YpSPoMj/4i9p//79ZSZRFhYW6NatG4BnBfjU6ae8qSdra2tFUlLW9NKTJ0+wYsUKAED37t1V6lPVuDS9lrLes9WrV79wOvV57dq1AwDcu3dPse2tt94CAGzYsAGPHz9Wqz190a1bNwiCgOPHj+PcuXOl9v/666+4c+cOrKys0LFjx1L7nz59ipUrV5bafunSJRw7dgyCICA4OFhp34t+D6uSPJbMzMwy41mwYIHO+1LluksuMOdUHr1MmEQRgGd32TVp0gRpaWn44YcfAADm5uaYM2cOAODtt9/Gjh07Sk2LXbp0CdOmTVO600m+CH3+/Pm4ffu2Yvvp06cRHh4Oc3PzMmOIjIyEqakpVqxYgY8//ljp7qGCggL8/PPPOH78uGJbrVq1YGNjg5SUlHJHaqZNmwYA+P7777Fp0ybF9qysLISGhuLhw4fw8PDAoEGDXvwmqUHda5G/ZzNnzlRKmPbu3YuPPvqozPds48aN+Oyzz0pVmX/8+DG+/fZbAICvr69ie+vWrfHOO+/g8ePHCA4OLpVkFBUVITo6GoMHD0Z+fr7mFy+hRo0aoV+/fgCe3VlZcgTy7NmzmDBhAoBnz4Mra1rOxMQEkZGRSneb3rlzR3GXZr9+/Uot9JbftFDWHapVrUaNGmjWrBkKCwvxwQcfKCqaFxUV4YsvvsDPP/+smNrTlvyJAVeuXHlhkt+3b1/UrFkTO3fuxD///AMnJ6dSi/qJ9FKVFFIgyalS1HDlypWK4ncli2eWrPjt4OAgtmnTRvT19RUdHBwU2//880/F8RkZGWKDBg1EAKKZmZn4yiuvKCqiN23aVFGd+fm6M6IoiuvXr1cUqbS0tBR9fX3FJk2aiObm5mXGHx4eLgIQzc3NxdatW4sBAQGl6taUjN/V1VVs3bq1ohK4vb29GBsbW+77dfv2bVXe3jKpcy0JCQmK99PCwkL08fERPTw8RABiUFCQOHjw4FLnfPPNN4rrqlevntimTRul6uP16tUTExISlGLKysoSg4ODFee5ubmJbdu2FV955RXRwsJCsf354qmVRf4+e3p6lqr4XfK1ePFildssWbHc2NhYbNmypaIWGgCxa9euKlUsb9y4sdiqVStFIdoGDRooqpCXNGfOHEVfrVq1UvwOqlOxvCwvqsO0evVqEYA4bNgwpe2///67olaWg4OD2Lp1a0U9tE8++aTc321160SJoii+9tprIgDRxsZGbNu2rRgQECAOHDiwzHjff/99xWfA2lD0smASZSBUSaLy8/PFunXrigDE7777TmlfTEyM+O6774qurq6imZmZ6ODgILZo0UIMDw8X9+zZIz59+lTp+Hv37omhoaGio6OjaGZmJtavX1+cPHmymJGRUeE/yqIoipcvXxbDwsJENzc30czMTHR0dBRfffVVcdasWaW+xLKyssSJEyeKHh4eioSlrL8Ndu3aJQYHB4v29vaimZmZ6O7uLo4ZM6ZURe/n3y9tkih1r+Xff/8V+/XrJ9rZ2Ynm5uait7e3OHv2bDE/P18cNmxYqc8vMTFR/OKLL8Tg4GDRzc1NNDc3F2vWrCn6+vqKn3/+ebkFKouKisSNGzeK3bt3Fx0dHUVTU1PR2dlZbNu2rTht2rQyk8rKomqxzbKKpVYkOztbnDNnjti8eXPRwsJCtLKyEtu0aSMuWbKk1O+qKConLE+fPhVnzZolNmrUSJTJZKKzs7M4duxY8eHDh2X29fTpUzEyMlL08vJSPNKn5O9OVSdRoiiKe/fuFTt06CBaWFiINjY2Yrt27RQV+3WZRCUnJ4vDhw8X69Wrp0g2y7ues2fPKt6bS5culXkMkb4RRLGc25aIiAxEdHQ0goKCEBAQIOnC+JfZ3r170bNnT7Ru3RqnT5+WOhwineCaKCIiqnTyBfthYWESR0KkO0yiiIioUp06dQo7duyAra0tBg8eLHU4RDrDiuVERFQpBg0ahPj4eJw9exZFRUWIiIiAnZ2d1GER6QyTKCIiqhR///03EhMT4eLigpEjRypKjhC9LLiwnIiIiEgDXBNFREREpAFO5+lYcXEx7t27BxsbG5WfLUVERNWHKIrIyspC3bp1YWRUeWMNeXl5iqry2jAzMyv3SRBUuZhE6di9e/fg6uoqdRhERKSlpKQkuLi4VErbeXl5sLSwgC7W0zg5OeH27dtMpCTAJErH5M/jSqoB2HIg6qU3Kk3qCKgq7ZI6AKoSIoA8oMznK+rK06dPIQKwAKDNV4UIIDk5GU+fPmUSJQEmUTomn8KzFZhEGQJTqQOgKsX/pA1LVSzJMIb2SRRJh0kUERGRRJhE6TfenUdERESkAY5EERERScQIHInSZ0yiiIiIJGIE7aaEinUVCGmESRQREZFEjKFdEsWbHaTFNVFEREREGuBIFBERkUS0nc4jaTGJIiIikgin8/QbE2AiIiIiDXAkioiISCIcidJvTKKIiIgkwjVR+o2fHREREZEGOBJFREQkESM8m9Ij/cQkioiISCLaTufxsS/S4nQeERERkQY4EkVERCQRY3A6T58xiSIiIpIIkyj9xiSKiIhIIlwTpd+4JoqIiIhIAxyJIiIikgin8/QbkygiIiKJMInSb5zOIyIiItIAR6KIiIgkIkC70YxiXQVCGmESRUREJBFtp/N4d560OJ1HREREpAGORBEREUlE2zpRHAmRFpMoIiIiiXA6T78xiSUiIiKVHD16FFOmTEFQUBDs7OwgCAKGDx+uUVuCIJT7mj9/vm4DryQciSIiIpKIvo1ErVq1CmvXroWlpSXc3NyQmZmpVXvu7u5lJmH+/v5atVtVmEQRERFJRN/WRI0fPx4fffQRvL29cfr0abRv316r9jw8PDBr1izdBCcBJlFEREQS0beRqNatW1dxj9UbkygiIiKSRHp6OlasWIGUlBTUqlULgYGB8PT0lDoslTGJIiIikogRtBuJklcsf35tkkwmg0wm06LlqnHhwgWMGjVK8bMgCBg8eDCWLVsGS0tLCSNTDe/OIyIikoiRDl4A4OrqCjs7O8UrKiqqSq9DE1OmTMGpU6eQmpqKtLQ0HDp0CG3btsWGDRswYsQIqcNTCUeiiIiI9FxSUhJsbW0VP1c0CuXo6IjHjx+r3Pbhw4cRGBioTXhl+vLLL5V+DgoKwsGDB9GyZUts2bIFM2fORLNmzXTery4xiSIiIpKItgvL5dN5tra2SklURUJCQpCVlaVyH05OThpEphlLS0uEhITgs88+Q0xMDJMoIiIiKpsUJQ6WLFmiRY+Vz9HREQCQm5srcSQvxjVRREREVG2cOnUKwLMaUtUdkygiIiKJGOvgVZ3l5ubi2rVrSExMVNp+7ty5Mkeatm3bhs2bN8PR0RFdu3atqjA1xuk8IiIiiehqTVRVOX78OFasWAEAePjwoWKb/NEt3t7eiIiIUBwfGxuLoKAgBAQEIDo6WrF98eLF2LlzJ7p06QI3NzeIooizZ8/i2LFjMDc3x9q1a2FtbV1l16UpJlFERESkkhs3bmDt2rVK227evImbN28CAAICApSSqPL06dMH6enpOHv2LPbu3YvCwkLUq1cPI0aMwJQpU+Dt7V0p8euaIIpiVVeNf6llZmbCzs4OGfaArSB1NFTZhqRKHQFVpR1SB0BVQgTwBEBGRobKd7ypS/5dMQCAqRbtFADYjsqNlcrHkSgiIiKJaFuxvEhXgZBGmEQRERFJRNs1UdV9YfnLjnfnEREREWmAI1FEREQSkaLYJukOkygiIiKJcDpPvzGJJSIiItIAR6KIiIgkwuk8/cYkioiISCKcztNvTGKJiIiINMCRKCIiIolwJEq/VfuRqPT0dEyYMAHt27eHk5MTZDIZ6tWrh9deew2//PILynpqTWZmJiZPngx3d3fIZDK4u7tj8uTJyMzMLLefTZs2wc/PD1ZWVrC3t0evXr1w5syZyrw0IiIycAL+ty5KkxefLvY/oiji+PHjmDdvHnr16oVmzZqhdu3asLGxQf369eHn54cxY8Zg48aNSE5O1kmf1f7ZeTdu3ICPjw/atWuHRo0awcHBASkpKdi1axdSUlIwatQo/PTTT4rjc3Jy4O/vj/PnzyM4OBi+vr64cOEC9u7dCx8fHxw/fhxWVlZKfcybNw8zZsyAm5sbBgwYgOzsbGzZsgV5eXnYt28fAgMDVY6Xz84zLHx2nmHhs/MMQ1U+O+89ADIt2skHsAyG/ey8O3fuYPny5VizZg3u3LkDAGUOsMgJggBjY2P06NEDo0aNwptvvqlx39U+iSoqKoIoijAxUZ55zMrKQrt27XDlyhVcunQJzZo1AwBERkZizpw5mDp1Kr744gvF8fLtn376KWbPnq3YHhcXh6ZNm6JBgwaIjY2FnZ0dAODy5cvw8/ODs7Mzrl27Vqr/8jCJMixMogwLkyjDUJVJ1H+gfRL1PQwziUpLS8Pnn3+O77//Hvn5+TAxMUHbtm3h5+eHNm3awNnZGQ4ODrCwsEBqaipSU1Nx5coVxMbG4sSJE7hz5w4EQUCLFi0wf/58dO/eXe0Yqn0SVZHJkyfjm2++wc6dO9GnTx+IoggXFxdkZmYiOTlZacQpLy8PdevWhaWlJZKSkiAIzzKcjz/+GFFRUVi7di1CQ0OV2h87dix+/PFH7Nu3D926dVMpJiZRhoVJlGFhEmUYqjKJeh/aJ1FLYJhJlL29PTIyMtCuXTsMGzYMAwYMQM2aNVU+/8SJE9i0aRM2btyIzMxMLFy4EBMnTlQrhmq/Jqo8eXl5OHToEARBQNOmTQE8G1W6d+8eOnbsWGrKztzcHJ07d8bdu3dx48YNxfbo6GgAKDNJkmelR44cqaSrICIiQ6bNeihta0zpO19fXxw6dAgnTpzAe++9p1YCBQAdOnTA0qVLER8fj08//RTGxuov09ebu/PS09OxaNEiFBcXIyUlBX/88QeSkpIQGRkJT09PAM+SKACKn59X8riS/9/a2hpOTk4VHk9ERETVx8GDB3XSjp2dHSIjIzU6V6+SqJJrmUxNTfHll1/iww8/VGzLyMgAAMW6pufJhzrlx8n/f+3atVU+/nn5+fnIz89X/FzRHYBEREQlscSBftObkUAPDw+IoojCwkLcvn0bc+bMwYwZM9C/f38UFhZKFldUVBTs7OwUL1dXV8liISIi/cLpPP2mNyNRcsbGxvDw8EBERASMjY0xdepULF++HGPHjlWMQJU3ciQfJSo5UmVnZ6fW8c+bPn06Jk+erHQOEykiIiLppKSkICEhAQ8fPsSTJ0/g6OiIWrVqwcvLS6O1T+XRuySqpG7dumHq1KmIjo7G2LFjX7iGqaw1U56enjh58iSSk5NLrYt60RorAJDJZJDJtLm3goiIDBWn83TnwIED+Pnnn3H06FHcvHmzzGMsLS3Rrl07dO/eHUOHDkWdOnW06lOvRwLv3bsHAIoaTp6enqhbty5iYmKQk5OjdGxeXh6OHj2KunXrolGjRortAQEBAID9+/eXan/fvn1KxxAREemSEf6XSGny0usvcR3Iy8vDl19+iQYNGqBHjx5YtWoVbty4AXNzc7i5ucHHxwft27eHl5cXatWqhZycHBw8eBDTpk2Dm5sb+vfvj3/++Ufj/qv9+3/+/Pkyp9tSU1Px8ccfAwB69uwJ4FkV0pEjRyI7Oxtz5sxROj4qKgppaWkYOXKkokYUAISFhcHExARz585V6ufy5ctYt24dGjZsiNdee60yLo2IiIg0tGrVKnh6emLatGm4f/8+evfujeXLl+PChQvIysrC7du38c8//+D48eO4cuUKkpOT8ejRI/zxxx+YPn063N3dsWPHDvj5+SEkJAQJCQlqx1Dti21OmjQJK1asQFBQENzd3WFlZYWEhATs2bMH2dnZ6N+/P7Zu3Qojo2f54POPfXn11Vdx4cIF/Pnnn+U+9mXu3LmYOXOm4rEvOTk52Lx5M548eYJ9+/YhKChI5XhZbNOwsNimYWGxTcNQlcU2ZwAw16KdPABzYZjFNo2MjNCgQQNMnToVgwYN0uj6//nnH3z77bfYvHkzZs6ciU8//VSt86t9EnX8+HGsXLkSf//9N+7du4fc3Fw4ODjA19cXoaGhGDRokNLIEvDsl2n27NnYvn27Yq3TgAEDEBkZWe4i8Y0bN2LRokW4fPkyzMzM0L59e8yZMwdt2rRRK14mUYaFSZRhYRJlGKoyifoU2idRc2CYSdT69evx7rvv6mSh+O3bt3Hnzh106tRJrfOqfRKlb5hEGRYmUYaFSZRhYBJFqtLru/OIiIj0Ge/O029MooiIiCSibcHMan932EuOSRQREZFEOBKlnefvxNeEuovJS2ISRURERHpp1qxZipvLRFEsdaNZReTHM4kiIiLSQ5zO0w0vLy906NBBrSRKF5hEERERSUResVyb8w2Zo6MjHj16hH///RdPnz7F4MGDMWTIkAof16ZLhv7+ExERkZ66f/8+du/ejbfffhv379/HZ599Bm9vb3To0AHff/89Hj9+XKn9M4kiIiKSiDbPzdN2UfrLwNjYGL169cKWLVvw4MEDrFy5EoGBgYiNjcX777+PunXrok+fPti+fTvy8/N13j+TKCIiIokY6eBFz1hbWyMsLAwHDx5EQkIC5s2bh8aNG2PXrl0YOHAgnJycMGrUKJw6dUpnffL9JyIiopdKvXr1MG3aNFy8eBHnzp3D5MmTYW5ujlWrVml1N97zuLCciIhIIqwTVbmKioqQmJiIxMREpKenQxRF6PJpd0yiiIiIJMIkqnKcOnUK69evx9atW/H48WOIoghPT08MHjwYQ4cO1Vk/nM4jIiKiF8rJycGGDRvwzjvvoHHjxrCwsECNGjUQEBCAzZs3a9Tmvn37EBgYCFtbW9jY2CAwMBD79u3TqK1bt25hzpw5ippR33//PQBg7NixOHnyJP799198+umnqF+/vkbtl4UjUURERBLRp2Kbx44dw9ChQ1GzZk106dIF/fv3R0pKCn799Ve8++67OHHiBJYsWaJyexs3bsSQIUPg6OiIYcOGQRAEbN26FT169MCGDRswePDgF7aRlpaGn3/+GevXr8fff/8NURRhbm6OAQMGYMiQIejZsydMTCov1RFEXU4OEjIzM2FnZ4cMe8C2agunkgSGpEodAVWlHVIHQFVCBPAEQEZGBmxtbSulD/l3xU8ALLRo5wmA0ajcWOUuXLiAy5cv4+2334apqali+4MHD9C2bVskJCQgNjYWbdq0eWFbaWlpaNCgAUxMTHD27Fm4uroCeFb3ydfXF3l5ebh16xbs7e0rbEcmk6GwsBCCIKBTp04YOnQo3n77bdjY2Gh3sSriSBQREZFEBGg3mlSVf6u3bNkSLVu2LLW9Tp06eO+99/Dxxx/jyJEjKiVR27ZtQ3p6OmbPnq1IoADA2dkZkyZNQkREBLZt24bRo0dX2E5BQQEEQUCjRo1gamqKLVu2YMuWLSpfkyAIGk8fAkyiiIiISEvykSlVp86io6MBAN26dSu1r3v37oiIiMCRI0demEQBzx4kfP36dVy/fl31gP+fts/aYxJFREQkEV3dnZeZmam0XSaTQSaTadGy6oqKirBu3ToIgoCuXbuqdE5cXBwAlPmMO/k2+TEVWb16tRqR6h6TKCIiIonoKokqOSUGAJGRkZg1a5YWLavuk08+wcWLFxEeHo7mzZurdE5GRgYAwM7OrtQ+KysrGBsbK46pyLBhw9QLVseYRBEREem5pKQkpYXlFY1COTo6qvVg3sOHDyMwMLDMfT/99BOioqLQqlUrLF68WOU2XxZMooiIiCSiqxIHtra2Kt+dFxISgqysLJX7cHJyKnP76tWrMWbMGLzyyis4cOAArK2tVW5TPgKVkZGBmjVrKu3LyclBUVFRmaNU1Q2TKCIiIolIUbFcnVpO5Vm1ahVGjRqFpk2b4uDBg6USoRfx9PTEmTNnEBcXV+rcitZLPW/dunVq9VuW0NBQjc9lEkVEREQqW7VqFUaOHIkmTZrg0KFDqFWrltptyKuc79+/H+3atVPaJy85EBAQ8MJ2hg8frtUddoIgaJVEsdimjrHYpmFhsU3DwmKbhqEqi23+DMBSi3ZyAQxE1RTbBICVK1di1KhR8Pb2xuHDh1GnTp2K48vNRWJiIiwtLeHm5qbYnpaWhvr168PU1FSrYpseHh5alym4ffu2xudyJIqIiEgi+vTYl0OHDmHUqFEQRRGdO3fGDz/8UOoYHx8f9O3bV/FzbGwsgoKCEBAQoKgNBQD29vZYunQphg4dCl9fXwwaNAhGRkb4+eef8eDBA6xfv/6FCRQAxMfH6+DKNMckioiIiF4oMTER8smrZcuWlXnMsGHDlJKoisifmxcVFYU1a9YAAHx9fbF27Vp0795dFyFXOk7n6Rin8wwLp/MMC6fzDENVTuf9AsBKi3ZyAPRH1U3nkbKqHAkkIiKiEox08DJk/fr1wyeffCJZ/4b+/hMREUnGWAcvQ7Zz504cOXKkzH3GxsYq3eGnDSZRRERE9NIRRRGVvWKJC8uJiIgkIkWxTdIdJlFEREQS0acSB1Qa338iIiIiDXAkioiISCKcztNvTKKIiIgkwiRKe3FxcQgPD1d7H/Ds2XkrV67UuG8W29QxFts0LCy2aVhYbNMwVGWxzYPQvthmFxhusU0jIyMIgqD2XXjycwRBQFFRkcb9cySKiIhIIgK0W5xs6H+rDxs2TNL+mUQRERFJhNN52lm9erWk/fPuPCIiIiINcCSKiIhIIqwTpd/4/hMREUmEz87TXGxsrM7ays3NxZUrV9Q+j0kUERGRRJhEaa5du3bo2bMnjh8/rnEbaWlpmDdvHtzd3bF9+3a1z2cSRURERHpnypQpOHLkCAICAtCwYUPMnDkTJ06cQF5eXoXnJSYmYtOmTejTpw+cnZ0xc+ZMuLu7480331Q7BtaJ0jHWiTIsrBNlWFgnyjBUZZ2o0wCstWgnG0AbGG6dqDt37iAyMhKbN29GXl4eBEGAsbExmjRpAmdnZzg4OEAmkyE9PR2pqam4du0aHj16BAAQRRFNmjTBzJkzERISolH/TKJ0jEmUYWESZViYRBmGqkyizkL7JMoXhptEyaWnp2Pt2rX4+eef8c8//6CgoKDcY+vVq4fg4GCMGDECHTt21Kpf3p1HREREeq1GjRqYOHEiJk6ciLy8PJw+fRoJCQl49OgR8vLy4ODggNq1a8PHxwceHh4665dJFBERkUSMoN3icC5sLs3c3BydOnVCp06dKr0vJlFEREQSYZ0o/cb3n4iIiEgDHIkiIiKSCJ+dp1vh4eEqH2tsbAwbGxt4eHigY8eOePXVV9Xuj0kUERGRRDidp1tr1qwBAAjCs9vjyypA8Pw++c+vvvoq1q5diyZNmqjcH5MoIiIiiXAkSrdWr16Nmzdv4osvvoCVlRX69u2LFi1awMbGBllZWbh48SJ27tyJnJwcTJ06FU5OTrh69Sp++eUXnDlzBkFBQTh37hycnZ1V6o91onSMdaIMC+tEGRbWiTIMVVkn6joAGy3ayQLQGKwTJXf79m20bt0afn5+2Lx5M2rUqFHqmMzMTAwcOBCnT59GbGwsGjRogJycHPTr1w9//fUXJk6ciIULF6rUH5MoHVMkUccAW20qqJF+eF3qAKgqxdyTOgKqCjkAuqNqkqib0D6JaggmUXKDBw/Gzp07cffu3TITKLm0tDS4uLigT58+2LRpEwDg7t27cHd3R6NGjXDt2jWV+uN0HhERkUS4Jkq3Dh48iGbNmlWYQAGAvb09mjVrhkOHDim21atXD97e3rh9+7bK/fH9JyIiopdCZmYmUlNVW2eRmpqKzMxMpW0ymUyx0FwVTKKIiIgkIq9YrumLX+LKPD09cfv2bezevbvC43bv3o1bt26hcePGSttv3bqFWrVqqdwf338iIiKJaJNAaXtn38to7NixEEUR77zzDubPn4/k5GSl/Q8ePMAXX3yBQYMGQRAEjB07VrHvwoULyMjIgK+vr8r9cU0UERERvRTGjBmD06dPY/Xq1ZgxYwZmzJiBmjVrwsbGBtnZ2Xj06BGAZzWiRowYgffee09xbnR0NAICAhAaGqpyf7w7T8d4d56B4d15BoV35xmGqrw77x4AbXrIBFAXvDvvedu3b8fXX3+N2NhYpYKbRkZGaNu2LSZPnoz+/ftr3Q9HooiIiCTCYpuVY8CAARgwYACys7Nx48YN5OTkwMrKCo0aNYK1te5GOJhEERER0UvJ2toaPj4+ldY+kygiIiKJsE6UfmMSRUREJBFO52lu3bp1AAA7Ozv06dNHaZs61FlI/jwuLNcxLiw3MFxYblC4sNwwVOXC8gxov7DcDlWzsDwnJwc7duzA77//jvPnzyMpKQkymQwtW7bEmDFjEBISolZ7FRW1jIqKQkRERIXnGxkZQRAEeHl54cqVK0rb1FFUVKTW8SVxJIqIiIhe6NixYxg6dChq1qyJLl26oH///khJScGvv/6Kd999FydOnMCSJUvUatPd3R3Dhw8vtd3f3/+F54aGhkIQBDg7O5faVlU4EqVjHIkyMByJMigciTIMVToSJQC2WnznZ4qAnVg1I1EXLlzA5cuX8fbbb8PU1FSx/cGDB2jbti0SEhIQGxuLNm3aqNSeIAgICAhAdHR0JUVc+bgmjYiISCp6VLK8ZcuWePfdd5USKACoU6eOomjlkSNHqi6gaoDTeURERKQVeWJlYqJeWpGeno4VK1YgJSUFtWrVQmBgIDw9PXUWV3FxMR4/fownT57Azc1NZ+3KMYkiIiKSijEAbZbwiAAKn00PliSTySCTybSJTGVFRUVYt24dBEFA165d1Tr3woULGDVqlOJnQRAwePBgLFu2DJaWlhrH9Mcff+Cbb77BiRMnkJeXB0EQUFhYqNg/d+5cXL58GYsXL1brgcPP43QeERGRVIx08ALg6uoKOzs7xSsqKqrKLuGTTz7BxYsXERYWhubNm6t83pQpU3Dq1CmkpqYiLS0Nhw4dQtu2bbFhwwaMGDFC43imTp2KN998EwcPHkRRURFMTU3x/PJvZ2dn/Pzzz9ixY4fG/QBcWK5zXFhuYLiw3KBwYblhqNKF5RY6WFj+BEhKSlKKtaKRKEdHRzx+/FjlPg4fPozAwMAy9/30009477330KpVKxw9elTrR6rk5uaiZcuWuHHjBi5duoRmzZqpdf4vv/yCt99+G/Xq1cOyZcvQvXt3BAYG4sSJE0qlDNLS0uDo6IiePXti9+7dGsfL6TwiIiKp6GI6D4Ctra3KCV9ISAiysrJU7sLJyanM7atXr8aYMWPwyiuv4MCBAzp5Jp2lpSVCQkLw2WefISYmRu0k6rvvvoMgCNi2bRvatWtX7nH29vaoX78+4uLitIqXSRQREZFUdJREqUPdWk5lWbVqFUaNGoWmTZvi4MGDqFmzptZtyjk6OgJ4NiqlrnPnzsHV1bXCBEquVq1auHjxotp9lMQ1UURERKSyVatWYeTIkfD29sahQ4e0WphdllOnTgEAPDw81D43Pz8fNWrUUOnY3NxcGBtrVyOCSRQREZFUdLSwvKqsXLlSKYGqXbt2hcfn5ubi2rVrSExMVNp+7ty5Mkeatm3bhs2bN8PR0VHtO/2AZwvsb9y4gYKCggqPy8jIwLVr19CwYUO1+yiJ03lERERS0TYRKtZVIC926NAhjBo1CqIoonPnzvjhhx9KHePj44O+ffsqfo6NjUVQUFCpyuSLFy/Gzp070aVLF7i5uUEURZw9exbHjh2Dubk51q5dq9Eaq+7du+O7777DN998g6lTp5Z73Jw5c1BYWIg33nhD7T5KYhJFREQkFQlGkzSVmJioKBWwbNmyMo8ZNmyYUhJVnj59+iA9PR1nz57F3r17UVhYiHr16mHEiBGYMmUKvL29NYpx2rRpWLduHT7++GM8fPhQqVRCcXExLl26hEWLFmHNmjWoVasWJk6cqFE/cixxoGMscWBgWOLAoLDEgWGo0hIHtQBbLZKozGLA7mHVPDtPXxw5cgT9+vVDenp6mftFUYSDgwN+//13dOjQQau+9CT/JSIiegnp0bPz9EVAQAAuXbqESZMmwd3dHaIoKl7Ozs4YP348Lly4oHUCBXA6j4iISDrG0G44Q5vyCC8xZ2dnfP311/j666+Rk5ODjIwMWFtb63y0jkkUERERvbSsrKxgZWVVKW0ziSIiIpKKHi0sp9KYRBEREUmF03l6jfkvERERkQY4EkVERCQVI/AOOz3GJIqIiEgq2q6JYqVHSXE6j4iIiEgDHIkiIiKSCgtm6jUmUURERFLhdJ5eYxJFREQkFY5EaWzdunU6aSc0NFTjc5lEERERkd4ZPnw4BEH7QlmVnkQ1aNBA4w7KIggCbt68qdM2iYiI9A5HojQWGhqqkyRKGyolUfHx8TrtVOqLJiIiqha4Jkpja9askToE1afz2rRpg61bt2rd4dtvv41//vlH63aIiIiIpKRyEiWTyeDu7q51hzKZTOs2iIiIXgraViw34JGo6kClJKp3795o3ry5Tjrs1KkTHB0dddIWERGRXtN2TRSTqHIVFxcjLi4OqampKCgoKPe4zp07a9yHSknUzp07Ne7gefPmzdNZW0REREQlPXz4EBEREdi6dStyc3MrPFYQBBQWFmrcV5WVOLh+/ToaN25cVd0RERFVf9ouLOfD25Q8fvwYbdu2RUJCAlxcXGBsbIysrCx06NABSUlJuHv3LoqKimBhYQE/Pz+t+1P57f/qq6807uS///0vAgICND6fiIjopWSsgxcpLFiwAPHx8Rg/fjwSEhLwyiuvAACOHTuG+Ph4PHjwABERESgsLIS7uzsOHz6sVX8qJ1HTpk3D4sWL1e4gNjYWQUFBSElJUftcIiIiIlXt2rULFhYW+Oyzz8rc7+DggHnz5mH58uVYv349vv/+e636U2sgcPLkyfjuu+9UPv7IkSMIDg5GWloa2rdvr3ZwRERELzUjHbxIISEhAR4eHrC1tQUAGBk9e4OeX1geGhoKZ2dnrFy5Uqv+VH77V61aBUEQMGHCBCxbtuyFx+/duxe9evVCVlYWunTpgv3792sVKBER0UuH03k6ZWpqCktLS8XPNjY2AIDk5ORSxzo7OyMuLk6r/lROooYNG4affvoJADBu3DisWLGi3GN//fVX9O3bF0+ePMGbb76J3bt3K10UERERgUmUjrm4uOD+/fuKn+U3tB07dkzpuJycHMTFxWn9BBW1BgLDw8OxbNkyiKKIMWPGlFlyfd26dRg0aBCePn2KgQMH4pdffmGBTSIiIqp0fn5+ePDgAdLT0wEAb775JkRRxEcffYS//voLOTk5uHXrFoYMGYKsrCytlxqpPZs6cuRIfP/99xBFESNHjsT69esV+3744QeEh4ejsLAQ4eHh2LRpE0xMqqyKAhERkX4RoN16KD6KVkmfPn1QVFSEXbt2AQCCgoLQp08f3L9/H927d4etrS08PT3x22+/wczMDJ9//rlW/WmU4bz33nsoLi7GuHHjEB4eDhMTEyQlJWH69OkQRRETJkzAokWLtAqMiIjopaftlFyxrgJ5Obz55ptISkpSrIUCgK1btyIqKgqbNm1CfHw8LCws4O/vj9mzZ8PX11er/gRRFDUuGr906VJMmDABRkZGEEURoihi+vTpmDt3rlZB6bPMzEzY2dkh4xhgay11NFTpXpc6AKpKMfekjoCqQg6A7gAyMjIUd3npmuK7ojtga6pFOwWA3b7KjZXKp9Vc2/jx4yGKIiZOnAhBEBAVFYVp06bpKjYiIqKXG0ei9JrKa6IaNGhQ5uubb76BqakpjI2NsWzZsnKPa9iwocZBenh4QBCEMl9jxowpdXxmZiYmT54Md3d3yGQyuLu7Y/LkycjMzCy3j02bNsHPzw9WVlawt7dHr169cObMGY1jJiIieiHWidJrKo9ExcfHa3WMtrcR2tnZYdKkSaW2t27dWunnnJwcBAQE4Pz58wgODkZISAguXLiAb775BocPH8bx48dhZWWldM68efMwY8YMuLm5YcyYMcjOzsaWLVvQsWNH7Nu3D4GBgVrFTkRERFVn37592Lt3L27duoXs7GyUt3JJEAQcPHhQ435UTqJWr16tcSe6UKNGDcyaNeuFxy1YsADnz5/H1KlT8cUXXyi2R0ZGYs6cOViwYAFmz56t2B4XF4fIyEg0btwYsbGxsLOzAwBMmDABfn5+GDlyJK5du8a7DImISPc4nadTmZmZ6Nu3L44cOVJu4lSStgM8Wi0sryoeHh4AXjwaJooiXFxckJmZieTkZKURp7y8PNStWxeWlpZISkpSvHEff/wxoqKisHbtWoSGhiq1N3bsWPz444/Yt28funXrplKsXFhuYLiw3KBwYblhqNKF5W/pYGH5Di4slxs7diyWLVsGBwcHjB49Gq1atUKtWrUqTJYCAgI07k9vhlfy8/Oxdu1a3L17F/b29ujQoQNatmypdExcXBzu3buH7t27l5qyMzc3R+fOnfHbb7/hxo0b8PT0BABER0cDQJlJUvfu3fHjjz/iyJEjKidRREREJI1ff/0VpqamOHLkCJo1a1bp/elNEpWcnIzhw4crbevRowfWr18PR0dHAFA8A0eeID1Pvj0uLk7p/1tbW8PJyanC48uTn5+P/Px8xc8VLV4nIiJSwuk8ncrJyYGXl1eVJFCAiuv6161bh3379umkw3379mHdunVqnRMeHo7o6Gg8fPgQmZmZ+Pvvv9GzZ0/s3bsXvXv3Vsx7ZmRkAIBiXdPz5EOd8uPk/1+d458XFRUFOzs7xcvV1VWtayMiIgNmBO2em8e785R4e3vjyZMnVdafSm//8OHDdVZA8/PPP0dYWJha53z66acICAiAo6MjbGxs0LZtW+zevRv+/v44efIk/vjjD53Eponp06cjIyND8UpKSpIsFiIi0jN6VuJg/vz56NatG1xdXWFhYYGaNWuidevWWLhwIXJzc9VuT34HvK2tLWxsbBAYGKjVoM24ceNw8+ZNxVKdyqa3OayRkZEiGYuJiQHwvxGo8kaO5FNtJUee7Ozs1Dr+eTKZDLa2tkovIiKil9GyZcuQlpaG4OBgTJw4ESEhIcjLy8OHH36IDh06qJVIbdy4ET169MDly5cxbNgwhIWF4dq1a+jRowc2btyoUXxhYWF4//330a9fPyxZsgTZ2dkataMqlddEXbx4Ea+99prWHV68eFHrNuTka6HkH9qL1jCVtWbK09MTJ0+eRHJycql1US9aY0VERKQVbddEaXOuBq5evQpzc/NS20NDQ7F+/XqsXr0a48aNe2E7aWlpGD9+PBwdHXH27FnFUpjp06fD19cX48ePR69evWBvb692jAsWLEBSUhImTZqESZMmoVatWrC0tCzzWEEQcPPmTbX7kFM5icrIyNDZ8Ji2dRnkTp06BeB/JRA8PT1Rt25dxMTEICcnp1SJg6NHj6Ju3bpo1KiRYntAQABOnjyJ/fv3lypxIB9S1Ob2RyIionLpWRJVVgIFAAMGDMD69etx48YNldrZtm0b0tPTMXv2bKW1xM7Ozpg0aRIiIiKwbds2jB49Wq34Hjx4gK5du+LKlSuK9dIpKSnlHq9tPqJSEnX48GGtOtHGlStXULduXdSoUUNp+/Hjx7Fw4ULIZDL069cPwLM3Y+TIkZgzZw7mzJmjVGwzKioKaWlpeP/995XetLCwMHz11VeYO3cu+vTpo5i6u3z5MtatW4eGDRvqZASOiIjoZbVnzx4AQPPmzVU6/kXlhSIiInDkyBG1k6hp06bh8uXLaNSoET766CP4+Pi8sE6UNlRKoqQcidm6dSsWLFiALl26wMPDAzKZDJcuXcL+/fthZGSEH3/8EW5uborjp06dit9//x0LFizAuXPn8Oqrr+LChQv4888/4ePjg6lTpyq137hxY8yaNQszZ85EixYtMGDAAOTk5GDz5s0oKCjA8uXLWa2ciIgqh7aLw///3OfL68hkMshkMi0artiiRYuQnp6O9PR0xMTE4MyZM+jWrVupGZ3yVLRcRpXyQuXZu3cvzM3NER0djbp166p9vrqqfXYQFBSEq1ev4uzZszhy5Ajy8vJQp04dDBw4EB988AH8/PyUjreyskJ0dDRmz56N7du3Izo6Gk5OTvjggw8QGRlZqggnAMyYMQMeHh5YtGgRfvjhB5iZmaFDhw6YM2cO2rRpU1WXSkREhkZH03nPl9eJjIxU6VFpmlq0aBESEhIUPw8ZMgQ//PADTE1VK79eUUkiKysrGBsbV1heqDw5OTnw9vaukgQK0JPHvugTPvbFwPCxLwaFj30xDFX62JcRgK2ZFu08BexWAklJSUqxVjQS5ejoiMePH6vcx+HDhxEYGFjmvuTkZBw+fBhTp06Fra0t9u3bBxcXlxe22bhxY8TFxaGgoKDM2R4TExM0bNgQ//77r8pxAkCHDh1w9+5dpQSvMlX7kSgiIqKXlo6m89QpsRMSEoKsrCyVuyjriR4l94WEhKBRo0bw8/PDhx9+iJ9//vmFbZYsSVSzZk2lfTk5OSgqKqqwvFB5PvroI/Tv3x9bt27FO++8o/b56mISRUREJBV5xXJtzlfTkiVLtOiwbG3atIG9vb3Kd/F7enrizJkziIuLK5VEaVNe6K233sK3336LkSNH4tSpUwgPD0fDhg3LvatQW3pbbJOIiIiqh+zsbGRkZKh8I5b8hrX9+/eX2qdNeSFjY2NMnDgROTk5WLRoEVq0aKFYY1XWS9sbx5hEERERSUWb5+ZpuyhdTQkJCYiPjy+1vaCgAJMmTUJxcTF69uyptC83NxfXrl1DYmKi0vZ33nkHdnZ2WLJkidLj0u7fv49FixahRo0aePvtt9WOURRFtV7Fxdo9wZnTeURERFLR0ZqoqnDu3Dn0798fnTp1gqenJxwdHfHgwQP89ddfSEpKgpeXV6nn7MbGxiIoKAgBAQFKU3329vZYunQphg4dCl9fXwwaNAhGRkb4+eef8eDBA6xfv16jauXaJkXqUjmJeu2119CiRQssWrSoEsMhIiIyIHpUsdzX1xcTJ07E0aNHsWPHDqSnp8Pa2hpNmjTB+PHjMW7cuDLLCJVnyJAhcHR0RFRUFNasWaPoY+3atejevXslXYVuqZxERUdHo7CwsDJjISIiomrKzc0NCxcuVOucwMBAVFRJqUePHujRo4e2oUmG03lERERS0aORKCqNSRQREZFU9GhNVHXToEEDAECjRo0Ud/nJt6lKEATcvHlT4xiYRBEREZHekd8pWLIGVFl3D1ZE2wcTM4kiIiKSCqfzNHb79m0AUHpen3xbVVEriYqJiYGxsWafmCAIXJhORERUkgDtpuS0G0jRa+7u7iptq0xqJVF8VjERERHRM2olUa+88gq+/fbbyoqFiIjIsHA6T6+plUTZ2dlp9CwbIiIiKgOTKJ0rKCjA6tWr8eeff+LWrVvIzs4udyaNd+cRERERAXj06BFee+01XL58WaUlSLw7j4iISF+xTpRORURE4NKlS3BxccHUqVPRpk0b1K5dG0ZGlfNGMYkiIiKSCqfzdGr37t0wNTXFoUOH0KhRo0rvj0kUERGRVJhE6VRGRga8vLyqJIEC1EiiiouLKzMOIiIiIq00atQIT58+rbL+OJtKREQkFSMdvEhh5MiRiIuLwz///FMl/fHtJyIikooR/jelp8mL3+JKJkyYgJCQEPTt2xe//fZbpffHNVFERET0UujSpQsAICUlBf369YO9vT0aNmwIKyurMo8XBAEHDx7UuD8mUURERFJhiQOdio6OVvo5NTUVqamp5R7POlFERET6infn6dThw4ertD8mUURERPRSqOpH0zGJIiIikgpHovQakygiIiKpcE2UXmMSRURERHonPDwcAODs7Iy5c+cqbVOVIAhYuXKlxjEIoiqPOSaVZWZmws7ODhnHAFtrqaOhSve61AFQVYq5J3UEVBVyAHTHs0eI2NraVkofiu+KHwBbCy3aeQLYja3cWKsr+UOFvb29ceXKFaVtqhIEAUVFRRrHwJEoIiIiqXA6T2OrV68GANjZ2ZXaVlWYRBEREUlFXrFcm/MN1LBhw1TaVpkM+O0nIiIi0hxHooiIiKTCEgd6jUkUERGRVLgmqlJcu3YN+/btw61bt5CdnY3y7qHT9u48JlFERET0UigoKMDo0aOxbt06ACg3eZJjEkVERKSvOJ2nU59++inWrl0LMzMz9OvXD61atUKtWrW0ftBweZhEERERSYVJlE5t2LABRkZG2L9/Pzp37lzp/XE2lYiIiF4Kjx8/RuPGjaskgQI4EkVERCQdLizXqQYNGlRpf3z7iYiIpGKsgxcphIWF4erVq7h48WKV9MckioiIiF4KH3zwAXr37o033ngDu3btqvT+OJ1HREQkFQHaDWdUzk1nesvIyAi//vor+vfvj759+8LBwQENGzaEpaVlmccLgoCDBw9q3B+TKCIiIqnw7jydys7OxltvvYVDhw5BFEU8fvwYjx8/Lvd4bUsfMIkiIiKSip4lUfPnz8ehQ4dw9epVPHr0CJaWlqhfvz7effddjBkzptwRn7JUlMBERUUhIiJC7fhmzJiBgwcPombNmhg9ejR8fHxYJ4qIiIikt2zZMjg6OiI4OBi1a9dGdnY2oqOj8eGHH2LdunU4ceKEWomUu7s7hg8fXmq7v7+/RvH98ssvMDU1xZEjR9C0aVON2lAHkygiIiKp6FmJg6tXr8Lc3LzU9tDQUKxfvx6rV6/GuHHjVG7Pw8MDs2bN0ll8aWlp8Pb2rpIECuDdeURERNLRsxIHZSVQADBgwAAAwI0bN6oynFK8vLzw5MmTKuuPSRQRERFpZc+ePQCA5s2bq3Veeno6VqxYgXnz5mH58uWIi4vTKo7//Oc/uHHjBqKjo7VqR1WcziMiIpKKjhaWZ2ZmKm2WyWSQyWRaNFyxRYsWIT09Henp6YiJicGZM2fQrVs3hIaGqtXOhQsXMGrUKMXPgiBg8ODBWLZsmVprq+RGjhyJa9euoV+/fpg9ezbCwsJgbW2tdjuqYhJFREQkFR2tiXJ1dVXaHBkZqdO1Rs9btGgREhISFD8PGTIEP/zwA0xNTVVuY8qUKXj77bfh6ekJQRBw7tw5fPzxx9iwYQMKCwuxefNmteOSP/YlOzsbkyZNwqRJk1CrVq0K60TdvHlT7X4U54uiKGp8NpWSmZkJOzs7ZBwDbCsv+aXq4nWpA6CqFHNP6gioKuQA6A4gIyMDtra2ldKH4rviL8DWSot2cgC7rkBSUpJSrBWNRDk6OlZYO+l5hw8fRmBgYJn7kpOTcfjwYUydOhW2trbYt28fXFxc1LqGknJzc9GyZUvcuHEDly5dQrNmzdQ638hIvYxUEAQUFRWpdU5JHImqLC0ygEr6j4+qkRssF2xIOm6XOgKqCplPALxXRZ0ZQbvpvP/PGWxtbVVO+EJCQpCVlaVyF05OThXuCwkJQaNGjeDn54cPP/wQP//8s8ptP8/S0hIhISH47LPPEBMTo3YSdfv2bY371gSTKCIiIqlIUOJgyZIlWnRYtjZt2sDe3l4nC7odHR0BPBuVUpe7u7vW/auDd+cRERGRVrKzs5GRkQETE+3HZk6dOgXgWQ2p6o5JFBERkVT0qE5UQkIC4uPjS20vKCjApEmTUFxcjJ49eyrty83NxbVr15CYmKi0/dy5c2WONG3btg2bN2+Go6MjunbtqtP4KwOn84iIiKSiR8/OO3fuHPr3749OnTrB09MTjo6OePDgAf766y8kJSXBy8sLc+fOVTonNjYWQUFBCAgIUJrqW7x4MXbu3IkuXbrAzc0Noiji7NmzOHbsGMzNzbF27doXliZo3rw5PvnkE7zzzjtaPRsvMTER8+bNQ/369TFt2jS1zmUSRUREJBU9euyLr68vJk6ciKNHj2LHjh1IT0+HtbU1mjRpgvHjx2PcuHGwslLtVsM+ffogPT0dZ8+exd69e1FYWIh69ephxIgRmDJlCry9vV/YRlZWFt59913MnDkToaGhGDRoEDw9PVXq/+nTp9izZw82btyIXbt2oaioCMuXL1fp3JJY4kDHFLetVuKtsVSNPOHdeQaFd+cZhMwngN17VVTi4G/tyuFkZgN27So31uoqPz8f3377LebPn4+0tDQIgoCGDRvCz88Pr776KpydneHg4ACZTIb09HSkpqbi6tWrOHPmDM6cOYOcnByIoojg4GB88cUX8PHxUTsGJlE6xiTKwDCJMixMogxClSZRp3WQRLUxzCRKLisrCxs2bMDy5ctx/vx5ACh3ek+e8lhZWWHQoEEYPXo02rRpo3HfnM4jIiKSih6tiaqubGxsMHbsWIwdOxZxcXE4evQoTpw4gYSEBDx69Ah5eXlwcHBA7dq14ePjA39/f3To0EGjx8o8j0kUERERvRQ8PT3h6emJESNGVEl/TKKIiIikIkC7xeFcUSApJlFERERS4XSeXmMSRURERHrv4cOH+O2333Dq1CnExcUhLS0NT548gYWFBezt7eHp6Ym2bduid+/eqF27tk76ZBJFREQkFT2qE1Vd5eXlYerUqfjpp59QUFCA8ooOHD16FKtWrcL48eMxatQoLFiwABYWFlr1zSSKiIhIKpzO00p+fj4CAwNx+vRpiKIIb29vdOzYEQ0aNIC9vT1kMhny8/ORlpaGW7duISYmBteuXcP333+P2NhYHDt2DGZmZhr3zySKiIiI9NKXX36J2NhYeHl5YdWqVWjfvv0Lzzlx4gTCw8Nx5swZLFiwADNnztS4fw4EEhERSUWPHkBcHW3evBlmZmbYv3+/SgkUAHTo0AH79u2DiYkJNm3apFX/HIkiIiKSCtdEaeX27dto3rw5XF1d1TrP3d0dzZs3x9WrV7Xqn0kUERGRVLgmSivW1tZISUnR6NyUlBSVH5hcHgPPYYmIiEhftW/fHnfv3sXChQvVOu+rr77C3bt30aFDB636ZxJFREQkFSNotx7KwL/FIyIiYGRkhI8++gi9evXC9u3bcf/+/TKPvX//PrZv346ePXti2rRpMDY2xvTp07Xqn9N5REREUuGaKK20b98ea9aswciRI7F3717s27cPACCTyVCjRg2YmZnh6dOnSE9PR35+PgBAFEWYmZlh+fLlaNeunVb9G/jbT0RERPps8ODBuHbtGsaOHQsnJyeIooi8vDwkJycjMTERycnJyMvLgyiKqFOnDsaOHYtr165h6NChWvfNkSgiIiKpcGG5Tri7u+O7777Dd999h8TERMVjX/Ly8mBubq547Iubm5tO+2USRUREJBVO5+mcm5ubzpOl8vDtJyIiItIAR6KIiIikwuk8ydy9exdFRUVajVoxiSIiIpIKkyjJ+Pj4IC0tDYWFhRq3wek8IiIiMkiiKGp1PkeiiIiIpMKF5XqNSRQREZFUBCNAELQ4XwRQrLNw9M28efM0PvfJkyda988kioiISDImALRIoiACeKqjWPTPzJkzIWiYhIqiqPG5ckyiiIiISC8ZGxujuLgY/fr1g7W1tVrnbtmyBU+fapeAMokiIiKSDEeitNGsWTNcvHgRo0aNQrdu3dQ6d/fu3UhNTdWqfy5JIyIikoyJDl6Gy8/PDwBw5swZSfpnEkVERER6yc/PD6Io4tSpU2qfq215A8DQU1giIiJJGUO78QzDvTMPALp27YqJEyfC0dFR7XN///13FBQUaNU/kygiIiLJmIBJlOY8PDzwzTffaHRuhw4dtO6f03lEREREGuBIFBERkWQ4EqXPmEQRERFJhkmUPmMSRURERC8FY2NjlY81MjKCjY0NPDw84O/vj5EjR6JFixZq9cc1UURERJIx1sGL5ERRVPlVVFSE9PR0nD9/HkuXLsWrr76KL7/8Uq3+mEQRERFJxhjaFdpkElVScXExFi5cCJlMhmHDhiE6OhqpqakoKChAamoqjhw5guHDh0Mmk2HhwoXIzs7GmTNn8J///AeiKCIiIgIHDx5UuT8mUURERJLR74rlf//9N4yNjSEIAubPn6/2+fv27UNgYCBsbW1hY2ODwMBA7Nu3T+N4fvnlF3z44YdYuHAhVq9ejc6dO6NGjRowNjZGjRo10KlTJ6xatQoLFy7Ehx9+iD179sDX1xdLly7FggULIIoili5dqnJ/TKKIiIhIbU+ePMHw4cNhYWGh0fkbN25Ejx49cPnyZQwbNgxhYWG4du0aevTogY0bN2rU5ldffQVnZ2eMHTu2wuPGjh0LZ2dnfP3114ptEyZMgK2tLf7++2+V+2MSRUREJBn9HYmaMWMG7t+/j4iICLXPTUtLw/jx4+Ho6IizZ89iyZIl+Pbbb3Hu3Dk4OTlh/PjxSEtLU7vdS5cuoV69eiodW69ePVy5ckXxs4mJCRo3bqzWQ4mZRBEREUlGP5OomJgYLF68GF999RVcXFzUPn/btm1IT0/H+++/D1dXV8V2Z2dnTJo0Cenp6di2bZva7ZqamuL69evIz8+v8Lj8/Hxcv34dJibK719mZiZsbGxU7o9JFBEREaksNzcXw4cPR2BgIEaNGqVRG9HR0QCAbt26ldrXvXt3AMCRI0fUbrdjx47IzMzE+PHjUVxcdg0tURTx/vvvIyMjA/7+/ortT58+xe3bt1G3bl2V+2OdKCIiIsloe4edAODZCEpJMpkMMplMi3bLFxERgfv372P//v0atxEXFwcA8PT0LLVPvk1+jDrmzJmDv/76C6tWrcKJEycwdOhQtGjRAjY2NsjOzsZ///tfbNiwAVeuXIFMJsOcOXMU5+7YsQMFBQUICgpSuT8mUURERJKRlzjQTskpMQCIjIzErFmztG73eUeOHMHSpUuxaNEi1K9fX+N2MjIyAAB2dnal9llZWcHY2FhxjDpatWqFXbt2YejQobh69SpmzJhR6hhRFOHk5IT169fDx8dHsb1OnTpYvXo1OnXqpHJ/TKKIiIj0XFJSEmxtbRU/VzQK5ejoiMePH6vc9uHDhxEYGIicnByEh4ejffv2GD9+vFbxVqauXbsiLi4OmzZtwoEDBxAXF4ecnBxYWVmhcePGCA4ORkhICKytrZXOCwwMVLsvJlFERESS0c3icFtbW6UkqiIhISHIyspSuW0nJycAz+7Gu3fvHv744w8YGWm3pFo+ApWRkYGaNWsq7cvJyUFRUVGZo1Sqsra2xujRozF69Git4nwRJlFERESSqfo77JYsWaLReefPn0deXh68vb3L3D99+nRMnz4dEydOxKJFiypsy9PTE2fOnEFcXFypJKqi9VLVDZMoIiIieqHXX38djRo1KrU9Li4OR48eRZs2bdCiRQu0b9/+hW0FBARg8+bN2L9/P9q1a6e0T16xPCAgQKt4b9++jQMHDuD69evIysqCjY2NYjpPm/VcJQmiKIo6aYkAPLtDws7ODhkZGSoPrZIeeyJIHQFVpe1SB0BVIfMJYPceKvXf8f99V7wGW1vNxzMyMwthZ3dI0u+cNWvWICwsDFFRUaUKb+bm5iIxMRGWlpZwc3NTbE9LS0P9+vVhamqKs2fPKhbG379/H76+vsjLy8OtW7dgb2+vdjxpaWn4z3/+g23btkGe4oiiCEF49u+1IAgYOHAgli5dqlH7JVX7OlFr1qyBIAgVvrp06aJ0TmZmJiZPngx3d3fIZDK4u7tj8uTJpW4BLWnTpk3w8/ODlZUV7O3t0atXL5w5c6ayL4+IiAzay/0A4tjYWDRp0gShoaFK2+3t7bF06VI8evQIvr6+eP/99zFx4kS0atUKycnJWLJkiUYJzpMnT9ClSxds3boVxcXFaNeuHUaMGIEZM2ZgxIgRaNeuHYqLi7FlyxZ07doVeXl5Wl1ftZ/O8/HxQWRkZJn7tm/fjsuXLysKcwHPFqQFBATg/PnzihX4Fy5cwDfffIPDhw/j+PHjsLKyUmpn3rx5mDFjBtzc3DBmzBhkZ2djy5Yt6Nixo+LhiERERLqn7Zoo/Z1MGjJkCBwdHREVFYU1a9YAAHx9fbF27Vql73V1fPPNNzh//jy8vb2xbt06tG7dutQxZ86cwbBhw3D+/HksWrRIo8fWyOntdN7Tp09Rt25dZGRk4M6dO6hTpw6AZ7Ux5syZg6lTp+KLL75QHC/f/umnn2L27NmK7XFxcWjatCkaNGiA2NhYxd0Aly9fhp+fH5ydnXHt2rVSpeHLw+k8A8PpPMPC6TyDULXTeT1ha2uqRTsFsLP7k985/8/HxweXL1/Gv//+iwYNGpR73M2bN+Ht7Y1mzZrh/PnzGvdX7afzyrNjxw48fvwYb7zxhiKBEkURK1asgLW1NT799FOl46dPnw57e3usXLkSJfPG1atXo7CwEDNmzFC6nbJZs2YIDQ3FzZs3cejQoaq5KCIiMjD6+ey86urGjRto3rx5hQkUADRs2BDNmzfHjRs3tOpPb5OolStXAgBGjhyp2BYXF4d79+6hY8eOpabszM3N0blzZ9y9e1fpTaus5/cQERG9GJMoXTI2NkZBQYFKxxYUFGhd70ovk6iEhAQcPHgQ9erVQ48ePRTbX1Rboqzn8cTFxcHa2lpRTOxFxz8vPz8fmZmZSi8iIiKqel5eXrh69SouXLhQ4XHnz5/HlStX0KRJE63608skavXq1SguLkZYWBiMjf93Z0JFz+IBoJgvLvk8noyMDLWOf15UVBTs7OwUr+efX0RERFQ+jkTp0tChQyGKIt544w3s2rWrzGN+//139O7dG4IgYOjQoVr1p3fvfnFxMVavXg1BEBAeHi51OJg+fTomT56s+DkzM5OJFBERqUjbBxAX6yqQl8LYsWOxc+dOHD58GH379oWbmxu8vb1Ru3ZtpKSk4OrVq0hKSoIoinjttdcwduxYrfrTuyTqwIEDSExMRJcuXUpVHC35LJ6yyKfaSo48ye+kU/X458lksgof9EhERERVw8TEBHv27MHMmTPx448/IiEhAQkJCUrHWFpaYuzYsfjss8+UZrM06k+rsyVQ1oJyuRetYSprzZSnpydOnjyJ5OTkUuui9On5PUREpI+MoV3BzOpdbFMK5ubm+OqrrxAZGYnjx4/j+vXryM7OhrW1NRo3bgx/f3/Y2NjopC+9SqIeP36M3377DQ4ODnjrrbdK7ff09ETdunURExODnJwcpTv08vLycPToUdStW1fp2T8BAQE4efIk9u/fX6qiqq6e30NERFQ2bdc1cTqvPDY2NujZsyd69uxZaX3o1cLy9evX4+nTpxgyZEiZU2iCIGDkyJHIzs7GnDlzlPZFRUUhLS0NI0eOVDw/BwDCwsJgYmKCuXPnKk3rXb58GevWrUPDhg3x2muvVd5FERERkV7Sq5Goiqby5KZOnYrff/8dCxYswLlz5/Dqq6/iwoUL+PPPP+Hj44OpU6cqHd+4cWPMmjULM2fORIsWLTBgwADk5ORg8+bNKCgowPLly1WuVk5ERKQejkRpKjExUSftlHwwsrr0JjuIjY3FpUuX4Ofnh1deeaXc46ysrBAdHY3Zs2dj+/btiI6OhpOTEz744ANERkaWKsIJADNmzICHhwcWLVqEH374AWZmZujQoQPmzJmDNm3aVOZlERGRQWMSpSkPDw+lmSVNCIKAwsJCzc/X12fnVVd8dp6B4bPzDAufnWcQqvbZef+Bra3md3hnZubDzu57g/zO0UUSBQC3b9/W+Fy9GYkiIiIikouPj5c6BCZRRERE0tF2Oq9IV4GQBphEERERSYZJlD7TqxIHRERERNUFR6KIiIgkw5EofcYkioiISDLaPoBY89vzSXucziMiIiLSAEeiiIiIJKPtdB6/xqXEd5+IiEgyTKL0GafziIiIiDTAFJaIiEgyHInSZ3z3iYiIJMMkSp/x3SciIpKMtiUOjHUVCGmAa6KIiIiINMCRKCIiIslwOk+f8d0nIiKSDJMofcbpPCIiIiINMIUlIiKSjDG0WxzOheVSYhJFREQkGd6dp884nUdERESkAY5EERERSYYLy/UZ330iIiLJMInSZ5zOIyIiItIAU1giIiLJcCRKn/HdJyIikgyTKH3G6TwiIiLJyEscaPqStsTB33//DWNjYwiCgPnz56t1riAI5b7UbUsqTGGJiIhIbU+ePMHw4cNhYWGBnJwcjdpwd3fH8OHDS2339/fXMrqqwSSKiIhIMvo7nTdjxgzcv38fERER+OSTTzRqw8PDA7NmzdJtYFWISRQREZFk9DOJiomJweLFi/Hjjz/C1NRUkhiqAyZRREREpLLc3FwMHz4cgYGBGDVqFNasWaNxW+np6VixYgVSUlJQq1YtBAYGwtPTU3fBVjImUURERJLRzUhUZmam0laZTAaZTKZFu+WLiIjA/fv3sX//fq3bunDhAkaNGqX4WRAEDB48GMuWLYOlpaXW7Vc23p1HREQkGW3uzPtfAubq6go7OzvFKyoqqlKiPXLkCJYuXYp58+ahfv36WrU1ZcoUnDp1CqmpqUhLS8OhQ4fQtm1bbNiwASNGjNBRxJWLI1FERER6LikpCba2toqfKxqFcnR0xOPHj1Vu+/DhwwgMDEROTg7Cw8PRvn17jB8/Xqt4AeDLL79U+jkoKAgHDx5Ey5YtsWXLFsycORPNmjXTup/KxCSKiIhIMvI6UdqcD9ja2iolURUJCQlBVlaWyj04OTkBeHY33r179/DHH3/AyKhyJrIsLS0REhKCzz77DDExMUyiiIiIqDxVf3fekiVLNOrp/PnzyMvLg7e3d5n7p0+fjunTp2PixIlYtGiRRn0Az0bKgGcL2Ks7JlFERESS0Z8SB6+//joaNWpUantcXByOHj2KNm3aoEWLFmjfvr1W/Zw6dQrAsxpS1R2TKCIiInqhjz76qMzta9aswdGjR9GvXz9EREQo7cvNzUViYiIsLS3h5uam2H7u3Dl4eXmVugNv27Zt2Lx5MxwdHdG1a1fdX4SOMYkiIiKSjP6MRGkiNjYWQUFBCAgIQHR0tGL74sWLsXPnTnTp0gVubm4QRRFnz57FsWPHYG5ujrVr18La2lq6wFVUvd99IiKil5puFpbrmz59+iA9PR1nz57F3r17UVhYiHr16mHEiBGYMmVKueuuqhtBFEVR6iBeJpmZmbCzs0NGRobKd0qQHnsiSB0BVaXtUgdAVSHzCWD3Hir13/H/fVecg62tjRbtZMHOrhW/cyTCkSgiIiLJGEO70ST9HIl6WTCJIiIikszLvSbqZcfHvhARERFpgCksERGRZDgSpc/47hMREUmGSZQ+43QeERERkQaYwhIREUnGMOtEvSyYRBEREUmG03n6jO8+ERGRZJhE6TOuiSIiIiLSAFNYIiIiyXAkSp/x3SciIpIMkyh9xndfx+TPc87MzJQ4EqoST6QOgKoUP2+DkPn/n7P83/NK7UvL7wp+10iLSZSOZWVlAQBcXV0ljoSIiLSRlZUFOzu7SmnbzMwMTk5OOvmucHJygpmZmQ6iInUJYlWk2gakuLgY9+7dg42NDQRBkDqcKpOZmQlXV1ckJSXB1tZW6nCoEvGzNhyG+lmLooisrCzUrVsXRkaVd/9VXl4enj59qnU7ZmZmMDc310FEpC6OROmYkZERXFxcpA5DMra2tgb1j60h42dtOAzxs66sEaiSzM3NmfzoOZY4ICIiItIAkygiIiIiDTCJIp2QyWSIjIyETCaTOhSqZPysDQc/a6KKcWE5ERERkQY4EkVERESkASZRRERERBpgEkVERESkASZRRERERBpgEkUa27BhA9577z20bt0aMpkMgiBgzZo1UodFOpaeno4JEyagffv2cHJygkwmQ7169fDaa6/hl19+qZLni1HV8vDwgCAIZb7GjBkjdXhE1QYrlpPGZs6ciYSEBDg6OsLZ2RkJCQlSh0SV4NGjR1i1ahXatWuHvn37wsHBASkpKdi1axcGDBiAUaNG4aeffpI6TNIxOzs7TJo0qdT21q1bV30wRNUUSxyQxv766y94enrC3d0d8+fPx/Tp07F69WoMHz5c6tBIh4qKiiCKIkxMlP/mysrKQrt27XDlyhVcunQJzZo1kyhC0jUPDw8AQHx8vKRxEFV3nM4jjXXt2hXu7u5Sh0GVzNjYuFQCBQA2Njbo3r07AODGjRtVHRYRkeQ4nUdEGsnLy8OhQ4cgCAKaNm0qdTikY/n5+Vi7di3u3r0Le3t7dOjQAS1btpQ6LKJqhUkUEakkPT0dixYtQnFxMVJSUvDHH38gKSkJkZGR8PT0lDo80rHk5ORSU/M9evTA+vXr4ejoKE1QRNUMkygiUkl6ejpmz56t+NnU1BRffvklPvzwQwmjosoQHh6OgIAANGvWDDKZDFeuXMHs2bPx559/onfv3oiJiYEgCFKHSSQ5rokiIpV4eHhAFEUUFhbi9u3bmDNnDmbMmIH+/fujsLBQ6vBIhz799FMEBATA0dERNjY2aNu2LXbv3g1/f3+cPHkSf/zxh9QhElULTKKISC3Gxsbw8PBAREQEPv/8c+zYsQPLly+XOiyqZEZGRggLCwMAxMTESBwNUfXAJIqINNatWzcAQHR0tLSBUJWQr4XKzc2VOBKi6oFJFBFp7N69ewBQZgkEevmcOnUKwP/qSBEZOiZRRFSh8+fPIyMjo9T21NRUfPzxxwCAnj17VnVYVEmuXLmC9PT0UtuPHz+OhQsXQiaToV+/flUfGFE1xD8fSWMrVqzA8ePHAQAXL15UbJNP7fTt2xd9+/aVKDrSlTVr1mDFihUICgqCu7s7rKyskJCQgD179iA7Oxv9+/fHu+++K3WYpCNbt27FggUL0KVLF3h4eEAmk+HSpUvYv38/jIyM8OOPP8LNzU3qMImqBSZRpLHjx49j7dq1SttiYmIUi049PDyYRL0EBgwYgIyMDPz99984evQocnNz4eDgAH9/f4SGhmLQoEG83f0lEhQUhKtXr+Ls2bM4cuQI8vLyUKdOHQwcOBAffPAB/Pz8pA6RqNrgs/OIiIiINMA1UUREREQaYBJFREREpAEmUUREREQaYBJFREREpAEmUUREREQaYBJFREREpAEmUUREREQaYBJFREREpAEmUUREREQaYBJFREREpAEmUUSkE/Hx8RAEQek1a9asSu3Tx8dHqb/AwMBK7Y+IqCQmUUR6JCYmBqNHj4a3tzfs7Owgk8lQr149vPHGG1ixYgVycnKkDhEymQwdO3ZEx44d4ebmVmq/h4eHIun58MMPK2xr8eLFSknS81q1aoWOHTuiefPmOoufiEhVfAAxkR7Izc1FWFgYtm7dCgAwNzdHw4YNYWFhgbt37+L+/fsAAGdnZ+zbtw+vvPJKlccYHx+P+vXrw93dHfHx8eUe5+HhgYSEBACAk5MT7ty5A2Nj4zKPbdOmDc6cOaP4ubx/rqKjoxEUFISAgABER0drfA1EROrgSBRRNVdQUIBu3bph69atcHJywtq1a5GamopLly7h9OnTuHfvHi5fvoz33nsPDx8+xM2bN6UOWSVeXl5ITk7GX3/9Veb+f//9F2fOnIGXl1cVR0ZEpBomUUTV3OzZsxETE4M6derg5MmTCA0NhYWFhdIxTZs2xY8//ojDhw+jdu3aEkWqniFDhgAANmzYUOb+9evXAwCGDh1aZTEREamDSRRRNZaRkYFvv/0WALBo0SJ4eHhUeLy/vz86dOhQBZFpLyAgAK6urtixY0eptVyiKGLjxo2wsLBAv379JIqQiKhiTKKIqrE9e/YgKysLtWrVwoABA6QOR6cEQcDgwYORk5ODHTt2KO07fvw44uPj0bdvX9jY2EgUIRFRxZhEEVVjJ06cAAB07NgRJiYmEkeje/KpOvnUnRyn8ohIHzCJIqrG7t69CwCoX7++xJFUjqZNm6JVq1Y4ePCg4g7D/Px8bNu2DbVr10ZwcLDEERIRlY9JFFE1lpWVBQCwsrLSqp3g4GAIglBqxKek+Ph49OnTBzY2NrC3t8fQoUPx6NEjrfpVxdChQ1FUVITNmzcDAHbv3o309HSEhIS8lKNvRPTyYBJFVI3J1wNpU0Tz/v37OHToEIDy74TLzs5GUFAQ7t69i82bN+Onn37CiRMn8Prrr6O4uFjjvlUREhICY2NjRYIn/1/53XtERNUV/8wjqsbq1asHALh9+7bGbWzatAnFxcUIDg7GwYMHkZycDCcnJ6Vjli1bhvv37+PEiRNwdnYG8Kwopp+fH3777Te89dZbml/ECzg5OaFr167Yt28fjh49ij///BPe3t5o3bp1pfVJRKQLHIkiqsbk5QpOnDiBwsJCjdpYv349WrRogfnz5ytNm5W0e/duBAUFKRIo4Fm18MaNG2PXrl2aBa8G+QLyoUOH4unTp1xQTkR6gUkUUTXWq1cvWFtbIyUlBdu3b1f7/MuXL+PChQsYPHgwfH190bRp0zKn9K5cuYJmzZqV2t6sWTNcvXpVo9jV8dZbb8Ha2hqJiYmK0gdERNUdkyiiaqxGjRp4//33AQCTJk2q8Jl0wLMHFMvLIgDPRqEEQcC7774L4Nk6o7Nnz5ZKjNLS0lCjRo1S7Tk4OCA1NVW7i1CBpaUlPvzwQ3Tp0gXvvfce3N3dK71PIiJtMYkiquZmzZqF9u3b48GDB2jfvj3Wr1+PvLw8pWOuX7+OcePGITAwECkpKQCeVf3etGkTAgIC4OLiAgAYPHgwBEEoczRKEIRS26ry+eSzZs3CX3/9hR9++KHK+iQi0gaTKKJqzszMDPv370f//v2RnJyM0NBQODg44JVXXoGfnx9cXFzg5eWF77//Hk5OTmjUqBEAIDo6GklJSejTpw/S09ORnp4OW1tbtG3bFhs3blRKkOzt7ZGWllaq77S0NDg4OFTZtRIR6RMmUUR6wNraGtu3b8fRo0cxYsQIuLq6Ij4+HhcuXIAoinj99dexcuVKXL9+Hc2bNwfwv3IGH3zwAezt7RWvv//+GwkJCTh+/Lii/WbNmuHKlSul+r1y5QqaNGlSNRdJRKRnWOKASI906tQJnTp1euFxeXl52L59O3r06IFp06Yp7SsoKEDv3r2xYcMGRVtvvPEGZsyYoVT+4J9//sG///6LqKgonV7Di9Z1Pc/FxaVKpxWJiFQliPzXieils3XrVgwcOBC7d+/G66+/Xmr/wIEDceDAASQnJ8PMzAxZWVlo0aIFatWqhcjISOTl5WHatGmoWbMmTp48CSOjFw9ax8fHo379+pDJZIoaT+Hh4QgPD9f59cmFhYUhLi4OGRkZuHTpEgICAhAdHV1p/RERlcTpPKKX0IYNG+Dk5IQePXqUuT8sLAxpaWnYs2cPgGeV0Q8dOgQnJycMHDgQI0aMQLt27bB7926VEqiS8vPzERMTg5iYGCQmJmp9LRU5d+4cYmJicOnSpUrth4ioLByJIiIiItIAR6KIiIiINMAkioiIiEgDTKKIiIiINMAkioiIiEgDTKKIiIiINMAkioiIiEgDTKKIiIiINMAkioiIiEgDTKKIiIiINMAkioiIiEgDTKKIiIiINMAkioiIiEgD/weENF/62WMhtgAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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hdb9ym455XFVGVuCiIiIqrn09HS1MUHaWoGAx9PLgcczrr7//ns0aNAAANC9e3fs3LkTfn5++OijjzBt2rSKD/r/NWnSxOgyZDIZ/vzzT73P0ykJMkWATzI0WCIiohrFRGOCHBwcdBoYrWy98ff3VyVASq1bt0aTJk2QmpqKzMzMUgcfK8soraVH2TVXWkvR065evarTcdrIZDIIISCTGfYQNZ2SIGMC1MbQYImIiGoUE40J0lXz5s0BoNQER7n90aNHpR5ja2uL+vXr48qVKyguLtYYF6QcC6QcG1SeK1euaN3+9ddf49///jdatmyJ119/HS1btoSrqyvu3r2LixcvYtWqVbh48SL+85//YOTIkTrV9TSdu8M6duyIHTt2GFTJk1566SX8+uuvRpdDRERE+unVqxcA4OLFixr7CgsLkZqaCltbW9StW7fMcoKCgrB9+3YkJiZqTJGPiYlRHaMLT09PjW0///wzFixYgJkzZ2oM1Pbx8UG3bt3w2muvYe7cuXjnnXfQvn17reWUR+ckSC6XG1SBtnKIiIgIxq8YrWdLkLe3N/r27YsDBw5g7dq1CAkJUe17//33kZmZibFjx6rWD8rIyEBGRgZcXFzg4uKiOnbKlCnYvn07Fi5ciJ9//hnW1tYAHi+WGBMTgx49ehg1Pf69996Do6MjPvjggzKPW7p0KTZs2ID33nsPffr00bsenZKgIUOGwNfXV+/CtenevbvajSQiIjJbxo4JMmC541WrVqFr16547bXX8P3336NFixb47bffcPjwYXh6emL58uWqY6OiohAZGYnw8HBERESotvfq1QshISFYu3Yt2rVrh4EDB6oem+Hg4FDuDLPynDlzBs2bN9c6Bf9JVlZW8Pb2NriHSack6PvvvzeocG3ee+89k5VFRERE+vH29sbp06exaNEi7N+/HwcOHICbmxveeOMNLFq0CPXq1dOpnDVr1sDPzw9r1qzBypUrYWdnh8GDB+Pdd981epFEIQSuXLmCkpISWFiUPmiquLgYV65cgaEPv6i0x2YkJycbfVOqAz42w7zwsRlmho/NMAuV+tiMzoCDEYvVZBcBihMVG6sUnnvuORw5cgTz58/HkiVLSj1u0aJFWLJkCXr37o2ff/5Z73p0HpNe3gqSZfn99991HiBFRERkNky0WGJN8+9//xsymQxLly5Fly5dsGnT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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# This problem has two degrees of freedom. Fixing dimensions are not necessary for drawing a heatmap\n", - "\n", - "fixed = {}\n", - "all_fim.figure_drawing(\n", - " fixed, [\"CA0[0]\", \"T[0]\"], \"Reactor case\", \"$C_{A0}$ [M]\", \"T [K]\"\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Grid Search for 3 Design Variables" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": {}, - "outputs": [], - "source": [ - "# Define design ranges\n", - "design_ranges = {\n", - " \"CA0[0]\": list(np.linspace(1, 5, 2)),\n", - " \"T[0]\": list(np.linspace(300, 700, 2)),\n", - " (\n", - " \"T[0.125]\",\n", - " \"T[0.25]\",\n", - " \"T[0.375]\",\n", - " \"T[0.5]\",\n", - " \"T[0.625]\",\n", - " \"T[0.75]\",\n", - " \"T[0.875]\",\n", - " \"T[1]\",\n", - " ): [300, 500],\n", - "}\n", - "\n", - "# Choose from 'sequential_finite', 'direct_kaug'\n", - "sensi_opt = \"direct_kaug\"" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO: =======Iteration Number: 1 =====\n", - "INFO: elapsed time: 0.8\n", - "INFO: This is run 1 out of 8.\n", - "INFO: The code has run 0.8139118879998932 seconds.\n", - "INFO: Estimated remaining time: 2.4417356639996797 seconds\n", - "INFO: =======Iteration Number: 2 =====\n", - "INFO: elapsed time: 0.8\n", - "INFO: This is run 2 out of 8.\n", - "INFO: The code has run 1.6158038199992006 seconds.\n", - "INFO: Estimated remaining time: 2.693006366665334 seconds\n", - "INFO: =======Iteration Number: 3 =====\n", - "INFO: elapsed time: 0.6\n", - "INFO: This is run 3 out of 8.\n", - "INFO: The code has run 2.2149686929988093 seconds.\n", - "INFO: Estimated remaining time: 2.2149686929988093 seconds\n", - "INFO: =======Iteration Number: 4 =====\n", - "INFO: elapsed time: 1.0\n", - "INFO: This is run 4 out of 8.\n", - "INFO: The code has run 3.1933937759986293 seconds.\n", - "INFO: Estimated remaining time: 1.9160362655991774 seconds\n", - "INFO: =======Iteration Number: 5 =====\n", - "INFO: elapsed time: 0.6\n", - "INFO: This is run 5 out of 8.\n", - "INFO: The code has run 3.7590698399981193 seconds.\n", - "INFO: Estimated remaining time: 1.253023279999373 seconds\n", - "INFO: =======Iteration Number: 6 =====\n", - "INFO: elapsed time: 0.8\n", - "INFO: This is run 6 out of 8.\n", - "INFO: The code has run 4.590044279998438 seconds.\n", - "INFO: Estimated remaining time: 0.6557206114283483 seconds\n", - "INFO: =======Iteration Number: 7 =====\n", - "INFO: elapsed time: 0.7\n", - "INFO: This is run 7 out of 8.\n", - "INFO: The code has run 5.270455575998312 seconds.\n", - "INFO: Estimated remaining time: 0.0 seconds\n", - "INFO: =======Iteration Number: 8 =====\n", - "INFO: elapsed time: 0.7\n", - "INFO: This is run 8 out of 8.\n", - "INFO: The code has run 5.959630374997687 seconds.\n", - "INFO: Estimated remaining time: -0.6621811527775208 seconds\n", - "INFO: Overall wall clock time [s]: 5.959630374997687\n" - ] - } - ], - "source": [ - "# Create doe_object using DesignOfExperiments\n", - "doe_object = DesignOfExperiments(\n", - " parameter_dict, # dictionary of parameters\n", - " design_gen, # design variable\n", - " measure_class, # measurement variable\n", - " create_model, # model\n", - " prior_FIM=prior_pass, # FIM of prior experiments\n", - " discretize_model=disc_for_measure, # discretized model\n", - ")\n", - "\n", - "# Run grid search\n", - "all_fim = doe_object.run_grid_search(\n", - " design_ranges, # range of design variables\n", - " mode=sensi_opt, # solver option for sensitivity\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Draw 1D Sensitivity Curve" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": { - "scrolled": true - }, - "outputs": [], - "source": [ - "# FIM criteria\n", - "test = all_fim.extract_criteria()" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAn4AAAHZCAYAAAAYITarAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAACImUlEQVR4nOzdd1gU1/oH8O8sZelFwAIiYg9YECsK1th7iVGjJtYUo8YSoyYRzdV4b+qNmmLUGKOiJlETuybBht2o2AUVOyIovcO+vz/8sdfN0ttSvp/n2Uc9c+acd2YW52XOzBlFRAREREREVOGpDB0AEREREZUOJn5ERERElQQTPyIiIqJKgokfERERUSXBxI+IiIiokmDiR0RERFRJMPEjIiIiqiSY+BERERFVEkz8iIiIiCoJJn5EREQ5UBQFiqIYOoxcLViwAIqiYMGCBTrlBw8ehKIo6NSpk0HiorKJiR9RGVa7dm3tiSfrY2ZmBnd3d4waNQqnT582dIgFFhMTgwULFuC///2voUOhYtKkSRMoigJzc3PExcUZOpx8+/HHH7FgwQLcvn3b0KGUugULFuglilQ5MPEjKgfq16+P9u3bo3379qhfvz4ePXqEDRs2wMfHB+vWrTN0eAUSExODhQsXMvGrIM6fP49Lly4BAFJSUvDrr78aOKL8+/HHH7Fw4cJcE7+GDRuiYcOGpRdUMbKwsEDDhg1Rq1YtvWULFy7EwoULDRAVGRoTP6JyYN68eQgKCkJQUBAuXryIhw8fYujQocjMzMTkyZMRHR1t6BCpksr6xcPOzk7n3xXFtWvXcO3aNUOHUSitW7fGtWvX8NNPPxk6FCpDmPgRlUP29vZYvXo1LC0tER8fj/379xs6JKqEMjMzsXHjRgDA8uXLYWRkhEOHDuHu3bsGjoyIcsLEj6icsrGxQYMGDQAgx6Gqffv2oX///qhWrRrUajVq1qyJsWPH4ubNm9nWP3HiBGbPno2WLVuiatWqUKvVcHV1xejRo3H58uVc47l+/TomTZqEevXqwdzcHA4ODmjRogX8/f0RHh4OAHjttdfg7u4OALhz547e/Yv/tGvXLvTs2ROOjo5Qq9Vwd3fHW2+9hXv37mUbQ9Y9kbdv38aBAwfQq1cvODo6QlEUHDx4MNf4C7otWf744w+8/fbbaNasGapUqQIzMzPUrVsXb775Zo4JUEZGBr766iu0bt0a1tbWUKvVcHZ2Rrt27eDv74+YmJhs1/nuu+/g6+sLOzs7mJmZoVGjRvjggw8Mdl/dn3/+ifDwcFSvXh3Dhw9Hly5dICLYsGFDodsUEaxfvx4dO3aEnZ0dzM3N0ahRI7z33nt4+vRptus8//0JCAhA69atYWVlhSpVqmDgwIHaoegsWQ89HDp0CADQuXNnne/hjz/+mG3bz3v+u3bo0CG8+OKLsLOzQ5UqVTBo0CCEhoZq627fvh1+fn6wsbGBvb09RowYgYcPH2a7LYX5PuUku4c7sh4E+ef2ZX1u376NOXPmQFEUTJkyJce2z5w5A0VRUKNGDWRmZhYoLjIwIaIyy83NTQDImjVrsl3esGFDASBLly7VWzZt2jQBIACkatWq0rx5c7GxsREAYmNjI0ePHtVbp27dugJAHBwcpHHjxtKsWTOxtbUVAGJubi4HDhzINo7169eLqamptp63t7c0atRI1Gq1TvyLFy+Wli1bCgBRq9XSvn17nc/z5syZo42/Zs2a0qJFC7GwsBAAYm9vL6dPn85xf3388ceiUqnE3t5eWrVqJTVr1swx9sJuSxYjIyNRFEWqVq0qXl5e0rhxY7G0tNTux8uXL+v1MWTIEO221a1bV1q1aiWurq5iZGQkAOTcuXM69WNjY6VDhw4CQFQqlbi5uUnjxo21cb7wwgsSERGRr+0rTiNHjhQAMm3aNBER+fHHH7XxFIZGo9G2CUDq1Kkj3t7e2u10c3OTmzdv6q2XVf8///mPAJDq1atLy5YtxdraWnscjxw5oq1/9uxZad++vfbnoXHjxjrfw927d+u1/U9Z37UvvvhCjIyMpGrVquLt7a099jVq1JDw8HD54osvtN/hZs2aab9HDRs2lOTkZL12C/N98vf3FwDi7++vU37gwAEBIB07dtSWrV69Wtq3b6/drn/+DIaHh8v169e1/aWmpmZ7rN5++20BILNmzcp2OZVdTPyIyrDcEr+QkBAxNjYWAHL48GGdZd99950AEHd3d52EJyMjQxYtWqQ9Ef3zxLN27Vq9E2t6erqsWrVKjI2NpU6dOpKZmamz/PTp02JiYiIAZPbs2ZKQkKBdlpaWJhs3btQ56YaFhWlP4jnZsWOHABBjY2NZv369tjw2NlYGDRokAKR27dqSlJSU7f4yMjKShQsXSnp6uog8SyhSUlJy7K+w2yIismLFCnnw4IFOWVJSkixevFgASKdOnXSWnTlzRgCIq6urXLlyRWdZbGysrFy5Uu7evatTPnz4cAEgXbt21Tk+T58+lcGDBwsAGTp0aJ7bV5zi4+O1ifipU6dERCQuLk7Mzc0FgJw5c6bAbS5btkwAiLW1tezfv19bHh4erk1W2rRpo7deVhJjYmIin3/+ufY7mpiYKK+88or2+/bP70vHjh0FQK6/FOSV+P2zz+joaGnbtq0AkD59+oiFhYVs2LBBu97du3elTp06AkC++eYbvXYL+n0SKVjil9d2Zcna31u3btVblpaWJg4ODgJALl26lGMbVDYx8aMSERUVJStWrJB+/fqJu7u7mJqaioODg/Ts2VP27t1b4PaePn0qM2fOlLp164qpqak4OjrKkCFD8vxP58CBA9K/f39xcnISU1NTqVmzpgwcOFDOnz+vraPRaGT37t3yxhtvSJMmTcTGxkbMzc2ladOmsnjx4mx/Ky8t2SV+sbGx8scff4iHh4f2N/bnpaamSvXq1cXIyEjOnj2bbbtZV5x++umnfMcyatQoAaB3pbB3794CQMaNG5evdvKT+GWddLKuJD0vMTFRHB0dBYCsXr1aZ1nW/urXr1++Yvmngm5LXnx9fQWA3L9/X1u2ceNGASDTp0/PVxvBwcHa/RUXF6e3PDExUVxdXUVRFLl9+3axxJ0fWVf36tWrp1P+0ksv5XjscqPRaMTV1VUAyJdffqm3/P79+9orf3/99ZfOsqwkpn///nrrZf08AJAffvhBZ1lxJH4DBgzQW7Zv3z7tetnth6xfzLKLNzfZfZ9ESibxW716dY7bt3XrVgEgLVu2LFD8VDYw8aMS8e233woAcXFxkdGjR8ucOXNk1KhR2qsBn376ab7bioqKkvr16wsA8fHxkRkzZsiIESPE1NRULCws5MSJE9mul3Vly9nZWSZOnChz586VcePGScOGDWXdunXaesnJydqhxx49esisWbPk7bff1vbZqlUrvSsFpSXr5JLdR6VSycsvvyxPnz7VWefgwYPauHOydu1aASDjx4/XW3b16lWZP3++DBo0SDp27KgdAso6KT9/lSIpKUl7hezatWv52qa8Er/4+HhRqVQCQG7cuJFtnblz5woAefnll3XKs/bXL7/8kq9YnleYbcly+vRpee+996Rfv37SoUMH7T6rWrWqANAZ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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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ixYtSi0UJ4QQX375pQAgXFxcxJQpU8S7774rrK2thZGRkTh48KA2T7fGeFbSxWuoX2bOnCnMzMzEq6++KsaPHy8++OAD0b17dyFJkjAwMCj1Jc7rp59u3rwpHBwcBADxyiuviA8++EB06dJFABCurq4iMTFRWZbXUL81a9ZMABCXLl0qt4w+XkMmXVVARESEGDFihPD29hbW1tbC0NBQODg4iB49eog1a9aUWFG3WFpamnj//feFs7OzMDIyEs7OzuL9998vs8es2KpVq0SrVq2EmZmZsLGxET169BCnT5+uzFOr0Z6VdAnBa6hPwsPDxaBBg4SHh4ewsrISRkZGwsnJSQwePFicOnWqzGN4/fRTfHy8GDFihKhXr57yuoSFhYmkpKRSZXkN9dOpU6cEANGmTZvnltW3a8gHXhMRERFpASfSExEREWkBky4iIiIiLWDSRURERKQFTLqIiIiItIBJFxEREZEWMOkiIiIi0gImXURERERawKSLiIiISAuYdBERVYLw8HBIklTi9eeff2qs/j59+pSou/iB20Skv5h0EVGN9t/ESJVXp06dVK7f2toaHTt2RMeOHVG3bt0S+/7888/nJkwrVqyAgYEBJEnCt99+q9zu4+ODjh07olWrVhU9ZSLSEUNdB0BEpEsdO3YstS09PR1RUVHl7vf19VW5fn9/f4SHh79QbMuXL8c777wDhUKBH374AVOmTFHumzNnDgAgNjYW7u7uL1Q/EWkXky4iqtGOHj1aalt4eDg6d+5c7n5t+P333zFmzBgIIbBgwQJMnDhRJ3EQkeYw6SIi0jO//vorxo0bBwBYsmQJ3nvvPR1HRESawKSLiEiP/PzzzwgLC1P++91339VxRESkKZxIT0SkJxYvXqzs1Vq6dCkTLqJqhkkXEZEeWLhwISZMmACZTIbly5fj7bff1nVIRKRhHF4kItKxe/fuYdKkSZAkCStWrEBoaKiuQyKiSsCeLiIiHRNCKP979+5dHUdDRJWFSRcRkY45OTkp192aPn06lixZouOIiKgyMOkiItID06dPx/Tp0wEAEyZM0Ogjg4hIPzDpIiLSE3PmzMGECRMghMDo0aOxadMmXYdERBrEpIuISI8sWLAAI0eOhFwux9ChQ7F7925dh0REGsKki4hIj0iShN9//x2DBg1CYWEh+vfvj0OHDuk6LCLSACZdRER6RiaTYdWqVXj11VeRl5eH3r174+TJk7oOi4jUxKSLiEgPGRkZYePGjejSpQuysrLQq1cvREZG6josIlIDky4iIj1lamqKv//+G+3bt0dqaiq6deuGmJgYXYdFRC+IK9ITEf1Hp06dlAuWVqYRI0ZgxIgRzyxjYWGB48ePV3osRFT5mHQREVWiCxcuIDAwEADwySefoGfPnhqpd8aMGThy5Ajy8/M1Uh8RVT4mXURElSgjIwPHjh0DACQlJWms3itXrijrJaKqQRLa6EMnIiIiquE4kZ6IiIhIC5h0EREREWkBky4iIiIiLWDSRURERKQFTLqIiIiItIBJFxEREZEWMOkiIiIi0gImXURERERawKSLiIiISAuYdBERERFpAZMuIiIiIi34f+QeRCpUXGt1AAAAAElFTkSuQmCC\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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lacX6OzZs1i6dClkMhkGDBig2h4XF4e4uDjY2NioeqJat26N1q1b51v/pk2bYG9vj40bN5baORQlJrnwhKm45YiIiEhzFW54bteuXahVqxbeeecdTJkyBZ988gl69OiBt956C1lZWVi1ahWcnJxU5VetWoWGDRti1apVOoy6eOzM1bsLTt1yREREpLkK19PUsWNH3L59G0FBQTh16hQyMjJQo0YNDB06FDNmzEDz5s11HaLGmtexRk1LIzxPzMh3XpMEgL2lEZrXsS7r0IiIiKqsSrPkgK4V55ZFdeTcPQcgV+KUs2b52pG+6NGopsbtEBERVWVVcsmByqZHo5pYO9IX9pa5h+DsLY2YMBEREelAhRueq0p6NKqJrh72uPwwHjHJGbAzfz0kpyeteM/IIyIiquiYNJVzelIJlxUgIiIqBzg8R0RERKQGJk1EREREamDSRERERKQGJk1EREREamDSRERERKQGJk1EREREamDSRERERKQGJk1EREREamDSRERERKQGrgiuJTnPPU5KStJxJERERKSunO/tnO/xwjBp0pLk5GQAgKOjo44jISIiouJKTk6GpaVloWUkQp3UioqkVCrx9OlTmJubQyLR7gN1k5KS4OjoiKioKFhYWGi1biobvIYVG69fxcdrWPGV1jUUQiA5ORm1atWCVFr4rCX2NGmJVCpF7dq1S7UNCwsLvtkrOF7Dio3Xr+LjNaz4SuMaFtXDlIMTwYmIiIjUwKSJiIiISA1MmioAmUyGuXPnQiaT6ToUKiFew4qN16/i4zWs+MrDNeREcCIiIiI1sKeJiIiISA1MmoiIiIjUwKSJiIiISA1MmoiIiIjUwKSpDCQkJGDq1Klo1aoV7O3tIZPJ4ODggE6dOuH333/P93k3SUlJmDlzJpydnSGTyeDs7IyZM2cW+my77du3o3nz5jA1NUW1atXw9ttv4+rVq6V5alXWokWLIJFIIJFIcPHixXzL8BqWLy4uLqpr9t/XxIkT85Tn9Su/9u7di65du6J69eowNjZGnTp1MHz4cERFReUqx2tYvvz8888FvgdzXp07d851THm7hrx7rgxERETA29sbLVu2RN26dWFtbY2YmBj8/fffiImJwfjx47F+/XpV+dTUVLRt2xbXr19H165d4evri5CQEBw6dAje3t44e/YsTE1Nc7Uxf/58/O9//4OTkxMG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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Draw 1D sensitivity curve\n", - "# This problem has three degrees of freedom. To draw the 1D curve, it needs to fix two dimensions\n", - "fixed = {\n", - " \"'CA0[0]'\": 1.0,\n", - " \"('T[0.125]','T[0.25]','T[0.375]','T[0.5]','T[0.625]','T[0.75]','T[0.875]','T[1]')\": 300,\n", - "}\n", - "\n", - "all_fim.figure_drawing(fixed, [\"T[0]\"], \"Reactor case\", \"T [K]\", \"$C_{A0}$ [M]\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Draw 2D Sensitivity Curve" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAnkAAAHcCAYAAACqMLxhAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAB5yklEQVR4nO3deXxMV/8H8M8kkX2RIEbILpHaEkpaSxpBouijdtUKQmKpolWPVnkk0aqllSaqdhUEbSlKqaBEiCVSja2WRCWxp0Q2spr7+8NvphkzSSaTyTLj83697ut53Hu2O3dqvs459xyRIAgCiIiIiEin6NV1A4iIiIhI8xjkEREREekgBnlEREREOohBHhEREZEOYpBHREREpIMY5BERERHpIAZ5RERERDqIQR4RERGRDmKQR0RERKSDGOQREWkRkUgEkUhU182oUFhYGEQiEcLCwuTOx8XFQSQSoUePHnXSLqKXDYM8qhInJyfZj4z0MDY2hrOzM0aNGoWzZ8/WdROrLDs7G2FhYYiMjKzrppCGtGvXDiKRCCYmJsjNza3r5qgsOjoaYWFhSEtLq+um1LqwsDCFoJCIqodBHqnFzc0N3bp1Q7du3eDm5ob79+9jy5Yt6NKlCzZv3lzXzauS7OxshIeHM8jTEcnJybh06RIAoLCwEDt27KjjFqkuOjoa4eHhFQZ5rVq1QqtWrWqvURpkamqKVq1awcHBQeFaeHg4wsPD66BVRLqLQR6p5bPPPsOJEydw4sQJXLx4EXfv3sXQoUPx7NkzTJkyBY8fP67rJtJLSvqPjIYNG8r9WVdcvXoVV69eretmqMXb2xtXr17Fpk2b6ropRC8FBnmkEdbW1li/fj3MzMyQl5eHgwcP1nWT6CX07NkzbNu2DQCwfPly6Ovr49ixY8jIyKjjlhER1T4GeaQxlpaWcHd3B4Byh5tiY2MxYMAANG3aFEZGRmjRogWCgoJw48YNpelPnz6NWbNmoVOnTrC1tYWRkRHs7e0RGBiIy5cvV9iea9euYcKECWjZsiVMTEzQqFEjvPrqqwgNDcW9e/cAAGPHjoWzszMAID09XWG+4Yv27duHN998E40bN4aRkRGcnZ3x/vvv49atW0rbIJ3DmJaWhqNHj6Jv375o3LgxRCIR4uLiKmx/Ve9F6tChQ/jggw/g6ekJGxsbGBsbw9XVFZMnTy432CktLUVUVBS8vb1hYWEBIyMj2NnZoWvXrggNDUV2drbSPKtWrUL37t3RsGFDGBsbw8PDA3Pnzq2zeXCHDx/GvXv3IBaL8c4776Bnz54QBAFbtmxRu0xBEBATEwNfX180bNgQJiYm8PDwwCeffIKsrCylecp+f7Zu3Qpvb2+Ym5vDxsYGAwcOlA0nS0lfSDh27BgAwM/PT+57GB0drbTsssp+144dO4bevXujYcOGsLGxwaBBg5CSkiJLu2fPHvj4+MDS0hLW1tYYOXIk7t69q/Re1Pk+lUfZixfSlzRevD/pkZaWhk8//RQikQhTp04tt+ykpCSIRCI0a9YMz549q1K7iHSWQFQFjo6OAgBhw4YNSq+3atVKACAsW7ZM4dr06dMFAAIAwdbWVujQoYNgaWkpABAsLS2FhIQEhTyurq4CAKFRo0ZC27ZtBU9PT8HKykoAIJiYmAhHjx5V2o6YmBjB0NBQlq5jx46Ch4eHYGRkJNf+BQsWCJ06dRIACEZGRkK3bt3kjrI+/fRTWftbtGghvPrqq4KpqakAQLC2thbOnj1b7uf15ZdfCnp6eoK1tbXQuXNnoUWLFuW2Xd17kdLX1xdEIpFga2sreHl5CW3bthXMzMxkn+Ply5cV6hgyZIjs3lxdXYXOnTsL9vb2gr6+vgBA+PPPP+XS5+TkCG+88YYAQNDT0xMcHR2Ftm3bytr5yiuvCA8ePFDp/jTp3XffFQAI06dPFwRBEKKjo2XtUYdEIpGVCUBwcXEROnbsKLtPR0dH4caNGwr5pOkXL14sABDEYrHQqVMnwcLCQvYcjx8/Lkt/7tw5oVu3brL/Htq2bSv3Pdy/f79C2S+SftciIiIEfX19wdbWVujYsaPs2Tdr1ky4d++eEBERIfsOe3p6yr5HrVq1EgoKChTKVef7FBoaKgAQQkND5c4fPXpUACD4+vrKzq1fv17o1q2b7L5e/G/w3r17wrVr12T1FRUVKX1WH3zwgQBAmDlzptLrRC8jBnlUJRUFedevXxcMDAwEAEJ8fLzctVWrVgkABGdnZ7ngprS0VPjiiy9kPzov/shs3LhR4Ue0pKREWLdunWBgYCC4uLgIz549k7t+9uxZoUGDBgIAYdasWUJ+fr7sWnFxsbBt2za5H9ibN2/KfrDLs3fvXgGAYGBgIMTExMjO5+TkCIMGDRIACE5OTsLTp0+Vfl76+vpCeHi4UFJSIgjC8+ChsLCw3PrUvRdBEITVq1cLd+7ckTv39OlTYcGCBQIAoUePHnLXkpKSBACCvb298Ndff8ldy8nJEdauXStkZGTInX/nnXcEAEKvXr3knk9WVpYwePBgAYAwdOjQSu9Pk/Ly8mRBd2JioiAIgpCbmyuYmJgIAISkpKQql/ntt98KAAQLCwvh4MGDsvP37t2TBSavvfaaQj5pwNKgQQNh6dKlsu/okydPhPfee0/2fXvx++Lr6ysAqPAfAJUFeS/W+fjxY+H1118XAAj9+/cXTE1NhS1btsjyZWRkCC4uLgIAYcWKFQrlVvX7JAhVC/Iquy8p6ee9c+dOhWvFxcVCo0aNBADCpUuXyi2D6GXDII+qRFmQl5OTIxw6dEho3bq17F/iZRUVFQlisVjQ19cXzp07p7RcaU/Spk2bVG7LqFGjBAAKPYD9+vUTAAjjxo1TqRxVgjzpD4y0h6isJ0+eCI0bNxYACOvXr5e7Jv28/vOf/6jUlhdV9V4q0717dwGAcPv2bdm5bdu2CQCEjz76SKUyzp8/L/u8cnNzFa4/efJEsLe3F0QikZCWlqaRdqtC2mvXsmVLufPDhg0r99lVRCKRCPb29gIA4ZtvvlG4fvv2bVmP3u+//y53TRqwDBgwQCGf9L8HAML3338vd00TQd7bb7+tcC02NlaWT9nnIP1HmLL2VkTZ90kQaibIW79+fbn3t3PnTgGA0KlTpyq1n0jXcU4eqSUoKEg2Z8bKygr+/v64evUqRowYgb1798qlPXXqFO7fv4+OHTuiQ4cOSssbMGAAAMjmJJV19epVhIaGYvDgwejRowe6d++O7t27y9KeP39elragoACHDh0CAMyaNUsj95qfn49Tp04BgNI5QaampggJCQGAcl84GT16dJXrrc69JCUl4dNPP8WAAQPg6+sr+8yuX78OALhw4YIsrb29PQDg999/L3eOWVm7du0CAAwfPhwWFhYK101NTdG7d28IgoDjx49Xqd3VIX2L9t1335U7/9577wEAtm3bhtLSUpXLu3LlCm7dugVjY2PZ8y2refPmGDJkCIDyn/uUKVMUzhkaGiI4OBjA8zmqmjZ+/HiFc15eXhVel/53+ffffystsyrfp5oyfPhwmJubY//+/fjnn3/krm3cuBHA8zm2RPQvg7puAGknNzc32NraQhAE3L9/H3///TcaNGiAzp07w9raWi7txYsXATx/GaN79+5Ky5NO7L9z547c+YULF2Lu3LmQSCTltqVsYJKamoqSkhI0bNhQY2uJpaamQiKRwMjICC4uLkrTtGnTBgBkP3oveuWVV9Sqt6r3IggCPvjgA6xYsaLCdGU/sy5duuC1117DmTNnYG9vD39/f7zxxhvw9fVFx44dFSb5S5/nrl27cPLkSaXlp6enA1B8njXlzp07OHr0KADFIK9v376wtrZGZmYmDh48iH79+qlUpvRZOjg4wMzMTGkadZ+79Hx5+arD1dVV4VyTJk1Uup6fny93Xp3vU00xNzfHsGHDsGHDBmzbtg3Tpk0DADx8+BD79++HoaEhRo4cWePtINIm7MkjtUjXyUtISMCNGzdw4sQJWFhYYObMmYiJiZFLm5OTAwD4559/kJCQoPSQvilbUFAgyxcfH4/PPvsMIpEICxcuxOXLl5Gfnw+JRAJBEDBnzhwAQElJiSyP9K1O6RppmiD94WvSpEm520k1bdoUAJCXl6f0enlBQkXUuZfNmzdjxYoVMDMzw4oVK5CSkoKnT59CeD41Q9arVfYz09PTw2+//Ybp06fDxMQEv/zyCz7++GN06tQJzs7Ocm92Av8+z9TU1HKf5+3btwHIP8/y3L9/X9YzVPao6E3KF23ZsgUSiQQdO3ZUCIgNDQ0xbNgw2eejKulzt7W1LTdNZc+9vLyV5asOU1NThXNlv7cVXRcEQe68Ot+nmjRu3DgA//bcAc/fXi4pKcGAAQNgY2NTK+0g0hbsySON6NatG9auXYtBgwZh+vTpGDBgACwtLQE8/xc48HzY7MUAsCLSZS/++9//4tNPP1W4rmzZEunwobIlP9Qlbf8///wDQRCUBnoPHjyQq18T1LkX6We2dOlSTJw4UeF6eUu9WFtbIzIyEt988w3Onz+P+Ph47N69G0ePHkVQUBDMzc0xdOhQAP9+HmvXrpUNO1ZHYWEhEhISFM4bGKj+15M0eDt37lyF+7r+8ssvyM3NlX03KyK9z8zMzHLTVPbc//nnH7Ro0ULhvLRMTX5faoK636ea0r17d7i7u+PcuXO4dOkS2rZty6FaogqwJ480ZuDAgXj99deRlZWFiIgI2fnWrVsDgMLaYJWRrrXXtWtXpdfLzsWTcnNzg6GhIbKzs3Ht2jWV6qlss/eWLVtCT08PRUVF5c5ZkvZEStcJ1AR17qWiz6ykpARXrlypML9IJIKXlxemTZuGI0eOyILrtWvXytKo+zzL4+TkJOsZKnuouo7gn3/+iUuXLkEkEqFp06blHoaGhigoKMDPP/+sUrnSZ5mRkaEwjClV2XMv7/OWnn8xX2XfxdpW3e9TTQgKCgLwfAu4S5cu4dy5cxCLxXjzzTdrvS1E9R2DPNIoaVCwbNky2Q+jj48PGjdujPPnz1dpAWATExMA//aWlHXw4EGlQZ6JiQkCAgIAAF9//XWV6ilvaNHc3Fz2I/ftt98qXC8oKMC6desAAH369FGpTlXbpe69KPvMNmzYoDBhvTKvv/46AMgtlDto0CAAQExMDB49elSl8mqCtBfvjTfewP3798s9Pv74Y7n0lXnllVfg4OCAwsJC2fMt6+7du7KAsbznrmwuW3FxMdavXw8AsucrVdl3sbZp+vukSl2V3fuYMWOgr6+PLVu2yJ7LqFGjoK+vr7G2EOmM2n+hl7RZZYshSyQS4ZVXXhEACEuWLJGdX7FihQBAaNy4sbBz505BIpHI5bt48aIwa9Ys4cSJE7JzX331lWxx3r///lt2PjExUWjevLlgbGysdJmGsmvLzZ49W3jy5InsWnFxsfDDDz/IrS0nkUhki9S+uE6clHSdvAYNGsitMZabmysMHTq00nXybt68qbTcylT1XqZMmSJbuy0zM1N2/rfffhMsLS1ln1nZ5xcTEyPMnz9foY0PHz4UevbsKQAQRo8eLXdt+PDhAgChQ4cOCsvilJaWCkePHhXeffddldYCrI7S0lLZciTr1q2rMO3ly5cFAIJIJFJY96880nXyLC0thcOHD8vO379/X/Dx8REACK+//rpCPpRZJy8yMlL2fX/69KkwevRo2bqEZZ+nIPz7/D755JNy24Rylhqp7LtWXj5BKH8ZIXW+T4Kg3hIqbdq0EQAIv/32m9I2ltW/f3/ZupXg2nhE5WKQR1VSWZAnCP+uZyUWi+UWNy67Y4SNjY3QuXNnoWPHjoKNjY3sfNm/4HNycmSLtBoaGgrt2rWT7ajRunVrYcaMGUp/SARBEDZv3iwLjkxNTYWOHTsKr7zySrk/SuPGjRMACMbGxkKnTp0EX19fhR+isu23t7cXOnXqJFv539raWrYAr7LPS90gr6r3kp6eLvs8TUxMBC8vL8HJyUkAIPj5+ckW4i2b55tvvpHdV/PmzYXOnTvL7V7RvHlzIT09Xa5NeXl5gr+/vyyfg4OD8Nprrwnt2rWTLT4MQOkOCpr022+/yZ5bdnZ2pek7dOggABAWLlyoUvkv7njRsmVLuR0vHBwcVN7xonPnzrIdLYyNjYVjx44p5IuPj5fldXd3F9544w3B19dX7r+L2gzy1Pk+CYJ6Qd78+fMF4PnC4R06dJD9N3jv3j2FtD///LPsfrg2HlH5GORRlagS5BUVFQl2dnYCAOG7776Tu5aQkCC8++67gr29vWBoaCjY2NgI7du3F8aNGyfs27dPKC4ulkt/9+5dYfTo0ULjxo0FQ0NDwdnZWZgxY4aQk5NT7g+J1OXLl4WgoCDBwcFBMDQ0FBo3biy8+uqrQlhYmMIPR15enjB9+nTByclJFlAp+0Hcu3ev4O/vL1hbWwuGhoaCo6OjMGnSpHJ7hjQR5FX1Xq5duyYMHjxYsLKyEoyNjQUPDw8hPDxcKCoqEsaMGaPw/DIyMoTFixcL/v7+goODg2BsbCw0atRI6Nixo/DFF18Ijx8/VtqmZ8+eCVu2bBH69OkjNG7cWGjQoIHQrFkz4bXXXhM++eQTpUGvpkkDsGHDhqmUfunSpbJ/JKhKIpEImzZtEnx8fARLS0vByMhIcHNzE/773/8KDx8+VJqn7Pdny5YtQufOnQVTU1PByspKGDBggHD+/Ply69u6davg7e0t+wfEi8+rNoM8Qaj690kQ1AvyiouLhdDQUKFVq1ayrdbKu5/i4mLZAuTLly9Xek9EJAgiQXjhnXkiIqqW8pYkIc3Izs6GWCyGIAi4d+8el04hKgdfvCAiIq2yZcsWFBUV4e2332aAR1QB9uQREWkYe/JqTlZWFjp06ICMjAwcPXoUPXr0qOsmEdVb7MkjIqJ6b9GiRfDx8YGrqysyMjIQEBDAAI+oEgzyiIio3rt69SpOnDgBfX19BAYGYuvWrXXdJKJ6j8O1RERERDqIPXlEREREOkj1HcCp1kkkEty9excWFhb1bk9LIiKqnCAIyMvLg52dHfT0aqZfpbCwEMXFxRopy9DQEMbGxhopi+oeg7x67O7du7C3t6/rZhARUTXdunULLVq00Hi5hYWFMDUxgabmXYnFYty8eZOBno5gkFePWVhYAABu3ToJS0vzOm4NUc0QW7Wv6yYQ1RgBQCH+/ftc04qLiyEAMAFQ3fEeAcD9+/dRXFzMIE9HMMirx6RDtJaW5rC0rJm/IIjqGici0Mugpqfc6EMzQR7pFgZ5REREWo5BHinDt2uJiIiIdBB78oiIiLScHtiTR4oY5BEREWk5PVR/aE6iiYZQvcIgj4iISMvpo/pBHl+C0j2ck0dERESkg9iTR0REpOU0MVxLuodBHhERkZbjcC0pw8CfiIiISAexJ4+IiEjLsSePlGGQR0REpOU4J4+U4XeCiIiISAexJ4+IiEjL6eH5kC1RWQzyiIiItJwmhmu5rZnu4XAtERERkQ5iTx4REZGW0weHa0kRgzwiIiItxyCPlGGQR0REpOU4J4+U4Zw8IiIiIh3EnjwiIiItx+FaUoZBHhERkZZjkEfKcLiWiIiISAexJ4+IiEjLiVD9XhuJJhpC9QqDPCIiIi2nieFavl2rezhcS0RERKSD2JNHRESk5TSxTh57fXQPgzwiIiItx+FaUoaBOxEREZEOYk8eERGRlmNPHinDII+IiEjLcU4eKcMgj4iISMuxJ4+UYeBOREREpIPYk0dERKTl9FD9njzueKF7GOQRERFpOc7JI2X4TImIiIh0EHvyiIiItJwmXrzgcK3uYZBHRESk5ThcS8rwmRIRERHpIPbkERERaTkO15Iy7MkjIiLScvoaOqrizp07iIyMREBAABwcHGBoaAixWIwhQ4bgzJkzVSrr9u3bmDhxoqwcOzs7BAUF4datWxXm27VrF/z9/dGoUSOYmJjA2dkZI0eOVMgXFhYGkUik9DA2NlYoNy0trdz0IpEIP/zwQ5Xur66wJ4+IiIiq7Ntvv8XixYvh6uoKf39/2NraIiUlBbt378bu3buxbds2DB8+vNJybty4ga5duyIzMxP+/v4YMWIEUlJSsHHjRuzfvx8nT56Eq6urXB5BEDBp0iSsWbMGrq6ueOedd2BhYYG7d+/i2LFjSE9Ph729vUJdY8aMgZOTk9w5A4PyQyFPT08MHDhQ4Xzbtm0rva/6gEEeERGRlquLFy+8vb0RHx8PHx8fufPHjx9Hr169MHnyZLz99tswMjKqsJzp06cjMzMTUVFRmDZtmuz89u3bMXz4cEyZMgUHDhyQy/Ptt99izZo1mDJlCqKioqCvL98PWVpaqrSusWPHokePHirfo5eXF8LCwlROX99wuJaIiEjLSXe8qM5R1YBg8ODBCgEeAPj4+MDPzw9ZWVm4ePFihWUUFhYiNjYWTZs2xdSpU+WuDRs2DF5eXoiNjcXff/8tO19QUIDw8HC4uLggMjJSIcADKu6de5nwUyAiItJymnjxorr5y2rQoAGAyoOtR48eobS0FI6OjhCJRArXnZ2dkZycjKNHj8LFxQUAcOjQIWRlZWHs2LF49uwZ9uzZg+vXr6Nhw4bo3bs3WrZsWW59x48fR2JiIvT19eHh4YHevXtX2NN49+5drFy5EtnZ2bCzs0OvXr3QokULVT6CeoFBHhEREcnk5ubK/dnIyKjSIdeyMjIycPjwYYjFYrRr167CtNbW1tDX10d6ejoEQVAI9G7evAkAuH79uuxcUlISgOcBpKenJ65duya7pqenh48++ghff/210vrmzZsn9+dmzZph48aN8Pf3V5r+0KFDOHTokOzPBgYGmDZtGr766ivo6dX/wdD630IiIiKqkJ6GDgCwt7eHlZWV7Fi4cKHK7SgpKUFgYCCKioqwZMkSpUOpZZmamsLX1xcPHjzAihUr5K7t3LkTycnJAIDs7GzZ+czMTADA0qVLYWlpicTEROTl5SE+Ph7u7u5YunQpVq5cKVeWl5cXNm7ciLS0NBQUFCAlJQWff/45srOzMWDAAJw/f16hXaGhoUhOTkZubi4yMzOxZ88euLm5ISIiAnPmzFH5M6lLIkEQhLpuBCmXm5sLKysr5ORcgKWlRV03h6hGmImc67oJRDVGAFAAICcnB5aWlhovX/o7MRqAYTXLKgawCcCtW7fk2qpqT55EIsGYMWMQExODkJAQrFmzRqV6z58/j+7duyM/Px99+vRB+/btkZqail9++QVt27bFhQsXMHnyZFkQOGHCBKxduxYmJiZITU2FnZ2drKzLly+jffv2cHZ2RmpqaqV1r127FhMmTMDQoUOxffv2StPfv38fbdu2RV5eHu7fvw9ra2uV7rGusCePiIiIZCwtLeUOVQI8QRAQEhKCmJgYjBo1CqtWrVK5Pk9PT5w9exbDhw/HuXPnEBUVhWvXrmH16tUIDAwEADRp0kSW3srKCgDQqVMnuQAPANq0aQMXFxfcuHFDrvevPGPGjIGBgQESEhJUaqtYLEa/fv1QXFyMs2fPqniHdYdz8oiIiLRcXe5dK5FIEBwcjA0bNmDkyJGIjo6u8nw1Dw8P/Pjjjwrnx44dC+B5QCfVqlUrAEDDhg2VliU9X1BQUG4aKUNDQ1hYWODp06cqt7Vx48YAUKU8dYU9eURERFquLna8AOQDvBEjRmDz5s2VzsNTVV5eHvbu3QsbGxu5FyP8/PwAAFeuXFHIU1JSgtTUVJiZmcn1/pUnJSUFjx8/VlgguSKJiYkAUKU8dYVBHhEREVWZRCLB+PHjsWHDBgwbNgwxMTEVBngPHz7E1atX8fDhQ7nzBQUFCosXFxUVYfz48cjKykJoaKjc1mOurq4ICAhAamoq1q1bJ5dv0aJFyM7OxqBBg2TLt+Tl5eHChQsK7Xn8+DHGjx8PABg5cqTctcTERJSUlCjkiYiIQEJCAlq3bg1PT89y77W+4IsX9RhfvKCXAV+8IF1WWy9eTIBmXrxYA9XbGhYWhvDwcJibm2P69OlK18QbOHAgvLy85NKHhobK7SJx4sQJDB48GP7+/rC3t0dubi727duHjIwMhISEYPXq1QpLq5TdCq1///7w8PDAn3/+iSNHjsDR0RGnT5+GWCwG8HwfWmdnZ3Tq1Ant2rWDra0t7ty5g99++w2PHj2Cv78/fv31Vxga/vsJ9ujRA1evXoWvry/s7e1RUFCAU6dO4c8//4S1tTUOHz6Mjh07Vvkzrm2ck0dERKTlRKj+0JziUsQVS0tLAwDk5+djwYIFStM4OTnJgrzyODg4oEePHjh+/DgePHgAU1NTdOzYERERERgyZIjSPK6urkhKSsK8efNw4MABHDx4EGKxGFOmTMG8efNga2srS2tjY4MpU6bg9OnT2Lt3L7Kzs2FmZoZ27dph1KhRCA4OVuiBHDVqFH7++WecPHlS1vPo6OiI6dOnY+bMmVqzIDJ78uox9uTRy4A9eaTLaqsnbyIA1ZcrVq4IwGrUXFup9rEnj4iISMvVt23NqH5gkEdERKTlGOSRMgzyiIiItFxdrpNH9RefKREREZEOYk8eERGRluNwLSnDII+IiEjLcbiWlOEzJSIiItJB7MkjIiLSchyuJWUY5BEREWk5PVQ/SOPQnu7hMyUiIiLSQezJIyIi0nJ88YKUYZBHRESk5Tgnj5Rh4E5ERESkg9iTR0REpOXYk0fKMMgjIiLScpyTR8owyCMiItJy7MkjZRi4ExEREekg9uQRERFpOQ7XkjIM8oiIiLQcd7wgZfhMiYiIiHQQe/KIiIi0HF+8IGUY5BEREWk5zskjZfhMiYiIiHQQe/KIiIi0HIdrSRkGeURERFqOQR4pw+FaIiIiIh3EnjwiIiItxxcvSBkGeURERFqOw7WkDIM8IiIiLSdC9XviRJpoCNUr9b53Njs7G9OmTUOXLl0gFothZGSE5s2bo2fPnvj5558hCIJCntzcXMyYMQOOjo4wMjKCo6MjZsyYgdzc3HLr2bp1K7y9vWFmZgZra2v069cPSUlJVW6vOnUTERERaZpIUBYl1SOpqanw8vLC66+/jpYtW8LGxgaZmZnYu3cvMjMzERISgjVr1sjSP3nyBN27d0dycjL8/f3RsWNHnD9/HgcOHICXlxdOnDgBMzMzuTq+/PJLzJkzBw4ODhg6dCjy8/Pxww8/oLCwELGxsejRo4dKbVWn7ork5ubCysoKOTkXYGlpoXI+Im1iJnKu6yYQ1RgBQAGAnJwcWFpaarx86e/E9wBMq1nWUwDjUHNtpdpX74drnZ2dkZ2dDQMD+abm5eXh9ddfx9q1azF9+nS0adMGALBkyRIkJydj1qxZWLx4sSx9aGgo5s+fjyVLliA8PFx2PiUlBaGhoXB3d0diYiKsrKwAANOmTYO3tzeCg4Nx9epVhfqVqWrdREREmsA5eaRMvR+u1dfXVxpgWVhYoE+fPgCe9/YBgCAIWLduHczNzTFv3jy59LNnz4a1tTXWr18vN8S7YcMGlJaWYs6cObIADwDatGmD0aNH48aNGzhy5Eil7VSnbiIiIqKaUu+DvPIUFhbiyJEjEIlEaN26NYDnvXJ3795Ft27dFIZFjY2N8cYbb+DOnTuyoBAA4uLiAAABAQEKdUiDyGPHjlXaHnXqJiIi0gQ9DR2kW+r9cK1UdnY2IiMjIZFIkJmZif379+PWrVsIDQ2Fm5sbgOeBFgDZn19UNl3Z/29ubg6xWFxh+sqoUzcREZEmcLiWlNGqIK/sfLYGDRrgq6++wscffyw7l5OTAwByw65lSSeSStNJ/7+tra3K6cujTt0vKioqQlFRkezPfCOXiIiI1KU1vbNOTk4QBAGlpaW4efMm5s+fjzlz5mDIkCEoLS2t6+ZpxMKFC2FlZSU77O3t67pJRESkBfQ1dJBu0ZqePCl9fX04OTnh008/hb6+PmbNmoW1a9di8uTJsl608nrLpD1jZXvbni9Ronr68qhT94tmz56NGTNmyOVhoEdERJXhtmbao6SkBGfPnsWJEyeQnp6Of/75BwUFBWjcuDGaNGmCjh07wsfHB82bN692XVoX5JUVEBCAWbNmIS4uDpMnT650Dp2yeXNubm44deoU7t+/rzAvr7J5dmWpU/eLjIyMYGRkVGldREREpF2OHj2KdevWYffu3SgsLAQApStuiETP9x555ZVXMG7cOIwePRqNGzdWq06tDvLu3r0LALIlVtzc3GBnZ4eEhAQ8efJE7i3XwsJCxMfHw87ODi1btpSd9/X1xalTp3Dw4EGMHj1arvzY2FhZmsqoUzcREZEm6KH6w63syasZe/fuxezZs3HlyhUIggADAwN4eXmhc+fOaNasGWxsbGBiYoKsrCxkZWXhr7/+wtmzZ/HXX39h5syZ+OyzzzBhwgT873//Q5MmTapUd71/psnJyUqHQLOysvDZZ58BAPr27QvgefQbHByM/Px8zJ8/Xy79woUL8fjxYwQHB8uiZAAICgqCgYEBFixYIFfP5cuXsWnTJri6uqJnz55yZWVkZODq1at4+vSp7Jw6dRMREWkCl1Cpn9544w0MHDgQaWlpGD58OHbt2oXc3Fz88ccfWLVqFUJDQzF16lQEBwdj1qxZWLRoEfbs2YN79+4hJSUFn3/+OVq2bInly5ejZcuW+OWXX6pUf73f1uzDDz/EunXr4OfnB0dHR5iZmSE9PR379u1Dfn4+hgwZgp9++gl6es+/ni9uLfbqq6/i/Pnz+O2338rdWmzBggWYO3eubFuzJ0+eYNu2bSgoKEBsbCz8/Pzk0vfo0QPHjh3D0aNH5bY8U6fuinBbM3oZcFsz0mW1ta3ZHgCq/7oo9wTAAHBbM02ysbHBtGnT8OGHH6Jhw4Zql3P06FF8/vnn8PPzw//+9z+V89X7IO/EiRNYv349Tp8+jbt37+Lp06ewsbFBx44dMXr0aLzzzjsKvWM5OTkIDw/Hjh07ZHPthg4ditDQ0HJffNiyZQsiIyNx+fJlGBoaokuXLpg/fz46d+6skLa8IE/dusvDII9eBgzySJcxyHu55eXlwcJCc7/fVS2v3gd5LzMGefQyYJBHuqy2grx90EyQ1x8M8nSJVr94QURERFxChZTjMyUiIqIqu3PnDiIjIxEQEAAHBwcYGhpCLBZjyJAhOHPmTJXKun37NiZOnCgrx87ODkFBQbh161aF+Xbt2gV/f380atQIJiYmcHZ2xsiRIxXyhYWFQSQSKT2MjY3LLX/r1q3w9vaGmZkZrK2t0a9fPyQlJVXp3irz9OlTPHr0SOlyKtXFnjwiIiItVxd713777bdYvHgxXF1d4e/vD1tbW6SkpGD37t3YvXs3tm3bhuHDh1dazo0bN9C1a1dkZmbC398fI0aMQEpKCjZu3Ij9+/fj5MmTcHV1lcsjCAImTZqENWvWwNXVFe+88w4sLCxw9+5dHDt2DOnp6Uo3ExgzZgycnJzkzkmXYXvRl19+iTlz5sDBwQGTJk1Cfn4+fvjhB3Tr1g2xsbEKc/JVkZubiz179iA+Pl62GLJ0zTyRSCR758DHxwcBAQFK3wuoCs7Jq8c4J49eBpyTR7qstubk/Q7NzMnrBdXbunPnTjRp0gQ+Pj5y548fP45evXrJgq7KFvl/6623sG/fPkRFRWHatGmy89u3b8fw4cPRp08fHDhwQC7PsmXLMH36dEyZMgVRUVHQ15cPUUtLS+WCt7CwMISHhyt9YVKZlJQUtG7dGi4uLkhMTJS9OHn58mV4e3ujWbNmuHr1arkB4osSExPx3Xff4eeff0ZBQUGlvXbSF0rbtm2L4OBgjB8/HqampirVVRaHa4mIiKjKBg8erBDgAYCPjw/8/PyQlZWFixcvVlhGYWEhYmNj0bRpU0ydOlXu2rBhw+Dl5YXY2Fj8/fffsvMFBQUIDw+Hi4sLIiMjFQI8oPzeOVVt2LABpaWlmDNnjtzKGG3atMHo0aNx48YNHDlypNJyrl+/jiFDhqBLly7YvHkzTE1N8e677yIqKgonT57EzZs3kZOTg+LiYty/fx9//fUXduzYgf/+97/o2rUrLl26hA8//BCurq5YvXo1JBJJle6Dw7VERERaToTq99pocqn+Bg0aAKg82Hr06BFKS0vh6OiodLMAZ2dnJCcn4+jRo3BxcQEAHDp0CFlZWRg7diyePXuGPXv24Pr162jYsCF69+5d4c5Sx48fR2JiIvT19eHh4YHevXsr7WmMi4sD8Hz71Bf16dMHq1atwrFjx5ReL6tNmzYAgBEjRmDMmDHo3bu30qAUAGxtbWFrawsPDw8MHjwYwPN5j9u2bcPKlSvx/vvv49GjR7KNIFTBII+IiEjLaXJOXm5urtz5qu6rnpGRgcOHD0MsFqNdu3YVprW2toa+vj7S09MhCIJCoHfz5k0Az3vEpKQvPhgYGMDT0xPXrl2TXdPT08NHH32Er7/+Wml98+bNk/tzs2bNsHHjRvj7+8udT0lJgbm5ucKe9kDle9WXNXr0aHz22WcKcwpV1bx5c8ycORMfffQRtmzZUuVdszhcS0RERDL29vawsrKSHQsXLlQ5b0lJCQIDA1FUVIQlS5aU22slZWpqCl9fXzx48AArVqyQu7Zz504kJycDALKzs2XnMzMzAQBLly6FpaUlEhMTkZeXh/j4eLi7u2Pp0qVYuXKlXFleXl7YuHEj0tLSUFBQINsyLDs7GwMGDMD58+fl0ufk5JS7gYF0vqKyLVdftH79erUDvLL09fUxevRoBAYGVikfe/KIiIi0nCbXybt165bcixeq9uJJJBKMGzcO8fHxCAkJUTkgiYiIQPfu3fHBBx9g7969aN++PVJTU/HLL7+gffv2uHDhglywKJ2XZmhoiN27d8POzg7A87mAO3bsQPv27bF06VJMnjxZlmfgwIFydbZs2RJz585F06ZNMWHCBHzxxRfYvn27Su3VJuzJIyIi0nL6GjqA5z1VZQ9VgjxBEBASEoKYmBiMGjUKq1atUrntnp6eOHv2LIYPH45z584hKioK165dw+rVq2WBYpMmTWTppT1snTp1kgV4Um3atIGLiwtu3Lgh1/tXnjFjxsDAwAAJCQly55+vbKG8p046nF3VrUrrAnvyiIiItFxdrJMnJZFIEBwcjA0bNmDkyJGIjo6Gnl7V+pA8PDzw448/KpwfO3YsgOcBnVSrVq0AAA0bNlRalvR8QUFBuWmkDA0NYWFhgadPn8qdd3Nzw6lTp2R70JclnYsnnZtXmfj4eJXSVeSNN95QKx+DPCIiIlJL2QBvxIgR2Lx5c6Xz8FSVl5eHvXv3wsbGRu7FCD8/PwDAlStXFPKUlJQgNTUVZmZmcr1/5UlJScHjx4/h6ekpd97X1xenTp3CwYMHMXr0aLlrsbGxsjSq6NGjR5VfmChLJBKhtLRUrbwM8oiIiLRcXexdK5FIMH78eERHR2PYsGGIiYmpMMB7+PAhHj58iMaNG6Nx48ay8wUFBWjQoIHccitFRUUYP348srKyEBUVJbf1mKurKwICAnDw4EGsW7cOwcHBsmuLFi1CdnY2Ro0aJSsvLy8PN2/eRPv27eXa8/jxY4wfPx4AMHLkSLlrQUFB+Prrr7FgwQK8/fbbcoshb9q0Ca6urujZs2eVPq9mzZrBxMSkSnmqizte1GPc8YJeBtzxgnRZbe14cQ6AeTXLygfQEaq3VbqLhLm5OaZPn650TbyBAwfCy8tLLn1oaCjCwsJkaU6cOIHBgwfD398f9vb2yM3Nxb59+5CRkYGQkBCsXr1aoSes7FZo/fv3h4eHB/78808cOXIEjo6OOH36tGyYNS0tDc7OzujUqRPatWsHW1tb3LlzB7/99hsePXoEf39//PrrrzA0NJSrY8GCBZg7dy4cHBwwdOhQPHnyBNu2bUNBQQFiY2NlPYqVkQ5dW1paYsiQIRg1apTKeauLPXlERERUZWlpaQCA/Px8LFiwQGkaJycnWZBXHgcHB/To0QPHjx/HgwcPYGpqio4dOyIiIgJDhgxRmsfV1RVJSUmYN28eDhw4gIMHD0IsFmPKlCmYN28ebG1tZWltbGwwZcoUnD59Gnv37kV2djbMzMzQrl07jBo1CsHBwUp7IOfMmQMnJydERkZi5cqVMDQ0RNeuXTF//vwq7Sl7/vx5bNq0Cdu2bcOGDRsQHR2NFi1a4L333sOoUaPQunVrlcuqKvbk1WPsyaOXAXvySJfVVk9eMoDq/krkAfBCzbX1ZScIAn7//Xds3rwZu3fvRl5eHkQiETw9PREYGIiRI0cqXXy5Ohjk1WMM8uhlwCCPdFltBXkXoJkgrz0Y5NWGgoIC7Nq1C5s3b8bvv/+O0tJS6Ovro1evXggMDMSgQYNgampa7Xq4Th4RERFRLTIxMcG7776L3377Dbdv30ZERAS8vLxkb/MOHTpUI/VwTh4REZGWq8t18qh6bG1tMXr0aBgaGuKff/5BRkaG2kumvIhBHhERkZariyVUqHqKi4uxZ88exMTE4MCBAygpKQHwfF29999/XyN1MMgjIiLScuzJ0x7x8fGIiYnBjh07kJOTA0EQ0KZNG4waNQrvvfceWrRoobG6GOQRERER1aCrV69i8+bN2Lp1KzIyMiAIAsRiMYKCghAYGFjpMjPqYpBHRESk5diTV3917twZ586dAwCYmpri3XffRWBgIHr37l3lPX6rikEeERGRluOcvPrrjz/+gEgkQqtWrTBo0CCYmZkhKSkJSUlJKpfx2WefqVU318mrx7hOHr0MuE4e6bLaWifvJjSzTp4zuE6epunp6UEkEkEQBIXt2SojzfPs2TO16mZPHhERkZbTQ/WHW9mTVzPGjBlTZ3UzyCMiItJynJNXf23YsKHO6mbgTkRERKSD2JNHRESk5fjiBSnDII+IiEjLcbi2/srIyKh2GQ4ODmrlY5BHREREVEOcnau3goBIJFJ7L1sGeURERFqOw7X1V3VXqqtOfgZ5REREWo7DtfXXzZs366xuBnlERERajkFe/eXo6FhndbN3loiIiEgHMcgjIiLSdiL8OzFP3aNqO26RipYtW4aff/65TupmkEdERKTt9DV0kMZ9+OGHiIqKUnqtZ8+e+PDDD2usbs7JIyIiIqoDcXFxai+PogoGeURERNpOH9UfbhUA1Fy8QXWAQR4REZG208Scuuot50b1EOfkEREREekg9uQRERFpO00N15JOYZBHRESk7Rjk1WuZmZnYtGlTla9JjR49Wq16RUJ1N1WjGpObmwsrKyvk5FyApaVFXTeHqEaYiaq3eTdRfSYAKACQk5MDS0tLjZcv+52wAiyrGeTlCoBVTs219WWlp6cHkUj9hyMSidR+A5c9eURERNqOL17UWw4ODtUK8qqDQR4REZG2k+5aUR0STTSEXpSWllZndTPIIyIi0naaCPJI5/ArQURERKSDGOQRERFpO+5dWy89ffq0TstjkEdERKTtGOTVS05OTli8eDHy8/OrVc7Jkyfx5ptvYunSpVXKxyCPiIiIqAa4uLhg9uzZsLe3x/jx43Ho0CE8e/ZMpbx3797FN998g06dOsHHxwcnTpxA27Ztq1Q/18mrx7hOHr0MuE4e6bJaWyfPHrCsZrdNrgSwusV18jRt+/btmDNnDlJTUyESiWBsbIwOHTrg1VdfRbNmzWBjYwMjIyNkZ2cjKysLV65cQVJSEtLT0yEIAgwMDBAUFITw8HCIxeIq1c0grx5jkEcvAwZ5pMtqLchz0lCQl8YgryYIgoADBw5gzZo12L9/P0pKSgBA6fp50rDM2dkZ48aNw7hx49CsWTO16uUSKkREREQ1SCQSoW/fvujbty+ePn2KU6dO4eTJk0hPT8fDhw9RWFgIGxsb2NrawsvLC927d0fLli2rXS+DPCIiIm2nB744oSVMTU3Rq1cv9OrVq8brYpBHRESk7TSxGDInb+kcBnlEREREdeTu3bu4c+cOCgoK8MYbb2i0bC6hQkREpO24Tp7WWblyJdzc3GBvb4/XX38dPXv2lLv+8ccfo2vXrsjIyFC7DgZ5RERE2k5PQwfVOEEQMGLECHzwwQf4+++/4eTkBHNzc7y42Mlrr72G06dPY+fOnWrXxUdKRESk7diTpzXWr1+P7du3o3Xr1khOTsaNGzfQvn17hXT9+/eHvr4+9u3bp3ZdnJNHREREVEvWr18PPT09bN++HR4eHuWmMzMzg6urK/7++2+161IpyHNxcVG7AmVEIhFu3Lih0TKJiIheWuyJ0xqXL1+Gi4tLhQGelLW1Nc6fP692XSoN16alpWn8ICIiIg2pgzl5d+7cQWRkJAICAuDg4ABDQ0OIxWIMGTIEZ86cqVJZt2/fxsSJE2Xl2NnZISgoCLdu3aow365du+Dv749GjRrBxMQEzs7OGDlyZKX5bt68CXNzc4hEIkyaNEnhelpaGkQiUbnHDz/8UKX7K0sikcDIyEiltLm5uSqnVUbl4drOnTvjp59+UrsiqWHDhuGPP/6odjlERERUd7799lssXrwYrq6u8Pf3h62tLVJSUrB7927s3r0b27Ztw/Dhwyst58aNG+jatSsyMzPh7++PESNGICUlBRs3bsT+/ftx8uRJuLq6yuURBAGTJk3CmjVr4OrqinfeeQcWFha4e/cujh07hvT0dNjb2yutTxAEBAUFqXSPnp6eGDhwoML5tm3bqpRfGWdnZ6SmpiI/Px/m5ublprt//z6uXbsGb29vtetSOcgzMjKCo6Oj2hWVLYeIiIg0SBM7XlRxMWRvb2/Ex8fDx8dH7vzx48fRq1cvTJ48GW+//Xalv/vTp09HZmYmoqKiMG3aNNn57du3Y/jw4ZgyZQoOHDggl+fbb7/FmjVrMGXKFERFRUFfX/7mS0tLy63v22+/RUJCApYsWYIZM2ZU2DYvLy+EhYVVmKaqBgwYgIULF2LevHmIiIgoN93HH38MQRAwaNAgtetSqXN2wIABGlugz8fHBwMGDNBIWURERIQ6ebt28ODBCgEe8Px33s/PD1lZWbh48WKFZRQWFiI2NhZNmzbF1KlT5a4NGzYMXl5eiI2NlXv5oKCgAOHh4XBxcUFkZKRCgAcABgbK+7BSU1Mxe/ZszJo1Cx06dFDlNjVu5syZsLOzQ1RUFIYNG4YDBw6gsLAQwPNh5D179qB3797Ytm0bnJ2d8f7776tdl0o9ebt371a7ghd9+eWXGiuLiIiI6p8GDRoAKD/Yknr06BFKS0vh6OgIkUikcN3Z2RnJyck4evSo7CXQQ4cOISsrC2PHjsWzZ8+wZ88eXL9+HQ0bNkTv3r3RsmVLpXVJJBIEBQXB0dER8+bNw6lTpyq9j7t372LlypXIzs6GnZ0devXqhRYtWlSaryLW1taIjY3F22+/jZ9//lluHTxp2wVBgIuLC/bt2wczMzO166q1JVSuX78Od3f32qqOiIjo5aGJxYz/P39ubq7caSMjoypNtcrIyMDhw4chFovRrl27CtNaW1tDX18f6enpEARBIdC7efMmgOcxhFRSUhKA5wGkp6cnrl279u8t6Onho48+wtdff61QV2RkJE6ePIkTJ06ofD+HDh3CoUOHZH82MDDAtGnT8NVXX0FPT/0PvE2bNrhw4QLWr1+PXbt24eLFi8jJyYG5uTlat26NwYMHY+LEidUK8IAqfCWUfWCqunDhAnx9fdXOT0RERBXQ4HCtvb09rKysZMfChQtVbkZJSQkCAwNRVFSEJUuWKB1KLcvU1BS+vr548OABVqxYIXdt586dSE5OBgBkZ2fLzmdmZgIAli5dCktLSyQmJiIvLw/x8fFwd3fH0qVLsXLlSrmyrl+/jrlz52L69Ono0qVLpfdhamqK0NBQJCcnIzc3F5mZmdizZw/c3NwQERGBOXPmqPBpVF7H1KlTceTIEfzzzz8oLi5GVlYWTpw4gRkzZlQ7wAMAkfDiPhrl0NfXR0REBKZPn16lChITE9G3b19kZ2fj2bNnajXyZZWbmwsrKyvk5FyApaVFXTeHqEaYiZzruglENUYAUAAgJycHlpaWGi9f9jvRDbCs5thcbilglQDcunVLrq2q9uRJJBKMGTMGMTExCAkJwZo1a1Sq9/z58+jevTvy8/PRp08ftG/fHqmpqfjll1/Qtm1bXLhwAZMnT5YFgRMmTMDatWthYmKC1NRU2NnZycq6fPky2rdvL3uDVdqu7t27IzMzExcuXICpqSkAIC4uDn5+fpg4cSJWrVqlUlvv37+Ptm3bIi8vD/fv34e1tbVK+epKlfoaZ8yYge+++07l9MeOHYO/vz8eP36sUuRMREREatDgOnmWlpZyhyoBniAICAkJQUxMDEaNGqVy0AQ8X6bk7NmzGD58OM6dO4eoqChcu3YNq1evRmBgIACgSZMmsvRWVlYAgE6dOskFeMDzYVAXFxfcuHFD1vu3bNkynD59GuvWrZMFeOoSi8Xo168fiouLcfbsWbXKePDgATZt2oSTJ09WmC4hIQGbNm2S9VyqQ+Ug7/vvv4dIJMK0adOwevXqStMfOHAA/fr1Q15eHnr16oWDBw+q3UgiIiKqQB3uXSuRSDB+/Hh8//33GDlyJKKjo6s8X83DwwM//vgjMjMzUVRUhMuXLyM4OBiXLl0C8Dygk2rVqhUAoGHDhkrLkp4vKCgAACQnJ0MQBPj5+cktaOzn5wcAWL16NUQikdL18JRp3LgxAODp06dVukeplStXIigoCLdv364w3Z07dxAUFKRyj6gyKnfujhkzBs+ePUNISAimTJkCfX19BAcHK027c+dOvPvuuyguLsZ//vMf/PTTT1wfj4iIqKZoYluzKq6TBzwP8IKDg7FhwwaMGDECmzdvrnQenqry8vKwd+9e2NjYwN/fX3ZeGpxduXJFIU9JSQlSU1NhZmYm6/3z9fVV+pbvvXv3sH//fnh4eKBbt24qL6mSmJgIAHBycqrqLQEAfv31VxgZGWHIkCEVphs8eDCMjIywZ88ezJ07V626qjSCP27cOEgkEkycOBGTJk2CgYEBxo4dK5dm06ZNCA4ORmlpqeyBV/YKNREREWkXaQ9edHQ0hg0bhpiYmAoDvIcPH+Lhw4do3LixrDcMeN7j1qBBA7lYoaioCOPHj0dWVhaioqJgbGwsu+bq6oqAgAAcPHgQ69atk+twWrRoEbKzszFq1ChZeUFBQUp3uIiLi8P+/fvh6+urMLycmJiIDh06yJaCkYqIiEBCQgJat24NT09PFT8peWlpaXB2dq40GDYwMICzszPS09PVqgdQYwmV4OBgPHv2DO+//z6Cg4Ohr68vGzNfuXIlpk6dColEgnHjxmHt2rVK170hIiIiDRKh+kuoVPHnev78+YiOjoa5uTnc3d3xxRdfKKQZOHAgvLy8AADLly9HeHg4QkND5XaR+OOPPzB48GD4+/vD3t4eubm52LdvHzIyMhASEqKwSDIArFixAl27dkVISAh2794NDw8P/Pnnnzhy5AgcHR3x1VdfVe1mXjBr1ixcvXoVvr6+sLe3R0FBAU6dOoU///wT1tbW2Lx5s9rxzdOnT1WeG2hiYqKwpE1VqNXFNnHiREgkEkyZMgXjxo2DgYEBbt26hdmzZ0MQBEybNg2RkZFqN4qIiIiqQBPDtZKqJU9LSwMA5OfnY8GCBUrTODk5yYK88jg4OKBHjx44fvw4Hjx4AFNTU3Ts2BERERHlDmm6uroiKSkJ8+bNw4EDB3Dw4EGIxWJMmTIF8+bNg62tbdVu5gWjRo3Czz//jJMnT+Lhw4cAAEdHR0yfPh0zZ86s1oLIzZs3x5UrV1BQUAATE5Ny0xUUFODq1asQi8Vq16XyEirKLF++HNOmTYOenh4EQYAgCJg9e3a5D5uqhkuo0MuAS6iQLqu1JVT6AJYNKk9fYVklgFVszbWVnpswYQLWr1+PTz75pMJdwObMmYOFCxdi3LhxWLdunVp1Vatz94MPPkBUVBQkkufh/8KFCxngERER1bY6fLuWqmbmzJlo0KABFi9ejAkTJiAlJUXuekpKCiZOnIhFixbB0NAQM2fOVLsulXvypHvGKXPnzh0IglBh96VIJMKNGzeq3sKXGHvy6GXAnjzSZbXWk/eWhnryfmVPXm3YsmULxo0bh9LSUgDPl31p2LAhsrOzkZ2dDUEQ0KBBA3z//fd477331K5H5Tl50rF3ddPwBQwiIiIi4L333kOrVq0QGhqKw4cP4/Hjx3j8+DEAwNDQEAEBAQgNDcWrr75arXpUDvI2bNhQrYqIiIiohtTBixdUPZ06dcK+fftQWFiI1NRU5ObmwsLCAm5ubnJLxlRHlRZDJiIionqozLZk1SqDap2xsTHatm1bI2XzkRIRERHpIG5FQUREpO04XKuVTp8+jfPnzyMrKwslJSVK04hEIvzvf/9Tq3yVgrxNmzahadOm6NOnj1qVlBUbG4sHDx5g9OjR1S7r5eEIgG86kW56IoTUdROIakxubjGsrDbWfEV6qH6Q90wTDSFVxMfHY/z48fj7778rTCcIQs0HeWPHjkX37t01EuR98cUXOHnyJIM8IiIiTeGcPK3x119/oW/fvigpKcF7772HY8eO4fbt2/jss89w69YtnD9/HufPn4eJiQkmT54MCwv1l1DjcC0RERFRLVm0aBEKCwuxbt06BAUFwcfHB7dv38bnn38uS3Pw4EGMHz8esbGxOHXqlNp1qRzkXbx4ET179lS7orLlEBERkQZpYk4ed7yoFXFxcbCysqpw1ZKAgADs3LkTr732GubPn48lS5aoVZfKQV5OTg7i4uLUquRFXBiZiIhIgxjkaY3MzEy0bt0aenrPx8cNDJ6HYgUFBTAxMZGl69y5M1q1aoWdO3fWbJB39OhRtQonIiIion9ZWVnh2bN/33KxsbEBAKSnp8PDw0MuraGhoUo7jpVHpSDP19dX7QqIiIiohvHFC63h4OCA9PR02Z/btWuH3bt3Y+/evXJBXlpaGq5duwYrKyu16+IjJSIi0nb6Gjqoxvn5+eHRo0eyHrqRI0dCJBJhzpw5mDt3Lvbt24fvv/8eAQEBKCkpQb9+/dSui2/XEhEREdWSIUOGYNeuXThx4gScnJzQqlUrfP7555gzZw4WLlwoSycIAlxcXLBo0SK162KQR0REpO04XKs1XnvtNaSkpMidmz17Nrp3744tW7YgLS0NJiYm6N69OyZMmMB18oiIiF5qmtjxgkFenfLx8YGPj49Gy+QjJSIiIqolPXv2RL9+/VBcXFzjdTHIIyIi0nZ88UJrnDp1CpmZmTA0NKzxujhcS0REpO04J09rODg4oLCwsFbqUvmR9uzZEx9++GENNoWIiIjUwp48rTFkyBBcvXoV169fr/G6VA7y4uLicO7cuZpsCxEREZFOmzt3Lry8vPD222/j/PnzNVoXh2uJiIi0Hfeu1RoffPAB3NzcsGPHDnTs2BFt2rTBK6+8AjMzM6XpRSIR1q9fr1ZdDPKIiIi0HefkaY3o6GiIRCIIggAAuHTpEi5dulRuegZ5RERERFpgw4YNtVYXgzwiIiJtx+FarTFmzJhaq6tKQV5CQgL09dX7FohEIpSWlqqVl4iIiCogQvWHW0WaaAhVJiMjA8bGxrC1ta00bWZmJgoLC+Hg4KBWXVX6SgiCUK2DiIiI6GXm5OSEYcOGqZR2xIgRcHFxUbuuKvXktWvXDsuWLVO7MiIiIqoBHK7VKlXp+KpOJ1mVgjwrKyv4+vqqXRkRERHVAAZ5Oik3NxdGRkZq5+eLF0RERET1SFFREY4dO4YLFy7Azc1N7XK4Kg4REZG209PQQRoXHh4OfX192QH8+yJreYepqSn69u2LZ8+e4Z133lG7bvbkERERaTsO19ZbL758WnYh5PKYmJjAxcUFI0aMwKeffqp23QzyiIiItB2DvHorLCwMYWFhsj/r6emhe/fuiI+Pr/G6VQ7yJBJJTbaDiIiISOeFhoaqve5dVbEnj4iISNtx71qtERoaWmt1McgjIiLSdnqo/nArgzydw0dKREREVAPatm2LH3/8sdq7fmVkZGDSpElYvHhxlfIxyCMiItJ2XEKlXsrLy8O7774Ld3d3fP7550hJSVE5b3FxMXbt2oWhQ4fCzc0N69atU2m/27I4XEtERKTt+HZtvXT9+nUsW7YMixYtQmhoKMLCwuDq6gpvb2+8+uqraNasGWxsbGBkZITs7GxkZWXhypUrSEpKQlJSEp48eQJBEODv74/FixfDy8urSvWLhOr2IVKNyc3NhZWVFXJycmBpaVnXzSGqIRPqugFENSY3txhWVhtr7O9x2e/EN4ClSTXLKgCsPgJ/c2pAXl4eYmJisHbtWiQnJwN4vl6eMtKwzMzMDO+88w4mTJiAzp07q1Uve/KIiIi0HXvy6jULCwtMnjwZkydPRkpKCuLj43Hy5Emkp6fj4cOHKCwshI2NDWxtbeHl5YXu3buja9euMDU1rVa9DPKIiIi0HZdQ0Rpubm5wc3PD+PHja7wuPlIiIiIiHcQgj4iISNvpa+iogjt37iAyMhIBAQFwcHCAoaEhxGIxhgwZgjNnzlSprNu3b2PixImycuzs7BAUFIRbt25VmG/Xrl3w9/dHo0aNYGJiAmdnZ4wcObLSfDdv3oS5uTlEIhEmTZpUbrqtW7fC29sbZmZmsLa2Rr9+/ZCUlFSle6tLHK4lIiLSdnUwXPvtt99i8eLFcHV1hb+/P2xtbZGSkoLdu3dj9+7d2LZtG4YPH15pOTdu3EDXrl2RmZkJf39/jBgxAikpKdi4cSP279+PkydPwtXVVS6PIAiYNGkS1qxZA1dXV7zzzjuwsLDA3bt3cezYMaSnp8Pe3l5pfYIgICgoqNJ2ffnll5gzZw4cHBwwadIk5Ofn44cffkC3bt0QGxuLHj16qPQ5lfXPP//gl19+wZkzZ5CSkoLHjx+joKAAJiYmsLa2hpubG1577TUMGDCgysulKMO3a+sxvl1LLwe+XUu6q9berl0LWFZvjj5ynwJWIaq/Xbtz5040adIEPj4+cuePHz+OXr16yYIuIyOjCst56623sG/fPkRFRWHatGmy89u3b8fw4cPRp08fHDhwQC7PsmXLMH36dEyZMgVRUVHQ15fvhiwtLYWBgfJ+rGXLluHjjz/GkiVLMGPGDEycOBGrVq2SS5OSkoLWrVvDxcUFiYmJsLKyAgBcvnwZ3t7eaNasGa5evVpuHS8qLCzErFmzsGbNGpSUlFS4OLJIJEKDBg0QEhKCJUuWwMRE/dem2ZNHREREVTZ48GCl5318fODn54eDBw/i4sWL6NSpU7llFBYWIjY2Fk2bNsXUqVPlrg0bNgxeXl6IjY3F33//DRcXFwBAQUEBwsPD4eLigsjISIUAD0C5wVdqaipmz56NWbNmoUOHDuW2a8OGDSgtLcWcOXNkAR4AtGnTBqNHj8aqVatw5MgRBAQElFuGVFFREXr06IGzZ89CEAR4eHigW7ducHFxgbW1NYyMjFBUVITHjx/j77//RkJCAq5evYoVK1YgMTERx48fh6GhYaX1KMMgj4iISNvVsyVUGjRoAKD8YEvq0aNHKC0thaOjo9J145ydnZGcnIyjR4/KgrxDhw4hKysLY8eOxbNnz7Bnzx5cv34dDRs2RO/evdGyZUuldUkkEgQFBcHR0RHz5s3DqVOnym1XXFwcACgN4vr06YNVq1bh2LFjKgV5X331FRITE9GqVSt8//336NKlS6V5Tp48iXHjxiEpKQlLlizB3LlzK82jDIM8IiIibafBOXm5ublyp42MjCodci0rIyMDhw8fhlgsRrt27SpMa21tDX19faSnp0MQBIVA7+bNmwCe7xwhJX3xwcDAAJ6enrh27dq/t6Cnh48++ghff/21Ql2RkZE4efIkTpw4Uen9pKSkwNzcHGKxWOGam5ubLI0qtm3bBkNDQxw8eLDceYIv6tq1K2JjY+Hu7o6tW7eqHeTx7VoiIiKSsbe3h5WVlexYuHChynlLSkoQGBiIoqIiLFmyROlQalmmpqbw9fXFgwcPsGLFCrlrO3fulO0OkZ2dLTufmZkJAFi6dCksLS2RmJiIvLw8xMfHw93dHUuXLsXKlSvlyrp+/Trmzp2L6dOnq9STlpOTIzdMW5Z0vmJOTk6l5QDPA9W2bduqHOBJOTo6om3btkhLS6tSvrLYk0dERKTtNDhce+vWLbkXL1TtxZNIJBg3bhzi4+MREhKCwMBAlfJFRESge/fu+OCDD7B37160b98eqamp+OWXX9C+fXtcuHBBLliUSCQAAENDQ+zevRt2dnYAns8F3LFjB9q3b4+lS5di8uTJsvRjx46FnZ0dvvjiC5XapEnm5uaywLSqMjMzYWZmpnbd7MkjIiLSdhpcJ8/S0lLuUCXIEwQBISEhiImJwahRoxTeVq2Ip6cnzp49i+HDh+PcuXOIiorCtWvXsHr1almg2KRJE1l6aQ9bp06dZAGeVJs2beDi4oIbN27Iev+WLVuG06dPY926dSpvEyZd2UIZ6XB2eT19L+rSpQvu3LmDiIgIldJLff3117hz5w66du1apXxlMcgjIiIitUkkEowfPx7ff/89Ro4ciejoaOjpVS288PDwwI8//ojMzEwUFRXh8uXLCA4OxqVLlwBA7g3dVq1aAQAaNmyotCzp+YKCAgBAcnIyBEGAn58fRCKR7PDz8wMArF69GiKRCAMHDpSV4ebmhvz8fNy/f1+hfOlcPOncvMp8+umn0NPTw3//+1/069cPO3bswL1795SmvXfvHnbs2IG+ffvik08+gb6+PmbPnq1SPcpwuJaIiEjb1dHetRKJBMHBwdiwYQNGjBiBzZs3VzoPT1V5eXnYu3cvbGxs4O/vLzsvDc6uXLmikKekpASpqakwMzOT9f75+voqfcv33r172L9/v2xJk7JLqvj6+uLUqVM4ePAgRo8eLZcvNjZWlkYVXbp0QXR0NIKDg3HgwAFZfiMjIzRs2BCGhoYoLi5GdnY2ioqKADzvGTU0NMTatWvx+uuvq1SPMgzyiIiItF0dLKEi7cGLjo7GsGHDEBMTU2GA9/DhQzx8+BCNGzdG48aNZecLCgrQoEEDuUCsqKgI48ePR1ZWFqKiomBsbCy75urqioCAABw8eBDr1q1DcHCw7NqiRYuQnZ2NUaNGycoLCgpSusNFXFwc9u/fD19fX4Xh5aCgIHz99ddYsGAB3n77bbnFkDdt2gRXV1f07NlT5c/qvffeQ/fu3bFkyRLs3r0b9+7dQ2FhodKeQrFYjEGDBuG///0vnJycVK5DGQZ5REREVGXz589HdHQ0zM3N4e7urvSlhoEDB8LLywsAsHz5coSHhyM0NBRhYWGyNH/88QcGDx4Mf39/2NvbIzc3F/v27UNGRgZCQkIUFkkGgBUrVqBr164ICQnB7t274eHhgT///BNHjhyBo6Mjvvrqq2rdm7u7O8LCwjB37ly0b98eQ4cOxZMnT7Bt2zaUlJRg7dq1Ku92IeXo6IjvvvsO3333HTIyMmTbmhUWFsLY2Fi2rZmDg0O12l4WgzwiIiJtJ0L1h2sV1yKukHRpj/z8fCxYsEBpGicnJ1mQVx4HBwf06NEDx48fx4MHD2BqaoqOHTsiIiICQ4YMUZrH1dUVSUlJmDdvHg4cOICDBw9CLBZjypQpmDdvnkb2fZ0zZw6cnJwQGRmJlStXwtDQEF27dsX8+fPRuXPnapXt4OCg0WCuPNy7th7j3rX0cuDetaS7am3v2l2ApforbTwv6wlgNUj1vWup/mNPHhERkbarZ9uakWbcuXMHz549U7vXj0EeERERUT3k5eWFx48fo7S0VK38DPKIiIi0XR0toUI1rzqz6hjkERERaTsO15ISDPKIiIiIasiXX36pdl7prh3qYpBHRESk7diTV2/NnTsXIlEV16f5f4IgqJ0XYJBHRESk/Tgnr97S19eHRCLB4MGDYW5uXqW8P/zwA4qLi9Wum0EeERERUQ1p06YNLl68iJCQEAQEBFQp76+//oqsrCy162bcTkREpO308O+QrboHI4Ia4e3tDQBISkqq9br5SImIiLSdnoYO0jhvb28IgoAzZ85UOW91NyXjcC0RERFRDenduzemT5+Oxo0bVznvnj17UFJSonbdDPKIiIi0Hd+urbecnJzwzTffqJW3a9eu1aqbQR4REZG2Y5BHSjDIIyIi0nZcQoWU4CMlIiIi0kHsySMiItJ2HK7VGvr6qn/Qenp6sLCwgJOTE7p3747g4GC0b99e9fzqNJCIiIjqkequkaeJIJFUIgiCysezZ8+QnZ2N5ORkLF++HK+++iq++uorletikEdERERUSyQSCSIiImBkZIQxY8YgLi4OWVlZKCkpQVZWFo4dO4axY8fCyMgIERERyM/PR1JSEt5//30IgoBPP/0Uv//+u0p1cbiWiIhI24lQ/W4bkSYaQpX5+eef8fHHH2P58uWYPHmy3LWGDRvCx8cHPj4+6Ny5Mz744AM0b94cw4YNQ8eOHeHi4oKZM2di+fLl6NWrV6V1iYTqLqdMNSY3NxdWVlbIycmBpaVlXTeHqIZMqOsGENWY3NxiWFltrLG/x2W/ExcAS4tqlpUHWLUHf3NqWJcuXXDr1i3cvn270rQtWrRAixYtcPr0aQBAaWkpGjduDBMTE9y7d6/S/ByuJSIiIqolly5dQvPmzVVK27x5c/z111+yPxsYGMDd3R1ZWVkq5edwLRERkbbjOnlao0GDBrh+/TqKiopgZGRUbrqioiJcv34dBgbyoVpubi4sLFTrtuUjJSIi0nZ8u1ZrdOvWDbm5ufjggw8gkUiUphEEAVOnTkVOTg66d+8uO19cXIybN2/Czs5OpbrYk0dERERUS+bPn4/Dhw/j+++/x8mTJxEYGIj27dvDwsIC+fn5uHDhAmJiYvDXX3/ByMgI8+fPl+XdtWsXSkpK4Ofnp1JdDPKIiIi0HRdD1hodOnTA3r17ERgYiCtXrmDOnDkKaQRBgFgsxubNm+Hl5SU737RpU2zYsAE+Pj4q1cUgj4iISNtxTp5W6d27N1JSUrB161YcOnQIKSkpePLkCczMzODu7g5/f3+MHDkS5ubmcvl69OhRpXoY5BEREWk79uRpHXNzc0yYMAETJtTcMlKM24mIiIh0EHvyiIiItJ0eqt8Tx26fWnfz5k0cOnQI169fR15eHiwsLGTDtc7OztUun0EeERGRtuOcPK3y+PFjvP/++9i+fTukG48JggCR6PneciKRCCNGjMDy5cthbW2tdj0M8oiIiIhqSUFBAXr16oXz589DEAR06dIFbdq0QdOmTfHgwQNcvnwZp06dwg8//ICrV68iISEBxsbGatXFII+IiEjb8cULrfHNN98gOTkZHh4e2LRpEzp16qSQJikpCWPGjEFycjIiIyPx6aefqlUXO2eJiIi0nZ6GDqpxP/30E/T19fHrr78qDfAAoFOnTtizZw/09PTwww8/qF1XvX+k0dHREIlEFR69evWSy5Obm4sZM2bA0dERRkZGcHR0xIwZM5Cbm1tuPVu3boW3tzfMzMxgbW2Nfv36ISkpqcrtVaduIiIiejmkpqaibdu2cHFxqTCdq6sr2rZti9TUVLXrqvfDtV5eXggNDVV6bceOHbh8+TL69OkjO/fkyRP4+voiOTlZtpjg+fPn8c033+Do0aM4ceIEzMzM5Mr58ssvMWfOHDg4OGDSpEnIz8/HDz/8gG7duiE2NlblxQfVqZuIiKjaOFyrNfT19VFSUqJS2pKSEujpqd8fpxVBXtktPaSKi4uxfPlyGBgYYMyYMbLzS5YsQXJyMmbNmoXFixfLzoeGhmL+/PlYsmQJwsPDZedTUlIQGhoKd3d3JCYmwsrKCgAwbdo0eHt7Izg4GFevXoWBQeUfVVXrJiIi0ggGeVqjVatW+OOPP3D+/Hl4enqWmy45ORl//fUXOnfurHZd9X64tjy7du3Co0eP8NZbb6Fp06YAnr9+vG7dOpibm2PevHly6WfPng1ra2usX79e9royAGzYsAGlpaWYM2eOLMADgDZt2mD06NG4ceMGjhw5Uml71KmbiIiIXi6BgYEQBAFvvfUW9u7dqzTNnj17MGDAAIhEIgQGBqpdl9YGeevXrwcABAcHy86lpKTg7t276Natm8KwqLGxMd544w3cuXNHbnw7Li4OABAQEKBQh3QY+NixY5W2R526iYiINIIvXmiNyZMnw8/PD3fu3MHAgQPh7OyMvn37YsyYMejbty+cnJwwaNAg3L59G35+fpg8ebLaddX74Vpl0tPT8fvvv6N58+Z48803ZedTUlIAAG5ubkrzSc+npKTI/X9zc3OIxeIK01dGnbpfVFRUhKKiItmf+bIGERGpRKQH/P9CuuqXIQCQaKQ5VD4DAwPs27cPc+fOxapVq5Ceno709HS5NKamppg8eTI+//xz6OurP46ulUHehg0bIJFIEBQUJHfzOTk5ACA37FqWpaWlXDrp/7e1tVU5fXnUqftFCxcu5Jw9IiJSgwGAagZ5EAAUa6AtVBljY2N8/fXXCA0NxYkTJ3D9+nXk5+fD3Nwc7u7u6N69OywsLKpdj9YFeRKJBBs2bIBIJMK4cePqujkaNXv2bMyYMUP259zcXNjb29dhi4iIiKimWFhYoG/fvujbt2+NlK91Qd6hQ4eQkZGBXr16KWzeK+1FK6+3TDr8Wba3zcrKqkrpy6NO3S8yMjKCkZFRpXURERHJY09efZSRkaGRchwcHNTKp3VBnrIXLqQqm0OnbN6cm5sbTp06hfv37yvMy6tsnl116yYiItIMTQV5pElOTk4QVXOupEgkQmlpqVp5tSrIe/ToEX755RfY2Nhg0KBBCtfd3NxgZ2eHhIQEPHnyRO4t18LCQsTHx8POzg4tW7aUnff19cWpU6dw8OBBjB49Wq682NhYWZrKqFM3ERER6S4HB4dqB3nVoVUvTG/evBnFxcUYNWqU0mFNkUiE4OBg5OfnY/78+XLXFi5ciMePHyM4OFjuAw8KCoKBgQEWLFggN9R6+fJlbNq0Ca6urujZs6dcWRkZGbh69SqePn1arbqJiIg0Qx/P+22qc3A1ZE1LS0vDzZs3q32oS6uCvIqGaqVmzZoFLy8vLFmyBAEBAZg9ezb69euH+fPnw8vLC7NmzZJL7+7ujrCwMFy/fh3t27fHxx9/jEmTJqFr164oKSnB2rVrFXa7GD16NF555RUkJiZWq24iIiLNqG6AJz1Ud+fOHURGRiIgIAAODg4wNDSEWCzGkCFDcObMmSqVdfv2bUycOFFWjp2dHYKCgnDr1q0K8+3atQv+/v5o1KgRTExM4OzsjJEjRyrkW7t2Lf7zn//A2dkZZmZmsLKygqenJ+bNm4esrCyFctPS0iASico9fvjhhyrdX13RmuHaxMREXLp0Cd7e3mjXrl256czMzBAXF4fw8HDs2LEDcXFxEIvF+OijjxAaGqp079g5c+bAyckJkZGRWLlyJQwNDdG1a1fMnz+/StuJqFM3ERGRNvr222+xePFiuLq6wt/fH7a2tkhJScHu3buxe/dubNu2DcOHD6+0nBs3bqBr167IzMyEv78/RowYgZSUFGzcuBH79+/HyZMn4erqKpdHEARMmjQJa9asgaurK9555x1YWFjg7t27OHbsGNLT0+VWp9i8eTMeP34MHx8fNGvWDEVFRTh9+jQ+//xzbNy4EWfOnFG6Xq6npycGDhyocL5t27ZV/8DqgEjgPlv1Vm5uruztX+k6e0S6Z0JdN4CoxuTmFsPKamON/T3+7+9EM1haVm9wLjdXAiureyq3defOnWjSpAl8fHzkzh8/fhy9evWSBV2VrRrx1ltvYd++fYiKisK0adNk57dv347hw4ejT58+OHDggFyeZcuWYfr06ZgyZQqioqIUFgwuLS2VG4UrLCyEsbGxQt3/+9//8MUXX2DmzJn46quvZOfT0tLg7OyMMWPGIDo6utLPor7SquFaIiIiUqb2h2sHDx6sEOABgI+PD/z8/JCVlYWLFy9WWEZhYSFiY2PRtGlTTJ06Ve7asGHD4OXlhdjYWPz999+y8wUFBQgPD4eLiwsiIyOV7gjx4jQrZQGetA4AOrvlqNYM1xIREZF2aNCgAQDFYOtFjx49QmlpKRwdHZW+mOjs7Izk5GQcPXoULi4uAJ6vl5uVlYWxY8fi2bNn2LNnD65fv46GDRuid+/eVVrFYt++fQDKH369e/cuVq5ciezsbNjZ2aFXr15o0aKFyuXXNQZ5REREWk8f1R+c08zqDxkZGTh8+DDEYnGFc+gBwNraGvr6+khPT4cgCAqBnvTN0uvXr8vOJSUlAXgeQHp6euLatWuya3p6evjoo4/w9ddfK60vOjoaaWlpyMvLw7lz5xAXF4cOHTrI7TZV1qFDh3Do0CHZnw0MDDBt2jR89dVX0NOr/4Oh9b+FREREVAnNLaGSm5srdxQVFancipKSEgQGBqKoqAhLlixROpRalqmpKXx9ffHgwQOsWLFC7trOnTuRnJwMAMjOzpadz8zMBAAsXboUlpaWSExMRF5eHuLj4+Hu7o6lS5di5cqVSuuLjo5GeHg4IiIiEBcXh4CAABw4cADW1tYK7QoNDUVycjJyc3ORmZmJPXv2wM3NDREREZgzZ47Kn0ldYpBHRESk9TQ3J8/e3h5WVlayY+HChSq1QCKRYNy4cYiPj0dISAgCAwNVyhcREQFzc3N88MEHePPNNzFr1iwMHjwYw4YNQ/v27QFALliUSCQAAENDQ+zevRudO3eGubk5fHx8sGPHDujp6WHp0qVK64qLi4MgCPjnn3/w66+/4vbt2+jYsSMuXLggl87W1hZhYWHw9PSEhYUFmjRpgv/85z84cuQIGjVqhIiICDx+/Fil+6tLDPKIiIhI5tatW8jJyZEds2fPrjSPIAgICQlBTEwMRo0ahVWrVqlcn6enJ86ePYvhw4fj3LlziIqKwrVr17B69WpZoNikSRNZeuke8J06dYKdnZ1cWW3atIGLiwtu3Lgh1/v3osaNG6N///44cOAAHj58iJCQEJXaKhaL0a9fPxQXF+Ps2bMq32Nd4Zw8IiIiraeJHSuez4eztLSs0nIvEokEwcHB2LBhA0aOHIno6Ogqz1fz8PDAjz/+qHB+7NixAJ4HdFKtWrUCADRs2FBpWdLzBQUF5aaRsre3xyuvvIKzZ8/i6dOnMDU1rbStjRs3BgC5Xa/qKwZ5REREWk9zQV5VlA3wRowYgc2bN1c6D09VeXl52Lt3L2xsbODv7y877+fnBwC4cuWKQp6SkhKkpqbCzMxMrvevIvfu3YNIJFK53dLdrpycnFRKX5c4XEtERERVJpFIMH78eGzYsAHDhg1DTExMhYHSw4cPcfXqVTx8+FDufEFBAUpLS+XOFRUVYfz48cjKykJoaKjcOneurq4ICAhAamoq1q1bJ5dv0aJFyM7OxqBBg2TLtzx69AiXL19WaI8gCAgLC8ODBw/g5+cnt2hzYmIiSkpKFPJEREQgISEBrVu3hqenZwWfTv3AnjwiIiKtV/s9efPnz0d0dDTMzc3h7u6OL774QiHNwIED4eXlBQBYvnw5wsPDERoairCwMFmaP/74A4MHD4a/vz/s7e2Rm5uLffv2ISMjAyEhIQqLJAPAihUr0LVrV4SEhGD37t3w8PDAn3/+iSNHjsDR0VFu94pbt26hQ4cO8Pb2RuvWrSEWi/Hw4UMcP34c165dg1gsxnfffSdX/qxZs3D16lX4+vrC3t4eBQUFOHXqFP78809YW1tj8+bNStf1q28Y5BEREWk96RIqtSctLQ0AkJ+fjwULFihN4+TkJAvyyuPg4IAePXrg+PHjePDgAUxNTdGxY0dERERgyJAhSvO4uroiKSkJ8+bNw4EDB3Dw4EGIxWJMmTIF8+bNg62trSyto6MjZs+ejbi4OOzfvx9ZWVkwNjaGm5sb5s6diw8//BCNGjWSK3/UqFH4+eefcfLkSVnPo6OjI6ZPn46ZM2dqzYLI3Lu2HuPetfRy4N61pLtqb+9ab1haVi/Iy80thZVVIn9zdAh78oiIiLRe1feeJd3HbwQREZHWY5BHivh2LREREZEOYthPRESk9diTR4r4jSAiItJ6mni7lu9h6hoGeURERFpPEz15DPJ0DefkEREREekg9uQRERFpPfbkkSIGeURERFqPQR4p4nAtERERkQ5iTx4REZHWY08eKWKQR0REpPU0sYSKRBMNoXqEw7VEREREOog9eURERFpP//+P6pZBuoRBHhERkdbTxJw8DtfqGg7XEhEREekg9uQRERFpPfbkkSIGeURERFqPQR4pYpBHRESk9TSxhMozTTSE6hHOySMiIiLSQezJIyIi0nqaGK5lT56uYZBHRESk9RjkkSIO1xIRERHpIPbkERERaT325JEiBnlERERaTxNv15ZqoiFUj3C4loiIiEgHsSePiIhI62liuJYhga7hEyUiItJ6DPJIEYdriYiIiHQQw3YiIiKtx548UsQnSkREpPUY5JEiPlEiIiKtp4klVPQ10RCqRzgnj4iIiEgHsSePiIhI63G4lhTxiRIREWk9BnmkiMO1RERERDqIYTsREZHW00f1X5zgixe6hkEeERGR1uPbtaSIw7VEREREOog9eURERFqPL16QIj5RIiIirccgjxRxuJaIiIhIBzFsJyIi0nrsySNFfKJERERaj0EeKeITJSIi0npcQoUUcU4eERERkQ5ikEdERKT1DDR0qO7OnTuIjIxEQEAAHBwcYGhoCLFYjCFDhuDMmTNVKuv27duYOHGirBw7OzsEBQXh1q1bFebbtWsX/P390ahRI5iYmMDZ2RkjR45UyLd27Vr85z//gbOzM8zMzGBlZQVPT0/MmzcPWVlZ5Za/detWeHt7w8zMDNbW1ujXrx+S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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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IiKStQcPHqBnz544fvw4hBBo2bIlQkJC0KRJE7i6ukKpVOLBgwe4d+8e/v77byQlJSE1NRWff/45jh07hl9//RU2NjYGl8sQRUREJHf1vDtv6dKlOHbsGFq0aIGvv/4aXbt2rfKYw4cPIzo6GsnJyViyZAnmzp1rcLkyvmVEREQE4H/fzjN2kanNmzfDxsYGe/bs0StAAUC3bt2we/duWFlZYdOmTZLKZYgiIiIiWbt8+TLatm0LHx8fg47z8/ND27ZtkZaWJqlcducRERHJXT2fJ8rR0RE3b96UdOzNmzfh4OAg6Vi2RBEREcmdhYkWmeratSuuXr2Kjz76yKDjPvzwQ1y9ehXdunWTVK6MbxkRERERMHPmTFhYWOCf//wnnnvuOWzduhXXr1/Xue/169exdetW9O/fH++88w4sLS0xa9YsSeWyO4+IiEju6nl3XteuXbF27VrExMTg559/xu7duwEASqUSTzzxBGxsbFBUVITs7Gw8ePAAACCEgI2NDVatWoVnnnlGUrlsiSIiIpK7et6dBwAjR45EamoqJk6cCE9PTwghUFhYiKysLGRkZCArKwuFhYUQQqBhw4aYOHEiUlNTERkZKblMtkQRERHJHWcsB/Dw23afffYZPvvsM2RkZGge+1JYWAhbW1vNY198fX1NUh5DFBEREdU5vr6+JgtLFWGIIiIikrt6PibKXBiiiIiI5K6eP/bFGFevXkVpaamkViuGKCIiIqq3goKCcO/ePZSUlBh8LEMUERGR3LE7zyhCCEnHMUQRERHJHUOUWTBEERERkay99957ko8tKCiQfCxDFBERkdzV84Hlc+fOhUKhkHSsEELysQxRREREclfPu/MsLS1RVlaGl156CY6OjgYd+80336CoqEhSuQxRREREJGtt2rTByZMnMW7cOPTt29egY3fu3Im7d+9KKlfGjXdEREQEAFDA+OfmSevRqhWCg4MBAMnJyTVaLkMUERGR3FmaaJGp4OBgCCHw22+/GXys1OkNAHbnERERyV89HxPVp08fTJkyBe7u7gYf+8MPP6C4uFhSuQxRREREJGv+/v74+OOPJR3brVs3yeUyRBEREcldPZ/iwFwYooiIiOSunnfnmQtzJxEREZEEbIkiIiKSO7ZEabG01P9iLCws4OTkBH9/f4SGhiImJgbt27fX71ipFSQiIqJawtg5okwx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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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PgGFKhYDsirs33//haurq767QUREWkpISICLi4vO683JyYGFuTl0tfbI0dERd+/eZVCmBwzIqjD58/ES+gM2xnruDFEFcdyj7x4QVRwBIAdQ+bxTXcjLy4MAYA5A23kUASAxMRF5eXkMyPSAAVkVJp+mtDFmQEbVFyfjqSao6GUnhtBNQEb6w4CMiIhI4hiQSR/vsiQiIiLSM46QERERSZwBOEImdQzIiIiIJM4A2k95FemiI1RuDMiIiIgkzhDaB2S8wUa/uIaMiIiISM84QkZERCRxupiyJP1iQEZERCRxnLKUPgbURERERHrGETIiIiKJ4wiZ9DEgIyIikjiuIZM+vn9EREREesYRMiIiIokzwLNpS5IuBmREREQSp4spSz46Sb84ZUlERESkZxwhIyIikjhDcMpS6hiQERERSRwDMuljQEZERCRxXEMmfVxDRkRERKRnHCEjIiKSOE5ZSh8DMiIiIoljQCZ9nLIkIiIi0jOOkBEREUmcDNqPsBTpoiNUbgzIiIiIJE4XU5a8y1K/OGVJREREpGccISMiIpI4XeQh4wiNfjEgIyIikjhOWUofA2IiIiIiPeMIGRERkcRxhEz6GJARERFJHNeQSR8DMiIiIonjCJn0MSAmIiIi0jOOkBEREUmcAbQfIWOmfv1iQEZERCRxXEMmfbz+RERERHrGETIiIiKJ08Wifk5Z6hcDMiIiIonjlKX08foTERFRpTtx4gRmzZqFgIAA2NraQiaTYcyYMeWqSyaTlfpatmyZbjteQThCRkREJHFSnLLcsGEDNm3aBAsLCzRo0ADp6ela1efm5qYyoPPz89Oq3srCgIyIiEjipBiQTZs2De+//z68vb1x7tw5dO7cWav63N3dERoaqpvO6QEDMiIiIqp07dq103cXqhQGZERERBLHRf1Aamoq1q1bh6SkJNSpUwc9evSAp6envrulNgZkREREEqeLTP2F///f59dymZqawtTUVMvaK97ly5cxceJExc8ymQwjR47EmjVrYGFhoceeqUfqATEREVGNZ6ijFwC4urrC1tZW8Vq6dGllnkq5zJo1C2fOnEFycjJSUlJw7NgxdOzYEVu3bsX48eP13T21cISMiIiIFBISEmBjY6P4uazRMQcHBzx58kTtuiMiItCjRw9tuqfS559/rvRzQEAAjh49itatW2PHjh1YsGABmjdv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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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atQIc+fOxcyZM+Hk5KRHzyof0yZUYUybQDUC0yZQNVZZaROGA7A0sK58AFuBCutrdccRKiIiIpkzxb38SB0DKiIiIpkz1ZQf/T8GpEREREQG4ggVERGRzHHKz/QYUBEREckcp/xMjwEpERERkYE4QkVERCRzHKEyPQZUREREMifB8CknyRgdqcE45UdERERkII5QERERyRyn/EyPARUREZHMMaAyPQZUREREMsc8VKbH60dERERkII5QERERyRyn/EyPARUREZHMccrP9Hj9iIiIiAzEESoiIiKZ45Sf6TGgIiIikjkzGB4QccrKMLx+RERERAbiCBUREZHMcVG66TGgIiIikjmuoTI9BqREREREBuIIFRERkcxxhMr0GFARERHJHNdQmR4DKiIiIpnjCJXpMSAlIiIiMhBHqIiIiGSOU36mx4CKiIhI5pgp3fR4/YiIiIgMxBEqIiIimeOidNNjQEVERCRzXENlerx+RERERAbiCBUREZHMccrP9BhQERERyRwDKtPjlB8RERGRgThCRUREJHNclG56DKiIiIhkjlN+pseAioiISOYkGD7CJBmjIzVYlR/hy8jIwLRp09ClSxe4ublBoVCgfv366NmzJ/773/9CCFHimKysLMyYMQPe3t5QKBTw9vbGjBkzkJWVpbWdLVu2oGPHjrCxsYGTkxOeffZZxMfHl7u/+rRNRERE8iYJTRFJFXLhwgW0adMGnTt3RqNGjeDs7Izbt29j165duH37NiZMmIBvv/1WVT4nJwfdunXDmTNnEBoainbt2iEhIQH79u1DmzZtEBsbCxsbG7U2Fi1ahLlz58LLywuDBw/G/fv3sW3bNuTm5iIyMhLBwcE69VWftkuTlZUFBwcHZA4B7GvpfBiRvGyq0h9BRAZRfY5nZsLe3r7C6v8OQG0D63oAYBxQYX2t7qr8lF+DBg2QkZEBCwv1rmZnZ6Nz585YvXo1pk+fjhYtWgAAlixZgjNnzmD27Nn4+OOPVeXDwsIwf/58LFmyBBEREartqampCAsLg5+fH06dOgUHBwcAwLRp09CxY0eMHz8eycnJJdrXpLxtExERGQPXUJlelZ/yMzc31xjM2NnZoU+fPgAejWIBgBACa9asga2tLT744AO18u+++y6cnJywdu1atWnCdevWobCwEHPnzlUFUwDQokULjBo1ChcvXsThw4fL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hz08PBwHDx7EihUrKq1NIqrZGFAR1SCWlpbYv38/XnzxRdy8eROjRo2Cs7MzWrZsiY4dO8LDwwNNmjTB8uXL4ebmhkaNGgEAoqKicPXqVTz//PPIyMhARkYG7O3t0alTJ2zevFktWHJycsK9e/dKtH3v3j04OztX2rkSEVUmBlRENYytrS127NiBmJgYvPrqq/D09MSlS5eQkJAAIQT69euHtWvXIiUlBf7+/gD+P0XCW2+9BScnJ9XjxIkTuHz5MmJjY1X1t2jRAufOnSvR7rlz59CsWbPKOUkiokrGtAlENVT37t3RvXv3Msvl5uZix44deOaZZ/DOO++o7SsoKMCAAQOwadMmVV3PPfcc5s6dq5ZS4ffff8f58+exePFio55DWevAnuTh4VGpU49EVHNIgp8uRFSK7du3Y+jQodi9ezf69etXYv/QoUNx4MAB3Lx5E5aWlsjOzkarVq1Qp04dhIWFITc3F++88w6eeuopHD9+HGZmZQ+MX7p0CQ0aNIBCoVDlkBo3bhzGjRtn9PNTGjt2LFJTU5GZmYnExEQEBQUhKiqqwtojouqFU35EVKpNmzbBzc0NzzzzjMb9Y8eOxb1797Bnzx4AjzKyHz58GG5ubhg6dCheffVVdO7cGbt379YpmHpcXl4e4uLiEBcXhytXrhh8LqX5448/EBcXh8TExApth4iqJ45QERERERmII1REREREBmJARURERGQgBlREREREBmJARURERGQgBlREREREBmJARURERGQgBlREREREBmJARURERGQgBlREREREBmJARURERGQgBlREREREBmJARURERGSg/wNLJnUzYAtbewAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# This problem has three degrees of freedom. To draw the heatmap, it needs to fix one dimension\n", - "fixed = {\n", - " \"('T[0.125]','T[0.25]','T[0.375]','T[0.5]','T[0.625]','T[0.75]','T[0.875]','T[1]')\": 300\n", - "}\n", - "\n", - "all_fim.figure_drawing(\n", - " fixed, [\"CA0[0]\", \"T[0]\"], \"Reactor case\", \"$C_{A0}$ [M]\", \"T [K]\"\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "As seen in the Reactor Case - A optimality figure, A-optimality shows that the most informative region is around $C_{A0}=5.0$ M, $T=300.0$ K, while the least informative region is around $C_{A0}=1.0$ M, $T=700.0$ K.\n", - "\n", - "As seen in the Reactor Case - D optimality figure, D-optimality shows that the most informative region is around $C_{A0}=5.0$ M, $T=300.0$ K, while the least informative region is around $C_{A0}=1.0$ M, $T=700.0$ K.\n", - "\n", - "As seen in the Reactor Case - E optimality figure, E-optimality shows that the most informative region is around $C_{A0}=5.0$ M, $T=300.0$ K, while the least informative region is around $C_{A0}=1.0$ M, $T=700.0$ K.\n", - "\n", - "As seen in the Reactor Case - Modified E optimality figure, ME-optimality shows that the most informative region is around $C_{A0}=1.0$ M, $T=700.0$ K, while the least informative region is around $C_{A0}=5.0$ M, $T=300.0$ K." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Key Takeaways\n", - "\n", - "* MBDoE maximizes the information gained from experiments which reduces uncertainty (technical risk) and facilitates better decision-making.\n", - "\n", - "* FIM quantifies the information contained in a set of experiments (data) with respect to a mathematical model\n", - "\n", - "* MBDoE optimality criteria (e.g., A, D, E-optimal designs) compress the FIM into a scalar. The \"correct\" criterion depends on the DoE goal and model context.\n", - "\n", - "* Heatmaps provide visualizations of the most informative parameters using the MBDoE optimality criteria." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.11" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/pyomo/contrib/doe/examples/reactor_compute_FIM.py b/pyomo/contrib/doe/examples/reactor_compute_FIM.py deleted file mode 100644 index 108f5bd16a0..00000000000 --- a/pyomo/contrib/doe/examples/reactor_compute_FIM.py +++ /dev/null @@ -1,111 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - - -from pyomo.common.dependencies import numpy as np -from pyomo.contrib.doe.examples.reactor_kinetics import create_model, disc_for_measure -from pyomo.contrib.doe import DesignOfExperiments, MeasurementVariables, DesignVariables - - -def main(): - ### Define inputs - # Control time set [h] - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - # Define parameter nominal value - parameter_dict = {"A1": 85, "A2": 370, "E1": 8, "E2": 15} - - # Define measurement object - measurements = MeasurementVariables() - measurements.add_variables( - "C", # measurement variable name - indices={ - 0: ["CA", "CB", "CC"], - 1: t_control, - }, # 0,1 are indices of the index sets - time_index_position=1, - ) - - # design object - exp_design = DesignVariables() - - # add CAO as design variable - exp_design.add_variables( - "CA0", # design variable name - indices={0: [0]}, # index dictionary - time_index_position=0, # time index position - values=[5], # design variable values - lower_bounds=1, # design variable lower bounds - upper_bounds=5, # design variable upper bounds - ) - - # add T as design variable - exp_design.add_variables( - "T", # design variable name - indices={0: t_control}, # index dictionary - time_index_position=0, # time index position - values=[ - 570, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - ], # same length with t_control - lower_bounds=300, # design variable lower bounds - upper_bounds=700, # design variable upper bounds - ) - - ### Compute the FIM of a square model-based Design of Experiments problem - doe_object = DesignOfExperiments( - parameter_dict, # parameter dictionary - exp_design, # DesignVariables object - measurements, # MeasurementVariables object - create_model, # create model function - discretize_model=disc_for_measure, # discretize model function - ) - - result = doe_object.compute_FIM( - mode="sequential_finite", # calculation mode - scale_nominal_param_value=True, # scale nominal parameter value - formula="central", # formula for finite difference - ) - - result.result_analysis() - - # test result - relative_error = abs(np.log10(result.trace) - 2.78) - assert relative_error < 0.01 - - relative_error = abs(np.log10(result.det) - 2.99) - assert relative_error < 0.01 - - -if __name__ == "__main__": - main() diff --git a/pyomo/contrib/doe/examples/reactor_compute_factorial_FIM.py b/pyomo/contrib/doe/examples/reactor_compute_factorial_FIM.py new file mode 100644 index 00000000000..abff79cab7a --- /dev/null +++ b/pyomo/contrib/doe/examples/reactor_compute_factorial_FIM.py @@ -0,0 +1,97 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +from pyomo.common.dependencies import numpy as np, pathlib + +from pyomo.contrib.doe.examples.reactor_experiment import ReactorExperiment +from pyomo.contrib.doe import DesignOfExperiments + +import pyomo.environ as pyo + +import json + + +# Example to run a DoE on the reactor +def run_reactor_doe(): + # Read in file + DATA_DIR = pathlib.Path(__file__).parent + file_path = DATA_DIR / "result.json" + + with open(file_path) as f: + data_ex = json.load(f) + + # Put temperature control time points into correct format for reactor experiment + data_ex["control_points"] = { + float(k): v for k, v in data_ex["control_points"].items() + } + + # Create a ReactorExperiment object; data and discretization information are part + # of the constructor of this object + experiment = ReactorExperiment(data=data_ex, nfe=10, ncp=3) + + # Use a central difference, with step size 1e-3 + fd_formula = "central" + step_size = 1e-3 + + # Use the determinant objective with scaled sensitivity matrix + objective_option = "determinant" + scale_nominal_param_value = True + + # Create the DesignOfExperiments object + # We will not be passing any prior information in this example. + # We also will rely on the initialization routine within + # the DesignOfExperiments class. + doe_obj = DesignOfExperiments( + experiment, + fd_formula=fd_formula, + step=step_size, + objective_option=objective_option, + scale_constant_value=1, + scale_nominal_param_value=scale_nominal_param_value, + prior_FIM=None, + jac_initial=None, + fim_initial=None, + L_diagonal_lower_bound=1e-7, + solver=None, + tee=False, + get_labeled_model_args=None, + _Cholesky_option=True, + _only_compute_fim_lower=True, + ) + + # Make design ranges to compute the full factorial design + design_ranges = {"CA[0]": [1, 5, 9], "T[0]": [300, 700, 9]} + + # Compute the full factorial design with the sequential FIM calculation + doe_obj.compute_FIM_full_factorial(design_ranges=design_ranges, method="sequential") + + # Plot the results + doe_obj.draw_factorial_figure( + sensitivity_design_variables=["CA[0]", "T[0]"], + fixed_design_variables={ + "T[0.125]": 300, + "T[0.25]": 300, + "T[0.375]": 300, + "T[0.5]": 300, + "T[0.625]": 300, + "T[0.75]": 300, + "T[0.875]": 300, + "T[1]": 300, + }, + title_text="Reactor Example", + xlabel_text="Concentration of A (M)", + ylabel_text="Initial Temperature (K)", + figure_file_name="example_reactor_compute_FIM", + log_scale=False, + ) + + +if __name__ == "__main__": + run_reactor_doe() diff --git a/pyomo/contrib/doe/examples/reactor_example.py b/pyomo/contrib/doe/examples/reactor_example.py new file mode 100644 index 00000000000..0fe81c723dd --- /dev/null +++ b/pyomo/contrib/doe/examples/reactor_example.py @@ -0,0 +1,129 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +from pyomo.common.dependencies import numpy as np, pathlib + +from pyomo.contrib.doe.examples.reactor_experiment import ReactorExperiment +from pyomo.contrib.doe import DesignOfExperiments + +import pyomo.environ as pyo + +import json + + +# Example for sensitivity analysis on the reactor experiment +# After sensitivity analysis is done, we perform optimal DoE +def run_reactor_doe(): + # Read in file + DATA_DIR = pathlib.Path(__file__).parent + file_path = DATA_DIR / "result.json" + + with open(file_path) as f: + data_ex = json.load(f) + + # Put temperature control time points into correct format for reactor experiment + data_ex["control_points"] = { + float(k): v for k, v in data_ex["control_points"].items() + } + + # Create a ReactorExperiment object; data and discretization information are part + # of the constructor of this object + experiment = ReactorExperiment(data=data_ex, nfe=10, ncp=3) + + # Use a central difference, with step size 1e-3 + fd_formula = "central" + step_size = 1e-3 + + # Use the determinant objective with scaled sensitivity matrix + objective_option = "determinant" + scale_nominal_param_value = True + + # Create the DesignOfExperiments object + # We will not be passing any prior information in this example + # and allow the experiment object and the DesignOfExperiments + # call of ``run_doe`` perform model initialization. + doe_obj = DesignOfExperiments( + experiment, + fd_formula=fd_formula, + step=step_size, + objective_option=objective_option, + scale_constant_value=1, + scale_nominal_param_value=scale_nominal_param_value, + prior_FIM=None, + jac_initial=None, + fim_initial=None, + L_diagonal_lower_bound=1e-7, + solver=None, + tee=False, + get_labeled_model_args=None, + _Cholesky_option=True, + _only_compute_fim_lower=True, + ) + + # Make design ranges to compute the full factorial design + design_ranges = {"CA[0]": [1, 5, 9], "T[0]": [300, 700, 9]} + + # Compute the full factorial design with the sequential FIM calculation + doe_obj.compute_FIM_full_factorial(design_ranges=design_ranges, method="sequential") + + # Plot the results + doe_obj.draw_factorial_figure( + sensitivity_design_variables=["CA[0]", "T[0]"], + fixed_design_variables={ + "T[0.125]": 300, + "T[0.25]": 300, + "T[0.375]": 300, + "T[0.5]": 300, + "T[0.625]": 300, + "T[0.75]": 300, + "T[0.875]": 300, + "T[1]": 300, + }, + title_text="Reactor Example", + xlabel_text="Concentration of A (M)", + ylabel_text="Initial Temperature (K)", + figure_file_name="example_reactor_compute_FIM", + log_scale=False, + ) + + ########################### + # End sensitivity analysis + + # Begin optimal DoE + #################### + doe_obj.run_doe() + + # Print out a results summary + print("Optimal experiment values: ") + print( + "\tInitial concentration: {:.2f}".format( + doe_obj.results["Experiment Design"][0] + ) + ) + print( + ("\tTemperature values: [" + "{:.2f}, " * 8 + "{:.2f}]").format( + *doe_obj.results["Experiment Design"][1:] + ) + ) + print("FIM at optimal design:\n {}".format(np.array(doe_obj.results["FIM"]))) + print( + "Objective value at optimal design: {:.2f}".format( + pyo.value(doe_obj.model.objective) + ) + ) + + print(doe_obj.results["Experiment Design Names"]) + + ################### + # End optimal DoE + + +if __name__ == "__main__": + run_reactor_doe() diff --git a/pyomo/contrib/doe/examples/reactor_experiment.py b/pyomo/contrib/doe/examples/reactor_experiment.py new file mode 100644 index 00000000000..34a67a50f95 --- /dev/null +++ b/pyomo/contrib/doe/examples/reactor_experiment.py @@ -0,0 +1,214 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +# === Required imports === +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar, Simulator + +from pyomo.contrib.parmest.experiment import Experiment + + +# ======================== +class ReactorExperiment(Experiment): + def __init__(self, data, nfe, ncp): + """ + Arguments + --------- + data: object containing vital experimental information + nfe: number of finite elements + ncp: number of collocation points for the finite elements + """ + self.data = data + self.nfe = nfe + self.ncp = ncp + self.model = None + + ############################# + # End constructor definition + + def get_labeled_model(self): + if self.model is None: + self.create_model() + self.finalize_model() + self.label_experiment() + return self.model + + # Create flexible model without data + def create_model(self): + """ + This is an example user model provided to DoE library. + It is a dynamic problem solved by Pyomo.DAE. + + Return + ------ + m: a Pyomo.DAE model + """ + + m = self.model = pyo.ConcreteModel() + + # Model parameters + m.R = pyo.Param(mutable=False, initialize=8.314) + + # Define model variables + ######################## + # time + m.t = ContinuousSet(bounds=[0, 1]) + + # Concentrations + m.CA = pyo.Var(m.t, within=pyo.NonNegativeReals) + m.CB = pyo.Var(m.t, within=pyo.NonNegativeReals) + m.CC = pyo.Var(m.t, within=pyo.NonNegativeReals) + + # Temperature + m.T = pyo.Var(m.t, within=pyo.NonNegativeReals) + + # Arrhenius rate law equations + m.A1 = pyo.Var(within=pyo.NonNegativeReals) + m.E1 = pyo.Var(within=pyo.NonNegativeReals) + m.A2 = pyo.Var(within=pyo.NonNegativeReals) + m.E2 = pyo.Var(within=pyo.NonNegativeReals) + + # Differential variables (Conc.) + m.dCAdt = DerivativeVar(m.CA, wrt=m.t) + m.dCBdt = DerivativeVar(m.CB, wrt=m.t) + + ######################## + # End variable def. + + # Equation definition + ######################## + + # Expression for rate constants + @m.Expression(m.t) + def k1(m, t): + return m.A1 * pyo.exp(-m.E1 * 1000 / (m.R * m.T[t])) + + @m.Expression(m.t) + def k2(m, t): + return m.A2 * pyo.exp(-m.E2 * 1000 / (m.R * m.T[t])) + + # Concentration odes + @m.Constraint(m.t) + def CA_rxn_ode(m, t): + return m.dCAdt[t] == -m.k1[t] * m.CA[t] + + @m.Constraint(m.t) + def CB_rxn_ode(m, t): + return m.dCBdt[t] == m.k1[t] * m.CA[t] - m.k2[t] * m.CB[t] + + # algebraic balance for concentration of C + # Valid because the reaction system (A --> B --> C) is equimolar + @m.Constraint(m.t) + def CC_balance(m, t): + return m.CA[0] == m.CA[t] + m.CB[t] + m.CC[t] + + ######################## + # End equation definition + + def finalize_model(self): + """ + Example finalize model function. There are two main tasks + here: + + 1. Extracting useful information for the model to align + with the experiment. (Here: CA0, t_final, t_control) + 2. Discretizing the model subject to this information. + + """ + m = self.model + + # Unpacking data before simulation + control_points = self.data["control_points"] + + # Set initial concentration values for the experiment + m.CA[0].value = self.data["CA0"] + m.CB[0].fix(self.data["CB0"]) + + # Update model time `t` with time range and control time points + m.t.update(self.data["t_range"]) + m.t.update(control_points) + + # Fix the unknown parameter values + m.A1.fix(self.data["A1"]) + m.A2.fix(self.data["A2"]) + m.E1.fix(self.data["E1"]) + m.E2.fix(self.data["E2"]) + + # Add upper and lower bounds to the design variable, CA[0] + m.CA[0].setlb(self.data["CA_bounds"][0]) + m.CA[0].setub(self.data["CA_bounds"][1]) + + m.t_control = control_points + + # Discretizing the model + discr = pyo.TransformationFactory("dae.collocation") + discr.apply_to(m, nfe=self.nfe, ncp=self.ncp, wrt=m.t) + + # Initializing Temperature in the model + cv = None + for t in m.t: + if t in control_points: + cv = control_points[t] + m.T[t].setlb(self.data["T_bounds"][0]) + m.T[t].setub(self.data["T_bounds"][1]) + m.T[t] = cv + + # Make a constraint that holds temperature constant between control time points + @m.Constraint(m.t - control_points) + def T_control(m, t): + """ + Piecewise constant temperature between control points + """ + neighbour_t = max(tc for tc in control_points if tc < t) + return m.T[t] == m.T[neighbour_t] + + ######################### + # End model finalization + + def label_experiment(self): + """ + Example for annotating (labeling) the model with a + full experiment. + """ + m = self.model + + # Set measurement labels + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + # Add CA to experiment outputs + m.experiment_outputs.update((m.CA[t], None) for t in m.t_control) + # Add CB to experiment outputs + m.experiment_outputs.update((m.CB[t], None) for t in m.t_control) + # Add CC to experiment outputs + m.experiment_outputs.update((m.CC[t], None) for t in m.t_control) + + # Adding error for measurement values (assuming no covariance and constant error for all measurements) + m.measurement_error = pyo.Suffix(direction=pyo.Suffix.LOCAL) + concentration_error = 1e-2 # Error in concentration measurement + # Add measurement error for CA + m.measurement_error.update((m.CA[t], concentration_error) for t in m.t_control) + # Add measurement error for CB + m.measurement_error.update((m.CB[t], concentration_error) for t in m.t_control) + # Add measurement error for CC + m.measurement_error.update((m.CC[t], concentration_error) for t in m.t_control) + + # Identify design variables (experiment inputs) for the model + m.experiment_inputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + # Add experimental input label for initial concentration + m.experiment_inputs[m.CA[m.t.first()]] = None + # Add experimental input label for Temperature + m.experiment_inputs.update((m.T[t], None) for t in m.t_control) + + # Add unknown parameter labels + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + # Add labels to all unknown parameters with nominal value as the value + m.unknown_parameters.update((k, pyo.value(k)) for k in [m.A1, m.A2, m.E1, m.E2]) + + ######################### + # End model labeling diff --git a/pyomo/contrib/doe/examples/reactor_grid_search.py b/pyomo/contrib/doe/examples/reactor_grid_search.py deleted file mode 100644 index 1f5aae77f85..00000000000 --- a/pyomo/contrib/doe/examples/reactor_grid_search.py +++ /dev/null @@ -1,140 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - - -from pyomo.common.dependencies import numpy as np -from pyomo.contrib.doe.examples.reactor_kinetics import create_model, disc_for_measure -from pyomo.contrib.doe import DesignOfExperiments, MeasurementVariables, DesignVariables - - -def main(): - ### Define inputs - # Control time set [h] - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - # Define parameter nominal value - parameter_dict = {"A1": 85, "A2": 372, "E1": 8, "E2": 15} - - # measurement object - measurements = MeasurementVariables() - measurements.add_variables( - "C", # variable name - indices={0: ["CA", "CB", "CC"], 1: t_control}, # indices - time_index_position=1, - ) # position of time index - - # design object - exp_design = DesignVariables() - - # add CAO as design variable - exp_design.add_variables( - "CA0", # variable name - indices={0: [0]}, # indices - time_index_position=0, # position of time index - values=[5], # nominal value - lower_bounds=1, # lower bound - upper_bounds=5, # upper bound - ) - - # add T as design variable - exp_design.add_variables( - "T", # variable name - indices={0: t_control}, # indices - time_index_position=0, # position of time index - values=[470, 300, 300, 300, 300, 300, 300, 300, 300], # nominal value - lower_bounds=300, # lower bound - upper_bounds=700, # upper bound - ) - - # For each variable, we define a list of possible values that are used - # in the sensitivity analysis - - design_ranges = { - "CA0[0]": [1, 3, 5], - ( - "T[0]", - "T[0.125]", - "T[0.25]", - "T[0.375]", - "T[0.5]", - "T[0.625]", - "T[0.75]", - "T[0.875]", - "T[1]", - ): [300, 500, 700], - } - ## choose from "sequential_finite", "direct_kaug" - sensi_opt = "direct_kaug" - - doe_object = DesignOfExperiments( - parameter_dict, # parameter dictionary - exp_design, # design variables - measurements, # measurement variables - create_model, # model function - discretize_model=disc_for_measure, # discretization function - ) - # run full factorial grid search - all_fim = doe_object.run_grid_search(design_ranges, mode=sensi_opt) - - all_fim.extract_criteria() - - ### 3 design variable example - # Define design ranges - design_ranges = { - "CA0[0]": list(np.linspace(1, 5, 2)), - "T[0]": list(np.linspace(300, 700, 2)), - ( - "T[0.125]", - "T[0.25]", - "T[0.375]", - "T[0.5]", - "T[0.625]", - "T[0.75]", - "T[0.875]", - "T[1]", - ): [300, 500], - } - - sensi_opt = "direct_kaug" - - doe_object = DesignOfExperiments( - parameter_dict, # parameter dictionary - exp_design, # design variables - measurements, # measurement variables - create_model, # model function - discretize_model=disc_for_measure, # discretization function - ) - # run the grid search for 3 dimensional case - all_fim = doe_object.run_grid_search(design_ranges, mode=sensi_opt) - - all_fim.extract_criteria() - - # see the criteria values - all_fim.store_all_results_dataframe - - -if __name__ == "__main__": - main() diff --git a/pyomo/contrib/doe/examples/reactor_kinetics.py b/pyomo/contrib/doe/examples/reactor_kinetics.py deleted file mode 100644 index ed2175085f2..00000000000 --- a/pyomo/contrib/doe/examples/reactor_kinetics.py +++ /dev/null @@ -1,247 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - - -import pyomo.environ as pyo -from pyomo.dae import ContinuousSet, DerivativeVar -from pyomo.contrib.doe import ModelOptionLib - - -def disc_for_measure(m, nfe=32, block=True): - """Pyomo.DAE discretization - - Arguments - --------- - m: Pyomo model - nfe: number of finite elements b - block: if True, the input model has blocks - """ - discretizer = pyo.TransformationFactory("dae.collocation") - if block: - for s in range(len(m.block)): - discretizer.apply_to(m.block[s], nfe=nfe, ncp=3, wrt=m.block[s].t) - else: - discretizer.apply_to(m, nfe=nfe, ncp=3, wrt=m.t) - return m - - -def create_model( - mod=None, - model_option="stage2", - control_time=[0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1], - control_val=None, - t_range=[0.0, 1], - CA_init=1, - C_init=0.1, -): - """ - This is an example user model provided to DoE library. - It is a dynamic problem solved by Pyomo.DAE. - - Arguments - --------- - mod: Pyomo model. If None, a Pyomo concrete model is created - model_option: choose from the 3 options in model_option - if ModelOptionLib.parmest, create a process model. - if ModelOptionLib.stage1, create the global model. - if ModelOptionLib.stage2, add model variables and constraints for block. - control_time: a list of control timepoints - control_val: control design variable values T at corresponding timepoints - t_range: time range, h - CA_init: time-independent design (control) variable, an initial value for CA - C_init: An initial value for C - - Return - ------ - m: a Pyomo.DAE model - """ - - theta = {"A1": 84.79, "A2": 371.72, "E1": 7.78, "E2": 15.05} - - model_option = ModelOptionLib(model_option) - - if model_option == ModelOptionLib.parmest: - mod = pyo.ConcreteModel() - return_m = True - elif model_option == ModelOptionLib.stage1 or model_option == ModelOptionLib.stage2: - if not mod: - raise ValueError( - "If model option is stage1 or stage2, a created model needs to be provided." - ) - return_m = False - else: - raise ValueError( - "model_option needs to be defined as parmest,stage1, or stage2." - ) - - if not control_val: - control_val = [300] * 9 - - controls = {} - for i, t in enumerate(control_time): - controls[t] = control_val[i] - - mod.t0 = pyo.Set(initialize=[0]) - mod.t_con = pyo.Set(initialize=control_time) - mod.CA0 = pyo.Var( - mod.t0, initialize=CA_init, bounds=(1.0, 5.0), within=pyo.NonNegativeReals - ) # mol/L - - # check if control_time is in time range - assert ( - control_time[0] >= t_range[0] and control_time[-1] <= t_range[1] - ), "control time is outside time range." - - if model_option == ModelOptionLib.stage1: - mod.T = pyo.Var( - mod.t_con, - initialize=controls, - bounds=(300, 700), - within=pyo.NonNegativeReals, - ) - return - - else: - para_list = ["A1", "A2", "E1", "E2"] - - ### Add variables - mod.CA_init = CA_init - mod.para_list = para_list - - # timepoints - mod.t = ContinuousSet(bounds=t_range, initialize=control_time) - - # time-dependent design variable, initialized with the first control value - def T_initial(m, t): - if t in m.t_con: - return controls[t] - else: - # count how many control points are before the current t; - # locate the nearest neighbouring control point before this t - neighbour_t = max(tc for tc in control_time if tc < t) - return controls[neighbour_t] - - mod.T = pyo.Var( - mod.t, initialize=T_initial, bounds=(300, 700), within=pyo.NonNegativeReals - ) - - mod.R = 8.31446261815324 # J / K / mole - - # Define parameters as Param - mod.A1 = pyo.Var(initialize=theta["A1"]) - mod.A2 = pyo.Var(initialize=theta["A2"]) - mod.E1 = pyo.Var(initialize=theta["E1"]) - mod.E2 = pyo.Var(initialize=theta["E2"]) - - # Concentration variables under perturbation - mod.C_set = pyo.Set(initialize=["CA", "CB", "CC"]) - mod.C = pyo.Var( - mod.C_set, mod.t, initialize=C_init, within=pyo.NonNegativeReals - ) - - # time derivative of C - mod.dCdt = DerivativeVar(mod.C, wrt=mod.t) - - # kinetic parameters - def kp1_init(m, t): - return m.A1 * pyo.exp(-m.E1 * 1000 / (m.R * m.T[t])) - - def kp2_init(m, t): - return m.A2 * pyo.exp(-m.E2 * 1000 / (m.R * m.T[t])) - - mod.kp1 = pyo.Var(mod.t, initialize=kp1_init) - mod.kp2 = pyo.Var(mod.t, initialize=kp2_init) - - def T_control(m, t): - """ - T at interval timepoint equal to the T of the control time point at the beginning of this interval - Count how many control points are before the current t; - locate the nearest neighbouring control point before this t - """ - if t in m.t_con: - return pyo.Constraint.Skip - else: - neighbour_t = max(tc for tc in control_time if tc < t) - return m.T[t] == m.T[neighbour_t] - - def cal_kp1(m, t): - """ - Create the perturbation parameter sets - m: model - t: time - """ - # LHS: 1/h - # RHS: 1/h*(kJ/mol *1000J/kJ / (J/mol/K) / K) - return m.kp1[t] == m.A1 * pyo.exp(-m.E1 * 1000 / (m.R * m.T[t])) - - def cal_kp2(m, t): - """ - Create the perturbation parameter sets - m: model - t: time - """ - # LHS: 1/h - # RHS: 1/h*(kJ/mol *1000J/kJ / (J/mol/K) / K) - return m.kp2[t] == m.A2 * pyo.exp(-m.E2 * 1000 / (m.R * m.T[t])) - - def dCdt_control(m, y, t): - """ - Calculate CA in Jacobian matrix analytically - y: CA, CB, CC - t: timepoints - """ - if y == "CA": - return m.dCdt[y, t] == -m.kp1[t] * m.C["CA", t] - elif y == "CB": - return m.dCdt[y, t] == m.kp1[t] * m.C["CA", t] - m.kp2[t] * m.C["CB", t] - elif y == "CC": - return pyo.Constraint.Skip - - def alge(m, t): - """ - The algebraic equation for mole balance - z: m.pert - t: time - """ - return m.C["CA", t] + m.C["CB", t] + m.C["CC", t] == m.CA0[0] - - # Control time - mod.T_rule = pyo.Constraint(mod.t, rule=T_control) - - # calculating C, Jacobian, FIM - mod.k1_pert_rule = pyo.Constraint(mod.t, rule=cal_kp1) - mod.k2_pert_rule = pyo.Constraint(mod.t, rule=cal_kp2) - mod.dCdt_rule = pyo.Constraint(mod.C_set, mod.t, rule=dCdt_control) - - mod.alge_rule = pyo.Constraint(mod.t, rule=alge) - - # B.C. - mod.C["CB", 0.0].fix(0.0) - mod.C["CC", 0.0].fix(0.0) - - if return_m: - return mod diff --git a/pyomo/contrib/doe/examples/reactor_optimize_doe.py b/pyomo/contrib/doe/examples/reactor_optimize_doe.py deleted file mode 100644 index f7b4a74c891..00000000000 --- a/pyomo/contrib/doe/examples/reactor_optimize_doe.py +++ /dev/null @@ -1,123 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - -from pyomo.common.dependencies import numpy as np -from pyomo.contrib.doe.examples.reactor_kinetics import create_model, disc_for_measure -from pyomo.contrib.doe import DesignOfExperiments, MeasurementVariables, DesignVariables - - -def main(): - ### Define inputs - # Control time set [h] - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - # Define parameter nominal value - parameter_dict = {"A1": 85, "A2": 372, "E1": 8, "E2": 15} - - # measurement object - measurements = MeasurementVariables() - measurements.add_variables( - "C", # name of measurement - indices={0: ["CA", "CB", "CC"], 1: t_control}, # indices of measurement - time_index_position=1, - ) # position of time index - - # design object - exp_design = DesignVariables() - - # add CAO as design variable - exp_design.add_variables( - "CA0", # name of design variable - indices={0: [0]}, # indices of design variable - time_index_position=0, # position of time index - values=[5], # nominal value of design variable - lower_bounds=1, # lower bound of design variable - upper_bounds=5, # upper bound of design variable - ) - - # add T as design variable - exp_design.add_variables( - "T", # name of design variable - indices={0: t_control}, # indices of design variable - time_index_position=0, # position of time index - values=[ - 470, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - ], # nominal value of design variable - lower_bounds=300, # lower bound of design variable - upper_bounds=700, # upper bound of design variable - ) - - design_names = exp_design.variable_names - exp1 = [5, 570, 300, 300, 300, 300, 300, 300, 300, 300] - exp1_design_dict = dict(zip(design_names, exp1)) - exp_design.update_values(exp1_design_dict) - - # add a prior information (scaled FIM with T=500 and T=300 experiments) - prior = np.asarray( - [ - [28.67892806, 5.41249739, -81.73674601, -24.02377324], - [5.41249739, 26.40935036, -12.41816477, -139.23992532], - [-81.73674601, -12.41816477, 240.46276004, 58.76422806], - [-24.02377324, -139.23992532, 58.76422806, 767.25584508], - ] - ) - - doe_object2 = DesignOfExperiments( - parameter_dict, # dictionary of parameters - exp_design, # design variables - measurements, # measurement variables - create_model, # function to create model - prior_FIM=prior, # prior information - discretize_model=disc_for_measure, # function to discretize model - ) - - square_result, optimize_result = doe_object2.stochastic_program( - if_optimize=True, # if optimize - if_Cholesky=True, # if use Cholesky decomposition - scale_nominal_param_value=True, # if scale nominal parameter value - objective_option="det", # objective option - L_initial=np.linalg.cholesky(prior), # initial Cholesky decomposition - ) - - square_result, optimize_result = doe_object2.stochastic_program( - if_optimize=True, # if optimize - if_Cholesky=True, # if use Cholesky decomposition - scale_nominal_param_value=True, # if scale nominal parameter value - objective_option="trace", # objective option - L_initial=np.linalg.cholesky(prior), # initial Cholesky decomposition - ) - - -if __name__ == "__main__": - main() diff --git a/pyomo/contrib/doe/examples/result.json b/pyomo/contrib/doe/examples/result.json new file mode 100644 index 00000000000..7e1b1a79a1b --- /dev/null +++ b/pyomo/contrib/doe/examples/result.json @@ -0,0 +1 @@ +{"CA0": 5.0, "CA_bounds": [1.0, 5.0], "CB0": 0.0, "CC0": 0.0, "t_range": [0, 1], "control_points": {"0": 500, "0.125": 300, "0.25": 300, "0.375": 300, "0.5": 300, "0.625": 300, "0.75": 300, "0.875": 300, "1": 300}, "T_bounds": [300, 700], "A1": 84.79, "A2": 371.72, "E1": 7.78, "E2": 15.05} \ No newline at end of file diff --git a/pyomo/contrib/doe/measurements.py b/pyomo/contrib/doe/measurements.py deleted file mode 100644 index 5a3c44a76e4..00000000000 --- a/pyomo/contrib/doe/measurements.py +++ /dev/null @@ -1,328 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - -import itertools - - -class VariablesWithIndices: - def __init__(self): - """This class provides utility methods for DesignVariables and MeasurementVariables to create - lists of Pyomo variable names with an arbitrary number of indices. - """ - self.variable_names = [] - self.variable_names_value = {} - self.lower_bounds = {} - self.upper_bounds = {} - - def set_variable_name_list(self, variable_name_list): - """ - Specify variable names with its full name. - - Parameters - ---------- - variable_name_list: a ``list`` of ``string``, containing the variable names with indices, - for e.g. "C['CA', 23, 0]". - """ - self.variable_names.extend(variable_name_list) - - def add_variables( - self, - var_name, - indices=None, - time_index_position=None, - values=None, - lower_bounds=None, - upper_bounds=None, - ): - """ - Used for generating string names with indices. - - Parameters - ---------- - var_name: variable name in ``string`` - indices: a ``dict`` containing indices - if default (None), no extra indices needed for all var in var_name - for e.g., {0:["CA", "CB", "CC"], 1: [1,2,3]}. - time_index_position: an integer indicates which index is the time index - for e.g., 1 is the time index position in the indices example. - values: a ``list`` containing values which has the same shape of flattened variables - default choice is None, means there is no give nvalues - lower_bounds: a ``list `` of lower bounds. If given a scalar number, it is set as the lower bounds for all variables. - upper_bounds: a ``list`` of upper bounds. If given a scalar number, it is set as the upper bounds for all variables. - - Returns - ------- - if not defining values, return a set of variable names - if defining values, return a dictionary of variable names and its value - """ - added_names = self._generate_variable_names_with_indices( - var_name, indices=indices, time_index_position=time_index_position - ) - - self._check_valid_input( - len(added_names), - var_name, - indices, - time_index_position, - values, - lower_bounds, - upper_bounds, - ) - - if values: - # this dictionary keys are special set, values are its value - self.variable_names_value.update(zip(added_names, values)) - - # if a scalar (int or float) is given, set it as the lower bound for all variables - if lower_bounds: - if type(lower_bounds) in [int, float]: - lower_bounds = [lower_bounds] * len(added_names) - self.lower_bounds.update(zip(added_names, lower_bounds)) - - if upper_bounds: - if type(upper_bounds) in [int, float]: - upper_bounds = [upper_bounds] * len(added_names) - self.upper_bounds.update(zip(added_names, upper_bounds)) - - return added_names - - def _generate_variable_names_with_indices( - self, var_name, indices=None, time_index_position=None - ): - """ - Used for generating string names with indices. - - Parameters - ---------- - var_name: a ``list`` of var names - indices: a ``dict`` containing indices - if default (None), no extra indices needed for all var in var_name - for e.g., {0:["CA", "CB", "CC"], 1: [1,2,3]}. - time_index_position: an integer indicates which index is the time index - for e.g., 1 is the time index position in the indices example. - """ - # first combine all indices into a list - all_index_list = [] # contains all index lists - if indices: - for index_pointer in indices: - all_index_list.append(indices[index_pointer]) - - # all index list for one variable, such as ["CA", 10, 1] - # exhaustively enumerate over the full product of indices. For e.g., - # {0:["CA", "CB", "CC"], 1: [1,2,3]} - # becomes ["CA", 1], ["CA", 2], ..., ["CC", 2], ["CC", 3] - all_variable_indices = list(itertools.product(*all_index_list)) - - # list store all names added this time - added_names = [] - # iterate over index combinations ["CA", 1], ["CA", 2], ..., ["CC", 2], ["CC", 3] - for index_instance in all_variable_indices: - var_name_index_string = var_name + "[" - for i, idx in enumerate(index_instance): - # use repr() is different from using str() - # with repr(), "CA" is "CA", with str(), "CA" is CA. The first is not valid in our interface. - var_name_index_string += str(idx) - - # if i is the last index, close the []. if not, add a "," for the next index. - if i == len(index_instance) - 1: - var_name_index_string += "]" - else: - var_name_index_string += "," - - self.variable_names.append(var_name_index_string) - added_names.append(var_name_index_string) - - return added_names - - def _check_valid_input( - self, - len_indices, - var_name, - indices, - time_index_position, - values, - lower_bounds, - upper_bounds, - ): - """ - Check if the measurement information provided are valid to use. - """ - assert type(var_name) is str, "var_name should be a string." - - if time_index_position not in indices: - raise ValueError("time index cannot be found in indices.") - - # if given a list, check if bounds have the same length with flattened variable - if values and len(values) != len_indices: - raise ValueError("Values is of different length with indices.") - - if ( - lower_bounds - and type(lower_bounds) == list - and len(lower_bounds) != len_indices - ): - raise ValueError("Lowerbounds is of different length with indices.") - - if ( - upper_bounds - and type(upper_bounds) == list - and len(upper_bounds) != len_indices - ): - raise ValueError("Upperbounds is of different length with indices.") - - -class MeasurementVariables(VariablesWithIndices): - def __init__(self): - """ - This class stores information on which algebraic and differential variables in the Pyomo model are considered measurements. - """ - super().__init__() - self.variance = {} - - def set_variable_name_list(self, variable_name_list, variance=1): - """ - Specify variable names if given strings containing names and indices. - - Parameters - ---------- - variable_name_list: a ``list`` of ``string``, containing the variable names with indices, - for e.g. "C['CA', 23, 0]". - variance: a ``list`` of scalar numbers , which is the variance for this measurement. - """ - super().set_variable_name_list(variable_name_list) - - # add variance - if variance is not list: - variance = [variance] * len(variable_name_list) - - self.variance.update(zip(variable_name_list, variance)) - - def add_variables( - self, var_name, indices=None, time_index_position=None, variance=1 - ): - """ - Parameters - ----------- - var_name: a ``list`` of var names - indices: a ``dict`` containing indices - if default (None), no extra indices needed for all var in var_name - for e.g., {0:["CA", "CB", "CC"], 1: [1,2,3]}. - time_index_position: an integer indicates which index is the time index - for e.g., 1 is the time index position in the indices example. - variance: a scalar number, which is the variance for this measurement. - """ - added_names = super().add_variables( - var_name=var_name, indices=indices, time_index_position=time_index_position - ) - - # store variance - # if variance is a scalar number, repeat it for all added names - if variance is not list: - variance = [variance] * len(added_names) - self.variance.update(zip(added_names, variance)) - - def check_subset(self, subset_object): - """ - Check if subset_object is a subset of the current measurement object - - Parameters - ---------- - subset_object: a measurement object - """ - for name in subset_object.variable_names: - if name not in self.variable_names: - raise ValueError("Measurement not in the set: ", name) - - return True - - -class DesignVariables(VariablesWithIndices): - """ - Define design variables - """ - - def __init__(self): - super().__init__() - - def set_variable_name_list(self, variable_name_list): - """ - Specify variable names with its full name. - - Parameters - ---------- - variable_name_list: a ``list`` of ``string``, containing the variable names with indices, - for e.g. "C['CA', 23, 0]". - """ - super().set_variable_name_list(variable_name_list) - - def add_variables( - self, - var_name, - indices=None, - time_index_position=None, - values=None, - lower_bounds=None, - upper_bounds=None, - ): - """ - - Parameters - ---------- - var_name: a ``list`` of var names - indices: a ``dict`` containing indices - if default (None), no extra indices needed for all var in var_name - for e.g., {0:["CA", "CB", "CC"], 1: [1,2,3]}. - time_index_position: an integer indicates which index is the time index - for e.g., 1 is the time index position in the indices example. - values: a ``list`` containing values which has the same shape of flattened variables - default choice is None, means there is no give nvalues - lower_bounds: a ``list`` of lower bounds. If given a scalar number, it is set as the lower bounds for all variables. - upper_bounds: a ``list`` of upper bounds. If given a scalar number, it is set as the upper bounds for all variables. - """ - super().add_variables( - var_name=var_name, - indices=indices, - time_index_position=time_index_position, - values=values, - lower_bounds=lower_bounds, - upper_bounds=upper_bounds, - ) - - def update_values(self, new_value_dict): - """ - Update values of variables. Used for defining values for design variables of different experiments. - - Parameters - ---------- - new_value_dict: a ``dict`` containing the new values for the variables. - for e.g., {"C['CA', 23, 0]": 0.5, "C['CA', 24, 0]": 0.6} - """ - for key in new_value_dict: - if key not in self.variable_names: - raise ValueError("Variable not in the set: ", key) - - self.variable_names_value[key] = new_value_dict[key] diff --git a/pyomo/contrib/doe/result.py b/pyomo/contrib/doe/result.py deleted file mode 100644 index 1593214c30a..00000000000 --- a/pyomo/contrib/doe/result.py +++ /dev/null @@ -1,758 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - - -from pyomo.common.dependencies import numpy as np, pandas as pd, matplotlib as plt -from pyomo.core.expr.numvalue import value - -from itertools import product -import logging -from pyomo.opt import SolverStatus, TerminationCondition - - -class FisherResults: - def __init__( - self, - parameter_names, - measurements, - jacobian_info=None, - all_jacobian_info=None, - prior_FIM=None, - store_FIM=None, - scale_constant_value=1, - max_condition_number=1.0e12, - ): - """Analyze the FIM result for a single run - - Parameters - ---------- - parameter_names: - A ``list`` of parameter names - measurements: - A ``MeasurementVariables`` which contains the Pyomo variable names and their corresponding indices and - bounds for experimental measurements - jacobian_info: - the jacobian for this measurement object - all_jacobian_info: - the overall jacobian - prior_FIM: - if there's prior FIM to be added - store_FIM: - if storing the FIM in a .csv or .txt, give the file name here as a string - scale_constant_value: - scale all elements in Jacobian matrix, default is 1. - max_condition_number: - max condition number - """ - self.parameter_names = parameter_names - self.measurements = measurements - self.measurement_variables = measurements.variable_names - - if jacobian_info is None: - self.jaco_information = all_jacobian_info - else: - self.jaco_information = jacobian_info - self.all_jacobian_info = all_jacobian_info - - self.prior_FIM = prior_FIM - self.store_FIM = store_FIM - self.scale_constant_value = scale_constant_value - self.fim_scale_constant_value = scale_constant_value**2 - self.max_condition_number = max_condition_number - self.logger = logging.getLogger(__name__) - self.logger.setLevel(level=logging.WARN) - - def result_analysis(self, result=None): - """Calculate FIM from Jacobian information. This is for grid search (combined models) results - - Parameters - ---------- - result: - solver status returned by IPOPT - """ - self.result = result - self.doe_result = None - - # get number of parameters - no_param = len(self.parameter_names) - - fim = np.zeros((no_param, no_param)) - - # convert dictionary to a numpy array - Q_all = [] - for par in self.parameter_names: - Q_all.append(self.jaco_information[par]) - n = len(self.parameter_names) - - Q_all = np.array(list(self.jaco_information[p] for p in self.parameter_names)).T - # add the FIM for each measurement variables together - for i, mea_name in enumerate(self.measurement_variables): - fim += ( - 1 - / self.measurements.variance[str(mea_name)] # variance of measurement - * ( - Q_all[i, :].reshape(n, 1) @ Q_all[i, :].reshape(n, 1).T - ) # Q.T @ Q for each measurement variable - ) - - # add prior information - if self.prior_FIM is not None: - try: - fim = fim + self.prior_FIM - self.logger.info('Existed information has been added.') - except: - raise ValueError('Check the shape of prior FIM.') - - if np.linalg.cond(fim) > self.max_condition_number: - self.logger.info( - "Warning: FIM is near singular. The condition number is: %s ;", - np.linalg.cond(fim), - ) - self.logger.info( - 'A condition number bigger than %s is considered near singular.', - self.max_condition_number, - ) - - # call private methods - self._print_FIM_info(fim) - if self.result is not None: - self._get_solver_info() - - # if given store file name, store the FIM - if self.store_FIM is not None: - self._store_FIM() - - def subset(self, measurement_subset): - """Create new FisherResults object corresponding to provided measurement_subset. - This requires that measurement_subset is a true subset of the original measurement object. - - Parameters - ---------- - measurement_subset: Instance of Measurements class - - Returns - ------- - new_result: New instance of FisherResults - """ - - # Check that measurement_subset is a valid subset of self.measurement - self.measurements.check_subset(measurement_subset) - - # Split Jacobian (should already be 3D) - small_jac = self._split_jacobian(measurement_subset) - - # create a new subject - FIM_subset = FisherResults( - self.parameter_names, - measurement_subset, - jacobian_info=small_jac, - prior_FIM=self.prior_FIM, - store_FIM=self.store_FIM, - scale_constant_value=self.scale_constant_value, - max_condition_number=self.max_condition_number, - ) - - return FIM_subset - - def _split_jacobian(self, measurement_subset): - """ - Split jacobian - - Parameters - ---------- - measurement_subset: the object of the measurement subsets - - Returns - ------- - jaco_info: split Jacobian - """ - # create a dict for FIM. It has the same keys as the Jacobian dict. - jaco_info = {} - - # reorganize the jacobian subset with the same form of the jacobian - # loop over parameters - for par in self.parameter_names: - jaco_info[par] = [] - # loop over measurements - for name in measurement_subset.variable_names: - try: - n_all_measure = self.measurement_variables.index(name) - jaco_info[par].append(self.all_jacobian_info[par][n_all_measure]) - except: - raise ValueError( - "Measurement ", name, " is not in original measurement set." - ) - - return jaco_info - - def _print_FIM_info(self, FIM): - """ - using a dictionary to store all FIM information - - Parameters - ---------- - FIM: the Fisher Information Matrix, needs to be P.D. and symmetric - - Returns - ------- - fim_info: a FIM dictionary containing the following key:value pairs - ~['FIM']: a list of FIM itself - ~[design variable name]: a list of design variable values at each time point - ~['Trace']: a scalar number of Trace - ~['Determinant']: a scalar number of determinant - ~['Condition number:']: a scalar number of condition number - ~['Minimal eigen value:']: a scalar number of minimal eigen value - ~['Eigen values:']: a list of all eigen values - ~['Eigen vectors:']: a list of all eigen vectors - """ - eig = np.linalg.eigvals(FIM) - self.FIM = FIM - self.trace = np.trace(FIM) - self.det = np.linalg.det(FIM) - self.min_eig = min(eig) - self.cond = max(eig) / min(eig) - self.eig_vals = eig - self.eig_vecs = np.linalg.eig(FIM)[1] - - self.logger.info( - 'FIM: %s; \n Trace: %s; \n Determinant: %s;', self.FIM, self.trace, self.det - ) - self.logger.info( - 'Condition number: %s; \n Min eigenvalue: %s.', self.cond, self.min_eig - ) - - def _solution_info(self, m, dv_set): - """ - Solution information. Only for optimization problem - - Parameters - ---------- - m: model - dv_set: design variable dictionary - - Returns - ------- - model_info: model solutions dictionary containing the following key:value pairs - -['obj']: a scalar number of objective function value - -['det']: a scalar number of determinant calculated by the model (different from FIM_info['det'] which - is calculated by numpy) - -['trace']: a scalar number of trace calculated by the model - -[design variable name]: a list of design variable solution - """ - self.obj_value = value(m.obj) - - # When scaled with constant values, the effect of the scaling factors are removed here - # For determinant, the scaling factor to determinant is scaling factor ** (Dim of FIM) - # For trace, the scaling factor to trace is the scaling factor. - if self.obj == 'det': - self.obj_det = np.exp(value(m.obj)) / (self.fim_scale_constant_value) ** ( - len(self.parameter_names) - ) - elif self.obj == 'trace': - self.obj_trace = np.exp(value(m.obj)) / (self.fim_scale_constant_value) - - design_variable_names = list(dv_set.keys()) - dv_times = list(dv_set.values()) - - solution = {} - for d, dname in enumerate(design_variable_names): - sol = [] - if dv_times[d] is not None: - for t, time in enumerate(dv_times[d]): - newvar = getattr(m, dname)[time] - sol.append(value(newvar)) - else: - newvar = getattr(m, dname) - sol.append(value(newvar)) - - solution[dname] = sol - self.solution = solution - - def _store_FIM(self): - # if given store file name, store the FIM - store_dict = {} - for i, name in enumerate(self.parameter_names): - store_dict[name] = self.FIM[i] - FIM_store = pd.DataFrame(store_dict) - FIM_store.to_csv(self.store_FIM, index=False) - - def _get_solver_info(self): - """ - Solver information dictionary - - Return: - ------ - solver_status: a solver information dictionary containing the following key:value pairs - -['square']: a string of square result solver status - -['doe']: a string of doe result solver status - """ - - if (self.result.solver.status == SolverStatus.ok) and ( - self.result.solver.termination_condition == TerminationCondition.optimal - ): - self.status = 'converged' - elif ( - self.result.solver.termination_condition == TerminationCondition.infeasible - ): - self.status = 'infeasible' - else: - self.status = self.result.solver.status - - -class GridSearchResult: - def __init__( - self, - design_ranges, - design_dimension_names, - FIM_result_list, - store_optimality_name=None, - ): - """ - This class deals with the FIM results from grid search, providing A, D, E, ME-criteria results for each design variable. - Can choose to draw 1D sensitivity curves and 2D heatmaps. - - Parameters - ---------- - design_ranges: - a ``dict`` whose keys are design variable names, values are a list of design variable values to go over - design_dimension_names: - a ``list`` of design variables names - FIM_result_list: - a ``dict`` containing FIM results, keys are a tuple of design variable values, values are FIM result objects - store_optimality_name: - a .csv file name containing all four optimalities value - """ - # design variables - self.design_names = design_dimension_names - self.design_ranges = design_ranges - self.FIM_result_list = FIM_result_list - - self.store_optimality_name = store_optimality_name - - def extract_criteria(self): - """ - Extract design criteria values for every 'grid' (design variable combination) searched. - - Returns - ------- - self.store_all_results_dataframe: a pandas dataframe with columns as design variable names and A, D, E, ME-criteria names. - Each row contains the design variable value for this 'grid', and the 4 design criteria value for this 'grid'. - """ - - # a list store all results - store_all_results = [] - - # generate combinations of design variable values to go over - search_design_set = product(*self.design_ranges) - - # loop over deign value combinations - for design_set_iter in search_design_set: - # locate this grid in the dictionary of combined results - result_object_asdict = { - k: v for k, v in self.FIM_result_list.items() if k == design_set_iter - } - # an result object is identified by a tuple of the design variable value it uses - result_object_iter = result_object_asdict[design_set_iter] - - # store results as a row in the dataframe - store_iteration_result = list(design_set_iter) - store_iteration_result.append(result_object_iter.trace) - store_iteration_result.append(result_object_iter.det) - store_iteration_result.append(result_object_iter.min_eig) - store_iteration_result.append(result_object_iter.cond) - - # add this row to the dataframe - store_all_results.append(store_iteration_result) - - # generate column names for the dataframe - column_names = [] - # this count is for repeated design variable names which can happen in dynamic problems - for i in self.design_names: - # if design variables share the same value, use the first name as the column name - if type(i) is list: - column_names.append(i[0]) - else: - column_names.append(i) - - # Each design criteria has a column to store values - column_names.append('A') - column_names.append('D') - column_names.append('E') - column_names.append('ME') - # generate the dataframe - store_all_results = np.asarray(store_all_results) - self.store_all_results_dataframe = pd.DataFrame( - store_all_results, columns=column_names - ) - # if needs to store the values - if self.store_optimality_name is not None: - self.store_all_results_dataframe.to_csv( - self.store_optimality_name, index=False - ) - - def figure_drawing( - self, - fixed_design_dimensions, - sensitivity_dimension, - title_text, - xlabel_text, - ylabel_text, - font_axes=16, - font_tick=14, - log_scale=True, - ): - """ - Extract results needed for drawing figures from the overall result dataframe. - Draw 1D sensitivity curve or 2D heatmap. - It can be applied to results of any dimensions, but requires design variable values in other dimensions be fixed. - - Parameters - ---------- - fixed_design_dimensions: a dictionary, keys are the design variable names to be fixed, values are the value of it to be fixed. - sensitivity_dimension: a list of design variable names to draw figures. - If only one name is given, a 1D sensitivity curve is drawn - if two names are given, a 2D heatmap is drawn. - title_text: name of the figure, a string - xlabel_text: x label title, a string. - In a 1D sensitivity curve, it is the design variable by which the curve is drawn. - In a 2D heatmap, it should be the second design variable in the design_ranges - ylabel_text: y label title, a string. - A 1D sensitivity curve does not need it. In a 2D heatmap, it should be the first design variable in the dv_ranges - font_axes: axes label font size - font_tick: tick label font size - log_scale: if True, the result matrix will be scaled by log10 - - Returns - -------- - None - """ - self.fixed_design_names = list(fixed_design_dimensions.keys()) - self.fixed_design_values = list(fixed_design_dimensions.values()) - self.sensitivity_dimension = sensitivity_dimension - - if len(self.fixed_design_names) + len(self.sensitivity_dimension) != len( - self.design_names - ): - raise ValueError( - 'Error: All dimensions except for those the figures are drawn by should be fixed.' - ) - - if len(self.sensitivity_dimension) not in [1, 2]: - raise ValueError("Error: Either 1D or 2D figures can be drawn.") - - # generate a combination of logic sentences to filter the results of the DOF needed. - # an example filter: (self.store_all_results_dataframe["CA0"]==5). - if len(self.fixed_design_names) != 0: - filter = '' - for i in range(len(self.fixed_design_names)): - filter += '(self.store_all_results_dataframe[' - filter += str(self.fixed_design_names[i]) - filter += ']==' - filter += str(self.fixed_design_values[i]) - filter += ')' - if i != (len(self.fixed_design_names) - 1): - filter += '&' - # extract results with other dimensions fixed - figure_result_data = self.store_all_results_dataframe.loc[eval(filter)] - # if there is no other fixed dimensions - else: - figure_result_data = self.store_all_results_dataframe - - # add results for figures - self.figure_result_data = figure_result_data - - # if one design variable name is given as DOF, draw 1D sensitivity curve - if len(sensitivity_dimension) == 1: - self._curve1D( - title_text, xlabel_text, font_axes=16, font_tick=14, log_scale=True - ) - # if two design variable names are given as DOF, draw 2D heatmaps - elif len(sensitivity_dimension) == 2: - self._heatmap( - title_text, - xlabel_text, - ylabel_text, - font_axes=16, - font_tick=14, - log_scale=True, - ) - - def _curve1D( - self, title_text, xlabel_text, font_axes=16, font_tick=14, log_scale=True - ): - """ - Draw 1D sensitivity curves for all design criteria - - Parameters - ---------- - title_text: name of the figure, a string - xlabel_text: x label title, a string. - In a 1D sensitivity curve, it is the design variable by which the curve is drawn. - font_axes: axes label font size - font_tick: tick label font size - log_scale: if True, the result matrix will be scaled by log10 - - Returns - -------- - 4 Figures of 1D sensitivity curves for each criteria - """ - - # extract the range of the DOF design variable - x_range = self.figure_result_data[self.sensitivity_dimension[0]].values.tolist() - - # decide if the results are log scaled - if log_scale: - y_range_A = np.log10(self.figure_result_data['A'].values.tolist()) - y_range_D = np.log10(self.figure_result_data['D'].values.tolist()) - y_range_E = np.log10(self.figure_result_data['E'].values.tolist()) - y_range_ME = np.log10(self.figure_result_data['ME'].values.tolist()) - else: - y_range_A = self.figure_result_data['A'].values.tolist() - y_range_D = self.figure_result_data['D'].values.tolist() - y_range_E = self.figure_result_data['E'].values.tolist() - y_range_ME = self.figure_result_data['ME'].values.tolist() - - # Draw A-optimality - fig = plt.pyplot.figure() - plt.pyplot.rc('axes', titlesize=font_axes) - plt.pyplot.rc('axes', labelsize=font_axes) - plt.pyplot.rc('xtick', labelsize=font_tick) - plt.pyplot.rc('ytick', labelsize=font_tick) - ax = fig.add_subplot(111) - params = {'mathtext.default': 'regular'} - # plt.rcParams.update(params) - ax.plot(x_range, y_range_A) - ax.scatter(x_range, y_range_A) - ax.set_ylabel('$log_{10}$ Trace') - ax.set_xlabel(xlabel_text) - plt.pyplot.title(title_text + ' - A optimality') - plt.pyplot.show() - - # Draw D-optimality - fig = plt.pyplot.figure() - plt.pyplot.rc('axes', titlesize=font_axes) - plt.pyplot.rc('axes', labelsize=font_axes) - plt.pyplot.rc('xtick', labelsize=font_tick) - plt.pyplot.rc('ytick', labelsize=font_tick) - ax = fig.add_subplot(111) - params = {'mathtext.default': 'regular'} - # plt.rcParams.update(params) - ax.plot(x_range, y_range_D) - ax.scatter(x_range, y_range_D) - ax.set_ylabel('$log_{10}$ Determinant') - ax.set_xlabel(xlabel_text) - plt.pyplot.title(title_text + ' - D optimality') - plt.pyplot.show() - - # Draw E-optimality - fig = plt.pyplot.figure() - plt.pyplot.rc('axes', titlesize=font_axes) - plt.pyplot.rc('axes', labelsize=font_axes) - plt.pyplot.rc('xtick', labelsize=font_tick) - plt.pyplot.rc('ytick', labelsize=font_tick) - ax = fig.add_subplot(111) - params = {'mathtext.default': 'regular'} - # plt.rcParams.update(params) - ax.plot(x_range, y_range_E) - ax.scatter(x_range, y_range_E) - ax.set_ylabel('$log_{10}$ Minimal eigenvalue') - ax.set_xlabel(xlabel_text) - plt.pyplot.title(title_text + ' - E optimality') - plt.pyplot.show() - - # Draw Modified E-optimality - fig = plt.pyplot.figure() - plt.pyplot.rc('axes', titlesize=font_axes) - plt.pyplot.rc('axes', labelsize=font_axes) - plt.pyplot.rc('xtick', labelsize=font_tick) - plt.pyplot.rc('ytick', labelsize=font_tick) - ax = fig.add_subplot(111) - params = {'mathtext.default': 'regular'} - # plt.rcParams.update(params) - ax.plot(x_range, y_range_ME) - ax.scatter(x_range, y_range_ME) - ax.set_ylabel('$log_{10}$ Condition number') - ax.set_xlabel(xlabel_text) - plt.pyplot.title(title_text + ' - Modified E optimality') - plt.pyplot.show() - - def _heatmap( - self, - title_text, - xlabel_text, - ylabel_text, - font_axes=16, - font_tick=14, - log_scale=True, - ): - """ - Draw 2D heatmaps for all design criteria - - Parameters - ---------- - title_text: name of the figure, a string - xlabel_text: x label title, a string. - In a 2D heatmap, it should be the second design variable in the design_ranges - ylabel_text: y label title, a string. - In a 2D heatmap, it should be the first design variable in the dv_ranges - font_axes: axes label font size - font_tick: tick label font size - log_scale: if True, the result matrix will be scaled by log10 - - Returns - -------- - 4 Figures of 2D heatmap for each criteria - """ - - # achieve the design variable ranges this figure needs - # create a dictionary for sensitivity dimensions - sensitivity_dict = {} - for i, name in enumerate(self.design_names): - if name in self.sensitivity_dimension: - sensitivity_dict[name] = self.design_ranges[i] - elif name[0] in self.sensitivity_dimension: - sensitivity_dict[name[0]] = self.design_ranges[i] - - x_range = sensitivity_dict[self.sensitivity_dimension[0]] - y_range = sensitivity_dict[self.sensitivity_dimension[1]] - - # extract the design criteria values - A_range = self.figure_result_data['A'].values.tolist() - D_range = self.figure_result_data['D'].values.tolist() - E_range = self.figure_result_data['E'].values.tolist() - ME_range = self.figure_result_data['ME'].values.tolist() - - # reshape the design criteria values for heatmaps - cri_a = np.asarray(A_range).reshape(len(x_range), len(y_range)) - cri_d = np.asarray(D_range).reshape(len(x_range), len(y_range)) - cri_e = np.asarray(E_range).reshape(len(x_range), len(y_range)) - cri_e_cond = np.asarray(ME_range).reshape(len(x_range), len(y_range)) - - self.cri_a = cri_a - self.cri_d = cri_d - self.cri_e = cri_e - self.cri_e_cond = cri_e_cond - - # decide if log scaled - if log_scale: - hes_a = np.log10(self.cri_a) - hes_e = np.log10(self.cri_e) - hes_d = np.log10(self.cri_d) - hes_e2 = np.log10(self.cri_e_cond) - else: - hes_a = self.cri_a - hes_e = self.cri_e - hes_d = self.cri_d - hes_e2 = self.cri_e_cond - - # set heatmap x,y ranges - xLabel = x_range - yLabel = y_range - - # A-optimality - fig = plt.pyplot.figure() - plt.pyplot.rc('axes', titlesize=font_axes) - plt.pyplot.rc('axes', labelsize=font_axes) - plt.pyplot.rc('xtick', labelsize=font_tick) - plt.pyplot.rc('ytick', labelsize=font_tick) - ax = fig.add_subplot(111) - params = {'mathtext.default': 'regular'} - plt.pyplot.rcParams.update(params) - ax.set_yticks(range(len(yLabel))) - ax.set_yticklabels(yLabel) - ax.set_ylabel(ylabel_text) - ax.set_xticks(range(len(xLabel))) - ax.set_xticklabels(xLabel) - ax.set_xlabel(xlabel_text) - im = ax.imshow(hes_a.T, cmap=plt.pyplot.cm.hot_r) - ba = plt.pyplot.colorbar(im) - ba.set_label('log10(trace(FIM))') - plt.pyplot.title(title_text + ' - A optimality') - plt.pyplot.show() - - # D-optimality - fig = plt.pyplot.figure() - plt.pyplot.rc('axes', titlesize=font_axes) - plt.pyplot.rc('axes', labelsize=font_axes) - plt.pyplot.rc('xtick', labelsize=font_tick) - plt.pyplot.rc('ytick', labelsize=font_tick) - ax = fig.add_subplot(111) - params = {'mathtext.default': 'regular'} - plt.pyplot.rcParams.update(params) - ax.set_yticks(range(len(yLabel))) - ax.set_yticklabels(yLabel) - ax.set_ylabel(ylabel_text) - ax.set_xticks(range(len(xLabel))) - ax.set_xticklabels(xLabel) - ax.set_xlabel(xlabel_text) - im = ax.imshow(hes_d.T, cmap=plt.pyplot.cm.hot_r) - ba = plt.pyplot.colorbar(im) - ba.set_label('log10(det(FIM))') - plt.pyplot.title(title_text + ' - D optimality') - plt.pyplot.show() - - # E-optimality - fig = plt.pyplot.figure() - plt.pyplot.rc('axes', titlesize=font_axes) - plt.pyplot.rc('axes', labelsize=font_axes) - plt.pyplot.rc('xtick', labelsize=font_tick) - plt.pyplot.rc('ytick', labelsize=font_tick) - ax = fig.add_subplot(111) - params = {'mathtext.default': 'regular'} - plt.pyplot.rcParams.update(params) - ax.set_yticks(range(len(yLabel))) - ax.set_yticklabels(yLabel) - ax.set_ylabel(ylabel_text) - ax.set_xticks(range(len(xLabel))) - ax.set_xticklabels(xLabel) - ax.set_xlabel(xlabel_text) - im = ax.imshow(hes_e.T, cmap=plt.pyplot.cm.hot_r) - ba = plt.pyplot.colorbar(im) - ba.set_label('log10(minimal eig(FIM))') - plt.pyplot.title(title_text + ' - E optimality') - plt.pyplot.show() - - # modified E-optimality - fig = plt.pyplot.figure() - plt.pyplot.rc('axes', titlesize=font_axes) - plt.pyplot.rc('axes', labelsize=font_axes) - plt.pyplot.rc('xtick', labelsize=font_tick) - plt.pyplot.rc('ytick', labelsize=font_tick) - ax = fig.add_subplot(111) - params = {'mathtext.default': 'regular'} - plt.pyplot.rcParams.update(params) - ax.set_yticks(range(len(yLabel))) - ax.set_yticklabels(yLabel) - ax.set_ylabel(ylabel_text) - ax.set_xticks(range(len(xLabel))) - ax.set_xticklabels(xLabel) - ax.set_xlabel(xlabel_text) - im = ax.imshow(hes_e2.T, cmap=plt.pyplot.cm.hot_r) - ba = plt.pyplot.colorbar(im) - ba.set_label('log10(cond(FIM))') - plt.pyplot.title(title_text + ' - Modified E-optimality') - plt.pyplot.show() diff --git a/pyomo/contrib/doe/scenario.py b/pyomo/contrib/doe/scenario.py deleted file mode 100644 index 6c6f5ef7d1b..00000000000 --- a/pyomo/contrib/doe/scenario.py +++ /dev/null @@ -1,154 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - -import pickle -from enum import Enum -from collections import namedtuple - - -class FiniteDifferenceStep(Enum): - forward = "forward" - central = "central" - backward = "backward" - - -# namedtuple for scenario data -ScenarioData = namedtuple( - "ScenarioData", ["scenario", "scena_num", "eps_abs", "scenario_indices"] -) - - -class ScenarioGenerator: - def __init__(self, parameter_dict=None, formula="central", step=0.001, store=False): - """Generate scenarios. - DoE library first calls this function to generate scenarios. - - Parameters - ----------- - parameter_dict: - a ``dict`` of parameter, keys are names of ''string'', values are their nominal value of ''float''. - for e.g., {'A1': 84.79, 'A2': 371.72, 'E1': 7.78, 'E2': 15.05} - formula: - choose from 'central', 'forward', 'backward', None. - step: - Sensitivity perturbation step size, a fraction between [0,1]. default is 0.001 - store: - if True, store results. - """ - # get info from parameter dictionary - self.parameter_dict = parameter_dict - self.para_names = list(parameter_dict.keys()) - self.no_para = len(self.para_names) - self.formula = FiniteDifferenceStep(formula) - self.step = step - self.store = store - self.scenario_nominal = [parameter_dict[d] for d in self.para_names] - - # generate scenarios - self.generate_scenario() - - def generate_scenario(self): - """ - Generate scenario data for the given parameter dictionary. - - Returns: - ------- - ScenarioData: a namedtuple containing scenarios information. - ScenarioData.scenario: a list of dictionaries, each dictionary contains a perturbed scenario - ScenarioData.scena_num: a dict of scenario number related to one parameter - ScenarioData.eps_abs: keys are parameter name, values are the step it is perturbed - ScenarioData.scenario_indices: a list of scenario indices - - - For e.g., if a dict {'P':100, 'D':20} is given, step=0.1, formula='central', it will return: - self.ScenarioData.scenario: [{'P':101, 'D':20}, {'P':99, 'D':20}, {'P':100, 'D':20.2}, {'P':100, 'D':19.8}], - self.ScenarioData.scena_num: {'P':[0,1], 'D':[2,3]}} - self.ScenarioData.eps_abs: {'P': 2.0, 'D': 0.4} - self.ScenarioData.scenario_indices: [0,1,2,3] - if formula ='forward', it will return: - self.ScenarioData.scenario:[{'P':101, 'D':20}, {'P':100, 'D':20.2}, {'P':100, 'D':20}], - self.ScenarioData.scena_num: {'P':[0,2], 'D':[1,2]}} - self.ScenarioData.eps_abs: {'P': 2.0, 'D': 0.4} - self.ScenarioData.scenario_indices: [0,1,2] - """ - # dict for parameter perturbation step size - eps_abs = {} - # scenario dict for block - scenario = [] - # number of scenario - scena_num = {} - - # loop over parameter name - for p, para in enumerate(self.para_names): - ## get scenario dictionary - if self.formula == FiniteDifferenceStep.central: - scena_num[para] = [2 * p, 2 * p + 1] - scena_dict_up, scena_dict_lo = ( - self.parameter_dict.copy(), - self.parameter_dict.copy(), - ) - # corresponding parameter dictionary for the scenario - scena_dict_up[para] *= 1 + self.step - scena_dict_lo[para] *= 1 - self.step - - scenario.append(scena_dict_up) - scenario.append(scena_dict_lo) - - elif self.formula in [ - FiniteDifferenceStep.forward, - FiniteDifferenceStep.backward, - ]: - # the base case is added as the last one - scena_num[para] = [p, len(self.param_names)] - scena_dict_up, scena_dict_lo = ( - self.parameter_dict.copy(), - self.parameter_dict.copy(), - ) - if self.formula == FiniteDifferenceStep.forward: - scena_dict_up[para] *= 1 + self.step - - elif self.formula == FiniteDifferenceStep.backward: - scena_dict_lo[para] *= 1 - self.step - - scenario.append(scena_dict_up) - scenario.append(scena_dict_lo) - - ## get perturbation sizes - # for central difference scheme, perturbation size is two times the step size - if self.formula == FiniteDifferenceStep.central: - eps_abs[para] = 2 * self.step * self.parameter_dict[para] - else: - eps_abs[para] = self.step * self.parameter_dict[para] - - self.ScenarioData = ScenarioData( - scenario, scena_num, eps_abs, list(range(len(scenario))) - ) - - # store scenario - if self.store: - with open('scenario_simultaneous.pickle', 'wb') as f: - pickle.dump(self.scenario_data, f) diff --git a/pyomo/contrib/doe/tests/__init__.py b/pyomo/contrib/doe/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/doe/tests/__init__.py +++ b/pyomo/contrib/doe/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/doe/tests/experiment_class_example_flags.py b/pyomo/contrib/doe/tests/experiment_class_example_flags.py new file mode 100644 index 00000000000..fc2acb816e5 --- /dev/null +++ b/pyomo/contrib/doe/tests/experiment_class_example_flags.py @@ -0,0 +1,262 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +# === Required imports === +import pyomo.environ as pyo +from pyomo.dae import ContinuousSet, DerivativeVar, Simulator + +from pyomo.contrib.parmest.experiment import Experiment + +import itertools + +# ======================== + + +def expand_model_components(m, base_components, index_sets): + """ + Takes model components and index sets and returns the + model component labels. + + Arguments + --------- + m: Pyomo model + base_components: list of variables from model 'm' + index_sets: list, same length as base_components, where each + element is a list of index sets, or None + """ + for val, indexes in itertools.zip_longest(base_components, index_sets): + # If the variable has no index, + # add just the model component + if not val.is_indexed(): + yield val + # If the component is indexed but no + # index supplied, add all indices + elif indexes is None: + yield from val.values() + else: + for j in itertools.product(*indexes): + yield val[j] + + +class BadExperiment(object): + def __init__(self): + self.model = None + + +class ReactorExperiment(Experiment): + def __init__(self, data, nfe, ncp): + self.data = data + self.nfe = nfe + self.ncp = ncp + self.model = None + + def get_labeled_model(self, flag=0): + if self.model is None: + self.create_model() + self.finalize_model() + self.label_experiment(flag=flag) + return self.model + + def create_model(self): + """ + This is an example user model provided to DoE library. + It is a dynamic problem solved by Pyomo.DAE. + + Return + ------ + m: a Pyomo.DAE model + """ + + m = self.model = pyo.ConcreteModel() + + # Model parameters + m.R = pyo.Param(mutable=False, initialize=8.314) + + # Define model variables + ######################## + # time + m.t = ContinuousSet(bounds=[0, 1]) + + # Concentrations + m.CA = pyo.Var(m.t, within=pyo.NonNegativeReals) + m.CB = pyo.Var(m.t, within=pyo.NonNegativeReals) + m.CC = pyo.Var(m.t, within=pyo.NonNegativeReals) + + # Temperature + m.T = pyo.Var(m.t, within=pyo.NonNegativeReals) + + # Arrhenius rate law equations + m.A1 = pyo.Var(within=pyo.NonNegativeReals) + m.E1 = pyo.Var(within=pyo.NonNegativeReals) + m.A2 = pyo.Var(within=pyo.NonNegativeReals) + m.E2 = pyo.Var(within=pyo.NonNegativeReals) + + # Differential variables (Conc.) + m.dCAdt = DerivativeVar(m.CA, wrt=m.t) + m.dCBdt = DerivativeVar(m.CB, wrt=m.t) + + ######################## + # End variable def. + + # Equation def'n + ######################## + + # Expression for rate constants + @m.Expression(m.t) + def k1(m, t): + return m.A1 * pyo.exp(-m.E1 * 1000 / (m.R * m.T[t])) + + @m.Expression(m.t) + def k2(m, t): + return m.A2 * pyo.exp(-m.E2 * 1000 / (m.R * m.T[t])) + + # Concentration odes + @m.Constraint(m.t) + def CA_rxn_ode(m, t): + return m.dCAdt[t] == -m.k1[t] * m.CA[t] + + @m.Constraint(m.t) + def CB_rxn_ode(m, t): + return m.dCBdt[t] == m.k1[t] * m.CA[t] - m.k2[t] * m.CB[t] + + # algebraic balance for concentration of C + # Valid because the reaction system (A --> B --> C) is equimolar + @m.Constraint(m.t) + def CC_balance(m, t): + return m.CA[0] == m.CA[t] + m.CB[t] + m.CC[t] + + ######################## + # End equation def'n + + def finalize_model(self): + """ + Example finalize model function. There are two main tasks + here: + 1. Extracting useful information for the model to align + with the experiment. (Here: CA0, t_final, t_control) + 2. Discretizing the model subject to this information. + + Arguments + --------- + m: Pyomo model + data: object containing vital experimental information + nfe: number of finite elements + ncp: number of collocation points for the finite elements + """ + m = self.model + + # Unpacking data before simulation + control_points = self.data["control_points"] + + m.CA[0].value = self.data["CA0"] + m.CB[0].fix(self.data["CB0"]) + m.t.update(self.data["t_range"]) + m.t.update(control_points) + m.A1.fix(self.data["A1"]) + m.A2.fix(self.data["A2"]) + m.E1.fix(self.data["E1"]) + m.E2.fix(self.data["E2"]) + + m.CA[0].setlb(self.data["CA_bounds"][0]) + m.CA[0].setub(self.data["CA_bounds"][1]) + + m.t_control = control_points + + # Discretizing the model + discr = pyo.TransformationFactory("dae.collocation") + discr.apply_to(m, nfe=self.nfe, ncp=self.ncp, wrt=m.t) + + # Initializing Temperature in the model + cv = None + for t in m.t: + if t in control_points: + cv = control_points[t] + m.T[t].setlb(self.data["T_bounds"][0]) + m.T[t].setub(self.data["T_bounds"][1]) + m.T[t] = cv + + @m.Constraint(m.t - control_points) + def T_control(m, t): + """ + Piecewise constant Temperature between control points + """ + neighbour_t = max(tc for tc in control_points if tc < t) + return m.T[t] == m.T[neighbour_t] + + # sim.initialize_model() + + def label_experiment_impl(self, index_sets_meas, flag=0): + """ + Example for annotating (labeling) the model with a + full experiment. + + Arguments + --------- + + """ + m = self.model + base_comp_meas = [m.CA, m.CB, m.CC] + + if flag != 1: + # Grab measurement labels + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + (k, None) + for k in expand_model_components(m, base_comp_meas, index_sets_meas) + ) + + if flag != 2: + # Adding no error for measurements currently + m.measurement_error = pyo.Suffix(direction=pyo.Suffix.LOCAL) + if flag == 5: + m.measurement_error.update((m.CA[0], 1e-2) for k in range(1)) + else: + m.measurement_error.update( + (k, 1e-2) + for k in expand_model_components(m, base_comp_meas, index_sets_meas) + ) + + if flag != 3: + # Grab design variables + base_comp_des = [m.CA, m.T] + index_sets_des = [[[m.t.first()]], [m.t_control]] + m.experiment_inputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_inputs.update( + (k, None) + for k in expand_model_components(m, base_comp_des, index_sets_des) + ) + + if flag != 4: + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.value(k)) for k in [m.A1, m.A2, m.E1, m.E2] + ) + + +class FullReactorExperiment(ReactorExperiment): + def label_experiment(self, flag=0): + m = self.model + return self.label_experiment_impl( + [[m.t_control], [m.t_control], [m.t_control]], flag=flag + ) + + +class FullReactorExperimentBad(ReactorExperiment): + def label_experiment(self, flag=0): + m = self.model + + self.label_experiment_impl( + [[m.t_control], [m.t_control], [m.t_control]], flag=flag + ) + + m.bad_con_1 = pyo.Constraint(expr=m.CA[0] >= 1.0) + m.bad_con_2 = pyo.Constraint(expr=m.CA[0] <= 0.0) + + return m diff --git a/pyomo/contrib/doe/tests/test_doe_build.py b/pyomo/contrib/doe/tests/test_doe_build.py new file mode 100644 index 00000000000..39615f47808 --- /dev/null +++ b/pyomo/contrib/doe/tests/test_doe_build.py @@ -0,0 +1,479 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +import json +import os.path + +from pyomo.common.dependencies import ( + numpy as np, + numpy_available, + pandas as pd, + pandas_available, +) +from pyomo.common.fileutils import this_file_dir +import pyomo.common.unittest as unittest + +from pyomo.contrib.doe import DesignOfExperiments +from pyomo.contrib.doe.examples.reactor_example import ( + ReactorExperiment as FullReactorExperiment, +) + +import pyomo.environ as pyo + +from pyomo.opt import SolverFactory + +ipopt_available = SolverFactory("ipopt").available() + +currdir = this_file_dir() +file_path = os.path.join(currdir, "..", "examples", "result.json") + +with open(file_path) as f: + data_ex = json.load(f) + +data_ex["control_points"] = {float(k): v for k, v in data_ex["control_points"].items()} + + +def get_FIM_FIMPrior_Q_L(doe_obj=None): + """ + Helper function to retrieve results to compare. + + """ + model = doe_obj.model + + n_param = doe_obj.n_parameters + n_y = doe_obj.n_experiment_outputs + + FIM_vals = [ + pyo.value(model.fim[i, j]) + for i in model.parameter_names + for j in model.parameter_names + ] + FIM_prior_vals = [ + pyo.value(model.prior_FIM[i, j]) + for i in model.parameter_names + for j in model.parameter_names + ] + if hasattr(model, "L"): + L_vals = [ + pyo.value(model.L[i, j]) + for i in model.parameter_names + for j in model.parameter_names + ] + else: + L_vals = [[0] * n_param] * n_param + Q_vals = [ + pyo.value(model.sensitivity_jacobian[i, j]) + for i in model.output_names + for j in model.parameter_names + ] + sigma_inv = [1 / v for k, v in model.scenario_blocks[0].measurement_error.items()] + param_vals = np.array( + [[v for k, v in model.scenario_blocks[0].unknown_parameters.items()]] + ) + + FIM_vals_np = np.array(FIM_vals).reshape((n_param, n_param)) + FIM_prior_vals_np = np.array(FIM_prior_vals).reshape((n_param, n_param)) + + for i in range(n_param): + for j in range(n_param): + if j < i: + FIM_vals_np[j, i] = FIM_vals_np[i, j] + + L_vals_np = np.array(L_vals).reshape((n_param, n_param)) + Q_vals_np = np.array(Q_vals).reshape((n_y, n_param)) + + sigma_inv_np = np.zeros((n_y, n_y)) + + for ind, v in enumerate(sigma_inv): + sigma_inv_np[ind, ind] = v + + return FIM_vals_np, FIM_prior_vals_np, Q_vals_np, L_vals_np, sigma_inv_np + + +def get_standard_args(experiment, fd_method, obj_used): + args = {} + args['experiment'] = experiment + args['fd_formula'] = fd_method + args['step'] = 1e-3 + args['objective_option'] = obj_used + args['scale_constant_value'] = 1 + args['scale_nominal_param_value'] = True + args['prior_FIM'] = None + args['jac_initial'] = None + args['fim_initial'] = None + args['L_diagonal_lower_bound'] = 1e-7 + args['solver'] = None + args['tee'] = False + args['get_labeled_model_args'] = None + args['_Cholesky_option'] = True + args['_only_compute_fim_lower'] = True + return args + + +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@unittest.skipIf(not numpy_available, "Numpy is not available") +class TestReactorExampleBuild(unittest.TestCase): + def test_reactor_fd_central_check_fd_eqns(self): + fd_method = "central" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.create_doe_model() + + model = doe_obj.model + + # Check that the parameter values are correct + for s in model.scenarios: + param = model.parameter_scenarios[s] + + diff = (-1) ** s * doe_obj.step + + param_val = pyo.value( + pyo.ComponentUID(param).find_component_on(model.scenario_blocks[s]) + ) + + param_val_from_step = model.scenario_blocks[0].unknown_parameters[ + pyo.ComponentUID(param).find_component_on(model.scenario_blocks[0]) + ] * (1 + diff) + + for k, v in model.scenario_blocks[s].unknown_parameters.items(): + if pyo.ComponentUID( + k, context=model.scenario_blocks[s] + ) == pyo.ComponentUID(param): + continue + + other_param_val = pyo.value(k) + self.assertAlmostEqual(other_param_val, v) + + self.assertAlmostEqual(param_val, param_val_from_step) + + def test_reactor_fd_backward_check_fd_eqns(self): + fd_method = "backward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.create_doe_model() + + model = doe_obj.model + + # Check that the parameter values are correct + for s in model.scenarios: + diff = -doe_obj.step * (s != 0) + if s != 0: + param = model.parameter_scenarios[s] + + param_val = pyo.value( + pyo.ComponentUID(param).find_component_on(model.scenario_blocks[s]) + ) + + param_val_from_step = model.scenario_blocks[0].unknown_parameters[ + pyo.ComponentUID(param).find_component_on(model.scenario_blocks[0]) + ] * (1 + diff) + self.assertAlmostEqual(param_val, param_val_from_step) + + for k, v in model.scenario_blocks[s].unknown_parameters.items(): + if (s != 0) and pyo.ComponentUID( + k, context=model.scenario_blocks[s] + ) == pyo.ComponentUID(param): + continue + + other_param_val = pyo.value(k) + self.assertAlmostEqual(other_param_val, v) + + def test_reactor_fd_forward_check_fd_eqns(self): + fd_method = "forward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.create_doe_model() + + model = doe_obj.model + + # Check that the parameter values are correct + for s in model.scenarios: + diff = doe_obj.step * (s != 0) + if s != 0: + param = model.parameter_scenarios[s] + + param_val = pyo.value( + pyo.ComponentUID(param).find_component_on(model.scenario_blocks[s]) + ) + + param_val_from_step = model.scenario_blocks[0].unknown_parameters[ + pyo.ComponentUID(param).find_component_on(model.scenario_blocks[0]) + ] * (1 + diff) + self.assertAlmostEqual(param_val, param_val_from_step) + + for k, v in model.scenario_blocks[s].unknown_parameters.items(): + if (s != 0) and pyo.ComponentUID( + k, context=model.scenario_blocks[s] + ) == pyo.ComponentUID(param): + continue + + other_param_val = pyo.value(k) + self.assertAlmostEqual(other_param_val, v) + + def test_reactor_fd_central_design_fixing(self): + fd_method = "central" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.create_doe_model() + + model = doe_obj.model + + # Check that the design fixing constraints are generated + design_vars = [k for k, v in model.scenario_blocks[0].experiment_inputs.items()] + + con_name_base = "global_design_eq_con_" + + # Ensure that + for ind, d in enumerate(design_vars): + if ind == 0: + continue + + con_name = con_name_base + str(ind) + self.assertTrue(hasattr(model, con_name)) + # Ensure that each set of constraints has all blocks pairs with scenario 0 + # i.e., (0, 1), (0, 2), ..., (0, N) --> N - 1 constraints + self.assertEqual(len(getattr(model, con_name)), (len(model.scenarios) - 1)) + # Should not have any constraints sets beyond the length of design_vars - 1 (started with index 0) + self.assertFalse(hasattr(model, con_name_base + str(len(design_vars)))) + + def test_reactor_fd_backward_design_fixing(self): + fd_method = "backward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.create_doe_model() + + model = doe_obj.model + + # Check that the design fixing constraints are generated + design_vars = [k for k, v in model.scenario_blocks[0].experiment_inputs.items()] + + con_name_base = "global_design_eq_con_" + + # Ensure that + for ind, d in enumerate(design_vars): + if ind == 0: + continue + + con_name = con_name_base + str(ind) + self.assertTrue(hasattr(model, con_name)) + # Ensure that each set of constraints has all blocks pairs with scenario 0 + # i.e., (0, 1), (0, 2), ..., (0, N) --> N - 1 constraints + self.assertEqual(len(getattr(model, con_name)), (len(model.scenarios) - 1)) + # Should not have any constraints sets beyond the length of design_vars - 1 (started with index 0) + self.assertFalse(hasattr(model, con_name_base + str(len(design_vars)))) + + def test_reactor_fd_forward_design_fixing(self): + fd_method = "forward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.create_doe_model() + + model = doe_obj.model + + # Check that the design fixing constraints are generated + design_vars = [k for k, v in model.scenario_blocks[0].experiment_inputs.items()] + + con_name_base = "global_design_eq_con_" + + # Ensure that + for ind, d in enumerate(design_vars): + if ind == 0: + continue + + con_name = con_name_base + str(ind) + self.assertTrue(hasattr(model, con_name)) + # Ensure that each set of constraints has all blocks pairs with scenario 0 + # i.e., (0, 1), (0, 2), ..., (0, N) --> N - 1 constraints + self.assertEqual(len(getattr(model, con_name)), (len(model.scenarios) - 1)) + # Should not have any constraints sets beyond the length of design_vars - 1 (started with index 0) + self.assertFalse(hasattr(model, con_name_base + str(len(design_vars)))) + + def test_reactor_check_user_initialization(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + FIM_prior = np.ones((4, 4)) + FIM_initial = np.eye(4) + FIM_prior + JAC_initial = np.ones((27, 4)) * 2 + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + DoE_args['prior_FIM'] = FIM_prior + DoE_args['fim_initial'] = FIM_initial + DoE_args['jac_initial'] = JAC_initial + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.create_doe_model() + + # Grab the matrix values on the model + FIM, FIM_prior_model, Q, L, sigma = get_FIM_FIMPrior_Q_L(doe_obj) + + # Make sure they match the inputs we gave + assert np.array_equal(FIM, FIM_initial) + assert np.array_equal(FIM_prior, FIM_prior_model) + assert np.array_equal(JAC_initial, Q) + + def test_update_FIM(self): + fd_method = "forward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + FIM_update = np.ones((4, 4)) * 10 + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.create_doe_model() + + doe_obj.update_FIM_prior(FIM=FIM_update) + + # Grab values to ensure we set the correct piece + FIM, FIM_prior_model, Q, L, sigma = get_FIM_FIMPrior_Q_L(doe_obj) + + # Make sure they match the inputs we gave + assert np.array_equal(FIM_update, FIM_prior_model) + + def test_get_experiment_inputs_without_blocks(self): + fd_method = "forward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.compute_FIM(method="sequential") + + stuff = doe_obj.get_experiment_input_values(model=doe_obj.compute_FIM_model) + + count = 0 + for k, v in doe_obj.compute_FIM_model.experiment_inputs.items(): + self.assertEqual(pyo.value(k), stuff[count]) + count += 1 + + def test_get_experiment_outputs_without_blocks(self): + fd_method = "forward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.compute_FIM(method="sequential") + + stuff = doe_obj.get_experiment_output_values(model=doe_obj.compute_FIM_model) + + count = 0 + for k, v in doe_obj.compute_FIM_model.experiment_outputs.items(): + self.assertEqual(pyo.value(k), stuff[count]) + count += 1 + + def test_get_measurement_error_without_blocks(self): + fd_method = "forward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.compute_FIM(method="sequential") + + stuff = doe_obj.get_measurement_error_values(model=doe_obj.compute_FIM_model) + + count = 0 + for k, v in doe_obj.compute_FIM_model.measurement_error.items(): + self.assertEqual(pyo.value(k), stuff[count]) + count += 1 + + def test_get_unknown_parameters_without_blocks(self): + fd_method = "forward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.compute_FIM(method="sequential") + + # Make sure the values can be retrieved + stuff = doe_obj.get_unknown_parameter_values(model=doe_obj.compute_FIM_model) + + count = 0 + for k, v in doe_obj.compute_FIM_model.unknown_parameters.items(): + self.assertEqual(pyo.value(k), stuff[count]) + count += 1 + + def test_generate_blocks_without_model(self): + fd_method = "forward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj._generate_scenario_blocks() + + for i in doe_obj.model.parameter_scenarios: + self.assertTrue( + doe_obj.model.find_component("scenario_blocks[" + str(i) + "]") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/doe/tests/test_doe_errors.py b/pyomo/contrib/doe/tests/test_doe_errors.py new file mode 100644 index 00000000000..4c9823a251d --- /dev/null +++ b/pyomo/contrib/doe/tests/test_doe_errors.py @@ -0,0 +1,751 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +import json +import os.path + +from pyomo.common.dependencies import ( + numpy as np, + numpy_available, + pandas as pd, + pandas_available, +) +from pyomo.common.fileutils import this_file_dir +import pyomo.common.unittest as unittest + +from pyomo.contrib.doe import DesignOfExperiments +from pyomo.contrib.doe.tests.experiment_class_example_flags import ( + BadExperiment, + FullReactorExperiment, +) + +from pyomo.opt import SolverFactory + +ipopt_available = SolverFactory("ipopt").available() + +currdir = this_file_dir() +file_path = os.path.join(currdir, "..", "examples", "result.json") + +with open(file_path) as f: + data_ex = json.load(f) +data_ex["control_points"] = {float(k): v for k, v in data_ex["control_points"].items()} + + +def get_standard_args(experiment, fd_method, obj_used, flag): + args = {} + args['experiment'] = experiment + args['fd_formula'] = fd_method + args['step'] = 1e-3 + args['objective_option'] = obj_used + args['scale_constant_value'] = 1 + args['scale_nominal_param_value'] = True + args['prior_FIM'] = None + args['jac_initial'] = None + args['fim_initial'] = None + args['L_diagonal_lower_bound'] = 1e-7 + args['solver'] = None + args['tee'] = False + args['get_labeled_model_args'] = {"flag": flag} + args['_Cholesky_option'] = True + args['_only_compute_fim_lower'] = True + return args + + +@unittest.skipIf(not numpy_available, "Numpy is not available") +class TestReactorExampleErrors(unittest.TestCase): + def test_reactor_check_no_get_labeled_model(self): + fd_method = "central" + obj_used = "trace" + flag_val = 1 # Value for faulty model build mode - 1: No exp outputs + + experiment = BadExperiment() + + with self.assertRaisesRegex( + ValueError, + "The experiment object must have a ``get_labeled_model`` function", + ): + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + def test_reactor_check_no_experiment_outputs(self): + fd_method = "central" + obj_used = "trace" + flag_val = 1 # Value for faulty model build mode - 1: No exp outputs + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Experiment model does not have suffix " + '"experiment_outputs".', + ): + doe_obj.create_doe_model() + + def test_reactor_check_no_measurement_error(self): + fd_method = "central" + obj_used = "trace" + flag_val = 2 # Value for faulty model build mode - 2: No meas error + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Experiment model does not have suffix " + '"measurement_error".', + ): + doe_obj.create_doe_model() + + def test_reactor_check_no_experiment_inputs(self): + fd_method = "central" + obj_used = "trace" + flag_val = 3 # Value for faulty model build mode - 3: No exp inputs/design vars + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Experiment model does not have suffix " + '"experiment_inputs".', + ): + doe_obj.create_doe_model() + + def test_reactor_check_no_unknown_parameters(self): + fd_method = "central" + obj_used = "trace" + flag_val = 4 # Value for faulty model build mode - 4: No unknown params + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Experiment model does not have suffix " + '"unknown_parameters".', + ): + doe_obj.create_doe_model() + + def test_reactor_check_bad_prior_size(self): + fd_method = "central" + obj_used = "trace" + flag_val = 0 # Value for faulty model build mode - 0: full model + + prior_FIM = np.ones((5, 5)) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + DoE_args['prior_FIM'] = prior_FIM + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + ValueError, + "Shape of FIM provided should be n parameters by n parameters, or {} by {}, FIM provided has shape {} by {}".format( + 4, 4, prior_FIM.shape[0], prior_FIM.shape[1] + ), + ): + doe_obj.create_doe_model() + + def test_reactor_check_bad_prior_negative_eigenvalue(self): + from pyomo.contrib.doe.doe import _SMALL_TOLERANCE_DEFINITENESS + + fd_method = "central" + obj_used = "trace" + flag_val = 0 # Value for faulty model build mode - 0: full model + + prior_FIM = -np.ones((4, 4)) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + DoE_args['prior_FIM'] = prior_FIM + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + ValueError, + r"FIM provided is not positive definite. It has one or more negative eigenvalue\(s\) less than -{:.1e}".format( + _SMALL_TOLERANCE_DEFINITENESS + ), + ): + doe_obj.create_doe_model() + + def test_reactor_check_bad_prior_not_symmetric(self): + from pyomo.contrib.doe.doe import _SMALL_TOLERANCE_SYMMETRY + + fd_method = "central" + obj_used = "trace" + flag_val = 0 # Value for faulty model build mode - 0: full model + + prior_FIM = np.zeros((4, 4)) + prior_FIM[0, 1] = 1e-3 + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + DoE_args['prior_FIM'] = prior_FIM + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + ValueError, + "FIM provided is not symmetric using absolute tolerance {}".format( + _SMALL_TOLERANCE_SYMMETRY + ), + ): + doe_obj.create_doe_model() + + def test_reactor_check_bad_jacobian_init_size(self): + fd_method = "central" + obj_used = "trace" + flag_val = 0 # Value for faulty model build mode - 0: full model + + jac_init = np.ones((5, 5)) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + DoE_args['jac_initial'] = jac_init + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + ValueError, + "Shape of Jacobian provided should be n experiment outputs by n parameters, or {} by {}, Jacobian provided has shape {} by {}".format( + 27, 4, jac_init.shape[0], jac_init.shape[1] + ), + ): + doe_obj.create_doe_model() + + def test_reactor_check_unbuilt_update_FIM(self): + fd_method = "central" + obj_used = "trace" + flag_val = 0 # Value for faulty model build mode - 0: full model + + FIM_update = np.ones((4, 4)) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "``fim`` is not defined on the model provided. Please build the model first.", + ): + doe_obj.update_FIM_prior(FIM=FIM_update) + + def test_reactor_check_none_update_FIM(self): + fd_method = "central" + obj_used = "trace" + flag_val = 0 # Value for faulty model build mode - 0: full model + + FIM_update = None + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + ValueError, + "FIM input for update_FIM_prior must be a 2D, square numpy array.", + ): + doe_obj.update_FIM_prior(FIM=FIM_update) + + def test_reactor_check_results_file_name(self): + fd_method = "central" + obj_used = "trace" + flag_val = 0 # Value for faulty model build mode - 0: Full model + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + ValueError, "``results_file`` must be either a Path object or a string." + ): + doe_obj.run_doe(results_file=int(15)) + + def test_reactor_check_measurement_and_output_length_match(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 5 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + ValueError, + "Number of experiment outputs, {}, and length of measurement error, {}, do not match. Please check model labeling.".format( + 27, 1 + ), + ): + doe_obj.create_doe_model() + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_grid_search_des_range_inputs(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag=0) + + doe_obj = DesignOfExperiments(**DoE_args) + + design_ranges = {"not": [1, 5, 3], "correct": [300, 700, 3]} + + with self.assertRaisesRegex( + ValueError, + "Design ranges keys must be a subset of experimental design names.", + ): + doe_obj.compute_FIM_full_factorial( + design_ranges=design_ranges, method="sequential" + ) + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_premature_figure_drawing(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag=0) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Results must be provided or the compute_FIM_full_factorial function must be run.", + ): + doe_obj.draw_factorial_figure() + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_figure_drawing_no_des_var_names(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag=0) + + doe_obj = DesignOfExperiments(**DoE_args) + + design_ranges = {"CA[0]": [1, 5, 2], "T[0]": [300, 700, 2]} + + doe_obj.compute_FIM_full_factorial( + design_ranges=design_ranges, method="sequential" + ) + + with self.assertRaisesRegex( + ValueError, + "If results object is provided, you must include all the design variable names.", + ): + doe_obj.draw_factorial_figure(results=doe_obj.fim_factorial_results) + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_figure_drawing_no_sens_names(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag=0) + + doe_obj = DesignOfExperiments(**DoE_args) + + design_ranges = {"CA[0]": [1, 5, 2], "T[0]": [300, 700, 2]} + + doe_obj.compute_FIM_full_factorial( + design_ranges=design_ranges, method="sequential" + ) + + with self.assertRaisesRegex( + ValueError, "``sensitivity_design_variables`` must be included." + ): + doe_obj.draw_factorial_figure() + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_figure_drawing_no_fixed_names(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag=0) + + doe_obj = DesignOfExperiments(**DoE_args) + + design_ranges = {"CA[0]": [1, 5, 2], "T[0]": [300, 700, 2]} + + doe_obj.compute_FIM_full_factorial( + design_ranges=design_ranges, method="sequential" + ) + + with self.assertRaisesRegex( + ValueError, "``fixed_design_variables`` must be included." + ): + doe_obj.draw_factorial_figure(sensitivity_design_variables={"dummy": "var"}) + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_figure_drawing_bad_fixed_names(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag=0) + + doe_obj = DesignOfExperiments(**DoE_args) + + design_ranges = {"CA[0]": [1, 5, 2], "T[0]": [300, 700, 2]} + + doe_obj.compute_FIM_full_factorial( + design_ranges=design_ranges, method="sequential" + ) + + with self.assertRaisesRegex( + ValueError, + "Fixed design variables do not all appear in the results object keys.", + ): + doe_obj.draw_factorial_figure( + sensitivity_design_variables={"CA[0]": 1}, + fixed_design_variables={"bad": "entry"}, + ) + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_figure_drawing_bad_sens_names(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag=0) + + doe_obj = DesignOfExperiments(**DoE_args) + + design_ranges = {"CA[0]": [1, 5, 2], "T[0]": [300, 700, 2]} + + doe_obj.compute_FIM_full_factorial( + design_ranges=design_ranges, method="sequential" + ) + + with self.assertRaisesRegex( + ValueError, + "Sensitivity design variables do not all appear in the results object keys.", + ): + doe_obj.draw_factorial_figure( + sensitivity_design_variables={"bad": "entry"}, + fixed_design_variables={"CA[0]": 1}, + ) + + def test_reactor_check_get_FIM_without_FIM(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Model provided does not have variable `fim`. Please make sure the model is built properly before calling `get_FIM`", + ): + doe_obj.get_FIM() + + def test_reactor_check_get_sens_mat_without_model(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Model provided does not have variable `sensitivity_jacobian`. Please make sure the model is built properly before calling `get_sensitivity_matrix`", + ): + doe_obj.get_sensitivity_matrix() + + def test_reactor_check_get_exp_inputs_without_model(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Model provided does not have expected structure. Please make sure model is built properly before calling `get_experiment_input_values`", + ): + doe_obj.get_experiment_input_values() + + def test_reactor_check_get_exp_outputs_without_model(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Model provided does not have expected structure. Please make sure model is built properly before calling `get_experiment_output_values`", + ): + doe_obj.get_experiment_output_values() + + def test_reactor_check_get_unknown_params_without_model(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Model provided does not have expected structure. Please make sure model is built properly before calling `get_unknown_parameter_values`", + ): + doe_obj.get_unknown_parameter_values() + + def test_reactor_check_get_meas_error_without_model(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Model provided does not have expected structure. Please make sure model is built properly before calling `get_measurement_error_values`", + ): + doe_obj.get_measurement_error_values() + + def test_multiple_exp_not_implemented_seq(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + NotImplementedError, "Multiple experiment optimization not yet supported." + ): + doe_obj.run_multi_doe_sequential(N_exp=1) + + def test_multiple_exp_not_implemented_sim(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + NotImplementedError, "Multiple experiment optimization not yet supported." + ): + doe_obj.run_multi_doe_simultaneous(N_exp=1) + + def test_update_unknown_parameter_values_not_implemented_seq(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + NotImplementedError, "Updating unknown parameter values not yet supported." + ): + doe_obj.update_unknown_parameter_values() + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + def test_bad_FD_generate_scens(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + AttributeError, + "Finite difference option not recognized. Please contact the developers as you should not see this error.", + ): + doe_obj.fd_formula = "bad things" + doe_obj._generate_scenario_blocks() + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + def test_bad_FD_seq_compute_FIM(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + AttributeError, + "Finite difference option not recognized. Please contact the developers as you should not see this error.", + ): + doe_obj.fd_formula = "bad things" + doe_obj.compute_FIM(method="sequential") + + def test_bad_objective(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + AttributeError, + "Objective option not recognized. Please contact the developers as you should not see this error.", + ): + doe_obj.objective_option = "bad things" + doe_obj.create_objective_function() + + def test_no_model_for_objective(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Model provided does not have variable `fim`. Please make sure the model is built properly before creating the objective.", + ): + doe_obj.create_objective_function() + + @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + def test_bad_compute_FIM_option(self): + fd_method = "central" + obj_used = "trace" + flag_val = ( + 0 # Value for faulty model build mode - 5: Mismatch error and output length + ) + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + ValueError, + "The method provided, {}, must be either `sequential` or `kaug`".format( + "Bad Method" + ), + ): + doe_obj.compute_FIM(method="Bad Method") + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/doe/tests/test_doe_solve.py b/pyomo/contrib/doe/tests/test_doe_solve.py new file mode 100644 index 00000000000..83884be0b2c --- /dev/null +++ b/pyomo/contrib/doe/tests/test_doe_solve.py @@ -0,0 +1,447 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +import json +import logging +import os.path + +from pyomo.common.dependencies import ( + numpy as np, + numpy_available, + pandas as pd, + pandas_available, + scipy_available, +) +from pyomo.common.fileutils import this_file_dir +import pyomo.common.unittest as unittest + +from pyomo.contrib.doe import DesignOfExperiments +from pyomo.contrib.doe.examples.reactor_example import ( + ReactorExperiment as FullReactorExperiment, +) +from pyomo.contrib.doe.tests.experiment_class_example_flags import ( + FullReactorExperimentBad, +) +from pyomo.contrib.doe.utils import rescale_FIM + +import pyomo.environ as pyo + +from pyomo.opt import SolverFactory + + +ipopt_available = SolverFactory("ipopt").available() +k_aug_available = SolverFactory('k_aug', solver_io='nl', validate=False) + +currdir = this_file_dir() +file_path = os.path.join(currdir, "..", "examples", "result.json") + +with open(file_path) as f: + data_ex = json.load(f) +data_ex["control_points"] = {float(k): v for k, v in data_ex["control_points"].items()} + + +def get_FIM_Q_L(doe_obj=None): + """ + Helper function to retrieve results to compare. + + """ + model = doe_obj.model + + n_param = doe_obj.n_parameters + n_y = doe_obj.n_experiment_outputs + + FIM_vals = [ + pyo.value(model.fim[i, j]) + for i in model.parameter_names + for j in model.parameter_names + ] + if hasattr(model, "L"): + L_vals = [ + pyo.value(model.L[i, j]) + for i in model.parameter_names + for j in model.parameter_names + ] + else: + L_vals = [[0] * n_param] * n_param + Q_vals = [ + pyo.value(model.sensitivity_jacobian[i, j]) + for i in model.output_names + for j in model.parameter_names + ] + sigma_inv = [1 / v for k, v in model.scenario_blocks[0].measurement_error.items()] + param_vals = np.array( + [[v for k, v in model.scenario_blocks[0].unknown_parameters.items()]] + ) + + FIM_vals_np = np.array(FIM_vals).reshape((n_param, n_param)) + + for i in range(n_param): + for j in range(n_param): + if j < i: + FIM_vals_np[j, i] = FIM_vals_np[i, j] + + L_vals_np = np.array(L_vals).reshape((n_param, n_param)) + Q_vals_np = np.array(Q_vals).reshape((n_y, n_param)) + + sigma_inv_np = np.zeros((n_y, n_y)) + + for ind, v in enumerate(sigma_inv): + sigma_inv_np[ind, ind] = v + + return FIM_vals_np, Q_vals_np, L_vals_np, sigma_inv_np + + +def get_standard_args(experiment, fd_method, obj_used): + args = {} + args['experiment'] = experiment + args['fd_formula'] = fd_method + args['step'] = 1e-3 + args['objective_option'] = obj_used + args['scale_constant_value'] = 1 + args['scale_nominal_param_value'] = True + args['prior_FIM'] = None + args['jac_initial'] = None + args['fim_initial'] = None + args['L_diagonal_lower_bound'] = 1e-7 + args['solver'] = None + args['tee'] = False + args['get_labeled_model_args'] = None + args['_Cholesky_option'] = True + args['_only_compute_fim_lower'] = True + return args + + +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@unittest.skipIf(not numpy_available, "Numpy is not available") +class TestReactorExampleSolving(unittest.TestCase): + def test_reactor_fd_central_solve(self): + fd_method = "central" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.run_doe() + + # assert model solves + self.assertEqual(doe_obj.results["Solver Status"], "ok") + + # assert that Q, F, and L are the same. + FIM, Q, L, sigma_inv = get_FIM_Q_L(doe_obj=doe_obj) + + # Since Trace is used, no comparison for FIM and L.T @ L + + # Make sure FIM and Q.T @ sigma_inv @ Q are close (alternate definition of FIM) + self.assertTrue(np.all(np.isclose(FIM, Q.T @ sigma_inv @ Q))) + + def test_reactor_fd_forward_solve(self): + fd_method = "forward" + obj_used = "zero" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.run_doe() + + self.assertEqual(doe_obj.results["Solver Status"], "ok") + + # assert that Q, F, and L are the same. + FIM, Q, L, sigma_inv = get_FIM_Q_L(doe_obj=doe_obj) + + # Since Trace is used, no comparison for FIM and L.T @ L + + # Make sure FIM and Q.T @ sigma_inv @ Q are close (alternate definition of FIM) + self.assertTrue(np.all(np.isclose(FIM, Q.T @ sigma_inv @ Q))) + + def test_reactor_fd_backward_solve(self): + fd_method = "backward" + obj_used = "trace" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.run_doe() + + self.assertEqual(doe_obj.results["Solver Status"], "ok") + + # assert that Q, F, and L are the same. + FIM, Q, L, sigma_inv = get_FIM_Q_L(doe_obj=doe_obj) + + # Since Trace is used, no comparison for FIM and L.T @ L + + # Make sure FIM and Q.T @ sigma_inv @ Q are close (alternate definition of FIM) + self.assertTrue(np.all(np.isclose(FIM, Q.T @ sigma_inv @ Q))) + + def test_reactor_obj_det_solve(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + DoE_args['scale_nominal_param_value'] = ( + False # Vanilla determinant solve needs this + ) + DoE_args['_Cholesky_option'] = False + DoE_args['_only_compute_fim_lower'] = False + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.run_doe() + + self.assertEqual(doe_obj.results['Solver Status'], "ok") + + def test_reactor_obj_cholesky_solve(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.run_doe() + + self.assertEqual(doe_obj.results["Solver Status"], "ok") + + # assert that Q, F, and L are the same. + FIM, Q, L, sigma_inv = get_FIM_Q_L(doe_obj=doe_obj) + + # Since Cholesky is used, there is comparison for FIM and L.T @ L + self.assertTrue(np.all(np.isclose(FIM, L @ L.T))) + + # Make sure FIM and Q.T @ sigma_inv @ Q are close (alternate definition of FIM) + self.assertTrue(np.all(np.isclose(FIM, Q.T @ sigma_inv @ Q))) + + def test_reactor_obj_cholesky_solve_bad_prior(self): + + from pyomo.contrib.doe.doe import _SMALL_TOLERANCE_DEFINITENESS + + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + # Specify a prior that is slightly negative definite + # Because it is less than the tolerance, it should be adjusted to be positive definite + # No error should be thrown + DoE_args['prior_FIM'] = -(_SMALL_TOLERANCE_DEFINITENESS / 100) * np.eye(4) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.run_doe() + + self.assertEqual(doe_obj.results["Solver Status"], "ok") + + # assert that Q, F, and L are the same. + FIM, Q, L, sigma_inv = get_FIM_Q_L(doe_obj=doe_obj) + + # Since Cholesky is used, there is comparison for FIM and L.T @ L + self.assertTrue(np.all(np.isclose(FIM, L @ L.T))) + + # Make sure FIM and Q.T @ sigma_inv @ Q are close (alternate definition of FIM) + self.assertTrue(np.all(np.isclose(FIM, Q.T @ sigma_inv @ Q))) + + # This test ensure that compute FIM runs without error using the + # `sequential` option with central finite differences + def test_compute_FIM_seq_centr(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.compute_FIM(method="sequential") + + # This test ensure that compute FIM runs without error using the + # `sequential` option with forward finite differences + def test_compute_FIM_seq_forward(self): + fd_method = "forward" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.compute_FIM(method="sequential") + + # This test ensure that compute FIM runs without error using the + # `kaug` option. kaug computes the FIM directly so no finite difference + # scheme is needed. + @unittest.skipIf(not scipy_available, "Scipy is not available") + @unittest.skipIf( + not k_aug_available.available(False), "The 'k_aug' command is not available" + ) + def test_compute_FIM_kaug(self): + fd_method = "forward" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.compute_FIM(method="kaug") + + # This test ensure that compute FIM runs without error using the + # `sequential` option with backward finite differences + def test_compute_FIM_seq_backward(self): + fd_method = "backward" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + doe_obj.compute_FIM(method="sequential") + + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_grid_search(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + design_ranges = {"CA[0]": [1, 5, 3], "T[0]": [300, 700, 3]} + + doe_obj.compute_FIM_full_factorial( + design_ranges=design_ranges, method="sequential" + ) + + # Check to make sure the lengths of the inputs in results object are indeed correct + CA_vals = doe_obj.fim_factorial_results["CA[0]"] + T_vals = doe_obj.fim_factorial_results["T[0]"] + + # assert length is correct + self.assertTrue((len(CA_vals) == 9) and (len(T_vals) == 9)) + self.assertTrue((len(set(CA_vals)) == 3) and (len(set(T_vals)) == 3)) + + # assert unique values are correct + self.assertTrue( + (set(CA_vals).issuperset(set([1, 3, 5]))) + and (set(T_vals).issuperset(set([300, 500, 700]))) + ) + + def test_rescale_FIM(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperiment(data_ex, 10, 3) + + # With parameter scaling + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + # Without parameter scaling + DoE_args2 = get_standard_args(experiment, fd_method, obj_used) + DoE_args2['scale_nominal_param_value'] = False + + doe_obj2 = DesignOfExperiments(**DoE_args2) + # Run both problems + doe_obj.run_doe() + doe_obj2.run_doe() + + # Extract FIM values + FIM, Q, L, sigma_inv = get_FIM_Q_L(doe_obj=doe_obj) + FIM2, Q2, L2, sigma_inv2 = get_FIM_Q_L(doe_obj=doe_obj2) + + # Get rescaled FIM from the scaled version + param_vals = np.array( + [ + [ + v + for k, v in doe_obj.model.scenario_blocks[ + 0 + ].unknown_parameters.items() + ] + ] + ) + + resc_FIM = rescale_FIM(FIM, param_vals) + + # Compare scaled and rescaled values + self.assertTrue(np.all(np.isclose(FIM2, resc_FIM))) + + def test_reactor_solve_bad_model(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperimentBad(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + + doe_obj = DesignOfExperiments(**DoE_args) + + with self.assertRaisesRegex( + RuntimeError, + "Model from experiment did not solve appropriately. Make sure the model is well-posed.", + ): + doe_obj.run_doe() + + @unittest.skipIf(not pandas_available, "pandas is not available") + def test_reactor_grid_search_bad_model(self): + fd_method = "central" + obj_used = "determinant" + + experiment = FullReactorExperimentBad(data_ex, 10, 3) + + DoE_args = get_standard_args(experiment, fd_method, obj_used) + DoE_args['logger_level'] = logging.ERROR + + doe_obj = DesignOfExperiments(**DoE_args) + + design_ranges = {"CA[0]": [1, 5, 3], "T[0]": [300, 700, 3]} + + doe_obj.compute_FIM_full_factorial( + design_ranges=design_ranges, method="sequential" + ) + + # Check to make sure the lengths of the inputs in results object are indeed correct + CA_vals = doe_obj.fim_factorial_results["CA[0]"] + T_vals = doe_obj.fim_factorial_results["T[0]"] + + # assert length is correct + self.assertTrue((len(CA_vals) == 9) and (len(T_vals) == 9)) + self.assertTrue((len(set(CA_vals)) == 3) and (len(set(T_vals)) == 3)) + + # assert unique values are correct + self.assertTrue( + (set(CA_vals).issuperset(set([1, 3, 5]))) + and (set(T_vals).issuperset(set([300, 500, 700]))) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/doe/tests/test_example.py b/pyomo/contrib/doe/tests/test_example.py deleted file mode 100644 index b59014a8110..00000000000 --- a/pyomo/contrib/doe/tests/test_example.py +++ /dev/null @@ -1,70 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - - -from pyomo.common.dependencies import ( - numpy as np, - numpy_available, - pandas as pd, - pandas_available, - scipy_available, -) - -import pyomo.common.unittest as unittest - -from pyomo.opt import SolverFactory - -ipopt_available = SolverFactory('ipopt').available() - - -class TestReactorExample(unittest.TestCase): - @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") - @unittest.skipIf(not scipy_available, "scipy is not available") - @unittest.skipIf(not numpy_available, "Numpy is not available") - def test_reactor_compute_FIM(self): - from pyomo.contrib.doe.examples import reactor_compute_FIM - - reactor_compute_FIM.main() - - @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") - @unittest.skipIf(not numpy_available, "Numpy is not available") - def test_reactor_optimize_doe(self): - from pyomo.contrib.doe.examples import reactor_optimize_doe - - reactor_optimize_doe.main() - - @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") - @unittest.skipIf(not pandas_available, "pandas is not available") - @unittest.skipIf(not numpy_available, "Numpy is not available") - def test_reactor_grid_search(self): - from pyomo.contrib.doe.examples import reactor_grid_search - - reactor_grid_search.main() - - -if __name__ == "__main__": - unittest.main() diff --git a/pyomo/contrib/doe/tests/test_fim_doe.py b/pyomo/contrib/doe/tests/test_fim_doe.py deleted file mode 100644 index 31d250f0d10..00000000000 --- a/pyomo/contrib/doe/tests/test_fim_doe.py +++ /dev/null @@ -1,360 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - -from pyomo.common.dependencies import numpy as np, numpy_available -import pyomo.common.unittest as unittest -from pyomo.contrib.doe import ( - MeasurementVariables, - DesignVariables, - ScenarioGenerator, - DesignOfExperiments, - VariablesWithIndices, -) -from pyomo.contrib.doe.examples.reactor_kinetics import create_model, disc_for_measure - - -class TestMeasurementError(unittest.TestCase): - def test(self): - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - variable_name = "C" - indices = {0: ['CA', 'CB', 'CC'], 1: t_control} - # measurement object - measurements = MeasurementVariables() - # if time index is not in indices, an value error is thrown. - with self.assertRaises(ValueError): - measurements.add_variables( - variable_name, indices=indices, time_index_position=2 - ) - - -class TestDesignError(unittest.TestCase): - def test(self): - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - # design object - exp_design = DesignVariables() - - # add T as design variable - var_T = 'T' - indices_T = {0: t_control} - exp1_T = [470, 300, 300, 300, 300, 300, 300, 300, 300] - - upper_bound = [ - 700, - 700, - 700, - 700, - 700, - 700, - 700, - 700, - 700, - 800, - ] # wrong upper bound since it has more elements than the length of variable names - lower_bound = [300, 300, 300, 300, 300, 300, 300, 300, 300] - - with self.assertRaises(ValueError): - exp_design.add_variables( - var_T, - indices=indices_T, - time_index_position=0, - values=exp1_T, - lower_bounds=lower_bound, - upper_bounds=upper_bound, - ) - - -@unittest.skipIf(not numpy_available, "Numpy is not available") -class TestPriorFIMError(unittest.TestCase): - def test(self): - # Control time set [h] - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - # measurement object - variable_name = "C" - indices = {0: ['CA', 'CB', 'CC'], 1: t_control} - - measurements = MeasurementVariables() - measurements.add_variables( - variable_name, indices=indices, time_index_position=1 - ) - - # design object - exp_design = DesignVariables() - - # add CAO as design variable - var_C = 'CA0' - indices_C = {0: [0]} - exp1_C = [5] - exp_design.add_variables( - var_C, - indices=indices_C, - time_index_position=0, - values=exp1_C, - lower_bounds=1, - upper_bounds=5, - ) - - # add T as design variable - var_T = 'T' - indices_T = {0: t_control} - exp1_T = [470, 300, 300, 300, 300, 300, 300, 300, 300] - - exp_design.add_variables( - var_T, - indices=indices_T, - time_index_position=0, - values=exp1_T, - lower_bounds=300, - upper_bounds=700, - ) - - parameter_dict = {"A1": 1, "A2": 1, "E1": 1} - - # empty prior - prior_right = [[0] * 3 for i in range(3)] - prior_pass = [[0] * 5 for i in range(10)] - - # check if the error can be thrown when given a wrong shape of FIM prior - with self.assertRaises(ValueError): - doe_object = DesignOfExperiments( - parameter_dict, - exp_design, - measurements, - create_model, - prior_FIM=prior_pass, - discretize_model=disc_for_measure, - ) - - -class TestMeasurement(unittest.TestCase): - """Test the MeasurementVariables class, specify, add_element, update_variance, check_subset functions.""" - - def test_setup(self): - ### add_element function - - # control time for C [h] - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - # control time for T [h] - t_control2 = [0.2, 0.4, 0.6, 0.8] - - # measurement object - measurements = MeasurementVariables() - - # add variable C - variable_name = "C" - indices = {0: ['CA', 'CB', 'CC'], 1: t_control} - measurements.add_variables( - variable_name, indices=indices, time_index_position=1 - ) - - # add variable T - variable_name2 = "T" - indices2 = {0: [1, 3, 5], 1: t_control2} - measurements.add_variables( - variable_name2, indices=indices2, time_index_position=1, variance=10 - ) - - # check variable names - self.assertEqual(measurements.variable_names[0], 'C[CA,0]') - self.assertEqual(measurements.variable_names[1], 'C[CA,0.125]') - self.assertEqual(measurements.variable_names[-1], 'T[5,0.8]') - self.assertEqual(measurements.variable_names[-2], 'T[5,0.6]') - self.assertEqual(measurements.variance['T[5,0.4]'], 10) - self.assertEqual(measurements.variance['T[5,0.6]'], 10) - self.assertEqual(measurements.variance['T[5,0.4]'], 10) - self.assertEqual(measurements.variance['T[5,0.6]'], 10) - - ### specify function - var_names = [ - 'C[CA,0]', - 'C[CA,0.125]', - 'C[CA,0.875]', - 'C[CA,1]', - 'C[CB,0]', - 'C[CB,0.125]', - 'C[CB,0.25]', - 'C[CB,0.375]', - 'C[CC,0]', - 'C[CC,0.125]', - 'C[CC,0.25]', - 'C[CC,0.375]', - ] - - measurements2 = MeasurementVariables() - measurements2.set_variable_name_list(var_names) - - self.assertEqual(measurements2.variable_names[1], 'C[CA,0.125]') - self.assertEqual(measurements2.variable_names[-1], 'C[CC,0.375]') - - ### check_subset function - self.assertTrue(measurements.check_subset(measurements2)) - - -class TestDesignVariable(unittest.TestCase): - """Test the DesignVariable class, specify, add_element, add_bounds, update_values.""" - - def test_setup(self): - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - - # design object - exp_design = DesignVariables() - - # add CAO as design variable - var_C = 'CA0' - indices_C = {0: [0]} - exp1_C = [5] - exp_design.add_variables( - var_C, - indices=indices_C, - time_index_position=0, - values=exp1_C, - lower_bounds=1, - upper_bounds=5, - ) - - # add T as design variable - var_T = 'T' - indices_T = {0: t_control} - exp1_T = [470, 300, 300, 300, 300, 300, 300, 300, 300] - - exp_design.add_variables( - var_T, - indices=indices_T, - time_index_position=0, - values=exp1_T, - lower_bounds=300, - upper_bounds=700, - ) - - self.assertEqual( - exp_design.variable_names, - [ - 'CA0[0]', - 'T[0]', - 'T[0.125]', - 'T[0.25]', - 'T[0.375]', - 'T[0.5]', - 'T[0.625]', - 'T[0.75]', - 'T[0.875]', - 'T[1]', - ], - ) - self.assertEqual(exp_design.variable_names_value['CA0[0]'], 5) - self.assertEqual(exp_design.variable_names_value['T[0]'], 470) - self.assertEqual(exp_design.upper_bounds['CA0[0]'], 5) - self.assertEqual(exp_design.upper_bounds['T[0]'], 700) - self.assertEqual(exp_design.lower_bounds['CA0[0]'], 1) - self.assertEqual(exp_design.lower_bounds['T[0]'], 300) - - design_names = exp_design.variable_names - exp1 = [4, 600, 300, 300, 300, 300, 300, 300, 300, 300] - exp1_design_dict = dict(zip(design_names, exp1)) - exp_design.update_values(exp1_design_dict) - self.assertEqual(exp_design.variable_names_value['CA0[0]'], 4) - self.assertEqual(exp_design.variable_names_value['T[0]'], 600) - - -class TestParameter(unittest.TestCase): - """Test the ScenarioGenerator class, generate_scenario function.""" - - def test_setup(self): - # set up parameter class - param_dict = {'A1': 84.79, 'A2': 371.72, 'E1': 7.78, 'E2': 15.05} - - scenario_gene = ScenarioGenerator(param_dict, formula="central", step=0.1) - parameter_set = scenario_gene.ScenarioData - - self.assertAlmostEqual(parameter_set.eps_abs['A1'], 16.9582, places=1) - self.assertAlmostEqual(parameter_set.eps_abs['E1'], 1.5554, places=1) - self.assertEqual(parameter_set.scena_num['A2'], [2, 3]) - self.assertEqual(parameter_set.scena_num['E1'], [4, 5]) - self.assertAlmostEqual(parameter_set.scenario[0]['A1'], 93.2699, places=1) - self.assertAlmostEqual(parameter_set.scenario[2]['A2'], 408.8895, places=1) - self.assertAlmostEqual(parameter_set.scenario[-1]['E2'], 13.54, places=1) - self.assertAlmostEqual(parameter_set.scenario[-2]['E2'], 16.55, places=1) - - -class TestVariablesWithIndices(unittest.TestCase): - """Test the DesignVariable class, specify, add_element, add_bounds, update_values.""" - - def test_setup(self): - special = VariablesWithIndices() - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - ### add_element function - # add CAO as design variable - var_C = 'CA0' - indices_C = {0: [0]} - exp1_C = [5] - special.add_variables( - var_C, - indices=indices_C, - time_index_position=0, - values=exp1_C, - lower_bounds=1, - upper_bounds=5, - ) - - # add T as design variable - var_T = 'T' - indices_T = {0: t_control} - exp1_T = [470, 300, 300, 300, 300, 300, 300, 300, 300] - - special.add_variables( - var_T, - indices=indices_T, - time_index_position=0, - values=exp1_T, - lower_bounds=300, - upper_bounds=700, - ) - - self.assertEqual( - special.variable_names, - [ - 'CA0[0]', - 'T[0]', - 'T[0.125]', - 'T[0.25]', - 'T[0.375]', - 'T[0.5]', - 'T[0.625]', - 'T[0.75]', - 'T[0.875]', - 'T[1]', - ], - ) - self.assertEqual(special.variable_names_value['CA0[0]'], 5) - self.assertEqual(special.variable_names_value['T[0]'], 470) - self.assertEqual(special.upper_bounds['CA0[0]'], 5) - self.assertEqual(special.upper_bounds['T[0]'], 700) - self.assertEqual(special.lower_bounds['CA0[0]'], 1) - self.assertEqual(special.lower_bounds['T[0]'], 300) - - -if __name__ == '__main__': - unittest.main() diff --git a/pyomo/contrib/doe/tests/test_reactor_example.py b/pyomo/contrib/doe/tests/test_reactor_example.py deleted file mode 100644 index daf2ee89194..00000000000 --- a/pyomo/contrib/doe/tests/test_reactor_example.py +++ /dev/null @@ -1,220 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# -# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation -# Initiative (CCSI), and is copyright (c) 2022 by the software owners: -# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., -# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, -# Battelle Memorial Institute, University of Notre Dame, -# The University of Pittsburgh, The University of Texas at Austin, -# University of Toledo, West Virginia University, et al. All rights reserved. -# -# NOTICE. This Software was developed under funding from the -# U.S. Department of Energy and the U.S. Government consequently retains -# certain rights. As such, the U.S. Government has been granted for itself -# and others acting on its behalf a paid-up, nonexclusive, irrevocable, -# worldwide license in the Software to reproduce, distribute copies to the -# public, prepare derivative works, and perform publicly and display -# publicly, and to permit other to do so. -# ___________________________________________________________________________ - - -# import libraries -from pyomo.common.dependencies import numpy as np, numpy_available, pandas_available -import pyomo.common.unittest as unittest -from pyomo.contrib.doe import DesignOfExperiments, MeasurementVariables, DesignVariables -from pyomo.environ import value, ConcreteModel -from pyomo.contrib.doe.examples.reactor_kinetics import create_model, disc_for_measure -from pyomo.opt import SolverFactory - -ipopt_available = SolverFactory('ipopt').available() - - -class Test_example_options(unittest.TestCase): - """Test the three options in the kinetics example.""" - - def test_setUP(self): - # parmest option - mod = create_model(model_option="parmest") - - # global and block option - mod = ConcreteModel() - create_model(mod, model_option="stage1") - create_model(mod, model_option="stage2") - # both options need a given model, or raise errors - with self.assertRaises(ValueError): - create_model(model_option="stage1") - - with self.assertRaises(ValueError): - create_model(model_option="stage2") - - with self.assertRaises(ValueError): - create_model(model_option="NotDefine") - - -class Test_doe_object(unittest.TestCase): - """Test the kinetics example with both the sequential_finite mode and the direct_kaug mode""" - - @unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") - @unittest.skipIf(not numpy_available, "Numpy is not available") - @unittest.skipIf(not pandas_available, "Pandas is not available") - def test_setUP(self): - ### Define inputs - # Control time set [h] - t_control = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] - # Define parameter nominal value - parameter_dict = {'A1': 84.79, 'A2': 371.72, 'E1': 7.78, 'E2': 15.05} - - # measurement object - variable_name = "C" - indices = {0: ['CA', 'CB', 'CC'], 1: t_control} - - measurements = MeasurementVariables() - measurements.add_variables( - variable_name, indices=indices, time_index_position=1 - ) - - # design object - exp_design = DesignVariables() - - # add CAO as design variable - var_C = 'CA0' - indices_C = {0: [0]} - exp1_C = [5] - exp_design.add_variables( - var_C, - indices=indices_C, - time_index_position=0, - values=exp1_C, - lower_bounds=1, - upper_bounds=5, - ) - - # add T as design variable - var_T = 'T' - indices_T = {0: t_control} - exp1_T = [470, 300, 300, 300, 300, 300, 300, 300, 300] - - exp_design.add_variables( - var_T, - indices=indices_T, - time_index_position=0, - values=exp1_T, - lower_bounds=300, - upper_bounds=700, - ) - - ### Test sequential_finite mode - sensi_opt = "sequential_finite" - - design_names = exp_design.variable_names - exp1 = [5, 570, 300, 300, 300, 300, 300, 300, 300, 300] - exp1_design_dict = dict(zip(design_names, exp1)) - - exp_design.update_values(exp1_design_dict) - - doe_object = DesignOfExperiments( - parameter_dict, - exp_design, - measurements, - create_model, - discretize_model=disc_for_measure, - ) - - result = doe_object.compute_FIM( - mode=sensi_opt, scale_nominal_param_value=True, formula="central" - ) - - result.result_analysis() - - self.assertAlmostEqual(np.log10(result.trace), 2.7885, places=2) - self.assertAlmostEqual(np.log10(result.det), 2.8218, places=2) - self.assertAlmostEqual(np.log10(result.min_eig), -1.0123, places=2) - - ### check subset feature - sub_name = "C" - sub_indices = {0: ["CB", "CC"], 1: [0.125, 0.25, 0.5, 0.75, 0.875]} - - measure_subset = MeasurementVariables() - measure_subset.add_variables( - sub_name, indices=sub_indices, time_index_position=1 - ) - sub_result = result.subset(measure_subset) - sub_result.result_analysis() - - self.assertAlmostEqual(np.log10(sub_result.trace), 2.5535, places=2) - self.assertAlmostEqual(np.log10(sub_result.det), 1.3464, places=2) - self.assertAlmostEqual(np.log10(sub_result.min_eig), -1.5386, places=2) - - ### Test direct_kaug mode - sensi_opt = "direct_kaug" - # Define a new experiment - - exp1 = [5, 570, 400, 300, 300, 300, 300, 300, 300, 300] - exp1_design_dict = dict(zip(design_names, exp1)) - exp_design.update_values(exp1_design_dict) - - doe_object = DesignOfExperiments( - parameter_dict, - exp_design, - measurements, - create_model, - discretize_model=disc_for_measure, - ) - - result = doe_object.compute_FIM( - mode=sensi_opt, scale_nominal_param_value=True, formula="central" - ) - - result.result_analysis() - - self.assertAlmostEqual(np.log10(result.trace), 2.7211, places=2) - self.assertAlmostEqual(np.log10(result.det), 2.0845, places=2) - self.assertAlmostEqual(np.log10(result.min_eig), -1.3510, places=2) - - ### Test stochastic_program mode - - exp1 = [5, 570, 300, 300, 300, 300, 300, 300, 300, 300] - exp1_design_dict = dict(zip(design_names, exp1)) - exp_design.update_values(exp1_design_dict) - - # add a prior information (scaled FIM with T=500 and T=300 experiments) - prior = np.asarray( - [ - [28.67892806, 5.41249739, -81.73674601, -24.02377324], - [5.41249739, 26.40935036, -12.41816477, -139.23992532], - [-81.73674601, -12.41816477, 240.46276004, 58.76422806], - [-24.02377324, -139.23992532, 58.76422806, 767.25584508], - ] - ) - - doe_object2 = DesignOfExperiments( - parameter_dict, - exp_design, - measurements, - create_model, - prior_FIM=prior, - discretize_model=disc_for_measure, - ) - - square_result, optimize_result = doe_object2.stochastic_program( - if_optimize=True, - if_Cholesky=True, - scale_nominal_param_value=True, - objective_option="det", - L_initial=np.linalg.cholesky(prior), - ) - - self.assertAlmostEqual(value(optimize_result.model.CA0[0]), 5.0, places=2) - self.assertAlmostEqual(value(optimize_result.model.T[0.5]), 300, places=2) - - -if __name__ == '__main__': - unittest.main() diff --git a/pyomo/contrib/doe/utils.py b/pyomo/contrib/doe/utils.py new file mode 100644 index 00000000000..24be6fd696a --- /dev/null +++ b/pyomo/contrib/doe/utils.py @@ -0,0 +1,102 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# +# Pyomo.DoE was produced under the Department of Energy Carbon Capture Simulation +# Initiative (CCSI), and is copyright (c) 2022 by the software owners: +# TRIAD National Security, LLC., Lawrence Livermore National Security, LLC., +# Lawrence Berkeley National Laboratory, Pacific Northwest National Laboratory, +# Battelle Memorial Institute, University of Notre Dame, +# The University of Pittsburgh, The University of Texas at Austin, +# University of Toledo, West Virginia University, et al. All rights reserved. +# +# NOTICE. This Software was developed under funding from the +# U.S. Department of Energy and the U.S. Government consequently retains +# certain rights. As such, the U.S. Government has been granted for itself +# and others acting on its behalf a paid-up, nonexclusive, irrevocable, +# worldwide license in the Software to reproduce, distribute copies to the +# public, prepare derivative works, and perform publicly and display +# publicly, and to permit other to do so. +# ___________________________________________________________________________ + +import pyomo.environ as pyo + +from pyomo.common.dependencies import numpy as np, numpy_available + +from pyomo.core.base.param import ParamData +from pyomo.core.base.var import VarData + + +# Rescale FIM (a scaling function to help rescale FIM from parameter values) +def rescale_FIM(FIM, param_vals): + """ + Rescales the FIM based on the input and parameter vals. + It is assumed the parameter vals align with the FIM + dimensions such that (1, i) corresponds to the i-th + column or row of the FIM. + + Parameters + ---------- + FIM: 2D numpy array to be scaled + param_vals: scaling factors for the parameters + + """ + if isinstance(param_vals, list): + param_vals = np.array([param_vals]) + elif isinstance(param_vals, np.ndarray): + if len(param_vals.shape) > 2 or ( + (len(param_vals.shape) == 2) and (param_vals.shape[0] != 1) + ): + raise ValueError( + "param_vals should be a vector of dimensions: 1 by `n_params`. The shape you provided is {}.".format( + param_vals.shape + ) + ) + if len(param_vals.shape) == 1: + param_vals = np.array([param_vals]) + else: + raise ValueError( + "param_vals should be a list or numpy array of dimensions: 1 by `n_params`" + ) + scaling_mat = (1 / param_vals).transpose().dot((1 / param_vals)) + scaled_FIM = np.multiply(FIM, scaling_mat) + return scaled_FIM + + +# TODO: Add swapping parameters for variables helper function +# def get_parameters_from_suffix(suffix, fix_vars=False): +# """ +# Finds the Params within the suffix provided. It will also check to see +# if there are Vars in the suffix provided. ``fix_vars`` will indicate +# if we should fix all the Vars in the set or not. +# +# Parameters +# ---------- +# suffix: pyomo Suffix object, contains the components to be checked +# as keys +# fix_vars: boolean, whether or not to fix the Vars, default = False +# +# Returns +# ------- +# param_list: list of Param +# """ +# param_list = [] +# +# # FIX THE MODEL TREE ISSUE WHERE I GET base_model. INSTEAD OF +# # Check keys if they are Param or Var. Fix the vars if ``fix_vars`` is True +# for k, v in suffix.items(): +# if isinstance(k, ParamData): +# param_list.append(k.name) +# elif isinstance(k, VarData): +# if fix_vars: +# k.fix() +# else: +# pass # ToDo: Write error for suffix keys that aren't ParamData or VarData +# +# return param_list diff --git a/pyomo/contrib/example/__init__.py b/pyomo/contrib/example/__init__.py index c70b50e84de..de53fcf28be 100644 --- a/pyomo/contrib/example/__init__.py +++ b/pyomo/contrib/example/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,18 +10,19 @@ # ___________________________________________________________________________ # -# import symbols and sub-packages +# Import "public" symbols and sub-packages. # from pyomo.contrib.example.foo import * -import pyomo.contrib.example.bar +from pyomo.contrib.example import bar # -# import the plugins directory +# Register plugins from this sub-package. # -# The pyomo.environ package normally calls the load() function in -# the pyomo.*.plugins subdirectories. However, pyomo.contrib packages -# are not loaded by pyomo.environ, so we need to call this function -# when we import the rest of this package. +# The pyomo.environ package normally calls the load() function in a +# hard-coded list of pyomo.*.plugins and pyomo.contrib.*.plugins +# modules. However, This example is not included in that list, so we +# will load (and register) the plugins when this module (or any +# submodule) is imported. # from pyomo.contrib.example.plugins import load diff --git a/pyomo/contrib/example/bar.py b/pyomo/contrib/example/bar.py index 22e5c3997e9..1ad7e056311 100644 --- a/pyomo/contrib/example/bar.py +++ b/pyomo/contrib/example/bar.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/example/foo.py b/pyomo/contrib/example/foo.py index f879bc70722..57d90de50bd 100644 --- a/pyomo/contrib/example/foo.py +++ b/pyomo/contrib/example/foo.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/example/plugins/__init__.py b/pyomo/contrib/example/plugins/__init__.py index 179098bc18e..35e25c54767 100644 --- a/pyomo/contrib/example/plugins/__init__.py +++ b/pyomo/contrib/example/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,4 +14,4 @@ def load(): - import pyomo.contrib.example.plugins.ex_plugin + from pyomo.contrib.example.plugins import ex_plugin diff --git a/pyomo/contrib/example/plugins/ex_plugin.py b/pyomo/contrib/example/plugins/ex_plugin.py index 7ee4c414ccf..ac9d514c298 100644 --- a/pyomo/contrib/example/plugins/ex_plugin.py +++ b/pyomo/contrib/example/plugins/ex_plugin.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/example/tests/__init__.py b/pyomo/contrib/example/tests/__init__.py index 9c45a6ef8b6..57b9c2e9faa 100644 --- a/pyomo/contrib/example/tests/__init__.py +++ b/pyomo/contrib/example/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/example/tests/test_example.py b/pyomo/contrib/example/tests/test_example.py index 55394f5d0c1..6586461337c 100644 --- a/pyomo/contrib/example/tests/test_example.py +++ b/pyomo/contrib/example/tests/test_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/fbbt/__init__.py b/pyomo/contrib/fbbt/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/fbbt/__init__.py +++ b/pyomo/contrib/fbbt/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/fbbt/expression_bounds_walker.py b/pyomo/contrib/fbbt/expression_bounds_walker.py index cb287d54df5..1027328a526 100644 --- a/pyomo/contrib/fbbt/expression_bounds_walker.py +++ b/pyomo/contrib/fbbt/expression_bounds_walker.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -232,15 +232,15 @@ def _handle_unknowable_bounds(visitor, node, arg): def _handle_equality(visitor, node, arg1, arg2): - return eq(*arg1, *arg2) + return eq(*arg1, *arg2, feasibility_tol=visitor.feasibility_tol) def _handle_inequality(visitor, node, arg1, arg2): - return ineq(*arg1, *arg2) + return ineq(*arg1, *arg2, feasibility_tol=visitor.feasibility_tol) def _handle_ranged(visitor, node, arg1, arg2, arg3): - return ranged(*arg1, *arg2, *arg3) + return ranged(*arg1, *arg2, *arg3, feasibility_tol=visitor.feasibility_tol) def _handle_expr_if(visitor, node, arg1, arg2, arg3): diff --git a/pyomo/contrib/fbbt/fbbt.py b/pyomo/contrib/fbbt/fbbt.py index bde33b3caa0..c1f6fd4ad01 100644 --- a/pyomo/contrib/fbbt/fbbt.py +++ b/pyomo/contrib/fbbt/fbbt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,6 +12,7 @@ from collections import defaultdict from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.contrib.fbbt.expression_bounds_walker import ExpressionBoundsVisitor +import pyomo.core.expr.relational_expr as relational_expr import pyomo.core.expr.numeric_expr as numeric_expr from pyomo.core.expr.visitor import ( ExpressionValueVisitor, @@ -24,9 +25,10 @@ import math from pyomo.core.base.block import Block from pyomo.core.base.constraint import Constraint +from pyomo.core.base.expression import ExpressionData, ScalarExpression +from pyomo.core.base.objective import ObjectiveData, ScalarObjective from pyomo.core.base.var import Var from pyomo.gdp import Disjunct -from pyomo.core.base.expression import _GeneralExpressionData, ScalarExpression import logging from pyomo.common.errors import InfeasibleConstraintException, PyomoException from pyomo.common.config import ( @@ -41,36 +43,45 @@ logger = logging.getLogger(__name__) -""" -The purpose of this file is to perform feasibility based bounds -tightening. This is a very basic implementation, but it is done -directly with pyomo expressions. The only functions that are meant to -be used by users are fbbt and compute_bounds_on_expr. The first set of -functions in this file (those with names starting with -_prop_bnds_leaf_to_root) are used for propagating bounds from the -variables to each node in the expression tree (all the way to the -root node). The second set of functions (those with names starting -with _prop_bnds_root_to_leaf) are used to propagate bounds from the -constraint back to the variables. For example, consider the constraint -x*y + z == 1 with -1 <= x <= 1 and -2 <= y <= 2. When propagating -bounds from the variables to the root (the root is x*y + z), we find -that -2 <= x*y <= 2, and that -inf <= x*y + z <= inf. However, -from the constraint, we know that 1 <= x*y + z <= 1, so we may -propagate bounds back to the variables. Since we know that -1 <= x*y + z <= 1 and -2 <= x*y <= 2, then we must have -1 <= z <= 3. -However, bounds cannot be improved on x*y, so bounds cannot be -improved on either x or y. - ->>> import pyomo.environ as pe ->>> m = pe.ConcreteModel() ->>> m.x = pe.Var(bounds=(-1,1)) ->>> m.y = pe.Var(bounds=(-2,2)) ->>> m.z = pe.Var() ->>> from pyomo.contrib.fbbt.fbbt import fbbt ->>> m.c = pe.Constraint(expr=m.x*m.y + m.z == 1) ->>> fbbt(m) ->>> print(m.z.lb, m.z.ub) --1.0 3.0 +__doc__ = """ +Feasibility-Based Bounds Tightening + +The purpose of this module is to perform feasibility-based bounds +tightening. This is a very basic implementation, but it is done +directly with pyomo expressions. The only functions that are meant to +be used by users are :func:`fbbt` and :func:`compute_bounds_on_expr`. +The first set of +functions in this file (those with names starting with +``_prop_bnds_leaf_to_root``) are used for propagating bounds from the +variables to each node in the expression tree (all the way to the +root node). The second set of functions (those with names starting +with ``_prop_bnds_root_to_leaf``) are used to propagate bounds from the +constraint back to the variables. + +For example, consider the constraint x*y + z == 1 with -1 <= x <= 1 and +-2 <= y <= 2. When propagating bounds from the variables to the root +(the root is x*y + z), we find that -2 <= x*y <= 2, and that -inf <= x*y ++ z <= inf. However, from the constraint, we know that 1 <= x*y + z <= +1, so we may propagate bounds back to the variables. Since we know that +1 <= x*y + z <= 1 and -2 <= x*y <= 2, then we must have -1 <= z <= 3. +However, bounds cannot be improved on x*y, so bounds cannot be improved +on either x or y. + +.. testcode:: + + import pyomo.environ as pyo + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1,1)) + m.y = pyo.Var(bounds=(-2,2)) + m.z = pyo.Var() + from pyomo.contrib.fbbt.fbbt import fbbt + m.c = pyo.Constraint(expr=m.x*m.y + m.z == 1) + fbbt(m) + print(f"z bounds = {m.z.bounds}") + +.. testoutput:: + + z bounds = (-1, 3) """ @@ -79,6 +90,27 @@ class FBBTException(PyomoException): pass +def _prop_bnds_leaf_to_root_equality(visitor, node, arg1, arg2): + bnds_dict = visitor.bnds_dict + bnds_dict[node] = interval.eq( + *bnds_dict[arg1], *bnds_dict[arg2], visitor.feasibility_tol + ) + + +def _prop_bnds_leaf_to_root_inequality(visitor, node, arg1, arg2): + bnds_dict = visitor.bnds_dict + bnds_dict[node] = interval.ineq( + *bnds_dict[arg1], *bnds_dict[arg2], visitor.feasibility_tol + ) + + +def _prop_bnds_leaf_to_root_ranged(visitor, node, arg1, arg2, arg3): + bnds_dict = visitor.bnds_dict + bnds_dict[node] = interval.ranged( + *bnds_dict[arg1], *bnds_dict[arg2], *bnds_dict[arg3], visitor.feasibility_tol + ) + + def _prop_bnds_leaf_to_root_ProductExpression(visitor, node, arg1, arg2): """ @@ -333,15 +365,15 @@ def _prop_bnds_leaf_to_root_UnaryFunctionExpression(visitor, node, arg): _unary_leaf_to_root_map[node.getname()](visitor, node, arg) -def _prop_bnds_leaf_to_root_GeneralExpression(visitor, node, expr): +def _prop_bnds_leaf_to_root_NamedExpression(visitor, node, expr): """ Propagate bounds from children to parent Parameters ---------- visitor: _FBBTVisitorLeafToRoot - node: pyomo.core.base.expression._GeneralExpressionData - expr: GeneralExpression arg + node: pyomo.core.base.expression.NamedExpressionData + expr: NamedExpressionData arg """ bnds_dict = visitor.bnds_dict if node in bnds_dict: @@ -366,12 +398,54 @@ def _prop_bnds_leaf_to_root_GeneralExpression(visitor, node, expr): numeric_expr.UnaryFunctionExpression: _prop_bnds_leaf_to_root_UnaryFunctionExpression, numeric_expr.LinearExpression: _prop_bnds_leaf_to_root_SumExpression, numeric_expr.AbsExpression: _prop_bnds_leaf_to_root_abs, - _GeneralExpressionData: _prop_bnds_leaf_to_root_GeneralExpression, - ScalarExpression: _prop_bnds_leaf_to_root_GeneralExpression, + relational_expr.EqualityExpression: _prop_bnds_leaf_to_root_equality, + relational_expr.InequalityExpression: _prop_bnds_leaf_to_root_inequality, + relational_expr.RangedExpression: _prop_bnds_leaf_to_root_ranged, + ExpressionData: _prop_bnds_leaf_to_root_NamedExpression, + ScalarExpression: _prop_bnds_leaf_to_root_NamedExpression, + ObjectiveData: _prop_bnds_leaf_to_root_NamedExpression, + ScalarObjective: _prop_bnds_leaf_to_root_NamedExpression, }, ) +def _prop_bnds_root_to_leaf_equality(node, bnds_dict, feasibility_tol): + assert bnds_dict[node][1] # This expression is feasible + arg1, arg2 = node.args + lb1, ub1 = bnds_dict[arg1] + lb2, ub2 = bnds_dict[arg2] + bnds_dict[arg1] = bnds_dict[arg2] = max(lb1, lb2), min(ub1, ub2) + + +def _prop_bnds_root_to_leaf_inequality(node, bnds_dict, feasibility_tol): + assert bnds_dict[node][1] # This expression is feasible + arg1, arg2 = node.args + lb1, ub1 = bnds_dict[arg1] + lb2, ub2 = bnds_dict[arg2] + if lb1 > lb2: + bnds_dict[arg2] = lb1, ub2 + if ub1 > ub2: + bnds_dict[arg1] = lb1, ub2 + + +def _prop_bnds_root_to_leaf_ranged(node, bnds_dict, feasibility_tol): + assert bnds_dict[node][1] # This expression is feasible + arg1, arg2, arg3 = node.args + lb1, ub1 = bnds_dict[arg1] + lb2, ub2 = bnds_dict[arg2] + lb3, ub3 = bnds_dict[arg3] + if lb1 > lb2: + bnds_dict[arg2] = lb1, ub2 + lb2 = lb1 + if lb2 > lb3: + bnds_dict[arg3] = lb2, ub3 + if ub2 > ub3: + bnds_dict[arg2] = lb2, ub3 + ub2 = ub3 + if ub1 > ub2: + bnds_dict[arg1] = lb1, ub2 + + def _prop_bnds_root_to_leaf_ProductExpression(node, bnds_dict, feasibility_tol): """ @@ -898,13 +972,13 @@ def _prop_bnds_root_to_leaf_UnaryFunctionExpression(node, bnds_dict, feasibility ) -def _prop_bnds_root_to_leaf_GeneralExpression(node, bnds_dict, feasibility_tol): +def _prop_bnds_root_to_leaf_NamedExpression(node, bnds_dict, feasibility_tol): """ Propagate bounds from parent to children. Parameters ---------- - node: pyomo.core.base.expression._GeneralExpressionData + node: pyomo.core.base.expression.NamedExpressionData bnds_dict: ComponentMap feasibility_tol: float If the bounds computed on the body of a constraint violate the bounds of the constraint by more than @@ -945,11 +1019,19 @@ def _prop_bnds_root_to_leaf_GeneralExpression(node, bnds_dict, feasibility_tol): ) _prop_bnds_root_to_leaf_map[numeric_expr.AbsExpression] = _prop_bnds_root_to_leaf_abs -_prop_bnds_root_to_leaf_map[_GeneralExpressionData] = ( - _prop_bnds_root_to_leaf_GeneralExpression +_prop_bnds_root_to_leaf_map[ExpressionData] = _prop_bnds_root_to_leaf_NamedExpression +_prop_bnds_root_to_leaf_map[ScalarExpression] = _prop_bnds_root_to_leaf_NamedExpression +_prop_bnds_root_to_leaf_map[ObjectiveData] = _prop_bnds_root_to_leaf_NamedExpression +_prop_bnds_root_to_leaf_map[ScalarObjective] = _prop_bnds_root_to_leaf_NamedExpression + +_prop_bnds_root_to_leaf_map[relational_expr.EqualityExpression] = ( + _prop_bnds_root_to_leaf_equality ) -_prop_bnds_root_to_leaf_map[ScalarExpression] = ( - _prop_bnds_root_to_leaf_GeneralExpression +_prop_bnds_root_to_leaf_map[relational_expr.InequalityExpression] = ( + _prop_bnds_root_to_leaf_inequality +) +_prop_bnds_root_to_leaf_map[relational_expr.RangedExpression] = ( + _prop_bnds_root_to_leaf_ranged ) @@ -1023,7 +1105,9 @@ def _before_external_function(visitor, child): def _register_new_before_child_handler(visitor, child): handlers = _before_child_handlers child_type = child.__class__ - if child.is_variable_type(): + if child_type in native_types: + handlers[child_type] = _before_constant + elif child.is_variable_type(): handlers[child_type] = _before_var elif not child.is_potentially_variable(): handlers[child_type] = _before_NPV @@ -1169,7 +1253,7 @@ def visiting_potential_leaf(self, node): ub = min(math.ceil(ub), math.floor(ub + self.integer_tol)) """ We have to make sure we do not make lb lower than the original lower bound - and make sure we do not make ub larger than the original upper bound. This is what + and make sure we do not make ub larger than the original upper bound. This is what _check_and_reset_bounds is for. """ lb, ub = _check_and_reset_bounds(node, lb, ub) @@ -1216,13 +1300,13 @@ def _fbbt_con(con, config): in the constraint based on the bounds of the constraint and the bounds of the other variables in the constraint. For example: - >>> import pyomo.environ as pe + >>> import pyomo.environ as pyo >>> from pyomo.contrib.fbbt.fbbt import fbbt - >>> m = pe.ConcreteModel() - >>> m.x = pe.Var(bounds=(-1,1)) - >>> m.y = pe.Var(bounds=(-2,2)) - >>> m.z = pe.Var() - >>> m.c = pe.Constraint(expr=m.x*m.y + m.z == 1) + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var(bounds=(-1,1)) + >>> m.y = pyo.Var(bounds=(-2,2)) + >>> m.z = pyo.Var() + >>> m.c = pyo.Constraint(expr=m.x*m.y + m.z == 1) >>> fbbt(m.c) >>> print(m.z.lb, m.z.ub) -1.0 3.0 @@ -1249,36 +1333,19 @@ def _fbbt_con(con, config): # a walker to propagate bounds from the variables to the root visitorA = _FBBTVisitorLeafToRoot(bnds_dict, feasibility_tol=config.feasibility_tol) - visitorA.walk_expression(con.body) - - # Now we need to replace the bounds in bnds_dict for the root - # node with the bounds on the constraint (if those bounds are - # better). - _lb = value(con.lower) - _ub = value(con.upper) - if _lb is None: - _lb = -interval.inf - if _ub is None: - _ub = interval.inf + visitorA.walk_expression(con.expr) - lb, ub = bnds_dict[con.body] + always_feasible, possibly_feasible = bnds_dict[con.expr] # check if the constraint is infeasible - if lb > _ub + config.feasibility_tol or ub < _lb - config.feasibility_tol: + if not possibly_feasible: raise InfeasibleConstraintException( 'Detected an infeasible constraint during FBBT: {0}'.format(str(con)) ) # check if the constraint is always satisfied - if config.deactivate_satisfied_constraints: - if lb >= _lb - config.feasibility_tol and ub <= _ub + config.feasibility_tol: - con.deactivate() - - if _lb > lb: - lb = _lb - if _ub < ub: - ub = _ub - bnds_dict[con.body] = (lb, ub) + if config.deactivate_satisfied_constraints and always_feasible: + con.deactivate() # Now, propagate bounds back from the root to the variables visitorB = _FBBTVisitorRootToLeaf( @@ -1286,7 +1353,7 @@ def _fbbt_con(con, config): integer_tol=config.integer_tol, feasibility_tol=config.feasibility_tol, ) - visitorB.dfs_postorder_stack(con.body) + visitorB.dfs_postorder_stack(con.expr) new_var_bounds = ComponentMap() for _node, _bnds in bnds_dict.items(): @@ -1333,7 +1400,7 @@ def _fbbt_block(m, config): for c in m.component_data_objects( ctype=Constraint, active=True, descend_into=config.descend_into, sort=True ): - for v in identify_variables(c.body): + for v in identify_variables(c.expr): if v not in var_to_con_map: var_to_con_map[v] = list() if v.lb is None: @@ -1520,14 +1587,14 @@ def __init__(self, comp): if comp.ctype == Constraint: if comp.is_indexed(): for c in comp.values(): - self._vars.update(identify_variables(c.body)) + self._vars.update(identify_variables(c.expr)) else: - self._vars.update(identify_variables(comp.body)) + self._vars.update(identify_variables(comp.expr)) else: for c in comp.component_data_objects( Constraint, descend_into=True, active=True, sort=True ): - self._vars.update(identify_variables(c.body)) + self._vars.update(identify_variables(c.expr)) def save_bounds(self): bnds = ComponentMap() diff --git a/pyomo/contrib/fbbt/interval.py b/pyomo/contrib/fbbt/interval.py index a12d1a4529f..3d96dd14f36 100644 --- a/pyomo/contrib/fbbt/interval.py +++ b/pyomo/contrib/fbbt/interval.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -57,7 +57,7 @@ def BoolFlag(val): return _true if val else _false -def ineq(xl, xu, yl, yu): +def ineq(xl, xu, yl, yu, feasibility_tol): """Compute the "bounds" on an InequalityExpression Note this is *not* performing interval arithmetic: we are @@ -67,9 +67,9 @@ def ineq(xl, xu, yl, yu): """ ans = [] - if yl < xu: + if yl < xu - feasibility_tol: ans.append(_false) - if xl <= yu: + if xl <= yu + feasibility_tol: ans.append(_true) assert ans if len(ans) == 1: @@ -77,7 +77,7 @@ def ineq(xl, xu, yl, yu): return tuple(ans) -def eq(xl, xu, yl, yu): +def eq(xl, xu, yl, yu, feasibility_tol): """Compute the "bounds" on an EqualityExpression Note this is *not* performing interval arithmetic: we are @@ -87,9 +87,13 @@ def eq(xl, xu, yl, yu): """ ans = [] - if xl != xu or yl != yu or xl != yl: + if ( + abs(xl - xu) > feasibility_tol + or abs(yl - yu) > feasibility_tol + or abs(xl - yl) > feasibility_tol + ): ans.append(_false) - if xl <= yu and yl <= xu: + if xl <= yu + feasibility_tol and yl <= xu + feasibility_tol: ans.append(_true) assert ans if len(ans) == 1: @@ -97,7 +101,7 @@ def eq(xl, xu, yl, yu): return tuple(ans) -def ranged(xl, xu, yl, yu, zl, zu): +def ranged(xl, xu, yl, yu, zl, zu, feasibility_tol): """Compute the "bounds" on a RangedExpression Note this is *not* performing interval arithmetic: we are @@ -106,8 +110,8 @@ def ranged(xl, xu, yl, yu, zl, zu): `z` and `z`, `y` can be outside the range `x` and `z`, or both. """ - lb = ineq(xl, xu, yl, yu) - ub = ineq(yl, yu, zl, zu) + lb = ineq(xl, xu, yl, yu, feasibility_tol) + ub = ineq(yl, yu, zl, zu, feasibility_tol) ans = [] if not lb[0] or not ub[0]: ans.append(_false) diff --git a/pyomo/contrib/fbbt/tests/__init__.py b/pyomo/contrib/fbbt/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/fbbt/tests/__init__.py +++ b/pyomo/contrib/fbbt/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/fbbt/tests/test_expression_bounds_walker.py b/pyomo/contrib/fbbt/tests/test_expression_bounds_walker.py index 5d27a2e4087..73ffbf35bc3 100644 --- a/pyomo/contrib/fbbt/tests/test_expression_bounds_walker.py +++ b/pyomo/contrib/fbbt/tests/test_expression_bounds_walker.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/fbbt/tests/test_fbbt.py b/pyomo/contrib/fbbt/tests/test_fbbt.py index f7d08d11215..d5a56232537 100644 --- a/pyomo/contrib/fbbt/tests/test_fbbt.py +++ b/pyomo/contrib/fbbt/tests/test_fbbt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ import pyomo.common.unittest as unittest import pyomo.environ as pyo -from pyomo.contrib.fbbt.fbbt import fbbt, compute_bounds_on_expr +from pyomo.contrib.fbbt.fbbt import fbbt, compute_bounds_on_expr, _before_child_handlers from pyomo.common.dependencies import numpy as np, numpy_available from pyomo.common.fileutils import find_library from pyomo.common.log import LoggingIntercept @@ -1335,3 +1335,58 @@ def test_named_expr(self): class TestFBBT(FbbtTestBase, unittest.TestCase): def setUp(self) -> None: self.tightener = fbbt + + def test_ranged_expression(self): + # The python version of FBBT is slightly more flexible than + # APPSI's cmodel (it allows - and correctly handles - + # RangedExpressions with variable lower / upper bounds). If we + # ever port that functionality into APPSI, then this test can be + # moved into the base class. + m = pyo.ConcreteModel() + m.l = pyo.Var(bounds=(2, None)) + m.x = pyo.Var() + m.u = pyo.Var(bounds=(None, 8)) + m.c = pyo.Constraint(expr=pyo.inequality(m.l, m.x, m.u)) + self.tightener(m) + self.tightener(m) + self.assertEqual(m.l.bounds, (2, 8)) + self.assertEqual(m.x.bounds, (2, 8)) + self.assertEqual(m.u.bounds, (2, 8)) + + m = pyo.ConcreteModel() + m.l = pyo.Var(bounds=(2, None)) + m.x = pyo.Var(bounds=(3, 7)) + m.u = pyo.Var(bounds=(None, 8)) + m.c = pyo.Constraint(expr=pyo.inequality(m.l, m.x, m.u)) + self.tightener(m) + self.tightener(m) + self.assertEqual(m.l.bounds, (2, 7)) + self.assertEqual(m.x.bounds, (3, 7)) + self.assertEqual(m.u.bounds, (3, 8)) + + @unittest.skipUnless(numpy_available, "Test requires numpy") + def test_numpy_leaves(self): + m = pyo.ConcreteModel() + m.l = pyo.Var(bounds=(2, None)) + m.x = pyo.Var() + m.u = pyo.Var(bounds=(None, 8)) + m.c = pyo.Constraint( + expr=pyo.inequality(m.l + np.int32(1), m.x, m.u - np.float64(1)) + ) + + # Remove the numpy types so we can test that automatic numeric + # type registrations + old = [(t, _before_child_handlers.pop(t, None)) for t in (np.int32, np.float64)] + + try: + self.tightener(m) + self.tightener(m) + self.assertEqual(m.l.bounds, (2, 6.0)) + self.assertEqual(m.x.bounds, (3, 7.0)) + self.assertEqual(m.u.bounds, (4, 8.0)) + finally: + for t, fcn in old: + if fcn is None: + _before_child_handlers.pop(t, None) + else: + _before_child_handlers[t] = fcn diff --git a/pyomo/contrib/fbbt/tests/test_interval.py b/pyomo/contrib/fbbt/tests/test_interval.py index 1e42162a35e..c27c3a48491 100644 --- a/pyomo/contrib/fbbt/tests/test_interval.py +++ b/pyomo/contrib/fbbt/tests/test_interval.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/fme/__init__.py b/pyomo/contrib/fme/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/fme/__init__.py +++ b/pyomo/contrib/fme/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/fme/fourier_motzkin_elimination.py b/pyomo/contrib/fme/fourier_motzkin_elimination.py index a1b5d744cf4..74f737bafa7 100644 --- a/pyomo/contrib/fme/fourier_motzkin_elimination.py +++ b/pyomo/contrib/fme/fourier_motzkin_elimination.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,13 +23,14 @@ value, ConstraintList, ) -from pyomo.core.base import TransformationFactory, _VarData +from pyomo.core.base import TransformationFactory, VarData from pyomo.core.plugins.transform.hierarchy import Transformation from pyomo.common.config import ConfigBlock, ConfigValue, NonNegativeFloat from pyomo.common.modeling import unique_component_name from pyomo.repn.standard_repn import generate_standard_repn from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.opt import TerminationCondition +from pyomo.util.config_domains import ComponentDataSet import logging @@ -57,23 +58,6 @@ def _check_var_bounds_filter(constraint): return True -def vars_to_eliminate_list(x): - if isinstance(x, (Var, _VarData)): - if not x.is_indexed(): - return ComponentSet([x]) - ans = ComponentSet() - for j in x.index_set(): - ans.add(x[j]) - return ans - elif hasattr(x, '__iter__'): - ans = ComponentSet() - for i in x: - ans.update(vars_to_eliminate_list(i)) - return ans - else: - raise ValueError("Expected Var or list of Vars.\n\tReceived %s" % type(x)) - - def gcd(a, b): while b != 0: a, b = b, a % b @@ -111,7 +95,7 @@ class Fourier_Motzkin_Elimination_Transformation(Transformation): 'vars_to_eliminate', ConfigValue( default=None, - domain=vars_to_eliminate_list, + domain=ComponentDataSet(Var), description="Continuous variable or list of continuous variables to " "project out of the model", doc=""" diff --git a/pyomo/contrib/fme/plugins.py b/pyomo/contrib/fme/plugins.py index b8278ccbb27..70b02c7d86c 100644 --- a/pyomo/contrib/fme/plugins.py +++ b/pyomo/contrib/fme/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/fme/tests/__init__.py b/pyomo/contrib/fme/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/fme/tests/__init__.py +++ b/pyomo/contrib/fme/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/fme/tests/test_fourier_motzkin_elimination.py b/pyomo/contrib/fme/tests/test_fourier_motzkin_elimination.py index dc721488f74..d6813ec1a7d 100644 --- a/pyomo/contrib/fme/tests/test_fourier_motzkin_elimination.py +++ b/pyomo/contrib/fme/tests/test_fourier_motzkin_elimination.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -562,7 +562,7 @@ def test_post_processing(self): fme = TransformationFactory('contrib.fourier_motzkin_elimination') fme.apply_to(m, vars_to_eliminate=disaggregatedVars, do_integer_arithmetic=True) # post-process - fme.post_process_fme_constraints(m, SolverFactory('glpk')) + fme.post_process_fme_constraints(m, SolverFactory('glpk'), tolerance=-1e-6) constraints = m._pyomo_contrib_fme_transformation.projected_constraints self.assertEqual(len(constraints), 11) diff --git a/pyomo/contrib/gdp_bounds/__init__.py b/pyomo/contrib/gdp_bounds/__init__.py index ac71890cf7c..6eb9ea8b81d 100644 --- a/pyomo/contrib/gdp_bounds/__init__.py +++ b/pyomo/contrib/gdp_bounds/__init__.py @@ -1,12 +1,10 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -import pyomo.contrib.gdp_bounds.plugins diff --git a/pyomo/contrib/gdp_bounds/compute_bounds.py b/pyomo/contrib/gdp_bounds/compute_bounds.py index 3c04e4e1af7..08f46b1beb2 100644 --- a/pyomo/contrib/gdp_bounds/compute_bounds.py +++ b/pyomo/contrib/gdp_bounds/compute_bounds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdp_bounds/info.py b/pyomo/contrib/gdp_bounds/info.py index 6f39af5908d..3380d6d8a32 100644 --- a/pyomo/contrib/gdp_bounds/info.py +++ b/pyomo/contrib/gdp_bounds/info.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -35,10 +35,10 @@ def disjunctive_bound(var, scope): """Compute the disjunctive bounds for a variable in a given scope. Args: - var (_VarData): Variable for which to compute bound + var (VarData): Variable for which to compute bound scope (Component): The scope in which to compute the bound. If not a - _DisjunctData, it will walk up the tree and use the scope of the - most immediate enclosing _DisjunctData. + DisjunctData, it will walk up the tree and use the scope of the + most immediate enclosing DisjunctData. Returns: numeric: the tighter of either the disjunctive lower bound, the diff --git a/pyomo/contrib/gdp_bounds/plugins.py b/pyomo/contrib/gdp_bounds/plugins.py index 016a1fc7b13..ed33fcc1045 100644 --- a/pyomo/contrib/gdp_bounds/plugins.py +++ b/pyomo/contrib/gdp_bounds/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdp_bounds/tests/__init__.py b/pyomo/contrib/gdp_bounds/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/gdp_bounds/tests/__init__.py +++ b/pyomo/contrib/gdp_bounds/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdp_bounds/tests/test_gdp_bounds.py b/pyomo/contrib/gdp_bounds/tests/test_gdp_bounds.py index 0c8eae2c43b..4ff4ae08ed1 100644 --- a/pyomo/contrib/gdp_bounds/tests/test_gdp_bounds.py +++ b/pyomo/contrib/gdp_bounds/tests/test_gdp_bounds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/GDPopt.py b/pyomo/contrib/gdpopt/GDPopt.py index f0ff6d690d6..dbfdc0ecfc2 100644 --- a/pyomo/contrib/gdpopt/GDPopt.py +++ b/pyomo/contrib/gdpopt/GDPopt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/__init__.py b/pyomo/contrib/gdpopt/__init__.py index a84b8385ad3..af660c591af 100644 --- a/pyomo/contrib/gdpopt/__init__.py +++ b/pyomo/contrib/gdpopt/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/algorithm_base_class.py b/pyomo/contrib/gdpopt/algorithm_base_class.py index c5929ad4a88..c3f6dfedefb 100644 --- a/pyomo/contrib/gdpopt/algorithm_base_class.py +++ b/pyomo/contrib/gdpopt/algorithm_base_class.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/branch_and_bound.py b/pyomo/contrib/gdpopt/branch_and_bound.py index 36b81c881be..2c8a4a0cf6e 100644 --- a/pyomo/contrib/gdpopt/branch_and_bound.py +++ b/pyomo/contrib/gdpopt/branch_and_bound.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/config_options.py b/pyomo/contrib/gdpopt/config_options.py index 467c4a6ec32..03b8003d619 100644 --- a/pyomo/contrib/gdpopt/config_options.py +++ b/pyomo/contrib/gdpopt/config_options.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,6 +22,9 @@ from pyomo.contrib.gdpopt.discrete_problem_initialize import valid_init_strategies from pyomo.contrib.gdpopt.nlp_initialization import restore_vars_to_original_values from pyomo.contrib.gdpopt.util import a_logger, _DoNothing +from pyomo.util.config_domains import ComponentDataSet +from pyomo.core.base import LogicalConstraint +from pyomo.gdp.disjunct import Disjunction _supported_algorithms = { 'LOA': ('gdpopt.loa', 'Logic-based Outer Approximation'), @@ -436,7 +439,7 @@ def _add_mip_solver_configs(CONFIG): ConfigValue( default="gurobi", description=""" - Mixed-integer linear solver to use. Note that no persisent solvers + Mixed-integer linear solver to use. Note that no persistent solvers other than the auto-persistent solvers in the APPSI package are supported.""", ), @@ -457,7 +460,7 @@ def _add_nlp_solver_configs(CONFIG, default_solver): ConfigValue( default=default_solver, description=""" - Nonlinear solver to use. Note that no persisent solvers + Nonlinear solver to use. Note that no persistent solvers other than the auto-persistent solvers in the APPSI package are supported.""", ), @@ -475,7 +478,7 @@ def _add_nlp_solver_configs(CONFIG, default_solver): ConfigValue( default="baron", description=""" - Mixed-integer nonlinear solver to use. Note that no persisent solvers + Mixed-integer nonlinear solver to use. Note that no persistent solvers other than the auto-persistent solvers in the APPSI package are supported.""", ), @@ -493,7 +496,7 @@ def _add_nlp_solver_configs(CONFIG, default_solver): ConfigValue( default="bonmin", description=""" - Mixed-integer nonlinear solver to use. Note that no persisent solvers + Mixed-integer nonlinear solver to use. Note that no persistent solvers other than the auto-persistent solvers in the APPSI package are supported.""", ), @@ -528,3 +531,40 @@ def _add_tolerance_configs(CONFIG): description="Tolerance for bound convergence.", ), ) + + +def _add_ldsda_configs(CONFIG): + CONFIG.declare( + "direction_norm", + ConfigValue( + default='L2', + domain=In(['L2', 'Linf']), + description="The norm to use for the search direction", + ), + ) + CONFIG.declare( + "starting_point", + ConfigValue(default=None, description="The value list of external variables."), + ) + CONFIG.declare( + "logical_constraint_list", + ConfigValue( + default=None, + domain=ComponentDataSet(LogicalConstraint), + description=""" + The list of logical constraints to be reformulated into external variables. + The logical constraints should be in the same order of provided starting point. + The provided logical constraints should be ExactlyExpressions.""", + ), + ) + CONFIG.declare( + "disjunction_list", + ConfigValue( + default=None, + domain=ComponentDataSet(Disjunction), + description=""" + The list of disjunctions to be reformulated into external variables. + The disjunctions should be in the same order of provided starting point. + """, + ), + ) diff --git a/pyomo/contrib/gdpopt/create_oa_subproblems.py b/pyomo/contrib/gdpopt/create_oa_subproblems.py index 690fe1f15f1..9bf6bdb0c15 100644 --- a/pyomo/contrib/gdpopt/create_oa_subproblems.py +++ b/pyomo/contrib/gdpopt/create_oa_subproblems.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/cut_generation.py b/pyomo/contrib/gdpopt/cut_generation.py index 742a2cde395..40c04804cb6 100644 --- a/pyomo/contrib/gdpopt/cut_generation.py +++ b/pyomo/contrib/gdpopt/cut_generation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/discrete_problem_initialize.py b/pyomo/contrib/gdpopt/discrete_problem_initialize.py index 81c339b94a2..f5daa3e63b2 100644 --- a/pyomo/contrib/gdpopt/discrete_problem_initialize.py +++ b/pyomo/contrib/gdpopt/discrete_problem_initialize.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/enumerate.py b/pyomo/contrib/gdpopt/enumerate.py index 6c25d0088f4..b47b0c259df 100644 --- a/pyomo/contrib/gdpopt/enumerate.py +++ b/pyomo/contrib/gdpopt/enumerate.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/gloa.py b/pyomo/contrib/gdpopt/gloa.py index 212da057e05..c07f57dc24a 100644 --- a/pyomo/contrib/gdpopt/gloa.py +++ b/pyomo/contrib/gdpopt/gloa.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/ldsda.py b/pyomo/contrib/gdpopt/ldsda.py new file mode 100644 index 00000000000..a5403f0bae2 --- /dev/null +++ b/pyomo/contrib/gdpopt/ldsda.py @@ -0,0 +1,540 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from collections import namedtuple +import itertools as it +import traceback +from pyomo.common.config import document_kwargs_from_configdict +from pyomo.common.errors import InfeasibleConstraintException +from pyomo.contrib.fbbt.fbbt import fbbt +from pyomo.contrib.gdpopt.algorithm_base_class import _GDPoptAlgorithm +from pyomo.contrib.gdpopt.create_oa_subproblems import ( + add_util_block, + add_disjunction_list, + add_disjunct_list, + add_algebraic_variable_list, + add_boolean_variable_lists, + add_transformed_boolean_variable_list, +) +from pyomo.contrib.gdpopt.config_options import ( + _add_nlp_solver_configs, + _add_ldsda_configs, + _add_mip_solver_configs, + _add_tolerance_configs, + _add_nlp_solve_configs, +) +from pyomo.contrib.gdpopt.nlp_initialization import restore_vars_to_original_values +from pyomo.contrib.gdpopt.util import SuppressInfeasibleWarning, get_main_elapsed_time +from pyomo.contrib.satsolver.satsolver import satisfiable +from pyomo.core import minimize, Suffix, TransformationFactory, Objective, value +from pyomo.opt import SolverFactory +from pyomo.opt import TerminationCondition as tc +from pyomo.core.expr.logical_expr import ExactlyExpression +from pyomo.common.dependencies import attempt_import + + +tabulate, tabulate_available = attempt_import('tabulate') + +# Data tuple for external variables. +ExternalVarInfo = namedtuple( + 'ExternalVarInfo', + [ + 'exactly_number', # number of external variables for this type + 'Boolean_vars', # list with names of the ordered Boolean variables to be reformulated + 'UB', # upper bound on external variable + 'LB', # lower bound on external variable + ], +) + + +@SolverFactory.register( + 'gdpopt.ldsda', + doc="The LD-SDA (Logic-based Discrete-Steepest Descent Algorithm) " + "Generalized Disjunctive Programming (GDP) solver", +) +class GDP_LDSDA_Solver(_GDPoptAlgorithm): + """The GDPopt (Generalized Disjunctive Programming optimizer) + LD-SDA (Logic-based Discrete-Steepest Descent (LD-SDA) solver. + + Accepts models that can include nonlinear, continuous variables and + constraints, as well as logical conditions. + """ + + CONFIG = _GDPoptAlgorithm.CONFIG() + _add_mip_solver_configs(CONFIG) + _add_nlp_solver_configs(CONFIG, default_solver='ipopt') + _add_nlp_solve_configs( + CONFIG, default_nlp_init_method=restore_vars_to_original_values + ) + _add_tolerance_configs(CONFIG) + _add_ldsda_configs(CONFIG) + + algorithm = 'LDSDA' + + # Override solve() to customize the docstring for this solver + @document_kwargs_from_configdict(CONFIG, doc=_GDPoptAlgorithm.solve.__doc__) + def solve(self, model, **kwds): + return super().solve(model, **kwds) + + def _log_citation(self, config): + config.logger.info( + "\n" + + """- LDSDA algorithm: + Bernal DE, Ovalle D, Liñán DA, Ricardez-Sandoval LA, Gómez JM, Grossmann IE. + Process Superstructure Optimization through Discrete Steepest Descent Optimization: a GDP Analysis and Applications in Process Intensification. + Computer Aided Chemical Engineering 2022 Jan 1 (Vol. 49, pp. 1279-1284). Elsevier. + https://doi.org/10.1016/B978-0-323-85159-6.50213-X + """.strip() + ) + + def _solve_gdp(self, model, config): + """Solve the GDP model. + + Parameters + ---------- + model : ConcreteModel + The GDP model to be solved + config : ConfigBlock + GDPopt configuration block + """ + logger = config.logger + self.log_formatter = ( + '{:>9} {:>15} {:>20} {:>11.5f} {:>11.5f} {:>8.2%} {:>7.2f} {}' + ) + self.best_direction = None + self.current_point = tuple(config.starting_point) + self.explored_point_set = set() + + # Create utility block on the original model so that we will be able to + # copy solutions between + util_block = self.original_util_block = add_util_block(model) + add_disjunct_list(util_block) + add_algebraic_variable_list(util_block) + add_boolean_variable_lists(util_block) + util_block.config_disjunction_list = config.disjunction_list + util_block.config_logical_constraint_list = config.logical_constraint_list + + # We will use the working_model to perform the LDSDA search. + self.working_model = model.clone() + self.working_model_util_block = self.working_model.find_component(util_block) + + add_disjunction_list(self.working_model_util_block) + TransformationFactory('core.logical_to_linear').apply_to(self.working_model) + # Now that logical_to_disjunctive has been called. + add_transformed_boolean_variable_list(self.working_model_util_block) + self._get_external_information(self.working_model_util_block, config) + self.directions = self._get_directions( + self.number_of_external_variables, config + ) + + # Add the BigM suffix if it does not already exist. Used later during + # nonlinear constraint activation. + if not hasattr(self.working_model_util_block, 'BigM'): + self.working_model_util_block.BigM = Suffix() + self._log_header(logger) + # Solve the initial point + _ = self._solve_GDP_subproblem(self.current_point, 'Initial point', config) + + # Main loop + locally_optimal = False + while not locally_optimal: + self.iteration += 1 + if self.any_termination_criterion_met(config): + break + locally_optimal = self.neighbor_search(config) + if not locally_optimal: + self.line_search(config) + + def any_termination_criterion_met(self, config): + return self.reached_iteration_limit(config) or self.reached_time_limit(config) + + def _solve_GDP_subproblem(self, external_var_value, search_type, config): + """Solve the GDP subproblem with disjunctions fixed according to the external variable. + + Parameters + ---------- + external_var_value : list + The values of the external variables to be evaluated + search_type : str + The type of search, neighbor search or line search + config : ConfigBlock + GDPopt configuration block + + Returns + ------- + bool + True if the primal bound is improved + """ + self.fix_disjunctions_with_external_var(external_var_value) + subproblem = self.working_model.clone() + TransformationFactory('core.logical_to_linear').apply_to(subproblem) + + with SuppressInfeasibleWarning(): + try: + TransformationFactory('gdp.bigm').apply_to(subproblem) + fbbt(subproblem, integer_tol=config.integer_tolerance) + TransformationFactory('contrib.detect_fixed_vars').apply_to(subproblem) + TransformationFactory('contrib.propagate_fixed_vars').apply_to( + subproblem + ) + TransformationFactory( + 'contrib.deactivate_trivial_constraints' + ).apply_to(subproblem, tmp=False, ignore_infeasible=False) + except InfeasibleConstraintException: + return False, None + minlp_args = dict(config.minlp_solver_args) + if config.time_limit is not None and config.minlp_solver == 'gams': + elapsed = get_main_elapsed_time(self.timing) + remaining = max(config.time_limit - elapsed, 1) + minlp_args['add_options'] = minlp_args.get('add_options', []) + minlp_args['add_options'].append('option reslim=%s;' % remaining) + result = SolverFactory(config.minlp_solver).solve(subproblem, **minlp_args) + # Retrieve the primal bound (objective value) from the subproblem + obj = next(subproblem.component_data_objects(Objective, active=True)) + primal_bound = value(obj) + primal_improved = self._handle_subproblem_result( + result, subproblem, external_var_value, config, search_type + ) + return primal_improved, primal_bound + + def _get_external_information(self, util_block, config): + """Function that obtains information from the model to perform the reformulation with external variables. + + Parameters + ---------- + util_block : Block + The GDPopt utility block of the model. + config : ConfigBlock + GDPopt configuration block. + + Raises + ------ + ValueError + The exactly_number of the exactly constraint is greater than 1. + """ + util_block.external_var_info_list = [] + model = util_block.parent_block() + reformulation_summary = [] + # Identify the variables that can be reformulated by performing a loop over logical constraints + # TODO: we can automatically find all Exactly logical constraints in the model. + # However, we cannot link the starting point and the logical constraint. + # for c in util_block.logical_constraint_list: + # if isinstance(c.body, ExactlyExpression): + if config.logical_constraint_list is not None: + for c in util_block.config_logical_constraint_list: + if not isinstance(c.body, ExactlyExpression): + raise ValueError( + "The logical_constraint_list config should be a list of ExactlyExpression logical constraints." + ) + # TODO: in the first version, we don't support more than one exactly constraint. + exactly_number = c.body.args[0] + if exactly_number > 1: + raise ValueError("The function only works for exactly_number = 1") + sorted_boolean_var_list = c.body.args[1:] + util_block.external_var_info_list.append( + ExternalVarInfo( + exactly_number=1, + Boolean_vars=sorted_boolean_var_list, + UB=len(sorted_boolean_var_list), + LB=1, + ) + ) + reformulation_summary.append( + [ + 1, + len(sorted_boolean_var_list), + [boolean_var.name for boolean_var in sorted_boolean_var_list], + ] + ) + if config.disjunction_list is not None: + for disjunction in util_block.config_disjunction_list: + sorted_boolean_var_list = [ + disjunct.indicator_var for disjunct in disjunction.disjuncts + ] + util_block.external_var_info_list.append( + ExternalVarInfo( + exactly_number=1, + Boolean_vars=sorted_boolean_var_list, + UB=len(sorted_boolean_var_list), + LB=1, + ) + ) + reformulation_summary.append( + [ + 1, + len(sorted_boolean_var_list), + [boolean_var.name for boolean_var in sorted_boolean_var_list], + ] + ) + config.logger.info("Reformulation Summary:") + config.logger.info( + tabulate.tabulate( + reformulation_summary, + headers=["Ext Var Index", "LB", "UB", "Associated Boolean Vars"], + showindex="always", + tablefmt="simple_outline", + ) + ) + self.number_of_external_variables = sum( + external_var_info.exactly_number + for external_var_info in util_block.external_var_info_list + ) + if self.number_of_external_variables != len(config.starting_point): + raise ValueError( + "The length of the provided starting point doesn't equal the number of disjunctions." + ) + + def fix_disjunctions_with_external_var(self, external_var_values_list): + """Function that fixes the disjunctions in the working_model using the values of the external variables. + + Parameters + ---------- + external_var_values_list : List + The list of values of the external variables + """ + for external_variable_value, external_var_info in zip( + external_var_values_list, + self.working_model_util_block.external_var_info_list, + ): + for idx, boolean_var in enumerate(external_var_info.Boolean_vars): + if idx == external_variable_value - 1: + boolean_var.fix(True) + if boolean_var.get_associated_binary() is not None: + boolean_var.get_associated_binary().fix(1) + else: + boolean_var.fix(False) + if boolean_var.get_associated_binary() is not None: + boolean_var.get_associated_binary().fix(0) + self.explored_point_set.add(tuple(external_var_values_list)) + + def _get_directions(self, dimension, config): + """Function creates the search directions of the given dimension. + + Parameters + ---------- + dimension : int + Dimension of the neighborhood + config : ConfigBlock + GDPopt configuration block + + Returns + ------- + list + the search directions. + """ + if config.direction_norm == 'L2': + directions = [] + for i in range(dimension): + directions.append(tuple([0] * i + [1] + [0] * (dimension - i - 1))) + directions.append(tuple([0] * i + [-1] + [0] * (dimension - i - 1))) + return directions + elif config.direction_norm == 'Linf': + directions = list(it.product([-1, 0, 1], repeat=dimension)) + directions.remove((0,) * dimension) + return directions + + def _check_valid_neighbor(self, neighbor): + """Function that checks if a given neighbor is valid. + + Parameters + ---------- + neighbor : list + the neighbor to be checked + + Returns + ------- + bool + True if the neighbor is valid, False otherwise + """ + if neighbor in self.explored_point_set: + return False + return all( + external_var_value >= external_var_info.LB + and external_var_value <= external_var_info.UB + for external_var_value, external_var_info in zip( + neighbor, self.working_model_util_block.external_var_info_list + ) + ) + + def neighbor_search(self, config): + """Function that evaluates a group of given points and returns the best + + Parameters + ---------- + config : ConfigBlock + GDPopt configuration block + """ + locally_optimal = True + best_neighbor = None + self.best_direction = None # reset best direction + fmin = float('inf') # Initialize the best objective value + best_dist = 0 # Initialize the best distance + abs_tol = ( + config.integer_tolerance + ) # Use integer_tolerance for objective comparison + + # Loop through all possible directions (neighbors) + for direction in self.directions: + # Generate a neighbor point by applying the direction to the current point + neighbor = tuple(map(sum, zip(self.current_point, direction))) + + # Check if the neighbor is valid + if self._check_valid_neighbor(neighbor): + # Solve the subproblem for this neighbor + primal_improved, primal_bound = self._solve_GDP_subproblem( + neighbor, 'Neighbor search', config + ) + + if primal_improved: + locally_optimal = False + + # --- Tiebreaker Logic --- + if abs(fmin - primal_bound) < abs_tol: + # Calculate the Euclidean distance from the current point + dist = sum( + (x - y) ** 2 for x, y in zip(neighbor, self.current_point) + ) + + # Update the best neighbor if this one is farther away + if dist > best_dist: + best_neighbor = neighbor + self.best_direction = direction + best_dist = dist # Update the best distance + else: + # Standard improvement logic: update if the objective is better + fmin = primal_bound # Update the best objective value + best_neighbor = neighbor # Update the best neighbor + self.best_direction = direction # Update the best direction + best_dist = sum( + (x - y) ** 2 for x, y in zip(neighbor, self.current_point) + ) + # --- End of Tiebreaker Logic --- + + # Move to the best neighbor if an improvement was found + if not locally_optimal: + self.current_point = best_neighbor + + return locally_optimal + + def line_search(self, config): + """Function that performs a line search in the best direction. + + Parameters + ---------- + config : ConfigBlock + GDPopt configuration block + """ + primal_improved = True + while primal_improved: + next_point = tuple(map(sum, zip(self.current_point, self.best_direction))) + if self._check_valid_neighbor(next_point): + primal_improved = self._solve_GDP_subproblem( + next_point, 'Line search', config + ) + if primal_improved: + self.current_point = next_point + else: + break + + def _handle_subproblem_result( + self, subproblem_result, subproblem, external_var_value, config, search_type + ): + """Function that handles the result of the subproblem + + Parameters + ---------- + subproblem_result : tuple + the result of the subproblem + subproblem : ConcreteModel + the subproblem model + external_var_value : list + the values of the external variables + config : ConfigBlock + GDPopt configuration block + search_type : str + the type of search, neighbor search or line search + + Returns + ------- + bool + True if the result improved the current point, False otherwise + """ + if subproblem_result is None: + return False + if subproblem_result.solver.termination_condition in { + tc.optimal, + tc.feasible, + tc.globallyOptimal, + tc.locallyOptimal, + tc.maxTimeLimit, + tc.maxIterations, + tc.maxEvaluations, + }: + primal_bound = ( + subproblem_result.problem.upper_bound + if self.objective_sense == minimize + else subproblem_result.problem.lower_bound + ) + primal_improved = self._update_bounds_after_solve( + search_type, + primal=primal_bound, + logger=config.logger, + current_point=external_var_value, + ) + if primal_improved: + self.update_incumbent( + subproblem.component(self.original_util_block.name) + ) + return primal_improved + return False + + def _log_header(self, logger): + logger.info( + '=================================================================' + '====================================' + ) + logger.info( + '{:^9} | {:^15} | {:^20} | {:^11} | {:^11} | {:^8} | {:^7}\n'.format( + 'Iteration', + 'Search Type', + 'External Variables', + 'Lower Bound', + 'Upper Bound', + 'Gap', + 'Time(s)', + ) + ) + + def _log_current_state( + self, logger, search_type, current_point, primal_improved=False + ): + star = "*" if primal_improved else "" + logger.info( + self.log_formatter.format( + self.iteration, + search_type, + str(current_point), + self.LB, + self.UB, + self.relative_gap(), + get_main_elapsed_time(self.timing), + star, + ) + ) + + def _update_bounds_after_solve( + self, search_type, primal=None, dual=None, logger=None, current_point=None + ): + primal_improved = self._update_bounds(primal, dual) + if logger is not None: + self._log_current_state(logger, search_type, current_point, primal_improved) + + return primal_improved diff --git a/pyomo/contrib/gdpopt/loa.py b/pyomo/contrib/gdpopt/loa.py index 354b61ae940..0901c2d0243 100644 --- a/pyomo/contrib/gdpopt/loa.py +++ b/pyomo/contrib/gdpopt/loa.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/nlp_initialization.py b/pyomo/contrib/gdpopt/nlp_initialization.py index dbc33eb20be..2cf81093cb0 100644 --- a/pyomo/contrib/gdpopt/nlp_initialization.py +++ b/pyomo/contrib/gdpopt/nlp_initialization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/oa_algorithm_utils.py b/pyomo/contrib/gdpopt/oa_algorithm_utils.py index ce4012d8800..d0410dd68b9 100644 --- a/pyomo/contrib/gdpopt/oa_algorithm_utils.py +++ b/pyomo/contrib/gdpopt/oa_algorithm_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/plugins.py b/pyomo/contrib/gdpopt/plugins.py index d0068d25993..a04e20b7ea0 100644 --- a/pyomo/contrib/gdpopt/plugins.py +++ b/pyomo/contrib/gdpopt/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,3 +17,4 @@ def load(): import pyomo.contrib.gdpopt.loa import pyomo.contrib.gdpopt.ric import pyomo.contrib.gdpopt.enumerate + import pyomo.contrib.gdpopt.ldsda diff --git a/pyomo/contrib/gdpopt/ric.py b/pyomo/contrib/gdpopt/ric.py index 2aa1aaf8c67..7708cd47729 100644 --- a/pyomo/contrib/gdpopt/ric.py +++ b/pyomo/contrib/gdpopt/ric.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/solve_discrete_problem.py b/pyomo/contrib/gdpopt/solve_discrete_problem.py index 54218edc50a..021a31eb32f 100644 --- a/pyomo/contrib/gdpopt/solve_discrete_problem.py +++ b/pyomo/contrib/gdpopt/solve_discrete_problem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/solve_subproblem.py b/pyomo/contrib/gdpopt/solve_subproblem.py index e3980c3c784..efb2cf6336c 100644 --- a/pyomo/contrib/gdpopt/solve_subproblem.py +++ b/pyomo/contrib/gdpopt/solve_subproblem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/tests/__init__.py b/pyomo/contrib/gdpopt/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/gdpopt/tests/__init__.py +++ b/pyomo/contrib/gdpopt/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/tests/common_tests.py b/pyomo/contrib/gdpopt/tests/common_tests.py index 88a2642704a..0c920506d58 100644 --- a/pyomo/contrib/gdpopt/tests/common_tests.py +++ b/pyomo/contrib/gdpopt/tests/common_tests.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/tests/four_stage_dynamic_model.py b/pyomo/contrib/gdpopt/tests/four_stage_dynamic_model.py new file mode 100644 index 00000000000..f5538d6f522 --- /dev/null +++ b/pyomo/contrib/gdpopt/tests/four_stage_dynamic_model.py @@ -0,0 +1,396 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +from pyomo.core import ( + Var, + Constraint, + Objective, + Set, + minimize, + exp, + ConcreteModel, + LogicalConstraint, + exactly, + lnot, + lor, + BooleanVar, + land, +) +from pyomo.dae import Integral, DerivativeVar, ContinuousSet +from pyomo.gdp import Disjunct, Disjunction + + +def build_model(mode_transfer=False): + model = ConcreteModel() + + # Set + model.stage = Set(initialize=[1, 2, 3, 4]) + model.mode = Set(initialize=[1, 2, 3]) + + model.t1 = ContinuousSet(bounds=(0, 1)) + model.t2 = ContinuousSet(bounds=(1, 2)) + model.t3 = ContinuousSet(bounds=(2, 3)) + model.t4 = ContinuousSet(bounds=(3, 4)) + + # Variables + model.x1 = Var(model.t1, bounds=(0, 10)) + model.x2 = Var(model.t2, bounds=(0, 10)) + model.x3 = Var(model.t3, bounds=(0, 10)) + model.x4 = Var(model.t4, bounds=(0, 10)) + model.u1 = Var(bounds=(-4, 4)) + model.u2 = Var(bounds=(-4, 4)) + model.u3 = Var(bounds=(-4, 4)) + model.u4 = Var(bounds=(-4, 4)) + + # Dynamic model + model.dxdt1 = DerivativeVar(model.x1, wrt=model.t1) + model.dxdt2 = DerivativeVar(model.x2, wrt=model.t2) + model.dxdt3 = DerivativeVar(model.x3, wrt=model.t3) + model.dxdt4 = DerivativeVar(model.x4, wrt=model.t4) + + # logic constraint + model.stage_mode = Disjunct(model.stage * model.mode) + model.d = Disjunction(model.stage) + + # Stage 1 + + def stage1_mode1_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt1[t] == -model.x1[t] * exp(model.x1[t] - 1) + model.u1 + + model.stage_mode[1, 1].mode1_dynamic_constraint = Constraint( + model.t1, rule=stage1_mode1_dynamic + ) + + def stage1_mode2_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt1[t] == (0.5 * model.x1[t] ** 3 + model.u1) / 20 + + model.stage_mode[1, 2].mode2_dynamic_constraint = Constraint( + model.t1, rule=stage1_mode2_dynamic + ) + + def stage1_mode3_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt1[t] == (model.x1[t] ** 2 + model.u1) / (t + 20) + + model.stage_mode[1, 3].mode3_dynamic_constraint = Constraint( + model.t1, rule=stage1_mode3_dynamic + ) + + # Stage 2 + + def stage2_mode1_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt2[t] == -model.x2[t] * exp(model.x2[t] - 1) + model.u2 + + model.stage_mode[2, 1].mode1_dynamic_constraint = Constraint( + model.t2, rule=stage2_mode1_dynamic + ) + + def stage2_mode2_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt2[t] == (0.5 * model.x2[t] ** 3 + model.u2) / 20 + + model.stage_mode[2, 2].mode2_dynamic_constraint = Constraint( + model.t2, rule=stage2_mode2_dynamic + ) + + def stage2_mode3_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt2[t] == (model.x2[t] ** 2 + model.u2) / (t + 20) + + model.stage_mode[2, 3].mode3_dynamic_constraint = Constraint( + model.t2, rule=stage2_mode3_dynamic + ) + + # Stage 3 + + def stage3_mode1_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt3[t] == -model.x3[t] * exp(model.x3[t] - 1) + model.u3 + + model.stage_mode[3, 1].mode1_dynamic_constraint = Constraint( + model.t3, rule=stage3_mode1_dynamic + ) + + def stage3_mode2_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt3[t] == (0.5 * model.x3[t] ** 3 + model.u3) / 20 + + model.stage_mode[3, 2].mode2_dynamic_constraint = Constraint( + model.t3, rule=stage3_mode2_dynamic + ) + + def stage3_mode3_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt3[t] == (model.x3[t] ** 2 + model.u3) / (t + 20) + + model.stage_mode[3, 3].mode3_dynamic_constraint = Constraint( + model.t3, rule=stage3_mode3_dynamic + ) + + # Stage 4 + + def stage4_mode1_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt4[t] == -model.x4[t] * exp(model.x4[t] - 1) + model.u4 + + model.stage_mode[4, 1].mode1_dynamic_constraint = Constraint( + model.t4, rule=stage4_mode1_dynamic + ) + + def stage4_mode2_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt4[t] == (0.5 * model.x4[t] ** 3 + model.u4) / 20 + + model.stage_mode[4, 2].mode2_dynamic_constraint = Constraint( + model.t4, rule=stage4_mode2_dynamic + ) + + def stage4_mode3_dynamic(disjunct, t): + model = disjunct.model() + return model.dxdt4[t] == (model.x4[t] ** 2 + model.u4) / (t + 20) + + model.stage_mode[4, 3].mode3_dynamic_constraint = Constraint( + model.t4, rule=stage4_mode3_dynamic + ) + + model.d[1] = [ + model.stage_mode[1, 1], + model.stage_mode[1, 2], + model.stage_mode[1, 3], + ] + model.d[2] = [ + model.stage_mode[2, 1], + model.stage_mode[2, 2], + model.stage_mode[2, 3], + ] + model.d[3] = [ + model.stage_mode[3, 1], + model.stage_mode[3, 2], + model.stage_mode[3, 3], + ] + model.d[4] = [ + model.stage_mode[4, 1], + model.stage_mode[4, 2], + model.stage_mode[4, 3], + ] + + if mode_transfer: + model.lc1 = LogicalConstraint( + expr=exactly( + 1, + model.stage_mode[1, 1].indicator_var, + model.stage_mode[1, 2].indicator_var, + model.stage_mode[1, 3].indicator_var, + ) + ) + model.lc2 = LogicalConstraint( + expr=exactly( + 1, + model.stage_mode[2, 1].indicator_var, + model.stage_mode[2, 2].indicator_var, + model.stage_mode[2, 3].indicator_var, + ) + ) + model.lc3 = LogicalConstraint( + expr=exactly( + 1, + model.stage_mode[3, 1].indicator_var, + model.stage_mode[3, 2].indicator_var, + model.stage_mode[3, 3].indicator_var, + ) + ) + model.lc4 = LogicalConstraint( + expr=exactly( + 1, + model.stage_mode[4, 1].indicator_var, + model.stage_mode[4, 2].indicator_var, + model.stage_mode[4, 3].indicator_var, + ) + ) + model.transfer_stage1 = Set(initialize=[2, 3, 4]) + model.transfer_stage2 = Set(initialize=[2, 3, 4, 5]) + model.mode_stransfer_set = Set(initialize=[1, 2]) + model.mode_transfer = BooleanVar( + model.transfer_stage2, model.mode_stransfer_set + ) + model.mode_transfer_lc1 = LogicalConstraint( + expr=exactly( + 1, + model.mode_transfer[2, 1], + model.mode_transfer[3, 1], + model.mode_transfer[4, 1], + model.mode_transfer[5, 1], + ) + ) + model.mode_transfer_lc2 = LogicalConstraint( + expr=exactly( + 1, + model.mode_transfer[2, 2], + model.mode_transfer[3, 2], + model.mode_transfer[4, 2], + model.mode_transfer[5, 2], + ) + ) + + def _mode_transfer_rule1(model, stage): + return model.mode_transfer[stage, 1].equivalent_to( + land( + model.stage_mode[stage - 1, 1].indicator_var, + model.stage_mode[stage, 2].indicator_var, + ) + ) + + model.mode_transfer2mode_choice_lc1 = LogicalConstraint( + model.transfer_stage1, rule=_mode_transfer_rule1 + ) + + def _mode_transfer_rule2(model, stage): + return model.mode_transfer[stage, 2].equivalent_to( + land( + model.stage_mode[stage - 1, 2].indicator_var, + model.stage_mode[stage, 3].indicator_var, + ) + ) + + model.mode_transfer2mode_choice_lc2 = LogicalConstraint( + model.transfer_stage1, rule=_mode_transfer_rule2 + ) + + def _mode_transfer_rule3(model, stage): + return model.mode_transfer[stage, 2].implies( + lor( + model.mode_transfer[stage1, 1] + for stage1 in model.transfer_stage1 + if stage1 < stage + ) + ) + + model.mode_transfer2mode_choice_lc3 = LogicalConstraint( + model.transfer_stage1, rule=_mode_transfer_rule3 + ) + + def _mode_transfer_rule4(model): + return model.mode_transfer[5, 1].implies( + lnot( + lor( + model.stage_mode[stage1, 2].indicator_var + for stage1 in model.stage + ) + ) + ) + + model.mode_transfer2mode_choice_lc4 = LogicalConstraint( + rule=_mode_transfer_rule4 + ) + + def _mode_transfer_rule5(model): + return model.mode_transfer[5, 2].implies( + lnot( + lor( + model.stage_mode[stage1, 3].indicator_var + for stage1 in model.stage + ) + ) + ) + + model.mode_transfer2mode_choice_lc5 = LogicalConstraint( + rule=_mode_transfer_rule5 + ) + + # Sequence constraint + def _sequence_rule1(model, stage): + if stage == 1: + return Constraint.Skip + else: + return model.stage_mode[stage, 2].indicator_var.implies( + lor( + model.stage_mode[stage2, 1].indicator_var + for stage2 in model.stage + if stage2 < stage + ) + ) + + model.seq1 = LogicalConstraint(model.stage, rule=_sequence_rule1) + model.stage_mode[1, 2].indicator_var.fix(False) + + def _sequence_rule2(model, stage): + if stage == 4: + return Constraint.Skip + else: + return model.stage_mode[stage, 2].indicator_var.implies( + lnot( + lor( + model.stage_mode[stage2, 1].indicator_var + for stage2 in model.stage + if stage2 > stage + ) + ) + ) + + model.seq2 = LogicalConstraint(model.stage, rule=_sequence_rule2) + + def _sequence_rule3(model, stage): + if stage <= 1: + return Constraint.Skip + else: + return model.stage_mode[stage, 3].indicator_var.implies( + lor( + model.stage_mode[stage2, 2].indicator_var + for stage2 in model.stage + if stage2 < stage + ) + ) + + model.seq3 = LogicalConstraint(model.stage, rule=_sequence_rule3) + model.stage_mode[1, 3].indicator_var.fix(False) + model.stage_mode[2, 3].indicator_var.fix(False) + + def _sequence_rule4(model, stage): + if stage == 4: + return Constraint.Skip + else: + return model.stage_mode[stage, 3].indicator_var.implies( + lnot( + lor( + model.stage_mode[stage2, 2].indicator_var + for stage2 in model.stage + if stage2 > stage + ) + ) + ) + + model.seq4 = LogicalConstraint(model.stage, rule=_sequence_rule4) + + model.c1 = Constraint(expr=model.x1[0] == 1) + model.c2 = Constraint(expr=model.x1[1] == model.x2[1]) + model.c3 = Constraint(expr=model.x2[2] == model.x3[2]) + model.c4 = Constraint(expr=model.x3[3] == model.x4[3]) + + # Objective function + model.intx1 = Integral( + model.t1, wrt=model.t1, rule=lambda model, t: model.x1[t] ** 2 + ) + model.intx2 = Integral( + model.t2, wrt=model.t2, rule=lambda model, t: model.x2[t] ** 2 + ) + model.intx3 = Integral( + model.t3, wrt=model.t3, rule=lambda model, t: model.x3[t] ** 2 + ) + model.intx4 = Integral( + model.t4, wrt=model.t4, rule=lambda model, t: model.x4[t] ** 2 + ) + + model.obj = Objective( + expr=-(model.intx1 + model.intx2 + model.intx3 + model.intx4), sense=minimize + ) + return model diff --git a/pyomo/contrib/gdpopt/tests/test_LBB.py b/pyomo/contrib/gdpopt/tests/test_LBB.py index 273327b02a4..9d811a60fde 100644 --- a/pyomo/contrib/gdpopt/tests/test_LBB.py +++ b/pyomo/contrib/gdpopt/tests/test_LBB.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -59,6 +59,7 @@ def test_infeasible_GDP(self): self.assertIsNone(m.d.disjuncts[0].indicator_var.value) self.assertIsNone(m.d.disjuncts[1].indicator_var.value) + @unittest.skipUnless(z3_available, "Z3 SAT solver is not available") def test_infeasible_GDP_check_sat(self): """Test for infeasible GDP with check_sat option True.""" m = ConcreteModel() diff --git a/pyomo/contrib/gdpopt/tests/test_enumerate.py b/pyomo/contrib/gdpopt/tests/test_enumerate.py index 8798557ddc9..7f42411ceaf 100644 --- a/pyomo/contrib/gdpopt/tests/test_enumerate.py +++ b/pyomo/contrib/gdpopt/tests/test_enumerate.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gdpopt/tests/test_gdpopt.py b/pyomo/contrib/gdpopt/tests/test_gdpopt.py index 005df56ced5..2857cfbf495 100644 --- a/pyomo/contrib/gdpopt/tests/test_gdpopt.py +++ b/pyomo/contrib/gdpopt/tests/test_gdpopt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,7 +22,6 @@ from pyomo.common.collections import Bunch from pyomo.common.config import ConfigDict, ConfigValue from pyomo.common.fileutils import import_file, PYOMO_ROOT_DIR -from pyomo.contrib.appsi.solvers.gurobi import Gurobi from pyomo.contrib.gdpopt.create_oa_subproblems import ( add_util_block, add_disjunct_list, @@ -767,6 +766,9 @@ def test_time_limit(self): results.solver.termination_condition, TerminationCondition.maxTimeLimit ) + @unittest.skipUnless( + license_available, "No BARON license--8PP logical problem exceeds demo size" + ) def test_LOA_8PP_logical_default_init(self): """Test logic-based outer approximation with 8PP.""" exfile = import_file(join(exdir, 'eight_process', 'eight_proc_logical.py')) @@ -870,6 +872,9 @@ def test_LOA_8PP_maxBinary(self): ) ct.check_8PP_solution(self, eight_process, results) + @unittest.skipUnless( + license_available, "No BARON license--8PP logical problem exceeds demo size" + ) def test_LOA_8PP_logical_maxBinary(self): """Test logic-based OA with max_binary initialization.""" exfile = import_file(join(exdir, 'eight_process', 'eight_proc_logical.py')) @@ -1050,7 +1055,11 @@ def assert_correct_disjuncts_active( self.assertTrue(fabs(value(eight_process.profit.expr) - 68) <= 1e-2) - @unittest.skipUnless(Gurobi().available(), "APPSI Gurobi solver is not available") + @unittest.skipUnless( + SolverFactory('appsi_gurobi').available(exception_flag=False) + and SolverFactory('appsi_gurobi').license_is_valid(), + "Legacy APPSI Gurobi solver is not available", + ) def test_auto_persistent_solver(self): exfile = import_file(join(exdir, 'eight_process', 'eight_proc_model.py')) m = exfile.build_eight_process_flowsheet() @@ -1126,6 +1135,9 @@ def test_RIC_8PP_default_init(self): ) ct.check_8PP_solution(self, eight_process, results) + @unittest.skipUnless( + license_available, "No BARON license--8PP logical problem exceeds demo size" + ) def test_RIC_8PP_logical_default_init(self): """Test logic-based outer approximation with 8PP.""" exfile = import_file(join(exdir, 'eight_process', 'eight_proc_logical.py')) diff --git a/pyomo/contrib/gdpopt/tests/test_ldsda.py b/pyomo/contrib/gdpopt/tests/test_ldsda.py new file mode 100644 index 00000000000..82b74612522 --- /dev/null +++ b/pyomo/contrib/gdpopt/tests/test_ldsda.py @@ -0,0 +1,61 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +from pyomo.environ import SolverFactory, value, Var, Constraint, TransformationFactory +from pyomo.gdp import Disjunct +import pyomo.common.unittest as unittest +from pyomo.contrib.gdpopt.tests.four_stage_dynamic_model import build_model + + +class TestGDPoptLDSDA(unittest.TestCase): + """Real unit tests for GDPopt""" + + @unittest.skipUnless( + SolverFactory('gams').available(False) + and SolverFactory('gams').license_is_valid(), + "gams solver not available", + ) + def test_solve_four_stage_dynamic_model(self): + + model = build_model(mode_transfer=True) + + # Discretize the model using dae.collocation + discretizer = TransformationFactory('dae.collocation') + discretizer.apply_to(model, nfe=10, ncp=3, scheme='LAGRANGE-RADAU') + # We need to reconstruct the constraints in disjuncts after discretization. + # This is a bug in Pyomo.dae. https://github.com/Pyomo/pyomo/issues/3101 + for disjunct in model.component_data_objects(ctype=Disjunct): + for constraint in disjunct.component_objects(ctype=Constraint): + constraint._constructed = False + constraint.construct() + + for dxdt in model.component_data_objects(ctype=Var, descend_into=True): + if 'dxdt' in dxdt.name: + dxdt.setlb(-300) + dxdt.setub(300) + + for direction_norm in ['L2', 'Linf']: + result = SolverFactory('gdpopt.ldsda').solve( + model, + direction_norm=direction_norm, + minlp_solver='gams', + minlp_solver_args=dict(solver='ipopth'), + starting_point=[1, 2], + logical_constraint_list=[ + model.mode_transfer_lc1, + model.mode_transfer_lc2, + ], + time_limit=100, + ) + self.assertAlmostEqual(value(model.obj), -23.305325, places=4) + + +if __name__ == '__main__': + unittest.main() diff --git a/pyomo/contrib/gdpopt/util.py b/pyomo/contrib/gdpopt/util.py index 2cb70f0ea60..43ca8a6e277 100644 --- a/pyomo/contrib/gdpopt/util.py +++ b/pyomo/contrib/gdpopt/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -499,15 +499,16 @@ def lower_logger_level_to(logger, level=None, tee=False): sh.setLevel(level) level_changed = True - yield - - if tee: - logger.handlers.clear() - for h in handlers: - logger.addHandler(h) - logger.propagate = True - if level_changed: - logger.setLevel(old_logger_level) + try: + yield + finally: + if tee: + logger.handlers.clear() + for h in handlers: + logger.addHandler(h) + logger.propagate = True + if level_changed: + logger.setLevel(old_logger_level) def _add_bigm_constraint_to_transformed_model(m, constraint, block): @@ -553,6 +554,13 @@ def _add_bigm_constraint_to_transformed_model(m, constraint, block): # making a Reference to the ComponentData so that it will look like an # indexed component for now. If I redesign bigm at some point, then this # could be prettier. - bigm._transform_constraint(Reference(constraint), parent_disjunct, None, [], []) + bigm._transform_constraint( + Reference(constraint), + parent_disjunct, + None, + [], + [], + 1 - parent_disjunct.binary_indicator_var, + ) # Now get rid of it because this is a class attribute! del bigm._config diff --git a/pyomo/contrib/gjh/GJH.py b/pyomo/contrib/gjh/GJH.py index dc7c8de89c1..c082e2ec27a 100644 --- a/pyomo/contrib/gjh/GJH.py +++ b/pyomo/contrib/gjh/GJH.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -41,9 +41,9 @@ def readgjh(fname=None): H : list Current objective Hessian. variableList : list - Variables as defined by *.col file. + Variables as defined by `*.col` file. constraintList : list - Constraints as defined by *.row file. + Constraints as defined by `*.row` file. """ if fname is None: diff --git a/pyomo/contrib/gjh/__init__.py b/pyomo/contrib/gjh/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/gjh/__init__.py +++ b/pyomo/contrib/gjh/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/gjh/getGJH.py b/pyomo/contrib/gjh/getGJH.py index 2d503c71438..d509fb97a8d 100644 --- a/pyomo/contrib/gjh/getGJH.py +++ b/pyomo/contrib/gjh/getGJH.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,7 +18,7 @@ logger = logging.getLogger('pyomo.common') # These URLs were retrieved from -# https://ampl.com/resources/hooking-your-solver-to-ampl/ +# https://dev.ampl.com/more/reports.html#hooking-solvers-to-ampl # All 32-bit downloads are used - 64-bit is available only for Linux urlmap = { 'linux': 'https://netlib.org/ampl/student/linux/gjh.gz', diff --git a/pyomo/contrib/gjh/plugins.py b/pyomo/contrib/gjh/plugins.py index f072f7b2c38..6e6d3278d5e 100644 --- a/pyomo/contrib/gjh/plugins.py +++ b/pyomo/contrib/gjh/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/iis/__init__.py b/pyomo/contrib/iis/__init__.py index e8d6a7ac2c3..87e2be218b4 100644 --- a/pyomo/contrib/iis/__init__.py +++ b/pyomo/contrib/iis/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,3 +10,4 @@ # ___________________________________________________________________________ from pyomo.contrib.iis.iis import write_iis +from pyomo.contrib.iis.mis import compute_infeasibility_explanation diff --git a/pyomo/contrib/iis/iis.py b/pyomo/contrib/iis/iis.py index 1ffd6cb0bd3..6bc4d2a5d47 100644 --- a/pyomo/contrib/iis/iis.py +++ b/pyomo/contrib/iis/iis.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/iis/mis.py b/pyomo/contrib/iis/mis.py new file mode 100644 index 00000000000..12f3ec91ec1 --- /dev/null +++ b/pyomo/contrib/iis/mis.py @@ -0,0 +1,377 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +""" +WaterTAP Copyright (c) 2020-2023, The Regents of the University of California, through Lawrence Berkeley National Laboratory, Oak Ridge National Laboratory, National Renewable Energy Laboratory, and National Energy Technology Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + + Neither the name of the University of California, Lawrence Berkeley National Laboratory, Oak Ridge National Laboratory, National Renewable Energy Laboratory, National Energy Technology Laboratory, U.S. Dept. of Energy nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +You are under no obligation whatsoever to provide any bug fixes, patches, or upgrades to the features, functionality or performance of the source code ("Enhancements") to anyone; however, if you choose to make your Enhancements available either publicly, or directly to Lawrence Berkeley National Laboratory, without imposing a separate written license agreement for such Enhancements, then you hereby grant the following license: a non-exclusive, royalty-free perpetual license to install, use, modify, prepare derivative works, incorporate into other computer software, distribute, and sublicense such enhancements or derivative works thereof, in binary and source code form. +""" +""" +Minimal Intractable System (MIS) finder +Originally written by Ben Knueven as part of the WaterTAP project: + https://github.com/watertap-org/watertap +That's why this file has the watertap copyright notice. + +copied by DLW 18Feb2024 and edited + +See: https://www.sce.carleton.ca/faculty/chinneck/docs/CPAIOR07InfeasibilityTutorial.pdf +""" + +import logging +import pyomo.environ as pyo + +from pyomo.core.plugins.transform.add_slack_vars import AddSlackVariables + +from pyomo.core.plugins.transform.hierarchy import IsomorphicTransformation + +from pyomo.common.modeling import unique_component_name +from pyomo.common.collections import ComponentMap, ComponentSet + +from pyomo.opt import WriterFactory + +logger = logging.getLogger("pyomo.contrib.iis") +logger.setLevel(logging.INFO) + + +class _VariableBoundsAsConstraints(IsomorphicTransformation): + """Replace all variables bounds and domain information with constraints. + + Leaves fixed Vars untouched (for now) + """ + + def _apply_to(self, instance, **kwds): + + bound_constr_block_name = unique_component_name(instance, "_variable_bounds") + instance.add_component(bound_constr_block_name, pyo.Block()) + bound_constr_block = instance.component(bound_constr_block_name) + + for v in instance.component_data_objects(pyo.Var, descend_into=True): + if v.fixed: + continue + lb, ub = v.bounds + if lb is None and ub is None: + continue + var_name = v.getname(fully_qualified=True) + if lb is not None: + con_name = "lb_for_" + var_name + con = pyo.Constraint(expr=(lb, v, None)) + bound_constr_block.add_component(con_name, con) + if ub is not None: + con_name = "ub_for_" + var_name + con = pyo.Constraint(expr=(None, v, ub)) + bound_constr_block.add_component(con_name, con) + + # now we deactivate the variable bounds / domain + v.domain = pyo.Reals + v.setlb(None) + v.setub(None) + + +def compute_infeasibility_explanation( + model, solver, tee=False, tolerance=1e-8, logger=logger +): + """ + This function attempts to determine why a given model is infeasible. It deploys + two main algorithms: + + 1. Successfully relaxes the constraints of the problem, and reports to the user + some sets of constraints and variable bounds, which when relaxed, creates a + feasible model. + 2. Uses the information collected from (1) to attempt to compute a Minimal + Infeasible System (MIS), which is a set of constraints and variable bounds + which appear to be in conflict with each other. It is minimal in the sense + that removing any single constraint or variable bound would result in a + feasible subsystem. + + Args + ---- + model: A pyomo block + solver: A pyomo solver object or a string for SolverFactory + tee (optional): Display intermediate solves conducted (False) + tolerance (optional): The feasibility tolerance to use when declaring a + constraint feasible (1e-08) + logger:logging.Logger + A logger for messages. Uses pyomo.contrib.mis logger by default. + + """ + # Suggested enhancement: It might be useful to return sets of names for each set of relaxed components, as well as the final minimal infeasible system + + # hold the original harmless + modified_model = model.clone() + + if solver is None: + raise ValueError("A solver must be supplied") + elif isinstance(solver, str): + solver = pyo.SolverFactory(solver) + else: + # assume we have a solver + assert solver.available() + + # first, cache the values we get + _value_cache = ComponentMap() + for v in model.component_data_objects(pyo.Var, descend_into=True): + _value_cache[v] = v.value + + # finding proper reference + if model.parent_block() is None: + common_name = "" + else: + common_name = model.name + "." + + _modified_model_var_to_original_model_var = ComponentMap() + _modified_model_value_cache = ComponentMap() + + for v in model.component_data_objects(pyo.Var, descend_into=True): + modified_model_var = modified_model.find_component(v.name[len(common_name) :]) + + _modified_model_var_to_original_model_var[modified_model_var] = v + _modified_model_value_cache[modified_model_var] = _value_cache[v] + modified_model_var.set_value(_value_cache[v], skip_validation=True) + + # TODO: For WT / IDAES models, we should probably be more + # selective in *what* we elasticize. E.g., it probably + # does not make sense to elasticize property calculations + # and maybe certain other equality constraints calculating + # values. Maybe we shouldn't elasticize *any* equality + # constraints. + # For example, elasticizing the calculation of mass fraction + # makes absolutely no sense and will just be noise for the + # modeler to sift through. We could try to sort the constraints + # such that we look for those with linear coefficients `1` on + # some term and leave those be. + # Alternatively, we could apply this tool to a version of the + # model that has as many as possible of these constraints + # "substituted out". + # move the variable bounds to the constraints + _VariableBoundsAsConstraints().apply_to(modified_model) + + AddSlackVariables().apply_to(modified_model) + slack_block = modified_model._core_add_slack_variables + + for v in slack_block.component_data_objects(pyo.Var): + v.fix(0) + # start with variable bounds -- these are the easiest to interpret + for c in modified_model._variable_bounds.component_data_objects( + pyo.Constraint, descend_into=True + ): + plus = slack_block.component(f"_slack_plus_{c.name}") + minus = slack_block.component(f"_slack_minus_{c.name}") + assert not (plus is None and minus is None) + if plus is not None: + plus.unfix() + if minus is not None: + minus.unfix() + + # TODO: Elasticizing too much at once seems to cause Ipopt trouble. + # After an initial sweep, we should just fix one elastic variable + # and put everything else on a stack of "constraints to elasticize". + # We elasticize one constraint at a time and fix one constraint at a time. + # After fixing an elastic variable, we elasticize a single constraint it + # appears in and put the remaining constraints on the stack. If the resulting problem + # is feasible, we keep going "down the tree". If the resulting problem is + # infeasible or cannot be solved, we elasticize a single constraint from + # the top of the stack. + # The algorithm stops when the stack is empty and the subproblem is infeasible. + # Along the way, any time the current problem is infeasible we can check to + # see if the current set of constraints in the filter is as a collection of + # infeasible constraints -- to terminate early. + # However, while more stable, this is much more computationally intensive. + # So, we leave the implementation simpler for now and consider this as + # a potential extension if this tool sometimes cannot report a good answer. + # Phase 1 -- build the initial set of constraints, or prove feasibility + msg = "" + fixed_slacks = ComponentSet() + elastic_filter = ComponentSet() + + def _constraint_loop(relaxed_things, msg): + if msg == "": + msg += f"Model {model.name} may be infeasible. A feasible solution was found with only the following {relaxed_things} relaxed:\n" + else: + msg += f"Another feasible solution was found with only the following {relaxed_things} relaxed:\n" + while True: + + def _constraint_generator(): + elastic_filter_size_initial = len(elastic_filter) + for v in slack_block.component_data_objects(pyo.Var): + if v.value > tolerance: + constr = _get_constraint(modified_model, v) + yield constr, v.value + v.fix(0) + fixed_slacks.add(v) + elastic_filter.add(constr) + if len(elastic_filter) == elastic_filter_size_initial: + raise Exception(f"Found model {model.name} to be feasible!") + + msg = _get_results_with_value(_constraint_generator(), msg) + for var, val in _modified_model_value_cache.items(): + var.set_value(val, skip_validation=True) + results = solver.solve(modified_model, tee=tee) + if pyo.check_optimal_termination(results): + msg += f"Another feasible solution was found with only the following {relaxed_things} relaxed:\n" + else: + break + return msg + + results = solver.solve(modified_model, tee=tee) + if pyo.check_optimal_termination(results): + msg = _constraint_loop("variable bounds", msg) + + # next, try relaxing the inequality constraints + for v in slack_block.component_data_objects(pyo.Var): + c = _get_constraint(modified_model, v) + if c.equality: + # equality constraint + continue + if v not in fixed_slacks: + v.unfix() + + results = solver.solve(modified_model, tee=tee) + if pyo.check_optimal_termination(results): + msg = _constraint_loop("inequality constraints and/or variable bounds", msg) + + for v in slack_block.component_data_objects(pyo.Var): + if v not in fixed_slacks: + v.unfix() + + results = solver.solve(modified_model, tee=tee) + if pyo.check_optimal_termination(results): + msg = _constraint_loop( + "inequality constraints, equality constraints, and/or variable bounds", msg + ) + + if len(elastic_filter) == 0: + # load the feasible solution into the original model + for modified_model_var, v in _modified_model_var_to_original_model_var.items(): + v.set_value(modified_model_var.value, skip_validation=True) + results = solver.solve(model, tee=tee) + if pyo.check_optimal_termination(results): + logger.info(f"A feasible solution was found!") + else: + logger.info( + f"Could not find a feasible solution with violated constraints or bounds. This model is likely unstable" + ) + + # Phase 2 -- deletion filter + # remove slacks by fixing them to 0 + for v in slack_block.component_data_objects(pyo.Var): + v.fix(0) + for o in modified_model.component_data_objects(pyo.Objective, descend_into=True): + o.deactivate() + + # mark all constraints not in the filter as inactive + for c in modified_model.component_data_objects(pyo.Constraint): + if c in elastic_filter: + continue + else: + c.deactivate() + + try: + results = solver.solve(modified_model, tee=tee) + except: + results = None + + if (results is not None) and pyo.check_optimal_termination(results): + msg += "Could not determine Minimal Intractable System\n" + else: + deletion_filter = [] + guards = [] + for constr in elastic_filter: + constr.deactivate() + for var, val in _modified_model_value_cache.items(): + var.set_value(val, skip_validation=True) + math_failure = False + try: + results = solver.solve(modified_model, tee=tee) + except: + math_failure = True + + if math_failure: + constr.activate() + guards.append(constr) + elif pyo.check_optimal_termination(results): + constr.activate() + deletion_filter.append(constr) + else: # still infeasible without this constraint + pass + + msg += "Computed Minimal Intractable System (MIS)!\n" + msg += "Constraints / bounds in MIS:\n" + msg = _get_results(deletion_filter, msg) + msg += "Constraints / bounds in guards for stability:" + msg = _get_results(guards, msg) + + logger.info(msg) + + +def _get_results_with_value(constr_value_generator, msg=None): + # note that "lb_for_" and "ub_for_" are 7 characters long + if msg is None: + msg = "" + for c, value in constr_value_generator: + c_name = c.name + if "_variable_bounds" in c_name: + name = c.local_name + if "lb" in name: + msg += f"\tlb of var {name[7:]} by {value}\n" + elif "ub" in name: + msg += f"\tub of var {name[7:]} by {value}\n" + else: + raise RuntimeError("unrecognized var name") + else: + msg += f"\tconstraint: {c_name} by {value}\n" + return msg + + +def _get_results(constr_generator, msg=None): + # note that "lb_for_" and "ub_for_" are 7 characters long + if msg is None: + msg = "" + for c in constr_generator: + c_name = c.name + if "_variable_bounds" in c_name: + name = c.local_name + if "lb" in name: + msg += f"\tlb of var {name[7:]}\n" + elif "ub" in name: + msg += f"\tub of var {name[7:]}\n" + else: + raise RuntimeError("unrecognized var name") + else: + msg += f"\tconstraint: {c_name}\n" + return msg + + +def _get_constraint(modified_model, v): + if "_slack_plus_" in v.name: + constr = modified_model.find_component(v.local_name[len("_slack_plus_") :]) + if constr is None: + raise RuntimeError( + f"Bad constraint name {v.local_name[len('_slack_plus_'):]}" + ) + return constr + elif "_slack_minus_" in v.name: + constr = modified_model.find_component(v.local_name[len("_slack_minus_") :]) + if constr is None: + raise RuntimeError( + f"Bad constraint name {v.local_name[len('_slack_minus_'):]}" + ) + return constr + else: + raise RuntimeError(f"Bad var name {v.name}") diff --git a/pyomo/contrib/iis/tests/__init__.py b/pyomo/contrib/iis/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/iis/tests/__init__.py +++ b/pyomo/contrib/iis/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/iis/tests/test_iis.py b/pyomo/contrib/iis/tests/test_iis.py index cf7b5613a3a..b8995eca825 100644 --- a/pyomo/contrib/iis/tests/test_iis.py +++ b/pyomo/contrib/iis/tests/test_iis.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/iis/tests/test_mis.py b/pyomo/contrib/iis/tests/test_mis.py new file mode 100644 index 00000000000..4da3c6eaeca --- /dev/null +++ b/pyomo/contrib/iis/tests/test_mis.py @@ -0,0 +1,125 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +import pyomo.environ as pyo +import pyomo.contrib.iis.mis as mis +from pyomo.contrib.iis.mis import _get_constraint +from pyomo.common.tempfiles import TempfileManager + +import logging +import os + + +def _get_infeasible_model(): + m = pyo.ConcreteModel("trivial4test") + m.x = pyo.Var(within=pyo.Binary) + m.y = pyo.Var(within=pyo.NonNegativeReals) + + m.c1 = pyo.Constraint(expr=m.y <= 100.0 * m.x) + m.c2 = pyo.Constraint(expr=m.y <= -100.0 * m.x) + m.c3 = pyo.Constraint(expr=m.x >= 0.5) + + m.o = pyo.Objective(expr=-m.y) + + return m + + +def _get_feasible_model(): + m = pyo.ConcreteModel("Trivial Feasible Quad") + m.x = pyo.Var([1, 2], bounds=(0, 1)) + m.y = pyo.Var(bounds=(0, 1)) + m.c = pyo.Constraint(expr=m.x[1] * m.x[2] >= -1) + m.d = pyo.Constraint(expr=m.x[1] + m.y >= 1) + + return m + + +class TestMIS(unittest.TestCase): + @unittest.skipUnless( + pyo.SolverFactory("ipopt").available(exception_flag=False), + "ipopt not available", + ) + def test_write_mis_ipopt(self): + _test_mis("ipopt") + + def test__get_constraint_errors(self): + # A not-completely-cynical way to get the coverage up. + m = _get_infeasible_model() # not modified + fct = _get_constraint + + m.foo_slack_plus_ = pyo.Var() + self.assertRaises(RuntimeError, fct, m, m.foo_slack_plus_) + m.foo_slack_minus_ = pyo.Var() + self.assertRaises(RuntimeError, fct, m, m.foo_slack_minus_) + m.foo_bar = pyo.Var() + self.assertRaises(RuntimeError, fct, m, m.foo_bar) + + def test_feasible_model(self): + m = _get_feasible_model() + opt = pyo.SolverFactory("ipopt") + self.assertRaises(Exception, mis.compute_infeasibility_explanation, m, opt) + + +def _check_output(file_name): + # pretty simple check for now + with open(file_name, "r+") as file1: + lines = file1.readlines() + trigger = "Constraints / bounds in MIS:" + nugget = "lb of var y" + live = False # (long i) + found_nugget = False + for line in lines: + if trigger in line: + live = True + if live: + if nugget in line: + found_nugget = True + if not found_nugget: + raise RuntimeError(f"Did not find '{nugget}' after '{trigger}' in output") + else: + pass + + +def _test_mis(solver_name): + m = _get_infeasible_model() + opt = pyo.SolverFactory(solver_name) + + # This test seems to fail on Windows as it unlinks the tempfile, so live with it + # On a Windows machine, we will not use a temp dir and just try to delete the log file + if os.name == "nt": + file_name = f"_test_mis_{solver_name}.log" + logger = logging.getLogger(f"test_mis_{solver_name}") + logger.setLevel(logging.INFO) + fh = logging.FileHandler(file_name) + fh.setLevel(logging.DEBUG) + logger.addHandler(fh) + + mis.compute_infeasibility_explanation(m, opt, logger=logger) + _check_output(file_name) + # os.remove(file_name) cannot remove it on Windows. Still in use. + + else: # not windows + with TempfileManager.new_context() as tmpmgr: + tmp_path = tmpmgr.mkdtemp() + file_name = os.path.join(tmp_path, f"_test_mis_{solver_name}.log") + logger = logging.getLogger(f"test_mis_{solver_name}") + logger.setLevel(logging.INFO) + fh = logging.FileHandler(file_name) + fh.setLevel(logging.DEBUG) + logger.addHandler(fh) + + mis.compute_infeasibility_explanation(m, opt, logger=logger) + _check_output(file_name) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/iis/tests/trivial_mis.py b/pyomo/contrib/iis/tests/trivial_mis.py new file mode 100644 index 00000000000..33780db76a6 --- /dev/null +++ b/pyomo/contrib/iis/tests/trivial_mis.py @@ -0,0 +1,29 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +import pyomo.environ as pyo + +from pyomo.contrib.iis.mis import compute_infeasibility_explanation + + +class TestMIS(unittest.TestCase): + def test_trivial_quad(self): + m = pyo.ConcreteModel("Trivial Quad") + m.x = pyo.Var([1, 2], bounds=(0, 1)) + m.y = pyo.Var(bounds=(0, 1)) + m.c = pyo.Constraint(expr=m.x[1] * m.x[2] == -1) + m.d = pyo.Constraint(expr=m.x[1] + m.y >= 1) + # Note: this particular little problem is quadratic + # As of 18Feb2024 DLW is not sure the explanation code works + # with solvers other than ipopt + ipopt = pyo.SolverFactory("ipopt") + compute_infeasibility_explanation(m, solver=ipopt) diff --git a/pyomo/contrib/incidence_analysis/README.md b/pyomo/contrib/incidence_analysis/README.md index e998f417be3..6f2442869b5 100644 --- a/pyomo/contrib/incidence_analysis/README.md +++ b/pyomo/contrib/incidence_analysis/README.md @@ -7,7 +7,7 @@ These tools can be used to detect whether and (approximately) why the Jacobian of equality constraints is structurally or numerically singular, which commonly happens as the result of a modeling error. See the -[documentation](https://pyomo.readthedocs.io/en/stable/contributed_packages/incidence/index.html) +[documentation](https://pyomo.readthedocs.io/en/stable/explanation/analysis/incidence/index.html) for more information and examples. ## Dependencies diff --git a/pyomo/contrib/incidence_analysis/__init__.py b/pyomo/contrib/incidence_analysis/__init__.py index 8942d09b6b9..3fde03e3073 100644 --- a/pyomo/contrib/incidence_analysis/__init__.py +++ b/pyomo/contrib/incidence_analysis/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,3 +18,21 @@ ) from .incidence import get_incident_variables from .config import IncidenceMethod + +# +# declare deprecation paths for removed modules +# +from pyomo.common.deprecation import moved_module + +moved_module( + "pyomo.contrib.incidence_analysis.util", + "pyomo.contrib.incidence_analysis.scc_solver", + version='6.5.0', + msg=( + "The 'pyomo.contrib.incidence_analysis.util' module has been moved to " + "'pyomo.contrib.incidence_analysis.scc_solver'. However, we recommend " + "importing this functionality (e.g. solve_strongly_connected_components) " + "directly from 'pyomo.contrib.incidence_analysis'." + ), +) +del moved_module diff --git a/pyomo/contrib/incidence_analysis/common/__init__.py b/pyomo/contrib/incidence_analysis/common/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/incidence_analysis/common/__init__.py +++ b/pyomo/contrib/incidence_analysis/common/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/common/dulmage_mendelsohn.py b/pyomo/contrib/incidence_analysis/common/dulmage_mendelsohn.py index 5bc724fafc1..32a0f12f911 100644 --- a/pyomo/contrib/incidence_analysis/common/dulmage_mendelsohn.py +++ b/pyomo/contrib/incidence_analysis/common/dulmage_mendelsohn.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/common/tests/__init__.py b/pyomo/contrib/incidence_analysis/common/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/incidence_analysis/common/tests/__init__.py +++ b/pyomo/contrib/incidence_analysis/common/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/common/tests/test_dulmage_mendelsohn.py b/pyomo/contrib/incidence_analysis/common/tests/test_dulmage_mendelsohn.py index b17ae9b1dfc..6157ab8594a 100644 --- a/pyomo/contrib/incidence_analysis/common/tests/test_dulmage_mendelsohn.py +++ b/pyomo/contrib/incidence_analysis/common/tests/test_dulmage_mendelsohn.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/config.py b/pyomo/contrib/incidence_analysis/config.py index 128273b4dec..6dc8eef46a0 100644 --- a/pyomo/contrib/incidence_analysis/config.py +++ b/pyomo/contrib/incidence_analysis/config.py @@ -1,20 +1,19 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -"""Configuration options for incidence graph generation -""" +"""Configuration options for incidence graph generation""" import enum from pyomo.common.config import ConfigDict, ConfigValue, InEnum from pyomo.common.modeling import NOTSET -from pyomo.repn.plugins.nl_writer import AMPLRepnVisitor, text_nl_template +from pyomo.repn.ampl import AMPLRepnVisitor from pyomo.repn.util import FileDeterminism, FileDeterminism_to_SortComponents @@ -33,7 +32,14 @@ class IncidenceMethod(enum.Enum): """ ampl_repn = 3 - """Use ``pyomo.repn.plugins.nl_writer.AMPLRepnVisitor``""" + """Use ``pyomo.repn.ampl.AMPLRepnVisitor``""" + + +class IncidenceOrder(enum.Enum): + + dulmage_mendelsohn_upper = 0 + + dulmage_mendelsohn_lower = 1 _include_fixed = ConfigValue( @@ -123,7 +129,6 @@ def get_config_from_kwds(**kwds): and kwds.get("_ampl_repn_visitor", None) is None ): subexpression_cache = {} - subexpression_order = [] external_functions = {} var_map = {} used_named_expressions = set() @@ -134,9 +139,7 @@ def get_config_from_kwds(**kwds): export_defined_variables = False sorter = FileDeterminism_to_SortComponents(FileDeterminism.ORDERED) amplvisitor = AMPLRepnVisitor( - text_nl_template, subexpression_cache, - subexpression_order, external_functions, var_map, used_named_expressions, diff --git a/pyomo/contrib/incidence_analysis/connected.py b/pyomo/contrib/incidence_analysis/connected.py index 28d4bdee73f..df4bfb915a6 100644 --- a/pyomo/contrib/incidence_analysis/connected.py +++ b/pyomo/contrib/incidence_analysis/connected.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/dulmage_mendelsohn.py b/pyomo/contrib/incidence_analysis/dulmage_mendelsohn.py index 3a6d06a809c..46f3537d3f4 100644 --- a/pyomo/contrib/incidence_analysis/dulmage_mendelsohn.py +++ b/pyomo/contrib/incidence_analysis/dulmage_mendelsohn.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/incidence.py b/pyomo/contrib/incidence_analysis/incidence.py index 96cbf77c47d..3cfd82ad3d7 100644 --- a/pyomo/contrib/incidence_analysis/incidence.py +++ b/pyomo/contrib/incidence_analysis/incidence.py @@ -1,15 +1,14 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -"""Functionality for identifying variables that participate in expressions -""" +"""Functionality for identifying variables that participate in expressions""" from contextlib import nullcontext @@ -17,7 +16,6 @@ from pyomo.core.expr.numvalue import value as pyo_value from pyomo.repn import generate_standard_repn from pyomo.util.subsystems import TemporarySubsystemManager -from pyomo.repn.plugins.nl_writer import AMPLRepn from pyomo.contrib.incidence_analysis.config import ( IncidenceMethod, get_config_from_kwds, @@ -83,21 +81,27 @@ def _get_incident_via_standard_repn( def _get_incident_via_ampl_repn(expr, linear_only, visitor): + def _nonlinear_var_id_collector(idlist): + for _id in idlist: + if _id in visitor.subexpression_cache: + info = visitor.subexpression_cache[_id][1] + if info.nonlinear: + yield from _nonlinear_var_id_collector(info.nonlinear[1]) + if info.linear: + yield from _nonlinear_var_id_collector(info.linear) + else: + yield _id + var_map = visitor.var_map - orig_activevisitor = AMPLRepn.ActiveVisitor - AMPLRepn.ActiveVisitor = visitor - try: - repn = visitor.walk_expression((expr, None, 0, 1.0)) - finally: - AMPLRepn.ActiveVisitor = orig_activevisitor - - nonlinear_var_ids = [] if repn.nonlinear is None else repn.nonlinear[1] + repn = visitor.walk_expression((expr, None, 0, 1.0)) + nonlinear_var_id_set = set() unique_nonlinear_var_ids = [] - for v_id in nonlinear_var_ids: - if v_id not in nonlinear_var_id_set: - nonlinear_var_id_set.add(v_id) - unique_nonlinear_var_ids.append(v_id) + if repn.nonlinear: + for v_id in _nonlinear_var_id_collector(repn.nonlinear[1]): + if v_id not in nonlinear_var_id_set: + nonlinear_var_id_set.add(v_id) + unique_nonlinear_var_ids.append(v_id) nonlinear_vars = [var_map[v_id] for v_id in unique_nonlinear_var_ids] linear_only_vars = [ diff --git a/pyomo/contrib/incidence_analysis/interface.py b/pyomo/contrib/incidence_analysis/interface.py index 50cb84daaf5..45a783019b7 100644 --- a/pyomo/contrib/incidence_analysis/interface.py +++ b/pyomo/contrib/incidence_analysis/interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,7 +15,7 @@ import enum import textwrap -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.core.base.var import Var from pyomo.core.base.constraint import Constraint from pyomo.core.base.objective import Objective @@ -28,7 +28,7 @@ scipy as sp, plotly, ) -from pyomo.common.deprecation import deprecated +from pyomo.common.deprecation import deprecated, deprecation_warning from pyomo.contrib.incidence_analysis.config import get_config_from_kwds from pyomo.contrib.incidence_analysis.matching import maximum_matching from pyomo.contrib.incidence_analysis.connected import get_independent_submatrices @@ -279,7 +279,7 @@ def __init__(self, model=None, active=True, include_inequality=True, **kwds): self._incidence_graph = None self._variables = None self._constraints = None - elif isinstance(model, _BlockData): + elif isinstance(model, BlockData): self._constraints = [ con for con in model.component_data_objects(Constraint, active=active) @@ -348,7 +348,7 @@ def __init__(self, model=None, active=True, include_inequality=True, **kwds): else: raise TypeError( "Unsupported type for incidence graph. Expected PyomoNLP" - " or _BlockData but got %s." % type(model) + " or BlockData but got %s." % type(model) ) @property @@ -453,11 +453,29 @@ def _validate_input(self, variables, constraints): raise ValueError("Neither variables nor a model have been provided.") else: variables = self.variables + elif self._incidence_graph is not None: + # If variables were provided and an incidence graph is cached, + # make sure the provided variables exist in the graph. + for var in variables: + if var not in self._var_index_map: + raise KeyError( + f"Variable {var} does not exist in the cached" + " incidence graph." + ) if constraints is None: if self._incidence_graph is None: raise ValueError("Neither constraints nor a model have been provided.") else: constraints = self.constraints + elif self._incidence_graph is not None: + # If constraints were provided and an incidence graph is cached, + # make sure the provided constraints exist in the graph. + for con in constraints: + if con not in self._con_index_map: + raise KeyError( + f"Constraint {con} does not exist in the cached" + " incidence graph." + ) _check_unindexed(variables + constraints) return variables, constraints @@ -854,7 +872,7 @@ def dulmage_mendelsohn(self, variables=None, constraints=None): # Hopefully this does not get too confusing... return var_partition, con_partition - def remove_nodes(self, nodes, constraints=None): + def remove_nodes(self, variables=None, constraints=None): """Removes the specified variables and constraints (columns and rows) from the cached incidence matrix. @@ -866,35 +884,76 @@ def remove_nodes(self, nodes, constraints=None): Parameters ---------- - nodes: list - VarData or ConData objects whose columns or rows will be - removed from the incidence matrix. + variables: list + VarData objects whose nodes will be removed from the incidence graph constraints: list - VarData or ConData objects whose columns or rows will be - removed from the incidence matrix. + ConData objects whose nodes will be removed from the incidence graph + + .. note:: + + **Deprecation in Pyomo v6.7.2** + + The pre-6.7.2 implementation of ``remove_nodes`` allowed variables and + constraints to remove to be specified in a single list. This made + error checking difficult, and indeed, if invalid components were + provided, we carried on silently instead of throwing an error or + warning. As part of a fix to raise an error if an invalid component + (one that is not part of the incidence graph) is provided, we now require + variables and constraints to be specified separately. """ if constraints is None: constraints = [] + if variables is None: + variables = [] if self._incidence_graph is None: raise RuntimeError( "Attempting to remove variables and constraints from cached " "incidence matrix,\nbut no incidence matrix has been cached." ) - to_exclude = ComponentSet(nodes) - to_exclude.update(constraints) - vars_to_include = [v for v in self.variables if v not in to_exclude] - cons_to_include = [c for c in self.constraints if c not in to_exclude] + + vars_to_validate = [] + cons_to_validate = [] + depr_msg = ( + "In IncidenceGraphInterface.remove_nodes, passing variables and" + " constraints in the same list is deprecated. Please separate your" + " variables and constraints and pass them in the order variables," + " constraints." + ) + if any(var in self._con_index_map for var in variables) or any( + con in self._var_index_map for con in constraints + ): + deprecation_warning(depr_msg, version="6.7.2") + # If we received variables/constraints in the same list, sort them. + # Any unrecognized objects will be caught by _validate_input. + for var in variables: + if var in self._con_index_map: + cons_to_validate.append(var) + else: + vars_to_validate.append(var) + for con in constraints: + if con in self._var_index_map: + vars_to_validate.append(con) + else: + cons_to_validate.append(con) + + variables, constraints = self._validate_input( + vars_to_validate, cons_to_validate + ) + v_exclude = ComponentSet(variables) + c_exclude = ComponentSet(constraints) + vars_to_include = [v for v in self.variables if v not in v_exclude] + cons_to_include = [c for c in self.constraints if c not in c_exclude] incidence_graph = self._extract_subgraph(vars_to_include, cons_to_include) # update attributes self._variables = vars_to_include self._constraints = cons_to_include self._incidence_graph = incidence_graph self._var_index_map = ComponentMap( - (var, i) for i, var in enumerate(self.variables) + (var, i) for i, var in enumerate(vars_to_include) ) self._con_index_map = ComponentMap( - (con, i) for i, con in enumerate(self._constraints) + (con, i) for i, con in enumerate(cons_to_include) ) def plot(self, variables=None, constraints=None, title=None, show=True): diff --git a/pyomo/contrib/incidence_analysis/matching.py b/pyomo/contrib/incidence_analysis/matching.py index e37b35cd973..cee862d028f 100644 --- a/pyomo/contrib/incidence_analysis/matching.py +++ b/pyomo/contrib/incidence_analysis/matching.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/scc_solver.py b/pyomo/contrib/incidence_analysis/scc_solver.py index 835e07c7c02..a5b802521f2 100644 --- a/pyomo/contrib/incidence_analysis/scc_solver.py +++ b/pyomo/contrib/incidence_analysis/scc_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,15 +18,16 @@ IncidenceGraphInterface, _generate_variables_in_constraints, ) +from pyomo.contrib.incidence_analysis.config import IncidenceMethod _log = logging.getLogger(__name__) def generate_strongly_connected_components( - constraints, variables=None, include_fixed=False + constraints, variables=None, include_fixed=False, igraph=None ): - """Yield in order ``_BlockData`` that each contain the variables and + """Yield in order ``BlockData`` that each contain the variables and constraints of a single diagonal block in a block lower triangularization of the incidence matrix of constraints and variables @@ -41,13 +42,16 @@ def generate_strongly_connected_components( variables: List of Pyomo variable data objects Variables that may participate in strongly connected components. If not provided, all variables in the constraints will be used. - include_fixed: Bool + include_fixed: Bool, optional Indicates whether fixed variables will be included when identifying variables in constraints. + igraph: IncidenceGraphInterface, optional + Incidence graph containing (at least) the provided constraints + and variables. Yields ------ - Tuple of ``_BlockData``, list-of-variables + Tuple of ``BlockData``, list-of-variables Blocks containing the variables and constraints of every strongly connected component, in a topological order. The variables are the "input variables" for that block. @@ -55,11 +59,24 @@ def generate_strongly_connected_components( """ if variables is None: variables = list( - _generate_variables_in_constraints(constraints, include_fixed=include_fixed) + _generate_variables_in_constraints( + constraints, + include_fixed=include_fixed, + method=IncidenceMethod.ampl_repn, + ) ) - assert len(variables) == len(constraints) - igraph = IncidenceGraphInterface() + if len(variables) != len(constraints): + nvar = len(variables) + ncon = len(constraints) + raise RuntimeError( + "generate_strongly_connected_components only supports systems with the" + f" same numbers of variables and equality constraints. Got {nvar}" + f" variables and {ncon} constraints." + ) + if igraph is None: + igraph = IncidenceGraphInterface() + var_blocks, con_blocks = igraph.block_triangularize( variables=variables, constraints=constraints ) @@ -68,12 +85,14 @@ def generate_strongly_connected_components( subsets, include_fixed=include_fixed ): # TODO: How does len scale for reference-to-list? + # If this assert fails, it may be due to a bug in block_triangularize + # or generate_subsystem_block. assert len(block.vars) == len(block.cons) yield (block, inputs) def solve_strongly_connected_components( - block, solver=None, solve_kwds=None, calc_var_kwds=None + block, *, solver=None, solve_kwds=None, use_calc_var=True, calc_var_kwds=None ): """Solve a square system of variables and equality constraints by solving strongly connected components individually. @@ -98,6 +117,9 @@ def solve_strongly_connected_components( a solve method. solve_kwds: Dictionary Keyword arguments for the solver's solve method + use_calc_var: Bool + Whether to use ``calculate_variable_from_constraint`` for one-by-one + square system solves calc_var_kwds: Dictionary Keyword arguments for calculate_variable_from_constraint @@ -112,23 +134,28 @@ def solve_strongly_connected_components( calc_var_kwds = {} igraph = IncidenceGraphInterface( - block, active=True, include_fixed=False, include_inequality=False + block, + active=True, + include_fixed=False, + include_inequality=False, + method=IncidenceMethod.ampl_repn, ) constraints = igraph.constraints variables = igraph.variables res_list = [] log_blocks = _log.isEnabledFor(logging.DEBUG) - for scc, inputs in generate_strongly_connected_components(constraints, variables): - with TemporarySubsystemManager(to_fix=inputs): + for scc, inputs in generate_strongly_connected_components( + constraints, variables, igraph=igraph + ): + with TemporarySubsystemManager(to_fix=inputs, remove_bounds_on_fix=True): N = len(scc.vars) - if N == 1: + if N == 1 and use_calc_var: if log_blocks: _log.debug(f"Solving 1x1 block: {scc.cons[0].name}.") results = calculate_variable_from_constraint( scc.vars[0], scc.cons[0], **calc_var_kwds ) - res_list.append(results) else: if solver is None: var_names = [var.name for var in scc.vars.values()][:10] @@ -142,5 +169,5 @@ def solve_strongly_connected_components( if log_blocks: _log.debug(f"Solving {N}x{N} block.") results = solver.solve(scc, **solve_kwds) - res_list.append(results) + res_list.append(results) return res_list diff --git a/pyomo/contrib/incidence_analysis/tests/__init__.py b/pyomo/contrib/incidence_analysis/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/incidence_analysis/tests/__init__.py +++ b/pyomo/contrib/incidence_analysis/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/tests/models_for_testing.py b/pyomo/contrib/incidence_analysis/tests/models_for_testing.py index 6040e80e068..cdd0341fdbd 100644 --- a/pyomo/contrib/incidence_analysis/tests/models_for_testing.py +++ b/pyomo/contrib/incidence_analysis/tests/models_for_testing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/tests/test_connected.py b/pyomo/contrib/incidence_analysis/tests/test_connected.py index 421231d3dd0..306081fa0c8 100644 --- a/pyomo/contrib/incidence_analysis/tests/test_connected.py +++ b/pyomo/contrib/incidence_analysis/tests/test_connected.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/tests/test_dulmage_mendelsohn.py b/pyomo/contrib/incidence_analysis/tests/test_dulmage_mendelsohn.py index 6195d6afca7..f6eded632fb 100644 --- a/pyomo/contrib/incidence_analysis/tests/test_dulmage_mendelsohn.py +++ b/pyomo/contrib/incidence_analysis/tests/test_dulmage_mendelsohn.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/tests/test_incidence.py b/pyomo/contrib/incidence_analysis/tests/test_incidence.py index 832fbbfb10c..5e576754de8 100644 --- a/pyomo/contrib/incidence_analysis/tests/test_incidence.py +++ b/pyomo/contrib/incidence_analysis/tests/test_incidence.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/tests/test_interface.py b/pyomo/contrib/incidence_analysis/tests/test_interface.py index 4b77d60d8ba..ce4e176a792 100644 --- a/pyomo/contrib/incidence_analysis/tests/test_interface.py +++ b/pyomo/contrib/incidence_analysis/tests/test_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -634,17 +634,15 @@ def test_exception(self): nlp = PyomoNLP(model) igraph = IncidenceGraphInterface(nlp) - with self.assertRaises(RuntimeError) as exc: + with self.assertRaisesRegex(KeyError, "does not exist"): variables = [model.P] constraints = [model.ideal_gas] igraph.maximum_matching(variables, constraints) - self.assertIn("must be unindexed", str(exc.exception)) - with self.assertRaises(RuntimeError) as exc: + with self.assertRaisesRegex(KeyError, "does not exist"): variables = [model.P] constraints = [model.ideal_gas] igraph.block_triangularize(variables, constraints) - self.assertIn("must be unindexed", str(exc.exception)) @unittest.skipUnless(networkx_available, "networkx is not available.") @@ -885,17 +883,15 @@ def test_exception(self): model = make_gas_expansion_model() igraph = IncidenceGraphInterface(model) - with self.assertRaises(RuntimeError) as exc: + with self.assertRaisesRegex(KeyError, "does not exist"): variables = [model.P] constraints = [model.ideal_gas] igraph.maximum_matching(variables, constraints) - self.assertIn("must be unindexed", str(exc.exception)) - with self.assertRaises(RuntimeError) as exc: + with self.assertRaisesRegex(KeyError, "does not exist"): variables = [model.P] constraints = [model.ideal_gas] igraph.block_triangularize(variables, constraints) - self.assertIn("must be unindexed", str(exc.exception)) @unittest.skipUnless(scipy_available, "scipy is not available.") def test_remove(self): @@ -923,7 +919,7 @@ def test_remove(self): # Say we know that these variables and constraints should # be matched... vars_to_remove = [model.F[0], model.F[2]] - cons_to_remove = (model.mbal[1], model.mbal[2]) + cons_to_remove = [model.mbal[1], model.mbal[2]] igraph.remove_nodes(vars_to_remove, cons_to_remove) variable_set = ComponentSet(igraph.variables) self.assertNotIn(model.F[0], variable_set) @@ -1309,7 +1305,7 @@ def test_remove(self): # matrix. vars_to_remove = [m.flow_comp[1]] cons_to_remove = [m.flow_eqn[1]] - igraph.remove_nodes(vars_to_remove + cons_to_remove) + igraph.remove_nodes(vars_to_remove, cons_to_remove) var_dmp, con_dmp = igraph.dulmage_mendelsohn() var_con_set = ComponentSet(igraph.variables + igraph.constraints) underconstrained_set = ComponentSet( @@ -1460,6 +1456,42 @@ def test_remove_no_matrix(self): with self.assertRaisesRegex(RuntimeError, "no incidence matrix"): igraph.remove_nodes([m.v1]) + def test_remove_bad_node(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3]) + m.eq = pyo.Constraint(pyo.PositiveIntegers) + m.eq[1] = m.x[1] * m.x[2] == m.x[3] + m.eq[2] = m.x[1] + 2 * m.x[2] == 3 * m.x[3] + igraph = IncidenceGraphInterface(m) + with self.assertRaisesRegex(KeyError, "does not exist"): + # Suppose we think something like this should work. We should get + # an error, and not silently do nothing. + igraph.remove_nodes([m.x], [m.eq[1]]) + + with self.assertRaisesRegex(KeyError, "does not exist"): + igraph.remove_nodes(None, [m.eq]) + + with self.assertRaisesRegex(KeyError, "does not exist"): + igraph.remove_nodes([[m.x[1], m.x[2]], [m.eq[1]]]) + + def test_remove_varcon_samelist_deprecated(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3]) + m.eq = pyo.Constraint(pyo.PositiveIntegers) + m.eq[1] = m.x[1] * m.x[2] == m.x[3] + m.eq[2] = m.x[1] + 2 * m.x[2] == 3 * m.x[3] + + igraph = IncidenceGraphInterface(m) + # This raises a deprecation warning. When the deprecated functionality + # is removed, this will fail, and this test should be updated accordingly. + igraph.remove_nodes([m.eq[1], m.x[1]]) + self.assertEqual(len(igraph.variables), 2) + self.assertEqual(len(igraph.constraints), 1) + + igraph.remove_nodes([], [m.eq[2], m.x[2]]) + self.assertEqual(len(igraph.variables), 1) + self.assertEqual(len(igraph.constraints), 0) + @unittest.skipUnless(networkx_available, "networkx is not available.") @unittest.skipUnless(scipy_available, "scipy is not available.") @@ -1840,7 +1872,7 @@ def test_var_elim(self): for adj_con in igraph.get_adjacent_to(m.x[1]): for adj_var in igraph.get_adjacent_to(m.eq4): igraph.add_edge(adj_var, adj_con) - igraph.remove_nodes([m.x[1], m.eq4]) + igraph.remove_nodes([m.x[1]], [m.eq4]) assert ComponentSet(igraph.variables) == ComponentSet([m.x[2], m.x[3], m.x[4]]) assert ComponentSet(igraph.constraints) == ComponentSet([m.eq1, m.eq2, m.eq3]) @@ -1888,7 +1920,7 @@ def test_block_data_obj(self): self.assertEqual(len(var_dmp.unmatched), 1) self.assertEqual(len(con_dmp.unmatched), 1) - msg = "Unsupported type.*_BlockData" + msg = "Unsupported type.*BlockData" with self.assertRaisesRegex(TypeError, msg): igraph = IncidenceGraphInterface(m.block) diff --git a/pyomo/contrib/incidence_analysis/tests/test_matching.py b/pyomo/contrib/incidence_analysis/tests/test_matching.py index 2327439f0a2..f1d48143753 100644 --- a/pyomo/contrib/incidence_analysis/tests/test_matching.py +++ b/pyomo/contrib/incidence_analysis/tests/test_matching.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py b/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py index b75f93e4a12..08cf5ace9c1 100644 --- a/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py +++ b/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -501,5 +501,22 @@ def test_with_inequalities(self): self.assertEqual(m.x[3].value, 1.0) +@unittest.skipUnless(scipy_available, "SciPy is not available") +@unittest.skipUnless(networkx_available, "NetworkX is not available") +class TestExceptions(unittest.TestCase): + def test_nonsquare_system(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2], initialize=1) + m.eq = pyo.Constraint(expr=m.x[1] + m.x[2] == 1) + + msg = "Got 2 variables and 1 constraints" + with self.assertRaisesRegex(RuntimeError, msg): + list( + generate_strongly_connected_components( + constraints=[m.eq], variables=[m.x[1], m.x[2]] + ) + ) + + if __name__ == "__main__": unittest.main() diff --git a/pyomo/contrib/incidence_analysis/tests/test_triangularize.py b/pyomo/contrib/incidence_analysis/tests/test_triangularize.py index 22548a15998..0fc47e0d38a 100644 --- a/pyomo/contrib/incidence_analysis/tests/test_triangularize.py +++ b/pyomo/contrib/incidence_analysis/tests/test_triangularize.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/tests/test_visualize.py b/pyomo/contrib/incidence_analysis/tests/test_visualize.py new file mode 100644 index 00000000000..1c06ecd2888 --- /dev/null +++ b/pyomo/contrib/incidence_analysis/tests/test_visualize.py @@ -0,0 +1,47 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +from pyomo.common.dependencies import ( + matplotlib, + matplotlib_available, + scipy_available, + networkx_available, +) +from pyomo.contrib.incidence_analysis.visualize import spy_dulmage_mendelsohn +from pyomo.contrib.incidence_analysis.tests.models_for_testing import ( + make_gas_expansion_model, + make_dynamic_model, + make_degenerate_solid_phase_model, +) + + +@unittest.skipUnless(matplotlib_available, "Matplotlib is not available") +@unittest.skipUnless(scipy_available, "SciPy is not available") +@unittest.skipUnless(networkx_available, "NetworkX is not available") +class TestSpy(unittest.TestCase): + def test_spy_dulmage_mendelsohn(self): + models = [ + make_gas_expansion_model(), + make_dynamic_model(), + make_degenerate_solid_phase_model(), + ] + for m in models: + fig, ax = spy_dulmage_mendelsohn(m) + # Note that this is a weak test. We just test that we can call the + # plot method, it doesn't raise an error, and gives us back the + # types we expect. We don't attempt to validate the resulting plot. + self.assertTrue(isinstance(fig, matplotlib.pyplot.Figure)) + self.assertTrue(isinstance(ax, matplotlib.pyplot.Axes)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/incidence_analysis/triangularize.py b/pyomo/contrib/incidence_analysis/triangularize.py index 6af251b1ec6..d6a6f95d805 100644 --- a/pyomo/contrib/incidence_analysis/triangularize.py +++ b/pyomo/contrib/incidence_analysis/triangularize.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/incidence_analysis/util.py b/pyomo/contrib/incidence_analysis/util.py deleted file mode 100644 index 8b6572eb900..00000000000 --- a/pyomo/contrib/incidence_analysis/util.py +++ /dev/null @@ -1,22 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.common.deprecation import relocated_module - -msg = ( - "The 'pyomo.contrib.incidence_analysis.util' module has been moved to" - " 'pyomo.contrib.incidence_analysis.scc_solver'. However, we recommend" - " importing this functionality (e.g. solve_strongly_connected_components)" - " directly from 'pyomo.contrib.incidence_analysis'." -) -relocated_module( - "pyomo.contrib.incidence_analysis.scc_solver", version='6.5.0', msg=msg -) diff --git a/pyomo/contrib/incidence_analysis/visualize.py b/pyomo/contrib/incidence_analysis/visualize.py new file mode 100644 index 00000000000..d7ce379c882 --- /dev/null +++ b/pyomo/contrib/incidence_analysis/visualize.py @@ -0,0 +1,217 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +"""Module for visualizing results of incidence graph or matrix analysis""" +from pyomo.contrib.incidence_analysis.config import IncidenceOrder +from pyomo.contrib.incidence_analysis.interface import ( + IncidenceGraphInterface, + get_structural_incidence_matrix, +) +from pyomo.common.dependencies import matplotlib + + +def _partition_variables_and_constraints( + model, order=IncidenceOrder.dulmage_mendelsohn_upper, **kwds +): + """Partition variables and constraints in an incidence graph""" + igraph = IncidenceGraphInterface(model, **kwds) + vdmp, cdmp = igraph.dulmage_mendelsohn() + + ucv = vdmp.unmatched + vdmp.underconstrained + ucc = cdmp.underconstrained + + ocv = vdmp.overconstrained + occ = cdmp.overconstrained + cdmp.unmatched + + ucvblocks, uccblocks = igraph.get_connected_components( + variables=ucv, constraints=ucc + ) + ocvblocks, occblocks = igraph.get_connected_components( + variables=ocv, constraints=occ + ) + wcvblocks, wccblocks = igraph.block_triangularize( + variables=vdmp.square, constraints=cdmp.square + ) + # By default, we block-*lower* triangularize. By default, however, we want + # the Dulmage-Mendelsohn decomposition to be block-*upper* triangular. + wcvblocks.reverse() + wccblocks.reverse() + vpartition = [ucvblocks, wcvblocks, ocvblocks] + cpartition = [uccblocks, wccblocks, occblocks] + + if order == IncidenceOrder.dulmage_mendelsohn_lower: + # If a block-lower triangular matrix was requested, we need to reverse + # both the inner and outer partitions + vpartition.reverse() + cpartition.reverse() + for vb in vpartition: + vb.reverse() + for cb in cpartition: + cb.reverse() + + return vpartition, cpartition + + +def _get_rectangle_around_coords(ij1, ij2, linewidth=2, linestyle="-"): + i1, j1 = ij1 + i2, j2 = ij2 + buffer = 0.5 + ll_corner = (min(i1, i2) - buffer, min(j1, j2) - buffer) + width = abs(i1 - i2) + 2 * buffer + height = abs(j1 - j2) + 2 * buffer + rect = matplotlib.patches.Rectangle( + ll_corner, + width, + height, + clip_on=False, + fill=False, + edgecolor="orange", + linewidth=linewidth, + linestyle=linestyle, + ) + return rect + + +def spy_dulmage_mendelsohn( + model, + *, + incidence_kwds=None, + order=IncidenceOrder.dulmage_mendelsohn_upper, + highlight_coarse=True, + highlight_fine=True, + skip_wellconstrained=False, + ax=None, + linewidth=2, + spy_kwds=None, +): + """Plot sparsity structure in Dulmage-Mendelsohn order on Matplotlib axes + + This is a wrapper around the Matplotlib ``Axes.spy`` method for plotting + an incidence matrix in Dulmage-Mendelsohn order, with coarse and/or fine + partitions highlighted. The coarse partition refers to the under-constrained, + over-constrained, and well-constrained subsystems, while the fine partition + refers to block diagonal or block triangular partitions of the former + subsystems. + + Parameters + ---------- + + model: ``ConcreteModel`` + Input model to plot sparsity structure of + + incidence_kwds: dict, optional + Config options for ``IncidenceGraphInterface`` + + order: ``IncidenceOrder``, optional + Order in which to plot sparsity structure. Default is + ``IncidenceOrder.dulmage_mendelsohn_upper`` for a block-upper triangular + matrix. Set to ``IncidenceOrder.dulmage_mendelsohn_lower`` for a + block-lower triangular matrix. + + highlight_coarse: bool, optional + Whether to draw a rectangle around the coarse partition. Default True + + highlight_fine: bool, optional + Whether to draw a rectangle around the fine partition. Default True + + skip_wellconstrained: bool, optional + Whether to skip highlighting the well-constrained subsystem of the + coarse partition. Default False + + ax: ``matplotlib.pyplot.Axes``, optional + Axes object on which to plot. If not provided, new figure + and axes are created. + + linewidth: int, optional + Line width of for rectangle used to highlight. Default 2 + + spy_kwds: dict, optional + Keyword arguments for ``Axes.spy`` + + Returns + ------- + + fig: ``matplotlib.pyplot.Figure`` or ``None`` + Figure on which the sparsity structure is plotted. ``None`` if axes + are provided + + ax: ``matplotlib.pyplot.Axes`` + Axes on which the sparsity structure is plotted + + """ + plt = matplotlib.pyplot + if incidence_kwds is None: + incidence_kwds = {} + if spy_kwds is None: + spy_kwds = {} + + vpart, cpart = _partition_variables_and_constraints(model, order=order) + vpart_fine = sum(vpart, []) + cpart_fine = sum(cpart, []) + vorder = sum(vpart_fine, []) + corder = sum(cpart_fine, []) + + imat = get_structural_incidence_matrix(vorder, corder) + nvar = len(vorder) + ncon = len(corder) + + if ax is None: + fig, ax = plt.subplots() + else: + fig = None + + markersize = spy_kwds.pop("markersize", None) + if markersize is None: + # At 10000 vars/cons, we want markersize=0.2 + # At 20 vars/cons, we want markersize=10 + # We assume we want a linear relationship between 1/nvar + # and the markersize. + markersize = (10.0 - 0.2) / (1 / 20 - 1 / 10000) * ( + 1 / max(nvar, ncon) - 1 / 10000 + ) + 0.2 + + ax.spy(imat, markersize=markersize, **spy_kwds) + ax.tick_params(length=0) + if highlight_coarse: + start = (0, 0) + for i, (vblocks, cblocks) in enumerate(zip(vpart, cpart)): + # Get the total number of variables/constraints in this part + # of the coarse partition + nv = sum(len(vb) for vb in vblocks) + nc = sum(len(cb) for cb in cblocks) + stop = (start[0] + nv - 1, start[1] + nc - 1) + if not (i == 1 and skip_wellconstrained) and nv > 0 and nc > 0: + # Regardless of whether we are plotting in upper or lower + # triangular order, the well-constrained subsystem is at + # position 1 + # + # The get-rectangle function doesn't look good if we give it + # an "empty region" to box. + ax.add_patch( + _get_rectangle_around_coords(start, stop, linewidth=linewidth) + ) + start = (stop[0] + 1, stop[1] + 1) + + if highlight_fine: + # Use dashed lines to distinguish inner from outer partitions + # if we are highlighting both + linestyle = "--" if highlight_coarse else "-" + start = (0, 0) + for vb, cb in zip(vpart_fine, cpart_fine): + stop = (start[0] + len(vb) - 1, start[1] + len(cb) - 1) + # Note that the subset's we're boxing here can't be empty. + ax.add_patch( + _get_rectangle_around_coords( + start, stop, linestyle=linestyle, linewidth=linewidth + ) + ) + start = (stop[0] + 1, stop[1] + 1) + + return fig, ax diff --git a/pyomo/contrib/interior_point/__init__.py b/pyomo/contrib/interior_point/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/interior_point/__init__.py +++ b/pyomo/contrib/interior_point/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/examples/__init__.py b/pyomo/contrib/interior_point/examples/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/interior_point/examples/__init__.py +++ b/pyomo/contrib/interior_point/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/examples/ex1.py b/pyomo/contrib/interior_point/examples/ex1.py index f6d8f14ac0a..e4ed9239d2c 100644 --- a/pyomo/contrib/interior_point/examples/ex1.py +++ b/pyomo/contrib/interior_point/examples/ex1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,24 +16,29 @@ import logging -logging.basicConfig(level=logging.INFO) -# Supposedly this sets the root logger's level to INFO. -# But when linear_solver.logger logs with debug, -# it gets propagated to a mysterious root logger with -# level NOTSET... +def solve_qcqp_example(): + logging.basicConfig(level=logging.INFO) + # Supposedly this sets the root logger's level to INFO. + # But when linear_solver.logger logs with debug, + # it gets propagated to a mysterious root logger with + # level NOTSET... -m = pyo.ConcreteModel() -m.x = pyo.Var() -m.y = pyo.Var() -m.obj = pyo.Objective(expr=m.x**2 + m.y**2) -m.c1 = pyo.Constraint(expr=m.y == pyo.exp(m.x)) -m.c2 = pyo.Constraint(expr=m.y >= (m.x - 1) ** 2) -interface = InteriorPointInterface(m) -linear_solver = MumpsInterface( - # log_filename='lin_sol.log', - icntl_options={11: 1} # Set error level to 1 (most detailed) -) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y == pyo.exp(m.x)) + m.c2 = pyo.Constraint(expr=m.y >= (m.x - 1) ** 2) + interface = InteriorPointInterface(m) + linear_solver = MumpsInterface( + # log_filename='lin_sol.log', + icntl_options={11: 1} # Set error level to 1 (most detailed) + ) -ip_solver = InteriorPointSolver(linear_solver) -x, duals_eq, duals_ineq = ip_solver.solve(interface) -print(x, duals_eq, duals_ineq) + ip_solver = InteriorPointSolver(linear_solver) + x, duals_eq, duals_ineq = ip_solver.solve(interface) + print(x, duals_eq, duals_ineq) + + +if __name__ == '__main__': + solve_qcqp_example() diff --git a/pyomo/contrib/interior_point/interface.py b/pyomo/contrib/interior_point/interface.py index 93b83f385ba..a873edfa81a 100644 --- a/pyomo/contrib/interior_point/interface.py +++ b/pyomo/contrib/interior_point/interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/interior_point.py b/pyomo/contrib/interior_point/interior_point.py index 502de338fdc..f67b3c2cd7f 100644 --- a/pyomo/contrib/interior_point/interior_point.py +++ b/pyomo/contrib/interior_point/interior_point.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/inverse_reduced_hessian.py b/pyomo/contrib/interior_point/inverse_reduced_hessian.py index ac3c6a98463..37742c44872 100644 --- a/pyomo/contrib/interior_point/inverse_reduced_hessian.py +++ b/pyomo/contrib/interior_point/inverse_reduced_hessian.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/linalg/__init__.py b/pyomo/contrib/interior_point/linalg/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/interior_point/linalg/__init__.py +++ b/pyomo/contrib/interior_point/linalg/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/linalg/base_linear_solver_interface.py b/pyomo/contrib/interior_point/linalg/base_linear_solver_interface.py index c3304fd1395..983787c77ca 100644 --- a/pyomo/contrib/interior_point/linalg/base_linear_solver_interface.py +++ b/pyomo/contrib/interior_point/linalg/base_linear_solver_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/linalg/ma27_interface.py b/pyomo/contrib/interior_point/linalg/ma27_interface.py index 7604bd432bb..8dc668b9cf6 100644 --- a/pyomo/contrib/interior_point/linalg/ma27_interface.py +++ b/pyomo/contrib/interior_point/linalg/ma27_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/linalg/mumps_interface.py b/pyomo/contrib/interior_point/linalg/mumps_interface.py index c7480e2b6d0..3b6dfc45bd9 100644 --- a/pyomo/contrib/interior_point/linalg/mumps_interface.py +++ b/pyomo/contrib/interior_point/linalg/mumps_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/linalg/scipy_interface.py b/pyomo/contrib/interior_point/linalg/scipy_interface.py index d0f773fcb81..cf45474a44d 100644 --- a/pyomo/contrib/interior_point/linalg/scipy_interface.py +++ b/pyomo/contrib/interior_point/linalg/scipy_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/linalg/tests/__init__.py b/pyomo/contrib/interior_point/linalg/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/interior_point/linalg/tests/__init__.py +++ b/pyomo/contrib/interior_point/linalg/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/linalg/tests/test_linear_solvers.py b/pyomo/contrib/interior_point/linalg/tests/test_linear_solvers.py index 93071a5f215..86a469c1cc5 100644 --- a/pyomo/contrib/interior_point/linalg/tests/test_linear_solvers.py +++ b/pyomo/contrib/interior_point/linalg/tests/test_linear_solvers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/linalg/tests/test_realloc.py b/pyomo/contrib/interior_point/linalg/tests/test_realloc.py index 3a53d0e7db9..76edd752dac 100644 --- a/pyomo/contrib/interior_point/linalg/tests/test_realloc.py +++ b/pyomo/contrib/interior_point/linalg/tests/test_realloc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/tests/__init__.py b/pyomo/contrib/interior_point/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/interior_point/tests/__init__.py +++ b/pyomo/contrib/interior_point/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/tests/test_interior_point.py b/pyomo/contrib/interior_point/tests/test_interior_point.py index a05408abe1e..b38b3b34756 100644 --- a/pyomo/contrib/interior_point/tests/test_interior_point.py +++ b/pyomo/contrib/interior_point/tests/test_interior_point.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/tests/test_inverse_reduced_hessian.py b/pyomo/contrib/interior_point/tests/test_inverse_reduced_hessian.py index 61f5e90e3cf..4fa6584b398 100644 --- a/pyomo/contrib/interior_point/tests/test_inverse_reduced_hessian.py +++ b/pyomo/contrib/interior_point/tests/test_inverse_reduced_hessian.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/interior_point/tests/test_realloc.py b/pyomo/contrib/interior_point/tests/test_realloc.py index b7a5d00e488..f8f5895d5a8 100644 --- a/pyomo/contrib/interior_point/tests/test_realloc.py +++ b/pyomo/contrib/interior_point/tests/test_realloc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,6 @@ # ___________________________________________________________________________ import pyomo.common.unittest as unittest -import pyomo.environ as pe from pyomo.core.base import ConcreteModel, Var, Constraint, Objective from pyomo.common.dependencies import attempt_import diff --git a/pyomo/contrib/interior_point/tests/test_reg.py b/pyomo/contrib/interior_point/tests/test_reg.py index a7fc686545b..fc7eeb78bc2 100644 --- a/pyomo/contrib/interior_point/tests/test_reg.py +++ b/pyomo/contrib/interior_point/tests/test_reg.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/latex_printer/Readme.md b/pyomo/contrib/latex_printer/Readme.md index 9b9febf9644..f1c97640887 100644 --- a/pyomo/contrib/latex_printer/Readme.md +++ b/pyomo/contrib/latex_printer/Readme.md @@ -19,19 +19,3 @@ m.constraint_1 = pyo.Constraint(expr = m.x**2 + m.y**2.0 - m.z**2.0 <= m.c ) pstr = latex_printer(m) ``` - -## Acknowledgement - -Pyomo: Python Optimization Modeling Objects -Copyright (c) 2008-2023 -National Technology and Engineering Solutions of Sandia, LLC -Under the terms of Contract DE-NA0003525 with National Technology and -Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -rights in this software. - -Development of this module was conducted as part of the Institute for -the Design of Advanced Energy Systems (IDAES) with support through the -Simulation-Based Engineering, Crosscutting Research Program within the -U.S. Department of Energy’s Office of Fossil Energy and Carbon Management. - -This software is distributed under the 3-clause BSD License. \ No newline at end of file diff --git a/pyomo/contrib/latex_printer/__init__.py b/pyomo/contrib/latex_printer/__init__.py index c434b53dfe1..1fdab1d9800 100644 --- a/pyomo/contrib/latex_printer/__init__.py +++ b/pyomo/contrib/latex_printer/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2023 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -# Recommended just to build all of the appropriate things -import pyomo.environ - # Remove one layer of .latex_printer -# import statemnt is now: +# import statement is now: # from pyomo.contrib.latex_printer import latex_printer try: from pyomo.contrib.latex_printer.latex_printer import latex_printer diff --git a/pyomo/contrib/latex_printer/latex_printer.py b/pyomo/contrib/latex_printer/latex_printer.py index 110df7cd5ca..16f8bf1e804 100644 --- a/pyomo/contrib/latex_printer/latex_printer.py +++ b/pyomo/contrib/latex_printer/latex_printer.py @@ -1,18 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2023 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -45,8 +34,8 @@ from pyomo.core.expr.visitor import identify_components from pyomo.core.expr.base import ExpressionBase -from pyomo.core.base.expression import ScalarExpression, _GeneralExpressionData -from pyomo.core.base.objective import ScalarObjective, _GeneralObjectiveData +from pyomo.core.base.expression import ScalarExpression, ExpressionData +from pyomo.core.base.objective import ScalarObjective, ObjectiveData import pyomo.core.kernel as kernel from pyomo.core.expr.template_expr import ( GetItemExpression, @@ -58,9 +47,9 @@ resolve_template, templatize_rule, ) -from pyomo.core.base.var import ScalarVar, _GeneralVarData, IndexedVar -from pyomo.core.base.param import _ParamData, ScalarParam, IndexedParam -from pyomo.core.base.set import _SetData +from pyomo.core.base.var import ScalarVar, VarData, IndexedVar +from pyomo.core.base.param import ParamData, ScalarParam, IndexedParam +from pyomo.core.base.set import SetData, SetOperator from pyomo.core.base.constraint import ScalarConstraint, IndexedConstraint from pyomo.common.collections.component_map import ComponentMap from pyomo.common.collections.component_set import ComponentSet @@ -75,7 +64,7 @@ from pyomo.core.base.external import _PythonCallbackFunctionID from pyomo.core.base.enums import SortComponents -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.repn.util import ExprType @@ -90,6 +79,40 @@ from pyomo.common.dependencies import numpy as np, numpy_available +set_operator_map = { + '|': r' \cup ', + '&': r' \cap ', + '*': r' \times ', + '-': r' \setminus ', + '^': r' \triangle ', +} + +latex_reals = r'\mathds{R}' +latex_integers = r'\mathds{Z}' + +domainMap = { + 'Reals': latex_reals, + 'PositiveReals': latex_reals + '_{> 0}', + 'NonPositiveReals': latex_reals + '_{\\leq 0}', + 'NegativeReals': latex_reals + '_{< 0}', + 'NonNegativeReals': latex_reals + '_{\\geq 0}', + 'Integers': latex_integers, + 'PositiveIntegers': latex_integers + '_{> 0}', + 'NonPositiveIntegers': latex_integers + '_{\\leq 0}', + 'NegativeIntegers': latex_integers + '_{< 0}', + 'NonNegativeIntegers': latex_integers + '_{\\geq 0}', + 'Boolean': '\\left\\{ \\text{True} , \\text{False} \\right \\}', + 'Binary': '\\left\\{ 0 , 1 \\right \\}', + # 'Any': None, + # 'AnyWithNone': None, + 'EmptySet': '\\varnothing', + 'UnitInterval': latex_reals, + 'PercentFraction': latex_reals, + # 'RealInterval' : None , + # 'IntegerInterval' : None , +} + + def decoder(num, base): if int(num) != abs(num): # Requiring an integer is nice, but not strictly necessary; @@ -286,14 +309,15 @@ def handle_functionID_node(visitor, node, *args): def handle_indexTemplate_node(visitor, node, *args): - if node._set in ComponentSet(visitor.setMap.keys()): + if node._set in visitor.setMap: # already detected set, do nothing pass else: - visitor.setMap[node._set] = 'SET%d' % (len(visitor.setMap.keys()) + 1) + visitor.setMap[node._set] = 'SET%d' % (len(visitor.setMap) + 1) - return '__I_PLACEHOLDER_8675309_GROUP_%s_%s__' % ( + return '__I_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' % ( node._group, + node._id, visitor.setMap[node._set], ) @@ -315,8 +339,9 @@ def handle_numericGetItemExpression_node(visitor, node, *args): def handle_templateSumExpression_node(visitor, node, *args): pstr = '' for i in range(0, len(node._iters)): - pstr += '\\sum_{__S_PLACEHOLDER_8675309_GROUP_%s_%s__} ' % ( + pstr += '\\sum_{__S_PLACEHOLDER_8675309_GROUP_%s_%s_%s__} ' % ( node._iters[i][0]._group, + ','.join(str(it._id) for it in node._iters[i]), visitor.setMap[node._iters[i][0]._set], ) @@ -374,12 +399,12 @@ def __init__(self): EqualityExpression: handle_equality_node, InequalityExpression: handle_inequality_node, RangedExpression: handle_ranged_inequality_node, - _GeneralExpressionData: handle_named_expression_node, + ExpressionData: handle_named_expression_node, ScalarExpression: handle_named_expression_node, kernel.expression.expression: handle_named_expression_node, kernel.expression.noclone: handle_named_expression_node, - _GeneralObjectiveData: handle_named_expression_node, - _GeneralVarData: handle_var_node, + ObjectiveData: handle_named_expression_node, + VarData: handle_var_node, ScalarObjective: handle_named_expression_node, kernel.objective.objective: handle_named_expression_node, ExternalFunctionExpression: handle_external_function_node, @@ -392,7 +417,7 @@ def __init__(self): Numeric_GetItemExpression: handle_numericGetItemExpression_node, TemplateSumExpression: handle_templateSumExpression_node, ScalarParam: handle_param_node, - _ParamData: handle_param_node, + ParamData: handle_param_node, IndexedParam: handle_param_node, NPV_Numeric_GetItemExpression: handle_numericGetItemExpression_node, IndexedBlock: handle_indexedBlock_node, @@ -416,28 +441,6 @@ def exitNode(self, node, data): def analyze_variable(vr): - domainMap = { - 'Reals': '\\mathds{R}', - 'PositiveReals': '\\mathds{R}_{> 0}', - 'NonPositiveReals': '\\mathds{R}_{\\leq 0}', - 'NegativeReals': '\\mathds{R}_{< 0}', - 'NonNegativeReals': '\\mathds{R}_{\\geq 0}', - 'Integers': '\\mathds{Z}', - 'PositiveIntegers': '\\mathds{Z}_{> 0}', - 'NonPositiveIntegers': '\\mathds{Z}_{\\leq 0}', - 'NegativeIntegers': '\\mathds{Z}_{< 0}', - 'NonNegativeIntegers': '\\mathds{Z}_{\\geq 0}', - 'Boolean': '\\left\\{ \\text{True} , \\text{False} \\right \\}', - 'Binary': '\\left\\{ 0 , 1 \\right \\}', - # 'Any': None, - # 'AnyWithNone': None, - 'EmptySet': '\\varnothing', - 'UnitInterval': '\\mathds{R}', - 'PercentFraction': '\\mathds{R}', - # 'RealInterval' : None , - # 'IntegerInterval' : None , - } - domainName = vr.domain.name varBounds = vr.bounds lowerBoundValue = varBounds[0] @@ -584,7 +587,7 @@ def latex_printer( Parameters ---------- - pyomo_component: _BlockData or Model or Objective or Constraint or Expression + pyomo_component: BlockData or Model or Objective or Constraint or Expression The Pyomo component to be printed latex_component_map: pyomo.common.collections.component_map.ComponentMap @@ -627,15 +630,15 @@ def latex_printer( # Cody's backdoor because he got outvoted if latex_component_map is not None: - if 'use_short_descriptors' in list(latex_component_map.keys()): + if 'use_short_descriptors' in latex_component_map: if latex_component_map['use_short_descriptors'] == False: use_short_descriptors = False if latex_component_map is None: latex_component_map = ComponentMap() - existing_components = ComponentSet([]) + existing_components = ComponentSet() else: - existing_components = ComponentSet(list(latex_component_map.keys())) + existing_components = ComponentSet(latex_component_map) isSingle = False @@ -671,7 +674,7 @@ def latex_printer( use_equation_environment = True isSingle = True - elif isinstance(pyomo_component, _BlockData): + elif isinstance(pyomo_component, BlockData): objectives = [ obj for obj in pyomo_component.component_data_objects( @@ -702,10 +705,8 @@ def latex_printer( if isSingle: temp_comp, temp_indexes = templatize_fcn(pyomo_component) variableList = [] - for v in identify_components( - temp_comp, [ScalarVar, _GeneralVarData, IndexedVar] - ): - if isinstance(v, _GeneralVarData): + for v in identify_components(temp_comp, [ScalarVar, VarData, IndexedVar]): + if isinstance(v, VarData): v_write = v.parent_component() if v_write not in ComponentSet(variableList): variableList.append(v_write) @@ -714,10 +715,8 @@ def latex_printer( variableList.append(v) parameterList = [] - for p in identify_components( - temp_comp, [ScalarParam, _ParamData, IndexedParam] - ): - if isinstance(p, _ParamData): + for p in identify_components(temp_comp, [ScalarParam, ParamData, IndexedParam]): + if isinstance(p, ParamData): p_write = p.parent_component() if p_write not in ComponentSet(parameterList): parameterList.append(p_write) @@ -782,12 +781,12 @@ def latex_printer( for vr in variableList: vrIdx += 1 if isinstance(vr, ScalarVar): - variableMap[vr] = 'x_' + str(vrIdx) + variableMap[vr] = 'x_' + str(vrIdx) + '_' elif isinstance(vr, IndexedVar): - variableMap[vr] = 'x_' + str(vrIdx) + variableMap[vr] = 'x_' + str(vrIdx) + '_' for sd in vr.index_set().data(): vrIdx += 1 - variableMap[vr[sd]] = 'x_' + str(vrIdx) + variableMap[vr[sd]] = 'x_' + str(vrIdx) + '_' else: raise DeveloperError( 'Variable is not a variable. Should not happen. Contact developers' @@ -799,12 +798,12 @@ def latex_printer( for vr in parameterList: pmIdx += 1 if isinstance(vr, ScalarParam): - parameterMap[vr] = 'p_' + str(pmIdx) + parameterMap[vr] = 'p_' + str(pmIdx) + '_' elif isinstance(vr, IndexedParam): - parameterMap[vr] = 'p_' + str(pmIdx) + parameterMap[vr] = 'p_' + str(pmIdx) + '_' for sd in vr.index_set().data(): pmIdx += 1 - parameterMap[vr[sd]] = 'p_' + str(pmIdx) + parameterMap[vr[sd]] = 'p_' + str(pmIdx) + '_' else: raise DeveloperError( 'Parameter is not a parameter. Should not happen. Contact developers' @@ -915,24 +914,33 @@ def latex_printer( # setMap = visitor.setMap # Multiple constraints are generated using a set if len(indices) > 0: - if indices[0]._set in ComponentSet(visitor.setMap.keys()): - # already detected set, do nothing - pass - else: - visitor.setMap[indices[0]._set] = 'SET%d' % ( - len(visitor.setMap.keys()) + 1 + conLine += ' \\qquad \\forall' + + _bygroups = {} + for idx in indices: + _bygroups.setdefault(idx._group, []).append(idx) + for _group, idxs in _bygroups.items(): + if idxs[0]._set in visitor.setMap: + # already detected set, do nothing + pass + else: + visitor.setMap[idxs[0]._set] = 'SET%d' % ( + len(visitor.setMap) + 1 + ) + + idxTag = ','.join( + '__I_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' + % (idx._group, idx._id, visitor.setMap[idx._set]) + for idx in idxs ) - idxTag = '__I_PLACEHOLDER_8675309_GROUP_%s_%s__' % ( - indices[0]._group, - visitor.setMap[indices[0]._set], - ) - setTag = '__S_PLACEHOLDER_8675309_GROUP_%s_%s__' % ( - indices[0]._group, - visitor.setMap[indices[0]._set], - ) + setTag = '__S_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' % ( + indices[0]._group, + ','.join(str(it._id) for it in idxs), + visitor.setMap[indices[0]._set], + ) - conLine += ' \\qquad \\forall %s \\in %s ' % (idxTag, setTag) + conLine += ' %s \\in %s ' % (idxTag, setTag) pstr += conLine # Add labels as needed @@ -1059,15 +1067,22 @@ def latex_printer( setMap = visitor.setMap setMap_inverse = {vl: ky for ky, vl in setMap.items()} + def generate_set_name(st, lcm): + if st in lcm: + return lcm[st][0] + if st.parent_block().component(st.name) is st: + return st.name.replace('_', r'\_') + if isinstance(st, SetOperator): + return set_operator_map[st._operator.strip()].join( + generate_set_name(s, lcm) for s in st.subsets(False) + ) + else: + return str(st).replace('_', r'\_').replace('{', r'\{').replace('}', r'\}') + # Handling the iterator indices defaultSetLatexNames = ComponentMap() - for ky, vl in setMap.items(): - st = ky - defaultSetLatexNames[st] = st.name.replace('_', '\\_') - if st in ComponentSet(latex_component_map.keys()): - defaultSetLatexNames[st] = latex_component_map[st][ - 0 - ] # .replace('_', '\\_') + for ky in setMap: + defaultSetLatexNames[ky] = generate_set_name(ky, latex_component_map) latexLines = pstr.split('\n') for jj in range(0, len(latexLines)): @@ -1081,8 +1096,8 @@ def latex_printer( for word in splitLatex: if "PLACEHOLDER_8675309_GROUP_" in word: ifo = word.split("PLACEHOLDER_8675309_GROUP_")[1] - gpNum, stName = ifo.split('_') - if gpNum not in groupMap.keys(): + gpNum, idNum, stName = ifo.split('_') + if gpNum not in groupMap: groupMap[gpNum] = [stName] if stName not in ComponentSet(uniqueSets): uniqueSets.append(stName) @@ -1099,10 +1114,7 @@ def latex_printer( ix = int(ky[3:]) - 1 setInfo[ky]['setObject'] = setMap_inverse[ky] # setList[ix] setInfo[ky]['setRegEx'] = ( - r'__S_PLACEHOLDER_8675309_GROUP_([0-9*])_%s__' % (ky) - ) - setInfo[ky]['sumSetRegEx'] = ( - r'sum_{__S_PLACEHOLDER_8675309_GROUP_([0-9*])_%s__}' % (ky) + r'__S_PLACEHOLDER_8675309_GROUP_([0-9]+)_([0-9,]+)_%s__' % (ky,) ) # setInfo[ky]['idxRegEx'] = r'__I_PLACEHOLDER_8675309_GROUP_[0-9*]_%s__'%(ky) @@ -1127,27 +1139,41 @@ def latex_printer( ed = stData[-1] replacement = ( - r'sum_{ __I_PLACEHOLDER_8675309_GROUP_\1_%s__ = %d }^{%d}' + r'sum_{ __I_PLACEHOLDER_8675309_GROUP_\1_\2_%s__ = %d }^{%d}' % (ky, bgn, ed) ) - ln = re.sub(setInfo[ky]['sumSetRegEx'], replacement, ln) + ln = re.sub( + 'sum_{' + setInfo[ky]['setRegEx'] + '}', replacement, ln + ) else: # if the set is not continuous or the flag has not been set - replacement = ( - r'sum_{ __I_PLACEHOLDER_8675309_GROUP_\1_%s__ \\in __S_PLACEHOLDER_8675309_GROUP_\1_%s__ }' - % (ky, ky) - ) - ln = re.sub(setInfo[ky]['sumSetRegEx'], replacement, ln) + for _grp, _id in re.findall( + 'sum_{' + setInfo[ky]['setRegEx'] + '}', ln + ): + set_placeholder = '__S_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' % ( + _grp, + _id, + ky, + ) + i_placeholder = ','.join( + '__I_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' % (_grp, _, ky) + for _ in _id.split(',') + ) + replacement = r'sum_{ %s \in %s }' % ( + i_placeholder, + set_placeholder, + ) + ln = ln.replace('sum_{' + set_placeholder + '}', replacement) replacement = repr(defaultSetLatexNames[setInfo[ky]['setObject']])[1:-1] ln = re.sub(setInfo[ky]['setRegEx'], replacement, ln) # groupNumbers = re.findall(r'__I_PLACEHOLDER_8675309_GROUP_([0-9*])_SET[0-9]*__',ln) setNumbers = re.findall( - r'__I_PLACEHOLDER_8675309_GROUP_[0-9*]_SET([0-9]*)__', ln + r'__I_PLACEHOLDER_8675309_GROUP_[0-9]+_[0-9]+_SET([0-9]+)__', ln ) - groupSetPairs = re.findall( - r'__I_PLACEHOLDER_8675309_GROUP_([0-9*])_SET([0-9]*)__', ln + groupIdSetTuples = re.findall( + r'__I_PLACEHOLDER_8675309_GROUP_([0-9]+)_([0-9]+)_SET([0-9]+)__', ln ) groupInfo = {} @@ -1157,43 +1183,44 @@ def latex_printer( 'indices': [], } - for gp in groupSetPairs: - if gp[0] not in groupInfo['SET' + gp[1]]['indices']: - groupInfo['SET' + gp[1]]['indices'].append(gp[0]) + for _gp, _id, _set in groupIdSetTuples: + if (_gp, _id) not in groupInfo['SET' + _set]['indices']: + groupInfo['SET' + _set]['indices'].append((_gp, _id)) + + def get_index_names(st, lcm): + if st in lcm: + return lcm[st][1] + elif isinstance(st, SetOperator): + return sum( + (get_index_names(s, lcm) for s in st.subsets(False)), start=[] + ) + elif st.dimen is not None: + return [None] * st.dimen + else: + return [Ellipsis] indexCounter = 0 for ky, vl in groupInfo.items(): - if vl['setObject'] in ComponentSet(latex_component_map.keys()): - indexNames = latex_component_map[vl['setObject']][1] - if len(indexNames) != 0: - if len(indexNames) < len(vl['indices']): - raise ValueError( - 'Insufficient number of indices provided to the overwrite dictionary for set %s' - % (vl['setObject'].name) - ) - for i in range(0, len(vl['indices'])): - ln = ln.replace( - '__I_PLACEHOLDER_8675309_GROUP_%s_%s__' - % (vl['indices'][i], ky), - indexNames[i], - ) - else: - for i in range(0, len(vl['indices'])): - ln = ln.replace( - '__I_PLACEHOLDER_8675309_GROUP_%s_%s__' - % (vl['indices'][i], ky), - alphabetStringGenerator(indexCounter), - ) - indexCounter += 1 - else: - for i in range(0, len(vl['indices'])): - ln = ln.replace( - '__I_PLACEHOLDER_8675309_GROUP_%s_%s__' - % (vl['indices'][i], ky), - alphabetStringGenerator(indexCounter), + indexNames = get_index_names(vl['setObject'], latex_component_map) + nonNone = list(filter(None, indexNames)) + if nonNone: + if len(nonNone) < len(vl['indices']): + raise ValueError( + 'Insufficient number of indices provided to the ' + 'overwrite dictionary for set %s (expected %s, but got %s)' + % (vl['setObject'].name, len(vl['indices']), indexNames) ) + else: + indexNames = [] + for i in vl['indices']: + indexNames.append(alphabetStringGenerator(indexCounter)) indexCounter += 1 - + for i in range(0, len(vl['indices'])): + ln = ln.replace( + '__I_PLACEHOLDER_8675309_GROUP_%s_%s_%s__' + % (*vl['indices'][i], ky), + indexNames[i], + ) latexLines[jj] = ln pstr = '\n'.join(latexLines) @@ -1236,25 +1263,25 @@ def latex_printer( ) for ky, vl in new_variableMap.items(): - if ky not in ComponentSet(latex_component_map.keys()): + if ky not in latex_component_map: latex_component_map[ky] = vl for ky, vl in new_parameterMap.items(): - if ky not in ComponentSet(latex_component_map.keys()): + if ky not in latex_component_map: latex_component_map[ky] = vl rep_dict = {} - for ky in ComponentSet(list(reversed(list(latex_component_map.keys())))): - if isinstance(ky, (pyo.Var, _GeneralVarData)): + for ky in reversed(list(latex_component_map)): + if isinstance(ky, (pyo.Var, VarData)): overwrite_value = latex_component_map[ky] if ky not in existing_components: overwrite_value = overwrite_value.replace('_', '\\_') rep_dict[variableMap[ky]] = overwrite_value - elif isinstance(ky, (pyo.Param, _ParamData)): + elif isinstance(ky, (pyo.Param, ParamData)): overwrite_value = latex_component_map[ky] if ky not in existing_components: overwrite_value = overwrite_value.replace('_', '\\_') rep_dict[parameterMap[ky]] = overwrite_value - elif isinstance(ky, _SetData): + elif isinstance(ky, SetData): # already handled pass elif isinstance(ky, (float, int)): diff --git a/pyomo/contrib/latex_printer/tests/__init__.py b/pyomo/contrib/latex_printer/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/latex_printer/tests/__init__.py +++ b/pyomo/contrib/latex_printer/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/latex_printer/tests/test_latex_printer.py b/pyomo/contrib/latex_printer/tests/test_latex_printer.py index 2d7dd69dba8..07dbec33e46 100644 --- a/pyomo/contrib/latex_printer/tests/test_latex_printer.py +++ b/pyomo/contrib/latex_printer/tests/test_latex_printer.py @@ -1,18 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2023 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,13 +10,15 @@ # ___________________________________________________________________________ import io +from textwrap import dedent + import pyomo.common.unittest as unittest -from pyomo.contrib.latex_printer import latex_printer +import pyomo.core.tests.examples.pmedian_concrete as pmedian_concrete import pyomo.environ as pyo -from textwrap import dedent + +from pyomo.contrib.latex_printer import latex_printer from pyomo.common.tempfiles import TempfileManager from pyomo.common.collections.component_map import ComponentMap - from pyomo.environ import ( Reals, PositiveReals, @@ -797,6 +788,50 @@ def ruleMaker_2(m, i): self.assertEqual('\n' + pstr + '\n', bstr) + def test_latexPrinter_pmedian_verbose(self): + m = pmedian_concrete.create_model() + self.assertEqual( + latex_printer(m).strip(), + r""" +\begin{align} + & \min + & & \sum_{ i \in Locations } \sum_{ j \in Customers } cost_{i,j} serve\_customer\_from\_location_{i,j} & \label{obj:M1_obj} \\ + & \text{s.t.} + & & \sum_{ i \in Locations } serve\_customer\_from\_location_{i,j} = 1 & \qquad \forall j \in Customers \label{con:M1_single_x} \\ + &&& serve\_customer\_from\_location_{i,j} \leq select\_location_{i} & \qquad \forall i,j \in Locations \times Customers \label{con:M1_bound_y} \\ + &&& \sum_{ i \in Locations } select\_location_{i} = P & \label{con:M1_num_facilities} \\ + & \text{w.b.} + & & 0.0 \leq serve\_customer\_from\_location \leq 1.0 & \qquad \in \mathds{R} \label{con:M1_serve_customer_from_location_bound} \\ + &&& select\_location & \qquad \in \left\{ 0 , 1 \right \} \label{con:M1_select_location_bound} +\end{align} + """.strip(), + ) + + def test_latexPrinter_pmedian_concise(self): + m = pmedian_concrete.create_model() + lcm = ComponentMap() + lcm[m.Locations] = ['L', ['n']] + lcm[m.Customers] = ['C', ['m']] + lcm[m.cost] = 'd' + lcm[m.serve_customer_from_location] = 'x' + lcm[m.select_location] = 'y' + self.assertEqual( + latex_printer(m, latex_component_map=lcm).strip(), + r""" +\begin{align} + & \min + & & \sum_{ n \in L } \sum_{ m \in C } d_{n,m} x_{n,m} & \label{obj:M1_obj} \\ + & \text{s.t.} + & & \sum_{ n \in L } x_{n,m} = 1 & \qquad \forall m \in C \label{con:M1_single_x} \\ + &&& x_{n,m} \leq y_{n} & \qquad \forall n,m \in L \times C \label{con:M1_bound_y} \\ + &&& \sum_{ n \in L } y_{n} = P & \label{con:M1_num_facilities} \\ + & \text{w.b.} + & & 0.0 \leq x \leq 1.0 & \qquad \in \mathds{R} \label{con:M1_x_bound} \\ + &&& y & \qquad \in \left\{ 0 , 1 \right \} \label{con:M1_y_bound} +\end{align} + """.strip(), + ) + if __name__ == '__main__': unittest.main() diff --git a/pyomo/contrib/latex_printer/tests/test_latex_printer_vartypes.py b/pyomo/contrib/latex_printer/tests/test_latex_printer_vartypes.py index dc3a415618b..8187dc7f136 100644 --- a/pyomo/contrib/latex_printer/tests/test_latex_printer_vartypes.py +++ b/pyomo/contrib/latex_printer/tests/test_latex_printer_vartypes.py @@ -1,18 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2023 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mcpp/__init__.py b/pyomo/contrib/mcpp/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/mcpp/__init__.py +++ b/pyomo/contrib/mcpp/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mcpp/build.py b/pyomo/contrib/mcpp/build.py index 7e119caec9f..b6975f1a4c1 100644 --- a/pyomo/contrib/mcpp/build.py +++ b/pyomo/contrib/mcpp/build.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mcpp/getMCPP.py b/pyomo/contrib/mcpp/getMCPP.py index dbce611d1a0..791653a4c6f 100644 --- a/pyomo/contrib/mcpp/getMCPP.py +++ b/pyomo/contrib/mcpp/getMCPP.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mcpp/mcppInterface.cpp b/pyomo/contrib/mcpp/mcppInterface.cpp index a1e74567896..1fb6d41eda5 100644 --- a/pyomo/contrib/mcpp/mcppInterface.cpp +++ b/pyomo/contrib/mcpp/mcppInterface.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mcpp/plugins.py b/pyomo/contrib/mcpp/plugins.py index 577feec7fe3..ad094a0ef1d 100644 --- a/pyomo/contrib/mcpp/plugins.py +++ b/pyomo/contrib/mcpp/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mcpp/pyomo_mcpp.py b/pyomo/contrib/mcpp/pyomo_mcpp.py index 35e883f98da..5146cb4a0a7 100644 --- a/pyomo/contrib/mcpp/pyomo_mcpp.py +++ b/pyomo/contrib/mcpp/pyomo_mcpp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,7 +20,7 @@ from pyomo.common.fileutils import Library from pyomo.core import value, Expression from pyomo.core.base.block import SubclassOf -from pyomo.core.base.expression import _ExpressionData +from pyomo.core.base.expression import NamedExpressionData from pyomo.core.expr.numvalue import nonpyomo_leaf_types from pyomo.core.expr.numeric_expr import ( AbsExpression, @@ -307,7 +307,9 @@ def exitNode(self, node, data): ans = self.mcpp.newConstant(node) elif not node.is_expression_type(): ans = self.register_num(node) - elif type(node) in SubclassOf(Expression) or isinstance(node, _ExpressionData): + elif type(node) in SubclassOf(Expression) or isinstance( + node, NamedExpressionData + ): ans = data[0] else: raise RuntimeError("Unhandled expression type: %s" % (type(node))) diff --git a/pyomo/contrib/mcpp/tests/__init__.py b/pyomo/contrib/mcpp/tests/__init__.py new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/mcpp/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/mcpp/test_mcpp.py b/pyomo/contrib/mcpp/tests/test_mcpp.py similarity index 99% rename from pyomo/contrib/mcpp/test_mcpp.py rename to pyomo/contrib/mcpp/tests/test_mcpp.py index 1cfb46ce328..7da46e45ef2 100644 --- a/pyomo/contrib/mcpp/test_mcpp.py +++ b/pyomo/contrib/mcpp/tests/test_mcpp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/MindtPy.py b/pyomo/contrib/mindtpy/MindtPy.py index 7b41e0078a3..1a8d807ffaf 100644 --- a/pyomo/contrib/mindtpy/MindtPy.py +++ b/pyomo/contrib/mindtpy/MindtPy.py @@ -3,7 +3,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/__init__.py b/pyomo/contrib/mindtpy/__init__.py index 652493b03a6..6778fd5f5e0 100644 --- a/pyomo/contrib/mindtpy/__init__.py +++ b/pyomo/contrib/mindtpy/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 785a89d8982..c4b0363160c 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -27,13 +27,7 @@ from operator import itemgetter from pyomo.common.errors import DeveloperError from pyomo.solvers.plugins.solvers.gurobi_direct import gurobipy -from pyomo.opt import ( - SolverFactory, - SolverResults, - ProblemSense, - SolutionStatus, - SolverStatus, -) +from pyomo.opt import SolverFactory, SolverResults, SolutionStatus, SolverStatus from pyomo.core import ( minimize, maximize, @@ -152,7 +146,9 @@ def __init__(self, **kwds): # Store the OA cuts generated in the mip_start_process. self.mip_start_lazy_oa_cuts = [] # Whether to load solutions in solve() function - self.load_solutions = True + self.mip_load_solutions = True + self.nlp_load_solutions = True + self.regularization_mip_load_solutions = True # Support use as a context manager under current solver API def __enter__(self): @@ -189,7 +185,7 @@ def _log_solver_intro_message(self): ' Mixed-Integer Nonlinear Decomposition Toolbox in Pyomo (MindtPy) \n' '-----------------------------------------------------------------------------------------------\n' 'For more information, please visit \n' - 'https://pyomo.readthedocs.io/en/stable/contributed_packages/mindtpy.html' + 'https://pyomo.readthedocs.io/en/stable/explanation/solvers/mindtpy.html' ) self.config.logger.info( 'If you use this software, please cite the following:\n' @@ -302,7 +298,7 @@ def model_is_valid(self): results = self.mip_opt.solve( self.original_model, tee=config.mip_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.mip_load_solutions, **config.mip_solver_args, ) if len(results.solution) > 0: @@ -633,9 +629,7 @@ def process_objective(self, update_var_con_list=True): raise ValueError('Model has multiple active objectives.') else: main_obj = active_objectives[0] - self.results.problem.sense = ( - ProblemSense.minimize if main_obj.sense == 1 else ProblemSense.maximize - ) + self.results.problem.sense = main_obj.sense self.objective_sense = main_obj.sense # Move the objective to the constraints if it is nonlinear or move_objective is True. @@ -846,7 +840,7 @@ def init_rNLP(self, add_oa_cuts=True): results = self.nlp_opt.solve( self.rnlp, tee=config.nlp_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.nlp_load_solutions, **nlp_args, ) if len(results.solution) > 0: @@ -868,7 +862,7 @@ def init_rNLP(self, add_oa_cuts=True): results = self.nlp_opt.solve( self.rnlp, tee=config.nlp_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.nlp_load_solutions, **nlp_args, ) if len(results.solution) > 0: @@ -999,7 +993,10 @@ def init_max_binaries(self): mip_args = dict(config.mip_solver_args) update_solver_timelimit(self.mip_opt, config.mip_solver, self.timing, config) results = self.mip_opt.solve( - m, tee=config.mip_solver_tee, load_solutions=self.load_solutions, **mip_args + m, + tee=config.mip_solver_tee, + load_solutions=self.mip_load_solutions, + **mip_args, ) if len(results.solution) > 0: m.solutions.load_from(results) @@ -1119,7 +1116,7 @@ def solve_subproblem(self): results = self.nlp_opt.solve( self.fixed_nlp, tee=config.nlp_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.nlp_load_solutions, **nlp_args, ) if len(results.solution) > 0: @@ -1387,12 +1384,20 @@ def solve_feasibility_subproblem(self): update_solver_timelimit( self.feasibility_nlp_opt, config.nlp_solver, self.timing, config ) - TransformationFactory('contrib.deactivate_trivial_constraints').apply_to( - feas_subproblem, - tmp=True, - ignore_infeasible=False, - tolerance=config.constraint_tolerance, - ) + try: + TransformationFactory('contrib.deactivate_trivial_constraints').apply_to( + self.fixed_nlp, + tmp=True, + ignore_infeasible=False, + tolerance=config.constraint_tolerance, + ) + except InfeasibleConstraintException as e: + config.logger.error( + str(e) + '\nInfeasibility detected in deactivate_trivial_constraints.' + ) + results = SolverResults() + results.solver.termination_condition = tc.infeasible + return self.fixed_nlp, results with SuppressInfeasibleWarning(): try: with time_code(self.timing, 'feasibility subproblem'): @@ -1578,7 +1583,7 @@ def fix_dual_bound(self, last_iter_cuts): main_mip_results = self.mip_opt.solve( self.mip, tee=config.mip_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.mip_load_solutions, **mip_args, ) if len(main_mip_results.solution) > 0: @@ -1666,7 +1671,7 @@ def solve_main(self): main_mip_results = self.mip_opt.solve( self.mip, tee=config.mip_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.mip_load_solutions, **mip_args, ) # update_attributes should be before load_from(main_mip_results), since load_from(main_mip_results) may fail. @@ -1727,7 +1732,7 @@ def solve_fp_main(self): main_mip_results = self.mip_opt.solve( self.mip, tee=config.mip_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.mip_load_solutions, **mip_args, ) # update_attributes should be before load_from(main_mip_results), since load_from(main_mip_results) may fail. @@ -1770,7 +1775,7 @@ def solve_regularization_main(self): main_mip_results = self.regularization_mip_opt.solve( self.mip, tee=config.mip_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.regularization_mip_load_solutions, **dict(config.mip_solver_args), ) if len(main_mip_results.solution) > 0: @@ -1986,7 +1991,7 @@ def handle_main_unbounded(self, main_mip): main_mip_results = self.mip_opt.solve( main_mip, tee=config.mip_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.mip_load_solutions, **config.mip_solver_args, ) if len(main_mip_results.solution) > 0: @@ -2269,6 +2274,11 @@ def check_subsolver_validity(self): raise ValueError(self.config.mip_solver + ' is not available.') if not self.mip_opt.license_is_valid(): raise ValueError(self.config.mip_solver + ' is not licensed.') + if self.config.mip_solver == "appsi_highs": + if self.mip_opt.version() < (1, 7, 0): + raise ValueError( + "MindtPy requires the use of HIGHS version 1.7.0 or higher for full compatibility." + ) if not self.nlp_opt.available(): raise ValueError(self.config.nlp_solver + ' is not available.') if not self.nlp_opt.license_is_valid(): @@ -2316,15 +2326,15 @@ def check_config(self): config.mip_solver = 'cplex_persistent' # related to https://github.com/Pyomo/pyomo/issues/2363 + if 'appsi' in config.mip_solver: + self.mip_load_solutions = False + if 'appsi' in config.nlp_solver: + self.nlp_load_solutions = False if ( - 'appsi' in config.mip_solver - or 'appsi' in config.nlp_solver - or ( - config.mip_regularization_solver is not None - and 'appsi' in config.mip_regularization_solver - ) + config.mip_regularization_solver is not None + and 'appsi' in config.mip_regularization_solver ): - self.load_solutions = False + self.regularization_mip_load_solutions = False ################################################################################################################################ # Feasibility Pump @@ -2392,7 +2402,7 @@ def solve_fp_subproblem(self): results = self.nlp_opt.solve( fp_nlp, tee=config.nlp_solver_tee, - load_solutions=self.load_solutions, + load_solutions=self.nlp_load_solutions, **nlp_args, ) if len(results.solution) > 0: @@ -2957,6 +2967,10 @@ def MindtPy_iteration_loop(self): skip_fixed=False, ) if self.curr_int_sol not in set(self.integer_list): + # Call the NLP pre-solve callback + with time_code(self.timing, 'Call before subproblem solve'): + config.call_before_subproblem_solve(self.fixed_nlp) + fixed_nlp, fixed_nlp_result = self.solve_subproblem() self.handle_nlp_subproblem_tc(fixed_nlp, fixed_nlp_result) @@ -2968,6 +2982,10 @@ def MindtPy_iteration_loop(self): # Solve NLP subproblem # The constraint linearization happens in the handlers if not config.solution_pool: + # Call the NLP pre-solve callback + with time_code(self.timing, 'Call before subproblem solve'): + config.call_before_subproblem_solve(self.fixed_nlp) + fixed_nlp, fixed_nlp_result = self.solve_subproblem() self.handle_nlp_subproblem_tc(fixed_nlp, fixed_nlp_result) @@ -3000,6 +3018,11 @@ def MindtPy_iteration_loop(self): continue else: self.integer_list.append(self.curr_int_sol) + + # Call the NLP pre-solve callback + with time_code(self.timing, 'Call before subproblem solve'): + config.call_before_subproblem_solve(self.fixed_nlp) + fixed_nlp, fixed_nlp_result = self.solve_subproblem() self.handle_nlp_subproblem_tc(fixed_nlp, fixed_nlp_result) diff --git a/pyomo/contrib/mindtpy/config_options.py b/pyomo/contrib/mindtpy/config_options.py index ba2b74cdfe0..0f544d2d0a6 100644 --- a/pyomo/contrib/mindtpy/config_options.py +++ b/pyomo/contrib/mindtpy/config_options.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -323,6 +323,15 @@ def _add_common_configs(CONFIG): doc='Callback hook after a solution of the main problem.', ), ) + CONFIG.declare( + 'call_before_subproblem_solve', + ConfigValue( + default=_DoNothing(), + domain=None, + description='Function to be executed before every subproblem', + doc='Callback hook before a solution of the nonlinear subproblem.', + ), + ) CONFIG.declare( 'call_after_subproblem_solve', ConfigValue( @@ -549,7 +558,7 @@ def _add_subsolver_configs(CONFIG): 'cplex_persistent', 'appsi_cplex', 'appsi_gurobi', - # 'appsi_highs', TODO: feasibility pump now fails with appsi_highs #2951 + 'appsi_highs', ] ), description='MIP subsolver name', @@ -631,7 +640,7 @@ def _add_subsolver_configs(CONFIG): 'cplex_persistent', 'appsi_cplex', 'appsi_gurobi', - # 'appsi_highs', + 'appsi_highs', ] ), description='MIP subsolver for regularization problem', diff --git a/pyomo/contrib/mindtpy/cut_generation.py b/pyomo/contrib/mindtpy/cut_generation.py index e932755e9fd..ce855f8a8e8 100644 --- a/pyomo/contrib/mindtpy/cut_generation.py +++ b/pyomo/contrib/mindtpy/cut_generation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/extended_cutting_plane.py b/pyomo/contrib/mindtpy/extended_cutting_plane.py index 7bb3ff783c9..f21926c24b0 100644 --- a/pyomo/contrib/mindtpy/extended_cutting_plane.py +++ b/pyomo/contrib/mindtpy/extended_cutting_plane.py @@ -3,7 +3,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/feasibility_pump.py b/pyomo/contrib/mindtpy/feasibility_pump.py index 5ee1260dd42..41661c363f6 100644 --- a/pyomo/contrib/mindtpy/feasibility_pump.py +++ b/pyomo/contrib/mindtpy/feasibility_pump.py @@ -3,7 +3,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/global_outer_approximation.py b/pyomo/contrib/mindtpy/global_outer_approximation.py index c43409a8493..e3786d6c683 100644 --- a/pyomo/contrib/mindtpy/global_outer_approximation.py +++ b/pyomo/contrib/mindtpy/global_outer_approximation.py @@ -3,7 +3,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -56,7 +56,7 @@ def check_config(self): if config.mip_solver not in {'cplex_persistent', 'gurobi_persistent'}: raise ValueError( "Only cplex_persistent and gurobi_persistent are supported for LP/NLP based Branch and Bound method." - "Please refer to https://pyomo.readthedocs.io/en/stable/contributed_packages/mindtpy.html#lp-nlp-based-branch-and-bound." + "Please refer to https://pyomo.readthedocs.io/en/stable/explanation/solvers/mindtpy.html#lp-nlp-based-branch-and-bound." ) if config.threads > 1: config.threads = 1 diff --git a/pyomo/contrib/mindtpy/outer_approximation.py b/pyomo/contrib/mindtpy/outer_approximation.py index ead5cadfeac..0f93bed1f02 100644 --- a/pyomo/contrib/mindtpy/outer_approximation.py +++ b/pyomo/contrib/mindtpy/outer_approximation.py @@ -3,7 +3,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -63,7 +63,7 @@ def check_config(self): if config.mip_solver not in {'cplex_persistent', 'gurobi_persistent'}: raise ValueError( "Only cplex_persistent and gurobi_persistent are supported for LP/NLP based Branch and Bound method." - "Please refer to https://pyomo.readthedocs.io/en/stable/contributed_packages/mindtpy.html#lp-nlp-based-branch-and-bound." + "Please refer to https://pyomo.readthedocs.io/en/stable/explanation/solvers/mindtpy.html#lp-nlp-based-branch-and-bound." ) if config.threads > 1: config.threads = 1 diff --git a/pyomo/contrib/mindtpy/plugins.py b/pyomo/contrib/mindtpy/plugins.py index bf0ab0d1581..e047cec577e 100644 --- a/pyomo/contrib/mindtpy/plugins.py +++ b/pyomo/contrib/mindtpy/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/single_tree.py b/pyomo/contrib/mindtpy/single_tree.py index 05ba6bbee86..bbffba757c6 100644 --- a/pyomo/contrib/mindtpy/single_tree.py +++ b/pyomo/contrib/mindtpy/single_tree.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -773,6 +773,9 @@ def __call__(self): mindtpy_solver.integer_list.append(mindtpy_solver.curr_int_sol) # solve subproblem + # Call the NLP pre-solve callback + with time_code(mindtpy_solver.timing, 'Call before subproblem solve'): + config.call_before_subproblem_solve(mindtpy_solver.fixed_nlp) # The constraint linearization happens in the handlers fixed_nlp, fixed_nlp_result = mindtpy_solver.solve_subproblem() # add oa cuts @@ -919,6 +922,9 @@ def LazyOACallback_gurobi(cb_m, cb_opt, cb_where, mindtpy_solver, config): cut_ind = len(mindtpy_solver.mip.MindtPy_utils.cuts.oa_cuts) # solve subproblem + # Call the NLP pre-solve callback + with time_code(mindtpy_solver.timing, 'Call before subproblem solve'): + config.call_before_subproblem_solve(mindtpy_solver.fixed_nlp) # The constraint linearization happens in the handlers fixed_nlp, fixed_nlp_result = mindtpy_solver.solve_subproblem() diff --git a/pyomo/contrib/mindtpy/tabu_list.py b/pyomo/contrib/mindtpy/tabu_list.py index 15c1d3b3a2b..298cfcd2807 100644 --- a/pyomo/contrib/mindtpy/tabu_list.py +++ b/pyomo/contrib/mindtpy/tabu_list.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/MINLP2_simple.py b/pyomo/contrib/mindtpy/tests/MINLP2_simple.py index f3fd51af79a..8da14738125 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP2_simple.py +++ b/pyomo/contrib/mindtpy/tests/MINLP2_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/MINLP3_simple.py b/pyomo/contrib/mindtpy/tests/MINLP3_simple.py index a17659e0c51..6ab4145fd90 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP3_simple.py +++ b/pyomo/contrib/mindtpy/tests/MINLP3_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/MINLP4_simple.py b/pyomo/contrib/mindtpy/tests/MINLP4_simple.py index 44b6c7df543..93953b22b45 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP4_simple.py +++ b/pyomo/contrib/mindtpy/tests/MINLP4_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ # -*- coding: utf-8 -*- -""" Example 1 in Paper 'Using regularization and second order information in outer approximation for convex MINLP' +"""Example 1 in Paper 'Using regularization and second order information in outer approximation for convex MINLP' The expected optimal solution value is -56.981. diff --git a/pyomo/contrib/mindtpy/tests/MINLP5_simple.py b/pyomo/contrib/mindtpy/tests/MINLP5_simple.py index d5b04d0915c..3da6bb43195 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP5_simple.py +++ b/pyomo/contrib/mindtpy/tests/MINLP5_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/MINLP_simple.py b/pyomo/contrib/mindtpy/tests/MINLP_simple.py index cde65536f43..ca61ada6c79 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP_simple.py +++ b/pyomo/contrib/mindtpy/tests/MINLP_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py b/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py index 412067de0b5..8d8c4cf4090 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py +++ b/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/__init__.py b/pyomo/contrib/mindtpy/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/mindtpy/tests/__init__.py +++ b/pyomo/contrib/mindtpy/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py b/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py index c0849094300..f0483264990 100644 --- a/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py +++ b/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ # -*- coding: utf-8 -*- -""" Example of constraint qualification. +"""Example of constraint qualification. The expected optimal solution value is 3. diff --git a/pyomo/contrib/mindtpy/tests/eight_process_problem.py b/pyomo/contrib/mindtpy/tests/eight_process_problem.py index ed9059ae4ae..641d498b01f 100644 --- a/pyomo/contrib/mindtpy/tests/eight_process_problem.py +++ b/pyomo/contrib/mindtpy/tests/eight_process_problem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/feasibility_pump1.py b/pyomo/contrib/mindtpy/tests/feasibility_pump1.py index fec750f9f12..0f602458a95 100644 --- a/pyomo/contrib/mindtpy/tests/feasibility_pump1.py +++ b/pyomo/contrib/mindtpy/tests/feasibility_pump1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/feasibility_pump2.py b/pyomo/contrib/mindtpy/tests/feasibility_pump2.py index d739e4efbbe..b40704d29ee 100644 --- a/pyomo/contrib/mindtpy/tests/feasibility_pump2.py +++ b/pyomo/contrib/mindtpy/tests/feasibility_pump2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/from_proposal.py b/pyomo/contrib/mindtpy/tests/from_proposal.py index f29fbcd2cf7..45897b9ee5f 100644 --- a/pyomo/contrib/mindtpy/tests/from_proposal.py +++ b/pyomo/contrib/mindtpy/tests/from_proposal.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/nonconvex1.py b/pyomo/contrib/mindtpy/tests/nonconvex1.py index 71b7e22af96..d0455c61290 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex1.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/nonconvex2.py b/pyomo/contrib/mindtpy/tests/nonconvex2.py index 94c519ab0e1..404b4101137 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex2.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/nonconvex3.py b/pyomo/contrib/mindtpy/tests/nonconvex3.py index 5b6a1de8d7d..27a2b08f0ca 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex3.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/nonconvex4.py b/pyomo/contrib/mindtpy/tests/nonconvex4.py index 3b7f6660ddf..4998480da76 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex4.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/online_doc_example.py b/pyomo/contrib/mindtpy/tests/online_doc_example.py index 17a758552c0..12f6798f6ef 100644 --- a/pyomo/contrib/mindtpy/tests/online_doc_example.py +++ b/pyomo/contrib/mindtpy/tests/online_doc_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -""" Example in the online doc. +"""Example in the online doc. The expected optimal solution value is 2.438447187191098. diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy.py b/pyomo/contrib/mindtpy/tests/test_mindtpy.py index 37969276d55..dd994c27ccd 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -56,7 +56,12 @@ QCP_model._generate_model() extreme_model_list = [LP_model.model, QCP_model.model] -required_solvers = ('ipopt', 'glpk') +if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory( + 'appsi_highs' +).version() >= (1, 7, 0): + required_solvers = ('ipopt', 'appsi_highs') +else: + required_solvers = ('ipopt', 'glpk') if all(SolverFactory(s).available(exception_flag=False) for s in required_solvers): subsolvers_available = True else: @@ -101,6 +106,30 @@ def test_OA_rNLP(self): ) self.check_optimal_solution(model) + def test_OA_callback(self): + """Test the outer approximation decomposition algorithm.""" + with SolverFactory('mindtpy') as opt: + + def callback(model): + model.Y[1].value = 0 + model.Y[2].value = 0 + model.Y[3].value = 0 + + model = SimpleMINLP2() + # The callback function will make the OA method cycling. + results = opt.solve( + model, + strategy='OA', + init_strategy='rNLP', + mip_solver=required_solvers[1], + nlp_solver=required_solvers[0], + call_before_subproblem_solve=callback, + ) + self.assertIs( + results.solver.termination_condition, TerminationCondition.feasible + ) + self.assertAlmostEqual(value(results.problem.lower_bound), 5, places=1) + def test_OA_extreme_model(self): """Test the outer approximation decomposition algorithm.""" with SolverFactory('mindtpy') as opt: diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py index 24679047793..bbf5e64f27c 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,7 +23,13 @@ from pyomo.environ import SolverFactory, value from pyomo.opt import TerminationCondition -required_solvers = ('ipopt', 'glpk') +if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory( + 'appsi_highs' +).version() >= (1, 7, 0): + required_solvers = ('ipopt', 'appsi_highs') +else: + required_solvers = ('ipopt', 'glpk') + if all(SolverFactory(s).available(exception_flag=False) for s in required_solvers): subsolvers_available = True else: diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py index cbc906851bf..0b48e9e2bde 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -28,8 +28,13 @@ from pyomo.contrib.mindtpy.tests.feasibility_pump1 import FeasPump1 from pyomo.contrib.mindtpy.tests.feasibility_pump2 import FeasPump2 -required_solvers = ('ipopt', 'glpk') -# TODO: 'appsi_highs' will fail here. +if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory( + 'appsi_highs' +).version() >= (1, 7, 0): + required_solvers = ('ipopt', 'appsi_highs') +else: + required_solvers = ('ipopt', 'glpk') + if all(SolverFactory(s).available(exception_flag=False) for s in required_solvers): subsolvers_available = True else: diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py index 07774805364..f16c0e429ba 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py index 792bdb8d993..255706c37d5 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py index d50a41ad000..0f070314add 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,7 +18,14 @@ from pyomo.contrib.mindtpy.tests.MINLP_simple import SimpleMINLP as SimpleMINLP model_list = [SimpleMINLP(grey_box=True)] -required_solvers = ('cyipopt', 'glpk') + +if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory( + 'appsi_highs' +).version() >= (1, 7, 0): + required_solvers = ('cyipopt', 'appsi_highs') +else: + required_solvers = ('cyipopt', 'glpk') + if all(SolverFactory(s).available(exception_flag=False) for s in required_solvers): subsolvers_available = True else: diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_lp_nlp.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_lp_nlp.py index 97f73ece525..afd83b51de3 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_lp_nlp.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_lp_nlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py index 2e864a49578..b80227d3de0 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py index a41f41d4d65..1f378df4e41 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/tests/unit_test.py b/pyomo/contrib/mindtpy/tests/unit_test.py index af6ffad282d..7e03b1623e6 100644 --- a/pyomo/contrib/mindtpy/tests/unit_test.py +++ b/pyomo/contrib/mindtpy/tests/unit_test.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mindtpy/util.py b/pyomo/contrib/mindtpy/util.py index 1543497838f..d0dd23a23c1 100644 --- a/pyomo/contrib/mindtpy/util.py +++ b/pyomo/contrib/mindtpy/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -29,7 +29,6 @@ from pyomo.contrib.mcpp.pyomo_mcpp import mcpp_available, McCormick from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr import pyomo.core.expr as EXPR -from pyomo.opt import ProblemSense from pyomo.contrib.gdpopt.util import get_main_elapsed_time, time_code from pyomo.util.model_size import build_model_size_report from pyomo.common.dependencies import attempt_import @@ -147,7 +146,7 @@ def generate_norm2sq_objective_function(model, setpoint_model, discrete_only=Fal r"""This function generates objective (FP-NLP subproblem) for minimum euclidean distance to setpoint_model. - L2 distance of (x,y) = \sqrt{\sum_i (x_i - y_i)^2}. + L2 distance of :math:`(x,y) = \sqrt{\sum_i (x_i - y_i)^2}`. Parameters ---------- @@ -203,7 +202,7 @@ def generate_norm1_objective_function(model, setpoint_model, discrete_only=False r"""This function generates objective (PF-OA main problem) for minimum Norm1 distance to setpoint_model. - Norm1 distance of (x,y) = \sum_i |x_i - y_i|. + Norm1 distance of :math:`(x,y) = \sum_i |x_i - y_i|`. Parameters ---------- @@ -258,7 +257,7 @@ def generate_norm1_objective_function(model, setpoint_model, discrete_only=False def generate_norm_inf_objective_function(model, setpoint_model, discrete_only=False): r"""This function generates objective (PF-OA main problem) for minimum Norm Infinity distance to setpoint_model. - Norm-Infinity distance of (x,y) = \max_i |x_i - y_i|. + Norm-Infinity distance of :math:`(x,y) = \max_i |x_i - y_i|`. Parameters ---------- @@ -448,7 +447,7 @@ def generate_norm1_norm_constraint(model, setpoint_model, config, discrete_only= Norm constraint is used to guarantees the monotonicity of the norm objective value sequence of all iterations. - Norm1 distance of (x,y) = \sum_i |x_i - y_i|. + Norm1 distance of :math:`(x,y) = \sum_i |x_i - y_i|`. Ref: Paper 'A storm of feasibility pumps for nonconvex MINLP' Eq. (16). Parameters @@ -702,11 +701,7 @@ def copy_var_list_values_from_solution_pool( class GurobiPersistent4MindtPy(GurobiPersistent): - """A new persistent interface to Gurobi. - - Args: - GurobiPersistent (PersistentSolver): A class that provides a persistent interface to Gurobi. - """ + """A new persistent interface to Gurobi.""" def _intermediate_callback(self): def f(gurobi_model, where): diff --git a/pyomo/contrib/mpc/README.md b/pyomo/contrib/mpc/README.md index 7e03163f703..27bfa00cfe6 100644 --- a/pyomo/contrib/mpc/README.md +++ b/pyomo/contrib/mpc/README.md @@ -2,7 +2,7 @@ Pyomo MPC is an extension for developing model predictive control simulations using Pyomo models. Please see the -[documentation](https://pyomo.readthedocs.io/en/stable/contributed_packages/mpc/index.html) +[documentation](https://pyomo.readthedocs.io/en/stable/explanation/analysis/mpc/index.html) for more detailed information. Pyomo MPC helps with, among other things, the following use cases: diff --git a/pyomo/contrib/mpc/__init__.py b/pyomo/contrib/mpc/__init__.py index 2e1c51e154f..ecc0360c12a 100644 --- a/pyomo/contrib/mpc/__init__.py +++ b/pyomo/contrib/mpc/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/__init__.py b/pyomo/contrib/mpc/data/__init__.py index 6051f4ba3a2..e64d457d1be 100644 --- a/pyomo/contrib/mpc/data/__init__.py +++ b/pyomo/contrib/mpc/data/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/convert.py b/pyomo/contrib/mpc/data/convert.py index 10885370032..6b2def7c0a6 100644 --- a/pyomo/contrib/mpc/data/convert.py +++ b/pyomo/contrib/mpc/data/convert.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/dynamic_data_base.py b/pyomo/contrib/mpc/data/dynamic_data_base.py index 5e567f060cf..095442fc7d3 100644 --- a/pyomo/contrib/mpc/data/dynamic_data_base.py +++ b/pyomo/contrib/mpc/data/dynamic_data_base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/find_nearest_index.py b/pyomo/contrib/mpc/data/find_nearest_index.py index c53a7a79841..b8308a95168 100644 --- a/pyomo/contrib/mpc/data/find_nearest_index.py +++ b/pyomo/contrib/mpc/data/find_nearest_index.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/get_cuid.py b/pyomo/contrib/mpc/data/get_cuid.py index ef0df7ea679..89f89d808db 100644 --- a/pyomo/contrib/mpc/data/get_cuid.py +++ b/pyomo/contrib/mpc/data/get_cuid.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -37,11 +37,8 @@ def get_indexed_cuid(var, sets=None, dereference=None, context=None): ComponentUID corresponding to the provided ``var`` and sets """ - # TODO: Does this function have a good name? # Should this function be generalized beyond a single indexing set? - if isinstance(var, ComponentUID): - return var - elif isinstance(var, (str, IndexedComponent_slice)): + if isinstance(var, (str, IndexedComponent_slice, ComponentUID)): # TODO: Raise error if string and context is None return ComponentUID(var, context=context) # At this point we are assuming var is a Pyomo Var or VarData object. diff --git a/pyomo/contrib/mpc/data/interpolation.py b/pyomo/contrib/mpc/data/interpolation.py new file mode 100644 index 00000000000..491013d7ae2 --- /dev/null +++ b/pyomo/contrib/mpc/data/interpolation.py @@ -0,0 +1,79 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from bisect import bisect_right + + +def _get_time_index_vec(time_set, time_data): + """Get the position index of time_data above and below the times in + time_set. This can be used to find positions of points to interpolate + between. + + Parameters + ---------- + time_set: iterable + Time points to locate + time_data: iterable + Sorted time points to locate time_set in + + Returns + ------- + numpy.array + Position index of the first time in time_data greater than the + corresponding points time_set. If a time is less than all the times + in time_data return 1. If a time is greater than all times time_data + set return the last index of time_data. + """ + pos = [None] * len(time_set) + for i, t in enumerate(time_set): + pos[i] = bisect_right(time_data, t) + if pos[i] == 0: + pos[i] = 1 + elif pos[i] == len(time_data): + pos[i] = len(time_data) - 1 + return pos + + +def _get_interp_expr_vec(time_set, time_data, data, indexes=None): + """Return an array of floats interpolated at the time points in time_set + from data defined at time_data. + + Parameters + ---------- + time_set: iterable + Time points to locate + time_data: iterable + Sorted time points to locate time_set in + data: iterable + Data corresponding to times in time_data, must have the same + length as time data. + indexes: numpy.array + Numpy array of position indexes of the time points to interpolate in the + time data. The format is the same as returned by ``_get_time_index_vec()``. + If this is None, ``_get_time_index_vec()`` is called. The reason to pass + this is to avoid multiple position searches when interpolating multiple + outputs with the same time points. + + Returns + ------- + list + If data are Pyomo components, this will return Pyomo expressions. + If data are floats, this will return floats. + """ + if indexes is None: + indexes = _get_time_index_vec(time_set, time_data) + expr = [None] * len(time_set) + for i, (h, t) in enumerate(zip(indexes, time_set)): + l = h - 1 + expr[i] = data[l] + (data[h] - data[l]) / (time_data[h] - time_data[l]) * ( + t - time_data[l] + ) + return expr diff --git a/pyomo/contrib/mpc/data/interval_data.py b/pyomo/contrib/mpc/data/interval_data.py index 54b7ca7e906..a417b2f7441 100644 --- a/pyomo/contrib/mpc/data/interval_data.py +++ b/pyomo/contrib/mpc/data/interval_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/scalar_data.py b/pyomo/contrib/mpc/data/scalar_data.py index b67384c8159..159777b564b 100644 --- a/pyomo/contrib/mpc/data/scalar_data.py +++ b/pyomo/contrib/mpc/data/scalar_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/series_data.py b/pyomo/contrib/mpc/data/series_data.py index c812e76c9fc..a922ebb5716 100644 --- a/pyomo/contrib/mpc/data/series_data.py +++ b/pyomo/contrib/mpc/data/series_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,7 +15,10 @@ from pyomo.contrib.mpc.data.get_cuid import get_indexed_cuid from pyomo.contrib.mpc.data.dynamic_data_base import _is_iterable, _DynamicDataBase from pyomo.contrib.mpc.data.scalar_data import ScalarData - +from pyomo.contrib.mpc.data.interpolation import ( + _get_time_index_vec, + _get_interp_expr_vec, +) TimeSeriesTuple = namedtuple("TimeSeriesTuple", ["data", "time"]) @@ -25,18 +28,21 @@ class TimeSeriesData(_DynamicDataBase): An object to store time series data associated with time-indexed variables. + Parameters + ---------- + data : dict or ComponentMap + Maps variables, names, or CUIDs to lists of values + + time : list + Contains the time points corresponding to variable data points. + + time_set : ContinuousSetData + + context : BlockData """ def __init__(self, data, time, time_set=None, context=None): - """ - Arguments: - ---------- - data: dict or ComponentMap - Maps variables, names, or CUIDs to lists of values - time: list - Contains the time points corresponding to variable data points. - - """ + """ """ _time = list(time) if _time != list(sorted(time)): raise ValueError("Time points are not sorted in increasing order") @@ -119,7 +125,7 @@ def get_data_at_time(self, time=None, tolerance=0.0): Returns ------- - TimeSeriesData or ScalarData + TimeSeriesData or ~scalar_data.ScalarData TimeSeriesData containing only the specified time points or dict mapping CUIDs to values at the specified scalar time point. @@ -149,6 +155,50 @@ def get_data_at_time(self, time=None, tolerance=0.0): indices = indices[0] return self.get_data_at_time_indices(indices) + def get_interpolated_data(self, time=None, tolerance=0.0): + """ + Returns the data associated with the provided time point or points by + linear interpolation. + + Parameters + ---------- + time: Float or iterable + The time point or points corresponding to returned data. + tolerance: float + Tolerance used when checking if time points are inside the data + range. + + Returns + ------- + TimeSeriesData or ~scalar_data.ScalarData + TimeSeriesData containing only the specified time points + or dict mapping CUIDs to values at the specified scalar time + point. + + """ + if time is None: + # If time is not specified, assume we want the entire time + # set. Skip all the overhead, don't create a new object, and + # return self. + return self + is_iterable = _is_iterable(time) + if not is_iterable: + time = [time] + for t in time: + if t > self._time[-1] + tolerance or t < self._time[0] - tolerance: + raise RuntimeError("Requesting interpolation outside data range.") + idxs = _get_time_index_vec(time, self._time) + data = {} + for cuid in self._data: + v = _get_interp_expr_vec(time, self._time, self._data[cuid], idxs) + data[cuid] = v + if is_iterable: + return TimeSeriesData(data, list(time)) + else: + for cuid in self._data: + data[cuid] = data[cuid][0] + return ScalarData(data) + def to_serializable(self): """ Convert to json-serializable object. diff --git a/pyomo/contrib/mpc/data/tests/__init__.py b/pyomo/contrib/mpc/data/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/mpc/data/tests/__init__.py +++ b/pyomo/contrib/mpc/data/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/tests/test_convert.py b/pyomo/contrib/mpc/data/tests/test_convert.py index dda3583cb00..7ad293c3ce4 100644 --- a/pyomo/contrib/mpc/data/tests/test_convert.py +++ b/pyomo/contrib/mpc/data/tests/test_convert.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/tests/test_find_nearest_index.py b/pyomo/contrib/mpc/data/tests/test_find_nearest_index.py index 8fb92e17534..5e09407b9c6 100644 --- a/pyomo/contrib/mpc/data/tests/test_find_nearest_index.py +++ b/pyomo/contrib/mpc/data/tests/test_find_nearest_index.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/tests/test_get_cuid.py b/pyomo/contrib/mpc/data/tests/test_get_cuid.py index 66bfb613bcb..9cc069fa3cc 100644 --- a/pyomo/contrib/mpc/data/tests/test_get_cuid.py +++ b/pyomo/contrib/mpc/data/tests/test_get_cuid.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/tests/test_interval_data.py b/pyomo/contrib/mpc/data/tests/test_interval_data.py index b208c9066f9..0f5dfcb4252 100644 --- a/pyomo/contrib/mpc/data/tests/test_interval_data.py +++ b/pyomo/contrib/mpc/data/tests/test_interval_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/tests/test_scalar_data.py b/pyomo/contrib/mpc/data/tests/test_scalar_data.py index 6522242e267..16960dd525e 100644 --- a/pyomo/contrib/mpc/data/tests/test_scalar_data.py +++ b/pyomo/contrib/mpc/data/tests/test_scalar_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/data/tests/test_series_data.py b/pyomo/contrib/mpc/data/tests/test_series_data.py index 88b672279f2..d5c267d3ef3 100644 --- a/pyomo/contrib/mpc/data/tests/test_series_data.py +++ b/pyomo/contrib/mpc/data/tests/test_series_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -126,6 +126,63 @@ def test_get_data_at_time_with_tolerance(self): with self.assertRaisesRegex(RuntimeError, msg): new_data = data.get_data_at_time(-0.01, tolerance=1e-3) + def test_get_data_interpolate(self): + m = self._make_model() + data_dict = {m.var[:, "A"]: [1, 2, 3], m.var[:, "B"]: [2, 4, 6]} + data = TimeSeriesData(data_dict, m.time) + new_data = data.get_interpolated_data(0.05) + self.assertEqual(ScalarData({m.var[:, "A"]: 1.5, m.var[:, "B"]: 3}), new_data) + + t1 = 0.05 + new_data = data.get_interpolated_data([t1]) + self.assertEqual( + TimeSeriesData({m.var[:, "A"]: [1.5], m.var[:, "B"]: [3]}, [t1]), new_data + ) + + new_t = [0.05, 0.15] + new_data = data.get_interpolated_data(new_t) + self.assertEqual( + TimeSeriesData({m.var[:, "A"]: [1.5, 2.5], m.var[:, "B"]: [3, 5]}, new_t), + new_data, + ) + + def test_get_data_interpolate_range_check(self): + m = self._make_model() + data_dict = {m.var[:, "A"]: [1, 2, 3], m.var[:, "B"]: [2, 4, 6]} + data = TimeSeriesData(data_dict, m.time) + msg = "Requesting interpolation outside data range." + with self.assertRaisesRegex(RuntimeError, msg): + new_data = data.get_interpolated_data(0.2 + 1e-6) + with self.assertRaisesRegex(RuntimeError, msg): + new_data = data.get_interpolated_data(0.0 - 1e-6) + new_data = data.get_interpolated_data(0.2 + 1e-6, tolerance=1e-5) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "A"]), 3, 4) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "B"]), 6, 4) + + t1 = 0.2 + 1e-6 + with self.assertRaisesRegex(RuntimeError, msg): + new_data = data.get_interpolated_data([t1]) + new_data = data.get_interpolated_data([t1], tolerance=1e-5) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "A"])[0], 3, 4) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "B"])[0], 6, 4) + + new_t = [0.0 - 1e-6, 0.2 + 1e-6] + with self.assertRaisesRegex(RuntimeError, msg): + new_data = data.get_interpolated_data(new_t) + new_data = data.get_interpolated_data(new_t, tolerance=1e-5) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "A"])[0], 1, 4) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "B"])[0], 2, 4) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "A"])[1], 3, 4) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "B"])[1], 6, 4) + + # check that the exact endpoints don't raise an exception with 0 tol + new_t = [0.0, 0.2] + new_data = data.get_interpolated_data(new_t, tolerance=0.0) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "A"])[0], 1, 4) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "B"])[0], 2, 4) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "A"])[1], 3, 4) + self.assertAlmostEqual(new_data.get_data_from_key(m.var[:, "B"])[1], 6, 4) + def test_to_serializable(self): m = self._make_model() data_dict = {m.var[:, "A"]: [1, 2, 3], m.var[:, "B"]: [2, 4, 6]} diff --git a/pyomo/contrib/mpc/examples/__init__.py b/pyomo/contrib/mpc/examples/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/mpc/examples/__init__.py +++ b/pyomo/contrib/mpc/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/examples/cstr/__init__.py b/pyomo/contrib/mpc/examples/cstr/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/mpc/examples/cstr/__init__.py +++ b/pyomo/contrib/mpc/examples/cstr/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/examples/cstr/model.py b/pyomo/contrib/mpc/examples/cstr/model.py index 376e77186dd..dc8594e6518 100644 --- a/pyomo/contrib/mpc/examples/cstr/model.py +++ b/pyomo/contrib/mpc/examples/cstr/model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/examples/cstr/run_mpc.py b/pyomo/contrib/mpc/examples/cstr/run_mpc.py index 588ed7d49fe..16115c6103b 100644 --- a/pyomo/contrib/mpc/examples/cstr/run_mpc.py +++ b/pyomo/contrib/mpc/examples/cstr/run_mpc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/examples/cstr/run_openloop.py b/pyomo/contrib/mpc/examples/cstr/run_openloop.py index 66fd0680a01..3557ae26420 100644 --- a/pyomo/contrib/mpc/examples/cstr/run_openloop.py +++ b/pyomo/contrib/mpc/examples/cstr/run_openloop.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/examples/cstr/tests/__init__.py b/pyomo/contrib/mpc/examples/cstr/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/mpc/examples/cstr/tests/__init__.py +++ b/pyomo/contrib/mpc/examples/cstr/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/examples/cstr/tests/test_mpc.py b/pyomo/contrib/mpc/examples/cstr/tests/test_mpc.py index e808b8fc414..86a150dc778 100644 --- a/pyomo/contrib/mpc/examples/cstr/tests/test_mpc.py +++ b/pyomo/contrib/mpc/examples/cstr/tests/test_mpc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/examples/cstr/tests/test_openloop.py b/pyomo/contrib/mpc/examples/cstr/tests/test_openloop.py index c21cb55233e..b2306a95235 100644 --- a/pyomo/contrib/mpc/examples/cstr/tests/test_openloop.py +++ b/pyomo/contrib/mpc/examples/cstr/tests/test_openloop.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/interfaces/__init__.py b/pyomo/contrib/mpc/interfaces/__init__.py index 9b70a983e24..0c4b68aefe7 100644 --- a/pyomo/contrib/mpc/interfaces/__init__.py +++ b/pyomo/contrib/mpc/interfaces/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/interfaces/copy_values.py b/pyomo/contrib/mpc/interfaces/copy_values.py index faf1594f114..b5b484c0489 100644 --- a/pyomo/contrib/mpc/interfaces/copy_values.py +++ b/pyomo/contrib/mpc/interfaces/copy_values.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/interfaces/load_data.py b/pyomo/contrib/mpc/interfaces/load_data.py index b1851c3aa51..964d56c6fcc 100644 --- a/pyomo/contrib/mpc/interfaces/load_data.py +++ b/pyomo/contrib/mpc/interfaces/load_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,7 +25,7 @@ def load_data_from_scalar(data, model, time): Arguments --------- - data: ScalarData + data: ~scalar_data.ScalarData model: BlockData time: Iterable diff --git a/pyomo/contrib/mpc/interfaces/model_interface.py b/pyomo/contrib/mpc/interfaces/model_interface.py index 9a30878c921..a38a31c7b6f 100644 --- a/pyomo/contrib/mpc/interfaces/model_interface.py +++ b/pyomo/contrib/mpc/interfaces/model_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -180,13 +180,14 @@ def load_data( Arguments --------- - data: ScalarData, TimeSeriesData, or mapping - If ScalarData, loads values into indicated variables at - all (or specified) time points. If TimeSeriesData, loads - lists of values into time points. - If mapping, checks whether each variable and value is - indexed or iterable and correspondingly loads data into + data: ~scalar_data.ScalarData, TimeSeriesData, or mapping + If :class:`ScalarData`, loads values into indicated + variables at all (or specified) time points. If + :class:`TimeSeriesData`, loads lists of values into time + points. If mapping, checks whether each variable and value + is indexed or iterable and correspondingly loads data into variables. + time_points: Iterable (optional) Subset of time points into which data should be loaded. Default of None corresponds to loading into all time points. @@ -299,7 +300,7 @@ def get_penalty_from_target( Parameters ---------- - target_data: ScalarData, TimeSeriesData, or IntervalData + target_data: ~scalar_data.ScalarData, TimeSeriesData, or IntervalData Holds target values for variables time: Set (optional) Points at which to apply the tracking cost. Default will use @@ -307,7 +308,7 @@ def get_penalty_from_target( variables: List of Pyomo VarData (optional) Subset of variables supplied in setpoint_data to use in the tracking cost. Default is to use all variables supplied. - weight_data: ScalarData (optional) + weight_data: ~scalar_data.ScalarData (optional) Holds the weights to use in the tracking cost for each variable variable_set: Set (optional) A set indexing the list of provided variables, if one already diff --git a/pyomo/contrib/mpc/interfaces/tests/__init__.py b/pyomo/contrib/mpc/interfaces/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/mpc/interfaces/tests/__init__.py +++ b/pyomo/contrib/mpc/interfaces/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/interfaces/tests/test_interface.py b/pyomo/contrib/mpc/interfaces/tests/test_interface.py index e67e58bf900..0f7bb17491c 100644 --- a/pyomo/contrib/mpc/interfaces/tests/test_interface.py +++ b/pyomo/contrib/mpc/interfaces/tests/test_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/interfaces/tests/test_var_linker.py b/pyomo/contrib/mpc/interfaces/tests/test_var_linker.py index e169af686f3..10dc3b02d9b 100644 --- a/pyomo/contrib/mpc/interfaces/tests/test_var_linker.py +++ b/pyomo/contrib/mpc/interfaces/tests/test_var_linker.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/interfaces/var_linker.py b/pyomo/contrib/mpc/interfaces/var_linker.py index 87831379204..1f3731163fd 100644 --- a/pyomo/contrib/mpc/interfaces/var_linker.py +++ b/pyomo/contrib/mpc/interfaces/var_linker.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/modeling/__init__.py b/pyomo/contrib/mpc/modeling/__init__.py index a174bafc944..f7233c3b750 100644 --- a/pyomo/contrib/mpc/modeling/__init__.py +++ b/pyomo/contrib/mpc/modeling/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/modeling/constraints.py b/pyomo/contrib/mpc/modeling/constraints.py index e6a1edf648b..ebef12063d8 100644 --- a/pyomo/contrib/mpc/modeling/constraints.py +++ b/pyomo/contrib/mpc/modeling/constraints.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/modeling/cost_expressions.py b/pyomo/contrib/mpc/modeling/cost_expressions.py index aeb26705a38..c0470a8315f 100644 --- a/pyomo/contrib/mpc/modeling/cost_expressions.py +++ b/pyomo/contrib/mpc/modeling/cost_expressions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -48,9 +48,9 @@ def get_penalty_from_constant_target( time: iterable Set of variable indices for which a cost expression will be created - setpoint_data: ScalarData, dict, or ComponentMap + setpoint_data: ~scalar_data.ScalarData, dict, or ComponentMap Maps variable names to setpoint values - weight_data: ScalarData, dict, or ComponentMap + weight_data: ~scalar_data.ScalarData, dict, or ComponentMap Optional. Maps variable names to tracking cost weights. If not provided, weights of one are used. variable_set: Set @@ -123,7 +123,7 @@ def get_penalty_from_piecewise_constant_target( setpoint_data: IntervalData Holds the piecewise constant values that will be used as setpoints - weight_data: ScalarData (optional) + weight_data: ~scalar_data.ScalarData (optional) Weights for variables. Default is all ones. tolerance: Float (optional) Tolerance used for determining whether a time point @@ -220,7 +220,7 @@ def get_penalty_from_time_varying_target( Index used for the cost expression setpoint_data: TimeSeriesData Holds the trajectory values that will be used as a setpoint - weight_data: ScalarData (optional) + weight_data: ~scalar_data.ScalarData (optional) Weights for variables. Default is all ones. variable_set: Set (optional) Set indexing the list of provided variables, if one exists already. @@ -262,11 +262,13 @@ def get_penalty_from_target( """A function to get a penalty expression for specified variables from a target that is constant, piecewise constant, or time-varying. - This function accepts ScalarData, IntervalData, or TimeSeriesData objects, - or compatible mappings/tuples as the target, and builds the appropriate - penalty expression for each. Mappings are converted to ScalarData, and - tuples (of data dict, time list) are unpacked and converted to IntervalData - or TimeSeriesData depending on the contents of the time list. + This function accepts :class:`~.scalar_data.ScalarData`, + :class:`.IntervalData`, or :class:`.TimeSeriesData` objects, or + compatible mappings/tuples as the target, and builds the appropriate + penalty expression for each. Mappings are converted to ScalarData, + and tuples (of data dict, time list) are unpacked and converted to + IntervalData or TimeSeriesData depending on the contents of the time + list. Arguments --------- @@ -275,10 +277,10 @@ def get_penalty_from_target( time: Set Set of time points at which to construct penalty expressions. Also indexes the returned Expression. - setpoint_data: ScalarData, TimeSeriesData, or IntervalData + setpoint_data: ~scalar_data.ScalarData, TimeSeriesData, or IntervalData Data structure representing the possibly time-varying or piecewise constant setpoint - weight_data: ScalarData (optional) + weight_data: ~scalar_data.ScalarData (optional) Data structure holding the weights to be applied to each variable variable_set: Set (optional) Set indexing the provided variables, if one already exists. Also diff --git a/pyomo/contrib/mpc/modeling/terminal.py b/pyomo/contrib/mpc/modeling/terminal.py index d2118c7d92e..c53816d8b04 100644 --- a/pyomo/contrib/mpc/modeling/terminal.py +++ b/pyomo/contrib/mpc/modeling/terminal.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -87,10 +87,10 @@ def get_penalty_at_time( List of time-indexed variables that will be penalized t: Float Time point at which to apply the penalty - target_data: ScalarData + target_data: ~scalar_data.ScalarData ScalarData object containing the target for (at least) the variables to be penalized - weight_data: ScalarData (optional) + weight_data: ~scalar_data.ScalarData (optional) ScalarData object containing the penalty weights for (at least) the variables to be penalized time_set: Set (optional) @@ -135,10 +135,10 @@ def get_terminal_penalty( time_set: Set Time set that indexes the provided variables. Penalties are applied at the last point in this set. - target_data: ScalarData + target_data: ~scalar_data.ScalarData ScalarData object containing the target for (at least) the variables to be penalized - weight_data: ScalarData (optional) + weight_data: ~scalar_data.ScalarData (optional) ScalarData object containing the penalty weights for (at least) the variables to be penalized variable_set: Set (optional) diff --git a/pyomo/contrib/mpc/modeling/tests/__init__.py b/pyomo/contrib/mpc/modeling/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/mpc/modeling/tests/__init__.py +++ b/pyomo/contrib/mpc/modeling/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/modeling/tests/test_cost_expressions.py b/pyomo/contrib/mpc/modeling/tests/test_cost_expressions.py index 67c474f7722..e6b53b0d9bc 100644 --- a/pyomo/contrib/mpc/modeling/tests/test_cost_expressions.py +++ b/pyomo/contrib/mpc/modeling/tests/test_cost_expressions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/modeling/tests/test_input_constraints.py b/pyomo/contrib/mpc/modeling/tests/test_input_constraints.py index be9edad37b9..c62895c49fa 100644 --- a/pyomo/contrib/mpc/modeling/tests/test_input_constraints.py +++ b/pyomo/contrib/mpc/modeling/tests/test_input_constraints.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/mpc/modeling/tests/test_terminal.py b/pyomo/contrib/mpc/modeling/tests/test_terminal.py index ef89fe24b57..e6b626e5bd9 100644 --- a/pyomo/contrib/mpc/modeling/tests/test_terminal.py +++ b/pyomo/contrib/mpc/modeling/tests/test_terminal.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/multistart/__init__.py b/pyomo/contrib/multistart/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/multistart/__init__.py +++ b/pyomo/contrib/multistart/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/multistart/high_conf_stop.py b/pyomo/contrib/multistart/high_conf_stop.py index ce24d2dc1fc..8a2f3ab36cc 100644 --- a/pyomo/contrib/multistart/high_conf_stop.py +++ b/pyomo/contrib/multistart/high_conf_stop.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/multistart/multi.py b/pyomo/contrib/multistart/multi.py index 377ac8182e2..8cdc43143f0 100644 --- a/pyomo/contrib/multistart/multi.py +++ b/pyomo/contrib/multistart/multi.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/multistart/plugins.py b/pyomo/contrib/multistart/plugins.py index f094e2f58cc..4fc99756298 100644 --- a/pyomo/contrib/multistart/plugins.py +++ b/pyomo/contrib/multistart/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/multistart/reinit.py b/pyomo/contrib/multistart/reinit.py index 2b097bbc898..537ea1b32d6 100644 --- a/pyomo/contrib/multistart/reinit.py +++ b/pyomo/contrib/multistart/reinit.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/multistart/tests/__init__.py b/pyomo/contrib/multistart/tests/__init__.py new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/multistart/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/multistart/test_multi.py b/pyomo/contrib/multistart/tests/test_multi.py similarity index 99% rename from pyomo/contrib/multistart/test_multi.py rename to pyomo/contrib/multistart/tests/test_multi.py index f8103eed3b8..ae15e517d5f 100644 --- a/pyomo/contrib/multistart/test_multi.py +++ b/pyomo/contrib/multistart/tests/test_multi.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/__init__.py b/pyomo/contrib/parmest/__init__.py index e7d513dd95c..1ae2b07445a 100644 --- a/pyomo/contrib/parmest/__init__.py +++ b/pyomo/contrib/parmest/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,8 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.deprecation import relocated_module_attribute +# +# declare deprecation paths for removed modules +# +from pyomo.common.deprecation import relocated_module_attribute, moved_module +moved_module( + 'pyomo.contrib.parmest.ipopt_solver_wrapper', + 'pyomo.contrib.parmest.utils.ipopt_solver_wrapper', + version='6.4.2', +) relocated_module_attribute( 'create_ef', 'pyomo.contrib.parmest.utils.create_ef', version='6.4.2' ) @@ -25,3 +33,4 @@ relocated_module_attribute( 'scenario_tree', 'pyomo.contrib.parmest.utils.scenario_tree', version='6.4.2' ) +del relocated_module_attribute, moved_module diff --git a/pyomo/contrib/parmest/examples/__init__.py b/pyomo/contrib/parmest/examples/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/parmest/examples/__init__.py +++ b/pyomo/contrib/parmest/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/examples/reaction_kinetics/__init__.py b/pyomo/contrib/parmest/examples/reaction_kinetics/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/parmest/examples/reaction_kinetics/__init__.py +++ b/pyomo/contrib/parmest/examples/reaction_kinetics/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/examples/reaction_kinetics/simple_reaction_parmest_example.py b/pyomo/contrib/parmest/examples/reaction_kinetics/simple_reaction_parmest_example.py index 7dfe0829262..e71ebf564c0 100644 --- a/pyomo/contrib/parmest/examples/reaction_kinetics/simple_reaction_parmest_example.py +++ b/pyomo/contrib/parmest/examples/reaction_kinetics/simple_reaction_parmest_example.py @@ -1,14 +1,14 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -''' +''' Example from Y. Bard, "Nonlinear Parameter Estimation", (pg. 124) This example shows: @@ -18,6 +18,7 @@ Code provided by Paul Akula. ''' +import pyomo.environ as pyo from pyomo.environ import ( ConcreteModel, Param, @@ -32,6 +33,7 @@ value, ) import pyomo.contrib.parmest.parmest as parmest +from pyomo.contrib.parmest.experiment import Experiment def simple_reaction_model(data): @@ -72,7 +74,62 @@ def total_cost_rule(m): return model +# For this experiment class, data is dictionary +class SimpleReactionExperiment(Experiment): + + def __init__(self, data): + self.data = data + self.model = None + + def create_model(self): + self.model = simple_reaction_model(self.data) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.x1, self.data['x1']), (m.x2, self.data['x2']), (m.y, self.data['y'])] + ) + + return m + + def get_labeled_model(self): + self.create_model() + m = self.label_model() + + return m + + +# k[2] fixed +class SimpleReactionExperimentK2Fixed(SimpleReactionExperiment): + + def label_model(self): + + m = super().label_model() + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.k[1]]) + + return m + + +# k[2] variable +class SimpleReactionExperimentK2Variable(SimpleReactionExperiment): + + def label_model(self): + + m = super().label_model() + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.k[1], m.k[2]]) + + return m + + def main(): + # Data from Table 5.2 in Y. Bard, "Nonlinear Parameter Estimation", (pg. 124) data = [ {'experiment': 1, 'x1': 0.1, 'x2': 100, 'y': 0.98}, @@ -92,21 +149,34 @@ def main(): {'experiment': 15, 'x1': 0.1, 'x2': 300, 'y': 0.006}, ] + # Create an experiment list with k[2] fixed + exp_list = [] + for i in range(len(data)): + exp_list.append(SimpleReactionExperimentK2Fixed(data[i])) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + # ======================================================================= # Parameter estimation without covariance estimate # Only estimate the parameter k[1]. The parameter k[2] will remain fixed # at its initial value - theta_names = ['k[1]'] - pest = parmest.Estimator(simple_reaction_model, data, theta_names) + + pest = parmest.Estimator(exp_list) obj, theta = pest.theta_est() print(obj) print(theta) print() + # Create an experiment list with k[2] variable + exp_list = [] + for i in range(len(data)): + exp_list.append(SimpleReactionExperimentK2Variable(data[i])) + # ======================================================================= # Estimate both k1 and k2 and compute the covariance matrix - theta_names = ['k'] - pest = parmest.Estimator(simple_reaction_model, data, theta_names) + pest = parmest.Estimator(exp_list) n = 15 # total number of data points used in the objective (y in 15 scenarios) obj, theta, cov = pest.theta_est(calc_cov=True, cov_n=n) print(obj) diff --git a/pyomo/contrib/parmest/examples/reactor_design/__init__.py b/pyomo/contrib/parmest/examples/reactor_design/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/__init__.py +++ b/pyomo/contrib/parmest/examples/reactor_design/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/examples/reactor_design/bootstrap_example.py b/pyomo/contrib/parmest/examples/reactor_design/bootstrap_example.py index 1a4dc75e083..7c0514a18c4 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/bootstrap_example.py +++ b/pyomo/contrib/parmest/examples/reactor_design/bootstrap_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,31 +13,27 @@ from os.path import join, abspath, dirname import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, + ReactorDesignExperiment, ) def main(): - # Vars to estimate - theta_names = ["k1", "k2", "k3"] - # Data + # Read in data file_dirname = dirname(abspath(str(__file__))) file_name = abspath(join(file_dirname, "reactor_data.csv")) data = pd.read_csv(file_name) - # Sum of squared error function - def SSE(model, data): - expr = ( - (float(data.iloc[0]["ca"]) - model.ca) ** 2 - + (float(data.iloc[0]["cb"]) - model.cb) ** 2 - + (float(data.iloc[0]["cc"]) - model.cc) ** 2 - + (float(data.iloc[0]["cd"]) - model.cd) ** 2 - ) - return expr - - # Create an instance of the parmest estimator - pest = parmest.Estimator(reactor_design_model, data, theta_names, SSE) + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + + pest = parmest.Estimator(exp_list, obj_function='SSE') # Parameter estimation obj, theta = pest.theta_est() diff --git a/pyomo/contrib/parmest/examples/reactor_design/confidence_region_example.py b/pyomo/contrib/parmest/examples/reactor_design/confidence_region_example.py new file mode 100644 index 00000000000..c9105c8b6fb --- /dev/null +++ b/pyomo/contrib/parmest/examples/reactor_design/confidence_region_example.py @@ -0,0 +1,51 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pandas as pd +from os.path import join, abspath, dirname +import pyomo.contrib.parmest.parmest as parmest +from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( + ReactorDesignExperiment, +) + + +def main(): + + # Read in data + file_dirname = dirname(abspath(str(__file__))) + file_name = abspath(join(file_dirname, "reactor_data.csv")) + data = pd.read_csv(file_name) + + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + + pest = parmest.Estimator(exp_list, obj_function='SSE') + + # Parameter estimation + obj, theta = pest.theta_est() + + # Bootstrapping + bootstrap_theta = pest.theta_est_bootstrap(10) + print(bootstrap_theta) + + # Confidence region test + CR = pest.confidence_region_test(bootstrap_theta, "MVN", [0.5, 0.75, 1.0]) + print(CR) + + +if __name__ == "__main__": + main() diff --git a/pyomo/contrib/parmest/examples/reactor_design/datarec_example.py b/pyomo/contrib/parmest/examples/reactor_design/datarec_example.py index e995502d4ae..7390ef4d16c 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/datarec_example.py +++ b/pyomo/contrib/parmest/examples/reactor_design/datarec_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,24 +9,90 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import pyomo.environ as pyo from pyomo.common.dependencies import numpy as np, pandas as pd import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( reactor_design_model, + ReactorDesignExperiment, ) np.random.seed(1234) -def reactor_design_model_for_datarec(data): - # Unfix inlet concentration for data rec - model = reactor_design_model(data) - model.caf.fixed = False +class ReactorDesignExperimentDataRec(ReactorDesignExperiment): - return model + def __init__(self, data, data_std, experiment_number): + + super().__init__(data, experiment_number) + self.data_std = data_std + + def create_model(self): + + self.model = m = reactor_design_model() + m.caf.fixed = False + + return m + + def label_model(self): + + m = self.model + + # experiment outputs + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [ + (m.ca, self.data_i['ca']), + (m.cb, self.data_i['cb']), + (m.cc, self.data_i['cc']), + (m.cd, self.data_i['cd']), + ] + ) + + # experiment standard deviations + m.experiment_outputs_std = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs_std.update( + [ + (m.ca, self.data_std['ca']), + (m.cb, self.data_std['cb']), + (m.cc, self.data_std['cc']), + (m.cd, self.data_std['cd']), + ] + ) + + # no unknowns (theta names) + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + + return m + + +class ReactorDesignExperimentPostDataRec(ReactorDesignExperiment): + + def __init__(self, data, data_std, experiment_number): + + super().__init__(data, experiment_number) + self.data_std = data_std + + def label_model(self): + + m = super().label_model() + + # add experiment standard deviations + m.experiment_outputs_std = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs_std.update( + [ + (m.ca, self.data_std['ca']), + (m.cb, self.data_std['cb']), + (m.cc, self.data_std['cc']), + (m.cd, self.data_std['cd']), + ] + ) + + return m def generate_data(): + ### Generate data based on real sv, caf, ca, cb, cc, and cd sv_real = 1.05 caf_real = 10000 @@ -53,24 +119,26 @@ def generate_data(): def main(): + # Generate data data = generate_data() data_std = data.std() + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperimentDataRec(data, data_std, i)) + # Define sum of squared error objective function for data rec - def SSE(model, data): - expr = ( - ((float(data.iloc[0]["ca"]) - model.ca) / float(data_std["ca"])) ** 2 - + ((float(data.iloc[0]["cb"]) - model.cb) / float(data_std["cb"])) ** 2 - + ((float(data.iloc[0]["cc"]) - model.cc) / float(data_std["cc"])) ** 2 - + ((float(data.iloc[0]["cd"]) - model.cd) / float(data_std["cd"])) ** 2 + def SSE_with_std(model): + expr = sum( + ((y - y_hat) / model.experiment_outputs_std[y]) ** 2 + for y, y_hat in model.experiment_outputs.items() ) return expr ### Data reconciliation - theta_names = [] # no variables to estimate, use initialized values - - pest = parmest.Estimator(reactor_design_model_for_datarec, data, theta_names, SSE) + pest = parmest.Estimator(exp_list, obj_function=SSE_with_std) obj, theta, data_rec = pest.theta_est(return_values=["ca", "cb", "cc", "cd", "caf"]) print(obj) @@ -83,10 +151,14 @@ def SSE(model, data): ) ### Parameter estimation using reconciled data - theta_names = ["k1", "k2", "k3"] data_rec["sv"] = data["sv"] - pest = parmest.Estimator(reactor_design_model, data_rec, theta_names, SSE) + # make a new list of experiments using reconciled data + exp_list = [] + for i in range(data_rec.shape[0]): + exp_list.append(ReactorDesignExperimentPostDataRec(data_rec, data_std, i)) + + pest = parmest.Estimator(exp_list, obj_function=SSE_with_std) obj, theta = pest.theta_est() print(obj) print(theta) diff --git a/pyomo/contrib/parmest/examples/reactor_design/leaveNout_example.py b/pyomo/contrib/parmest/examples/reactor_design/leaveNout_example.py index 8cac82e3879..275b85e6e98 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/leaveNout_example.py +++ b/pyomo/contrib/parmest/examples/reactor_design/leaveNout_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,15 +13,13 @@ from os.path import join, abspath, dirname import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, + ReactorDesignExperiment, ) def main(): - # Vars to estimate - theta_names = ["k1", "k2", "k3"] - # Data + # Read in data file_dirname = dirname(abspath(str(__file__))) file_name = abspath(join(file_dirname, "reactor_data.csv")) data = pd.read_csv(file_name) @@ -33,18 +31,16 @@ def main(): df_sample = data.sample(N, replace=True).reset_index(drop=True) data = df_sample + df_rand.dot(df_std) / 10 - # Sum of squared error function - def SSE(model, data): - expr = ( - (float(data.iloc[0]["ca"]) - model.ca) ** 2 - + (float(data.iloc[0]["cb"]) - model.cb) ** 2 - + (float(data.iloc[0]["cc"]) - model.cc) ** 2 - + (float(data.iloc[0]["cd"]) - model.cd) ** 2 - ) - return expr + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() - # Create an instance of the parmest estimator - pest = parmest.Estimator(reactor_design_model, data, theta_names, SSE) + pest = parmest.Estimator(exp_list, obj_function='SSE') # Parameter estimation obj, theta = pest.theta_est() diff --git a/pyomo/contrib/parmest/examples/reactor_design/likelihood_ratio_example.py b/pyomo/contrib/parmest/examples/reactor_design/likelihood_ratio_example.py index 0d5665123b4..0c62d94438e 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/likelihood_ratio_example.py +++ b/pyomo/contrib/parmest/examples/reactor_design/likelihood_ratio_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,31 +14,27 @@ from os.path import join, abspath, dirname import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, + ReactorDesignExperiment, ) def main(): - # Vars to estimate - theta_names = ["k1", "k2", "k3"] - # Data + # Read in data file_dirname = dirname(abspath(str(__file__))) file_name = abspath(join(file_dirname, "reactor_data.csv")) data = pd.read_csv(file_name) - # Sum of squared error function - def SSE(model, data): - expr = ( - (float(data.iloc[0]["ca"]) - model.ca) ** 2 - + (float(data.iloc[0]["cb"]) - model.cb) ** 2 - + (float(data.iloc[0]["cc"]) - model.cc) ** 2 - + (float(data.iloc[0]["cd"]) - model.cd) ** 2 - ) - return expr - - # Create an instance of the parmest estimator - pest = parmest.Estimator(reactor_design_model, data, theta_names, SSE) + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + + pest = parmest.Estimator(exp_list, obj_function='SSE') # Parameter estimation obj, theta = pest.theta_est() diff --git a/pyomo/contrib/parmest/examples/reactor_design/multisensor_data_example.py b/pyomo/contrib/parmest/examples/reactor_design/multisensor_data_example.py index cf77f46f08a..0b43c881303 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/multisensor_data_example.py +++ b/pyomo/contrib/parmest/examples/reactor_design/multisensor_data_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,37 +11,81 @@ from pyomo.common.dependencies import pandas as pd from os.path import join, abspath, dirname +import pyomo.environ as pyo import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, + ReactorDesignExperiment, ) +class MultisensorReactorDesignExperiment(ReactorDesignExperiment): + + def finalize_model(self): + + m = self.model + + # Experiment inputs values + m.sv = self.data_i['sv'] + m.caf = self.data_i['caf'] + + # Experiment output values + m.ca = (self.data_i['ca1'] + self.data_i['ca2'] + self.data_i['ca3']) * (1 / 3) + m.cb = self.data_i['cb'] + m.cc = (self.data_i['cc1'] + self.data_i['cc2']) * (1 / 2) + m.cd = self.data_i['cd'] + + return m + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [ + (m.ca, [self.data_i['ca1'], self.data_i['ca2'], self.data_i['ca3']]), + (m.cb, [self.data_i['cb']]), + (m.cc, [self.data_i['cc1'], self.data_i['cc2']]), + (m.cd, [self.data_i['cd']]), + ] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.ComponentUID(k)) for k in [m.k1, m.k2, m.k3] + ) + + return m + + def main(): # Parameter estimation using multisensor data - # Vars to estimate - theta_names = ["k1", "k2", "k3"] - - # Data, includes multiple sensors for ca and cc + # Read in data file_dirname = dirname(abspath(str(__file__))) file_name = abspath(join(file_dirname, "reactor_data_multisensor.csv")) data = pd.read_csv(file_name) - # Sum of squared error function - def SSE_multisensor(model, data): - expr = ( - ((float(data.iloc[0]["ca1"]) - model.ca) ** 2) * (1 / 3) - + ((float(data.iloc[0]["ca2"]) - model.ca) ** 2) * (1 / 3) - + ((float(data.iloc[0]["ca3"]) - model.ca) ** 2) * (1 / 3) - + (float(data.iloc[0]["cb"]) - model.cb) ** 2 - + ((float(data.iloc[0]["cc1"]) - model.cc) ** 2) * (1 / 2) - + ((float(data.iloc[0]["cc2"]) - model.cc) ** 2) * (1 / 2) - + (float(data.iloc[0]["cd"]) - model.cd) ** 2 - ) + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(MultisensorReactorDesignExperiment(data, i)) + + # Define sum of squared error + def SSE_multisensor(model): + expr = 0 + for y, y_hat in model.experiment_outputs.items(): + num_outputs = len(y_hat) + for i in range(num_outputs): + expr += ((y - y_hat[i]) ** 2) * (1 / num_outputs) return expr - pest = parmest.Estimator(reactor_design_model, data, theta_names, SSE_multisensor) + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + # print(SSE_multisensor(exp0_model)) + + pest = parmest.Estimator(exp_list, obj_function=SSE_multisensor) obj, theta = pest.theta_est() print(obj) print(theta) diff --git a/pyomo/contrib/parmest/examples/reactor_design/parameter_estimation_example.py b/pyomo/contrib/parmest/examples/reactor_design/parameter_estimation_example.py index c53d9ef36dc..b33650cca8f 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/parameter_estimation_example.py +++ b/pyomo/contrib/parmest/examples/reactor_design/parameter_estimation_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,45 +13,32 @@ from os.path import join, abspath, dirname import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, + ReactorDesignExperiment, ) def main(): - # Vars to estimate - theta_names = ["k1", "k2", "k3"] - # Data + # Read in data file_dirname = dirname(abspath(str(__file__))) file_name = abspath(join(file_dirname, "reactor_data.csv")) data = pd.read_csv(file_name) - # Sum of squared error function - def SSE(model, data): - expr = ( - (float(data.iloc[0]["ca"]) - model.ca) ** 2 - + (float(data.iloc[0]["cb"]) - model.cb) ** 2 - + (float(data.iloc[0]["cc"]) - model.cc) ** 2 - + (float(data.iloc[0]["cd"]) - model.cd) ** 2 - ) - return expr - - # Create an instance of the parmest estimator - pest = parmest.Estimator(reactor_design_model, data, theta_names, SSE) - - # Parameter estimation - obj, theta = pest.theta_est() - - # Assert statements compare parameter estimation (theta) to an expected value - k1_expected = 5.0 / 6.0 - k2_expected = 5.0 / 3.0 - k3_expected = 1.0 / 6000.0 - relative_error = abs(theta["k1"] - k1_expected) / k1_expected - assert relative_error < 0.05 - relative_error = abs(theta["k2"] - k2_expected) / k2_expected - assert relative_error < 0.05 - relative_error = abs(theta["k3"] - k3_expected) / k3_expected - assert relative_error < 0.05 + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + + pest = parmest.Estimator(exp_list, obj_function='SSE') + + # Parameter estimation with covariance + obj, theta, cov = pest.theta_est(calc_cov=True, cov_n=17) + print(obj) + print(theta) if __name__ == "__main__": diff --git a/pyomo/contrib/parmest/examples/reactor_design/reactor_design.py b/pyomo/contrib/parmest/examples/reactor_design/reactor_design.py index 65046d76a05..282d1b3227d 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/reactor_design.py +++ b/pyomo/contrib/parmest/examples/reactor_design/reactor_design.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,57 +12,46 @@ Continuously stirred tank reactor model, based on pyomo/examples/doc/pyomobook/nonlinear-ch/react_design/ReactorDesign.py """ + from pyomo.common.dependencies import pandas as pd -from pyomo.environ import ( - ConcreteModel, - Param, - Var, - PositiveReals, - Objective, - Constraint, - maximize, - SolverFactory, -) - - -def reactor_design_model(data): +import pyomo.environ as pyo +import pyomo.contrib.parmest.parmest as parmest +from pyomo.contrib.parmest.experiment import Experiment + + +def reactor_design_model(): + # Create the concrete model - model = ConcreteModel() + model = pyo.ConcreteModel() # Rate constants - model.k1 = Param(initialize=5.0 / 6.0, within=PositiveReals, mutable=True) # min^-1 - model.k2 = Param(initialize=5.0 / 3.0, within=PositiveReals, mutable=True) # min^-1 - model.k3 = Param( - initialize=1.0 / 6000.0, within=PositiveReals, mutable=True + model.k1 = pyo.Param( + initialize=5.0 / 6.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k2 = pyo.Param( + initialize=5.0 / 3.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k3 = pyo.Param( + initialize=1.0 / 6000.0, within=pyo.PositiveReals, mutable=True ) # m^3/(gmol min) # Inlet concentration of A, gmol/m^3 - if isinstance(data, dict) or isinstance(data, pd.Series): - model.caf = Param(initialize=float(data["caf"]), within=PositiveReals) - elif isinstance(data, pd.DataFrame): - model.caf = Param(initialize=float(data.iloc[0]["caf"]), within=PositiveReals) - else: - raise ValueError("Unrecognized data type.") + model.caf = pyo.Param(initialize=10000, within=pyo.PositiveReals, mutable=True) # Space velocity (flowrate/volume) - if isinstance(data, dict) or isinstance(data, pd.Series): - model.sv = Param(initialize=float(data["sv"]), within=PositiveReals) - elif isinstance(data, pd.DataFrame): - model.sv = Param(initialize=float(data.iloc[0]["sv"]), within=PositiveReals) - else: - raise ValueError("Unrecognized data type.") + model.sv = pyo.Param(initialize=1.0, within=pyo.PositiveReals, mutable=True) # Outlet concentration of each component - model.ca = Var(initialize=5000.0, within=PositiveReals) - model.cb = Var(initialize=2000.0, within=PositiveReals) - model.cc = Var(initialize=2000.0, within=PositiveReals) - model.cd = Var(initialize=1000.0, within=PositiveReals) + model.ca = pyo.Var(initialize=5000.0, within=pyo.PositiveReals) + model.cb = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cc = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cd = pyo.Var(initialize=1000.0, within=pyo.PositiveReals) # Objective - model.obj = Objective(expr=model.cb, sense=maximize) + model.obj = pyo.Objective(expr=model.cb, sense=pyo.maximize) # Constraints - model.ca_bal = Constraint( + model.ca_bal = pyo.Constraint( expr=( 0 == model.sv * model.caf @@ -72,28 +61,96 @@ def reactor_design_model(data): ) ) - model.cb_bal = Constraint( + model.cb_bal = pyo.Constraint( expr=(0 == -model.sv * model.cb + model.k1 * model.ca - model.k2 * model.cb) ) - model.cc_bal = Constraint(expr=(0 == -model.sv * model.cc + model.k2 * model.cb)) + model.cc_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cc + model.k2 * model.cb) + ) - model.cd_bal = Constraint( + model.cd_bal = pyo.Constraint( expr=(0 == -model.sv * model.cd + model.k3 * model.ca**2.0) ) return model +class ReactorDesignExperiment(Experiment): + + def __init__(self, data, experiment_number): + self.data = data + self.experiment_number = experiment_number + self.data_i = data.loc[experiment_number, :] + self.model = None + + def create_model(self): + self.model = m = reactor_design_model() + return m + + def finalize_model(self): + m = self.model + + # Experiment inputs values + m.sv = self.data_i['sv'] + m.caf = self.data_i['caf'] + + # Experiment output values + m.ca = self.data_i['ca'] + m.cb = self.data_i['cb'] + m.cc = self.data_i['cc'] + m.cd = self.data_i['cd'] + + return m + + def label_model(self): + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [ + (m.ca, self.data_i['ca']), + (m.cb, self.data_i['cb']), + (m.cc, self.data_i['cc']), + (m.cd, self.data_i['cd']), + ] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.ComponentUID(k)) for k in [m.k1, m.k2, m.k3] + ) + + return m + + def get_labeled_model(self): + m = self.create_model() + m = self.finalize_model() + m = self.label_model() + + return m + + def main(): + # For a range of sv values, return ca, cb, cc, and cd results = [] sv_values = [1.0 + v * 0.05 for v in range(1, 20)] caf = 10000 for sv in sv_values: - model = reactor_design_model(pd.DataFrame(data={"caf": [caf], "sv": [sv]})) - solver = SolverFactory("ipopt") + + # make model + model = reactor_design_model() + + # add caf, sv + model.caf = caf + model.sv = sv + + # solve model + solver = pyo.SolverFactory("ipopt") solver.solve(model) + + # save results results.append([sv, caf, model.ca(), model.cb(), model.cc(), model.cd()]) results = pd.DataFrame(results, columns=["sv", "caf", "ca", "cb", "cc", "cd"]) diff --git a/pyomo/contrib/parmest/examples/reactor_design/timeseries_data_example.py b/pyomo/contrib/parmest/examples/reactor_design/timeseries_data_example.py index b0b213752cb..b3c1466706b 100644 --- a/pyomo/contrib/parmest/examples/reactor_design/timeseries_data_example.py +++ b/pyomo/contrib/parmest/examples/reactor_design/timeseries_data_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,38 +14,64 @@ import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, + ReactorDesignExperiment, ) -def main(): - # Parameter estimation using timeseries data +class TimeSeriesReactorDesignExperiment(ReactorDesignExperiment): + + def __init__(self, data, experiment_number): + self.data = data + self.experiment_number = experiment_number + data_i = data.loc[data['experiment'] == experiment_number, :] + self.data_i = data_i.reset_index() + self.model = None + + def finalize_model(self): + m = self.model + + # Experiment inputs values + m.sv = self.data_i['sv'].mean() + m.caf = self.data_i['caf'].mean() + + # Experiment output values + m.ca = self.data_i['ca'][0] + m.cb = self.data_i['cb'][0] + m.cc = self.data_i['cc'][0] + m.cd = self.data_i['cd'][0] + + return m - # Vars to estimate - theta_names = ['k1', 'k2', 'k3'] + +def main(): + # Parameter estimation using timeseries data, grouped by experiment number # Data, includes multiple sensors for ca and cc file_dirname = dirname(abspath(str(__file__))) file_name = abspath(join(file_dirname, 'reactor_data_timeseries.csv')) data = pd.read_csv(file_name) - # Group time series data into experiments, return the mean value for sv and caf - # Returns a list of dictionaries - data_ts = parmest.group_data(data, 'experiment', ['sv', 'caf']) + # Create an experiment list + exp_list = [] + for i in data['experiment'].unique(): + exp_list.append(TimeSeriesReactorDesignExperiment(data, i)) + + def SSE_timeseries(model): - def SSE_timeseries(model, data): expr = 0 - for val in data['ca']: - expr = expr + ((float(val) - model.ca) ** 2) * (1 / len(data['ca'])) - for val in data['cb']: - expr = expr + ((float(val) - model.cb) ** 2) * (1 / len(data['cb'])) - for val in data['cc']: - expr = expr + ((float(val) - model.cc) ** 2) * (1 / len(data['cc'])) - for val in data['cd']: - expr = expr + ((float(val) - model.cd) ** 2) * (1 / len(data['cd'])) + for y, y_hat in model.experiment_outputs.items(): + num_time_points = len(y_hat) + for i in range(num_time_points): + expr += ((y - y_hat[i]) ** 2) * (1 / num_time_points) + return expr - pest = parmest.Estimator(reactor_design_model, data_ts, theta_names, SSE_timeseries) + # View one model & SSE + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + # print(SSE_timeseries(exp0_model)) + + pest = parmest.Estimator(exp_list, obj_function=SSE_timeseries) obj, theta = pest.theta_est() print(obj) print(theta) diff --git a/pyomo/contrib/parmest/examples/rooney_biegler/__init__.py b/pyomo/contrib/parmest/examples/rooney_biegler/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/parmest/examples/rooney_biegler/__init__.py +++ b/pyomo/contrib/parmest/examples/rooney_biegler/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/examples/rooney_biegler/bootstrap_example.py b/pyomo/contrib/parmest/examples/rooney_biegler/bootstrap_example.py index 49fed17c5b2..a34ddfdb014 100644 --- a/pyomo/contrib/parmest/examples/rooney_biegler/bootstrap_example.py +++ b/pyomo/contrib/parmest/examples/rooney_biegler/bootstrap_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,13 +12,11 @@ from pyomo.common.dependencies import pandas as pd import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - rooney_biegler_model, + RooneyBieglerExperiment, ) def main(): - # Vars to estimate - theta_names = ['asymptote', 'rate_constant'] # Data data = pd.DataFrame( @@ -27,14 +25,24 @@ def main(): ) # Sum of squared error function - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 for i in data.index - ) + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 return expr + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + # Create an instance of the parmest estimator - pest = parmest.Estimator(rooney_biegler_model, data, theta_names, SSE) + pest = parmest.Estimator(exp_list, obj_function=SSE) # Parameter estimation obj, theta = pest.theta_est() diff --git a/pyomo/contrib/parmest/examples/rooney_biegler/likelihood_ratio_example.py b/pyomo/contrib/parmest/examples/rooney_biegler/likelihood_ratio_example.py index a87beeb4d39..5763473dc13 100644 --- a/pyomo/contrib/parmest/examples/rooney_biegler/likelihood_ratio_example.py +++ b/pyomo/contrib/parmest/examples/rooney_biegler/likelihood_ratio_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,13 +13,11 @@ from itertools import product import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - rooney_biegler_model, + RooneyBieglerExperiment, ) def main(): - # Vars to estimate - theta_names = ['asymptote', 'rate_constant'] # Data data = pd.DataFrame( @@ -28,14 +26,24 @@ def main(): ) # Sum of squared error function - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 for i in data.index - ) + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 return expr + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + # Create an instance of the parmest estimator - pest = parmest.Estimator(rooney_biegler_model, data, theta_names, SSE) + pest = parmest.Estimator(exp_list, obj_function=SSE) # Parameter estimation obj, theta = pest.theta_est() diff --git a/pyomo/contrib/parmest/examples/rooney_biegler/parameter_estimation_example.py b/pyomo/contrib/parmest/examples/rooney_biegler/parameter_estimation_example.py index d11f0738ab4..dc01634ce38 100644 --- a/pyomo/contrib/parmest/examples/rooney_biegler/parameter_estimation_example.py +++ b/pyomo/contrib/parmest/examples/rooney_biegler/parameter_estimation_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,13 +12,11 @@ from pyomo.common.dependencies import pandas as pd import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - rooney_biegler_model, + RooneyBieglerExperiment, ) def main(): - # Vars to estimate - theta_names = ['asymptote', 'rate_constant'] # Data data = pd.DataFrame( @@ -27,14 +25,24 @@ def main(): ) # Sum of squared error function - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 for i in data.index - ) + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 return expr + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + # Create an instance of the parmest estimator - pest = parmest.Estimator(rooney_biegler_model, data, theta_names, SSE) + pest = parmest.Estimator(exp_list, obj_function=SSE) # Parameter estimation and covariance n = 6 # total number of data points used in the objective (y in 6 scenarios) diff --git a/pyomo/contrib/parmest/examples/rooney_biegler/rooney_biegler.py b/pyomo/contrib/parmest/examples/rooney_biegler/rooney_biegler.py index 724578b419f..fae2ab0f7cc 100644 --- a/pyomo/contrib/parmest/examples/rooney_biegler/rooney_biegler.py +++ b/pyomo/contrib/parmest/examples/rooney_biegler/rooney_biegler.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,13 +10,14 @@ # ___________________________________________________________________________ """ -Rooney Biegler model, based on Rooney, W. C. and Biegler, L. T. (2001). Design for -model parameter uncertainty using nonlinear confidence regions. AIChE Journal, +Rooney Biegler model, based on Rooney, W. C. and Biegler, L. T. (2001). Design for +model parameter uncertainty using nonlinear confidence regions. AIChE Journal, 47(8), 1794-1804. """ from pyomo.common.dependencies import pandas as pd import pyomo.environ as pyo +from pyomo.contrib.parmest.experiment import Experiment def rooney_biegler_model(data): @@ -25,6 +26,9 @@ def rooney_biegler_model(data): model.asymptote = pyo.Var(initialize=15) model.rate_constant = pyo.Var(initialize=0.5) + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + def response_rule(m, h): expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) return expr @@ -41,6 +45,47 @@ def SSE_rule(m): return model +class RooneyBieglerExperiment(Experiment): + + def __init__(self, data): + self.data = data + self.model = None + + def create_model(self): + # rooney_biegler_model expects a dataframe + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_model(data_df) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.hour, self.data['hour']), (m.y, self.data['y'])] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.ComponentUID(k)) for k in [m.asymptote, m.rate_constant] + ) + + def finalize_model(self): + + m = self.model + + # Experiment output values + m.hour = self.data['hour'] + m.y = self.data['y'] + + def get_labeled_model(self): + self.create_model() + self.label_model() + self.finalize_model() + + return self.model + + def main(): # These were taken from Table A1.4 in Bates and Watts (1988). data = pd.DataFrame( diff --git a/pyomo/contrib/parmest/examples/rooney_biegler/rooney_biegler_with_constraint.py b/pyomo/contrib/parmest/examples/rooney_biegler/rooney_biegler_with_constraint.py index 0c0de4dc6d8..3416682dca3 100644 --- a/pyomo/contrib/parmest/examples/rooney_biegler/rooney_biegler_with_constraint.py +++ b/pyomo/contrib/parmest/examples/rooney_biegler/rooney_biegler_with_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,6 +17,7 @@ from pyomo.common.dependencies import pandas as pd import pyomo.environ as pyo +from pyomo.contrib.parmest.experiment import Experiment def rooney_biegler_model_with_constraint(data): @@ -24,6 +25,10 @@ def rooney_biegler_model_with_constraint(data): model.asymptote = pyo.Var(initialize=15) model.rate_constant = pyo.Var(initialize=0.5) + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.response_function = pyo.Var(data.hour, initialize=0.0) # changed from expression to constraint @@ -44,6 +49,47 @@ def SSE_rule(m): return model +class RooneyBieglerExperiment(Experiment): + + def __init__(self, data): + self.data = data + self.model = None + + def create_model(self): + # rooney_biegler_model_with_constraint expects a dataframe + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_model_with_constraint(data_df) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.hour, self.data['hour']), (m.y, self.data['y'])] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.ComponentUID(k)) for k in [m.asymptote, m.rate_constant] + ) + + def finalize_model(self): + + m = self.model + + # Experiment output values + m.hour = self.data['hour'] + m.y = self.data['y'] + + def get_labeled_model(self): + self.create_model() + self.label_model() + self.finalize_model() + + return self.model + + def main(): # These were taken from Table A1.4 in Bates and Watts (1988). data = pd.DataFrame( diff --git a/pyomo/contrib/parmest/examples/semibatch/__init__.py b/pyomo/contrib/parmest/examples/semibatch/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/parmest/examples/semibatch/__init__.py +++ b/pyomo/contrib/parmest/examples/semibatch/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/examples/semibatch/parallel_example.py b/pyomo/contrib/parmest/examples/semibatch/parallel_example.py index d7cc497803e..ddc2541b621 100644 --- a/pyomo/contrib/parmest/examples/semibatch/parallel_example.py +++ b/pyomo/contrib/parmest/examples/semibatch/parallel_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ """ -The following script can be used to run semibatch parameter estimation in +The following script can be used to run semibatch parameter estimation in parallel and save results to files for later analysis and graphics. Example command: mpiexec -n 4 python parallel_example.py """ diff --git a/pyomo/contrib/parmest/examples/semibatch/parameter_estimation_example.py b/pyomo/contrib/parmest/examples/semibatch/parameter_estimation_example.py index c95d9084dc5..0fc5e7dc16d 100644 --- a/pyomo/contrib/parmest/examples/semibatch/parameter_estimation_example.py +++ b/pyomo/contrib/parmest/examples/semibatch/parameter_estimation_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,12 +12,10 @@ import json from os.path import join, abspath, dirname import pyomo.contrib.parmest.parmest as parmest -from pyomo.contrib.parmest.examples.semibatch.semibatch import generate_model +from pyomo.contrib.parmest.examples.semibatch.semibatch import SemiBatchExperiment def main(): - # Vars to estimate - theta_names = ['k1', 'k2', 'E1', 'E2'] # Data, list of dictionaries data = [] @@ -28,10 +26,19 @@ def main(): d = json.load(infile) data.append(d) + # Create an experiment list + exp_list = [] + for i in range(len(data)): + exp_list.append(SemiBatchExperiment(data[i])) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + # Note, the model already includes a 'SecondStageCost' expression # for sum of squared error that will be used in parameter estimation - pest = parmest.Estimator(generate_model, data, theta_names) + pest = parmest.Estimator(exp_list) obj, theta = pest.theta_est() print(obj) diff --git a/pyomo/contrib/parmest/examples/semibatch/scenario_example.py b/pyomo/contrib/parmest/examples/semibatch/scenario_example.py index 853a3770bb7..6200e0299e2 100644 --- a/pyomo/contrib/parmest/examples/semibatch/scenario_example.py +++ b/pyomo/contrib/parmest/examples/semibatch/scenario_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,13 +12,11 @@ import json from os.path import join, abspath, dirname import pyomo.contrib.parmest.parmest as parmest -from pyomo.contrib.parmest.examples.semibatch.semibatch import generate_model +from pyomo.contrib.parmest.examples.semibatch.semibatch import SemiBatchExperiment import pyomo.contrib.parmest.scenariocreator as sc def main(): - # Vars to estimate in parmest - theta_names = ['k1', 'k2', 'E1', 'E2'] # Data: list of dictionaries data = [] @@ -29,7 +27,16 @@ def main(): d = json.load(infile) data.append(d) - pest = parmest.Estimator(generate_model, data, theta_names) + # Create an experiment list + exp_list = [] + for i in range(len(data)): + exp_list.append(SemiBatchExperiment(data[i])) + + # View one model + # exp0_model = exp_list[0].get_labeled_model() + # exp0_model.pprint() + + pest = parmest.Estimator(exp_list) scenmaker = sc.ScenarioCreator(pest, "ipopt") diff --git a/pyomo/contrib/parmest/examples/semibatch/semibatch.py b/pyomo/contrib/parmest/examples/semibatch/semibatch.py index 462e5554142..c4c826caecb 100644 --- a/pyomo/contrib/parmest/examples/semibatch/semibatch.py +++ b/pyomo/contrib/parmest/examples/semibatch/semibatch.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,9 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ """ -Semibatch model, based on Nicholson et al. (2018). pyomo.dae: A modeling and +Semibatch model, based on Nicholson et al. (2018). pyomo.dae: A modeling and automatic discretization framework for optimization with di -erential and +erential and algebraic equations. Mathematical Programming Computation, 10(2), 187-223. """ import json @@ -29,8 +29,11 @@ SolverFactory, exp, minimize, + Suffix, + ComponentUID, ) from pyomo.dae import ContinuousSet, DerivativeVar +from pyomo.contrib.parmest.experiment import Experiment def generate_model(data): @@ -268,6 +271,35 @@ def total_cost_rule(model): return m +class SemiBatchExperiment(Experiment): + + def __init__(self, data): + self.data = data + self.model = None + + def create_model(self): + self.model = generate_model(self.data) + + def label_model(self): + + m = self.model + + m.unknown_parameters = Suffix(direction=Suffix.LOCAL) + m.unknown_parameters.update( + (k, ComponentUID(k)) for k in [m.k1, m.k2, m.E1, m.E2] + ) + + def finalize_model(self): + pass + + def get_labeled_model(self): + self.create_model() + self.label_model() + self.finalize_model() + + return self.model + + def main(): # Data loaded from files file_dirname = dirname(abspath(str(__file__))) diff --git a/pyomo/contrib/parmest/experiment.py b/pyomo/contrib/parmest/experiment.py new file mode 100644 index 00000000000..67a8be71f66 --- /dev/null +++ b/pyomo/contrib/parmest/experiment.py @@ -0,0 +1,31 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +class Experiment: + """ + The experiment class is a template for making experiment lists + to pass to parmest. + + An experiment is a Pyomo model "m" which is labeled + with additional suffixes: + * m.experiment_outputs which defines experiment outputs + * m.unknown_parameters which defines parameters to estimate + + The experiment class has one required method: + * get_labeled_model() which returns the labeled Pyomo model + """ + + def __init__(self, model=None): + self.model = model + + def get_labeled_model(self): + return self.model diff --git a/pyomo/contrib/parmest/graphics.py b/pyomo/contrib/parmest/graphics.py index c57bfb19696..f5357effe2a 100644 --- a/pyomo/contrib/parmest/graphics.py +++ b/pyomo/contrib/parmest/graphics.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 44c256f2019..ea9dfc00640 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,9 +11,9 @@ #### Using mpi-sppy instead of PySP; May 2020 #### Adding option for "local" EF starting Sept 2020 #### Wrapping mpi-sppy functionality and local option Jan 2021, Feb 2021 +#### Redesign with Experiment class Dec 2023 # TODO: move use_mpisppy to a Pyomo configuration option -# # False implies always use the EF that is local to parmest use_mpisppy = True # Use it if we can but use local if not. if use_mpisppy: @@ -42,7 +42,9 @@ import logging import types import json +from collections.abc import Callable from itertools import combinations +from functools import singledispatchmethod from pyomo.common.dependencies import ( attempt_import, @@ -63,6 +65,9 @@ import pyomo.contrib.parmest.graphics as graphics from pyomo.dae import ContinuousSet +from pyomo.common.deprecation import deprecated +from pyomo.common.deprecation import deprecation_warning + parmest_available = numpy_available & pandas_available & scipy_available inverse_reduced_hessian, inverse_reduced_hessian_available = attempt_import( @@ -209,12 +214,12 @@ def _experiment_instance_creation_callback( thetavals = outer_cb_data["ThetaVals"] # dlw august 2018: see mea code for more general theta - for vstr in thetavals: - theta_cuid = ComponentUID(vstr) + for name, val in thetavals.items(): + theta_cuid = ComponentUID(name) theta_object = theta_cuid.find_component_on(instance) - if thetavals[vstr] is not None: + if val is not None: # print("Fixing",vstr,"at",str(thetavals[vstr])) - theta_object.fix(thetavals[vstr]) + theta_object.fix(val) else: # print("Freeing",vstr) theta_object.unfix() @@ -222,93 +227,1218 @@ def _experiment_instance_creation_callback( return instance -# ============================================= -def _treemaker(scenlist): +def SSE(model): + """ + Sum of squared error between `experiment_output` model and data values + """ + expr = sum((y - y_hat) ** 2 for y, y_hat in model.experiment_outputs.items()) + return expr + + +class Estimator(object): """ - Makes a scenario tree (avoids dependence on daps) + Parameter estimation class Parameters ---------- - scenlist (list of `int`): experiment (i.e. scenario) numbers - - Returns - ------- - a `ConcreteModel` that is the scenario tree + experiment_list: list of Experiments + A list of experiment objects which creates one labeled model for + each experiment + obj_function: string or function (optional) + Built in objective (currently only "SSE") or custom function used to + formulate parameter estimation objective. + If no function is specified, the model is used + "as is" and should be defined with a "FirstStageCost" and + "SecondStageCost" expression that are used to build an objective. + Default is None. + tee: bool, optional + If True, print the solver output to the screen. Default is False. + diagnostic_mode: bool, optional + If True, print diagnostics from the solver. Default is False. + solver_options: dict, optional + Provides options to the solver (also the name of an attribute). + Default is None. """ - num_scenarios = len(scenlist) - m = scenario_tree.tree_structure_model.CreateAbstractScenarioTreeModel() - m = m.create_instance() - m.Stages.add('Stage1') - m.Stages.add('Stage2') - m.Nodes.add('RootNode') - for i in scenlist: - m.Nodes.add('LeafNode_Experiment' + str(i)) - m.Scenarios.add('Experiment' + str(i)) - m.NodeStage['RootNode'] = 'Stage1' - m.ConditionalProbability['RootNode'] = 1.0 - for node in m.Nodes: - if node != 'RootNode': - m.NodeStage[node] = 'Stage2' - m.Children['RootNode'].add(node) - m.Children[node].clear() - m.ConditionalProbability[node] = 1.0 / num_scenarios - m.ScenarioLeafNode[node.replace('LeafNode_', '')] = node - - return m + # The singledispatchmethod decorator is used here as a deprecation + # shim to be able to support the now deprecated Estimator interface + # which had a different number of arguments. When the deprecated API + # is removed this decorator and the _deprecated_init method below + # can be removed + @singledispatchmethod + def __init__( + self, + experiment_list, + obj_function=None, + tee=False, + diagnostic_mode=False, + solver_options=None, + ): + # check that we have a (non-empty) list of experiments + assert isinstance(experiment_list, list) + self.exp_list = experiment_list -def group_data(data, groupby_column_name, use_mean=None): - """ - Group data by scenario + # check that an experiment has experiment_outputs and unknown_parameters + model = self.exp_list[0].get_labeled_model() + try: + outputs = [k.name for k, v in model.experiment_outputs.items()] + except: + RuntimeError( + 'Experiment list model does not have suffix ' + '"experiment_outputs".' + ) + try: + params = [k.name for k, v in model.unknown_parameters.items()] + except: + RuntimeError( + 'Experiment list model does not have suffix ' + '"unknown_parameters".' + ) - Parameters - ---------- - data: DataFrame - Data - groupby_column_name: strings - Name of data column which contains scenario numbers - use_mean: list of column names or None, optional - Name of data columns which should be reduced to a single value per - scenario by taking the mean + # populate keyword argument options + self.obj_function = obj_function + self.tee = tee + self.diagnostic_mode = diagnostic_mode + self.solver_options = solver_options - Returns - ---------- - grouped_data: list of dictionaries - Grouped data - """ - if use_mean is None: - use_mean_list = [] - else: - use_mean_list = use_mean + # TODO: delete this when the deprecated interface is removed + self.pest_deprecated = None + + # TODO This might not be needed here. + # We could collect the union (or intersect?) of thetas when the models are built + theta_names = [] + for experiment in self.exp_list: + model = experiment.get_labeled_model() + theta_names.extend([k.name for k, v in model.unknown_parameters.items()]) + # Utilize list(dict.fromkeys(theta_names)) to preserve parameter + # order compared with list(set(theta_names)), which had + # nondeterministic ordering of parameters + self.estimator_theta_names = list(dict.fromkeys(theta_names)) + + self._second_stage_cost_exp = "SecondStageCost" + # boolean to indicate if model is initialized using a square solve + self.model_initialized = False + + # The deprecated Estimator constructor + # This works by checking the type of the first argument passed to + # the class constructor. If it matches the old interface (i.e. is + # callable) then this _deprecated_init method is called and the + # deprecation warning is displayed. + @__init__.register(Callable) + def _deprecated_init( + self, + model_function, + data, + theta_names, + obj_function=None, + tee=False, + diagnostic_mode=False, + solver_options=None, + ): + + deprecation_warning( + "You're using the deprecated parmest interface (model_function, " + "data, theta_names). This interface will be removed in a future release, " + "please update to the new parmest interface using experiment lists.", + version='6.7.2', + ) + self.pest_deprecated = _DeprecatedEstimator( + model_function, + data, + theta_names, + obj_function, + tee, + diagnostic_mode, + solver_options, + ) + + def _return_theta_names(self): + """ + Return list of fitted model parameter names + """ + # check for deprecated inputs + if self.pest_deprecated: + + # if fitted model parameter names differ from theta_names + # created when Estimator object is created + if hasattr(self, 'theta_names_updated'): + return self.pest_deprecated.theta_names_updated - grouped_data = [] - for exp_num, group in data.groupby(data[groupby_column_name]): - d = {} - for col in group.columns: - if col in use_mean_list: - d[col] = group[col].mean() else: - d[col] = list(group[col]) - grouped_data.append(d) - return grouped_data + # default theta_names, created when Estimator object is created + return self.pest_deprecated.theta_names + else: -class _SecondStageCostExpr(object): - """ - Class to pass objective expression into the Pyomo model - """ + # if fitted model parameter names differ from theta_names + # created when Estimator object is created + if hasattr(self, 'theta_names_updated'): + return self.theta_names_updated - def __init__(self, ssc_function, data): - self._ssc_function = ssc_function - self._data = data + else: - def __call__(self, model): - return self._ssc_function(model, self._data) + # default theta_names, created when Estimator object is created + return self.estimator_theta_names + def _expand_indexed_unknowns(self, model_temp): + """ + Expand indexed variables to get full list of thetas + """ -class Estimator(object): + model_theta_list = [] + for c in model_temp.unknown_parameters.keys(): + if c.is_indexed(): + for _, ci in c.items(): + model_theta_list.append(ci.name) + else: + model_theta_list.append(c.name) + + return model_theta_list + + def _create_parmest_model(self, experiment_number): + """ + Modify the Pyomo model for parameter estimation + """ + + model = self.exp_list[experiment_number].get_labeled_model() + + if len(model.unknown_parameters) == 0: + model.parmest_dummy_var = pyo.Var(initialize=1.0) + + # Add objective function (optional) + if self.obj_function: + + # Check for component naming conflicts + reserved_names = [ + 'Total_Cost_Objective', + 'FirstStageCost', + 'SecondStageCost', + ] + for n in reserved_names: + if model.component(n) or hasattr(model, n): + raise RuntimeError( + f"Parmest will not override the existing model component named {n}" + ) + + # Deactivate any existing objective functions + for obj in model.component_objects(pyo.Objective): + obj.deactivate() + + # TODO, this needs to be turned into an enum class of options that still support + # custom functions + if self.obj_function == 'SSE': + second_stage_rule = SSE + else: + # A custom function uses model.experiment_outputs as data + second_stage_rule = self.obj_function + + model.FirstStageCost = pyo.Expression(expr=0) + model.SecondStageCost = pyo.Expression(rule=second_stage_rule) + + def TotalCost_rule(model): + return model.FirstStageCost + model.SecondStageCost + + model.Total_Cost_Objective = pyo.Objective( + rule=TotalCost_rule, sense=pyo.minimize + ) + + # Convert theta Params to Vars, and unfix theta Vars + theta_names = [k.name for k, v in model.unknown_parameters.items()] + parmest_model = utils.convert_params_to_vars(model, theta_names, fix_vars=False) + + return parmest_model + + def _instance_creation_callback(self, experiment_number=None, cb_data=None): + model = self._create_parmest_model(experiment_number) + return model + + def _Q_opt( + self, + ThetaVals=None, + solver="ef_ipopt", + return_values=[], + bootlist=None, + calc_cov=False, + cov_n=None, + ): + """ + Set up all thetas as first stage Vars, return resulting theta + values as well as the objective function value. + + """ + if solver == "k_aug": + raise RuntimeError("k_aug no longer supported.") + + # (Bootstrap scenarios will use indirection through the bootlist) + if bootlist is None: + scenario_numbers = list(range(len(self.exp_list))) + scen_names = ["Scenario{}".format(i) for i in scenario_numbers] + else: + scen_names = ["Scenario{}".format(i) for i in range(len(bootlist))] + + # tree_model.CallbackModule = None + outer_cb_data = dict() + outer_cb_data["callback"] = self._instance_creation_callback + if ThetaVals is not None: + outer_cb_data["ThetaVals"] = ThetaVals + if bootlist is not None: + outer_cb_data["BootList"] = bootlist + outer_cb_data["cb_data"] = None # None is OK + outer_cb_data["theta_names"] = self.estimator_theta_names + + options = {"solver": "ipopt"} + scenario_creator_options = {"cb_data": outer_cb_data} + if use_mpisppy: + ef = sputils.create_EF( + scen_names, + _experiment_instance_creation_callback, + EF_name="_Q_opt", + suppress_warnings=True, + scenario_creator_kwargs=scenario_creator_options, + ) + else: + ef = local_ef.create_EF( + scen_names, + _experiment_instance_creation_callback, + EF_name="_Q_opt", + suppress_warnings=True, + scenario_creator_kwargs=scenario_creator_options, + ) + self.ef_instance = ef + + # Solve the extensive form with ipopt + if solver == "ef_ipopt": + if not calc_cov: + # Do not calculate the reduced hessian + + solver = SolverFactory('ipopt') + if self.solver_options is not None: + for key in self.solver_options: + solver.options[key] = self.solver_options[key] + + solve_result = solver.solve(self.ef_instance, tee=self.tee) + + # The import error will be raised when we attempt to use + # inv_reduced_hessian_barrier below. + # + # elif not asl_available: + # raise ImportError("parmest requires ASL to calculate the " + # "covariance matrix with solver 'ipopt'") + else: + # parmest makes the fitted parameters stage 1 variables + ind_vars = [] + for ndname, Var, solval in ef_nonants(ef): + ind_vars.append(Var) + # calculate the reduced hessian + (solve_result, inv_red_hes) = ( + inverse_reduced_hessian.inv_reduced_hessian_barrier( + self.ef_instance, + independent_variables=ind_vars, + solver_options=self.solver_options, + tee=self.tee, + ) + ) + + if self.diagnostic_mode: + print( + ' Solver termination condition = ', + str(solve_result.solver.termination_condition), + ) + + # assume all first stage are thetas... + thetavals = {} + for ndname, Var, solval in ef_nonants(ef): + # process the name + # the scenarios are blocks, so strip the scenario name + vname = Var.name[Var.name.find(".") + 1 :] + thetavals[vname] = solval + + objval = pyo.value(ef.EF_Obj) + + if calc_cov: + # Calculate the covariance matrix + + # Number of data points considered + n = cov_n + + # Extract number of fitted parameters + l = len(thetavals) + + # Assumption: Objective value is sum of squared errors + sse = objval + + '''Calculate covariance assuming experimental observation errors are + independent and follow a Gaussian + distribution with constant variance. + + The formula used in parmest was verified against equations (7-5-15) and + (7-5-16) in "Nonlinear Parameter Estimation", Y. Bard, 1974. + + This formula is also applicable if the objective is scaled by a constant; + the constant cancels out. (was scaled by 1/n because it computes an + expected value.) + ''' + cov = 2 * sse / (n - l) * inv_red_hes + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + + thetavals = pd.Series(thetavals) + + if len(return_values) > 0: + var_values = [] + if len(scen_names) > 1: # multiple scenarios + block_objects = self.ef_instance.component_objects( + Block, descend_into=False + ) + else: # single scenario + block_objects = [self.ef_instance] + for exp_i in block_objects: + vals = {} + for var in return_values: + exp_i_var = exp_i.find_component(str(var)) + if ( + exp_i_var is None + ): # we might have a block such as _mpisppy_data + continue + # if value to return is ContinuousSet + if type(exp_i_var) == ContinuousSet: + temp = list(exp_i_var) + else: + temp = [pyo.value(_) for _ in exp_i_var.values()] + if len(temp) == 1: + vals[var] = temp[0] + else: + vals[var] = temp + if len(vals) > 0: + var_values.append(vals) + var_values = pd.DataFrame(var_values) + if calc_cov: + return objval, thetavals, var_values, cov + else: + return objval, thetavals, var_values + + if calc_cov: + return objval, thetavals, cov + else: + return objval, thetavals + + else: + raise RuntimeError("Unknown solver in Q_Opt=" + solver) + + def _Q_at_theta(self, thetavals, initialize_parmest_model=False): + """ + Return the objective function value with fixed theta values. + + Parameters + ---------- + thetavals: dict + A dictionary of theta values. + + initialize_parmest_model: boolean + If True: Solve square problem instance, build extensive form of the model for + parameter estimation, and set flag model_initialized to True. Default is False. + + Returns + ------- + objectiveval: float + The objective function value. + thetavals: dict + A dictionary of all values for theta that were input. + solvertermination: Pyomo TerminationCondition + Tries to return the "worst" solver status across the scenarios. + pyo.TerminationCondition.optimal is the best and + pyo.TerminationCondition.infeasible is the worst. + """ + + optimizer = pyo.SolverFactory('ipopt') + + if len(thetavals) > 0: + dummy_cb = { + "callback": self._instance_creation_callback, + "ThetaVals": thetavals, + "theta_names": self._return_theta_names(), + "cb_data": None, + } + else: + dummy_cb = { + "callback": self._instance_creation_callback, + "theta_names": self._return_theta_names(), + "cb_data": None, + } + + if self.diagnostic_mode: + if len(thetavals) > 0: + print(' Compute objective at theta = ', str(thetavals)) + else: + print(' Compute objective at initial theta') + + # start block of code to deal with models with no constraints + # (ipopt will crash or complain on such problems without special care) + instance = _experiment_instance_creation_callback("FOO0", None, dummy_cb) + try: # deal with special problems so Ipopt will not crash + first = next(instance.component_objects(pyo.Constraint, active=True)) + active_constraints = True + except: + active_constraints = False + # end block of code to deal with models with no constraints + + WorstStatus = pyo.TerminationCondition.optimal + totobj = 0 + scenario_numbers = list(range(len(self.exp_list))) + if initialize_parmest_model: + # create dictionary to store pyomo model instances (scenarios) + scen_dict = dict() + + for snum in scenario_numbers: + sname = "scenario_NODE" + str(snum) + instance = _experiment_instance_creation_callback(sname, None, dummy_cb) + model_theta_names = self._expand_indexed_unknowns(instance) + + if initialize_parmest_model: + # list to store fitted parameter names that will be unfixed + # after initialization + theta_init_vals = [] + # use appropriate theta_names member + theta_ref = model_theta_names + + for i, theta in enumerate(theta_ref): + # Use parser in ComponentUID to locate the component + var_cuid = ComponentUID(theta) + var_validate = var_cuid.find_component_on(instance) + if var_validate is None: + logger.warning( + "theta_name %s was not found on the model", (theta) + ) + else: + try: + if len(thetavals) == 0: + var_validate.fix() + else: + var_validate.fix(thetavals[theta]) + theta_init_vals.append(var_validate) + except: + logger.warning( + 'Unable to fix model parameter value for %s (not a Pyomo model Var)', + (theta), + ) + + if active_constraints: + if self.diagnostic_mode: + print(' Experiment = ', snum) + print(' First solve with special diagnostics wrapper') + (status_obj, solved, iters, time, regu) = ( + utils.ipopt_solve_with_stats( + instance, optimizer, max_iter=500, max_cpu_time=120 + ) + ) + print( + " status_obj, solved, iters, time, regularization_stat = ", + str(status_obj), + str(solved), + str(iters), + str(time), + str(regu), + ) + + results = optimizer.solve(instance) + if self.diagnostic_mode: + print( + 'standard solve solver termination condition=', + str(results.solver.termination_condition), + ) + + if ( + results.solver.termination_condition + != pyo.TerminationCondition.optimal + ): + # DLW: Aug2018: not distinguishing "middlish" conditions + if WorstStatus != pyo.TerminationCondition.infeasible: + WorstStatus = results.solver.termination_condition + if initialize_parmest_model: + if self.diagnostic_mode: + print( + "Scenario {:d} infeasible with initialized parameter values".format( + snum + ) + ) + else: + if initialize_parmest_model: + if self.diagnostic_mode: + print( + "Scenario {:d} initialization successful with initial parameter values".format( + snum + ) + ) + if initialize_parmest_model: + # unfix parameters after initialization + for theta in theta_init_vals: + theta.unfix() + scen_dict[sname] = instance + else: + if initialize_parmest_model: + # unfix parameters after initialization + for theta in theta_init_vals: + theta.unfix() + scen_dict[sname] = instance + + objobject = getattr(instance, self._second_stage_cost_exp) + objval = pyo.value(objobject) + totobj += objval + + retval = totobj / len(scenario_numbers) # -1?? + if initialize_parmest_model and not hasattr(self, 'ef_instance'): + # create extensive form of the model using scenario dictionary + if len(scen_dict) > 0: + for scen in scen_dict.values(): + scen._mpisppy_probability = 1 / len(scen_dict) + + if use_mpisppy: + EF_instance = sputils._create_EF_from_scen_dict( + scen_dict, + EF_name="_Q_at_theta", + # suppress_warnings=True + ) + else: + EF_instance = local_ef._create_EF_from_scen_dict( + scen_dict, EF_name="_Q_at_theta", nonant_for_fixed_vars=True + ) + + self.ef_instance = EF_instance + # set self.model_initialized flag to True to skip extensive form model + # creation using theta_est() + self.model_initialized = True + + # return initialized theta values + if len(thetavals) == 0: + # use appropriate theta_names member + theta_ref = self._return_theta_names() + for i, theta in enumerate(theta_ref): + thetavals[theta] = theta_init_vals[i]() + + return retval, thetavals, WorstStatus + + def _get_sample_list(self, samplesize, num_samples, replacement=True): + samplelist = list() + + scenario_numbers = list(range(len(self.exp_list))) + + if num_samples is None: + # This could get very large + for i, l in enumerate(combinations(scenario_numbers, samplesize)): + samplelist.append((i, np.sort(l))) + else: + for i in range(num_samples): + attempts = 0 + unique_samples = 0 # check for duplicates in each sample + duplicate = False # check for duplicates between samples + while (unique_samples <= len(self._return_theta_names())) and ( + not duplicate + ): + sample = np.random.choice( + scenario_numbers, samplesize, replace=replacement + ) + sample = np.sort(sample).tolist() + unique_samples = len(np.unique(sample)) + if sample in samplelist: + duplicate = True + + attempts += 1 + if attempts > num_samples: # arbitrary timeout limit + raise RuntimeError( + """Internal error: timeout constructing + a sample, the dim of theta may be too + close to the samplesize""" + ) + + samplelist.append((i, sample)) + + return samplelist + + def theta_est( + self, solver="ef_ipopt", return_values=[], calc_cov=False, cov_n=None + ): + """ + Parameter estimation using all scenarios in the data + + Parameters + ---------- + solver: string, optional + Currently only "ef_ipopt" is supported. Default is "ef_ipopt". + return_values: list, optional + List of Variable names, used to return values from the model for data reconciliation + calc_cov: boolean, optional + If True, calculate and return the covariance matrix (only for "ef_ipopt" solver). + Default is False. + cov_n: int, optional + If calc_cov=True, then the user needs to supply the number of datapoints + that are used in the objective function. + + Returns + ------- + objectiveval: float + The objective function value + thetavals: pd.Series + Estimated values for theta + variable values: pd.DataFrame + Variable values for each variable name in return_values (only for solver='ef_ipopt') + cov: pd.DataFrame + Covariance matrix of the fitted parameters (only for solver='ef_ipopt') + """ + + # check if we are using deprecated parmest + if self.pest_deprecated is not None: + return self.pest_deprecated.theta_est( + solver=solver, + return_values=return_values, + calc_cov=calc_cov, + cov_n=cov_n, + ) + + assert isinstance(solver, str) + assert isinstance(return_values, list) + assert isinstance(calc_cov, bool) + if calc_cov: + num_unknowns = max( + [ + len(experiment.get_labeled_model().unknown_parameters) + for experiment in self.exp_list + ] + ) + assert isinstance(cov_n, int), ( + "The number of datapoints that are used in the objective function is " + "required to calculate the covariance matrix" + ) + assert ( + cov_n > num_unknowns + ), "The number of datapoints must be greater than the number of parameters to estimate" + + return self._Q_opt( + solver=solver, + return_values=return_values, + bootlist=None, + calc_cov=calc_cov, + cov_n=cov_n, + ) + + def theta_est_bootstrap( + self, + bootstrap_samples, + samplesize=None, + replacement=True, + seed=None, + return_samples=False, + ): + """ + Parameter estimation using bootstrap resampling of the data + + Parameters + ---------- + bootstrap_samples: int + Number of bootstrap samples to draw from the data + samplesize: int or None, optional + Size of each bootstrap sample. If samplesize=None, samplesize will be + set to the number of samples in the data + replacement: bool, optional + Sample with or without replacement. Default is True. + seed: int or None, optional + Random seed + return_samples: bool, optional + Return a list of sample numbers used in each bootstrap estimation. + Default is False. + + Returns + ------- + bootstrap_theta: pd.DataFrame + Theta values for each sample and (if return_samples = True) + the sample numbers used in each estimation + """ + + # check if we are using deprecated parmest + if self.pest_deprecated is not None: + return self.pest_deprecated.theta_est_bootstrap( + bootstrap_samples, + samplesize=samplesize, + replacement=replacement, + seed=seed, + return_samples=return_samples, + ) + + assert isinstance(bootstrap_samples, int) + assert isinstance(samplesize, (type(None), int)) + assert isinstance(replacement, bool) + assert isinstance(seed, (type(None), int)) + assert isinstance(return_samples, bool) + + if samplesize is None: + samplesize = len(self.exp_list) + + if seed is not None: + np.random.seed(seed) + + global_list = self._get_sample_list(samplesize, bootstrap_samples, replacement) + + task_mgr = utils.ParallelTaskManager(bootstrap_samples) + local_list = task_mgr.global_to_local_data(global_list) + + bootstrap_theta = list() + for idx, sample in local_list: + objval, thetavals = self._Q_opt(bootlist=list(sample)) + thetavals['samples'] = sample + bootstrap_theta.append(thetavals) + + global_bootstrap_theta = task_mgr.allgather_global_data(bootstrap_theta) + bootstrap_theta = pd.DataFrame(global_bootstrap_theta) + + if not return_samples: + del bootstrap_theta['samples'] + + return bootstrap_theta + + def theta_est_leaveNout( + self, lNo, lNo_samples=None, seed=None, return_samples=False + ): + """ + Parameter estimation where N data points are left out of each sample + + Parameters + ---------- + lNo: int + Number of data points to leave out for parameter estimation + lNo_samples: int + Number of leave-N-out samples. If lNo_samples=None, the maximum + number of combinations will be used + seed: int or None, optional + Random seed + return_samples: bool, optional + Return a list of sample numbers that were left out. Default is False. + + Returns + ------- + lNo_theta: pd.DataFrame + Theta values for each sample and (if return_samples = True) + the sample numbers left out of each estimation + """ + + # check if we are using deprecated parmest + if self.pest_deprecated is not None: + return self.pest_deprecated.theta_est_leaveNout( + lNo, lNo_samples=lNo_samples, seed=seed, return_samples=return_samples + ) + + assert isinstance(lNo, int) + assert isinstance(lNo_samples, (type(None), int)) + assert isinstance(seed, (type(None), int)) + assert isinstance(return_samples, bool) + + samplesize = len(self.exp_list) - lNo + + if seed is not None: + np.random.seed(seed) + + global_list = self._get_sample_list(samplesize, lNo_samples, replacement=False) + + task_mgr = utils.ParallelTaskManager(len(global_list)) + local_list = task_mgr.global_to_local_data(global_list) + + lNo_theta = list() + for idx, sample in local_list: + objval, thetavals = self._Q_opt(bootlist=list(sample)) + lNo_s = list(set(range(len(self.exp_list))) - set(sample)) + thetavals['lNo'] = np.sort(lNo_s) + lNo_theta.append(thetavals) + + global_bootstrap_theta = task_mgr.allgather_global_data(lNo_theta) + lNo_theta = pd.DataFrame(global_bootstrap_theta) + + if not return_samples: + del lNo_theta['lNo'] + + return lNo_theta + + def leaveNout_bootstrap_test( + self, lNo, lNo_samples, bootstrap_samples, distribution, alphas, seed=None + ): + """ + Leave-N-out bootstrap test to compare theta values where N data points are + left out to a bootstrap analysis using the remaining data, + results indicate if theta is within a confidence region + determined by the bootstrap analysis + + Parameters + ---------- + lNo: int + Number of data points to leave out for parameter estimation + lNo_samples: int + Leave-N-out sample size. If lNo_samples=None, the maximum number + of combinations will be used + bootstrap_samples: int: + Bootstrap sample size + distribution: string + Statistical distribution used to define a confidence region, + options = 'MVN' for multivariate_normal, 'KDE' for gaussian_kde, + and 'Rect' for rectangular. + alphas: list + List of alpha values used to determine if theta values are inside + or outside the region. + seed: int or None, optional + Random seed + + Returns + ------- + List of tuples with one entry per lNo_sample: + + * The first item in each tuple is the list of N samples that are left + out. + * The second item in each tuple is a DataFrame of theta estimated using + the N samples. + * The third item in each tuple is a DataFrame containing results from + the bootstrap analysis using the remaining samples. + + For each DataFrame a column is added for each value of alpha which + indicates if the theta estimate is in (True) or out (False) of the + alpha region for a given distribution (based on the bootstrap results) + """ + + # check if we are using deprecated parmest + if self.pest_deprecated is not None: + return self.pest_deprecated.leaveNout_bootstrap_test( + lNo, lNo_samples, bootstrap_samples, distribution, alphas, seed=seed + ) + + assert isinstance(lNo, int) + assert isinstance(lNo_samples, (type(None), int)) + assert isinstance(bootstrap_samples, int) + assert distribution in ['Rect', 'MVN', 'KDE'] + assert isinstance(alphas, list) + assert isinstance(seed, (type(None), int)) + + if seed is not None: + np.random.seed(seed) + + global_list = self._get_sample_list(lNo, lNo_samples, replacement=False) + + results = [] + for idx, sample in global_list: + + obj, theta = self.theta_est() + + bootstrap_theta = self.theta_est_bootstrap(bootstrap_samples) + + training, test = self.confidence_region_test( + bootstrap_theta, + distribution=distribution, + alphas=alphas, + test_theta_values=theta, + ) + + results.append((sample, test, training)) + + return results + + def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): + """ + Objective value for each theta + + Parameters + ---------- + theta_values: pd.DataFrame, columns=theta_names + Values of theta used to compute the objective + + initialize_parmest_model: boolean + If True: Solve square problem instance, build extensive form + of the model for parameter estimation, and set flag + model_initialized to True. Default is False. + + + Returns + ------- + obj_at_theta: pd.DataFrame + Objective value for each theta (infeasible solutions are + omitted). + """ + + # check if we are using deprecated parmest + if self.pest_deprecated is not None: + return self.pest_deprecated.objective_at_theta( + theta_values=theta_values, + initialize_parmest_model=initialize_parmest_model, + ) + + if len(self.estimator_theta_names) == 0: + pass # skip assertion if model has no fitted parameters + else: + # create a local instance of the pyomo model to access model variables and parameters + model_temp = self._create_parmest_model(0) + model_theta_list = self._expand_indexed_unknowns(model_temp) + + # if self.estimator_theta_names is not the same as temp model_theta_list, + # create self.theta_names_updated + if set(self.estimator_theta_names) == set(model_theta_list) and len( + self.estimator_theta_names + ) == len(set(model_theta_list)): + pass + else: + self.theta_names_updated = model_theta_list + + if theta_values is None: + all_thetas = {} # dictionary to store fitted variables + # use appropriate theta names member + theta_names = model_theta_list + else: + assert isinstance(theta_values, pd.DataFrame) + # for parallel code we need to use lists and dicts in the loop + theta_names = theta_values.columns + # # check if theta_names are in model + for theta in list(theta_names): + theta_temp = theta.replace("'", "") # cleaning quotes from theta_names + assert theta_temp in [ + t.replace("'", "") for t in model_theta_list + ], "Theta name {} in 'theta_values' not in 'theta_names' {}".format( + theta_temp, model_theta_list + ) + + assert len(list(theta_names)) == len(model_theta_list) + + all_thetas = theta_values.to_dict('records') + + if all_thetas: + task_mgr = utils.ParallelTaskManager(len(all_thetas)) + local_thetas = task_mgr.global_to_local_data(all_thetas) + else: + if initialize_parmest_model: + task_mgr = utils.ParallelTaskManager( + 1 + ) # initialization performed using just 1 set of theta values + # walk over the mesh, return objective function + all_obj = list() + if len(all_thetas) > 0: + for Theta in local_thetas: + obj, thetvals, worststatus = self._Q_at_theta( + Theta, initialize_parmest_model=initialize_parmest_model + ) + if worststatus != pyo.TerminationCondition.infeasible: + all_obj.append(list(Theta.values()) + [obj]) + # DLW, Aug2018: should we also store the worst solver status? + else: + obj, thetvals, worststatus = self._Q_at_theta( + thetavals={}, initialize_parmest_model=initialize_parmest_model + ) + if worststatus != pyo.TerminationCondition.infeasible: + all_obj.append(list(thetvals.values()) + [obj]) + + global_all_obj = task_mgr.allgather_global_data(all_obj) + dfcols = list(theta_names) + ['obj'] + obj_at_theta = pd.DataFrame(data=global_all_obj, columns=dfcols) + return obj_at_theta + + def likelihood_ratio_test( + self, obj_at_theta, obj_value, alphas, return_thresholds=False + ): + r""" + Likelihood ratio test to identify theta values within a confidence + region using the :math:`\chi^2` distribution + + Parameters + ---------- + obj_at_theta: pd.DataFrame, columns = theta_names + 'obj' + Objective values for each theta value (returned by + objective_at_theta) + obj_value: int or float + Objective value from parameter estimation using all data + alphas: list + List of alpha values to use in the chi2 test + return_thresholds: bool, optional + Return the threshold value for each alpha. Default is False. + + Returns + ------- + LR: pd.DataFrame + Objective values for each theta value along with True or False for + each alpha + thresholds: pd.Series + If return_threshold = True, the thresholds are also returned. + """ + + # check if we are using deprecated parmest + if self.pest_deprecated is not None: + return self.pest_deprecated.likelihood_ratio_test( + obj_at_theta, obj_value, alphas, return_thresholds=return_thresholds + ) + + assert isinstance(obj_at_theta, pd.DataFrame) + assert isinstance(obj_value, (int, float)) + assert isinstance(alphas, list) + assert isinstance(return_thresholds, bool) + + LR = obj_at_theta.copy() + S = len(self.exp_list) + thresholds = {} + for a in alphas: + chi2_val = scipy.stats.chi2.ppf(a, 2) + thresholds[a] = obj_value * ((chi2_val / (S - 2)) + 1) + LR[a] = LR['obj'] < thresholds[a] + + thresholds = pd.Series(thresholds) + + if return_thresholds: + return LR, thresholds + else: + return LR + + def confidence_region_test( + self, theta_values, distribution, alphas, test_theta_values=None + ): + """ + Confidence region test to determine if theta values are within a + rectangular, multivariate normal, or Gaussian kernel density distribution + for a range of alpha values + + Parameters + ---------- + theta_values: pd.DataFrame, columns = theta_names + Theta values used to generate a confidence region + (generally returned by theta_est_bootstrap) + distribution: string + Statistical distribution used to define a confidence region, + options = 'MVN' for multivariate_normal, 'KDE' for gaussian_kde, + and 'Rect' for rectangular. + alphas: list + List of alpha values used to determine if theta values are inside + or outside the region. + test_theta_values: pd.Series or pd.DataFrame, keys/columns = theta_names, optional + Additional theta values that are compared to the confidence region + to determine if they are inside or outside. + + Returns + ------- + training_results: pd.DataFrame + Theta value used to generate the confidence region along with True + (inside) or False (outside) for each alpha + test_results: pd.DataFrame + If test_theta_values is not None, returns test theta value along + with True (inside) or False (outside) for each alpha + """ + + # check if we are using deprecated parmest + if self.pest_deprecated is not None: + return self.pest_deprecated.confidence_region_test( + theta_values, distribution, alphas, test_theta_values=test_theta_values + ) + + assert isinstance(theta_values, pd.DataFrame) + assert distribution in ['Rect', 'MVN', 'KDE'] + assert isinstance(alphas, list) + assert isinstance( + test_theta_values, (type(None), dict, pd.Series, pd.DataFrame) + ) + + if isinstance(test_theta_values, (dict, pd.Series)): + test_theta_values = pd.Series(test_theta_values).to_frame().transpose() + + training_results = theta_values.copy() + + if test_theta_values is not None: + test_result = test_theta_values.copy() + + for a in alphas: + if distribution == 'Rect': + lb, ub = graphics.fit_rect_dist(theta_values, a) + training_results[a] = (theta_values > lb).all(axis=1) & ( + theta_values < ub + ).all(axis=1) + + if test_theta_values is not None: + # use upper and lower bound from the training set + test_result[a] = (test_theta_values > lb).all(axis=1) & ( + test_theta_values < ub + ).all(axis=1) + + elif distribution == 'MVN': + dist = graphics.fit_mvn_dist(theta_values) + Z = dist.pdf(theta_values) + score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) + training_results[a] = Z >= score + + if test_theta_values is not None: + # use score from the training set + Z = dist.pdf(test_theta_values) + test_result[a] = Z >= score + + elif distribution == 'KDE': + dist = graphics.fit_kde_dist(theta_values) + Z = dist.pdf(theta_values.transpose()) + score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) + training_results[a] = Z >= score + + if test_theta_values is not None: + # use score from the training set + Z = dist.pdf(test_theta_values.transpose()) + test_result[a] = Z >= score + + if test_theta_values is not None: + return training_results, test_result + else: + return training_results + + +################################ +# deprecated functions/classes # +################################ + + +@deprecated(version='6.7.2') +def group_data(data, groupby_column_name, use_mean=None): + """ + Group data by scenario + + Parameters + ---------- + data: DataFrame + Data + groupby_column_name: strings + Name of data column which contains scenario numbers + use_mean: list of column names or None, optional + Name of data columns which should be reduced to a single value per + scenario by taking the mean + + Returns + ---------- + grouped_data: list of dictionaries + Grouped data + """ + if use_mean is None: + use_mean_list = [] + else: + use_mean_list = use_mean + + grouped_data = [] + for exp_num, group in data.groupby(data[groupby_column_name]): + d = {} + for col in group.columns: + if col in use_mean_list: + d[col] = group[col].mean() + else: + d[col] = list(group[col]) + grouped_data.append(d) + + return grouped_data + + +class _DeprecatedSecondStageCostExpr(object): + """ + Class to pass objective expression into the Pyomo model + """ + + def __init__(self, ssc_function, data): + self._ssc_function = ssc_function + self._data = data + + def __call__(self, model): + return self._ssc_function(model, self._data) + + +class _DeprecatedEstimator(object): """ Parameter estimation class @@ -418,7 +1548,7 @@ def _create_parmest_model(self, data): ) model.FirstStageCost = pyo.Expression(expr=0) model.SecondStageCost = pyo.Expression( - rule=_SecondStageCostExpr(self.obj_function, data) + rule=_DeprecatedSecondStageCostExpr(self.obj_function, data) ) def TotalCost_rule(model): diff --git a/pyomo/contrib/parmest/scenariocreator.py b/pyomo/contrib/parmest/scenariocreator.py index b599e5952d2..3be02967a63 100644 --- a/pyomo/contrib/parmest/scenariocreator.py +++ b/pyomo/contrib/parmest/scenariocreator.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,6 +14,10 @@ import pyomo.environ as pyo +import logging + +logger = logging.getLogger(__name__) + class ScenarioSet(object): """ @@ -119,6 +123,7 @@ class ScenarioCreator(object): """ def __init__(self, pest, solvername): + self.pest = pest self.solvername = solvername @@ -133,23 +138,32 @@ def ScenariosFromExperiments(self, addtoSet): assert isinstance(addtoSet, ScenarioSet) - scenario_numbers = list(range(len(self.pest.callback_data))) + if self.pest.pest_deprecated is not None: + scenario_numbers = list(range(len(self.pest.pest_deprecated.callback_data))) + else: + scenario_numbers = list(range(len(self.pest.exp_list))) prob = 1.0 / len(scenario_numbers) for exp_num in scenario_numbers: ##print("Experiment number=", exp_num) - model = self.pest._instance_creation_callback( - exp_num, self.pest.callback_data - ) + if self.pest.pest_deprecated is not None: + model = self.pest.pest_deprecated._instance_creation_callback( + exp_num, self.pest.pest_deprecated.callback_data + ) + else: + model = self.pest._instance_creation_callback(exp_num) opt = pyo.SolverFactory(self.solvername) results = opt.solve(model) # solves and updates model ## pyo.check_termination_optimal(results) - ThetaVals = dict() - for theta in self.pest.theta_names: - tvar = eval('model.' + theta) - tval = pyo.value(tvar) - ##print(" theta, tval=", tvar, tval) - ThetaVals[theta] = tval + if self.pest.pest_deprecated is not None: + ThetaVals = { + theta: pyo.value(model.find_component(theta)) + for theta in self.pest.pest_deprecated.theta_names + } + else: + ThetaVals = { + k.name: pyo.value(k) for k in model.unknown_parameters.keys() + } addtoSet.addone(ParmestScen("ExpScen" + str(exp_num), ThetaVals, prob)) def ScenariosFromBootstrap(self, addtoSet, numtomake, seed=None): @@ -162,5 +176,10 @@ def ScenariosFromBootstrap(self, addtoSet, numtomake, seed=None): assert isinstance(addtoSet, ScenarioSet) - bootstrap_thetas = self.pest.theta_est_bootstrap(numtomake, seed=seed) + if self.pest.pest_deprecated is not None: + bootstrap_thetas = self.pest.pest_deprecated.theta_est_bootstrap( + numtomake, seed=seed + ) + else: + bootstrap_thetas = self.pest.theta_est_bootstrap(numtomake, seed=seed) addtoSet.append_bootstrap(bootstrap_thetas) diff --git a/pyomo/contrib/parmest/tests/__init__.py b/pyomo/contrib/parmest/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/parmest/tests/__init__.py +++ b/pyomo/contrib/parmest/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/tests/test_examples.py b/pyomo/contrib/parmest/tests/test_examples.py index 59a3e0adde2..69b8cc67140 100644 --- a/pyomo/contrib/parmest/tests/test_examples.py +++ b/pyomo/contrib/parmest/tests/test_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,9 +12,11 @@ import pyomo.common.unittest as unittest import pyomo.contrib.parmest.parmest as parmest from pyomo.contrib.parmest.graphics import matplotlib_available, seaborn_available +from pyomo.contrib.pynumero.asl import AmplInterface from pyomo.opt import SolverFactory ipopt_available = SolverFactory("ipopt").available() +pynumero_ASL_available = AmplInterface.available() @unittest.skipIf( @@ -43,6 +45,7 @@ def test_model_with_constraint(self): rooney_biegler_with_constraint.main() + @unittest.skipUnless(pynumero_ASL_available, "test requires libpynumero_ASL") @unittest.skipUnless(seaborn_available, "test requires seaborn") def test_parameter_estimation_example(self): from pyomo.contrib.parmest.examples.rooney_biegler import ( @@ -66,11 +69,11 @@ def test_likelihood_ratio_example(self): likelihood_ratio_example.main() -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", +@unittest.skipUnless(pynumero_ASL_available, "test requires libpynumero_ASL") +@unittest.skipUnless(ipopt_available, "The 'ipopt' solver is not available") +@unittest.skipUnless( + parmest.parmest_available, "Cannot test parmest: required dependencies are missing" ) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") class TestReactionKineticsExamples(unittest.TestCase): @classmethod def setUpClass(self): @@ -140,6 +143,7 @@ def test_model(self): reactor_design.main() + @unittest.skipUnless(pynumero_ASL_available, "test requires libpynumero_ASL") def test_parameter_estimation_example(self): from pyomo.contrib.parmest.examples.reactor_design import ( parameter_estimation_example, @@ -181,7 +185,10 @@ def test_multisensor_data_example(self): multisensor_data_example.main() - @unittest.skipUnless(matplotlib_available, "test requires matplotlib") + @unittest.skipUnless( + matplotlib_available and seaborn_available, + "test requires matplotlib and seaborn", + ) def test_datarec_example(self): from pyomo.contrib.parmest.examples.reactor_design import datarec_example diff --git a/pyomo/contrib/parmest/tests/test_graphics.py b/pyomo/contrib/parmest/tests/test_graphics.py index 3b4d0224ebe..0768c5d2024 100644 --- a/pyomo/contrib/parmest/tests/test_graphics.py +++ b/pyomo/contrib/parmest/tests/test_graphics.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index 31e083a5f33..a6a549757f7 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,42 +9,927 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.dependencies import ( - numpy as np, - numpy_available, - pandas as pd, - pandas_available, - scipy, - scipy_available, - matplotlib, - matplotlib_available, +import platform +import sys +import os +import subprocess +from itertools import product + +import pyomo.common.unittest as unittest +import pyomo.contrib.parmest.parmest as parmest +import pyomo.contrib.parmest.graphics as graphics +import pyomo.contrib.parmest as parmestbase +import pyomo.environ as pyo +import pyomo.dae as dae + +from pyomo.common.dependencies import numpy as np, pandas as pd, scipy, matplotlib +from pyomo.common.fileutils import this_file_dir +from pyomo.contrib.parmest.experiment import Experiment +from pyomo.contrib.pynumero.asl import AmplInterface +from pyomo.opt import SolverFactory + +is_osx = platform.mac_ver()[0] != "" +ipopt_available = SolverFactory("ipopt").available() +pynumero_ASL_available = AmplInterface.available() +testdir = this_file_dir() + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestRooneyBiegler(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( + RooneyBieglerExperiment, + ) + + # Note, the data used in this test has been corrected to use + # data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + # Sum of squared error function + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 + return expr + + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + # Create an instance of the parmest estimator + pest = parmest.Estimator(exp_list, obj_function=SSE) + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + exp_list, obj_function=SSE, solver_options=solver_options, tee=True + ) + + def test_theta_est(self): + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_bootstrap(self): + objval, thetavals = self.pest.theta_est() + + num_bootstraps = 10 + theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) + + num_samples = theta_est["samples"].apply(len) + self.assertEqual(len(theta_est.index), 10) + self.assertTrue(num_samples.equals(pd.Series([6] * 10))) + + del theta_est["samples"] + + # apply confidence region test + CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) + + self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) + self.assertEqual(CR[0.5].sum(), 5) + self.assertEqual(CR[0.75].sum(), 7) + self.assertEqual(CR[1.0].sum(), 10) # all true + + graphics.pairwise_plot(theta_est) + graphics.pairwise_plot(theta_est, thetavals) + graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_likelihood_ratio(self): + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) + + self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) + self.assertEqual(LR[0.8].sum(), 6) + self.assertEqual(LR[0.9].sum(), 10) + self.assertEqual(LR[1.0].sum(), 60) # all true + + graphics.pairwise_plot(LR, thetavals, 0.8) + + def test_leaveNout(self): + lNo_theta = self.pest.theta_est_leaveNout(1) + self.assertTrue(lNo_theta.shape == (6, 2)) + + results = self.pest.leaveNout_bootstrap_test( + 1, None, 3, "Rect", [0.5, 1.0], seed=5436 + ) + self.assertEqual(len(results), 6) # 6 lNo samples + i = 1 + samples = results[i][0] # list of N samples that are left out + lno_theta = results[i][1] + bootstrap_theta = results[i][2] + self.assertTrue(samples == [1]) # sample 1 was left out + self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 + self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) + self.assertEqual(lno_theta[1.0].sum(), 1) # all true + self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 + self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true + + def test_diagnostic_mode(self): + self.pest.diagnostic_mode = True + + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) + + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + self.pest.diagnostic_mode = False + + @unittest.skip("Presently having trouble with mpiexec on appveyor") + def test_parallel_parmest(self): + """use mpiexec and mpi4py""" + p = str(parmestbase.__path__) + l = p.find("'") + r = p.find("'", l + 1) + parmestpath = p[l + 1 : r] + rbpath = ( + parmestpath + + os.sep + + "examples" + + os.sep + + "rooney_biegler" + + os.sep + + "rooney_biegler_parmest.py" + ) + rbpath = os.path.abspath(rbpath) # paranoia strikes deep... + rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] + if sys.version_info >= (3, 5): + ret = subprocess.run(rlist) + retcode = ret.returncode + else: + retcode = subprocess.call(rlist) + self.assertEqual(retcode, 0) + + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def test_theta_est_cov(self): + objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + # Covariance matrix + self.assertAlmostEqual( + cov["asymptote"]["asymptote"], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov["asymptote"]["rate_constant"], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov["rate_constant"]["asymptote"], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov["rate_constant"]["rate_constant"], 0.04124, places=2 + ) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ + + def test_cov_scipy_least_squares_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + def model(theta, t): + """ + Model to be fitted y = model(theta, t) + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + + Returns: + y: model predictions [need to check paper for units] + """ + asymptote = theta[0] + rate_constant = theta[1] + + return asymptote * (1 - np.exp(-rate_constant * t)) + + def residual(theta, t, y): + """ + Calculate residuals + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + y: dependent variable [?] + """ + return y - model(theta, t) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + ## solve with optimize.least_squares + sol = scipy.optimize.least_squares( + residual, theta_guess, method="trf", args=(t, y), verbose=2 + ) + theta_hat = sol.x + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + # calculate residuals + r = residual(theta_hat, t, y) + + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + def test_cov_scipy_curve_fit_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + ## solve with optimize.curve_fit + def model(t, asymptote, rate_constant): + return asymptote * (1 - np.exp(-rate_constant * t)) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestModelVariants(unittest.TestCase): + + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( + RooneyBieglerExperiment, + ) + + self.data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + def rooney_biegler_params(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Param(initialize=15, mutable=True) + model.rate_constant = pyo.Param(initialize=0.5, mutable=True) + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentParams(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_params(data_df) + + rooney_biegler_params_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_params_exp_list.append( + RooneyBieglerExperimentParams(self.data.loc[i, :]) + ) + + def rooney_biegler_indexed_params(data): + model = pyo.ConcreteModel() + + model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Param( + model.param_names, + initialize={"asymptote": 15, "rate_constant": 0.5}, + mutable=True, + ) + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentIndexedParams(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_indexed_params(data_df) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.hour, self.data["hour"]), (m.y, self.data["y"])] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) + + rooney_biegler_indexed_params_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_indexed_params_exp_list.append( + RooneyBieglerExperimentIndexedParams(self.data.loc[i, :]) + ) + + def rooney_biegler_vars(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + model.asymptote.fixed = True # parmest will unfix theta variables + model.rate_constant.fixed = True + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentVars(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_vars(data_df) + + rooney_biegler_vars_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_vars_exp_list.append( + RooneyBieglerExperimentVars(self.data.loc[i, :]) + ) + + def rooney_biegler_indexed_vars(data): + model = pyo.ConcreteModel() + + model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Var( + model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} + ) + model.theta["asymptote"].fixed = ( + True # parmest will unfix theta variables, even when they are indexed + ) + model.theta["rate_constant"].fixed = True + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentIndexedVars(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_indexed_vars(data_df) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.hour, self.data["hour"]), (m.y, self.data["y"])] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) + + rooney_biegler_indexed_vars_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_indexed_vars_exp_list.append( + RooneyBieglerExperimentIndexedVars(self.data.loc[i, :]) + ) + + # Sum of squared error function + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 + return expr + + self.objective_function = SSE + + theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T + theta_vals_index = pd.DataFrame( + [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] + ).T + + self.input = { + "param": { + "exp_list": rooney_biegler_params_exp_list, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "param_index": { + "exp_list": rooney_biegler_indexed_params_exp_list, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars": { + "exp_list": rooney_biegler_vars_exp_list, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "vars_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars_quoted_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta['asymptote']", "theta['rate_constant']"], + "theta_vals": theta_vals_index, + }, + "vars_str_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta[asymptote]", "theta[rate_constant]"], + "theta_vals": theta_vals_index, + }, + } + + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def check_rooney_biegler_results(self, objval, cov): + + # get indices in covariance matrix + cov_cols = cov.columns.to_list() + asymptote_index = [idx for idx, s in enumerate(cov_cols) if "asymptote" in s][0] + rate_constant_index = [ + idx for idx, s in enumerate(cov_cols) if "rate_constant" in s + ][0] + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.04193591, places=2 + ) # 0.04124 from paper + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_initialize_parmest_model_option(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): + + for model_type, parmest_input in self.input.items(): + + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesign(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( + ReactorDesignExperiment, + ) + + # Data from the design + data = pd.DataFrame( + data=[ + [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], + [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], + [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], + [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], + [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], + [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], + [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], + [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], + [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], + [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], + [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], + [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], + [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], + [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], + [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], + [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], + [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], + [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], + [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], + ], + columns=["sv", "caf", "ca", "cb", "cc", "cd"], + ) + + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + solver_options = {"max_iter": 6000} + + self.pest = parmest.Estimator( + exp_list, obj_function="SSE", solver_options=solver_options + ) + + def test_theta_est(self): + # used in data reconciliation + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) + self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) + self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) + + def test_return_values(self): + objval, thetavals, data_rec = self.pest.theta_est( + return_values=["ca", "cb", "cc", "cd", "caf"] + ) + self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", ) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesign_DAE(unittest.TestCase): + # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html + + def setUp(self): + def ABC_model(data): + ca_meas = data["ca"] + cb_meas = data["cb"] + cc_meas = data["cc"] + + if isinstance(data, pd.DataFrame): + meas_t = data.index # time index + else: # dictionary + meas_t = list(ca_meas.keys()) # nested dictionary + + ca0 = 1.0 + cb0 = 0.0 + cc0 = 0.0 + + m = pyo.ConcreteModel() + + m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) + m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) + + m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) + + # initialization and bounds + m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) + m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) + m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) + + m.dca = dae.DerivativeVar(m.ca, wrt=m.time) + m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) + m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) + + def _dcarate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dca[t] == -m.k1 * m.ca[t] + + m.dcarate = pyo.Constraint(m.time, rule=_dcarate) + + def _dcbrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] + + m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) + + def _dccrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcc[t] == m.k2 * m.cb[t] + + m.dccrate = pyo.Constraint(m.time, rule=_dccrate) + + def ComputeFirstStageCost_rule(m): + return 0 + + m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) + + def ComputeSecondStageCost_rule(m): + return sum( + (m.ca[t] - ca_meas[t]) ** 2 + + (m.cb[t] - cb_meas[t]) ** 2 + + (m.cc[t] - cc_meas[t]) ** 2 + for t in meas_t + ) + + m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) + + def total_cost_rule(model): + return model.FirstStageCost + model.SecondStageCost + + m.Total_Cost_Objective = pyo.Objective( + rule=total_cost_rule, sense=pyo.minimize + ) + + disc = pyo.TransformationFactory("dae.collocation") + disc.apply_to(m, nfe=20, ncp=2) + + return m + + class ReactorDesignExperimentDAE(Experiment): + + def __init__(self, data): + + self.data = data + self.model = None + + def create_model(self): + self.model = ABC_model(self.data) + + def label_model(self): -import platform + m = self.model -is_osx = platform.mac_ver()[0] != "" + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.ComponentUID(k)) for k in [m.k1, m.k2] + ) -import pyomo.common.unittest as unittest -import sys -import os -import subprocess -from itertools import product + def get_labeled_model(self): + self.create_model() + self.label_model() -import pyomo.contrib.parmest.parmest as parmest -import pyomo.contrib.parmest.graphics as graphics -import pyomo.contrib.parmest as parmestbase -import pyomo.environ as pyo -import pyomo.dae as dae + return self.model -from pyomo.opt import SolverFactory + # This example tests data formatted in 3 ways + # Each format holds 1 scenario + # 1. dataframe with time index + # 2. nested dictionary {ca: {t, val pairs}, ... } + data = [ + [0.000, 0.957, -0.031, -0.015], + [0.263, 0.557, 0.330, 0.044], + [0.526, 0.342, 0.512, 0.156], + [0.789, 0.224, 0.499, 0.310], + [1.053, 0.123, 0.428, 0.454], + [1.316, 0.079, 0.396, 0.556], + [1.579, 0.035, 0.303, 0.651], + [1.842, 0.029, 0.287, 0.658], + [2.105, 0.025, 0.221, 0.750], + [2.368, 0.017, 0.148, 0.854], + [2.632, -0.002, 0.182, 0.845], + [2.895, 0.009, 0.116, 0.893], + [3.158, -0.023, 0.079, 0.942], + [3.421, 0.006, 0.078, 0.899], + [3.684, 0.016, 0.059, 0.942], + [3.947, 0.014, 0.036, 0.991], + [4.211, -0.009, 0.014, 0.988], + [4.474, -0.030, 0.036, 0.941], + [4.737, 0.004, 0.036, 0.971], + [5.000, -0.024, 0.028, 0.985], + ] + data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) + data_df = data.set_index("t") + data_dict = { + "ca": {k: v for (k, v) in zip(data.t, data.ca)}, + "cb": {k: v for (k, v) in zip(data.t, data.cb)}, + "cc": {k: v for (k, v) in zip(data.t, data.cc)}, + } -ipopt_available = SolverFactory("ipopt").available() + # Create an experiment list + exp_list_df = [ReactorDesignExperimentDAE(data_df)] + exp_list_dict = [ReactorDesignExperimentDAE(data_dict)] + + self.pest_df = parmest.Estimator(exp_list_df) + self.pest_dict = parmest.Estimator(exp_list_dict) + + # Estimator object with multiple scenarios + exp_list_df_multiple = [ + ReactorDesignExperimentDAE(data_df), + ReactorDesignExperimentDAE(data_df), + ] + exp_list_dict_multiple = [ + ReactorDesignExperimentDAE(data_dict), + ReactorDesignExperimentDAE(data_dict), + ] + + self.pest_df_multiple = parmest.Estimator(exp_list_df_multiple) + self.pest_dict_multiple = parmest.Estimator(exp_list_dict_multiple) + + # Create an instance of the model + self.m_df = ABC_model(data_df) + self.m_dict = ABC_model(data_dict) + + def test_dataformats(self): + obj1, theta1 = self.pest_df.theta_est() + obj2, theta2 = self.pest_dict.theta_est() + + self.assertAlmostEqual(obj1, obj2, places=6) + self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) + self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) + + def test_return_continuous_set(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) + obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) + self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) + + def test_return_continuous_set_multiple_datasets(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( + return_values=["time"] + ) + obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( + return_values=["time"] + ) + self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_covariance(self): + from pyomo.contrib.interior_point.inverse_reduced_hessian import ( + inv_reduced_hessian_barrier, + ) + + # Number of datapoints. + # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 + # In this example, this is the number of data points in data_df, but that's + # only because the data is indexed by time and contains no additional information. + n = 60 + + # Compute covariance using parmest + obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) -from pyomo.common.fileutils import find_library + # Compute covariance using interior_point + vars_list = [self.m_df.k1, self.m_df.k2] + solve_result, inv_red_hes = inv_reduced_hessian_barrier( + self.m_df, independent_variables=vars_list, tee=True + ) + l = len(vars_list) + cov_interior_point = 2 * obj / (n - l) * inv_red_hes + cov_interior_point = pd.DataFrame( + cov_interior_point, ["k1", "k2"], ["k1", "k2"] + ) -pynumero_ASL_available = False if find_library("pynumero_ASL") is None else True + cov_diff = (cov - cov_interior_point).abs().sum().sum() -testdir = os.path.dirname(os.path.abspath(__file__)) + self.assertTrue(cov.loc["k1", "k1"] > 0) + self.assertTrue(cov.loc["k2", "k2"] > 0) + self.assertAlmostEqual(cov_diff, 0, places=6) @unittest.skipIf( @@ -52,11 +937,115 @@ "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestRooneyBiegler(unittest.TestCase): +class TestSquareInitialization_RooneyBiegler(unittest.TestCase): def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - rooney_biegler_model, + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler_with_constraint import ( + RooneyBieglerExperiment, + ) + + # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + # Sum of squared error function + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 + return expr + + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + exp_list, obj_function=SSE, solver_options=solver_options, tee=True + ) + + def test_theta_est_with_square_initialization(self): + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + def test_theta_est_with_square_initialization_and_custom_init_theta(self): + theta_vals_init = pd.DataFrame( + data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] + ) + obj_init = self.pest.objective_at_theta( + theta_values=theta_vals_init, initialize_parmest_model=True ) + objval, thetavals = self.pest.theta_est() + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + def test_theta_est_with_square_initialization_diagnostic_mode_true(self): + self.pest.diagnostic_mode = True + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + self.pest.diagnostic_mode = False + + +########################### +# tests for deprecated UI # +########################### + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestRooneyBieglerDeprecated(unittest.TestCase): + def setUp(self): + + def rooney_biegler_model(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + def SSE_rule(m): + return sum( + (data.y[i] - m.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + + model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) + + return model # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) data = pd.DataFrame( @@ -132,7 +1121,7 @@ def test_likelihood_ratio(self): asym = np.arange(10, 30, 2) rate = np.arange(0, 1.5, 0.25) theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=self.pest.theta_names + list(product(asym, rate)), columns=self.pest._return_theta_names() ) obj_at_theta = self.pest.objective_at_theta(theta_vals) @@ -173,7 +1162,7 @@ def test_diagnostic_mode(self): asym = np.arange(10, 30, 2) rate = np.arange(0, 1.5, 0.25) theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=self.pest.theta_names + list(product(asym, rate)), columns=self.pest._return_theta_names() ) obj_at_theta = self.pest.objective_at_theta(theta_vals) @@ -205,17 +1194,7 @@ def test_parallel_parmest(self): retcode = subprocess.call(rlist) assert retcode == 0 - @unittest.skip("Most folks don't have k_aug installed") - def test_theta_k_aug_for_Hessian(self): - # this will fail if k_aug is not installed - objval, thetavals, Hessian = self.pest.theta_est(solver="k_aug") - self.assertAlmostEqual(objval, 4.4675, places=2) - - @unittest.skipIf(not pynumero_ASL_available, "pynumero ASL is not available") - @unittest.skipIf( - not parmest.inverse_reduced_hessian_available, - "Cannot test covariance matrix: required ASL dependency is missing", - ) + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") def test_theta_est_cov(self): objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) @@ -347,7 +1326,7 @@ def model(t, asymptote, rate_constant): "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestModelVariants(unittest.TestCase): +class TestModelVariantsDeprecated(unittest.TestCase): def setUp(self): self.data = pd.DataFrame( data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], @@ -473,11 +1452,7 @@ def SSE(model, data): }, } - @unittest.skipIf(not pynumero_ASL_available, "pynumero ASL is not available") - @unittest.skipIf( - not parmest.inverse_reduced_hessian_available, - "Cannot test covariance matrix: required ASL dependency is missing", - ) + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") def test_parmest_basics(self): for model_type, parmest_input in self.input.items(): pest = parmest.Estimator( @@ -506,6 +1481,7 @@ def test_parmest_basics(self): obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_initialize_parmest_model_option(self): for model_type, parmest_input in self.input.items(): pest = parmest.Estimator( @@ -537,6 +1513,7 @@ def test_parmest_basics_with_initialize_parmest_model_option(self): self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_square_problem_solve(self): for model_type, parmest_input in self.input.items(): pest = parmest.Estimator( @@ -568,6 +1545,7 @@ def test_parmest_basics_with_square_problem_solve(self): self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): for model_type, parmest_input in self.input.items(): pest = parmest.Estimator( @@ -601,11 +1579,84 @@ def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesign(unittest.TestCase): +class TestReactorDesignDeprecated(unittest.TestCase): def setUp(self): - from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, - ) + + def reactor_design_model(data): + # Create the concrete model + model = pyo.ConcreteModel() + + # Rate constants + model.k1 = pyo.Param( + initialize=5.0 / 6.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k2 = pyo.Param( + initialize=5.0 / 3.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k3 = pyo.Param( + initialize=1.0 / 6000.0, within=pyo.PositiveReals, mutable=True + ) # m^3/(gmol min) + + # Inlet concentration of A, gmol/m^3 + if isinstance(data, dict) or isinstance(data, pd.Series): + model.caf = pyo.Param( + initialize=float(data["caf"]), within=pyo.PositiveReals + ) + elif isinstance(data, pd.DataFrame): + model.caf = pyo.Param( + initialize=float(data.iloc[0]["caf"]), within=pyo.PositiveReals + ) + else: + raise ValueError("Unrecognized data type.") + + # Space velocity (flowrate/volume) + if isinstance(data, dict) or isinstance(data, pd.Series): + model.sv = pyo.Param( + initialize=float(data["sv"]), within=pyo.PositiveReals + ) + elif isinstance(data, pd.DataFrame): + model.sv = pyo.Param( + initialize=float(data.iloc[0]["sv"]), within=pyo.PositiveReals + ) + else: + raise ValueError("Unrecognized data type.") + + # Outlet concentration of each component + model.ca = pyo.Var(initialize=5000.0, within=pyo.PositiveReals) + model.cb = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cc = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cd = pyo.Var(initialize=1000.0, within=pyo.PositiveReals) + + # Objective + model.obj = pyo.Objective(expr=model.cb, sense=pyo.maximize) + + # Constraints + model.ca_bal = pyo.Constraint( + expr=( + 0 + == model.sv * model.caf + - model.sv * model.ca + - model.k1 * model.ca + - 2.0 * model.k3 * model.ca**2.0 + ) + ) + + model.cb_bal = pyo.Constraint( + expr=( + 0 + == -model.sv * model.cb + model.k1 * model.ca - model.k2 * model.cb + ) + ) + + model.cc_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cc + model.k2 * model.cb) + ) + + model.cd_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cd + model.k3 * model.ca**2.0) + ) + + return model # Data from the design data = pd.DataFrame( @@ -670,7 +1721,7 @@ def test_return_values(self): "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesign_DAE(unittest.TestCase): +class TestReactorDesign_DAE_Deprecated(unittest.TestCase): # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html @@ -838,6 +1889,7 @@ def test_return_continuous_set_multiple_datasets(self): self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_covariance(self): from pyomo.contrib.interior_point.inverse_reduced_hessian import ( inv_reduced_hessian_barrier, @@ -875,11 +1927,35 @@ def test_covariance(self): "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestSquareInitialization_RooneyBiegler(unittest.TestCase): +class TestSquareInitialization_RooneyBiegler_Deprecated(unittest.TestCase): def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler_with_constraint import ( - rooney_biegler_model_with_constraint, - ) + + def rooney_biegler_model_with_constraint(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + model.response_function = pyo.Var(data.hour, initialize=0.0) + + # changed from expression to constraint + def response_rule(m, h): + return m.response_function[h] == m.asymptote * ( + 1 - pyo.exp(-m.rate_constant * h) + ) + + model.response_function_constraint = pyo.Constraint( + data.hour, rule=response_rule + ) + + def SSE_rule(m): + return sum( + (data.y[i] - m.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + + model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) + + return model # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) data = pd.DataFrame( diff --git a/pyomo/contrib/parmest/tests/test_scenariocreator.py b/pyomo/contrib/parmest/tests/test_scenariocreator.py index 7db7d0ed5db..a34502cf489 100644 --- a/pyomo/contrib/parmest/tests/test_scenariocreator.py +++ b/pyomo/contrib/parmest/tests/test_scenariocreator.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -37,7 +37,7 @@ class TestScenarioReactorDesign(unittest.TestCase): def setUp(self): from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, + ReactorDesignExperiment, ) # Data from the design @@ -66,6 +66,193 @@ def setUp(self): columns=["sv", "caf", "ca", "cb", "cc", "cd"], ) + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + self.pest = parmest.Estimator(exp_list, obj_function='SSE') + + def test_scen_from_exps(self): + scenmaker = sc.ScenarioCreator(self.pest, "ipopt") + experimentscens = sc.ScenarioSet("Experiments") + scenmaker.ScenariosFromExperiments(experimentscens) + experimentscens.write_csv("delme_exp_csv.csv") + df = pd.read_csv("delme_exp_csv.csv") + os.remove("delme_exp_csv.csv") + # March '20: all reactor_design experiments have the same theta values! + k1val = df.loc[5].at["k1"] + self.assertAlmostEqual(k1val, 5.0 / 6.0, places=2) + tval = experimentscens.ScenarioNumber(0).ThetaVals["k1"] + self.assertAlmostEqual(tval, 5.0 / 6.0, places=2) + + @unittest.skipIf(not uuid_available, "The uuid module is not available") + def test_no_csv_if_empty(self): + # low level test of scenario sets + # verify that nothing is written, but no errors with empty set + + emptyset = sc.ScenarioSet("empty") + tfile = uuid.uuid4().hex + ".csv" + emptyset.write_csv(tfile) + self.assertFalse( + os.path.exists(tfile), "ScenarioSet wrote csv in spite of empty set" + ) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestScenarioSemibatch(unittest.TestCase): + def setUp(self): + import pyomo.contrib.parmest.examples.semibatch.semibatch as sb + import json + + self.fbase = os.path.join(testdir, "..", "examples", "semibatch") + # Data, list of dictionaries + data = [] + for exp_num in range(10): + fname = "exp" + str(exp_num + 1) + ".out" + fullname = os.path.join(self.fbase, fname) + with open(fullname, "r") as infile: + d = json.load(infile) + data.append(d) + + # Note, the model already includes a 'SecondStageCost' expression + # for the sum of squared error that will be used in parameter estimation + + # Create an experiment list + exp_list = [] + for i in range(len(data)): + exp_list.append(sb.SemiBatchExperiment(data[i])) + + self.pest = parmest.Estimator(exp_list) + + def test_semibatch_bootstrap(self): + scenmaker = sc.ScenarioCreator(self.pest, "ipopt") + bootscens = sc.ScenarioSet("Bootstrap") + numtomake = 2 + scenmaker.ScenariosFromBootstrap(bootscens, numtomake, seed=1134) + tval = bootscens.ScenarioNumber(0).ThetaVals["k1"] + self.assertAlmostEqual(tval, 20.64, places=1) + + +########################### +# tests for deprecated UI # +########################### + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestScenarioReactorDesignDeprecated(unittest.TestCase): + def setUp(self): + + def reactor_design_model(data): + # Create the concrete model + model = pyo.ConcreteModel() + + # Rate constants + model.k1 = pyo.Param( + initialize=5.0 / 6.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k2 = pyo.Param( + initialize=5.0 / 3.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k3 = pyo.Param( + initialize=1.0 / 6000.0, within=pyo.PositiveReals, mutable=True + ) # m^3/(gmol min) + + # Inlet concentration of A, gmol/m^3 + if isinstance(data, dict) or isinstance(data, pd.Series): + model.caf = pyo.Param( + initialize=float(data["caf"]), within=pyo.PositiveReals + ) + elif isinstance(data, pd.DataFrame): + model.caf = pyo.Param( + initialize=float(data.iloc[0]["caf"]), within=pyo.PositiveReals + ) + else: + raise ValueError("Unrecognized data type.") + + # Space velocity (flowrate/volume) + if isinstance(data, dict) or isinstance(data, pd.Series): + model.sv = pyo.Param( + initialize=float(data["sv"]), within=pyo.PositiveReals + ) + elif isinstance(data, pd.DataFrame): + model.sv = pyo.Param( + initialize=float(data.iloc[0]["sv"]), within=pyo.PositiveReals + ) + else: + raise ValueError("Unrecognized data type.") + + # Outlet concentration of each component + model.ca = pyo.Var(initialize=5000.0, within=pyo.PositiveReals) + model.cb = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cc = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cd = pyo.Var(initialize=1000.0, within=pyo.PositiveReals) + + # Objective + model.obj = pyo.Objective(expr=model.cb, sense=pyo.maximize) + + # Constraints + model.ca_bal = pyo.Constraint( + expr=( + 0 + == model.sv * model.caf + - model.sv * model.ca + - model.k1 * model.ca + - 2.0 * model.k3 * model.ca**2.0 + ) + ) + + model.cb_bal = pyo.Constraint( + expr=( + 0 + == -model.sv * model.cb + model.k1 * model.ca - model.k2 * model.cb + ) + ) + + model.cc_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cc + model.k2 * model.cb) + ) + + model.cd_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cd + model.k3 * model.ca**2.0) + ) + + return model + + # Data from the design + data = pd.DataFrame( + data=[ + [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], + [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], + [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], + [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], + [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], + [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], + [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], + [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], + [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], + [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], + [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], + [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], + [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], + [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], + [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], + [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], + [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], + [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], + [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], + ], + columns=["sv", "caf", "ca", "cb", "cc", "cd"], + ) + theta_names = ["k1", "k2", "k3"] def SSE(model, data): @@ -110,10 +297,267 @@ def test_no_csv_if_empty(self): "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestScenarioSemibatch(unittest.TestCase): +class TestScenarioSemibatchDeprecated(unittest.TestCase): def setUp(self): - import pyomo.contrib.parmest.examples.semibatch.semibatch as sb + import json + from pyomo.environ import ( + ConcreteModel, + Set, + Param, + Var, + Constraint, + ConstraintList, + Expression, + Objective, + TransformationFactory, + SolverFactory, + exp, + minimize, + ) + from pyomo.dae import ContinuousSet, DerivativeVar + + def generate_model(data): + # if data is a file name, then load file first + if isinstance(data, str): + file_name = data + try: + with open(file_name, "r") as infile: + data = json.load(infile) + except: + raise RuntimeError(f"Could not read {file_name} as json") + + # unpack and fix the data + cameastemp = data["Ca_meas"] + cbmeastemp = data["Cb_meas"] + ccmeastemp = data["Cc_meas"] + trmeastemp = data["Tr_meas"] + + cameas = {} + cbmeas = {} + ccmeas = {} + trmeas = {} + for i in cameastemp.keys(): + cameas[float(i)] = cameastemp[i] + cbmeas[float(i)] = cbmeastemp[i] + ccmeas[float(i)] = ccmeastemp[i] + trmeas[float(i)] = trmeastemp[i] + + m = ConcreteModel() + + # + # Measurement Data + # + m.measT = Set(initialize=sorted(cameas.keys())) + m.Ca_meas = Param(m.measT, initialize=cameas) + m.Cb_meas = Param(m.measT, initialize=cbmeas) + m.Cc_meas = Param(m.measT, initialize=ccmeas) + m.Tr_meas = Param(m.measT, initialize=trmeas) + + # + # Parameters for semi-batch reactor model + # + m.R = Param(initialize=8.314) # kJ/kmol/K + m.Mwa = Param(initialize=50.0) # kg/kmol + m.rhor = Param(initialize=1000.0) # kg/m^3 + m.cpr = Param(initialize=3.9) # kJ/kg/K + m.Tf = Param(initialize=300) # K + m.deltaH1 = Param(initialize=-40000.0) # kJ/kmol + m.deltaH2 = Param(initialize=-50000.0) # kJ/kmol + m.alphaj = Param(initialize=0.8) # kJ/s/m^2/K + m.alphac = Param(initialize=0.7) # kJ/s/m^2/K + m.Aj = Param(initialize=5.0) # m^2 + m.Ac = Param(initialize=3.0) # m^2 + m.Vj = Param(initialize=0.9) # m^3 + m.Vc = Param(initialize=0.07) # m^3 + m.rhow = Param(initialize=700.0) # kg/m^3 + m.cpw = Param(initialize=3.1) # kJ/kg/K + m.Ca0 = Param(initialize=data["Ca0"]) # kmol/m^3) + m.Cb0 = Param(initialize=data["Cb0"]) # kmol/m^3) + m.Cc0 = Param(initialize=data["Cc0"]) # kmol/m^3) + m.Tr0 = Param(initialize=300.0) # K + m.Vr0 = Param(initialize=1.0) # m^3 + + m.time = ContinuousSet( + bounds=(0, 21600), initialize=m.measT + ) # Time in seconds + + # + # Control Inputs + # + def _initTc(m, t): + if t < 10800: + return data["Tc1"] + else: + return data["Tc2"] + + m.Tc = Param( + m.time, initialize=_initTc, default=_initTc + ) # bounds= (288,432) Cooling coil temp, control input + + def _initFa(m, t): + if t < 10800: + return data["Fa1"] + else: + return data["Fa2"] + + m.Fa = Param( + m.time, initialize=_initFa, default=_initFa + ) # bounds=(0,0.05) Inlet flow rate, control input + + # + # Parameters being estimated + # + m.k1 = Var(initialize=14, bounds=(2, 100)) # 1/s Actual: 15.01 + m.k2 = Var(initialize=90, bounds=(2, 150)) # 1/s Actual: 85.01 + m.E1 = Var( + initialize=27000.0, bounds=(25000, 40000) + ) # kJ/kmol Actual: 30000 + m.E2 = Var( + initialize=45000.0, bounds=(35000, 50000) + ) # kJ/kmol Actual: 40000 + # m.E1.fix(30000) + # m.E2.fix(40000) + + # + # Time dependent variables + # + m.Ca = Var(m.time, initialize=m.Ca0, bounds=(0, 25)) + m.Cb = Var(m.time, initialize=m.Cb0, bounds=(0, 25)) + m.Cc = Var(m.time, initialize=m.Cc0, bounds=(0, 25)) + m.Vr = Var(m.time, initialize=m.Vr0) + m.Tr = Var(m.time, initialize=m.Tr0) + m.Tj = Var( + m.time, initialize=310.0, bounds=(288, None) + ) # Cooling jacket temp, follows coil temp until failure + + # + # Derivatives in the model + # + m.dCa = DerivativeVar(m.Ca) + m.dCb = DerivativeVar(m.Cb) + m.dCc = DerivativeVar(m.Cc) + m.dVr = DerivativeVar(m.Vr) + m.dTr = DerivativeVar(m.Tr) + + # + # Differential Equations in the model + # + + def _dCacon(m, t): + if t == 0: + return Constraint.Skip + return ( + m.dCa[t] + == m.Fa[t] / m.Vr[t] - m.k1 * exp(-m.E1 / (m.R * m.Tr[t])) * m.Ca[t] + ) + + m.dCacon = Constraint(m.time, rule=_dCacon) + + def _dCbcon(m, t): + if t == 0: + return Constraint.Skip + return ( + m.dCb[t] + == m.k1 * exp(-m.E1 / (m.R * m.Tr[t])) * m.Ca[t] + - m.k2 * exp(-m.E2 / (m.R * m.Tr[t])) * m.Cb[t] + ) + + m.dCbcon = Constraint(m.time, rule=_dCbcon) + + def _dCccon(m, t): + if t == 0: + return Constraint.Skip + return m.dCc[t] == m.k2 * exp(-m.E2 / (m.R * m.Tr[t])) * m.Cb[t] + + m.dCccon = Constraint(m.time, rule=_dCccon) + + def _dVrcon(m, t): + if t == 0: + return Constraint.Skip + return m.dVr[t] == m.Fa[t] * m.Mwa / m.rhor + + m.dVrcon = Constraint(m.time, rule=_dVrcon) + + def _dTrcon(m, t): + if t == 0: + return Constraint.Skip + return m.rhor * m.cpr * m.dTr[t] == m.Fa[t] * m.Mwa * m.cpr / m.Vr[ + t + ] * (m.Tf - m.Tr[t]) - m.k1 * exp(-m.E1 / (m.R * m.Tr[t])) * m.Ca[ + t + ] * m.deltaH1 - m.k2 * exp( + -m.E2 / (m.R * m.Tr[t]) + ) * m.Cb[ + t + ] * m.deltaH2 + m.alphaj * m.Aj / m.Vr0 * ( + m.Tj[t] - m.Tr[t] + ) + m.alphac * m.Ac / m.Vr0 * ( + m.Tc[t] - m.Tr[t] + ) + + m.dTrcon = Constraint(m.time, rule=_dTrcon) + + def _singlecooling(m, t): + return m.Tc[t] == m.Tj[t] + + m.singlecooling = Constraint(m.time, rule=_singlecooling) + + # Initial Conditions + def _initcon(m): + yield m.Ca[m.time.first()] == m.Ca0 + yield m.Cb[m.time.first()] == m.Cb0 + yield m.Cc[m.time.first()] == m.Cc0 + yield m.Vr[m.time.first()] == m.Vr0 + yield m.Tr[m.time.first()] == m.Tr0 + + m.initcon = ConstraintList(rule=_initcon) + + # + # Stage-specific cost computations + # + def ComputeFirstStageCost_rule(model): + return 0 + + m.FirstStageCost = Expression(rule=ComputeFirstStageCost_rule) + + def AllMeasurements(m): + return sum( + (m.Ca[t] - m.Ca_meas[t]) ** 2 + + (m.Cb[t] - m.Cb_meas[t]) ** 2 + + (m.Cc[t] - m.Cc_meas[t]) ** 2 + + 0.01 * (m.Tr[t] - m.Tr_meas[t]) ** 2 + for t in m.measT + ) + + def MissingMeasurements(m): + if data["experiment"] == 1: + return sum( + (m.Ca[t] - m.Ca_meas[t]) ** 2 + + (m.Cb[t] - m.Cb_meas[t]) ** 2 + + (m.Cc[t] - m.Cc_meas[t]) ** 2 + + (m.Tr[t] - m.Tr_meas[t]) ** 2 + for t in m.measT + ) + elif data["experiment"] == 2: + return sum((m.Tr[t] - m.Tr_meas[t]) ** 2 for t in m.measT) + else: + return sum( + (m.Cb[t] - m.Cb_meas[t]) ** 2 + (m.Tr[t] - m.Tr_meas[t]) ** 2 + for t in m.measT + ) + + m.SecondStageCost = Expression(rule=MissingMeasurements) + + def total_cost_rule(model): + return model.FirstStageCost + model.SecondStageCost + + m.Total_Cost_Objective = Objective(rule=total_cost_rule, sense=minimize) + + # Discretize model + disc = TransformationFactory("dae.collocation") + disc.apply_to(m, nfe=20, ncp=4) + return m # Vars to estimate in parmest theta_names = ["k1", "k2", "E1", "E2"] @@ -131,7 +575,7 @@ def setUp(self): # Note, the model already includes a 'SecondStageCost' expression # for the sum of squared error that will be used in parameter estimation - self.pest = parmest.Estimator(sb.generate_model, data, theta_names) + self.pest = parmest.Estimator(generate_model, data, theta_names) def test_semibatch_bootstrap(self): scenmaker = sc.ScenarioCreator(self.pest, "ipopt") diff --git a/pyomo/contrib/parmest/tests/test_solver.py b/pyomo/contrib/parmest/tests/test_solver.py index 77eca3a13b6..238a1becaa0 100644 --- a/pyomo/contrib/parmest/tests/test_solver.py +++ b/pyomo/contrib/parmest/tests/test_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/tests/test_utils.py b/pyomo/contrib/parmest/tests/test_utils.py index e75cc9d3bcd..f29f04e1d15 100644 --- a/pyomo/contrib/parmest/tests/test_utils.py +++ b/pyomo/contrib/parmest/tests/test_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,18 +25,12 @@ ) @unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") class TestUtils(unittest.TestCase): - @classmethod - def setUpClass(self): - pass - @classmethod - def tearDownClass(self): - pass - - @unittest.pytest.mark.expensive def test_convert_param_to_var(self): + # TODO: Check that this works for different structured models (indexed, blocks, etc) + from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - reactor_design_model, + ReactorDesignExperiment, ) data = pd.DataFrame( @@ -48,20 +42,23 @@ def test_convert_param_to_var(self): columns=["sv", "caf", "ca", "cb", "cc", "cd"], ) - theta_names = ["k1", "k2", "k3"] - - instance = reactor_design_model(data.loc[0]) - solver = pyo.SolverFactory("ipopt") - solver.solve(instance) + # make model + exp = ReactorDesignExperiment(data, 0) + instance = exp.get_labeled_model() - instance_vars = parmest.utils.convert_params_to_vars( + theta_names = ['k1', 'k2', 'k3'] + m_vars = parmest.utils.convert_params_to_vars( instance, theta_names, fix_vars=True ) - solver.solve(instance_vars) - assert instance.k1() == instance_vars.k1() - assert instance.k2() == instance_vars.k2() - assert instance.k3() == instance_vars.k3() + for v in theta_names: + self.assertTrue(hasattr(m_vars, v)) + c = m_vars.find_component(v) + self.assertIsInstance(c, pyo.Var) + self.assertTrue(c.fixed) + c_old = instance.find_component(v) + self.assertEqual(pyo.value(c), pyo.value(c_old)) + self.assertTrue(c in m_vars.unknown_parameters) if __name__ == "__main__": diff --git a/pyomo/contrib/parmest/utils/__init__.py b/pyomo/contrib/parmest/utils/__init__.py index 3c6900aa5d9..a3451a4b84b 100644 --- a/pyomo/contrib/parmest/utils/__init__.py +++ b/pyomo/contrib/parmest/utils/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/utils/create_ef.py b/pyomo/contrib/parmest/utils/create_ef.py index aaadc7f98b9..a85c22f9322 100644 --- a/pyomo/contrib/parmest/utils/create_ef.py +++ b/pyomo/contrib/parmest/utils/create_ef.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/utils/ipopt_solver_wrapper.py b/pyomo/contrib/parmest/utils/ipopt_solver_wrapper.py index 08388dc5ec1..61ad3c9bd80 100644 --- a/pyomo/contrib/parmest/utils/ipopt_solver_wrapper.py +++ b/pyomo/contrib/parmest/utils/ipopt_solver_wrapper.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/utils/model_utils.py b/pyomo/contrib/parmest/utils/model_utils.py index 77491f74b02..a604e912b86 100644 --- a/pyomo/contrib/parmest/utils/model_utils.py +++ b/pyomo/contrib/parmest/utils/model_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,6 +15,7 @@ from pyomo.core.expr import replace_expressions, identify_mutable_parameters from pyomo.core.base.var import IndexedVar from pyomo.core.base.param import IndexedParam +from pyomo.common.collections import ComponentMap from pyomo.environ import ComponentUID @@ -49,6 +50,7 @@ def convert_params_to_vars(model, param_names=None, fix_vars=False): # Convert Params to Vars, unfix Vars, and create a substitution map substitution_map = {} + comp_map = ComponentMap() for i, param_name in enumerate(param_names): # Leverage the parser in ComponentUID to locate the component. theta_cuid = ComponentUID(param_name) @@ -65,6 +67,7 @@ def convert_params_to_vars(model, param_names=None, fix_vars=False): theta_var_cuid = ComponentUID(theta_object.name) theta_var_object = theta_var_cuid.find_component_on(model) substitution_map[id(theta_object)] = theta_var_object + comp_map[theta_object] = theta_var_object # Indexed Param elif isinstance(theta_object, IndexedParam): @@ -90,6 +93,7 @@ def convert_params_to_vars(model, param_names=None, fix_vars=False): # Update substitution map (map each indexed param to indexed var) theta_var_cuid = ComponentUID(theta_object.name) theta_var_object = theta_var_cuid.find_component_on(model) + comp_map[theta_object] = theta_var_object var_theta_objects = [] for theta_obj in theta_var_object: theta_cuid = ComponentUID( @@ -101,6 +105,7 @@ def convert_params_to_vars(model, param_names=None, fix_vars=False): param_theta_objects, var_theta_objects ): substitution_map[id(param_theta_obj)] = var_theta_obj + comp_map[param_theta_obj] = var_theta_obj # Var or Indexed Var elif isinstance(theta_object, IndexedVar) or theta_object.is_variable_type(): @@ -182,6 +187,15 @@ def convert_params_to_vars(model, param_names=None, fix_vars=False): model.del_component(obj) model.add_component(obj.name, pyo.Objective(rule=expr, sense=obj.sense)) + # Convert Params to Vars in Suffixes + for s in model.component_objects(pyo.Suffix): + current_keys = list(s.keys()) + for c in current_keys: + if c in comp_map: + s[comp_map[c]] = s.pop(c) + + assert len(current_keys) == len(s.keys()) + # print('--- Updated Model ---') # model.pprint() # solver = pyo.SolverFactory('ipopt') diff --git a/pyomo/contrib/parmest/utils/mpi_utils.py b/pyomo/contrib/parmest/utils/mpi_utils.py index 45e3260117d..c6ba198b408 100644 --- a/pyomo/contrib/parmest/utils/mpi_utils.py +++ b/pyomo/contrib/parmest/utils/mpi_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/parmest/utils/scenario_tree.py b/pyomo/contrib/parmest/utils/scenario_tree.py index e71f51877b5..1ddb263b057 100644 --- a/pyomo/contrib/parmest/utils/scenario_tree.py +++ b/pyomo/contrib/parmest/utils/scenario_tree.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,7 +25,7 @@ def build_vardatalist(self, model, varlist=None): """ - Convert a list of pyomo variables to a list of ScalarVar and _GeneralVarData. If varlist is none, builds a + Convert a list of pyomo variables to a list of ScalarVar and VarData. If varlist is none, builds a list of all variables in the model. The new list is stored in the vars_to_tighten attribute. By CD Laird Parameters diff --git a/pyomo/contrib/piecewise/__init__.py b/pyomo/contrib/piecewise/__init__.py index 37873c83b3b..e402628b423 100644 --- a/pyomo/contrib/piecewise/__init__.py +++ b/pyomo/contrib/piecewise/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -33,3 +33,15 @@ from pyomo.contrib.piecewise.transform.convex_combination import ( ConvexCombinationTransformation, ) +from pyomo.contrib.piecewise.transform.nonlinear_to_pwl import ( + DomainPartitioningMethod, + NonlinearToPWL, +) +from pyomo.contrib.piecewise.transform.nested_inner_repn import ( + NestedInnerRepresentationGDPTransformation, +) +from pyomo.contrib.piecewise.transform.disaggregated_logarithmic import ( + DisaggregatedLogarithmicMIPTransformation, +) +from pyomo.contrib.piecewise.transform.incremental import IncrementalMIPTransformation +from pyomo.contrib.piecewise.triangulations import Triangulation diff --git a/pyomo/contrib/piecewise/ordered_3d_j1_triangulation_data.py b/pyomo/contrib/piecewise/ordered_3d_j1_triangulation_data.py new file mode 100644 index 00000000000..ae34f6201c2 --- /dev/null +++ b/pyomo/contrib/piecewise/ordered_3d_j1_triangulation_data.py @@ -0,0 +1,3119 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import networkx as nx +import itertools + + +def _get_double_cube_graph(): + # Graph of a double cube + sign_vecs = list(itertools.product((-1, 1), repeat=3)) + permutations = itertools.permutations(range(1, 4)) + simplices = list(itertools.product(sign_vecs, permutations)) + + G = nx.Graph() + G.add_nodes_from(simplices) + for s in sign_vecs: + # interior connectivity of cubes + G.add_edges_from( + [ + ((s, (1, 2, 3)), (s, (1, 3, 2))), + ((s, (1, 3, 2)), (s, (3, 1, 2))), + ((s, (3, 1, 2)), (s, (3, 2, 1))), + ((s, (3, 2, 1)), (s, (2, 3, 1))), + ((s, (2, 3, 1)), (s, (2, 1, 3))), + ((s, (2, 1, 3)), (s, (1, 2, 3))), + ] + ) + # connectivity between cubes in double cube + for simplex in simplices: + neighbor_sign = list(simplex[0]) + neighbor_sign[simplex[1][2] - 1] *= -1 + neighbor_simplex = (tuple(neighbor_sign), simplex[1]) + G.add_edge(simplex, neighbor_simplex) + + return G + + +""" +This code was used to generate the data structure in this file. It should never +need to be run again, but is here for the sake of documentation: + +# Get a list of 60 hamiltonian paths used in the 3d version of the ordered J1 +# triangulation, and dump it to stdout. +if __name__ == '__main__': + G = _get_double_cube_graph() + + # Each of these simplices has an outward face in the specified direction; also, + # the +x simplex of one cube is adjacent to the -x simplex of a cube adjacent in + # the x direction, and similarly for the others. + border_simplices = { + # simplices in low-coordinate cube + # -x + ((-1, 0, 0), 1): ((-1, -1, -1), (1, 2, 3)), + ((-1, 0, 0), 2): ((-1, -1, -1), (1, 3, 2)), + # -y + ((0, -1, 0), 1): ((-1, -1, -1), (2, 1, 3)), + ((0, -1, 0), 2): ((-1, -1, -1), (2, 3, 1)), + # -z + ((0, 0, -1), 1): ((-1, -1, -1), (3, 1, 2)), + ((0, 0, -1), 2): ((-1, -1, -1), (3, 2, 1)), + # simplices in one-high-coordinate cubes + # +x + ((1, 0, 0), 1): ((1, -1, -1), (1, 2, 3)), + ((1, 0, 0), 2): ((1, -1, -1), (1, 3, 2)), + # +y + ((0, 1, 0), 1): ((-1, 1, -1), (2, 1, 3)), + ((0, 1, 0), 2): ((-1, 1, -1), (2, 3, 1)), + # +z + ((0, 0, 1), 1): ((-1, -1, 1), (3, 1, 2)), + ((0, 0, 1), 2): ((-1, -1, 1), (3, 2, 1)), + } + + # Need: Hamiltonian paths from each input to some output in each direction + all_needed_hamiltonians = {} + for i, s1 in border_simplices.items(): + for j, s2 in border_simplices.items(): + # I could cut the number of these in half or less via symmetry but + # I don't care + if i[0] != j[0]: + if (i, (j[0], 1)) in all_needed_hamiltonians.keys() or ( + i, + (j[0], 2), + ) in all_needed_hamiltonians.keys(): + print( + f"skipping search for path from {i} to {j} because we have a " + f"path from {i} to {(j[0], 1) if (i, (j[0], 1)) in " + f"all_needed_hamiltonians.keys() else (j[0], 2)}" + ) + continue + print(f"searching for path from {i} to {j}") + for path in nx.all_simple_paths(G, s1, s2): + if len(path) == 48: + # it's hamiltonian! + print(f"found hamiltonian path from {i} to {j}") + all_needed_hamiltonians[(i, j)] = path + break + print(f"done looking for paths from {i} to {j}") + print() + print(all_needed_hamiltonians) + +""" + + +# This file was generated using generate_ordered_3d_j1_triangulation_data.py +# Data format: Keys are a pair of simplices specified as the direction they are facing, +# as a standard unit vector or negative of one, and a tag, 1 or 2, disambiguating which +# of the two simplices considered is used. Values are a list of simplices given as +# (sign_vector, permutation) pairs. +def get_hamiltonian_paths(): + return { + (((-1, 0, 0), 1), ((0, -1, 0), 1)): [ + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ], + (((-1, 0, 0), 1), ((0, 0, -1), 2)): [ + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ], + (((-1, 0, 0), 1), ((1, 0, 0), 1)): [ + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ], + (((-1, 0, 0), 1), ((0, 1, 0), 2)): [ + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ], + (((-1, 0, 0), 1), ((0, 0, 1), 1)): [ + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ], + (((-1, 0, 0), 2), ((0, -1, 0), 2)): [ + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ], + (((-1, 0, 0), 2), ((0, 0, -1), 1)): [ + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ], + (((-1, 0, 0), 2), ((1, 0, 0), 2)): [ + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ], + (((-1, 0, 0), 2), ((0, 1, 0), 1)): [ + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ], + (((-1, 0, 0), 2), ((0, 0, 1), 2)): [ + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ], + (((0, -1, 0), 1), ((-1, 0, 0), 1)): [ + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ], + (((0, -1, 0), 1), ((0, 0, -1), 1)): [ + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ], + (((0, -1, 0), 1), ((1, 0, 0), 2)): [ + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ], + (((0, -1, 0), 1), ((0, 1, 0), 1)): [ + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ], + (((0, -1, 0), 1), ((0, 0, 1), 2)): [ + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ], + (((0, -1, 0), 2), ((-1, 0, 0), 2)): [ + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ], + (((0, -1, 0), 2), ((0, 0, -1), 2)): [ + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ], + (((0, -1, 0), 2), ((1, 0, 0), 1)): [ + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ], + (((0, -1, 0), 2), ((0, 1, 0), 2)): [ + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ], + (((0, -1, 0), 2), ((0, 0, 1), 1)): [ + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ], + (((0, 0, -1), 1), ((-1, 0, 0), 2)): [ + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ], + (((0, 0, -1), 1), ((0, -1, 0), 1)): [ + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ], + (((0, 0, -1), 1), ((1, 0, 0), 1)): [ + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ], + (((0, 0, -1), 1), ((0, 1, 0), 2)): [ + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ], + (((0, 0, -1), 1), ((0, 0, 1), 1)): [ + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ], + (((0, 0, -1), 2), ((-1, 0, 0), 1)): [ + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ], + (((0, 0, -1), 2), ((0, -1, 0), 2)): [ + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ], + (((0, 0, -1), 2), ((1, 0, 0), 2)): [ + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ], + (((0, 0, -1), 2), ((0, 1, 0), 1)): [ + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ], + (((0, 0, -1), 2), ((0, 0, 1), 2)): [ + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ], + (((1, 0, 0), 1), ((-1, 0, 0), 1)): [ + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ], + (((1, 0, 0), 1), ((0, -1, 0), 2)): [ + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ], + (((1, 0, 0), 1), ((0, 0, -1), 1)): [ + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ], + (((1, 0, 0), 1), ((0, 1, 0), 1)): [ + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ], + (((1, 0, 0), 1), ((0, 0, 1), 2)): [ + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ], + (((1, 0, 0), 2), ((-1, 0, 0), 2)): [ + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ], + (((1, 0, 0), 2), ((0, -1, 0), 1)): [ + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ], + (((1, 0, 0), 2), ((0, 0, -1), 2)): [ + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ], + (((1, 0, 0), 2), ((0, 1, 0), 2)): [ + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ], + (((1, 0, 0), 2), ((0, 0, 1), 1)): [ + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ], + (((0, 1, 0), 1), ((-1, 0, 0), 2)): [ + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ], + (((0, 1, 0), 1), ((0, -1, 0), 1)): [ + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ], + (((0, 1, 0), 1), ((0, 0, -1), 2)): [ + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ], + (((0, 1, 0), 1), ((1, 0, 0), 1)): [ + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ], + (((0, 1, 0), 1), ((0, 0, 1), 1)): [ + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ], + (((0, 1, 0), 2), ((-1, 0, 0), 1)): [ + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ], + (((0, 1, 0), 2), ((0, -1, 0), 2)): [ + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ], + (((0, 1, 0), 2), ((0, 0, -1), 1)): [ + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ], + (((0, 1, 0), 2), ((1, 0, 0), 2)): [ + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ], + (((0, 1, 0), 2), ((0, 0, 1), 2)): [ + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (3, 2, 1)), + ], + (((0, 0, 1), 1), ((-1, 0, 0), 1)): [ + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ], + (((0, 0, 1), 1), ((0, -1, 0), 2)): [ + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ], + (((0, 0, 1), 1), ((0, 0, -1), 1)): [ + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ], + (((0, 0, 1), 1), ((1, 0, 0), 2)): [ + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ], + (((0, 0, 1), 1), ((0, 1, 0), 1)): [ + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((-1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ], + (((0, 0, 1), 2), ((-1, 0, 0), 2)): [ + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ], + (((0, 0, 1), 2), ((0, -1, 0), 1)): [ + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 3, 1)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 2, 1)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (2, 1, 3)), + ], + (((0, 0, 1), 2), ((0, 0, -1), 2)): [ + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ], + (((0, 0, 1), 2), ((1, 0, 0), 1)): [ + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (2, 3, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 2, 1)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (1, 2, 3)), + ], + (((0, 0, 1), 2), ((0, 1, 0), 2)): [ + ((-1, -1, 1), (3, 2, 1)), + ((-1, -1, 1), (3, 1, 2)), + ((-1, -1, 1), (1, 3, 2)), + ((-1, -1, 1), (1, 2, 3)), + ((-1, -1, 1), (2, 1, 3)), + ((-1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (2, 3, 1)), + ((1, -1, 1), (3, 2, 1)), + ((1, -1, 1), (3, 1, 2)), + ((1, -1, 1), (1, 3, 2)), + ((1, -1, 1), (1, 2, 3)), + ((1, -1, 1), (2, 1, 3)), + ((1, -1, -1), (2, 1, 3)), + ((1, -1, -1), (1, 2, 3)), + ((1, -1, -1), (1, 3, 2)), + ((1, -1, -1), (3, 1, 2)), + ((1, 1, -1), (3, 1, 2)), + ((1, 1, -1), (1, 3, 2)), + ((1, 1, -1), (1, 2, 3)), + ((1, 1, -1), (2, 1, 3)), + ((1, 1, -1), (2, 3, 1)), + ((1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 2, 1)), + ((-1, 1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 1, 2)), + ((-1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (3, 2, 1)), + ((1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 3, 1)), + ((-1, -1, -1), (2, 1, 3)), + ((-1, -1, -1), (1, 2, 3)), + ((-1, -1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 3, 2)), + ((-1, 1, -1), (1, 2, 3)), + ((-1, 1, 1), (1, 2, 3)), + ((-1, 1, 1), (1, 3, 2)), + ((-1, 1, 1), (3, 1, 2)), + ((-1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 2, 1)), + ((1, 1, 1), (3, 1, 2)), + ((1, 1, 1), (1, 3, 2)), + ((1, 1, 1), (1, 2, 3)), + ((1, 1, 1), (2, 1, 3)), + ((1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 3, 1)), + ((-1, 1, 1), (2, 1, 3)), + ((-1, 1, -1), (2, 1, 3)), + ((-1, 1, -1), (2, 3, 1)), + ], + } diff --git a/pyomo/contrib/piecewise/piecewise_linear_expression.py b/pyomo/contrib/piecewise/piecewise_linear_expression.py index ddcb7c6a42f..0cc3629020b 100644 --- a/pyomo/contrib/piecewise/piecewise_linear_expression.py +++ b/pyomo/contrib/piecewise/piecewise_linear_expression.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,12 +17,15 @@ class PiecewiseLinearExpression(NumericExpression): """ A numeric expression node representing a specific instantiation of a - PiecewiseLinearFunction. + :obj:`~.piecewise_linear_function.PiecewiseLinearFunction`. - Args: - args (list or tuple): Children of this node - pw_linear_function (PiecewiseLinearFunction): piece-wise linear function - of which this node is an instance. + Parameters + ---------- + args : list or tuple + Children of this node + + pw_linear_function : ~piecewise_linear_function.PiecewiseLinearFunction + Piece-wise linear function of which this node is an instance. """ __slots__ = ('_pw_linear_function',) diff --git a/pyomo/contrib/piecewise/piecewise_linear_function.py b/pyomo/contrib/piecewise/piecewise_linear_function.py index 66ca02ad125..97ed2d6bce4 100644 --- a/pyomo/contrib/piecewise/piecewise_linear_function.py +++ b/pyomo/contrib/piecewise/piecewise_linear_function.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,8 +19,13 @@ from pyomo.contrib.piecewise.piecewise_linear_expression import ( PiecewiseLinearExpression, ) -from pyomo.core import Any, NonNegativeIntegers, value, Var -from pyomo.core.base.block import _BlockData, Block +from pyomo.contrib.piecewise.triangulations import ( + get_unordered_j1_triangulation, + get_ordered_j1_triangulation, + Triangulation, +) +from pyomo.core import Any, NonNegativeIntegers, value +from pyomo.core.base.block import BlockData, Block from pyomo.core.base.component import ModelComponentFactory from pyomo.core.base.expression import Expression from pyomo.core.base.global_set import UnindexedComponent_index @@ -36,19 +41,28 @@ logger = logging.getLogger(__name__) -class PiecewiseLinearFunctionData(_BlockData): +class PiecewiseLinearFunctionData(BlockData): _Block_reserved_words = Any def __init__(self, component=None): - _BlockData.__init__(self, component) + BlockData.__init__(self, component) with self._declare_reserved_components(): + # map of PiecewiseLinearExpression objects to integer indices in + # self._expressions + self._expression_ids = ComponentMap() + # index is monotonically increasing integer self._expressions = Expression(NonNegativeIntegers) self._transformed_exprs = ComponentMap() self._simplices = None # These will always be tuples, even when we only have one dimension. self._points = [] self._linear_functions = [] + self._triangulation = None + + @property + def triangulation(self): + return self._triangulation def __call__(self, *args): """ @@ -63,8 +77,9 @@ def __call__(self, *args): return self._evaluate(*args) else: expr = PiecewiseLinearExpression(args, self) - idx = id(expr) + idx = len(self._expressions) self._expressions[idx] = expr + self._expression_ids[expr] = idx return self._expressions[idx] def _evaluate(self, *args): @@ -134,7 +149,12 @@ def map_transformation_var(self, pw_expr, v): Records on the PiecewiseLinearFunction object that the transformed form of the PiecewiseLinearExpression object pw_expr is the Var v. """ - self._transformed_exprs[self._expressions[id(pw_expr)]] = v + if pw_expr not in self._expression_ids: + raise DeveloperError( + "ID of PiecewiseLinearExpression '%s' not in the _expression_ids " + "dictionary of PiecewiseLinearFunction '%s'" % (pw_expr, self) + ) + self._transformed_exprs[self._expressions[self._expression_ids[pw_expr]]] = v def get_transformation_var(self, pw_expr): """ @@ -159,7 +179,7 @@ def __call__(self, x): class _multivariate_linear_functor(AutoSlots.Mixin): - __slots__ = 'normal' + __slots__ = ('normal',) def __init__(self, normal): self.normal = normal @@ -225,6 +245,19 @@ class PiecewiseLinearFunction(Block): expression for a linear function of the arguments. tabular_data: A dictionary mapping values of the nonlinear function to points in the domain + triangulation (optional): An enum value of type Triangulation specifying + how Pyomo should triangulate the function domain, or None. Behavior + depends on how this piecewise-linear function is constructed: + when constructed using methods (1) or (4) above, valid arguments + are the members of Triangulation except Unknown or AssumeValid, + and Pyomo will use that method to triangulate the domain and to tag + the resulting PWLF. If no argument or None is passed, the default + is Triangulation.Delaunay. When constructed using methods (2) or (3) + above, valid arguments are only Triangulation.Unknown and + Triangulation.AssumeValid. Pyomo will tag the constructed PWLF + as specified, trusting the user in the case of AssumeValid. + When no argument or None is passed, the default is + Triangulation.Unknown """ _ComponentDataClass = PiecewiseLinearFunctionData @@ -251,6 +284,7 @@ def __init__(self, *args, **kwargs): _linear_functions = kwargs.pop('linear_functions', None) _tabular_data_arg = kwargs.pop('tabular_data', None) _tabular_data_rule_arg = kwargs.pop('tabular_data_rule', None) + _triangulation_rule_arg = kwargs.pop('triangulation', None) kwargs.setdefault('ctype', PiecewiseLinearFunction) Block.__init__(self, *args, **kwargs) @@ -269,6 +303,9 @@ def __init__(self, *args, **kwargs): self._tabular_data_rule = Initializer( _tabular_data_rule_arg, treat_sequences_as_mappings=False ) + self._triangulation_rule = Initializer( + _triangulation_rule_arg, treat_sequences_as_mappings=False + ) def _get_dimension_from_points(self, points): if len(points) < 1: @@ -284,12 +321,34 @@ def _get_dimension_from_points(self, points): return dimension - def _construct_simplices_from_multivariate_points(self, obj, points, dimension): - try: - triangulation = spatial.Delaunay(points) - except (spatial.QhullError, ValueError) as error: - logger.error("Unable to triangulate the set of input points.") - raise + def _construct_simplices_from_multivariate_points( + self, obj, parent, points, dimension + ): + if self._triangulation_rule is None: + tri = Triangulation.Delaunay + else: + tri = self._triangulation_rule(parent, obj._index) + if tri is None: + tri = Triangulation.Delaunay + + if tri == Triangulation.Delaunay: + try: + triangulation = spatial.Delaunay(points) + except (spatial.QhullError, ValueError) as error: + logger.error("Unable to triangulate the set of input points.") + raise + obj._triangulation = tri + elif tri == Triangulation.J1: + triangulation = get_unordered_j1_triangulation(points, dimension) + obj._triangulation = tri + elif tri == Triangulation.OrderedJ1: + triangulation = get_ordered_j1_triangulation(points, dimension) + obj._triangulation = tri + else: + raise ValueError( + "Invalid or unrecognized triangulation specified for '%s': %s" + % (obj, tri) + ) # Get the points for the triangulation because they might not all be # there if any were coplanar. @@ -308,7 +367,13 @@ def _construct_simplices_from_multivariate_points(self, obj, points, dimension): # checking the determinant because matrix_rank will by default calculate a # tolerance based on the input to account for numerical errors in the # SVD computation. - if ( + if tri in (Triangulation.J1, Triangulation.OrderedJ1): + # Note: do not sort vertices from OrderedJ1, or it will break. + # Non-ordered J1 is already sorted, though it doesn't matter. + # Also, we don't need to check for degeneracy with simplices we + # made ourselves. + obj._simplices.append(tuple(simplex)) + elif ( np.linalg.matrix_rank( points[:, 1:] - np.append(points[:, : dimension - 1], points[:, [0]], axis=1) @@ -326,6 +391,24 @@ def _construct_simplices_from_multivariate_points(self, obj, points, dimension): "%s from the triangulation." % pt[0] ) + # Call when constructing from simplices to allow use of AssumeValid and + # ensure the user is not making mistakes + def _check_and_set_triangulation_from_user(self, parent, obj): + if self._triangulation_rule is None: + tri = None + else: + tri = self._triangulation_rule(parent, obj._index) + if tri is None or tri == Triangulation.Unknown: + obj._triangulation = Triangulation.Unknown + elif tri == Triangulation.AssumeValid: + obj._triangulation = Triangulation.AssumeValid + else: + raise ValueError( + f"Invalid or unrecognized triangulation tag specified for {obj} when" + f" giving simplices: {tri}. Valid arguments when giving simplices are" + " Triangulation.Unknown and Triangulation.AssumeValid." + ) + def _construct_one_dimensional_simplices_from_points(self, obj, points): points.sort() obj._simplices = [] @@ -347,15 +430,25 @@ def _construct_from_function_and_points(self, obj, parent, nonlinear_function): # avoid a dependence on scipy. self._construct_one_dimensional_simplices_from_points(obj, points) return self._construct_from_univariate_function_and_segments( - obj, nonlinear_function + obj, parent, nonlinear_function, segments_are_user_defined=False ) - self._construct_simplices_from_multivariate_points(obj, points, dimension) + self._construct_simplices_from_multivariate_points( + obj, parent, points, dimension + ) return self._construct_from_function_and_simplices( obj, parent, nonlinear_function, simplices_are_user_defined=False ) - def _construct_from_univariate_function_and_segments(self, obj, func): + def _construct_from_univariate_function_and_segments( + self, obj, parent, func, segments_are_user_defined=True + ): + # We can trust they are nicely ordered if we made them, otherwise anything goes. + if segments_are_user_defined: + self._check_and_set_triangulation_from_user(parent, obj) + else: + obj._triangulation = Triangulation.AssumeValid + for idx1, idx2 in obj._simplices: x1 = obj._points[idx1][0] x2 = obj._points[idx2][0] @@ -386,9 +479,14 @@ def _construct_from_function_and_simplices( # it separately in order to avoid a kind of silly dependence on # numpy. return self._construct_from_univariate_function_and_segments( - obj, nonlinear_function + obj, parent, nonlinear_function, simplices_are_user_defined ) + # If we triangulated, then this tag was already set. If they provided it, + # then check their arguments and set. + if simplices_are_user_defined: + self._check_and_set_triangulation_from_user(parent, obj) + # evaluate the function at each of the points and form the homogeneous # system of equations A = np.ones((dimension + 2, dimension + 2)) @@ -440,6 +538,7 @@ def _construct_from_linear_functions_and_simplices( # have been called. obj._get_simplices_from_arg(self._simplices_rule(parent, obj._index)) obj._linear_functions = [f for f in self._linear_funcs_rule(parent, obj._index)] + self._check_and_set_triangulation_from_user(parent, obj) return obj @_define_handler(_handlers, False, False, False, False, True) @@ -457,12 +556,20 @@ def _construct_from_tabular_data(self, obj, parent, nonlinear_function): # avoid a dependence on scipy. self._construct_one_dimensional_simplices_from_points(obj, points) return self._construct_from_univariate_function_and_segments( - obj, _tabular_data_functor(tabular_data, tupleize=True) + obj, + parent, + _tabular_data_functor(tabular_data, tupleize=True), + segments_are_user_defined=False, ) - self._construct_simplices_from_multivariate_points(obj, points, dimension) + self._construct_simplices_from_multivariate_points( + obj, parent, points, dimension + ) return self._construct_from_function_and_simplices( - obj, parent, _tabular_data_functor(tabular_data) + obj, + parent, + _tabular_data_functor(tabular_data), + simplices_are_user_defined=False, ) def _getitem_when_not_present(self, index): @@ -499,15 +606,15 @@ def _getitem_when_not_present(self, index): "a list of corresponding simplices, or a dictionary " "mapping points to nonlinear function values." ) - return handler(self, obj, parent, nonlinear_function) + obj = handler(self, obj, parent, nonlinear_function) + + return obj class ScalarPiecewiseLinearFunction( PiecewiseLinearFunctionData, PiecewiseLinearFunction ): def __init__(self, *args, **kwds): - self._suppress_ctypes = set() - PiecewiseLinearFunctionData.__init__(self, self) PiecewiseLinearFunction.__init__(self, *args, **kwds) self._data[None] = self diff --git a/pyomo/contrib/piecewise/tests/__init__.py b/pyomo/contrib/piecewise/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/piecewise/tests/__init__.py +++ b/pyomo/contrib/piecewise/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/piecewise/tests/common_inner_repn_tests.py b/pyomo/contrib/piecewise/tests/common_inner_repn_tests.py new file mode 100644 index 00000000000..4284a64cd59 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/common_inner_repn_tests.py @@ -0,0 +1,80 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.core import Var +from pyomo.core.base import Constraint +from pyomo.core.expr.compare import assertExpressionsEqual + +# This file contains check methods shared between GDP inner representation-based +# transformations. Currently, those are the inner_representation_gdp and +# nested_inner_repn_gdp transformations, since each have disjuncts with the +# same structure. + + +# Check one disjunct from the log model for proper contents +def check_log_disjunct(test, d, pts, f, substitute_var, x): + test.assertEqual(len(d.component_map(Constraint)), 3) + # lambdas and indicator_var + test.assertEqual(len(d.component_map(Var)), 2) + test.assertIsInstance(d.lambdas, Var) + test.assertEqual(len(d.lambdas), 2) + for lamb in d.lambdas.values(): + test.assertEqual(lamb.lb, 0) + test.assertEqual(lamb.ub, 1) + test.assertIsInstance(d.convex_combo, Constraint) + assertExpressionsEqual(test, d.convex_combo.expr, d.lambdas[0] + d.lambdas[1] == 1) + test.assertIsInstance(d.set_substitute, Constraint) + assertExpressionsEqual( + test, d.set_substitute.expr, substitute_var == f(x), places=7 + ) + test.assertIsInstance(d.linear_combo, Constraint) + test.assertEqual(len(d.linear_combo), 1) + assertExpressionsEqual( + test, d.linear_combo[0].expr, x == pts[0] * d.lambdas[0] + pts[1] * d.lambdas[1] + ) + + +# Check one disjunct from the paraboloid model for proper contents. +def check_paraboloid_disjunct(test, d, pts, f, substitute_var, x1, x2): + test.assertEqual(len(d.component_map(Constraint)), 3) + # lambdas and indicator_var + test.assertEqual(len(d.component_map(Var)), 2) + test.assertIsInstance(d.lambdas, Var) + test.assertEqual(len(d.lambdas), 3) + for lamb in d.lambdas.values(): + test.assertEqual(lamb.lb, 0) + test.assertEqual(lamb.ub, 1) + test.assertIsInstance(d.convex_combo, Constraint) + assertExpressionsEqual( + test, d.convex_combo.expr, d.lambdas[0] + d.lambdas[1] + d.lambdas[2] == 1 + ) + test.assertIsInstance(d.set_substitute, Constraint) + assertExpressionsEqual( + test, d.set_substitute.expr, substitute_var == f(x1, x2), places=7 + ) + test.assertIsInstance(d.linear_combo, Constraint) + test.assertEqual(len(d.linear_combo), 2) + assertExpressionsEqual( + test, + d.linear_combo[0].expr, + x1 + == pts[0][0] * d.lambdas[0] + + pts[1][0] * d.lambdas[1] + + pts[2][0] * d.lambdas[2], + ) + assertExpressionsEqual( + test, + d.linear_combo[1].expr, + x2 + == pts[0][1] * d.lambdas[0] + + pts[1][1] * d.lambdas[1] + + pts[2][1] * d.lambdas[2], + ) diff --git a/pyomo/contrib/piecewise/tests/common_tests.py b/pyomo/contrib/piecewise/tests/common_tests.py index 23e67474934..6b4e8bfe691 100644 --- a/pyomo/contrib/piecewise/tests/common_tests.py +++ b/pyomo/contrib/piecewise/tests/common_tests.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -34,8 +34,9 @@ def check_log_x_model_soln(test, m): test.assertAlmostEqual(value(m.obj), m.f2(4)) -def check_transformation_do_not_descend(test, transformation): - m = models.make_log_x_model() +def check_transformation_do_not_descend(test, transformation, m=None): + if m is None: + m = models.make_log_x_model() transform = TransformationFactory(transformation) transform.apply_to(m) @@ -43,8 +44,9 @@ def check_transformation_do_not_descend(test, transformation): test.check_pw_paraboloid(m) -def check_transformation_PiecewiseLinearFunction_targets(test, transformation): - m = models.make_log_x_model() +def check_transformation_PiecewiseLinearFunction_targets(test, transformation, m=None): + if m is None: + m = models.make_log_x_model() transform = TransformationFactory(transformation) transform.apply_to(m, targets=[m.pw_log]) @@ -54,8 +56,9 @@ def check_transformation_PiecewiseLinearFunction_targets(test, transformation): test.assertIsNone(m.pw_paraboloid.get_transformation_var(m.paraboloid_expr)) -def check_descend_into_expressions(test, transformation): - m = models.make_log_x_model() +def check_descend_into_expressions(test, transformation, m=None): + if m is None: + m = models.make_log_x_model() transform = TransformationFactory(transformation) transform.apply_to(m, descend_into_expressions=True) @@ -64,8 +67,9 @@ def check_descend_into_expressions(test, transformation): test.check_pw_paraboloid(m) -def check_descend_into_expressions_constraint_target(test, transformation): - m = models.make_log_x_model() +def check_descend_into_expressions_constraint_target(test, transformation, m=None): + if m is None: + m = models.make_log_x_model() transform = TransformationFactory(transformation) transform.apply_to(m, descend_into_expressions=True, targets=[m.indexed_c]) @@ -74,8 +78,9 @@ def check_descend_into_expressions_constraint_target(test, transformation): test.assertIsNone(m.pw_log.get_transformation_var(m.log_expr)) -def check_descend_into_expressions_objective_target(test, transformation): - m = models.make_log_x_model() +def check_descend_into_expressions_objective_target(test, transformation, m=None): + if m is None: + m = models.make_log_x_model() transform = TransformationFactory(transformation) transform.apply_to(m, descend_into_expressions=True, targets=[m.obj]) diff --git a/pyomo/contrib/piecewise/tests/models.py b/pyomo/contrib/piecewise/tests/models.py index 1a8bef04ad7..7e4459fa662 100644 --- a/pyomo/contrib/piecewise/tests/models.py +++ b/pyomo/contrib/piecewise/tests/models.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,18 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.contrib.piecewise import PiecewiseLinearFunction +from pyomo.contrib.piecewise import PiecewiseLinearFunction, Triangulation from pyomo.environ import ConcreteModel, Constraint, log, Objective, Var +default_simplices = [ + [(0, 1), (0, 4), (3, 4)], + [(0, 1), (3, 4), (3, 1)], + [(3, 4), (3, 7), (0, 7)], + [(0, 7), (0, 4), (3, 4)], +] -def make_log_x_model(): + +def make_log_x_model(simplices=default_simplices): m = ConcreteModel() m.x = Var(bounds=(1, 10)) m.pw_log = PiecewiseLinearFunction(points=[1, 3, 6, 10], function=log) @@ -50,14 +57,11 @@ def g2(x1, x2): return 3 * x1 + 11 * x2 - 28 m.g2 = g2 - simplices = [ - [(0, 1), (0, 4), (3, 4)], - [(0, 1), (3, 4), (3, 1)], - [(3, 4), (3, 7), (0, 7)], - [(0, 7), (0, 4), (3, 4)], - ] + m.pw_paraboloid = PiecewiseLinearFunction( - simplices=simplices, linear_functions=[g1, g1, g2, g2] + simplices=simplices, + linear_functions=[g1, g1, g2, g2], + triangulation=Triangulation.AssumeValid, ) m.paraboloid_expr = m.pw_paraboloid(m.x1, m.x2) diff --git a/pyomo/contrib/piecewise/tests/test_disaggregated_logarithmic.py b/pyomo/contrib/piecewise/tests/test_disaggregated_logarithmic.py new file mode 100644 index 00000000000..c827aa04043 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_disaggregated_logarithmic.py @@ -0,0 +1,275 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +from pyomo.contrib.piecewise.tests import models +import pyomo.contrib.piecewise.tests.common_tests as ct +from pyomo.core.base import TransformationFactory +from pyomo.environ import SolverFactory, Var, Constraint +from pyomo.core.expr.compare import assertExpressionsEqual + + +class TestTransformPiecewiseModelToNestedInnerRepnMIP(unittest.TestCase): + def check_pw_log(self, m): + z = m.pw_log.get_transformation_var(m.log_expr) + self.assertIsInstance(z, Var) + # Now we can use those Vars to check on what the transformation created + log_block = z.parent_block() + + # We should have three Vars, two of which are indexed, and five + # Constraints, three of which are indexed + + self.assertEqual(len(log_block.component_map(Var)), 3) + self.assertEqual(len(log_block.component_map(Constraint)), 5) + + # Constants + simplex_count = 3 + log_simplex_count = 2 + simplex_point_count = 2 + + # Substitute var + self.assertIsInstance(log_block.substitute_var, Var) + self.assertIs(m.obj.expr.expr, log_block.substitute_var) + # Binaries + self.assertIsInstance(log_block.binaries, Var) + self.assertEqual(len(log_block.binaries), log_simplex_count) + # Lambdas + self.assertIsInstance(log_block.lambdas, Var) + self.assertEqual(len(log_block.lambdas), simplex_count * simplex_point_count) + for l in log_block.lambdas.values(): + self.assertEqual(l.lb, 0) + self.assertEqual(l.ub, 1) + + # Convex combo constraint + self.assertIsInstance(log_block.convex_combo, Constraint) + assertExpressionsEqual( + self, + log_block.convex_combo.expr, + log_block.lambdas[0, 0] + + log_block.lambdas[0, 1] + + log_block.lambdas[1, 0] + + log_block.lambdas[1, 1] + + log_block.lambdas[2, 0] + + log_block.lambdas[2, 1] + == 1, + ) + + # Set substitute constraint + self.assertIsInstance(log_block.set_substitute, Constraint) + assertExpressionsEqual( + self, + log_block.set_substitute.expr, + log_block.substitute_var + == log_block.lambdas[0, 0] * m.f1(1) + + log_block.lambdas[1, 0] * m.f2(3) + + log_block.lambdas[2, 0] * m.f3(6) + + log_block.lambdas[0, 1] * m.f1(3) + + log_block.lambdas[1, 1] * m.f2(6) + + log_block.lambdas[2, 1] * m.f3(10), + places=7, + ) + + # x constraint + self.assertIsInstance(log_block.x_constraint, Constraint) + # one-dimensional case, so there is only one x variable here + self.assertEqual(len(log_block.x_constraint), 1) + assertExpressionsEqual( + self, + log_block.x_constraint[0].expr, + m.x + == 1 * log_block.lambdas[0, 0] + + 3 * log_block.lambdas[0, 1] + + 3 * log_block.lambdas[1, 0] + + 6 * log_block.lambdas[1, 1] + + 6 * log_block.lambdas[2, 0] + + 10 * log_block.lambdas[2, 1], + ) + + # simplex choice 1 constraint enables lambdas when binaries are on + self.assertEqual(len(log_block.simplex_choice_1), log_simplex_count) + assertExpressionsEqual( + self, + log_block.simplex_choice_1[0].expr, + log_block.lambdas[2, 0] + log_block.lambdas[2, 1] <= log_block.binaries[0], + ) + assertExpressionsEqual( + self, + log_block.simplex_choice_1[1].expr, + log_block.lambdas[1, 0] + log_block.lambdas[1, 1] <= log_block.binaries[1], + ) + # simplex choice 2 constraint enables lambdas when binaries are off + self.assertEqual(len(log_block.simplex_choice_2), log_simplex_count) + assertExpressionsEqual( + self, + log_block.simplex_choice_2[0].expr, + log_block.lambdas[0, 0] + + log_block.lambdas[0, 1] + + log_block.lambdas[1, 0] + + log_block.lambdas[1, 1] + <= 1 - log_block.binaries[0], + ) + assertExpressionsEqual( + self, + log_block.simplex_choice_2[1].expr, + log_block.lambdas[0, 0] + + log_block.lambdas[0, 1] + + log_block.lambdas[2, 0] + + log_block.lambdas[2, 1] + <= 1 - log_block.binaries[1], + ) + + def check_pw_paraboloid(self, m): + # This is a little larger, but at least test that the right numbers of + # everything are created + z = m.pw_paraboloid.get_transformation_var(m.paraboloid_expr) + self.assertIsInstance(z, Var) + paraboloid_block = z.parent_block() + + self.assertEqual(len(paraboloid_block.component_map(Var)), 3) + self.assertEqual(len(paraboloid_block.component_map(Constraint)), 5) + + # Constants + simplex_count = 4 + log_simplex_count = 2 + simplex_point_count = 3 + + # Substitute var + self.assertIsInstance(paraboloid_block.substitute_var, Var) + # Binaries + self.assertIsInstance(paraboloid_block.binaries, Var) + self.assertEqual(len(paraboloid_block.binaries), log_simplex_count) + # Lambdas + self.assertIsInstance(paraboloid_block.lambdas, Var) + self.assertEqual( + len(paraboloid_block.lambdas), simplex_count * simplex_point_count + ) + for l in paraboloid_block.lambdas.values(): + self.assertEqual(l.lb, 0) + self.assertEqual(l.ub, 1) + + # Convex combo constraint + self.assertIsInstance(paraboloid_block.convex_combo, Constraint) + assertExpressionsEqual( + self, + paraboloid_block.convex_combo.expr, + paraboloid_block.lambdas[0, 0] + + paraboloid_block.lambdas[0, 1] + + paraboloid_block.lambdas[0, 2] + + paraboloid_block.lambdas[1, 0] + + paraboloid_block.lambdas[1, 1] + + paraboloid_block.lambdas[1, 2] + + paraboloid_block.lambdas[2, 0] + + paraboloid_block.lambdas[2, 1] + + paraboloid_block.lambdas[2, 2] + + paraboloid_block.lambdas[3, 0] + + paraboloid_block.lambdas[3, 1] + + paraboloid_block.lambdas[3, 2] + == 1, + ) + + # Set substitute constraint + self.assertIsInstance(paraboloid_block.set_substitute, Constraint) + assertExpressionsEqual( + self, + paraboloid_block.set_substitute.expr, + paraboloid_block.substitute_var + == paraboloid_block.lambdas[0, 0] * m.g1(0, 1) + + paraboloid_block.lambdas[1, 0] * m.g1(0, 1) + + paraboloid_block.lambdas[2, 0] * m.g2(3, 4) + + paraboloid_block.lambdas[3, 0] * m.g2(0, 7) + + paraboloid_block.lambdas[0, 1] * m.g1(0, 4) + + paraboloid_block.lambdas[1, 1] * m.g1(3, 4) + + paraboloid_block.lambdas[2, 1] * m.g2(3, 7) + + paraboloid_block.lambdas[3, 1] * m.g2(0, 4) + + paraboloid_block.lambdas[0, 2] * m.g1(3, 4) + + paraboloid_block.lambdas[1, 2] * m.g1(3, 1) + + paraboloid_block.lambdas[2, 2] * m.g2(0, 7) + + paraboloid_block.lambdas[3, 2] * m.g2(3, 4), + places=7, + ) + + # x constraint + self.assertIsInstance(paraboloid_block.x_constraint, Constraint) + # Here we have two x variables + self.assertEqual(len(paraboloid_block.x_constraint), 2) + assertExpressionsEqual( + self, + paraboloid_block.x_constraint[0].expr, + m.x1 + == 0 * paraboloid_block.lambdas[0, 0] + + 0 * paraboloid_block.lambdas[0, 1] + + 3 * paraboloid_block.lambdas[0, 2] + + 0 * paraboloid_block.lambdas[1, 0] + + 3 * paraboloid_block.lambdas[1, 1] + + 3 * paraboloid_block.lambdas[1, 2] + + 3 * paraboloid_block.lambdas[2, 0] + + 3 * paraboloid_block.lambdas[2, 1] + + 0 * paraboloid_block.lambdas[2, 2] + + 0 * paraboloid_block.lambdas[3, 0] + + 0 * paraboloid_block.lambdas[3, 1] + + 3 * paraboloid_block.lambdas[3, 2], + ) + assertExpressionsEqual( + self, + paraboloid_block.x_constraint[1].expr, + m.x2 + == 1 * paraboloid_block.lambdas[0, 0] + + 4 * paraboloid_block.lambdas[0, 1] + + 4 * paraboloid_block.lambdas[0, 2] + + 1 * paraboloid_block.lambdas[1, 0] + + 4 * paraboloid_block.lambdas[1, 1] + + 1 * paraboloid_block.lambdas[1, 2] + + 4 * paraboloid_block.lambdas[2, 0] + + 7 * paraboloid_block.lambdas[2, 1] + + 7 * paraboloid_block.lambdas[2, 2] + + 7 * paraboloid_block.lambdas[3, 0] + + 4 * paraboloid_block.lambdas[3, 1] + + 4 * paraboloid_block.lambdas[3, 2], + ) + + # The choices will get long, so let's just assert we have enough + self.assertEqual(len(paraboloid_block.simplex_choice_1), log_simplex_count) + self.assertEqual(len(paraboloid_block.simplex_choice_2), log_simplex_count) + + # Test methods using the common_tests.py code. + def test_transformation_do_not_descend(self): + ct.check_transformation_do_not_descend( + self, 'contrib.piecewise.disaggregated_logarithmic' + ) + + def test_transformation_PiecewiseLinearFunction_targets(self): + ct.check_transformation_PiecewiseLinearFunction_targets( + self, 'contrib.piecewise.disaggregated_logarithmic' + ) + + def test_descend_into_expressions(self): + ct.check_descend_into_expressions( + self, 'contrib.piecewise.disaggregated_logarithmic' + ) + + def test_descend_into_expressions_constraint_target(self): + ct.check_descend_into_expressions_constraint_target( + self, 'contrib.piecewise.disaggregated_logarithmic' + ) + + def test_descend_into_expressions_objective_target(self): + ct.check_descend_into_expressions_objective_target( + self, 'contrib.piecewise.disaggregated_logarithmic' + ) + + # Check solution of the log(x) model + @unittest.skipUnless(SolverFactory('gurobi').available(), 'Gurobi is not available') + @unittest.skipUnless(SolverFactory('gurobi').license_is_valid(), 'No license') + def test_solve_log_model(self): + m = models.make_log_x_model() + TransformationFactory("contrib.piecewise.disaggregated_logarithmic").apply_to(m) + SolverFactory("gurobi").solve(m) + ct.check_log_x_model_soln(self, m) diff --git a/pyomo/contrib/piecewise/tests/test_incremental.py b/pyomo/contrib/piecewise/tests/test_incremental.py new file mode 100644 index 00000000000..fd8b7941a69 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_incremental.py @@ -0,0 +1,204 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +import pyomo.contrib.piecewise.tests.common_tests as ct +from pyomo.contrib.piecewise.tests.models import make_log_x_model +from pyomo.contrib.piecewise.triangulations import Triangulation +from pyomo.core.base import TransformationFactory +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.environ import ( + Constraint, + SolverFactory, + Var, + ConcreteModel, + Objective, + log, + value, + minimize, +) +from pyomo.contrib.piecewise import PiecewiseLinearFunction +import itertools + + +class TestTransformPiecewiseModelToIncrementalMIP(unittest.TestCase): + + def check_pw_log(self, m): + z = m.pw_log.get_transformation_var(m.log_expr) + self.assertIsInstance(z, Var) + log_block = z.parent_block() + + # Vars: three deltas, two y binaries, one substitute var + self.assertEqual(len(log_block.component_map(Var)), 3) + self.assertIsInstance(log_block.delta, Var) + self.assertEqual(len(log_block.delta), 3) + self.assertIsInstance(log_block.y_binaries, Var) + self.assertEqual(len(log_block.y_binaries), 2) + self.assertIsInstance(log_block.substitute_var, Var) + self.assertEqual(len(log_block.substitute_var), 1) + + # Constraints: 2 delta below y, 2 y below delta, one each of the three others + self.assertEqual(len(log_block.component_map(Constraint)), 5) + self.assertIsInstance(log_block.deltas_below_y, Constraint) + self.assertEqual(len(log_block.deltas_below_y), 2) + self.assertIsInstance(log_block.y_below_delta, Constraint) + self.assertEqual(len(log_block.y_below_delta), 2) + self.assertIsInstance(log_block.delta_one_constraint, Constraint) + self.assertEqual(len(log_block.delta_one_constraint), 1) + self.assertIsInstance(log_block.x_constraint, Constraint) + self.assertEqual(len(log_block.x_constraint), 1) + self.assertIsInstance(log_block.set_substitute, Constraint) + self.assertEqual(len(log_block.set_substitute), 1) + + assertExpressionsEqual( + self, + log_block.x_constraint[0].expr, + m.x + == 1 + + ( + log_block.delta[0, 1] * (3 - 1) + + log_block.delta[1, 1] * (6 - 3) + + log_block.delta[2, 1] * (10 - 6) + ), + ) + assertExpressionsEqual( + self, + log_block.set_substitute.expr, + log_block.substitute_var + == m.f1(1) + + ( + log_block.delta[0, 1] * (m.f2(3) - m.f1(1)) + + log_block.delta[1, 1] * (m.f3(6) - m.f2(3)) + + log_block.delta[2, 1] * (m.f3(10) - m.f3(6)) + ), + places=10, + ) + assertExpressionsEqual( + self, log_block.delta_one_constraint.expr, log_block.delta[0, 1] <= 1 + ) + assertExpressionsEqual( + self, + log_block.deltas_below_y[0].expr, + log_block.delta[1, 1] <= log_block.y_binaries[0], + ) + assertExpressionsEqual( + self, + log_block.deltas_below_y[1].expr, + log_block.delta[2, 1] <= log_block.y_binaries[1], + ) + assertExpressionsEqual( + self, + log_block.y_below_delta[0].expr, + log_block.y_binaries[0] <= log_block.delta[0, 1], + ) + assertExpressionsEqual( + self, + log_block.y_below_delta[1].expr, + log_block.y_binaries[1] <= log_block.delta[1, 1], + ) + + def check_pw_paraboloid(self, m): + z = m.pw_paraboloid.get_transformation_var(m.paraboloid_expr) + self.assertIsInstance(z, Var) + paraboloid_block = z.parent_block() + + # Vars: 8 deltas (2 per simplex), 3 y binaries, one substitute var + self.assertEqual(len(paraboloid_block.component_map(Var)), 3) + self.assertIsInstance(paraboloid_block.delta, Var) + self.assertEqual(len(paraboloid_block.delta), 8) + self.assertIsInstance(paraboloid_block.y_binaries, Var) + self.assertEqual(len(paraboloid_block.y_binaries), 3) + self.assertIsInstance(paraboloid_block.substitute_var, Var) + self.assertEqual(len(paraboloid_block.substitute_var), 1) + + # Constraints: 3 delta below y, 3 y below delta, two x constraints (two + # coordinates), one each of the three others + self.assertEqual(len(paraboloid_block.component_map(Constraint)), 5) + self.assertIsInstance(paraboloid_block.deltas_below_y, Constraint) + self.assertEqual(len(paraboloid_block.deltas_below_y), 3) + self.assertIsInstance(paraboloid_block.y_below_delta, Constraint) + self.assertEqual(len(paraboloid_block.y_below_delta), 3) + self.assertIsInstance(paraboloid_block.delta_one_constraint, Constraint) + self.assertEqual(len(paraboloid_block.delta_one_constraint), 1) + self.assertIsInstance(paraboloid_block.x_constraint, Constraint) + self.assertEqual(len(paraboloid_block.x_constraint), 2) + self.assertIsInstance(paraboloid_block.set_substitute, Constraint) + self.assertEqual(len(paraboloid_block.set_substitute), 1) + + ordered_simplices = [ + [(0, 1), (3, 1), (3, 4)], + [(3, 4), (0, 1), (0, 4)], + [(0, 4), (0, 7), (3, 4)], + [(3, 4), (3, 7), (0, 7)], + ] + + # Test methods using the common_tests.py code. + def test_transformation_do_not_descend(self): + ct.check_transformation_do_not_descend( + self, + 'contrib.piecewise.incremental', + make_log_x_model(simplices=self.ordered_simplices), + ) + + def test_transformation_PiecewiseLinearFunction_targets(self): + ct.check_transformation_PiecewiseLinearFunction_targets( + self, + 'contrib.piecewise.incremental', + make_log_x_model(simplices=self.ordered_simplices), + ) + + def test_descend_into_expressions(self): + ct.check_descend_into_expressions( + self, + 'contrib.piecewise.incremental', + make_log_x_model(simplices=self.ordered_simplices), + ) + + def test_descend_into_expressions_constraint_target(self): + ct.check_descend_into_expressions_constraint_target( + self, + 'contrib.piecewise.incremental', + make_log_x_model(simplices=self.ordered_simplices), + ) + + def test_descend_into_expressions_objective_target(self): + ct.check_descend_into_expressions_objective_target( + self, + 'contrib.piecewise.incremental', + make_log_x_model(simplices=self.ordered_simplices), + ) + + @unittest.skipUnless(SolverFactory('gurobi').available(), 'Gurobi is not available') + @unittest.skipUnless(SolverFactory('gurobi').license_is_valid(), 'No license') + def test_solve_log_model(self): + m = make_log_x_model(simplices=self.ordered_simplices) + TransformationFactory('contrib.piecewise.incremental').apply_to(m) + TransformationFactory('gdp.bigm').apply_to(m) + SolverFactory('gurobi').solve(m) + ct.check_log_x_model_soln(self, m) + + # Failed during development when ordered j1 vertex ordering got broken + @unittest.skipUnless(SolverFactory('gurobi').available(), 'Gurobi is not available') + @unittest.skipUnless(SolverFactory('gurobi').license_is_valid(), 'No license') + def test_solve_product_model(self): + m = ConcreteModel() + m.x1 = Var(bounds=(0.5, 5)) + m.x2 = Var(bounds=(0.9, 0.95)) + pts = list(itertools.product([0.5, 2.75, 5], [0.9, 0.925, 0.95])) + m.pwlf = PiecewiseLinearFunction( + points=pts, + function=lambda x, y: x * y, + triangulation=Triangulation.OrderedJ1, + ) + m.obj = Objective(sense=minimize, expr=m.pwlf(m.x1, m.x2)) + TransformationFactory("contrib.piecewise.incremental").apply_to(m) + SolverFactory('gurobi').solve(m) + self.assertAlmostEqual(0.45, value(m.obj)) diff --git a/pyomo/contrib/piecewise/tests/test_inner_repn_gdp.py b/pyomo/contrib/piecewise/tests/test_inner_repn_gdp.py index 27fe43e54d5..ebc3106835c 100644 --- a/pyomo/contrib/piecewise/tests/test_inner_repn_gdp.py +++ b/pyomo/contrib/piecewise/tests/test_inner_repn_gdp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,6 +12,7 @@ import pyomo.common.unittest as unittest from pyomo.contrib.piecewise.tests import models import pyomo.contrib.piecewise.tests.common_tests as ct +import pyomo.contrib.piecewise.tests.common_inner_repn_tests as inner_repn_tests from pyomo.core.base import TransformationFactory from pyomo.core.expr.compare import ( assertExpressionsEqual, @@ -22,67 +23,6 @@ class TestTransformPiecewiseModelToInnerRepnGDP(unittest.TestCase): - def check_log_disjunct(self, d, pts, f, substitute_var, x): - self.assertEqual(len(d.component_map(Constraint)), 3) - # lambdas and indicator_var - self.assertEqual(len(d.component_map(Var)), 2) - self.assertIsInstance(d.lambdas, Var) - self.assertEqual(len(d.lambdas), 2) - for lamb in d.lambdas.values(): - self.assertEqual(lamb.lb, 0) - self.assertEqual(lamb.ub, 1) - self.assertIsInstance(d.convex_combo, Constraint) - assertExpressionsEqual( - self, d.convex_combo.expr, d.lambdas[0] + d.lambdas[1] == 1 - ) - self.assertIsInstance(d.set_substitute, Constraint) - assertExpressionsEqual( - self, d.set_substitute.expr, substitute_var == f(x), places=7 - ) - self.assertIsInstance(d.linear_combo, Constraint) - self.assertEqual(len(d.linear_combo), 1) - assertExpressionsEqual( - self, - d.linear_combo[0].expr, - x == pts[0] * d.lambdas[0] + pts[1] * d.lambdas[1], - ) - - def check_paraboloid_disjunct(self, d, pts, f, substitute_var, x1, x2): - self.assertEqual(len(d.component_map(Constraint)), 3) - # lambdas and indicator_var - self.assertEqual(len(d.component_map(Var)), 2) - self.assertIsInstance(d.lambdas, Var) - self.assertEqual(len(d.lambdas), 3) - for lamb in d.lambdas.values(): - self.assertEqual(lamb.lb, 0) - self.assertEqual(lamb.ub, 1) - self.assertIsInstance(d.convex_combo, Constraint) - assertExpressionsEqual( - self, d.convex_combo.expr, d.lambdas[0] + d.lambdas[1] + d.lambdas[2] == 1 - ) - self.assertIsInstance(d.set_substitute, Constraint) - assertExpressionsEqual( - self, d.set_substitute.expr, substitute_var == f(x1, x2), places=7 - ) - self.assertIsInstance(d.linear_combo, Constraint) - self.assertEqual(len(d.linear_combo), 2) - assertExpressionsEqual( - self, - d.linear_combo[0].expr, - x1 - == pts[0][0] * d.lambdas[0] - + pts[1][0] * d.lambdas[1] - + pts[2][0] * d.lambdas[2], - ) - assertExpressionsEqual( - self, - d.linear_combo[1].expr, - x2 - == pts[0][1] * d.lambdas[0] - + pts[1][1] * d.lambdas[1] - + pts[2][1] * d.lambdas[2], - ) - def check_pw_log(self, m): ## # Check the transformation of the approximation of log(x) @@ -101,7 +41,9 @@ def check_pw_log(self, m): log_block.disjuncts[2]: ((6, 10), m.f3), } for d, (pts, f) in disjuncts_dict.items(): - self.check_log_disjunct(d, pts, f, log_block.substitute_var, m.x) + inner_repn_tests.check_log_disjunct( + self, d, pts, f, log_block.substitute_var, m.x + ) # Check the Disjunction self.assertIsInstance(log_block.pick_a_piece, Disjunction) @@ -129,8 +71,8 @@ def check_pw_paraboloid(self, m): paraboloid_block.disjuncts[3]: ([(0, 7), (0, 4), (3, 4)], m.g2), } for d, (pts, f) in disjuncts_dict.items(): - self.check_paraboloid_disjunct( - d, pts, f, paraboloid_block.substitute_var, m.x1, m.x2 + inner_repn_tests.check_paraboloid_disjunct( + self, d, pts, f, paraboloid_block.substitute_var, m.x1, m.x2 ) # Check the Disjunction diff --git a/pyomo/contrib/piecewise/tests/test_nested_inner_repn_gdp.py b/pyomo/contrib/piecewise/tests/test_nested_inner_repn_gdp.py new file mode 100644 index 00000000000..f0e9ded6a55 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_nested_inner_repn_gdp.py @@ -0,0 +1,135 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +from pyomo.contrib.piecewise.tests import models +import pyomo.contrib.piecewise.tests.common_tests as ct +import pyomo.contrib.piecewise.tests.common_inner_repn_tests as inner_repn_tests +from pyomo.core.base import TransformationFactory +from pyomo.environ import SolverFactory, Var, Constraint +from pyomo.gdp import Disjunction, Disjunct +from pyomo.core.expr.compare import assertExpressionsEqual + + +# Test the nested inner repn gdp model using the common_tests code +class TestTransformPiecewiseModelToNestedInnerRepnGDP(unittest.TestCase): + # Check the structure of the log PWLF Block + def check_pw_log(self, m): + z = m.pw_log.get_transformation_var(m.log_expr) + self.assertIsInstance(z, Var) + # Now we can use those Vars to check on what the transformation created + log_block = z.parent_block() + + # Not using ct.check_trans_block_structure() because these are slightly + # different + # Two top-level disjuncts + self.assertEqual(len(log_block.component_map(Disjunct)), 2) + # One disjunction + self.assertEqual(len(log_block.component_map(Disjunction)), 1) + # The 'z' var (that we will substitute in for the function being + # approximated) is here: + self.assertEqual(len(log_block.component_map(Var)), 1) + self.assertIsInstance(log_block.substitute_var, Var) + + # Check the tree structure, which should be heavier on the right + # Parent disjunction + self.assertIsInstance(log_block.disj, Disjunction) + self.assertEqual(len(log_block.disj.disjuncts), 2) + + # Left disjunct with constraints + self.assertIsInstance(log_block.d_l, Disjunct) + inner_repn_tests.check_log_disjunct( + self, log_block.d_l, (1, 3), m.f1, log_block.substitute_var, m.x + ) + + # Right disjunct with disjunction + self.assertIsInstance(log_block.d_r, Disjunct) + self.assertIsInstance(log_block.d_r.inner_disjunction_r, Disjunction) + self.assertEqual(len(log_block.d_r.inner_disjunction_r.disjuncts), 2) + + # Left and right child disjuncts with constraints + self.assertIsInstance(log_block.d_r.d_l, Disjunct) + inner_repn_tests.check_log_disjunct( + self, log_block.d_r.d_l, (3, 6), m.f2, log_block.substitute_var, m.x + ) + self.assertIsInstance(log_block.d_r.d_r, Disjunct) + inner_repn_tests.check_log_disjunct( + self, log_block.d_r.d_r, (6, 10), m.f3, log_block.substitute_var, m.x + ) + + # Check that this also became the objective + self.assertIs(m.obj.expr.expr, log_block.substitute_var) + + # Check the structure of the paraboloid PWLF block + def check_pw_paraboloid(self, m): + z = m.pw_paraboloid.get_transformation_var(m.paraboloid_expr) + self.assertIsInstance(z, Var) + paraboloid_block = z.parent_block() + + # Two top-level disjuncts + self.assertEqual(len(paraboloid_block.component_map(Disjunct)), 2) + # One disjunction + self.assertEqual(len(paraboloid_block.component_map(Disjunction)), 1) + # The 'z' var (that we will substitute in for the function being + # approximated) is here: + self.assertEqual(len(paraboloid_block.component_map(Var)), 1) + self.assertIsInstance(paraboloid_block.substitute_var, Var) + + # This one should have an even tree with four leaf disjuncts + disjuncts_dict = { + paraboloid_block.d_l.d_l: ([(0, 1), (0, 4), (3, 4)], m.g1), + paraboloid_block.d_l.d_r: ([(0, 1), (3, 4), (3, 1)], m.g1), + paraboloid_block.d_r.d_l: ([(3, 4), (3, 7), (0, 7)], m.g2), + paraboloid_block.d_r.d_r: ([(0, 7), (0, 4), (3, 4)], m.g2), + } + for d, (pts, f) in disjuncts_dict.items(): + inner_repn_tests.check_paraboloid_disjunct( + self, d, pts, f, paraboloid_block.substitute_var, m.x1, m.x2 + ) + + # And check the substitute Var is in the objective now. + self.assertIs(m.indexed_c[0].body.args[0].expr, paraboloid_block.substitute_var) + + # Test methods using the common_tests.py code. Copied in from test_inner_repn_gdp.py. + def test_transformation_do_not_descend(self): + ct.check_transformation_do_not_descend( + self, 'contrib.piecewise.nested_inner_repn_gdp' + ) + + def test_transformation_PiecewiseLinearFunction_targets(self): + ct.check_transformation_PiecewiseLinearFunction_targets( + self, 'contrib.piecewise.nested_inner_repn_gdp' + ) + + def test_descend_into_expressions(self): + ct.check_descend_into_expressions( + self, 'contrib.piecewise.nested_inner_repn_gdp' + ) + + def test_descend_into_expressions_constraint_target(self): + ct.check_descend_into_expressions_constraint_target( + self, 'contrib.piecewise.nested_inner_repn_gdp' + ) + + def test_descend_into_expressions_objective_target(self): + ct.check_descend_into_expressions_objective_target( + self, 'contrib.piecewise.nested_inner_repn_gdp' + ) + + # Check the solution of the log(x) model + @unittest.skipUnless(SolverFactory('gurobi').available(), 'Gurobi is not available') + @unittest.skipUnless(SolverFactory('gurobi').license_is_valid(), 'No license') + def test_solve_log_model(self): + m = models.make_log_x_model() + TransformationFactory("contrib.piecewise.nested_inner_repn_gdp").apply_to(m) + TransformationFactory("gdp.bigm").apply_to(m) + SolverFactory("gurobi").solve(m) + ct.check_log_x_model_soln(self, m) diff --git a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py new file mode 100644 index 00000000000..29bfd8723a7 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -0,0 +1,760 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from io import StringIO +import logging + +from pyomo.common.dependencies import attempt_import, scipy_available, numpy_available +from pyomo.common.log import LoggingIntercept +import pyomo.common.unittest as unittest +from pyomo.contrib.piecewise import PiecewiseLinearFunction +from pyomo.contrib.piecewise.transform.nonlinear_to_pwl import ( + NonlinearToPWL, + DomainPartitioningMethod, +) +from pyomo.core.base.expression import _ExpressionData +from pyomo.core.expr.compare import ( + assertExpressionsEqual, + assertExpressionsStructurallyEqual, +) +from pyomo.core.expr.numeric_expr import SumExpression +from pyomo.environ import ( + Binary, + ConcreteModel, + Var, + Constraint, + Integers, + TransformationFactory, + log, + Objective, + Reals, + SolverFactory, + TerminationCondition, + value, +) + +gurobi_available = ( + SolverFactory('gurobi').available(exception_flag=False) + and SolverFactory('gurobi').license_is_valid() +) +lineartree_available = attempt_import('lineartree')[1] +sklearn_available = attempt_import('sklearn.linear_model')[1] + + +class TestNonlinearToPWL_1D(unittest.TestCase): + def make_model(self): + m = ConcreteModel() + m.x = Var(bounds=(1, 10)) + m.cons = Constraint(expr=log(m.x) >= 0.35) + + return m + + def check_pw_linear_log_x(self, m, pwlf, x1, x2, x3): + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + + points = [(x1,), (x2,), (x3,)] + self.assertEqual(pwlf._simplices, [(0, 1), (1, 2)]) + self.assertEqual(pwlf._points, points) + self.assertEqual(len(pwlf._linear_functions), 2) + + assertExpressionsStructurallyEqual( + self, + pwlf._linear_functions[0](m.x), + ((log(x2) - log(x1)) / (x2 - x1)) * m.x + + (log(x2) - ((log(x2) - log(x1)) / (x2 - x1)) * x2), + places=7, + ) + assertExpressionsStructurallyEqual( + self, + pwlf._linear_functions[1](m.x), + ((log(x3) - log(x2)) / (x3 - x2)) * m.x + + (log(x3) - ((log(x3) - log(x2)) / (x3 - x2)) * x3), + places=7, + ) + + self.assertEqual(len(pwlf._expressions), 1) + new_cons = n_to_pwl.get_transformed_component(m.cons) + self.assertTrue(new_cons.active) + self.assertIs( + new_cons.body, pwlf._expressions[pwlf._expression_ids[new_cons.body.expr]] + ) + self.assertIsNone(new_cons.ub) + self.assertEqual(new_cons.lb, 0.35) + self.assertIs(n_to_pwl.get_src_component(new_cons), m.cons) + + quadratic = n_to_pwl.get_transformed_quadratic_constraints(m) + self.assertEqual(len(quadratic), 0) + nonlinear = n_to_pwl.get_transformed_nonlinear_constraints(m) + self.assertEqual(len(nonlinear), 1) + self.assertIn(m.cons, nonlinear) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + def test_log_constraint_uniform_grid(self): + m = self.make_model() + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=3, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + + # cons is transformed + self.assertFalse(m.cons.active) + + pwlf = list( + m.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(pwlf), 1) + pwlf = pwlf[0] + + points = [(1.0009,), (5.5,), (9.9991,)] + (x1, x2, x3) = 1.0009, 5.5, 9.9991 + self.check_pw_linear_log_x(m, pwlf, x1, x2, x3) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + def test_clone_transformed_model(self): + m = self.make_model() + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=3, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + + twin = m.clone() + + # cons is transformed + self.assertFalse(twin.cons.active) + + pwlf = list( + twin.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(pwlf), 1) + pwlf = pwlf[0] + + points = [(1.0009,), (5.5,), (9.9991,)] + (x1, x2, x3) = 1.0009, 5.5, 9.9991 + + self.check_pw_linear_log_x(twin, pwlf, x1, x2, x3) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + def test_log_constraint_random_grid(self): + m = self.make_model() + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + # [ESJ 3/30/24]: The seed is actually set in the function for getting + # the points right now, so this will be deterministic. + n_to_pwl.apply_to( + m, + num_points=3, + domain_partitioning_method=DomainPartitioningMethod.RANDOM_GRID, + ) + + # cons is transformed + self.assertFalse(m.cons.active) + + pwlf = list( + m.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(pwlf), 1) + pwlf = pwlf[0] + + x1 = 4.370861069626263 + x2 = 7.587945476302646 + x3 = 9.556428757689245 + self.check_pw_linear_log_x(m, pwlf, x1, x2, x3) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + def test_do_not_transform_quadratic_constraint(self): + m = self.make_model() + m.quad = Constraint(expr=m.x**2 <= 9) + m.lin = Constraint(expr=m.x >= 2) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=3, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + approximate_quadratic_constraints=False, + ) + + # cons is transformed + self.assertFalse(m.cons.active) + + pwlf = list( + m.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(pwlf), 1) + pwlf = pwlf[0] + + points = [(1.0009,), (5.5,), (9.9991,)] + (x1, x2, x3) = 1.0009, 5.5, 9.9991 + self.check_pw_linear_log_x(m, pwlf, x1, x2, x3) + + # quad is not + self.assertTrue(m.quad.active) + # neither is the linear one + self.assertTrue(m.lin.active) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + def test_constraint_target(self): + m = self.make_model() + m.quad = Constraint(expr=m.x**2 <= 9) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=3, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + targets=[m.cons], + ) + + # cons is transformed + self.assertFalse(m.cons.active) + + pwlf = list( + m.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(pwlf), 1) + pwlf = pwlf[0] + + points = [(1.0009,), (5.5,), (9.9991,)] + (x1, x2, x3) = 1.0009, 5.5, 9.9991 + self.check_pw_linear_log_x(m, pwlf, x1, x2, x3) + + # quad is not + self.assertTrue(m.quad.active) + + def test_crazy_target_error(self): + m = self.make_model() + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + with self.assertRaisesRegex( + ValueError, + "Target 'x' is not a Block, Constraint, or Objective. It " + "is of type '' and cannot " + "be transformed.", + ): + n_to_pwl.apply_to( + m, + num_points=3, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + targets=[m.x], + ) + + def test_cannot_approximate_constraints_with_unbounded_vars(self): + m = ConcreteModel() + m.x = Var() + m.quad = Constraint(expr=m.x**2 <= 9) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + with self.assertRaisesRegex( + ValueError, + "Cannot automatically approximate constraints with unbounded " + "variables. Var 'x' appearing in component 'quad' is missing " + "at least one bound", + ): + n_to_pwl.apply_to( + m, + num_points=3, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + + def test_error_for_non_separable_exceeding_max_dimension(self): + m = ConcreteModel() + m.x = Var([0, 1, 2, 3, 4], bounds=(-4, 5)) + m.ick = Constraint(expr=m.x[0] ** (m.x[1] * m.x[2] * m.x[3] * m.x[4]) <= 8) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + with self.assertRaisesRegex( + ValueError, + "Not approximating expression for component 'ick' as " + "it exceeds the maximum dimension of 4. Try increasing " + "'max_dimension' or additively separating the expression.", + ): + n_to_pwl.apply_to( + m, + num_points=3, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + max_dimension=4, + ) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + @unittest.skipUnless(scipy_available, "Scipy is not available") + def test_do_not_additively_decompose_below_min_dimension(self): + m = ConcreteModel() + m.x = Var([0, 1, 2, 3, 4], bounds=(-4, 5)) + m.c = Constraint(expr=m.x[0] * m.x[1] + m.x[3] <= 4) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=3, + additively_decompose=True, + min_dimension_to_additively_decompose=4, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + + transformed_c = n_to_pwl.get_transformed_component(m.c) + # This is only approximated by one pwlf: + self.assertIsInstance(transformed_c.body, _ExpressionData) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + def test_uniform_sampling_discrete_vars(self): + m = ConcreteModel() + m.x = Var(['rocky', 'bullwinkle'], domain=Binary) + m.y = Var(domain=Integers, bounds=(0, 5)) + m.c = Constraint(expr=m.x['rocky'] * m.x['bullwinkle'] + m.y <= 4) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + output = StringIO() + with LoggingIntercept(output, 'pyomo.core', logging.WARNING): + n_to_pwl.apply_to( + m, + num_points=3, + additively_decompose=False, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + # No warnings (this is to check that we aren't emitting a bunch of + # warnings about setting variables outside of their domains) + self.assertEqual(output.getvalue().strip(), "") + + transformed_c = n_to_pwl.get_transformed_component(m.c) + pwlf = transformed_c.body.expr.pw_linear_function + + # should sample 0, 1 for th m.x's + # should sample 0, 2, 5 for m.y (because of half to even rounding (*sigh*)) + points = set(pwlf._points) + self.assertEqual(len(points), 12) + for x in [0, 1]: + for y in [0, 1]: + for z in [0, 2, 5]: + self.assertIn((x, y, z), points) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + @unittest.skipUnless(scipy_available, "Scipy is not available") + def test_uniform_sampling_discrete_vars(self): + m = ConcreteModel() + m.x = Var(['rocky', 'bullwinkle'], domain=Binary) + m.y = Var(domain=Integers, bounds=(0, 5)) + m.c = Constraint(expr=m.x['rocky'] * m.x['bullwinkle'] + m.y <= 4) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + output = StringIO() + with LoggingIntercept(output, 'pyomo.core', logging.WARNING): + n_to_pwl.apply_to( + m, + num_points=3, + additively_decompose=False, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + # No warnings (this is to check that we aren't emitting a bunch of + # warnings about setting variables outside of their domains) + self.assertEqual(output.getvalue().strip(), "") + + transformed_c = n_to_pwl.get_transformed_component(m.c) + pwlf = transformed_c.body.expr.pw_linear_function + + # should sample 0, 1 for th m.x's + # should sample 0, 2, 5 for m.y (because of half to even rounding (*sigh*)) + points = set(pwlf._points) + self.assertEqual(len(points), 12) + for x in [0, 1]: + for y in [0, 1]: + for z in [0, 2, 5]: + self.assertIn((x, y, z), points) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + @unittest.skipUnless(scipy_available, "Scipy is not available") + def test_random_sampling_discrete_vars(self): + m = ConcreteModel() + m.x = Var(['rocky', 'bullwinkle'], domain=Binary) + m.y = Var(domain=Integers, bounds=(0, 5)) + m.c = Constraint(expr=m.x['rocky'] * m.x['bullwinkle'] + m.y <= 4) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + output = StringIO() + with LoggingIntercept(output, 'pyomo.core', logging.WARNING): + n_to_pwl.apply_to( + m, + num_points=3, + additively_decompose=False, + domain_partitioning_method=DomainPartitioningMethod.RANDOM_GRID, + ) + # No warnings (this is to check that we aren't emitting a bunch of + # warnings about setting variables outside of their domains) + self.assertEqual(output.getvalue().strip(), "") + + transformed_c = n_to_pwl.get_transformed_component(m.c) + pwlf = transformed_c.body.expr.pw_linear_function + + # should sample 0, 1 for th m.x's + # Happen to get 0, 1, 5 for m.y + points = set(pwlf._points) + self.assertEqual(len(points), 12) + for x in [0, 1]: + for y in [0, 1]: + for z in [0, 1, 5]: + self.assertIn((x, y, z), points) + + +class TestNonlinearToPWL_2D(unittest.TestCase): + def make_paraboloid_model(self): + m = ConcreteModel() + m.x1 = Var(bounds=(0, 3)) + m.x2 = Var(bounds=(1, 7)) + m.obj = Objective(expr=m.x1**2 + m.x2**2) + + return m + + def check_pw_linear_paraboloid(self, m, pwlf, x1, x2, y1, y2): + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + points = [(x1, y1), (x1, y2), (x2, y1), (x2, y2)] + self.assertEqual(pwlf._points, points) + self.assertEqual(pwlf._simplices, [(0, 1, 3), (0, 2, 3)]) + self.assertEqual(len(pwlf._linear_functions), 2) + + # just check that the linear functions make sense--they intersect the + # paraboloid at the vertices of the simplices. + self.assertAlmostEqual(pwlf._linear_functions[0](x1, y1), x1**2 + y1**2) + self.assertAlmostEqual(pwlf._linear_functions[0](x1, y2), x1**2 + y2**2) + self.assertAlmostEqual(pwlf._linear_functions[0](x2, y2), x2**2 + y2**2) + + self.assertAlmostEqual(pwlf._linear_functions[1](x1, y1), x1**2 + y1**2) + self.assertAlmostEqual(pwlf._linear_functions[1](x2, y1), x2**2 + y1**2) + self.assertAlmostEqual(pwlf._linear_functions[1](x2, y2), x2**2 + y2**2) + + self.assertEqual(len(pwlf._expressions), 1) + new_obj = n_to_pwl.get_transformed_component(m.obj) + self.assertTrue(new_obj.active) + self.assertIs( + new_obj.expr, pwlf._expressions[pwlf._expression_ids[new_obj.expr.expr]] + ) + self.assertIs(n_to_pwl.get_src_component(new_obj), m.obj) + + quadratic = n_to_pwl.get_transformed_quadratic_constraints(m) + self.assertEqual(len(quadratic), 0) + nonlinear = n_to_pwl.get_transformed_nonlinear_constraints(m) + self.assertEqual(len(nonlinear), 0) + quadratic = n_to_pwl.get_transformed_quadratic_objectives(m) + self.assertEqual(len(quadratic), 1) + self.assertIn(m.obj, quadratic) + nonlinear = n_to_pwl.get_transformed_nonlinear_objectives(m) + self.assertEqual(len(nonlinear), 0) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + @unittest.skipUnless(scipy_available, "Scipy is not available") + def test_paraboloid_objective_uniform_grid(self): + m = self.make_paraboloid_model() + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=2, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + + # check obj is transformed + self.assertFalse(m.obj.active) + + pwlf = list( + m.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(pwlf), 1) + pwlf = pwlf[0] + + x1 = 0.00030000000000000003 + x2 = 2.9997 + y1 = 1.0006 + y2 = 6.9994 + + self.check_pw_linear_paraboloid(m, pwlf, x1, x2, y1, y2) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + @unittest.skipUnless(scipy_available, "Scipy is not available") + def test_multivariate_clone(self): + m = self.make_paraboloid_model() + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=2, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + + twin = m.clone() + + # check obj is transformed + self.assertFalse(twin.obj.active) + + pwlf = list( + twin.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(pwlf), 1) + pwlf = pwlf[0] + + x1 = 0.00030000000000000003 + x2 = 2.9997 + y1 = 1.0006 + y2 = 6.9994 + + self.check_pw_linear_paraboloid(twin, pwlf, x1, x2, y1, y2) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + @unittest.skipUnless(scipy_available, "Scipy is not available") + def test_objective_target(self): + m = self.make_paraboloid_model() + + m.some_other_nonlinear_constraint = Constraint(expr=m.x1**3 + m.x2 <= 6) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=2, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + targets=[m.obj], + ) + + # check obj is transformed + self.assertFalse(m.obj.active) + + pwlf = list( + m.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(pwlf), 1) + pwlf = pwlf[0] + + x1 = 0.00030000000000000003 + x2 = 2.9997 + y1 = 1.0006 + y2 = 6.9994 + + self.check_pw_linear_paraboloid(m, pwlf, x1, x2, y1, y2) + + # and check that the constraint isn't transformed + self.assertTrue(m.some_other_nonlinear_constraint.active) + + def test_do_not_transform_quadratic_objective(self): + m = self.make_paraboloid_model() + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=2, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + approximate_quadratic_objectives=False, + ) + + # check obj is *not* transformed + self.assertTrue(m.obj.active) + + quadratic = n_to_pwl.get_transformed_quadratic_constraints(m) + self.assertEqual(len(quadratic), 0) + nonlinear = n_to_pwl.get_transformed_nonlinear_constraints(m) + self.assertEqual(len(nonlinear), 0) + quadratic = n_to_pwl.get_transformed_quadratic_objectives(m) + self.assertEqual(len(quadratic), 0) + nonlinear = n_to_pwl.get_transformed_nonlinear_objectives(m) + self.assertEqual(len(nonlinear), 0) + + +@unittest.skipUnless(lineartree_available, "lineartree not available") +@unittest.skipUnless(sklearn_available, "sklearn not available") +class TestLinearTreeDomainPartitioning(unittest.TestCase): + def make_absolute_value_model(self): + m = ConcreteModel() + m.x = Var(bounds=(-10, 10)) + m.obj = Objective(expr=abs(m.x)) + + return m + + def test_linear_model_tree_uniform(self): + m = self.make_absolute_value_model() + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=301, # sample a lot so we train a good tree + domain_partitioning_method=DomainPartitioningMethod.LINEAR_MODEL_TREE_UNIFORM, + linear_tree_max_depth=1, # force parsimony + ) + + transformed_obj = n_to_pwl.get_transformed_component(m.obj) + pwlf = transformed_obj.expr.expr.pw_linear_function + + self.assertEqual(len(pwlf._simplices), 2) + self.assertEqual(pwlf._simplices, [(0, 1), (1, 2)]) + self.assertEqual(pwlf._points, [(-10,), (-0.08402,), (10,)]) + self.assertEqual(len(pwlf._linear_functions), 2) + assertExpressionsEqual(self, pwlf._linear_functions[0](m.x), -1.0 * m.x) + assertExpressionsStructurallyEqual( + self, + pwlf._linear_functions[1](m.x), + # pretty close to m.x, but we're a bit off because we don't have 0 + # as a breakpoint. + 0.9833360108369479 * m.x + 0.16663989163052034, + places=7, + ) + + def test_linear_model_tree_random(self): + m = self.make_absolute_value_model() + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=300, # sample a lot so we train a good tree + domain_partitioning_method=DomainPartitioningMethod.LINEAR_MODEL_TREE_RANDOM, + linear_tree_max_depth=1, # force parsimony + ) + + transformed_obj = n_to_pwl.get_transformed_component(m.obj) + pwlf = transformed_obj.expr.expr.pw_linear_function + + self.assertEqual(len(pwlf._simplices), 2) + self.assertEqual(pwlf._simplices, [(0, 1), (1, 2)]) + self.assertEqual(pwlf._points, [(-10,), (-0.03638,), (10,)]) + self.assertEqual(len(pwlf._linear_functions), 2) + assertExpressionsEqual(self, pwlf._linear_functions[0](m.x), -1.0 * m.x) + assertExpressionsStructurallyEqual( + self, + pwlf._linear_functions[1](m.x), + # pretty close to m.x, but we're a bit off because we don't have 0 + # as a breakpoint. + 0.9927503741388829 * m.x + 0.07249625861117256, + places=7, + ) + + def test_linear_model_tree_random_auto_depth_tree(self): + m = self.make_absolute_value_model() + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + n_to_pwl.apply_to( + m, + num_points=100, # sample a lot but not too many because this one is + # more prone to overfitting + domain_partitioning_method=DomainPartitioningMethod.LINEAR_MODEL_TREE_RANDOM, + ) + + transformed_obj = n_to_pwl.get_transformed_component(m.obj) + pwlf = transformed_obj.expr.expr.pw_linear_function + + print(pwlf._simplices) + print(pwlf._points) + for f in pwlf._linear_functions: + print(f(m.x)) + + # We end up with 8, which is just what happens, but it's not a terrible + # approximation + self.assertEqual(len(pwlf._simplices), 8) + self.assertEqual( + pwlf._simplices, + [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7), (7, 8)], + ) + self.assertEqual( + pwlf._points, + [ + (-10,), + (-9.24119,), + (-8.71428,), + (-8.11135,), + (0.06048,), + (0.70015,), + (1.9285,), + (2.15597,), + (10,), + ], + ) + self.assertEqual(len(pwlf._linear_functions), 8) + for i in range(3): + assertExpressionsEqual(self, pwlf._linear_functions[i](m.x), -1.0 * m.x) + assertExpressionsStructurallyEqual( + self, + pwlf._linear_functions[3](m.x), + # pretty close to - m.x, but we're a bit off because we don't have 0 + # as a breakpoint. + -0.9851979299618323 * m.x + 0.12006477080409184, + places=7, + ) + for i in range(4, 8): + assertExpressionsEqual(self, pwlf._linear_functions[i](m.x), m.x) + + +class TestNonlinearToPWLIntegration(unittest.TestCase): + @unittest.skipUnless(gurobi_available, "Gurobi is not available") + @unittest.skipUnless(scipy_available, "Scipy is not available") + def test_transform_and_solve_additively_decomposes_model(self): + # A bit of an integration test to make sure that we build additively + # decomposed pw-linear approximations in such a way that they are + # transformed to MILP and solved correctly. (Largely because we have to + # be careful to make sure that we don't ever directly insert + # PiecewiseLinearExpression objects into expressions and are instead + # using the ExpressionData that points to them (and will eventually be + # replaced in transformation)) + m = ConcreteModel() + m.x1 = Var(within=Reals, bounds=(0, 2), initialize=1.745) + m.x4 = Var(within=Reals, bounds=(0, 5), initialize=3.048) + m.x7 = Var(within=Reals, bounds=(0.9, 0.95), initialize=0.928) + m.obj = Objective(expr=-6.3 * m.x4 * m.x7 + 5.04 * m.x1) + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + xm = n_to_pwl.create_using( + m, + num_points=4, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + additively_decompose=True, + ) + + self.assertFalse(xm.obj.active) + new_obj = n_to_pwl.get_transformed_component(xm.obj) + self.assertIs(n_to_pwl.get_src_component(new_obj), xm.obj) + self.assertTrue(new_obj.active) + # two terms + self.assertIsInstance(new_obj.expr, SumExpression) + self.assertEqual(len(new_obj.expr.args), 2) + first = new_obj.expr.args[0] + pwlf = first.expr.pw_linear_function + all_pwlf = list( + xm.component_data_objects(PiecewiseLinearFunction, descend_into=True) + ) + self.assertEqual(len(all_pwlf), 1) + # It is on the active tree. + self.assertIs(pwlf, all_pwlf[0]) + + second = new_obj.expr.args[1] + assertExpressionsEqual(self, second, 5.04 * xm.x1) + + objs = n_to_pwl.get_transformed_nonlinear_objectives(xm) + self.assertEqual(len(objs), 0) + objs = n_to_pwl.get_transformed_quadratic_objectives(xm) + self.assertEqual(len(objs), 1) + self.assertIn(xm.obj, objs) + self.assertEqual(len(n_to_pwl.get_transformed_nonlinear_constraints(xm)), 0) + self.assertEqual(len(n_to_pwl.get_transformed_quadratic_constraints(xm)), 0) + + TransformationFactory('contrib.piecewise.outer_repn_gdp').apply_to(xm) + TransformationFactory('gdp.bigm').apply_to(xm) + opt = SolverFactory('gurobi') + results = opt.solve(xm) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + + # solve the original + opt.options['NonConvex'] = 2 + results = opt.solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + + # Not a bad approximation: + self.assertAlmostEqual(value(xm.obj), value(m.obj), places=2) + + self.assertAlmostEqual(value(xm.x4), value(m.x4), places=3) + self.assertAlmostEqual(value(xm.x7), value(m.x7), places=4) + self.assertAlmostEqual(value(xm.x1), value(m.x1), places=7) diff --git a/pyomo/contrib/piecewise/tests/test_outer_repn_gdp.py b/pyomo/contrib/piecewise/tests/test_outer_repn_gdp.py index 5ee18875cb9..2995c72d1ac 100644 --- a/pyomo/contrib/piecewise/tests/test_outer_repn_gdp.py +++ b/pyomo/contrib/piecewise/tests/test_outer_repn_gdp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/piecewise/tests/test_piecewise_linear_function.py b/pyomo/contrib/piecewise/tests/test_piecewise_linear_function.py index 571601fefbc..4ef3eea10f7 100644 --- a/pyomo/contrib/piecewise/tests/test_piecewise_linear_function.py +++ b/pyomo/contrib/piecewise/tests/test_piecewise_linear_function.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,7 +16,7 @@ from pyomo.common.dependencies import attempt_import from pyomo.common.log import LoggingIntercept import pyomo.common.unittest as unittest -from pyomo.contrib.piecewise import PiecewiseLinearFunction +from pyomo.contrib.piecewise import PiecewiseLinearFunction, Triangulation from pyomo.core.expr.compare import ( assertExpressionsEqual, assertExpressionsStructurallyEqual, @@ -118,6 +118,23 @@ def test_pw_linear_approx_of_ln_x_tabular_data(self): ) self.check_ln_x_approx(m.pw, m.x) + def test_pw_linear_approx_of_ln_x_j1(self): + m = self.make_ln_x_model() + m.pw = PiecewiseLinearFunction( + points=[1, 3, 6, 10], triangulation=Triangulation.J1, function=m.f + ) + self.check_ln_x_approx(m.pw, m.x) + # we disregard their request because it's 1D + self.assertEqual(m.pw.triangulation, Triangulation.AssumeValid) + + def test_pw_linear_approx_of_ln_x_user_defined_segments(self): + m = self.make_ln_x_model() + m.pw = PiecewiseLinearFunction( + simplices=[[1, 3], [3, 6], [6, 10]], function=m.f + ) + self.check_ln_x_approx(m.pw, m.x) + self.assertEqual(m.pw.triangulation, Triangulation.Unknown) + def test_use_pw_function_in_constraint(self): m = self.make_ln_x_model() m.pw = PiecewiseLinearFunction( @@ -302,6 +319,27 @@ def test_pw_linear_approx_of_paraboloid_points(self): ) self.check_pw_linear_approximation(m) + @unittest.skipUnless(numpy_available, "numpy is not available") + def test_pw_linear_approx_of_paraboloid_j1(self): + m = self.make_model() + m.pw = PiecewiseLinearFunction( + points=[ + (0, 1), + (0, 4), + (0, 7), + (3, 1), + (3, 4), + (3, 7), + (4, 1), + (4, 4), + (4, 7), + ], + function=m.g, + triangulation=Triangulation.OrderedJ1, + ) + self.assertEqual(len(m.pw._simplices), 8) + self.assertEqual(m.pw.triangulation, Triangulation.OrderedJ1) + @unittest.skipUnless(scipy_available, "scipy is not available") def test_pw_linear_approx_tabular_data(self): m = self.make_model() diff --git a/pyomo/contrib/piecewise/tests/test_reduced_inner_repn.py b/pyomo/contrib/piecewise/tests/test_reduced_inner_repn.py index b70281c83ed..a09aa26abc4 100644 --- a/pyomo/contrib/piecewise/tests/test_reduced_inner_repn.py +++ b/pyomo/contrib/piecewise/tests/test_reduced_inner_repn.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/piecewise/tests/test_triangulations.py b/pyomo/contrib/piecewise/tests/test_triangulations.py new file mode 100644 index 00000000000..7342872f6c2 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_triangulations.py @@ -0,0 +1,245 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +import itertools +from unittest import skipUnless +import pyomo.common.unittest as unittest +from pyomo.contrib.piecewise.ordered_3d_j1_triangulation_data import ( + get_hamiltonian_paths, + _get_double_cube_graph, +) +from pyomo.contrib.piecewise.triangulations import ( + get_unordered_j1_triangulation, + get_ordered_j1_triangulation, + _get_Gn_hamiltonian, + _get_grid_hamiltonian, +) +from pyomo.common.dependencies import numpy as np, numpy_available, networkx_available +from math import factorial +import itertools + + +class TestTriangulations(unittest.TestCase): + + # check basic functionality for the unordered j1 triangulation. + @unittest.skipUnless(numpy_available, "numpy is not available") + def test_J1_small(self): + points = [ + [0.5, 0.5], # 0 + [0.5, 1.5], # 1 + [0.5, 2.5], # 2 + [1.5, 0.5], # 3 + [1.5, 1.5], # 4 + [1.5, 2.5], # 5 + [2.5, 0.5], # 6 + [2.5, 1.5], # 7 + [2.5, 2.5], # 8 + ] + triangulation = get_unordered_j1_triangulation(points, 2) + self.assertTrue( + np.array_equal( + triangulation.simplices, + np.array( + [ + [0, 1, 4], + [1, 2, 4], + [4, 6, 7], + [4, 7, 8], + [0, 3, 4], + [2, 4, 5], + [3, 4, 6], + [4, 5, 8], + ] + ), + ) + ) + + def check_J1_ordered(self, points, num_points, dim): + ordered_triangulation = get_ordered_j1_triangulation(points, dim).simplices + self.assertEqual( + len(ordered_triangulation), factorial(dim) * (num_points - 1) ** dim + ) + for idx, first_simplex in enumerate(ordered_triangulation): + if idx != len(ordered_triangulation) - 1: + second_simplex = ordered_triangulation[idx + 1] + # test property (2) which also guarantees property (1) (from Vielma 2010) + self.assertEqual( + first_simplex[-1], + second_simplex[0], + msg="Last and first vertices of adjacent simplices did not match", + ) + # The way I am constructing these, they should always share an (n-1)-face. + # Check that too for good measure. + count = len(set(first_simplex).intersection(set(second_simplex))) + self.assertEqual(count, dim) # (n-1)-face has n points + + @unittest.skipUnless(numpy_available, "numpy is not available") + def test_J1_ordered_2d(self): + self.check_J1_ordered(list(itertools.product([0, 1, 2], [1, 2.4, 3])), 3, 2) + self.check_J1_ordered( + list(itertools.product([0, 1, 2, 4, 5], [1, 2.4, 3, 5, 6])), 5, 2 + ) + self.check_J1_ordered( + list( + itertools.product([0, 1, 2, 4, 5, 6.3, 7.1], [1, 2.4, 3, 5, 6, 9.1, 10]) + ), + 7, + 2, + ) + self.check_J1_ordered( + list( + itertools.product( + [0, 1, 2, 4, 5, 6.3, 7.1, 7.2, 7.3], + [1, 2.4, 3, 5, 6, 9.1, 10, 11, 12], + ) + ), + 9, + 2, + ) + + @unittest.skipUnless(numpy_available, "numpy is not available") + def test_J1_ordered_3d(self): + self.check_J1_ordered( + list(itertools.product([0, 1, 2], [1, 2.4, 3], [2, 3, 4])), 3, 3 + ) + self.check_J1_ordered( + list( + itertools.product([0, 1, 2, 4, 5], [1, 2.4, 3, 5, 6], [-1, 0, 1, 2, 3]) + ), + 5, + 3, + ) + self.check_J1_ordered( + list( + itertools.product( + [0, 1, 2, 4, 5, 6, 7], + [1, 2.4, 3, 5, 6, 6.5, 7], + [-1, 0, 1, 2, 3, 4, 5], + ) + ), + 7, + 3, + ) + self.check_J1_ordered( + list( + itertools.product( + [0, 1, 2, 4, 5, 6, 7, 8, 9], + [1, 2.4, 3, 5, 6, 6.5, 7, 8, 9], + [-1, 0, 1, 2, 3, 4, 5, 6, 7], + ) + ), + 9, + 3, + ) + + @unittest.skipUnless(numpy_available, "numpy is not available") + def test_J1_ordered_4d_and_above(self): + self.check_J1_ordered( + list( + itertools.product( + [0, 1, 2, 4, 5], + [1, 2.4, 3, 5, 6], + [-1, 0, 1, 2, 3], + [1, 2, 3, 4, 5], + ) + ), + 5, + 4, + ) + self.check_J1_ordered( + list( + itertools.product( + [0, 1, 2, 4, 5], + [1, 2.4, 3, 5, 6], + [-1, 0, 1, 2, 3], + [1, 2, 3, 4, 5], + [2, 3, 4, 5, 6], + ) + ), + 5, + 5, + ) + + def check_Gn_hamiltonian_path(self, n, start_permutation, target_symbol, last): + path = _get_Gn_hamiltonian(n, start_permutation, target_symbol, last) + self.assertEqual(len(path), factorial(n)) + self.assertEqual(path[0], start_permutation) + if last: + self.assertEqual(path[-1][-1], target_symbol) + else: + self.assertEqual(path[-1][0], target_symbol) + for pi in itertools.permutations(range(1, n + 1), n): + self.assertTrue(tuple(pi) in path) + for i in range(len(path) - 1): + diff_indices = [j for j in range(n) if path[i][j] != path[i + 1][j]] + self.assertEqual(len(diff_indices), 2) + self.assertEqual(diff_indices[0], diff_indices[1] - 1) + self.assertEqual(path[i][diff_indices[0]], path[i + 1][diff_indices[1]]) + self.assertEqual(path[i][diff_indices[1]], path[i + 1][diff_indices[0]]) + + def test_Gn_hamiltonian_paths(self): + # each of the base cases + self.check_Gn_hamiltonian_path(4, (1, 2, 3, 4), 1, False) + self.check_Gn_hamiltonian_path(4, (1, 2, 3, 4), 2, False) + self.check_Gn_hamiltonian_path(4, (1, 2, 3, 4), 3, False) + self.check_Gn_hamiltonian_path(4, (1, 2, 3, 4), 4, False) + # some variants with start permutations and/or last + self.check_Gn_hamiltonian_path(4, (3, 4, 1, 2), 2, False) + self.check_Gn_hamiltonian_path(4, (1, 3, 2, 4), 3, True) + self.check_Gn_hamiltonian_path(4, (1, 4, 2, 3), 4, True) + self.check_Gn_hamiltonian_path(4, (1, 2, 3, 4), 2, True) + # some recursive cases + self.check_Gn_hamiltonian_path(5, (1, 2, 3, 4, 5), 1, False) + self.check_Gn_hamiltonian_path(5, (1, 2, 3, 4, 5), 3, False) + self.check_Gn_hamiltonian_path(5, (1, 2, 3, 4, 5), 5, False) + self.check_Gn_hamiltonian_path(5, (1, 2, 4, 3, 5), 5, True) + self.check_Gn_hamiltonian_path(6, (6, 1, 2, 4, 3, 5), 5, True) + self.check_Gn_hamiltonian_path(6, (6, 1, 2, 4, 3, 5), 5, False) + self.check_Gn_hamiltonian_path(7, (1, 2, 3, 4, 5, 6, 7), 7, False) + + def check_grid_hamiltonian(self, dim, length): + path = _get_grid_hamiltonian(dim, length) + self.assertEqual(len(path), length**dim) + for x in itertools.product(range(length), repeat=dim): + self.assertTrue(list(x) in path) + for i in range(len(path) - 1): + diff_indices = [j for j in range(dim) if path[i][j] != path[i + 1][j]] + self.assertEqual(len(diff_indices), 1) + self.assertEqual( + abs(path[i][diff_indices[0]] - path[i + 1][diff_indices[0]]), 1 + ) + + def test_grid_hamiltonian_paths(self): + self.check_grid_hamiltonian(1, 5) + self.check_grid_hamiltonian(2, 5) + self.check_grid_hamiltonian(2, 8) + self.check_grid_hamiltonian(3, 5) + self.check_grid_hamiltonian(4, 3) + + +@unittest.skipUnless(networkx_available, "Networkx is not available") +class TestHamiltonianPaths(unittest.TestCase): + def test_hamiltonian_paths(self): + G = _get_double_cube_graph() + + paths = get_hamiltonian_paths() + self.assertEqual(len(paths), 60) + + for ((s1, t1), (s2, t2)), path in paths.items(): + # ESJ: I'm not quite sure how to check this is *the right* path + # given the key? + + # Check it's Hamiltonian + self.assertEqual(len(path), 48) + # Check it's a path + for idx in range(1, 48): + self.assertTrue(G.has_edge(path[idx - 1], path[idx])) diff --git a/pyomo/contrib/piecewise/transform/__init__.py b/pyomo/contrib/piecewise/transform/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/piecewise/transform/__init__.py +++ b/pyomo/contrib/piecewise/transform/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/piecewise/transform/convex_combination.py b/pyomo/contrib/piecewise/transform/convex_combination.py index 21b72bd9e5d..fe600bb1836 100644 --- a/pyomo/contrib/piecewise/transform/convex_combination.py +++ b/pyomo/contrib/piecewise/transform/convex_combination.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/piecewise/transform/disaggregated_convex_combination.py b/pyomo/contrib/piecewise/transform/disaggregated_convex_combination.py index 0117bf1d045..a7e52bf4f13 100644 --- a/pyomo/contrib/piecewise/transform/disaggregated_convex_combination.py +++ b/pyomo/contrib/piecewise/transform/disaggregated_convex_combination.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/piecewise/transform/disaggregated_logarithmic.py b/pyomo/contrib/piecewise/transform/disaggregated_logarithmic.py new file mode 100644 index 00000000000..5b3022f4def --- /dev/null +++ b/pyomo/contrib/piecewise/transform/disaggregated_logarithmic.py @@ -0,0 +1,200 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.contrib.piecewise.transform.piecewise_linear_transformation_base import ( + PiecewiseLinearTransformationBase, +) +from pyomo.core import Constraint, Binary, Var, RangeSet, Set +from pyomo.core.base import TransformationFactory +from pyomo.common.errors import DeveloperError +from math import ceil, log2 + + +@TransformationFactory.register( + "contrib.piecewise.disaggregated_logarithmic", + doc=""" + Represent a piecewise linear function "logarithmically" by using a MIP with + log_2(|P|) binary decision variables. This is a direct-to-MIP transformation; + GDP is not used. + """, +) +class DisaggregatedLogarithmicMIPTransformation(PiecewiseLinearTransformationBase): + """Represent a piecewise linear function "logarithmically" as a MIP. + + This transformation represents a piecewise linear function + "logarithmically" by using a MIP with :math:`log_2(|P|)` binary + decision variables, following the "disaggregated logarithmic" method + from [VAN10]_. + + This is a direct-to-MIP transformation; GDP is not used. This + method of logarithmically formulating the piecewise linear function + imposes no restrictions on the family of polytopes, but we assume we + have simplices in this code. + + """ + + CONFIG = PiecewiseLinearTransformationBase.CONFIG() + _transformation_name = "pw_linear_disaggregated_log" + + # Implement to use PiecewiseLinearTransformationBase. This function returns the Var + # that replaces the transformed piecewise linear expr + def _transform_pw_linear_expr(self, pw_expr, pw_linear_func, transformation_block): + # Get a new Block for our transformation in transformation_block.transformed_functions, + # which is a Block(Any). This is where we will put our new components. + transBlock = transformation_block.transformed_functions[ + len(transformation_block.transformed_functions) + ] + + # Dimensionality of the PWLF + dimension = pw_expr.nargs() + transBlock.dimension_indices = RangeSet(0, dimension - 1) + + # Substitute Var that will hold the value of the PWLE + substitute_var = transBlock.substitute_var = Var() + pw_linear_func.map_transformation_var(pw_expr, substitute_var) + + # Bounds for the substitute_var that we will widen + substitute_var_lb = float("inf") + substitute_var_ub = -float("inf") + + # Simplices are tuples of indices of points. Give them their own indices, too + simplices = pw_linear_func._simplices + num_simplices = len(simplices) + transBlock.simplex_indices = RangeSet(0, num_simplices - 1) + # Assumption: the simplices are really full-dimensional simplices and all have the + # same number of points, which is dimension + 1 + transBlock.simplex_point_indices = RangeSet(0, dimension) + + # Enumeration of simplices: map from simplex number to simplex object + idx_to_simplex = {k: v for k, v in zip(transBlock.simplex_indices, simplices)} + + # List of tuples of simplex indices with their linear function + simplex_indices_and_lin_funcs = list( + zip(transBlock.simplex_indices, pw_linear_func._linear_functions) + ) + + # We don't seem to get a convenient opportunity later, so let's just widen + # the bounds here. All we need to do is go through the corners of each simplex. + for P, linear_func in simplex_indices_and_lin_funcs: + for v in transBlock.simplex_point_indices: + val = linear_func(*pw_linear_func._points[idx_to_simplex[P][v]]) + if val < substitute_var_lb: + substitute_var_lb = val + if val > substitute_var_ub: + substitute_var_ub = val + transBlock.substitute_var.setlb(substitute_var_lb) + transBlock.substitute_var.setub(substitute_var_ub) + + log_dimension = ceil(log2(num_simplices)) + transBlock.log_simplex_indices = RangeSet(0, log_dimension - 1) + transBlock.binaries = Var(transBlock.log_simplex_indices, domain=Binary) + + # Injective function B: \mathcal{P} -> {0,1}^ceil(log_2(|P|)) used to identify simplices + # (really just polytopes are required) with binary vectors. Any injective function + # is enough here. + B = {} + for i in transBlock.simplex_indices: + # map index(P) -> corresponding vector in {0, 1}^n + B[i] = self._get_binary_vector(i, log_dimension) + + # Build up P_0 and P_plus ahead of time. + + # {P \in \mathcal{P} | B(P)_l = 0} + def P_0_init(m, l): + return [p for p in transBlock.simplex_indices if B[p][l] == 0] + + transBlock.P_0 = Set(transBlock.log_simplex_indices, initialize=P_0_init) + + # {P \in \mathcal{P} | B(P)_l = 1} + def P_plus_init(m, l): + return [p for p in transBlock.simplex_indices if B[p][l] == 1] + + transBlock.P_plus = Set(transBlock.log_simplex_indices, initialize=P_plus_init) + + # The lambda variables \lambda_{P,v} are indexed by the simplex and the point in it + transBlock.lambdas = Var( + transBlock.simplex_indices, transBlock.simplex_point_indices, bounds=(0, 1) + ) + + # Numbered citations are from Vielma et al 2010, Mixed-Integer Models + # for Nonseparable Piecewise-Linear Optimization + + # Sum of all lambdas is one (6b) + transBlock.convex_combo = Constraint( + expr=sum( + transBlock.lambdas[P, v] + for P in transBlock.simplex_indices + for v in transBlock.simplex_point_indices + ) + == 1 + ) + + # The branching rules, establishing using the binaries that only one simplex's lambda + # coefficients may be nonzero + # Enabling lambdas when binaries are on + @transBlock.Constraint(transBlock.log_simplex_indices) # (6c.1) + def simplex_choice_1(b, l): + return ( + sum( + transBlock.lambdas[P, v] + for P in transBlock.P_plus[l] + for v in transBlock.simplex_point_indices + ) + <= transBlock.binaries[l] + ) + + # Disabling lambdas when binaries are on + @transBlock.Constraint(transBlock.log_simplex_indices) # (6c.2) + def simplex_choice_2(b, l): + return ( + sum( + transBlock.lambdas[P, v] + for P in transBlock.P_0[l] + for v in transBlock.simplex_point_indices + ) + <= 1 - transBlock.binaries[l] + ) + + # for i, (simplex, pwlf) in enumerate(choices): + # x_i = sum(lambda_P,v v_i, P in polytopes, v in V(P)) + @transBlock.Constraint(transBlock.dimension_indices) # (6a.1) + def x_constraint(b, i): + return pw_expr.args[i] == sum( + transBlock.lambdas[P, v] + * pw_linear_func._points[idx_to_simplex[P][v]][i] + for P in transBlock.simplex_indices + for v in transBlock.simplex_point_indices + ) + + # Make the substitute Var equal the PWLE (6a.2) + transBlock.set_substitute = Constraint( + expr=substitute_var + == sum( + transBlock.lambdas[P, v] + * linear_func(*pw_linear_func._points[idx_to_simplex[P][v]]) + for v in transBlock.simplex_point_indices + for (P, linear_func) in simplex_indices_and_lin_funcs + ) + ) + + return substitute_var + + # Not a Gray code, just a regular binary representation + # TODO test the Gray codes too + # note: Must have num != 0 and ceil(log2(num)) > length to be valid + def _get_binary_vector(self, num, length): + ans = [] + for i in range(length): + ans.append(num & 1) + num >>= 1 + assert not num + ans.reverse() + return tuple(ans) diff --git a/pyomo/contrib/piecewise/transform/incremental.py b/pyomo/contrib/piecewise/transform/incremental.py new file mode 100644 index 00000000000..801710b0690 --- /dev/null +++ b/pyomo/contrib/piecewise/transform/incremental.py @@ -0,0 +1,188 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.contrib.piecewise.transform.piecewise_linear_transformation_base import ( + PiecewiseLinearTransformationBase, +) +from pyomo.contrib.piecewise.triangulations import Triangulation +from pyomo.core import Constraint, Binary, Var, RangeSet, Param +from pyomo.core.base import TransformationFactory + + +@TransformationFactory.register( + "contrib.piecewise.incremental", + doc=""" + The incremental MIP formulation of a piecewise-linear function, as described + by [1]. To work in the multivariate case, the underlying triangulation must + satisfy these properties: + (1) The simplices are ordered T_1, ..., T_N such that T_i has nonempty intersection + with T_{i+1}. It doesn't have to be a whole face; just a vertex is enough. + (2) On each simplex T_i, the vertices are ordered T_i^1, ..., T_i^n such + that T_i^n = T_{i+1}^1 + In Pyomo, the Triangulation.OrderedJ1 triangulation is compatible with this + transformation. + + References + ---------- + [1] J.P. Vielma, S. Ahmed, and G. Nemhauser, "Mixed-integer models + for nonseparable piecewise-linear optimization: unifying framework + and extensions," Operations Research, vol. 58, no. 2, pp. 305-315, + 2010. + """, +) +class IncrementalMIPTransformation(PiecewiseLinearTransformationBase): + + CONFIG = PiecewiseLinearTransformationBase.CONFIG() + _transformation_name = "pw_linear_incremental" + + # Implement to use PiecewiseLinearTransformationBase. This function returns the Var + # that replaces the transformed piecewise linear expr + def _transform_pw_linear_expr(self, pw_expr, pw_linear_func, transformation_block): + if pw_linear_func.triangulation not in ( + Triangulation.OrderedJ1, + Triangulation.AssumeValid, + ): + # almost certain not to work + raise ValueError( + "Incremental transformation specified, but the triangulation " + f"{pw_linear_func.triangulation} may not be appropriately ordered. This " + "would likely lead to incorrect results! The built-in " + "Triangulation.OrderedJ1 triangulation has an appropriate ordering for " + "this transformation. If you know what you are doing, you can also " + "suppress this error by setting the triangulation tag to " + "Triangulation.AssumeValid during PiecewiseLinearFunction construction." + ) + # Get a new Block() in transformation_block.transformed_functions, which + # is a Block(Any) + transBlock = transformation_block.transformed_functions[ + len(transformation_block.transformed_functions) + ] + + # Dimensionality of the PWLF + dimension = pw_expr.nargs() + transBlock.dimension_indices = RangeSet(0, dimension - 1) + + # Substitute Var that will hold the value of the PWLE + substitute_var = transBlock.substitute_var = Var() + pw_linear_func.map_transformation_var(pw_expr, substitute_var) + + # Bounds for the substitute_var that we will widen + substitute_var_lb = float("inf") + substitute_var_ub = -float("inf") + + # Simplices are tuples of indices of points. Give them their own indices, too + simplices = pw_linear_func._simplices + num_simplices = len(simplices) + transBlock.simplex_indices = RangeSet(0, num_simplices - 1) + transBlock.simplex_indices_except_last = RangeSet(0, num_simplices - 2) + # Assumption: the simplices are really simplices and all have the same number of + # points, which is dimension + 1 + transBlock.simplex_point_indices = RangeSet(0, dimension) + transBlock.nonzero_simplex_point_indices = RangeSet(1, dimension) + transBlock.last_simplex_point_index = Param(initialize=dimension) + + # We don't seem to get a convenient opportunity later, so let's just widen + # the bounds here. All we need to do is go through the corners of each simplex. + for P, linear_func in zip( + transBlock.simplex_indices, pw_linear_func._linear_functions + ): + for v in transBlock.simplex_point_indices: + val = linear_func(*pw_linear_func._points[simplices[P][v]]) + if val < substitute_var_lb: + substitute_var_lb = val + if val > substitute_var_ub: + substitute_var_ub = val + # Now set those bounds + transBlock.substitute_var.setlb(substitute_var_lb) + transBlock.substitute_var.setub(substitute_var_ub) + + # Initial vertex (v_0^0 in Vielma) + initial_vertex = pw_linear_func._points[simplices[0][0]] + + # delta_i^j = delta[simplex][point] + transBlock.delta = Var( + transBlock.simplex_indices, + transBlock.nonzero_simplex_point_indices, + bounds=(0, 1), + ) + transBlock.delta_one_constraint = Constraint( + # 0 for for us because we are indexing from zero here (12b.1) + expr=sum( + transBlock.delta[0, j] for j in transBlock.nonzero_simplex_point_indices + ) + <= 1 + ) + # Set up the binary y_i variables, which interleave with the delta_i^j in + # an odd way + transBlock.y_binaries = Var( + transBlock.simplex_indices_except_last, domain=Binary + ) + + # If the delta for the final point in simplex i is not one, y_i must be zero. + # That is, y_i is one for and only for simplices that are completely "used" + @transBlock.Constraint(transBlock.simplex_indices_except_last) + def y_below_delta(m, i): + return ( + transBlock.y_binaries[i] + <= transBlock.delta[i, transBlock.last_simplex_point_index] + ) + + # The sum of the deltas for simplex i+1 should be less than y_i. The overall + # effect of these two constraints is that for simplices with y_i=1, the final + # delta being one and others zero is enforced. For the first simplex with y_i=0, + # the choice of deltas is free except that they must add to one. For following + # simplices with y_i=0, all deltas are fixed at zero. + @transBlock.Constraint(transBlock.simplex_indices_except_last) + def deltas_below_y(m, i): + return ( + sum( + transBlock.delta[i + 1, j] + for j in transBlock.nonzero_simplex_point_indices + ) + <= transBlock.y_binaries[i] + ) + + # Now we can relate the deltas and x. x is a sum along differences of points, + # weighted by deltas (12a.1) + @transBlock.Constraint(transBlock.dimension_indices) + def x_constraint(b, n): + return pw_expr.args[n] == initial_vertex[n] + sum( + # delta_i^j * (v_i^j - v_i^0) + transBlock.delta[i, j] + * ( + pw_linear_func._points[simplices[i][j]][n] + - pw_linear_func._points[simplices[i][0]][n] + ) + for j in transBlock.nonzero_simplex_point_indices + for i in transBlock.simplex_indices + ) + + # Now we can set the substitute Var for the PWLE (12a.2) + transBlock.set_substitute = Constraint( + expr=substitute_var + == pw_linear_func._linear_functions[0](*initial_vertex) + + sum( + # delta_i^j * (f(v_i^j) - f(v_i^0)) + transBlock.delta[i, j] + * ( + pw_linear_func._linear_functions[i]( + *pw_linear_func._points[simplices[i][j]] + ) + - pw_linear_func._linear_functions[i]( + *pw_linear_func._points[simplices[i][0]] + ) + ) + for j in transBlock.nonzero_simplex_point_indices + for i in transBlock.simplex_indices + ) + ) + + return substitute_var diff --git a/pyomo/contrib/piecewise/transform/inner_representation_gdp.py b/pyomo/contrib/piecewise/transform/inner_representation_gdp.py index f0be2d98825..1c205d74563 100644 --- a/pyomo/contrib/piecewise/transform/inner_representation_gdp.py +++ b/pyomo/contrib/piecewise/transform/inner_representation_gdp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,8 +10,8 @@ # ___________________________________________________________________________ from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr -from pyomo.contrib.piecewise.transform.piecewise_to_gdp_transformation import ( - PiecewiseLinearToGDP, +from pyomo.contrib.piecewise.transform.piecewise_linear_transformation_base import ( + PiecewiseLinearTransformationBase, ) from pyomo.core import Constraint, NonNegativeIntegers, Suffix, Var from pyomo.core.base import TransformationFactory @@ -25,7 +25,7 @@ "simplices that are the domains of the linear " "functions.", ) -class InnerRepresentationGDPTransformation(PiecewiseLinearToGDP): +class InnerRepresentationGDPTransformation(PiecewiseLinearTransformationBase): """ Convert a model involving piecewise linear expressions into a GDP by representing the piecewise linear functions as Disjunctions where the @@ -49,7 +49,7 @@ class InnerRepresentationGDPTransformation(PiecewiseLinearToGDP): this mode, targets must be Blocks, Constraints, and/or Objectives. """ - CONFIG = PiecewiseLinearToGDP.CONFIG() + CONFIG = PiecewiseLinearTransformationBase.CONFIG() _transformation_name = 'pw_linear_inner_repn' def _transform_pw_linear_expr(self, pw_expr, pw_linear_func, transformation_block): diff --git a/pyomo/contrib/piecewise/transform/multiple_choice.py b/pyomo/contrib/piecewise/transform/multiple_choice.py index 9291afa8862..4a6ec0d0e26 100644 --- a/pyomo/contrib/piecewise/transform/multiple_choice.py +++ b/pyomo/contrib/piecewise/transform/multiple_choice.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/piecewise/transform/nested_inner_repn.py b/pyomo/contrib/piecewise/transform/nested_inner_repn.py new file mode 100644 index 00000000000..bba5e633760 --- /dev/null +++ b/pyomo/contrib/piecewise/transform/nested_inner_repn.py @@ -0,0 +1,209 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.contrib.piecewise.transform.piecewise_linear_transformation_base import ( + PiecewiseLinearTransformationBase, +) +from pyomo.core import Constraint, NonNegativeIntegers, Suffix, Var +from pyomo.core.base import TransformationFactory +from pyomo.gdp import Disjunction +from pyomo.common.errors import DeveloperError + + +@TransformationFactory.register( + "contrib.piecewise.nested_inner_repn_gdp", + doc=""" + Represent a piecewise linear function by using a nested GDP to determine + which polytope a point is in, then representing it as a convex combination + of extreme points, with multipliers "local" to that particular polytope, + i.e., not shared with neighbors. This formulation has linearly many Boolean + variables, though up to variable substitution, it has logarithmically many. + """, +) +class NestedInnerRepresentationGDPTransformation(PiecewiseLinearTransformationBase): + """ + Represent a piecewise linear function by using a nested GDP to determine + which polytope a point is in, then representing it as a convex combination + of extreme points, with multipliers "local" to that particular polytope, + i.e., not shared with neighbors. This method of formulating the piecewise + linear function imposes no restrictions on the family of polytopes. Note + that this is NOT a logarithmic formulation - it has linearly many Boolean + variables. However, it is inspired by the disaggregated logarithmic + formulation of [1]. Up to variable substitution, the amount of Boolean + variables is logarithmic, as in [1]. + + References + ---------- + [1] J.P. Vielma, S. Ahmed, and G. Nemhauser, "Mixed-integer models + for nonseparable piecewise-linear optimization: unifying framework + and extensions," Operations Research, vol. 58, no. 2, pp. 305-315, + 2010. + """ + + CONFIG = PiecewiseLinearTransformationBase.CONFIG() + _transformation_name = "pw_linear_nested_inner_repn" + + # Implement to use PiecewiseLinearTransformationBase. This function returns the Var + # that replaces the transformed piecewise linear expr + def _transform_pw_linear_expr(self, pw_expr, pw_linear_func, transformation_block): + # Get a new Block() in transformation_block.transformed_functions, which + # is a Block(Any) + transBlock = transformation_block.transformed_functions[ + len(transformation_block.transformed_functions) + ] + + substitute_var = transBlock.substitute_var = Var() + pw_linear_func.map_transformation_var(pw_expr, substitute_var) + transBlock.substitute_var_lb = float("inf") + transBlock.substitute_var_ub = -float("inf") + + choices = list(zip(pw_linear_func._simplices, pw_linear_func._linear_functions)) + + # If there was only one choice, don't bother making a disjunction, just + # use the linear function directly (but still use the substitute_var for + # consistency). + if len(choices) == 1: + (_, linear_func) = choices[0] # simplex isn't important in this case + linear_func_expr = linear_func(*pw_expr.args) + transBlock.set_substitute = Constraint( + expr=substitute_var == linear_func_expr + ) + (transBlock.substitute_var_lb, transBlock.substitute_var_ub) = ( + compute_bounds_on_expr(linear_func_expr) + ) + else: + # Add the disjunction + transBlock.disj = self._get_disjunction( + choices, transBlock, pw_expr, pw_linear_func, transBlock + ) + + # Set bounds as determined when setting up the disjunction + if transBlock.substitute_var_lb < float("inf"): + transBlock.substitute_var.setlb(transBlock.substitute_var_lb) + if transBlock.substitute_var_ub > -float("inf"): + transBlock.substitute_var.setub(transBlock.substitute_var_ub) + + return substitute_var + + # Recursively form the Disjunctions and Disjuncts. This shouldn't blow up + # the stack, since the whole point is that we'll only go logarithmically + # many calls deep. + def _get_disjunction( + self, choices, parent_block, pw_expr, pw_linear_func, root_block + ): + size = len(choices) + + # Our base cases will be 3 and 2, since it would be silly to construct + # a Disjunction containing only one Disjunct. We can ensure that size + # is never 1 unless it was only passed a single choice from the start, + # which we can handle before calling. + if size > 3: + half = size // 2 # (integer divide) + # This tree will be slightly heavier on the right side + choices_l = choices[:half] + choices_r = choices[half:] + + @parent_block.Disjunct() + def d_l(b): + b.inner_disjunction_l = self._get_disjunction( + choices_l, b, pw_expr, pw_linear_func, root_block + ) + + @parent_block.Disjunct() + def d_r(b): + b.inner_disjunction_r = self._get_disjunction( + choices_r, b, pw_expr, pw_linear_func, root_block + ) + + return Disjunction(expr=[parent_block.d_l, parent_block.d_r]) + elif size == 3: + # Let's stay heavier on the right side for consistency. So the left + # Disjunct will be the one to contain constraints, rather than a + # Disjunction + @parent_block.Disjunct() + def d_l(b): + simplex, linear_func = choices[0] + self._set_disjunct_block_constraints( + b, simplex, linear_func, pw_expr, pw_linear_func, root_block + ) + + @parent_block.Disjunct() + def d_r(b): + b.inner_disjunction_r = self._get_disjunction( + choices[1:], b, pw_expr, pw_linear_func, root_block + ) + + return Disjunction(expr=[parent_block.d_l, parent_block.d_r]) + elif size == 2: + # In this case both sides are regular Disjuncts + @parent_block.Disjunct() + def d_l(b): + simplex, linear_func = choices[0] + self._set_disjunct_block_constraints( + b, simplex, linear_func, pw_expr, pw_linear_func, root_block + ) + + @parent_block.Disjunct() + def d_r(b): + simplex, linear_func = choices[1] + self._set_disjunct_block_constraints( + b, simplex, linear_func, pw_expr, pw_linear_func, root_block + ) + + return Disjunction(expr=[parent_block.d_l, parent_block.d_r]) + else: + raise DeveloperError( + "Unreachable: 1 or 0 choices were passed to " + "_get_disjunction in nested_inner_repn.py." + ) + + def _set_disjunct_block_constraints( + self, b, simplex, linear_func, pw_expr, pw_linear_func, root_block + ): + # Define the lambdas sparsely like in the normal inner repn, + # only the first few will participate in constraints + b.lambdas = Var(NonNegativeIntegers, dense=False, bounds=(0, 1)) + + # Get the extreme points to add up + extreme_pts = [] + for idx in simplex: + extreme_pts.append(pw_linear_func._points[idx]) + + # Constrain sum(lambda_i) = 1 + b.convex_combo = Constraint( + expr=sum(b.lambdas[i] for i in range(len(extreme_pts))) == 1 + ) + linear_func_expr = linear_func(*pw_expr.args) + + # Make the substitute Var equal the PWLE + b.set_substitute = Constraint( + expr=root_block.substitute_var == linear_func_expr + ) + + # Widen the variable bounds to those of this linear func expression + (lb, ub) = compute_bounds_on_expr(linear_func_expr) + if lb is not None and lb < root_block.substitute_var_lb: + root_block.substitute_var_lb = lb + if ub is not None and ub > root_block.substitute_var_ub: + root_block.substitute_var_ub = ub + + # Constrain x = \sum \lambda_i v_i + @b.Constraint(range(pw_expr.nargs())) # dimension + def linear_combo(d, i): + return pw_expr.args[i] == sum( + d.lambdas[j] * pt[i] for j, pt in enumerate(extreme_pts) + ) + + # Mark the lambdas as local in order to prevent disagreggating multiple + # times in the hull transformation + b.LocalVars = Suffix(direction=Suffix.LOCAL) + b.LocalVars[b] = [v for v in b.lambdas.values()] diff --git a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py new file mode 100644 index 00000000000..371918b6815 --- /dev/null +++ b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py @@ -0,0 +1,753 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from collections import defaultdict +import itertools + +import logging + +from pyomo.environ import ( + TransformationFactory, + Transformation, + Var, + Constraint, + Objective, + Any, + value, + BooleanVar, + Connector, + Expression, + Suffix, + Param, + Set, + SetOf, + RangeSet, + Block, + ExternalFunction, + SortComponents, + LogicalConstraint, +) +from pyomo.common.autoslots import AutoSlots +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.config import ConfigDict, ConfigValue, PositiveInt, InEnum +from pyomo.common.dependencies import attempt_import +from pyomo.common.dependencies import numpy as np +from pyomo.common.enums import IntEnum +from pyomo.common.modeling import unique_component_name +from pyomo.core.expr.numeric_expr import SumExpression +from pyomo.core.expr import identify_variables +from pyomo.core.expr import SumExpression +from pyomo.core.util import target_list +from pyomo.contrib.piecewise import PiecewiseLinearExpression, PiecewiseLinearFunction +from pyomo.gdp import Disjunct, Disjunction +from pyomo.network import Port +from pyomo.repn.quadratic import QuadraticRepnVisitor +from pyomo.repn.util import ExprType + + +lineartree, lineartree_available = attempt_import('lineartree') +sklearn_lm, sklearn_available = attempt_import('sklearn.linear_model') + +logger = logging.getLogger(__name__) + + +class DomainPartitioningMethod(IntEnum): + RANDOM_GRID = 1 + UNIFORM_GRID = 2 + LINEAR_MODEL_TREE_UNIFORM = 3 + LINEAR_MODEL_TREE_RANDOM = 4 + + +class _NonlinearToPWLTransformationData(AutoSlots.Mixin): + __slots__ = ( + 'transformed_component', + 'src_component', + 'transformed_constraints', + 'transformed_objectives', + ) + + def __init__(self): + self.transformed_component = ComponentMap() + self.src_component = ComponentMap() + self.transformed_constraints = defaultdict(ComponentSet) + self.transformed_objectives = defaultdict(ComponentSet) + + +Block.register_private_data_initializer(_NonlinearToPWLTransformationData) + + +def _get_random_point_grid(bounds, n, func, config, seed=42): + # Generate randomized grid of points + linspaces = [] + np.random.seed(seed) + for (lb, ub), is_integer in bounds: + if not is_integer: + linspaces.append(np.random.uniform(lb, ub, n)) + else: + size = min(n, ub - lb + 1) + linspaces.append( + np.random.choice(range(lb, ub + 1), size=size, replace=False) + ) + return list(itertools.product(*linspaces)) + + +def _get_uniform_point_grid(bounds, n, func, config): + # Generate non-randomized grid of points + linspaces = [] + for (lb, ub), is_integer in bounds: + if not is_integer: + # Issues happen when exactly using the boundary + nudge = (ub - lb) * 1e-4 + linspaces.append(np.linspace(lb + nudge, ub - nudge, n)) + else: + size = min(n, ub - lb + 1) + pts = np.linspace(lb, ub, size) + linspaces.append(np.array([round(i) for i in pts])) + return list(itertools.product(*linspaces)) + + +def _get_points_lmt_random_sample(bounds, n, func, config, seed=42): + points = _get_random_point_grid(bounds, n, func, config, seed=seed) + return _get_points_lmt(points, bounds, func, config, seed) + + +def _get_points_lmt_uniform_sample(bounds, n, func, config, seed=42): + points = _get_uniform_point_grid(bounds, n, func, config) + return _get_points_lmt(points, bounds, func, config, seed) + + +def _get_points_lmt(points, bounds, func, config, seed): + x_list = np.array(points) + y_list = [] + + for point in points: + y_list.append(func(*point)) + max_depth = config.linear_tree_max_depth + if max_depth is None: + # Want the tree to grow with increasing points but not get too large. + max_depth = max(4, int(np.log2(len(points) / 4))) + regr = lineartree.LinearTreeRegressor( + sklearn_lm.LinearRegression(), + criterion='mse', + max_bins=120, + min_samples_leaf=4, + max_depth=max_depth, + ) + regr.fit(x_list, y_list) + + leaves, splits, thresholds = _parse_linear_tree_regressor(regr, bounds) + + bound_point_list = _generate_bound_points(leaves, bounds) + return bound_point_list + + +_partition_method_dispatcher = { + DomainPartitioningMethod.RANDOM_GRID: _get_random_point_grid, + DomainPartitioningMethod.UNIFORM_GRID: _get_uniform_point_grid, + DomainPartitioningMethod.LINEAR_MODEL_TREE_UNIFORM: _get_points_lmt_uniform_sample, + DomainPartitioningMethod.LINEAR_MODEL_TREE_RANDOM: _get_points_lmt_random_sample, +} + + +def _get_pwl_function_approximation(func, config, bounds): + """ + Get a piecewise-linear approximation of a function, given: + + func: function to approximate + config: ConfigDict for transformation, specifying domain_partitioning_method, + num_points, and max_depth (if using linear trees) + bounds: list of tuples giving upper and lower bounds and a boolean indicating + if the variable's domain is discrete or not, for each of func's arguments + """ + method = config.domain_partitioning_method + n = config.num_points + points = _partition_method_dispatcher[method](bounds, n, func, config) + + # Don't confuse PiecewiseLinearFunction constructor... + dim = len(points[0]) + if dim == 1: + points = [pt[0] for pt in points] + + # After getting the points, construct PWLF using the + # function-and-list-of-points constructor + logger.debug( + f"Constructing PWLF with {len(points)} points, each of which " + f"are {dim}-dimensional" + ) + return PiecewiseLinearFunction(points=points, function=func) + + +# Given a leaves dict (as generated by parse_tree) and a list of tuples +# representing variable bounds, generate the set of vertices separating each +# subset of the domain +def _generate_bound_points(leaves, bounds): + bound_points = [] + for leaf in leaves.values(): + lower_corner_list = [] + upper_corner_list = [] + for var_bound in leaf['bounds'].values(): + lower_corner_list.append(var_bound[0]) + upper_corner_list.append(var_bound[1]) + + for pt in [lower_corner_list, upper_corner_list]: + for i in range(len(pt)): + # clamp within bounds range + pt[i] = max(pt[i], bounds[i][0][0]) + pt[i] = min(pt[i], bounds[i][0][1]) + + if tuple(lower_corner_list) not in bound_points: + bound_points.append(tuple(lower_corner_list)) + if tuple(upper_corner_list) not in bound_points: + bound_points.append(tuple(upper_corner_list)) + + # This process should have gotten every interior bound point. However, all + # but two of the corners of the overall bounding box should have been + # missed. Let's fix that now. + for outer_corner in itertools.product(*[b[0] for b in bounds]): + if outer_corner not in bound_points: + bound_points.append(outer_corner) + return bound_points + + +# Parse a LinearTreeRegressor and identify features such as bounds, slope, and +# intercept for leaves. Return some dicts. +def _parse_linear_tree_regressor(linear_tree_regressor, bounds): + leaves = linear_tree_regressor.summary(only_leaves=True) + splits = linear_tree_regressor.summary() + + for key, leaf in leaves.items(): + del splits[key] + leaf['bounds'] = {} + leaf['slope'] = list(leaf['models'].coef_) + leaf['intercept'] = leaf['models'].intercept_ + + L = np.array(list(leaves.keys())) + features = np.arange(0, len(leaves[L[0]]['slope'])) + + for node in splits.values(): + left_child_node = node['children'][0] # find its left child + right_child_node = node['children'][1] # find its right child + # create the list to save leaves + node['left_leaves'], node['right_leaves'] = [], [] + if left_child_node in leaves: # if left child is a leaf node + node['left_leaves'].append(left_child_node) + else: # traverse its left node by calling function to find all the + # leaves from its left node + node['left_leaves'] = _find_leaves(splits, leaves, splits[left_child_node]) + if right_child_node in leaves: # if right child is a leaf node + node['right_leaves'].append(right_child_node) + else: # traverse its right node by calling function to find all the + # leaves from its right node + node['right_leaves'] = _find_leaves( + splits, leaves, splits[right_child_node] + ) + + # For each feature in each leaf, initialize lower and upper bounds to None + for th in features: + for leaf in leaves: + leaves[leaf]['bounds'][th] = [None, None] + for split in splits: + var = splits[split]['col'] + for leaf in splits[split]['left_leaves']: + leaves[leaf]['bounds'][var][1] = splits[split]['th'] + + for leaf in splits[split]['right_leaves']: + leaves[leaf]['bounds'][var][0] = splits[split]['th'] + + leaves_new = _reassign_none_bounds(leaves, bounds) + splitting_thresholds = {} + for split in splits: + var = splits[split]['col'] + splitting_thresholds[var] = {} + for split in splits: + var = splits[split]['col'] + splitting_thresholds[var][split] = splits[split]['th'] + # Make sure every nested dictionary in the splitting_thresholds dictionary + # is sorted by value + for var in splitting_thresholds: + splitting_thresholds[var] = dict( + sorted(splitting_thresholds[var].items(), key=lambda x: x[1]) + ) + + return leaves_new, splits, splitting_thresholds + + +# This doesn't catch all additively separable expressions--we really need a +# walker (as does gdp.partition_disjuncts) +def _additively_decompose_expr(input_expr, min_dimension): + dimension = len(list(identify_variables(input_expr))) + if input_expr.__class__ is not SumExpression or dimension < min_dimension: + # This isn't separable or we don't want to separate it, so we just have + # the one expression + return [input_expr] + # else, it was a SumExpression, and we will break it into the summands + return list(input_expr.args) + + +# Populate the "None" bounds with the bounding box bounds for a leaves-dict-tree +# amalgamation. +def _reassign_none_bounds(leaves, input_bounds): + L = np.array(list(leaves.keys())) + features = np.arange(0, len(leaves[L[0]]['slope'])) + + for l in L: + for f in features: + if leaves[l]['bounds'][f][0] == None: + leaves[l]['bounds'][f][0] = input_bounds[f][0][0] + if leaves[l]['bounds'][f][1] == None: + leaves[l]['bounds'][f][1] = input_bounds[f][0][1] + return leaves + + +def _find_leaves(splits, leaves, input_node): + root_node = input_node + leaves_list = [] + queue = [root_node] + while queue: + node = queue.pop() + node_left = node['children'][0] + node_right = node['children'][1] + if node_left in leaves: + leaves_list.append(node_left) + else: + queue.append(splits[node_left]) + if node_right in leaves: + leaves_list.append(node_right) + else: + queue.append(splits[node_right]) + return leaves_list + + +@TransformationFactory.register( + 'contrib.piecewise.nonlinear_to_pwl', + doc="Convert nonlinear constraints and objectives to piecewise-linear " + "approximations.", +) +class NonlinearToPWL(Transformation): + """ + Convert nonlinear constraints and objectives to piecewise-linear approximations. + """ + + CONFIG = ConfigDict('contrib.piecewise.nonlinear_to_pwl') + CONFIG.declare( + 'targets', + ConfigValue( + default=None, + domain=target_list, + description="target or list of targets that will be approximated", + doc=""" + This specifies the list of components to approximate. If None (default), + the entire model is transformed. Note that if the transformation is + done out of place, the list of targets should be attached to the model + before it is cloned, and the list will specify the targets on the cloned + instance.""", + ), + ) + CONFIG.declare( + 'num_points', + ConfigValue( + default=3, + domain=PositiveInt, + description="Number of breakpoints for each piecewise-linear approximation", + doc=""" + Specifies the number of points in each function domain to triangulate in + order to construct the piecewise-linear approximation. Must be an integer + greater than 1.""", + ), + ) + CONFIG.declare( + 'domain_partitioning_method', + ConfigValue( + default=DomainPartitioningMethod.UNIFORM_GRID, + domain=InEnum(DomainPartitioningMethod), + description="Method for sampling points that will partition function " + "domains.", + doc=""" + The method by which the points used to partition each function domain + are selected. By default, the range of each variable is partitioned + uniformly, however it is possible to sample randomly or to use the + partitions from training a linear model tree based on either uniform + or random samples of the ranges.""", + ), + ) + CONFIG.declare( + 'approximate_quadratic_constraints', + ConfigValue( + default=True, + domain=bool, + description="Whether or not to approximate quadratic constraints.", + doc=""" + Whether or not to calculate piecewise-linear approximations for + quadratic constraints. If True, the resulting approximation will be + a mixed-integer linear program. If False, the resulting approximation + will be a mixed-integer quadratic program.""", + ), + ) + CONFIG.declare( + 'approximate_quadratic_objectives', + ConfigValue( + default=True, + domain=bool, + description="Whether or not to approximate quadratic objectives.", + doc=""" + Whether or not to calculate piecewise-linear approximations for + quadratic objectives. If True, the resulting approximation will be + a mixed-integer linear program. If False, the resulting approximation + will be a mixed-integer quadratic program.""", + ), + ) + CONFIG.declare( + 'additively_decompose', + ConfigValue( + default=False, + domain=bool, + description="Whether or not to additively decompose constraint expressions " + "and approximate the summands separately.", + doc=""" + If False, each nonlinear constraint expression will be approximated by + exactly one piecewise-linear function. If True, constraints will be + additively decomposed, and each of the resulting summands will be + approximated by a separate piecewise-linear function. + + It is recommended to leave this False as long as no nonlinear constraint + involves more than about 5-6 variables. For constraints with higher- + dimmensional nonlinear functions, additive decomposition will improve + the scalability of the approximation (since partitioning the domain is + subject to the curse of dimensionality).""", + ), + ) + CONFIG.declare( + 'max_dimension', + ConfigValue( + default=5, + domain=PositiveInt, + description="The maximum dimension of functions that will be approximated.", + doc=""" + Specifies the maximum dimension function the transformation should + attempt to approximate. If a nonlinear function dimension exceeds + 'max_dimension' the transformation will log a warning and leave the + expression as-is. For functions with dimension significantly above the + default (5), it is likely that this transformation will stall + triangulating the points in order to partition the function domain.""", + ), + ) + CONFIG.declare( + 'min_dimension_to_additively_decompose', + ConfigValue( + default=1, + domain=PositiveInt, + description="The minimum dimension of functions that will be additively " + "decomposed.", + doc=""" + Specifies the minimum dimension of a function that the transformation + should attempt to additively decompose. If a nonlinear function dimension + exceeds 'min_dimension_to_additively_decompose' the transformation will + additively decompose. If a the dimension of an expression is less than + the 'min_dimension_to_additively_decompose' then it will not be additively + decomposed""", + ), + ) + CONFIG.declare( + 'linear_tree_max_depth', + ConfigValue( + default=None, + domain=PositiveInt, + description="Maximum depth for linear tree training, used if using a " + "domain partitioning method based on linear model trees.", + doc=""" + Only used if 'domain_partitioning_method' is LINEAR_MODEL_TREE_UNIFORM or + LINEAR_MODEL_TREE_RANDOM: Specifies the maximum depth of the linear model + trees trained to determine the points to be triangulated to form the + domain of the piecewise-linear approximations. If None (the default), + the max depth will be given as max(4, ln(num_points / 4)). + """, + ), + ) + + def __init__(self): + super(Transformation).__init__() + self._handlers = { + Constraint: self._transform_constraint, + Objective: self._transform_objective, + Var: False, + BooleanVar: False, + Connector: False, + Expression: False, + Suffix: False, + Param: False, + Set: False, + SetOf: False, + RangeSet: False, + Disjunction: False, + Disjunct: self._transform_block_components, + Block: self._transform_block_components, + ExternalFunction: False, + Port: False, + PiecewiseLinearFunction: False, + LogicalConstraint: False, + } + self._transformation_blocks = {} + self._transformation_block_set = ComponentSet() + self._quadratic_repn_visitor = QuadraticRepnVisitor( + subexpression_cache={}, var_map={}, var_order={}, sorter=None + ) + + def _apply_to(self, instance, **kwds): + try: + self._apply_to_impl(instance, **kwds) + finally: + self._transformation_blocks.clear() + self._transformation_block_set.clear() + + def _apply_to_impl(self, model, **kwds): + config = self.CONFIG(kwds.pop('options', {})) + config.set_value(kwds) + + targets = config.targets + if targets is None: + targets = (model,) + + for target in targets: + if target.ctype is Block or target.ctype is Disjunct: + self._transform_block_components(target, config) + elif target.ctype is Constraint: + self._transform_constraint(target, config) + elif target.ctype is Objective: + self._transform_objective(target, config) + else: + raise ValueError( + "Target '%s' is not a Block, Constraint, or Objective. It " + "is of type '%s' and cannot be transformed." + % (target.name, type(target)) + ) + + def _get_transformation_block(self, parent): + if parent in self._transformation_blocks: + return self._transformation_blocks[parent] + + nm = unique_component_name(parent, '_pyomo_contrib_nonlinear_to_pwl') + self._transformation_blocks[parent] = transBlock = Block() + parent.add_component(nm, transBlock) + self._transformation_block_set.add(transBlock) + + transBlock._pwl_cons = Constraint(Any) + return transBlock + + def _transform_block_components(self, block, config): + blocks = block.values() if block.is_indexed() else (block,) + for b in blocks: + for obj in b.component_objects( + active=True, descend_into=False, sort=SortComponents.deterministic + ): + if obj in self._transformation_block_set: + # This is a Block we created--we know we don't need to look + # on it. + continue + handler = self._handlers.get(obj.ctype, None) + if not handler: + if handler is None: + raise RuntimeError( + "No transformation handler registered for modeling " + "components of type '%s'." % obj.ctype + ) + continue + handler(obj, config) + + def _transform_constraint(self, cons, config): + trans_block = self._get_transformation_block(cons.parent_block()) + trans_data_dict = trans_block.private_data() + src_data_dict = cons.parent_block().private_data() + constraints = cons.values() if cons.is_indexed() else (cons,) + for c in constraints: + pw_approx, expr_type = self._approximate_expression( + c.body, c, trans_block, config, config.approximate_quadratic_constraints + ) + + if pw_approx is None: + # Didn't need approximated, nothing to do + continue + c.model().private_data().transformed_constraints[expr_type].add(c) + + idx = len(trans_block._pwl_cons) + trans_block._pwl_cons[c.name, idx] = (c.lower, pw_approx, c.upper) + new_cons = trans_block._pwl_cons[c.name, idx] + trans_data_dict.src_component[new_cons] = c + src_data_dict.transformed_component[c] = new_cons + + # deactivate original + c.deactivate() + + def _transform_objective(self, objective, config): + trans_block = self._get_transformation_block(objective.parent_block()) + trans_data_dict = trans_block.private_data() + objectives = objective.values() if objective.is_indexed() else (objective,) + src_data_dict = objective.parent_block().private_data() + for obj in objectives: + pw_approx, expr_type = self._approximate_expression( + obj.expr, + obj, + trans_block, + config, + config.approximate_quadratic_objectives, + ) + + if pw_approx is None: + # Didn't need approximated, nothing to do + continue + obj.model().private_data().transformed_objectives[expr_type].add(obj) + + new_obj = Objective(expr=pw_approx, sense=obj.sense) + trans_block.add_component( + unique_component_name(trans_block, obj.name), new_obj + ) + trans_data_dict.src_component[new_obj] = obj + src_data_dict.transformed_component[obj] = new_obj + + obj.deactivate() + + def _get_bounds_list(self, var_list, obj): + bounds = [] + for v in var_list: + if None in v.bounds: + raise ValueError( + "Cannot automatically approximate constraints with unbounded " + "variables. Var '%s' appearing in component '%s' is missing " + "at least one bound" % (v.name, obj.name) + ) + else: + bounds.append((v.bounds, v.is_integer())) + return bounds + + def _needs_approximating(self, expr, approximate_quadratic): + repn = self._quadratic_repn_visitor.walk_expression(expr) + if repn.nonlinear is None: + if repn.quadratic is None: + # Linear constraint. Always skip. + return ExprType.LINEAR, False + else: + if not approximate_quadratic: + # Didn't need approximated, nothing to do + return ExprType.QUADRATIC, False + return ExprType.QUADRATIC, True + return ExprType.GENERAL, True + + def _approximate_expression( + self, expr, obj, trans_block, config, approximate_quadratic + ): + expr_type, needs_approximating = self._needs_approximating( + expr, approximate_quadratic + ) + if not needs_approximating: + return None, expr_type + + # Additively decompose expr and work on the pieces + pwl_summands = [] + for k, subexpr in enumerate( + _additively_decompose_expr( + expr, config.min_dimension_to_additively_decompose + ) + if config.additively_decompose + else (expr,) + ): + # First check if this is a good idea + expr_vars = list(identify_variables(subexpr, include_fixed=False)) + orig_values = ComponentMap((v, v.value) for v in expr_vars) + + dim = len(expr_vars) + if dim > config.max_dimension: + raise ValueError( + "Not approximating expression for component '%s' as " + "it exceeds the maximum dimension of %s. Try increasing " + "'max_dimension' or additively separating the expression." + % (obj.name, config.max_dimension) + ) + pwl_summands.append(subexpr) + continue + elif not self._needs_approximating(subexpr, approximate_quadratic)[1]: + pwl_summands.append(subexpr) + continue + # else we approximate subexpr + + def eval_expr(*args): + for i, v in enumerate(expr_vars): + v.value = args[i] + return value(subexpr) + + pwlf = _get_pwl_function_approximation( + eval_expr, config, self._get_bounds_list(expr_vars, obj) + ) + name = unique_component_name( + trans_block, obj.getname(fully_qualified=False) + ) + trans_block.add_component(f"_pwle_{name}_{k}", pwlf) + # NOTE: We are *not* using += because it will hit the NamedExpression + # implementation of iadd and dereference the ExpressionData holding + # the PiecewiseLinearExpression that we later transform my remapping + # it to a Var... + pwl_summands.append(pwlf(*expr_vars)) + + # restore var values + for v, val in orig_values.items(): + v.value = val + + return sum(pwl_summands), expr_type + + def get_src_component(self, cons): + data = cons.parent_block().private_data().src_component + if cons in data: + return data[cons] + else: + raise ValueError( + "It does not appear that '%s' is a transformed Constraint " + "created by the 'nonlinear_to_pwl' transformation." % cons.name + ) + + def get_transformed_component(self, cons): + data = cons.parent_block().private_data().transformed_component + if cons in data: + return data[cons] + else: + raise ValueError( + "It does not appear that '%s' is a Constraint that was " + "transformed by the 'nonlinear_to_pwl' transformation." % cons.name + ) + + def get_transformed_nonlinear_constraints(self, model): + """ + Given a model that has been transformed with contrib.piecewise.nonlinear_to_pwl, + return the list of general (not quadratic) nonlinear Constraints that were + approximated with PiecewiseLinearFunctions + """ + return model.private_data().transformed_constraints[ExprType.GENERAL] + + def get_transformed_quadratic_constraints(self, model): + """ + Given a model that has been transformed with contrib.piecewise.nonlinear_to_pwl, + return the list of quadratic Constraints that were approximated with + PiecewiseLinearFunctions + """ + return model.private_data().transformed_constraints[ExprType.QUADRATIC] + + def get_transformed_nonlinear_objectives(self, model): + """ + Given a model that has been transformed with contrib.piecewise.nonlinear_to_pwl, + return the list of general (not quadratic) nonlinear Constraints that were + approximated with PiecewiseLinearFunctions + """ + return model.private_data().transformed_objectives[ExprType.GENERAL] + + def get_transformed_quadratic_objectives(self, model): + """ + Given a model that has been transformed with contrib.piecewise.nonlinear_to_pwl, + return the list of quadratic Constraints that were approximated with + PiecewiseLinearFunctions + """ + return model.private_data().transformed_objectives[ExprType.QUADRATIC] diff --git a/pyomo/contrib/piecewise/transform/outer_representation_gdp.py b/pyomo/contrib/piecewise/transform/outer_representation_gdp.py index 7c81619430a..18fbfe3c56f 100644 --- a/pyomo/contrib/piecewise/transform/outer_representation_gdp.py +++ b/pyomo/contrib/piecewise/transform/outer_representation_gdp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,8 +12,8 @@ import pyomo.common.dependencies.numpy as np from pyomo.common.dependencies.scipy import spatial from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr -from pyomo.contrib.piecewise.transform.piecewise_to_gdp_transformation import ( - PiecewiseLinearToGDP, +from pyomo.contrib.piecewise.transform.piecewise_linear_transformation_base import ( + PiecewiseLinearTransformationBase, ) from pyomo.core import Constraint, NonNegativeIntegers, Suffix, Var from pyomo.core.base import TransformationFactory @@ -27,7 +27,7 @@ "the simplices that are the domains of the " "linear functions.", ) -class OuterRepresentationGDPTransformation(PiecewiseLinearToGDP): +class OuterRepresentationGDPTransformation(PiecewiseLinearTransformationBase): """ Convert a model involving piecewise linear expressions into a GDP by representing the piecewise linear functions as Disjunctions where the @@ -49,7 +49,7 @@ class OuterRepresentationGDPTransformation(PiecewiseLinearToGDP): this mode, targets must be Blocks, Constraints, and/or Objectives. """ - CONFIG = PiecewiseLinearToGDP.CONFIG() + CONFIG = PiecewiseLinearTransformationBase.CONFIG() _transformation_name = 'pw_linear_outer_repn' def _transform_pw_linear_expr(self, pw_expr, pw_linear_func, transformation_block): diff --git a/pyomo/contrib/piecewise/transform/piecewise_to_gdp_transformation.py b/pyomo/contrib/piecewise/transform/piecewise_linear_transformation_base.py similarity index 96% rename from pyomo/contrib/piecewise/transform/piecewise_to_gdp_transformation.py rename to pyomo/contrib/piecewise/transform/piecewise_linear_transformation_base.py index 2e056c47a15..d7cc5b3e5f3 100644 --- a/pyomo/contrib/piecewise/transform/piecewise_to_gdp_transformation.py +++ b/pyomo/contrib/piecewise/transform/piecewise_linear_transformation_base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -31,21 +31,22 @@ Connector, SortComponents, Any, + LogicalConstraint, ) from pyomo.core.base import Transformation -from pyomo.core.base.block import _BlockData, Block +from pyomo.core.base.block import Block from pyomo.core.util import target_list from pyomo.gdp import Disjunct, Disjunction from pyomo.gdp.util import is_child_of from pyomo.network import Port -class PiecewiseLinearToGDP(Transformation): +class PiecewiseLinearTransformationBase(Transformation): """ - Base class for transformations of piecewise-linear models to GDPs + Base class for transformations of piecewise-linear models to GDPs, MIPs, etc. """ - CONFIG = ConfigDict('contrib.piecewise_to_gdp') + CONFIG = ConfigDict('contrib.piecewise_linear_transformation_base') CONFIG.declare( 'targets', ConfigValue( @@ -102,6 +103,7 @@ def __init__(self): ExternalFunction: False, Port: False, PiecewiseLinearFunction: self._transform_piecewise_linear_function, + LogicalConstraint: False, } self._transformation_blocks = {} @@ -147,7 +149,7 @@ def _apply_to_impl(self, instance, **kwds): self._transform_piecewise_linear_function( t, config.descend_into_expressions ) - elif t.ctype is Block or isinstance(t, _BlockData): + elif issubclass(t.ctype, Block): self._transform_block(t, config.descend_into_expressions) elif t.ctype is Constraint: if not config.descend_into_expressions: diff --git a/pyomo/contrib/piecewise/transform/piecewise_to_mip_visitor.py b/pyomo/contrib/piecewise/transform/piecewise_to_mip_visitor.py index fae95a564bf..92ea282cef4 100644 --- a/pyomo/contrib/piecewise/transform/piecewise_to_mip_visitor.py +++ b/pyomo/contrib/piecewise/transform/piecewise_to_mip_visitor.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -50,7 +50,7 @@ def exitNode(self, node, data): substitute_var = self.transform_pw_linear_expression( node, parent, self.transBlock ) - parent._expressions[id(node)] = substitute_var + parent._expressions[parent._expression_ids[node]] = substitute_var return node finalizeResult = None diff --git a/pyomo/contrib/piecewise/transform/reduced_inner_representation_gdp.py b/pyomo/contrib/piecewise/transform/reduced_inner_representation_gdp.py index 5c7dfa895ab..0647f7b8cf4 100644 --- a/pyomo/contrib/piecewise/transform/reduced_inner_representation_gdp.py +++ b/pyomo/contrib/piecewise/transform/reduced_inner_representation_gdp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,8 +10,8 @@ # ___________________________________________________________________________ from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr -from pyomo.contrib.piecewise.transform.piecewise_to_gdp_transformation import ( - PiecewiseLinearToGDP, +from pyomo.contrib.piecewise.transform.piecewise_linear_transformation_base import ( + PiecewiseLinearTransformationBase, ) from pyomo.core import Constraint, NonNegativeIntegers, Var from pyomo.core.base import TransformationFactory @@ -25,7 +25,7 @@ "simplices that are the domains of the linear " "functions.", ) -class ReducedInnerRepresentationGDPTransformation(PiecewiseLinearToGDP): +class ReducedInnerRepresentationGDPTransformation(PiecewiseLinearTransformationBase): """ Convert a model involving piecewise linear expressions into a GDP by representing the piecewise linear functions as Disjunctions where the @@ -51,7 +51,7 @@ class ReducedInnerRepresentationGDPTransformation(PiecewiseLinearToGDP): this mode, targets must be Blocks, Constraints, and/or Objectives. """ - CONFIG = PiecewiseLinearToGDP.CONFIG() + CONFIG = PiecewiseLinearTransformationBase.CONFIG() _transformation_name = 'pw_linear_reduced_inner_repn' def _transform_pw_linear_expr(self, pw_expr, pw_linear_func, transformation_block): diff --git a/pyomo/contrib/piecewise/triangulations.py b/pyomo/contrib/piecewise/triangulations.py new file mode 100644 index 00000000000..e52a3a80a60 --- /dev/null +++ b/pyomo/contrib/piecewise/triangulations.py @@ -0,0 +1,728 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import itertools +from enum import Enum +from pyomo.common.errors import DeveloperError +from pyomo.common.dependencies import numpy as np +from pyomo.contrib.piecewise.ordered_3d_j1_triangulation_data import ( + get_hamiltonian_paths, +) + + +class Triangulation(Enum): + Unknown = 0 + AssumeValid = 1 + Delaunay = 2 + J1 = 3 + OrderedJ1 = 4 + + +# Duck-typed thing that looks reasonably similar to an instance of +# scipy.spatial.Delaunay +# Fields: +# - points: list of P points as P x n array +# - simplices: list of M simplices as P x (n + 1) array of point _indices_ +# - coplanar: list of N points omitted from triangulation as tuples of (point index, +# nearest simplex index, nearest vertex index), stacked into an N x 3 array +class _Triangulation: + def __init__(self, points, simplices, coplanar): + self.points = points + self.simplices = simplices + self.coplanar = coplanar + + +# Get an unordered J1 triangulation, as described by [1], of a finite grid of +# points in R^n having the same odd number of points along each axis. +# References +# ---------- +# [1] J.P. Vielma, S. Ahmed, and G. Nemhauser, "Mixed-integer models +# for nonseparable piecewise-linear optimization: unifying framework +# and extensions," Operations Research, vol. 58, no. 2, pp. 305-315, +# 2010. +def get_unordered_j1_triangulation(points, dimension): + points_map, num_pts = _process_points_j1(points, dimension) + simplices_list = _get_j1_triangulation(points_map, num_pts - 1, dimension) + return _Triangulation( + points=np.array(points), + simplices=np.array(simplices_list), + coplanar=np.array([]), + ) + + +# Get an ordered J1 triangulation, according to [1], with the additional condition +# added from [2] that simplex vertices are also ordered such that the final vertex +# of each simplex is the first vertex of the next simplex. +# References +# ---------- +# [1] Michael J. Todd. "Hamiltonian triangulations of Rn". In: Functional +# Differential Equations and Approximation of Fixed Points. Ed. by +# Heinz-Otto Peitgen and Hans-Otto Walther. Berlin, Heidelberg: Springer +# Berlin Heidelberg, 1979, pp. 470–483. ISBN: 978-3-540-35129-0. +# [2] J.P. Vielma, S. Ahmed, and G. Nemhauser, "Mixed-integer models +# for nonseparable piecewise-linear optimization: unifying framework +# and extensions," Operations Research, vol. 58, no. 2, pp. 305-315, +# 2010. +def get_ordered_j1_triangulation(points, dimension): + points_map, num_pts = _process_points_j1(points, dimension) + if dimension == 2: + simplices_list = _get_ordered_j1_triangulation_2d(points_map, num_pts - 1) + elif dimension == 3: + simplices_list = _get_ordered_j1_triangulation_3d(points_map, num_pts - 1) + else: + simplices_list = _get_ordered_j1_triangulation_4d_and_above( + points_map, num_pts - 1, dimension + ) + return _Triangulation( + points=np.array(points), + simplices=np.array(simplices_list), + coplanar=np.array([]), + ) + + +# Does some validation but mostly assumes the user did the right thing +def _process_points_j1(points, dimension): + if not len(points[0]) == dimension: + raise ValueError("Points not consistent with specified dimension") + num_pts = round(len(points) ** (1 / dimension)) + if not len(points) == num_pts**dimension: + raise ValueError( + "'points' must have points forming an n-dimensional grid with straight grid" + " lines and the same odd number of points in each axis." + ) + if not num_pts % 2 == 1: + raise ValueError( + "'points' must have points forming an n-dimensional grid with straight grid" + " lines and the same odd number of points in each axis." + ) + + # munge the points into an organized map from n-dimensional keys to original + # indices + points.sort() + points_map = {} + for point_index in itertools.product(range(num_pts), repeat=dimension): + point_flat_index = 0 + for n in range(dimension): + point_flat_index += point_index[dimension - 1 - n] * num_pts**n + points_map[point_index] = point_flat_index + return points_map, num_pts + + +# Implement the J1 "Union Jack" triangulation (Todd 79) as explained by +# Vielma 2010, with no ordering guarantees imposed. This function triangulates +# {0, ..., K}^n for even K using the J1 triangulation, mapping the +# obtained simplices through the points_map for a slight generalization. +def _get_j1_triangulation(points_map, K, n): + if K % 2 != 0: + raise ValueError("K must be even") + # 1, 3, ..., K - 1 + axis_odds = range(1, K, 2) + V_0 = itertools.product(axis_odds, repeat=n) + big_iterator = itertools.product( + V_0, + itertools.permutations(range(0, n), n), + itertools.product((-1, 1), repeat=n), + ) + ret = [] + for v_0, pi, s in big_iterator: + simplex = [] + current = list(v_0) + simplex.append(points_map[tuple(current)]) + for i in range(0, n): + current[pi[i]] += s[pi[i]] + simplex.append(points_map[tuple(current)]) + # sort this because it might happen again later and we'd like to stay + # consistent. Undo this if it's slow. + ret.append(sorted(simplex)) + return ret + + +class Direction(Enum): + left = 0 + down = 1 + up = 2 + right = 3 + + +# Implement something similar to proof-by-picture from Todd 79 (Figure 1). +# However, that drawing is misleading at best so I do it in a working way, and +# also slightly more regularly. I also go from the outside in instead of from +# the inside out, to make things easier to implement. +def _get_ordered_j1_triangulation_2d(points_map, num_pts): + # check when square has simplices in top-left and bottom-right + square_parity_tlbr = lambda x, y: x % 2 == y % 2 + # check when we are in a "turnaround square" as seen in the picture + is_turnaround = lambda x, y: x >= num_pts / 2 and y == (num_pts / 2) - 1 + + facing = None + + simplices = [] + start_square = (num_pts - 1, (num_pts / 2) - 1) + + # make it easier to read what I'm doing + def add_bottom_right(): + simplices.append( + (points_map[x, y], points_map[x + 1, y], points_map[x + 1, y + 1]) + ) + + def add_top_right(): + simplices.append( + (points_map[x, y + 1], points_map[x + 1, y], points_map[x + 1, y + 1]) + ) + + def add_bottom_left(): + simplices.append((points_map[x, y], points_map[x, y + 1], points_map[x + 1, y])) + + def add_top_left(): + simplices.append( + (points_map[x, y], points_map[x, y + 1], points_map[x + 1, y + 1]) + ) + + # identify square by bottom-left corner + x, y = start_square + used_squares = set() # not used for the turnaround squares + + # depending on parity we will need to go either up or down to start + if square_parity_tlbr(x, y): + add_bottom_right() + facing = Direction.down + y -= 1 + else: + add_top_right() + facing = Direction.up + y += 1 + + # state machine + while True: + if facing == Direction.left: + if square_parity_tlbr(x, y): + add_bottom_right() + add_top_left() + else: + add_top_right() + add_bottom_left() + used_squares.add((x, y)) + if (x - 1, y) in used_squares or x == 0: + # can't keep going left so we need to go up or down depending + # on parity + if square_parity_tlbr(x, y): + y += 1 + facing = Direction.up + continue + else: + y -= 1 + facing = Direction.down + continue + else: + x -= 1 + continue + elif facing == Direction.right: + if is_turnaround(x, y): + # finished; this case should always eventually be reached + add_bottom_left() + _fix_vertices_incremental_order(simplices) + return simplices + else: + if square_parity_tlbr(x, y): + add_top_left() + add_bottom_right() + else: + add_bottom_left() + add_top_right() + used_squares.add((x, y)) + if (x + 1, y) in used_squares or x == num_pts - 1: + # can't keep going right so we need to go up or down depending + # on parity + if square_parity_tlbr(x, y): + y -= 1 + facing = Direction.down + continue + else: + y += 1 + facing = Direction.up + continue + else: + x += 1 + continue + elif facing == Direction.down: + if is_turnaround(x, y): + # we are always in a TLBR square. Take the TL of this, the TR + # of the one on the left, and continue upwards one to the left + add_top_left() + x -= 1 + add_top_right() + y += 1 + facing = Direction.up + continue + else: + if square_parity_tlbr(x, y): + add_top_left() + add_bottom_right() + else: + add_top_right() + add_bottom_left() + used_squares.add((x, y)) + if (x, y - 1) in used_squares or y == 0: + # can't keep going down so we need to turn depending + # on our parity + if square_parity_tlbr(x, y): + x += 1 + facing = Direction.right + continue + else: + x -= 1 + facing = Direction.left + continue + else: + y -= 1 + continue + elif facing == Direction.up: + if is_turnaround(x, y): + # we are always in a non-TLBR square. Take the BL of this, the BR + # of the one on the left, and continue downwards one to the left + add_bottom_left() + x -= 1 + add_bottom_right() + y -= 1 + facing = Direction.down + continue + else: + if square_parity_tlbr(x, y): + add_bottom_right() + add_top_left() + else: + add_bottom_left() + add_top_right() + used_squares.add((x, y)) + if (x, y + 1) in used_squares or y == num_pts - 1: + # can't keep going up so we need to turn depending + # on our parity + if square_parity_tlbr(x, y): + x -= 1 + facing = Direction.left + continue + else: + x += 1 + facing = Direction.right + continue + else: + y += 1 + continue + + +def _get_ordered_j1_triangulation_3d(points_map, num_pts): + incremental_3d_simplex_pair_to_path = get_hamiltonian_paths() + # To start, we need a hamiltonian path in the grid graph of *double* cubes + # (2x2x2 cubes) + grid_hamiltonian = _get_grid_hamiltonian(3, round(num_pts / 2)) # division is exact + + # We always start by going from [0, 0, 0] to [0, 0, 1], so we can safely + # start from the -x side. + # Data format: the first tuple is a basis vector or its negative, representing a + # face. The number afterwards is a 1 or 2 disambiguating which, of the two simplices + # on that face we consider, we are referring to. + start_data = ((-1, 0, 0), 1) + + simplices = [] + for i in range(len(grid_hamiltonian) - 1): + current_double_cube_idx = grid_hamiltonian[i] + next_double_cube_idx = grid_hamiltonian[i + 1] + direction_to_next = tuple( + next_double_cube_idx[j] - current_double_cube_idx[j] for j in range(3) + ) + + current_v_0 = tuple(2 * current_double_cube_idx[j] + 1 for j in range(3)) + + current_cube_path = None + if ( + start_data, + (direction_to_next, 1), + ) in incremental_3d_simplex_pair_to_path.keys(): + current_cube_path = incremental_3d_simplex_pair_to_path[ + (start_data, (direction_to_next, 1)) + ] + # set the start data for the next iteration now + start_data = (tuple(-1 * i for i in direction_to_next), 1) + else: + current_cube_path = incremental_3d_simplex_pair_to_path[ + (start_data, (direction_to_next, 2)) + ] + start_data = (tuple(-1 * i for i in direction_to_next), 2) + + for simplex_data in current_cube_path: + simplices.append( + _get_one_j1_simplex( + current_v_0, simplex_data[1], simplex_data[0], 3, points_map + ) + ) + + # fill in the last cube. We have a good start_data but we need to invent a + # direction_to_next. Let's go straight in the direction we came from. + direction_to_next = tuple(-1 * i for i in start_data[0]) + current_v_0 = tuple(2 * grid_hamiltonian[-1][j] + 1 for j in range(3)) + if ( + start_data, + (direction_to_next, 1), + ) in incremental_3d_simplex_pair_to_path.keys(): + current_cube_path = incremental_3d_simplex_pair_to_path[ + (start_data, (direction_to_next, 1)) + ] + else: + current_cube_path = incremental_3d_simplex_pair_to_path[ + (start_data, (direction_to_next, 2)) + ] + + for simplex_data in current_cube_path: + simplices.append( + _get_one_j1_simplex( + current_v_0, simplex_data[1], simplex_data[0], 3, points_map + ) + ) + + _fix_vertices_incremental_order(simplices) + return simplices + + +def _get_ordered_j1_triangulation_4d_and_above(points_map, num_pts, dim): + # step one: get a hamiltonian path in the appropriate grid graph (low-coordinate + # corners of the grid squares) + grid_hamiltonian = _get_grid_hamiltonian(dim, num_pts) + + # step 1.5: get a starting simplex. Anything that is *not* adjacent to the + # second square is fine. Since we always go from [0, ..., 0] to [0, ..., 1], + # i.e., j=`dim`, anything where `dim` is not the first or last symbol should + # always work. Let's stick it in the second place + start_perm = tuple([1] + [dim] + list(range(2, dim))) + + # step two: for each square, get a sequence of simplices from a starting simplex, + # through the square, and then ending with a simplex adjacent to the next square. + # Then find the appropriate adjacent simplex to start on the next square + simplices = [] + for i in range(len(grid_hamiltonian) - 1): + current_corner = grid_hamiltonian[i] + next_corner = grid_hamiltonian[i + 1] + # differing index + j = [k + 1 for k in range(dim) if current_corner[k] != next_corner[k]][0] + # border x_j value between this square and next + c = max(current_corner[j - 1], next_corner[j - 1]) + v_0, sign = _get_nearest_odd_and_sign_vec(current_corner) + # According to Todd, what we need is to end with a permutation where rho(n) = j + # if c is odd, and end with one where rho(1) = j if c is even. I think this + # is right -- basically the sign from the sign vector sometimes cancels + # out the sign from whether we are entering in the +c or -c direction. + if c % 2 == 0: + perm_sequence = _get_Gn_hamiltonian(dim, start_perm, j, False) + for pi in perm_sequence: + simplices.append(_get_one_j1_simplex(v_0, pi, sign, dim, points_map)) + else: + perm_sequence = _get_Gn_hamiltonian(dim, start_perm, j, True) + for pi in perm_sequence: + simplices.append(_get_one_j1_simplex(v_0, pi, sign, dim, points_map)) + # should be true regardless of odd or even + start_perm = perm_sequence[-1] + + # step three: finish out the last square + # Any final permutation is fine; we are going nowhere after this + v_0, sign = _get_nearest_odd_and_sign_vec(grid_hamiltonian[-1]) + for pi in _get_Gn_hamiltonian(dim, start_perm, 1, False): + simplices.append(_get_one_j1_simplex(v_0, pi, sign, dim, points_map)) + + # fix vertices and return + _fix_vertices_incremental_order(simplices) + return simplices + + +def _get_one_j1_simplex(v_0, pi, sign, dim, points_map): + simplex = [] + current = list(v_0) + simplex.append(points_map[tuple(current)]) + for i in range(0, dim): + current[pi[i] - 1] += sign[pi[i] - 1] + simplex.append(points_map[tuple(current)]) + return sorted(simplex) + + +# get the v_0 and sign vectors corresponding to a given square, identified by its +# low-coordinate corner +def _get_nearest_odd_and_sign_vec(corner): + v_0 = [] + sign = [] + for x in corner: + if x % 2 == 0: + v_0.append(x + 1) + sign.append(-1) + else: + v_0.append(x) + sign.append(1) + return v_0, sign + + +def _get_grid_hamiltonian(dim, length): + if dim == 1: + return [[n] for n in range(length)] + else: + ret = [] + prev = _get_grid_hamiltonian(dim - 1, length) + for n in range(length): + # if n is even, add the previous hamiltonian with n in its new first + # coordinate. If odd, do the same with the previous hamiltonian in reverse. + if n % 2 == 0: + for x in prev: + ret.append([n] + x) + else: + for x in reversed(prev): + ret.append([n] + x) + return ret + + +# Fix vertices (in place) when the simplices are right but vertices are not +def _fix_vertices_incremental_order(simplices): + last_vertex_index = len(simplices[0]) - 1 + for i, simplex in enumerate(simplices): + # Choose vertices like this: first is always the same as last + # of the previous simplex. Last is arbitrarily chosen from the + # intersection with the next simplex. + first = None + last = None + if i == 0: + first = 0 + else: + first = simplex.index(simplices[i - 1][last_vertex_index]) + + if i == len(simplices) - 1: + last = last_vertex_index + else: + for n in range(last_vertex_index + 1): + if simplex[n] in simplices[i + 1] and n != first: + last = n + break + else: + # For the Python neophytes in the audience (and other sane + # people), the 'else' only runs if we do *not* break out of the + # for loop. + raise DeveloperError("Couldn't fix vertex ordering for incremental.") + + # reorder the simplex with the desired first and last + new_simplex = list(simplex) + temp = new_simplex[0] + new_simplex[0] = new_simplex[first] + new_simplex[first] = temp + if last == 0: + last = first + temp = new_simplex[last_vertex_index] + new_simplex[last_vertex_index] = new_simplex[last] + new_simplex[last] = temp + simplices[i] = tuple(new_simplex) + + +# Let G_n be the graph on n! vertices where the vertices are permutations in +# S_n and two vertices are adjacent if they are related by swapping the values +# of pi(i - 1) and pi(i) for some i in {2, ..., n}. +# +# This function gets a Hamiltonian path through G_n, starting from a fixed +# starting permutation, such that a fixed target symbol is either the image +# rho(1), or it is rho(n), depending on whether first or last is requested, +# where rho is the final permutation. +def _get_Gn_hamiltonian(n, start_permutation, target_symbol, last, _cache={}): + if n < 4: + raise ValueError("n must be at least 4 for this operation to be possible") + if (n, start_permutation, target_symbol, last) in _cache: + return _cache[(n, start_permutation, target_symbol, last)] + # first is enough because we can just reverse every permutation + if last: + ret = [ + tuple(reversed(pi)) + for pi in _get_Gn_hamiltonian( + n, tuple(reversed(start_permutation)), target_symbol, False + ) + ] + _cache[(n, start_permutation, target_symbol, last)] = ret + return ret + # trivial start permutation is enough because we can map it through at the end + if start_permutation != tuple(range(1, n + 1)): + new_target_symbol = [ + x for x in range(1, n + 1) if start_permutation[x - 1] == target_symbol + ][ + 0 + ] # pi^-1(j) + ret = [ + tuple(start_permutation[pi[i] - 1] for i in range(n)) + for pi in _get_Gn_hamiltonian_impl(n, new_target_symbol) + ] + _cache[(n, start_permutation, target_symbol, last)] = ret + return ret + else: + ret = _get_Gn_hamiltonian_impl(n, target_symbol) + _cache[(n, start_permutation, target_symbol, last)] = ret + return ret + + +# Assume the starting permutation is (1, ..., n) and the target symbol needs to +# be in the first position of the last permutation +def _get_Gn_hamiltonian_impl(n, target_symbol): + # base case: proof by picture from Todd 79, Figure 2 + # note: Figure 2 contains an error, careful! + if n == 4: + if target_symbol == 1: + return [ + (1, 2, 3, 4), + (2, 1, 3, 4), + (2, 1, 4, 3), + (2, 4, 1, 3), + (4, 2, 1, 3), + (4, 2, 3, 1), + (2, 4, 3, 1), + (2, 3, 4, 1), + (2, 3, 1, 4), + (3, 2, 1, 4), + (3, 2, 4, 1), + (3, 4, 2, 1), + (4, 3, 2, 1), + (4, 3, 1, 2), + (3, 4, 1, 2), + (3, 1, 4, 2), + (3, 1, 2, 4), + (1, 3, 2, 4), + (1, 3, 4, 2), + (1, 4, 3, 2), + (4, 1, 3, 2), + (4, 1, 2, 3), + (1, 4, 2, 3), + (1, 2, 4, 3), + ] + elif target_symbol == 2: + return [ + (1, 2, 3, 4), + (1, 2, 4, 3), + (1, 4, 2, 3), + (4, 1, 2, 3), + (4, 1, 3, 2), + (1, 4, 3, 2), + (1, 3, 4, 2), + (1, 3, 2, 4), + (3, 1, 2, 4), + (3, 1, 4, 2), + (3, 4, 1, 2), + (4, 3, 1, 2), + (4, 3, 2, 1), + (3, 4, 2, 1), + (3, 2, 4, 1), + (3, 2, 1, 4), + (2, 3, 1, 4), + (2, 3, 4, 1), + (2, 4, 3, 1), + (4, 2, 3, 1), + (4, 2, 1, 3), + (2, 4, 1, 3), + (2, 1, 4, 3), + (2, 1, 3, 4), + ] + elif target_symbol == 3: + return [ + (1, 2, 3, 4), + (1, 2, 4, 3), + (1, 4, 2, 3), + (4, 1, 2, 3), + (4, 1, 3, 2), + (1, 4, 3, 2), + (1, 3, 4, 2), + (1, 3, 2, 4), + (3, 1, 2, 4), + (3, 1, 4, 2), + (3, 4, 1, 2), + (4, 3, 1, 2), + (4, 3, 2, 1), + (3, 4, 2, 1), + (3, 2, 4, 1), + (2, 3, 4, 1), + (2, 4, 3, 1), + (4, 2, 3, 1), + (4, 2, 1, 3), + (2, 4, 1, 3), + (2, 1, 4, 3), + (2, 1, 3, 4), + (2, 3, 1, 4), + (3, 2, 1, 4), + ] + elif target_symbol == 4: + return [ + (1, 2, 3, 4), + (2, 1, 3, 4), + (2, 3, 1, 4), + (3, 2, 1, 4), + (3, 1, 2, 4), + (1, 3, 2, 4), + (1, 3, 4, 2), + (3, 1, 4, 2), + (3, 4, 1, 2), + (3, 4, 2, 1), + (3, 2, 4, 1), + (2, 3, 4, 1), + (2, 4, 3, 1), + (2, 4, 1, 3), + (2, 1, 4, 3), + (1, 2, 4, 3), + (1, 4, 2, 3), + (1, 4, 3, 2), + (4, 1, 3, 2), + (4, 3, 1, 2), + (4, 3, 2, 1), + (4, 2, 3, 1), + (4, 2, 1, 3), + (4, 1, 2, 3), + ] + # unreachable + else: + # recursive case + if target_symbol < n: # Less awful case + idx = n - 1 + facing = -1 + ret = [] + for pi in _get_Gn_hamiltonian_impl(n - 1, target_symbol): + for _ in range(n): + l = list(pi) + l.insert(idx, n) + ret.append(tuple(l)) + idx += facing + if idx == -1 or idx == n: # went too far + facing *= -1 + idx += facing # stay once because we get a new pi + return ret + else: # awful case, target_symbol = n + idx = 0 + facing = 1 + ret = [] + for pi in _get_Gn_hamiltonian_impl(n - 1, n - 1): + for _ in range(n): + l = [x + 1 for x in pi] + l.insert(idx, 1) + ret.append(tuple(l)) + idx += facing + if idx == -1 or idx == n: # went too far + facing *= -1 + idx += facing # stay once because we get a new pi + # now we almost have a correct sequence, but it ends with (1, n, ...) + # instead of (n, 1, ...) so we need to do some surgery + last = ret.pop() # of form (1, n, i, j, ...) + second_last = ret.pop() # of form (n, 1, i, j, ...) + i = last[2] + j = last[3] + test = list( + last + ) # want permutation of form (n, 1, j, i, ...) with same tail + test[0] = n + test[1] = 1 + test[2] = j + test[3] = i + idx = ret.index(tuple(test)) + ret.insert(idx, second_last) + ret.insert(idx, last) + return ret diff --git a/pyomo/contrib/preprocessing/__init__.py b/pyomo/contrib/preprocessing/__init__.py index 6458b7a6e71..6eb9ea8b81d 100644 --- a/pyomo/contrib/preprocessing/__init__.py +++ b/pyomo/contrib/preprocessing/__init__.py @@ -1,12 +1,10 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -import pyomo.contrib.preprocessing.plugins diff --git a/pyomo/contrib/preprocessing/plugins/__init__.py b/pyomo/contrib/preprocessing/plugins/__init__.py index 62f5a40c6a9..cd6c42900f5 100644 --- a/pyomo/contrib/preprocessing/plugins/__init__.py +++ b/pyomo/contrib/preprocessing/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,15 +11,17 @@ def load(): - import pyomo.contrib.preprocessing.plugins.deactivate_trivial_constraints - import pyomo.contrib.preprocessing.plugins.detect_fixed_vars - import pyomo.contrib.preprocessing.plugins.init_vars - import pyomo.contrib.preprocessing.plugins.remove_zero_terms - import pyomo.contrib.preprocessing.plugins.equality_propagate - import pyomo.contrib.preprocessing.plugins.strip_bounds - import pyomo.contrib.preprocessing.plugins.zero_sum_propagator - import pyomo.contrib.preprocessing.plugins.bounds_to_vars - import pyomo.contrib.preprocessing.plugins.var_aggregator - import pyomo.contrib.preprocessing.plugins.induced_linearity - import pyomo.contrib.preprocessing.plugins.constraint_tightener - import pyomo.contrib.preprocessing.plugins.int_to_binary + from pyomo.contrib.preprocessing.plugins import ( + deactivate_trivial_constraints, + detect_fixed_vars, + init_vars, + remove_zero_terms, + equality_propagate, + strip_bounds, + zero_sum_propagator, + bounds_to_vars, + var_aggregator, + induced_linearity, + constraint_tightener, + int_to_binary, + ) diff --git a/pyomo/contrib/preprocessing/plugins/bounds_to_vars.py b/pyomo/contrib/preprocessing/plugins/bounds_to_vars.py index 8cc17296ac3..7ba96b33f88 100644 --- a/pyomo/contrib/preprocessing/plugins/bounds_to_vars.py +++ b/pyomo/contrib/preprocessing/plugins/bounds_to_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/constraint_tightener.py b/pyomo/contrib/preprocessing/plugins/constraint_tightener.py index 73851bce618..136e42d918c 100644 --- a/pyomo/contrib/preprocessing/plugins/constraint_tightener.py +++ b/pyomo/contrib/preprocessing/plugins/constraint_tightener.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py b/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py index 59e475e9ba1..660d0f88eb6 100644 --- a/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py +++ b/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/detect_fixed_vars.py b/pyomo/contrib/preprocessing/plugins/detect_fixed_vars.py index e48914e0a91..31bf9fe7d7c 100644 --- a/pyomo/contrib/preprocessing/plugins/detect_fixed_vars.py +++ b/pyomo/contrib/preprocessing/plugins/detect_fixed_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,6 +23,8 @@ from pyomo.core.base.var import Var from pyomo.core.expr.numvalue import value from pyomo.core.plugins.transform.hierarchy import IsomorphicTransformation +from pyomo.core.base.block import Block +from pyomo.gdp import Disjunct @TransformationFactory.register( @@ -67,7 +69,9 @@ def _apply_to(self, instance, **kwargs): if config.tmp: instance._xfrm_detect_fixed_vars_old_values = ComponentMap() - for var in instance.component_data_objects(ctype=Var, descend_into=True): + for var in instance.component_data_objects( + ctype=Var, descend_into=[Block, Disjunct] + ): if var.fixed or var.lb is None or var.ub is None: # if the variable is already fixed, or if it is missing a # bound, we skip it. diff --git a/pyomo/contrib/preprocessing/plugins/equality_propagate.py b/pyomo/contrib/preprocessing/plugins/equality_propagate.py index 357a556fcb2..eeeb9e0cef5 100644 --- a/pyomo/contrib/preprocessing/plugins/equality_propagate.py +++ b/pyomo/contrib/preprocessing/plugins/equality_propagate.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/induced_linearity.py b/pyomo/contrib/preprocessing/plugins/induced_linearity.py index ba291070644..3cb4e78011f 100644 --- a/pyomo/contrib/preprocessing/plugins/induced_linearity.py +++ b/pyomo/contrib/preprocessing/plugins/induced_linearity.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/init_vars.py b/pyomo/contrib/preprocessing/plugins/init_vars.py index a81d898d52c..df22ba1052b 100644 --- a/pyomo/contrib/preprocessing/plugins/init_vars.py +++ b/pyomo/contrib/preprocessing/plugins/init_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/int_to_binary.py b/pyomo/contrib/preprocessing/plugins/int_to_binary.py index e1f7f98a81b..44bdeae5539 100644 --- a/pyomo/contrib/preprocessing/plugins/int_to_binary.py +++ b/pyomo/contrib/preprocessing/plugins/int_to_binary.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py b/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py index ca2052fa471..964d37c42bb 100644 --- a/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py +++ b/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/strip_bounds.py b/pyomo/contrib/preprocessing/plugins/strip_bounds.py index 196de64e405..c5a80ae81ad 100644 --- a/pyomo/contrib/preprocessing/plugins/strip_bounds.py +++ b/pyomo/contrib/preprocessing/plugins/strip_bounds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/plugins/var_aggregator.py b/pyomo/contrib/preprocessing/plugins/var_aggregator.py index d862f167fd7..668e8240f90 100644 --- a/pyomo/contrib/preprocessing/plugins/var_aggregator.py +++ b/pyomo/contrib/preprocessing/plugins/var_aggregator.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,7 +13,14 @@ from pyomo.common.collections import ComponentMap, ComponentSet -from pyomo.core.base import Block, Constraint, VarList, Objective, TransformationFactory +from pyomo.core.base import ( + Block, + Constraint, + VarList, + Objective, + Reals, + TransformationFactory, +) from pyomo.core.expr import ExpressionReplacementVisitor from pyomo.core.expr.numvalue import value from pyomo.core.plugins.transform.hierarchy import IsomorphicTransformation @@ -186,25 +193,26 @@ def max_if_not_None(iterable): doc="Aggregate model variables that are linked by equality constraints.", ) class VariableAggregator(IsomorphicTransformation): - """Aggregate model variables that are linked by equality constraints. + r"""Aggregate model variables that are linked by equality constraints. Before: .. math:: - - x &= y \\\\ - a &= 2x + 6y + 7 \\\\ - b &= 5y + 6 \\\\ + x = y \\ + a = 2x + 6y + 7 \\ + b = 5y + 6 \\ After: .. math:: + z = x = y \\ + a = 8z + 7 \\ + b = 5z + 6 - z &= x = y \\\\ - a &= 8z + 7 \\\\ - b &= 5z + 6 + .. warning:: - .. warning:: TODO: unclear what happens to "capital-E" Expressions at this point in time. + TODO: unclear what happens to "capital-E" Expressions at this + point in time. """ @@ -248,6 +256,12 @@ def _apply_to(self, model, detect_fixed_vars=True): # the variables in its equality set. z_agg.setlb(max_if_not_None(v.lb for v in eq_set if v.has_lb())) z_agg.setub(min_if_not_None(v.ub for v in eq_set if v.has_ub())) + # Set the domain of the aggregate variable to the intersection of + # the domains of the variables in its equality set + domain = Reals + for v in eq_set: + domain = domain & v.domain + z_agg.domain = domain # Set the fixed status of the aggregate var fixed_vars = [v for v in eq_set if v.fixed] diff --git a/pyomo/contrib/preprocessing/plugins/zero_sum_propagator.py b/pyomo/contrib/preprocessing/plugins/zero_sum_propagator.py index df6867719d2..6d2a41fd671 100644 --- a/pyomo/contrib/preprocessing/plugins/zero_sum_propagator.py +++ b/pyomo/contrib/preprocessing/plugins/zero_sum_propagator.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/__init__.py b/pyomo/contrib/preprocessing/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/preprocessing/tests/__init__.py +++ b/pyomo/contrib/preprocessing/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_bounds_to_vars_xfrm.py b/pyomo/contrib/preprocessing/tests/test_bounds_to_vars_xfrm.py index 0df9dd2462d..90750521585 100644 --- a/pyomo/contrib/preprocessing/tests/test_bounds_to_vars_xfrm.py +++ b/pyomo/contrib/preprocessing/tests/test_bounds_to_vars_xfrm.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_constraint_tightener.py b/pyomo/contrib/preprocessing/tests/test_constraint_tightener.py index acb939552f8..5f87db69bb5 100644 --- a/pyomo/contrib/preprocessing/tests/test_constraint_tightener.py +++ b/pyomo/contrib/preprocessing/tests/test_constraint_tightener.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py b/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py index 9e26aab8b77..62f6a7f9551 100644 --- a/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py +++ b/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_detect_fixed_vars.py b/pyomo/contrib/preprocessing/tests/test_detect_fixed_vars.py index a67291dc69f..f0f7a46b2cd 100644 --- a/pyomo/contrib/preprocessing/tests/test_detect_fixed_vars.py +++ b/pyomo/contrib/preprocessing/tests/test_detect_fixed_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_equality_propagate.py b/pyomo/contrib/preprocessing/tests/test_equality_propagate.py index 6b12f464710..4c2f41d8d9c 100644 --- a/pyomo/contrib/preprocessing/tests/test_equality_propagate.py +++ b/pyomo/contrib/preprocessing/tests/test_equality_propagate.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_induced_linearity.py b/pyomo/contrib/preprocessing/tests/test_induced_linearity.py index 4853cb838df..6d0da74b50e 100644 --- a/pyomo/contrib/preprocessing/tests/test_induced_linearity.py +++ b/pyomo/contrib/preprocessing/tests/test_induced_linearity.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_init_vars.py b/pyomo/contrib/preprocessing/tests/test_init_vars.py index a90d39af91c..bc8b41048a7 100644 --- a/pyomo/contrib/preprocessing/tests/test_init_vars.py +++ b/pyomo/contrib/preprocessing/tests/test_init_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_int_to_binary.py b/pyomo/contrib/preprocessing/tests/test_int_to_binary.py index 8aa244212ed..5ac2d4aea5a 100644 --- a/pyomo/contrib/preprocessing/tests/test_int_to_binary.py +++ b/pyomo/contrib/preprocessing/tests/test_int_to_binary.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_strip_bounds.py b/pyomo/contrib/preprocessing/tests/test_strip_bounds.py index f36ff4e9f52..f6ac6a2471c 100644 --- a/pyomo/contrib/preprocessing/tests/test_strip_bounds.py +++ b/pyomo/contrib/preprocessing/tests/test_strip_bounds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_var_aggregator.py b/pyomo/contrib/preprocessing/tests/test_var_aggregator.py index 6f6d02f2180..e93cb759c3a 100644 --- a/pyomo/contrib/preprocessing/tests/test_var_aggregator.py +++ b/pyomo/contrib/preprocessing/tests/test_var_aggregator.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,12 +19,16 @@ max_if_not_None, min_if_not_None, ) +from pyomo.core.expr.compare import assertExpressionsEqual from pyomo.environ import ( + Binary, ConcreteModel, Constraint, ConstraintList, + maximize, Objective, RangeSet, + Reals, SolverFactory, TransformationFactory, Var, @@ -210,6 +214,36 @@ def test_var_update(self): self.assertEqual(m.x.value, 0) self.assertEqual(m.y.value, 0) + def test_binary_inequality(self): + m = ConcreteModel() + m.x = Var(domain=Binary) + m.y = Var(domain=Binary) + m.c = Constraint(expr=m.x == m.y) + m.o = Objective(expr=0.5 * m.x + m.y, sense=maximize) + TransformationFactory('contrib.aggregate_vars').apply_to(m) + var_to_z = m._var_aggregator_info.var_to_z + z = var_to_z[m.x] + self.assertIs(var_to_z[m.y], z) + self.assertEqual(z.domain, Binary) + self.assertEqual(z.lb, 0) + self.assertEqual(z.ub, 1) + assertExpressionsEqual(self, m.o.expr, 0.5 * z + z) + + def test_equality_different_domains(self): + m = ConcreteModel() + m.x = Var(domain=Reals, bounds=(1, 2)) + m.y = Var(domain=Binary) + m.c = Constraint(expr=m.x == m.y) + m.o = Objective(expr=0.5 * m.x + m.y, sense=maximize) + TransformationFactory('contrib.aggregate_vars').apply_to(m) + var_to_z = m._var_aggregator_info.var_to_z + z = var_to_z[m.x] + self.assertIs(var_to_z[m.y], z) + self.assertEqual(z.lb, 1) + self.assertEqual(z.ub, 1) + self.assertEqual(z.domain, Binary) + assertExpressionsEqual(self, m.o.expr, 0.5 * z + z) + if __name__ == '__main__': unittest.main() diff --git a/pyomo/contrib/preprocessing/tests/test_zero_sum_propagate.py b/pyomo/contrib/preprocessing/tests/test_zero_sum_propagate.py index 41ece8e804f..acac9a92a4c 100644 --- a/pyomo/contrib/preprocessing/tests/test_zero_sum_propagate.py +++ b/pyomo/contrib/preprocessing/tests/test_zero_sum_propagate.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py b/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py index c5b7477c8f6..d8d18373295 100644 --- a/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py +++ b/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/preprocessing/util.py b/pyomo/contrib/preprocessing/util.py index 13f3e5dd18c..dbe533d68f8 100644 --- a/pyomo/contrib/preprocessing/util.py +++ b/pyomo/contrib/preprocessing/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/README.md b/pyomo/contrib/pynumero/README.md index 0d165dbc39c..f881e400d51 100644 --- a/pyomo/contrib/pynumero/README.md +++ b/pyomo/contrib/pynumero/README.md @@ -71,3 +71,75 @@ Prerequisites - cmake - a C/C++ compiler - MA57 library or COIN-HSL Full + +Code organization +================= + +PyNumero was initially designed around three core components: linear solver +interfaces, an interface for function and derivative callbacks, and block +vector and matrix classes. Since then, it has incorporated additional +functionality in an ad-hoc manner. The original "core functionality" of +PyNumero, as well as the solver interfaces accessible through +`SolverFactory`, should be considered stable and will only change after +appropriate deprecation warnings. Other functionality should be considered +experimental and subject to change without warning. + +The following is a rough overview of PyNumero, by directory: + +`linalg` +-------- + +Python interfaces to linear solvers. This is core functionality. + +`interfaces` +------------ + +- Classes that define and implement an API for function and derivative callbacks +required by nonlinear optimization solvers, e.g. `nlp.py` and `pyomo_nlp.py` +- Various wrappers around these NLP classes to support "hybrid" implementations, +e.g. `PyomoNLPWithGreyBoxBlocks` +- The `ExternalGreyBoxBlock` Pyomo modeling component and +`ExternalGreyBoxModel` API +- The `ExternalPyomoModel` implementation of `ExternalGreyBoxModel`, which allows +definition of an external grey box via an implicit function +- The `CyIpoptNLP` class, which wraps an object implementing the NLP API in +the interface required by CyIpopt + +Of the above, only `PyomoNLP` and the `NLP` base class should be considered core +functionality. + +`src` +----- + +C++ interfaces to ASL, MA27, and MA57. The ASL and MA27 interfaces are +core functionality. + +`sparse` +-------- + +Block vector and block matrix classes, including MPI variations. +These are core functionality. + +`algorithms` +------------ + +Originally intended to hold various useful algorithms implemented +on NLP objects rather than Pyomo models. Any files added here should +be considered experimental. + +`algorithms/solvers` +-------------------- + +Interfaces to Python solvers using the NLP API defined in `interfaces`. +Only the solvers accessible through `SolverFactory`, e.g. `PyomoCyIpoptSolver` +and `PyomoFsolveSolver`, should be considered core functionality. +The supported way to access these solvers is via `SolverFactory`. *The locations +of the underlying solver objects are subject to change without warning.* + +`examples` +---------- + +The examples demonstrated in `nlp_interface.py`, `nlp_interface_2.py1`, +`feasibility.py`, `mumps_example.py`, `sensitivity.py`, `sqp.py`, +`parallel_matvec.py`, and `parallel_vector_ops.py` are stable. All other +examples should be considered experimental. diff --git a/pyomo/contrib/pynumero/__init__.py b/pyomo/contrib/pynumero/__init__.py index 39ee2197cbf..b32f3b0c419 100644 --- a/pyomo/contrib/pynumero/__init__.py +++ b/pyomo/contrib/pynumero/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/__init__.py b/pyomo/contrib/pynumero/algorithms/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/algorithms/__init__.py +++ b/pyomo/contrib/pynumero/algorithms/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/solvers/__init__.py b/pyomo/contrib/pynumero/algorithms/solvers/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/__init__.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py index 9d24c0dd562..9464cfa53f8 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,7 +23,9 @@ from pyomo.common.deprecation import relocated_module_attribute from pyomo.common.dependencies import attempt_import, numpy as np, numpy_available -from pyomo.common.tee import redirect_fd, TeeStream +from pyomo.common.tee import capture_output +from pyomo.common.modeling import unique_component_name +from pyomo.core.base.objective import Objective # Because pynumero.interfaces requires numpy, we will leverage deferred # imports here so that the solver can be registered even when numpy is @@ -63,7 +65,7 @@ from pyomo.common.config import ConfigBlock, ConfigValue from pyomo.common.timing import TicTocTimer from pyomo.core.base import Block, Objective, minimize -from pyomo.opt import SolverStatus, SolverResults, TerminationCondition, ProblemSense +from pyomo.opt import SolverStatus, SolverResults, TerminationCondition from pyomo.opt.results.solution import Solution logger = logging.getLogger(__name__) @@ -226,23 +228,8 @@ def solve(self, x0=None, tee=False): for k, v in self._options.items(): add_option(k, v) - # We preemptively set up the TeeStream, even if we aren't - # going to use it: the implementation is such that the - # context manager does nothing (i.e., doesn't start up any - # processing threads) until after a client accesses - # STDOUT/STDERR - with TeeStream(sys.stdout) as _teeStream: - if tee: - try: - fd = sys.stdout.fileno() - except (io.UnsupportedOperation, AttributeError): - # If sys,stdout doesn't have a valid fileno, - # then create one using the TeeStream - fd = _teeStream.STDOUT.fileno() - else: - fd = None - with redirect_fd(fd=1, output=fd, synchronize=False): - x, info = cyipopt_solver.solve(xstart) + with capture_output(sys.stdout if tee else None, capture_fd=True): + x, info = cyipopt_solver.solve(xstart) return x, info @@ -317,7 +304,13 @@ def license_is_valid(self): return True def version(self): - return tuple(int(_) for _ in cyipopt.__version__.split(".")) + def _int(x): + try: + return int(x) + except: + return x + + return tuple(_int(_) for _ in cyipopt_interface.cyipopt.__version__.split(".")) def solve(self, model, **kwds): config = self.config(kwds, preserve_implicit=True) @@ -332,11 +325,26 @@ def solve(self, model, **kwds): grey_box_blocks = list( model.component_data_objects(egb.ExternalGreyBoxBlock, active=True) ) - if grey_box_blocks: - # nlp = pyomo_nlp.PyomoGreyBoxNLP(model) - nlp = pyomo_grey_box.PyomoNLPWithGreyBoxBlocks(model) - else: - nlp = pyomo_nlp.PyomoNLP(model) + # if there is no objective, add one temporarily so we can construct an NLP + objectives = list(model.component_data_objects(Objective, active=True)) + n_obj = len(objectives) + for gbb in grey_box_blocks: + if gbb.get_external_model().has_objective(): + n_obj += 1 + if n_obj == 0: + objname = unique_component_name(model, "_obj") + objective = model.add_component(objname, Objective(expr=0.0)) + try: + if grey_box_blocks: + # nlp = pyomo_nlp.PyomoGreyBoxNLP(model) + nlp = pyomo_grey_box.PyomoNLPWithGreyBoxBlocks(model) + else: + nlp = pyomo_nlp.PyomoNLP(model) + finally: + # We only need the objective to construct the NLP, so we delete + # it from the model ASAP + if n_obj == 0: + model.del_component(objective) problem = cyipopt_interface.CyIpoptNLP( nlp, @@ -375,23 +383,8 @@ def solve(self, model, **kwds): timer = TicTocTimer() try: - # We preemptively set up the TeeStream, even if we aren't - # going to use it: the implementation is such that the - # context manager does nothing (i.e., doesn't start up any - # processing threads) until after a client accesses - # STDOUT/STDERR - with TeeStream(sys.stdout) as _teeStream: - if config.tee: - try: - fd = sys.stdout.fileno() - except (io.UnsupportedOperation, AttributeError): - # If sys,stdout doesn't have a valid fileno, - # then create one using the TeeStream - fd = _teeStream.STDOUT.fileno() - else: - fd = None - with redirect_fd(fd=1, output=fd, synchronize=False): - x, info = cyipopt_solver.solve(problem.x_init()) + with capture_output(sys.stdout if config.tee else None, capture_fd=True): + x, info = cyipopt_solver.solve(problem.x_init()) solverStatus = SolverStatus.ok except: msg = "Exception encountered during cyipopt solve:" @@ -428,11 +421,10 @@ def solve(self, model, **kwds): results.problem.name = model.name obj = next(model.component_data_objects(Objective, active=True)) + results.problem.sense = obj.sense if obj.sense == minimize: - results.problem.sense = ProblemSense.minimize results.problem.upper_bound = info["obj_val"] else: - results.problem.sense = ProblemSense.maximize results.problem.lower_bound = info["obj_val"] results.problem.number_of_objectives = 1 results.problem.number_of_constraints = ng diff --git a/pyomo/contrib/pynumero/algorithms/solvers/implicit_functions.py b/pyomo/contrib/pynumero/algorithms/solvers/implicit_functions.py index e40580c1161..a0c69507a0b 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/implicit_functions.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/implicit_functions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/solvers/pyomo_ext_cyipopt.py b/pyomo/contrib/pynumero/algorithms/solvers/pyomo_ext_cyipopt.py index 16c5a19a5c6..0f7c06c675a 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/pyomo_ext_cyipopt.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/pyomo_ext_cyipopt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,13 +10,11 @@ # ___________________________________________________________________________ import numpy as np import abc -from pyomo.contrib.pynumero.algorithms.solvers.cyipopt_solver import ( - CyIpoptProblemInterface, -) +from pyomo.contrib.pynumero.interfaces.cyipopt_interface import CyIpoptProblemInterface from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP from pyomo.contrib.pynumero.sparse.block_vector import BlockVector from pyomo.environ import Var, Constraint, value -from pyomo.core.base.var import _VarData +from pyomo.core.base.var import VarData from pyomo.common.modeling import unique_component_name """ @@ -109,12 +107,12 @@ def __init__( An instance of a derived class (from ExternalInputOutputModel) that provides the methods to compute the outputs and the derivatives. - inputs : list of Pyomo variables (_VarData) + inputs : list of Pyomo variables (VarData) The Pyomo model needs to have variables to represent the inputs to the external model. This is the list of those input variables in the order that corresponds to the input_values vector provided in the set_inputs call. - outputs : list of Pyomo variables (_VarData) + outputs : list of Pyomo variables (VarData) The Pyomo model needs to have variables to represent the outputs from the external model. This is the list of those output variables in the order that corresponds to the numpy array returned from the evaluate_outputs call. @@ -130,7 +128,7 @@ def __init__( # verify that the inputs and outputs were passed correctly self._inputs = [v for v in inputs] for v in self._inputs: - if not isinstance(v, _VarData): + if not isinstance(v, VarData): raise RuntimeError( 'Argument inputs passed to PyomoExternalCyIpoptProblem must be' ' a list of VarData objects. Note: if you have an indexed variable, pass' @@ -139,7 +137,7 @@ def __init__( self._outputs = [v for v in outputs] for v in self._outputs: - if not isinstance(v, _VarData): + if not isinstance(v, VarData): raise RuntimeError( 'Argument outputs passed to PyomoExternalCyIpoptProblem must be' ' a list of VarData objects. Note: if you have an indexed variable, pass' diff --git a/pyomo/contrib/pynumero/algorithms/solvers/scipy_solvers.py b/pyomo/contrib/pynumero/algorithms/solvers/scipy_solvers.py index ec1f106b73c..a4c28392c24 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/scipy_solvers.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/scipy_solvers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/solvers/square_solver_base.py b/pyomo/contrib/pynumero/algorithms/solvers/square_solver_base.py index 1be3032c358..43f48211d3d 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/square_solver_base.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/square_solver_base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/__init__.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/__init__.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_interfaces.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_interfaces.py index 88d4df1e17d..86511f5cb7c 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_interfaces.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_interfaces.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py index e9da31097a0..5e993730a3b 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ import pyomo.common.unittest as unittest import pyomo.environ as pyo -import os +from pyomo.common.tempfiles import TempfileManager from pyomo.contrib.pynumero.dependencies import ( numpy as np, @@ -46,6 +46,7 @@ # We don't raise unittest.SkipTest if not cyipopt_available as there is a # test below that tests an exception when cyipopt is unavailable. cyipopt_ge_1_3 = hasattr(cyipopt, "CyIpoptEvaluationError") + ipopt_ge_3_14 = cyipopt.IPOPT_VERSION >= (3, 14, 0) def create_model1(): @@ -218,24 +219,25 @@ def test_model1_with_scaling(self): m.scaling_factor[m.d] = 3.0 # scale the inequality constraint m.scaling_factor[m.x[1]] = 4.0 # scale one of the x variables - cynlp = CyIpoptNLP(PyomoNLP(m)) - options = { - 'nlp_scaling_method': 'user-scaling', - 'output_file': '_cyipopt-scaling.log', - 'file_print_level': 10, - 'max_iter': 0, - } - solver = CyIpoptSolver(cynlp, options=options) - x, info = solver.solve() - - with open('_cyipopt-scaling.log', 'r') as fd: - solver_trace = fd.read() - cynlp.close() - os.remove('_cyipopt-scaling.log') - - # check for the following strings in the log and then delete the log + with TempfileManager.new_context() as temp: + cynlp = CyIpoptNLP(PyomoNLP(m)) + logfile = temp.create_tempfile('_cyipopt-scaling.log') + options = { + 'nlp_scaling_method': 'user-scaling', + 'output_file': logfile, + 'file_print_level': 10, + 'max_iter': 0, + } + solver = CyIpoptSolver(cynlp, options=options) + x, info = solver.solve() + cynlp.close() + + with open(logfile, 'r') as fd: + solver_trace = fd.read() + + # check for the following strings in the log self.assertIn('nlp_scaling_method = user-scaling', solver_trace) - self.assertIn('output_file = _cyipopt-scaling.log', solver_trace) + self.assertIn(f"output_file = {logfile}", solver_trace) self.assertIn('objective scaling factor = 1e-06', solver_trace) self.assertIn('x scaling provided', solver_trace) self.assertIn('c scaling provided', solver_trace) @@ -316,3 +318,101 @@ def test_hs071_evalerror_old_cyipopt(self): msg = "Error in AMPL evaluation" with self.assertRaisesRegex(PyNumeroEvaluationError, msg): res = solver.solve(m, tee=True) + + def test_solve_without_objective(self): + m = create_model1() + m.o.deactivate() + m.x[2].fix(0.0) + m.x[3].fix(4.0) + solver = pyo.SolverFactory("cyipopt") + res = solver.solve(m, tee=True) + pyo.assert_optimal_termination(res) + self.assertAlmostEqual(m.x[1].value, 9.0) + + def test_solve_13arg_callback(self): + m = create_model1() + + iterate_data = [] + + def intermediate( + nlp, + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + ): + x = nlp.get_primals() + y = nlp.get_duals() + iterate_data.append((x, y)) + + x_sol = np.array([3.85958688, 4.67936007, 3.10358931]) + y_sol = np.array([-1.0, 53.90357665]) + + solver = pyo.SolverFactory("cyipopt", intermediate_callback=intermediate) + res = solver.solve(m, tee=True) + pyo.assert_optimal_termination(res) + + # Make sure iterate vectors have the right shape and that the final + # iterate contains the primal solution we expect. + for x, y in iterate_data: + self.assertEqual(x.shape, (3,)) + self.assertEqual(y.shape, (2,)) + x, y = iterate_data[-1] + self.assertTrue(np.allclose(x_sol, x)) + # Note that we can't assert that dual variables in the NLP are those + # at the solution because, at this point in the algorithm, the NLP + # only has access to the *previous iteration's* dual values. + + # The 13-arg callback works with cyipopt < 1.3, but we will use the + # get_current_iterate method, which is only available in 1.3+ and IPOPT 3.14+ + @unittest.skipIf( + not cyipopt_available or not cyipopt_ge_1_3 or not ipopt_ge_3_14, + "cyipopt version < 1.3.0", + ) + def test_solve_get_current_iterate(self): + m = create_model1() + + iterate_data = [] + + def intermediate( + nlp, + problem, + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + ): + iterate = problem.get_current_iterate() + x = iterate["x"] + y = iterate["mult_g"] + iterate_data.append((x, y)) + + x_sol = np.array([3.85958688, 4.67936007, 3.10358931]) + y_sol = np.array([-1.0, 53.90357665]) + + solver = pyo.SolverFactory("cyipopt", intermediate_callback=intermediate) + res = solver.solve(m, tee=True) + pyo.assert_optimal_termination(res) + + # Make sure iterate vectors have the right shape and that the final + # iterate contains the primal and dual solution we expect. + for x, y in iterate_data: + self.assertEqual(x.shape, (3,)) + self.assertEqual(y.shape, (2,)) + x, y = iterate_data[-1] + self.assertTrue(np.allclose(x_sol, x)) + self.assertTrue(np.allclose(y_sol, y)) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_implicit_functions.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_implicit_functions.py index 3a13c1a7598..b2e9b9eace7 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_implicit_functions.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_implicit_functions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_pyomo_ext_cyipopt.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_pyomo_ext_cyipopt.py index 0036a6b3623..8cb6c432f1b 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_pyomo_ext_cyipopt.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_pyomo_ext_cyipopt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,9 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import os import pyomo.common.unittest as unittest import pyomo.environ as pyo +from pyomo.common.tempfiles import TempfileManager from pyomo.contrib.pynumero.dependencies import ( numpy as np, @@ -157,33 +157,33 @@ def test_pyomo_external_model_scaling(self): m.scaling_factor[m.F_con] = 8.0 # scale the pyomo constraint m.scaling_factor[m.Pin_con] = 9.0 # scale the pyomo constraint - cyipopt_problem = PyomoExternalCyIpoptProblem( - pyomo_model=m, - ex_input_output_model=PressureDropModel(), - inputs=[m.Pin, m.c1, m.c2, m.F], - outputs=[m.P1, m.P2], - outputs_eqn_scaling=[10.0, 11.0], - nl_file_options={'file_determinism': 2}, - ) - - # solve the problem - options = { - 'hessian_approximation': 'limited-memory', - 'nlp_scaling_method': 'user-scaling', - 'output_file': '_cyipopt-pyomo-ext-scaling.log', - 'file_print_level': 10, - 'max_iter': 0, - } - solver = CyIpoptSolver(cyipopt_problem, options=options) - x, info = solver.solve(tee=False) - - with open('_cyipopt-pyomo-ext-scaling.log', 'r') as fd: - solver_trace = fd.read() - cyipopt_problem.close() - os.remove('_cyipopt-pyomo-ext-scaling.log') + with TempfileManager.new_context() as temp: + cyipopt_problem = PyomoExternalCyIpoptProblem( + pyomo_model=m, + ex_input_output_model=PressureDropModel(), + inputs=[m.Pin, m.c1, m.c2, m.F], + outputs=[m.P1, m.P2], + outputs_eqn_scaling=[10.0, 11.0], + nl_file_options={'file_determinism': 2}, + ) + logfile = temp.create_tempfile('_cyipopt-pyomo-ext-scaling.log') + # solve the problem + options = { + 'hessian_approximation': 'limited-memory', + 'nlp_scaling_method': 'user-scaling', + 'output_file': logfile, + 'file_print_level': 10, + 'max_iter': 0, + } + solver = CyIpoptSolver(cyipopt_problem, options=options) + x, info = solver.solve(tee=False) + cyipopt_problem.close() + + with open(logfile, 'r') as fd: + solver_trace = fd.read() self.assertIn('nlp_scaling_method = user-scaling', solver_trace) - self.assertIn('output_file = _cyipopt-pyomo-ext-scaling.log', solver_trace) + self.assertIn(f"output_file = {logfile}", solver_trace) self.assertIn('objective scaling factor = 0.1', solver_trace) self.assertIn('x scaling provided', solver_trace) self.assertIn('c scaling provided', solver_trace) @@ -232,35 +232,33 @@ def test_pyomo_external_model_ndarray_scaling(self): m.scaling_factor[m.Pin_con] = 9.0 # scale the pyomo constraint # test that this all works with ndarray input as well - cyipopt_problem = PyomoExternalCyIpoptProblem( - pyomo_model=m, - ex_input_output_model=PressureDropModel(), - inputs=[m.Pin, m.c1, m.c2, m.F], - outputs=[m.P1, m.P2], - outputs_eqn_scaling=np.asarray([10.0, 11.0], dtype=np.float64), - nl_file_options={'file_determinism': 2}, - ) - - # solve the problem - options = { - 'hessian_approximation': 'limited-memory', - 'nlp_scaling_method': 'user-scaling', - 'output_file': '_cyipopt-pyomo-ext-scaling-ndarray.log', - 'file_print_level': 10, - 'max_iter': 0, - } - solver = CyIpoptSolver(cyipopt_problem, options=options) - x, info = solver.solve(tee=False) - - with open('_cyipopt-pyomo-ext-scaling-ndarray.log', 'r') as fd: - solver_trace = fd.read() - cyipopt_problem.close() - os.remove('_cyipopt-pyomo-ext-scaling-ndarray.log') + with TempfileManager.new_context() as temp: + cyipopt_problem = PyomoExternalCyIpoptProblem( + pyomo_model=m, + ex_input_output_model=PressureDropModel(), + inputs=[m.Pin, m.c1, m.c2, m.F], + outputs=[m.P1, m.P2], + outputs_eqn_scaling=np.asarray([10.0, 11.0], dtype=np.float64), + nl_file_options={'file_determinism': 2}, + ) + logfile = temp.create_tempfile('_cyipopt-pyomo-ext-scaling-ndarray.log') + # solve the problem + options = { + 'hessian_approximation': 'limited-memory', + 'nlp_scaling_method': 'user-scaling', + 'output_file': logfile, + 'file_print_level': 10, + 'max_iter': 0, + } + solver = CyIpoptSolver(cyipopt_problem, options=options) + x, info = solver.solve(tee=False) + cyipopt_problem.close() + + with open(logfile, 'r') as fd: + solver_trace = fd.read() self.assertIn('nlp_scaling_method = user-scaling', solver_trace) - self.assertIn( - 'output_file = _cyipopt-pyomo-ext-scaling-ndarray.log', solver_trace - ) + self.assertIn(f'output_file = {logfile}', solver_trace) self.assertIn('objective scaling factor = 0.1', solver_trace) self.assertIn('x scaling provided', solver_trace) self.assertIn('c scaling provided', solver_trace) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_scipy_solvers.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_scipy_solvers.py index 33b58f17887..f89ba812d9b 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_scipy_solvers.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_scipy_solvers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/asl.py b/pyomo/contrib/pynumero/asl.py index 55ecc7fd0ee..cffaaeecc35 100644 --- a/pyomo/contrib/pynumero/asl.py +++ b/pyomo/contrib/pynumero/asl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/build.py b/pyomo/contrib/pynumero/build.py index bb8443640d5..015c7246850 100644 --- a/pyomo/contrib/pynumero/build.py +++ b/pyomo/contrib/pynumero/build.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/dependencies.py b/pyomo/contrib/pynumero/dependencies.py index 9e2088ffa0a..f4cb08afa63 100644 --- a/pyomo/contrib/pynumero/dependencies.py +++ b/pyomo/contrib/pynumero/dependencies.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,7 +17,7 @@ 'numpy', 'Pynumero requires the optional Pyomo dependency "numpy"', minimum_version='1.13.0', - defer_check=False, + defer_import=False, ) if not numpy_available: diff --git a/pyomo/contrib/pynumero/examples/__init__.py b/pyomo/contrib/pynumero/examples/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/examples/__init__.py +++ b/pyomo/contrib/pynumero/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/callback/__init__.py b/pyomo/contrib/pynumero/examples/callback/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/examples/callback/__init__.py +++ b/pyomo/contrib/pynumero/examples/callback/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/callback/cyipopt_callback.py b/pyomo/contrib/pynumero/examples/callback/cyipopt_callback.py index f66374f6213..f0ab3e05af7 100644 --- a/pyomo/contrib/pynumero/examples/callback/cyipopt_callback.py +++ b/pyomo/contrib/pynumero/examples/callback/cyipopt_callback.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/callback/cyipopt_callback_halt.py b/pyomo/contrib/pynumero/examples/callback/cyipopt_callback_halt.py index 9e88f8d4964..018c51db814 100644 --- a/pyomo/contrib/pynumero/examples/callback/cyipopt_callback_halt.py +++ b/pyomo/contrib/pynumero/examples/callback/cyipopt_callback_halt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/callback/cyipopt_functor_callback.py b/pyomo/contrib/pynumero/examples/callback/cyipopt_functor_callback.py index 4befc816e1b..98d2e786b2d 100644 --- a/pyomo/contrib/pynumero/examples/callback/cyipopt_functor_callback.py +++ b/pyomo/contrib/pynumero/examples/callback/cyipopt_functor_callback.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/callback/reactor_design.py b/pyomo/contrib/pynumero/examples/callback/reactor_design.py index 3d9e19a446e..e32b3398f14 100644 --- a/pyomo/contrib/pynumero/examples/callback/reactor_design.py +++ b/pyomo/contrib/pynumero/examples/callback/reactor_design.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/__init__.py b/pyomo/contrib/pynumero/examples/external_grey_box/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/__init__.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/external_with_objective.py b/pyomo/contrib/pynumero/examples/external_grey_box/external_with_objective.py new file mode 100644 index 00000000000..78119aea8d1 --- /dev/null +++ b/pyomo/contrib/pynumero/examples/external_grey_box/external_with_objective.py @@ -0,0 +1,162 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import math +import numpy as np +from scipy.sparse import coo_matrix +import pyomo.environ as pyo +from pyomo.contrib.pynumero.interfaces.external_grey_box import ( + ExternalGreyBoxModel, + ExternalGreyBoxBlock, +) + + +class Unconstrained(ExternalGreyBoxModel): + """ + min (x+2)**2 + (y-2)**2 + """ + + def input_names(self): + return ['x', 'y'] + + def set_input_values(self, input_values): + self._input_values = list(input_values) + + def has_objective(self): + return True + + def evaluate_objective(self): + x = self._input_values[0] + y = self._input_values[1] + return (x + 2) ** 2 + (y - 2) ** 2 + + def evaluate_grad_objective(self): + x = self._input_values[0] + y = self._input_values[1] + return np.asarray([2 * (x + 2), 2 * (y - 2)], dtype=float) + + +class Constrained(ExternalGreyBoxModel): + """ + min x**2 + y**2 + s.t. 0 == y - exp(x) + """ + + def input_names(self): + return ['x', 'y'] + + def set_input_values(self, input_values): + self._input_values = list(input_values) + + def has_objective(self): + return True + + def evaluate_objective(self): + x = self._input_values[0] + y = self._input_values[1] + return x**2 + y**2 + + def evaluate_grad_objective(self): + x = self._input_values[0] + y = self._input_values[1] + return np.asarray([2 * x, 2 * y], dtype=float) + + def equality_constraint_names(self): + return ['c1'] + + def evaluate_equality_constraints(self): + x = self._input_values[0] + y = self._input_values[1] + return np.asarray([y - math.exp(x)], dtype=float) + + def evaluate_jacobian_equality_constraints(self): + x = self._input_values[0] + row = [0, 0] + col = [0, 1] + data = [-math.exp(x), 1] + jac = coo_matrix((data, (row, col)), shape=(1, 2)) + return jac + + +class ConstrainedWithHessian(Constrained): + def evaluate_hessian_objective(self): + row = [0, 1] + col = [0, 1] + data = [2, 2] + hess = coo_matrix((data, (row, col)), shape=(2, 2)) + return hess + + def set_equality_constraint_multipliers(self, eq_con_multiplier_values): + self._dual = eq_con_multiplier_values[0] + + def evaluate_hessian_equality_constraints(self): + x = self._input_values[0] + row = [0] + col = [0] + data = [-math.exp(x) * self._dual] + hess = coo_matrix((data, (row, col)), shape=(2, 2)) + return hess + + +def solve_unconstrained(): + m = pyo.ConcreteModel() + m.z = pyo.Var() + m.grey_box = ExternalGreyBoxBlock(external_model=Unconstrained()) + m.c = pyo.Constraint(expr=m.z == m.grey_box.inputs['x'] + 1) + + opt = pyo.SolverFactory('cyipopt') + opt.config.options['hessian_approximation'] = 'limited-memory' + res = opt.solve(m, tee=True) + pyo.assert_optimal_termination(res) + x = m.grey_box.inputs['x'].value + y = m.grey_box.inputs['y'].value + assert math.isclose(x, -2) + assert math.isclose(y, 2) + return m + + +def solve_constrained(): + m = pyo.ConcreteModel() + m.z = pyo.Var() + m.grey_box = ExternalGreyBoxBlock(external_model=Constrained()) + m.c2 = pyo.Constraint(expr=m.z == m.grey_box.inputs['x'] + 1) + + opt = pyo.SolverFactory('cyipopt') + opt.config.options['hessian_approximation'] = 'limited-memory' + res = opt.solve(m, tee=True) + pyo.assert_optimal_termination(res) + x = m.grey_box.inputs['x'].value + y = m.grey_box.inputs['y'].value + assert math.isclose(x, -0.4263027509962655) + assert math.isclose(y, 0.6529186403960969) + return m + + +def solve_constrained_with_hessian(): + m = pyo.ConcreteModel() + m.z = pyo.Var() + m.grey_box = ExternalGreyBoxBlock(external_model=ConstrainedWithHessian()) + m.c2 = pyo.Constraint(expr=m.z == m.grey_box.inputs['x'] + 1) + + opt = pyo.SolverFactory('cyipopt') + res = opt.solve(m, tee=True) + pyo.assert_optimal_termination(res) + x = m.grey_box.inputs['x'].value + y = m.grey_box.inputs['y'].value + assert math.isclose(x, -0.4263027509962655) + assert math.isclose(y, 0.6529186403960969) + return m + + +if __name__ == '__main__': + m = solve_constrained_with_hessian() + print(f"x: {m.grey_box.inputs['x'].value}") + print(f"y: {m.grey_box.inputs['y'].value}") diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/param_est/__init__.py b/pyomo/contrib/pynumero/examples/external_grey_box/param_est/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/param_est/__init__.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/param_est/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/param_est/generate_data.py b/pyomo/contrib/pynumero/examples/external_grey_box/param_est/generate_data.py index 65bb2c82de8..2e60591e4b8 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/param_est/generate_data.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/param_est/generate_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/param_est/models.py b/pyomo/contrib/pynumero/examples/external_grey_box/param_est/models.py index c6560b4f9c5..a9f98f5b3d2 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/param_est/models.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/param_est/models.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/param_est/perform_estimation.py b/pyomo/contrib/pynumero/examples/external_grey_box/param_est/perform_estimation.py index 142b47f8172..dd2aca16291 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/param_est/perform_estimation.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/param_est/perform_estimation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/__init__.py b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/__init__.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/maximize_cb_outputs.py b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/maximize_cb_outputs.py index e6afd8995a2..1802aed9de5 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/maximize_cb_outputs.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/maximize_cb_outputs.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/maximize_cb_ratio_residuals.py b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/maximize_cb_ratio_residuals.py index 415b58bee54..8774b895edb 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/maximize_cb_ratio_residuals.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/maximize_cb_ratio_residuals.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/reactor_model_outputs.py b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/reactor_model_outputs.py index ef8b2783237..96d04e774a8 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/reactor_model_outputs.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/reactor_model_outputs.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/reactor_model_residuals.py b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/reactor_model_residuals.py index bc5a2ca4ce4..c38e134e065 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/react_example/reactor_model_residuals.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/react_example/reactor_model_residuals.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/feasibility.py b/pyomo/contrib/pynumero/examples/feasibility.py index 59e4edcc9ec..7bddb1629c0 100644 --- a/pyomo/contrib/pynumero/examples/feasibility.py +++ b/pyomo/contrib/pynumero/examples/feasibility.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/mumps_example.py b/pyomo/contrib/pynumero/examples/mumps_example.py index 588ce58bc12..7e31032ce55 100644 --- a/pyomo/contrib/pynumero/examples/mumps_example.py +++ b/pyomo/contrib/pynumero/examples/mumps_example.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/nlp_interface.py b/pyomo/contrib/pynumero/examples/nlp_interface.py index 556b8ec0713..a1dd15e549f 100644 --- a/pyomo/contrib/pynumero/examples/nlp_interface.py +++ b/pyomo/contrib/pynumero/examples/nlp_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/nlp_interface_2.py b/pyomo/contrib/pynumero/examples/nlp_interface_2.py index 4a288a178b1..92530da5d03 100644 --- a/pyomo/contrib/pynumero/examples/nlp_interface_2.py +++ b/pyomo/contrib/pynumero/examples/nlp_interface_2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/parallel_matvec.py b/pyomo/contrib/pynumero/examples/parallel_matvec.py index cd77bcfabc9..f90e3d88819 100644 --- a/pyomo/contrib/pynumero/examples/parallel_matvec.py +++ b/pyomo/contrib/pynumero/examples/parallel_matvec.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/parallel_vector_ops.py b/pyomo/contrib/pynumero/examples/parallel_vector_ops.py index fe49ff29e59..ece95d14cfc 100644 --- a/pyomo/contrib/pynumero/examples/parallel_vector_ops.py +++ b/pyomo/contrib/pynumero/examples/parallel_vector_ops.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/sensitivity.py b/pyomo/contrib/pynumero/examples/sensitivity.py index 0bb0fb3a740..3329d19aa7e 100644 --- a/pyomo/contrib/pynumero/examples/sensitivity.py +++ b/pyomo/contrib/pynumero/examples/sensitivity.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/sqp.py b/pyomo/contrib/pynumero/examples/sqp.py index 925cab4c20b..8999c711ef5 100644 --- a/pyomo/contrib/pynumero/examples/sqp.py +++ b/pyomo/contrib/pynumero/examples/sqp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,7 +15,7 @@ from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP import numpy as np from scipy.sparse import tril -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo import dae from pyomo.common.timing import TicTocTimer import time @@ -47,65 +47,65 @@ def build_burgers_model(nfe_x=100, nfe_t=200, start_t=0, end_t=1): end_x = 1 dx = (end_x - start_x) / float(nfe_x) - m = pe.Block(concrete=True) - m.omega = pe.Param(initialize=0.02) - m.v = pe.Param(initialize=0.01) - m.r = pe.Param(initialize=0) + m = pyo.Block(concrete=True) + m.omega = pyo.Param(initialize=0.02) + m.v = pyo.Param(initialize=0.01) + m.r = pyo.Param(initialize=0) m.x = dae.ContinuousSet(bounds=(start_x, end_x)) m.t = dae.ContinuousSet(bounds=(start_t, end_t)) - m.y = pe.Var(m.x, m.t) + m.y = pyo.Var(m.x, m.t) m.dydt = dae.DerivativeVar(m.y, wrt=m.t) m.dydx = dae.DerivativeVar(m.y, wrt=m.x) m.dydx2 = dae.DerivativeVar(m.y, wrt=(m.x, m.x)) - m.u = pe.Var(m.x, m.t) + m.u = pyo.Var(m.x, m.t) def _y_init_rule(m, x): if x <= 0.5 * end_x: return 1 return 0 - m.y0 = pe.Param(m.x, default=_y_init_rule) + m.y0 = pyo.Param(m.x, default=_y_init_rule) def _upper_x_bound(m, t): return m.y[end_x, t] == 0 - m.upper_x_bound = pe.Constraint(m.t, rule=_upper_x_bound) + m.upper_x_bound = pyo.Constraint(m.t, rule=_upper_x_bound) def _lower_x_bound(m, t): return m.y[start_x, t] == 0 - m.lower_x_bound = pe.Constraint(m.t, rule=_lower_x_bound) + m.lower_x_bound = pyo.Constraint(m.t, rule=_lower_x_bound) def _upper_x_ubound(m, t): return m.u[end_x, t] == 0 - m.upper_x_ubound = pe.Constraint(m.t, rule=_upper_x_ubound) + m.upper_x_ubound = pyo.Constraint(m.t, rule=_upper_x_ubound) def _lower_x_ubound(m, t): return m.u[start_x, t] == 0 - m.lower_x_ubound = pe.Constraint(m.t, rule=_lower_x_ubound) + m.lower_x_ubound = pyo.Constraint(m.t, rule=_lower_x_ubound) def _lower_t_bound(m, x): if x == start_x or x == end_x: - return pe.Constraint.Skip + return pyo.Constraint.Skip return m.y[x, start_t] == m.y0[x] def _lower_t_ubound(m, x): if x == start_x or x == end_x: - return pe.Constraint.Skip + return pyo.Constraint.Skip return m.u[x, start_t] == 0 - m.lower_t_bound = pe.Constraint(m.x, rule=_lower_t_bound) - m.lower_t_ubound = pe.Constraint(m.x, rule=_lower_t_ubound) + m.lower_t_bound = pyo.Constraint(m.x, rule=_lower_t_bound) + m.lower_t_ubound = pyo.Constraint(m.x, rule=_lower_t_ubound) # PDE def _pde(m, x, t): if t == start_t or x == end_x or x == start_x: - e = pe.Constraint.Skip + e = pyo.Constraint.Skip else: # print(foo.last_t, t-dt, abs(foo.last_t - (t-dt))) # assert math.isclose(foo.last_t, t - dt, abs_tol=1e-6) @@ -115,10 +115,10 @@ def _pde(m, x, t): ) return e - m.pde = pe.Constraint(m.x, m.t, rule=_pde) + m.pde = pyo.Constraint(m.x, m.t, rule=_pde) # Discretize Model - disc = pe.TransformationFactory('dae.finite_difference') + disc = pyo.TransformationFactory('dae.finite_difference') disc.apply_to(m, nfe=nfe_t, wrt=m.t, scheme='BACKWARD') disc.apply_to(m, nfe=nfe_x, wrt=m.x, scheme='CENTRAL') @@ -142,7 +142,7 @@ def _obj(m): e += 0.5 * 0.5 * dx * dt * m.omega * m.u[x, start_t] ** 2 return e - m.obj = pe.Objective(rule=_obj) + m.obj = pyo.Objective(rule=_obj) return m @@ -210,7 +210,7 @@ def sqp( z += delta -def load_solution(m: pe.ConcreteModel(), nlp: PyomoNLP): +def load_solution(m: pyo.ConcreteModel(), nlp: PyomoNLP): primals = nlp.get_primals() pyomo_vars = nlp.get_pyomo_variables() for v, val in zip(pyomo_vars, primals): @@ -222,7 +222,7 @@ def main(linear_solver, nfe_x=100, nfe_t=200): nlp = PyomoNLP(m) sqp(nlp, linear_solver) load_solution(m, nlp) - return pe.value(m.obj) + return pyo.value(m.obj) if __name__ == '__main__': diff --git a/pyomo/contrib/pynumero/examples/tests/__init__.py b/pyomo/contrib/pynumero/examples/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/examples/tests/__init__.py +++ b/pyomo/contrib/pynumero/examples/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/examples/tests/test_cyipopt_examples.py b/pyomo/contrib/pynumero/examples/tests/test_cyipopt_examples.py index 1f45f26d43b..873b4bf00ec 100644 --- a/pyomo/contrib/pynumero/examples/tests/test_cyipopt_examples.py +++ b/pyomo/contrib/pynumero/examples/tests/test_cyipopt_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,14 +10,16 @@ # ___________________________________________________________________________ import os.path +from io import StringIO +import logging + from pyomo.common.fileutils import this_file_dir, import_file +from pyomo.common.tempfiles import TempfileManager import pyomo.common.unittest as unittest import pyomo.environ as pyo from pyomo.common.dependencies import attempt_import from pyomo.common.log import LoggingIntercept from pyomo.opt import TerminationCondition -from io import StringIO -import logging from pyomo.contrib.pynumero.dependencies import ( numpy as np, @@ -35,7 +37,7 @@ 'One of the tests below requires a recent version of pandas for' ' comparing with a tolerance.', minimum_version='1.1.0', - defer_check=False, + defer_import=False, ) from pyomo.contrib.pynumero.asl import AmplInterface @@ -44,11 +46,13 @@ raise unittest.SkipTest("Pynumero needs the ASL extension to run CyIpopt tests") import pyomo.contrib.pynumero.algorithms.solvers.cyipopt_solver as cyipopt_solver +from pyomo.contrib.pynumero.interfaces.cyipopt_interface import cyipopt_available -if not cyipopt_solver.cyipopt_available: +if not cyipopt_available: raise unittest.SkipTest("PyNumero needs CyIpopt installed to run CyIpopt tests") import cyipopt as cyipopt_core + example_dir = os.path.join(this_file_dir(), '..') @@ -85,30 +89,34 @@ def test_external_grey_box_react_example_maximize_cb_outputs_scaling(self): 'maximize_cb_ratio_residuals.py', ) ) - aoptions = { - 'nlp_scaling_method': 'user-scaling', - 'output_file': '_cyipopt-external-greybox-react-scaling.log', - 'file_print_level': 10, - } - m = ex.maximize_cb_ratio_residuals_with_output_scaling( - additional_options=aoptions - ) - self.assertAlmostEqual(pyo.value(m.reactor.inputs['sv']), 1.26541996, places=3) - self.assertAlmostEqual( - pyo.value(m.reactor.inputs['cb']), 1071.7410089, places=2 - ) - self.assertAlmostEqual( - pyo.value(m.reactor.outputs['cb_ratio']), 0.15190409266, places=3 - ) - with open('_cyipopt-external-greybox-react-scaling.log', 'r') as fd: - solver_trace = fd.read() - os.remove('_cyipopt-external-greybox-react-scaling.log') + with TempfileManager.new_context() as temp: + logfile = temp.create_tempfile( + '_cyipopt-external-greybox-react-scaling.log' + ) + aoptions = { + 'nlp_scaling_method': 'user-scaling', + 'output_file': logfile, + 'file_print_level': 10, + } + m = ex.maximize_cb_ratio_residuals_with_output_scaling( + additional_options=aoptions + ) + self.assertAlmostEqual( + pyo.value(m.reactor.inputs['sv']), 1.26541996, places=3 + ) + self.assertAlmostEqual( + pyo.value(m.reactor.inputs['cb']), 1071.7410089, places=2 + ) + self.assertAlmostEqual( + pyo.value(m.reactor.outputs['cb_ratio']), 0.15190409266, places=3 + ) + + with open(logfile, 'r') as fd: + solver_trace = fd.read() self.assertIn('nlp_scaling_method = user-scaling', solver_trace) - self.assertIn( - 'output_file = _cyipopt-external-greybox-react-scaling.log', solver_trace - ) + self.assertIn(f'output_file = {logfile}', solver_trace) self.assertIn('objective scaling factor = 1', solver_trace) self.assertIn('x scaling provided', solver_trace) self.assertIn('c scaling provided', solver_trace) @@ -266,6 +274,11 @@ def test_cyipopt_functor(self): s = df['ca_bal'] self.assertAlmostEqual(s.iloc[6], 0, places=3) + @unittest.skipIf( + cyipopt_solver.PyomoCyIpoptSolver().version() == (1, 4, 0), + "Terminating Ipopt through a user callback is broken in CyIpopt 1.4.0 " + "(see mechmotum/cyipopt#249)", + ) def test_cyipopt_callback_halt(self): ex = import_file( os.path.join(example_dir, 'callback', 'cyipopt_callback_halt.py') diff --git a/pyomo/contrib/pynumero/examples/tests/test_examples.py b/pyomo/contrib/pynumero/examples/tests/test_examples.py index d1494bab557..b50315e85ff 100644 --- a/pyomo/contrib/pynumero/examples/tests/test_examples.py +++ b/pyomo/contrib/pynumero/examples/tests/test_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,9 +25,9 @@ from pyomo.contrib.pynumero.linalg.mumps_interface import mumps_available from pyomo.contrib.pynumero.linalg.scipy_interface import ScipyLU -import pyomo.environ as pe +import pyomo.environ as pyo -ipopt_opt = pe.SolverFactory('ipopt') +ipopt_opt = pyo.SolverFactory('ipopt') ipopt_available = ipopt_opt.available(exception_flag=False) from pyomo.contrib.pynumero.examples import ( diff --git a/pyomo/contrib/pynumero/examples/tests/test_mpi_examples.py b/pyomo/contrib/pynumero/examples/tests/test_mpi_examples.py index 1ee02bb70ca..facbae95063 100644 --- a/pyomo/contrib/pynumero/examples/tests/test_mpi_examples.py +++ b/pyomo/contrib/pynumero/examples/tests/test_mpi_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/exceptions.py b/pyomo/contrib/pynumero/exceptions.py index 6b46dd2d9a7..83e4472303c 100644 --- a/pyomo/contrib/pynumero/exceptions.py +++ b/pyomo/contrib/pynumero/exceptions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/__init__.py b/pyomo/contrib/pynumero/interfaces/__init__.py index e2de0dd25cc..0db267a55ba 100644 --- a/pyomo/contrib/pynumero/interfaces/__init__.py +++ b/pyomo/contrib/pynumero/interfaces/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/ampl_nlp.py b/pyomo/contrib/pynumero/interfaces/ampl_nlp.py index 30258b3e685..27a818c6307 100644 --- a/pyomo/contrib/pynumero/interfaces/ampl_nlp.py +++ b/pyomo/contrib/pynumero/interfaces/ampl_nlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index 7845a4c189e..2e2e508bbf7 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,6 +21,7 @@ objects for the matrices (e.g., AmplNLP and PyomoNLP) """ import abc +import inspect from pyomo.common.dependencies import attempt_import, numpy as np, numpy_available from pyomo.contrib.pynumero.exceptions import PyNumeroEvaluationError @@ -309,6 +310,49 @@ def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=Non # cyipopt.Problem.__init__ super(CyIpoptNLP, self).__init__() + # Pre-Pyomo 6.8.0, we had no way to pass the cyipopt.Problem object + # to the user in an intermediate callback. This prevented them from calling + # the useful get_current_iterate and get_current_violations methods. Now, + # we support this by adding the Problem object to the args we pass to a user's + # callback. To preserve backwards compatibility, we inspect the user's + # callback to infer whether they want this argument. To preserve backwards + # compatibility if the user asked for variable-length *args, we do not pass + # the Problem object as an argument in this case. + # A more maintainable solution may be to force users to accept **kwds if they + # want "extra info." If we find ourselves continuing to augment this callback, + # this may be worth considering. -RBP + self._use_13arg_callback = None + if self._intermediate_callback is not None: + signature = inspect.signature(self._intermediate_callback) + positional_kinds = { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.POSITIONAL_ONLY, + } + positional = [ + param + for param in signature.parameters.values() + if param.kind in positional_kinds + ] + has_var_args = any( + p.kind is inspect.Parameter.VAR_POSITIONAL + for p in signature.parameters.values() + ) + + if len(positional) == 13 and not has_var_args: + # If *args is expected, we do not use the new callback + # signature. + self._use_13arg_callback = True + elif len(positional) == 12 or has_var_args: + # If *args is expected, we use the old callback signature + # for backwards compatibility. + self._use_13arg_callback = False + else: + raise ValueError( + "Invalid intermediate callback. A function with either 12 or 13" + " positional arguments, or a variable number of arguments, is" + " expected." + ) + def _set_primals_if_necessary(self, x): if not np.array_equal(x, self._cached_x): self._nlp.set_primals(x) @@ -436,19 +480,53 @@ def intermediate( alpha_pr, ls_trials, ): + """Calls user's intermediate callback + + This method has the call signature expected by CyIpopt. We then extend + this call signature to provide users of this interface class additional + functionality. Additional arguments are: + + - The ``NLP`` object that was used to construct this class instance. + This is useful for querying the variables, constraints, and + derivatives during the callback. + - The class instance itself. This is useful for calling the + ``get_current_iterate`` and ``get_current_violations`` methods, which + query Ipopt's internal data structures to provide this information. + + """ if self._intermediate_callback is not None: - return self._intermediate_callback( - self._nlp, - alg_mod, - iter_count, - obj_value, - inf_pr, - inf_du, - mu, - d_norm, - regularization_size, - alpha_du, - alpha_pr, - ls_trials, - ) + if self._use_13arg_callback: + # This is the callback signature expected as of Pyomo 6.8.0 + return self._intermediate_callback( + self._nlp, + self, + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + ) + else: + # This is the callback signature expected pre-Pyomo 6.8.0 and + # is supported for backwards compatibility. + return self._intermediate_callback( + self._nlp, + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + ) return True diff --git a/pyomo/contrib/pynumero/interfaces/external_grey_box.py b/pyomo/contrib/pynumero/interfaces/external_grey_box.py index 7e42f161bee..c4ed59ee19f 100644 --- a/pyomo/contrib/pynumero/interfaces/external_grey_box.py +++ b/pyomo/contrib/pynumero/interfaces/external_grey_box.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,6 +11,7 @@ import abc import logging +import numpy as np from scipy.sparse import coo_matrix from pyomo.common.dependencies import numpy as np @@ -18,7 +19,7 @@ from pyomo.common.log import is_debug_set from pyomo.common.timing import ConstructionTimer from pyomo.core.base import Var, Set, Constraint, value -from pyomo.core.base.block import _BlockData, Block, declare_custom_block +from pyomo.core.base.block import BlockData, Block, declare_custom_block from pyomo.core.base.global_set import UnindexedComponent_index from pyomo.core.base.initializer import Initializer from pyomo.core.base.set import UnindexedComponent_set @@ -45,6 +46,10 @@ we have a specialized interface built on PyNumero that provides an interface to the CyIpopt solver. +constraints: c(x) = 0 +outputs: y = c(x) + + To use this interface: * Create a class that is derived from ExternalGreyBoxModel and implement the necessary methods. This derived class must provide @@ -126,6 +131,9 @@ def evaluate_hessian_outputs(self): the input variables. This method must return H_o^k = sum_i (y_o^k)_i * grad^2_{uu} w_o(u^k) + def evaluate_hessian_objective(self): + Compute the hessian of the objective + Examples that show Hessian support are also found in: pyomo/contrib/pynumero/examples/external_grey_box/react-example/ @@ -315,8 +323,31 @@ def evaluate_jacobian_outputs(self): # def evaluate_hessian_outputs(self): # + # Support for objectives + def has_objective(self): + return False + + def evaluate_objective(self) -> float: + """ + Compute the objective from the values set in + input_values + """ + raise NotImplementedError( + 'evaluate_objective called but not implemented in the derived class.' + ) + + def evaluate_grad_objective(self, out=None): + """ + Compute the gradient of the objective from the + values set in input_values + """ + raise NotImplementedError( + 'evaluate_grad_objective called but not ' + 'implemented in the derived class.' + ) + -class ExternalGreyBoxBlockData(_BlockData): +class ExternalGreyBoxBlockData(BlockData): def set_external_model(self, external_grey_box_model, inputs=None, outputs=None): """ Parameters @@ -424,7 +455,7 @@ class ScalarExternalGreyBoxBlock(ExternalGreyBoxBlockData, ExternalGreyBoxBlock) def __init__(self, *args, **kwds): ExternalGreyBoxBlockData.__init__(self, component=self) ExternalGreyBoxBlock.__init__(self, *args, **kwds) - # The above inherit from Block and _BlockData, so it's not until here + # The above inherit from Block and BlockData, so it's not until here # that we know it's scalar. So we set the index accordingly. self._index = UnindexedComponent_index diff --git a/pyomo/contrib/pynumero/interfaces/external_pyomo_model.py b/pyomo/contrib/pynumero/interfaces/external_pyomo_model.py index bae3e0b8159..2b39dd9537a 100644 --- a/pyomo/contrib/pynumero/interfaces/external_pyomo_model.py +++ b/pyomo/contrib/pynumero/interfaces/external_pyomo_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/nlp.py b/pyomo/contrib/pynumero/interfaces/nlp.py index d6571086429..c9d0e106f33 100644 --- a/pyomo/contrib/pynumero/interfaces/nlp.py +++ b/pyomo/contrib/pynumero/interfaces/nlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,30 +15,35 @@ The first interface (NLP) presents the NLP in the following form (where all equality and inequality constraints are combined) -minimize f(x) -subject to g_L <= g(x) <= g_U - x_L <= x <= x_U +.. math:: -where x \in R^{n_x} are the primal variables, - x_L \in R^{n_x} are the lower bounds of the primal variables, - x_U \in R^{n_x} are the upper bounds of the primal variables, - g: R^{n_x} \rightarrow R^{n_c} are constraints (combined - equality and inequality) + \min\ & f(x) \\ + s.t.\ & g_L <= g(x) <= g_U \\ + & x_L <= x <= x_U + +where: +- :math:`x \in R^{n_x}` are the primal variables, +- :math:`x_L \in R^{n_x}` are the lower bounds of the primal variables, +- :math:`x_U \in R^{n_x}` are the upper bounds of the primal variables, +- :math:`g: R^{n_x} \rightarrow R^{n_c}` are constraints (equality and inequality) The second interface (ExtendedNLP) extends the definition above and presents the NLP in the following form where the equality and inequality constraints are separated. -minimize f(x) -subject to h(x) = 0 - q_L <= q(x) <= q_U - x_L <= x <= x_U +.. math:: + + \min\ & f(x) \\ + s.t.\ & h(x) = 0 \\ + & q_L <= q(x) <= q_U \\ + & x_L <= x <= x_U -where x \in R^{n_x} are the primal variables, - x_L \in R^{n_x} are the lower bounds of the primal variables, - x_U \in R^{n_x} are the upper bounds of the primal variables, - h: R^{n_x} \rightarrow R^{n_eq} are the equality constraints - q: R^{n_x} \rightarrow R^{n_ineq} are the inequality constraints +where: +- :math:`x \in R^{n_x}` are the primal variables, +- :math:`x_L \in R^{n_x}` are the lower bounds of the primal variables, +- :math:`x_U \in R^{n_x}` are the upper bounds of the primal variables, +- :math:`h: R^{n_x} \rightarrow R^{n_eq}` are the equality constraints +- :math:`q: R^{n_x} \rightarrow R^{n_ineq}` are the inequality constraints Note: In the case of the ExtendedNLP, it is generally assumed that both the NLP and the ExtendedNLP interfaces are supported and diff --git a/pyomo/contrib/pynumero/interfaces/nlp_projections.py b/pyomo/contrib/pynumero/interfaces/nlp_projections.py index 4be3cd28dd5..fba9a53484e 100644 --- a/pyomo/contrib/pynumero/interfaces/nlp_projections.py +++ b/pyomo/contrib/pynumero/interfaces/nlp_projections.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/pyomo_grey_box_nlp.py b/pyomo/contrib/pynumero/interfaces/pyomo_grey_box_nlp.py index e6ed40e9974..af40f850b7e 100644 --- a/pyomo/contrib/pynumero/interfaces/pyomo_grey_box_nlp.py +++ b/pyomo/contrib/pynumero/interfaces/pyomo_grey_box_nlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,6 +21,7 @@ from pyomo.common.deprecation import deprecated import pyomo.core.base as pyo from pyomo.common.collections import ComponentMap +from pyomo.common.modeling import unique_component_name from pyomo.contrib.pynumero.sparse.block_matrix import BlockMatrix from pyomo.contrib.pynumero.sparse.block_vector import BlockVector from pyomo.contrib.pynumero.interfaces.nlp import NLP @@ -31,6 +32,7 @@ ) from pyomo.contrib.pynumero.interfaces.external_grey_box import ExternalGreyBoxBlock from pyomo.contrib.pynumero.interfaces.nlp_projections import ProjectedNLP +from pyomo.core.base.suffix import SuffixFinder # Todo: make some of the numpy arrays not writable from __init__ @@ -44,6 +46,7 @@ def __init__(self, pyomo_model): # this is done over *all* variables in active blocks, even # if they are not included in this model self._pyomo_model_var_names_to_datas = None + number_of_objectives = 0 try: # We support Pynumero's ExternalGreyBoxBlock modeling # objects that are provided through ExternalGreyBoxBlock objects @@ -58,9 +61,24 @@ def __init__(self, pyomo_model): # store the pyomo model self._pyomo_model = pyomo_model + + # count the number of objectives excluding grey box objectives + for obj in self._pyomo_model.component_data_objects( + pyo.Objective, active=True, descend_into=True + ): + number_of_objectives += 1 + # build a PyomoNLP object (will include the "pyomo" # part of the model only) - self._pyomo_nlp = PyomoNLP(pyomo_model) + if number_of_objectives == 0: + objname = unique_component_name(pyomo_model, "_obj") + objective = pyomo_model.add_component(objname, pyo.Objective(expr=0.0)) + try: + self._pyomo_nlp = PyomoNLP(pyomo_model) + finally: + if number_of_objectives == 0: + pyomo_model.del_component(objective) + self._pyomo_model_var_names_to_datas = { v.getname(fully_qualified=True): v for v in pyomo_model.component_data_objects( @@ -99,8 +117,15 @@ def __init__(self, pyomo_model): fixed_vars.extend(v for v in data.inputs.values() if v.fixed) fixed_vars.extend(v for v in data.outputs.values() if v.fixed) greybox_nlp = _ExternalGreyBoxAsNLP(data) + if data.get_external_model().has_objective(): + number_of_objectives += 1 greybox_nlps.append(greybox_nlp) + if number_of_objectives > 1: + raise ValueError( + f'Found {number_of_objectives} active objectives. Expected 1.' + ) + if fixed_vars: logging.getLogger(__name__).error( 'PyomoNLPWithGreyBoxBlocks found fixed variables for the' @@ -226,13 +251,15 @@ def __init__(self, pyomo_model): else: need_scaling = True - self._primals_scaling = np.ones(self.n_primals()) - scaling_suffix = pyomo_model.component('scaling_factor') - if scaling_suffix and scaling_suffix.ctype is pyo.Suffix: - need_scaling = True - for i, v in enumerate(self._pyomo_model_var_datas): - if v in scaling_suffix: - self._primals_scaling[i] = scaling_suffix[v] + scaling_finder = SuffixFinder( + 'scaling_factor', default=1.0, context=self._pyomo_model + ) + self._primals_scaling = np.fromiter( + (scaling_finder.find(v) for v in self._pyomo_model_var_datas), + count=self.n_primals(), + dtype=float, + ) + need_scaling = bool(scaling_finder.all_suffixes) self._constraints_scaling = BlockVector(len(nlps)) for i, nlp in enumerate(nlps): @@ -338,8 +365,8 @@ def get_duals(self): # overloaded from NLP def set_obj_factor(self, obj_factor): - # objective is owned by the pyomo model - self._pyomo_nlp.set_obj_factor(obj_factor) + for nlp in self._nlps: + nlp.set_obj_factor(obj_factor) # overloaded from NLP def get_obj_factor(self): @@ -360,12 +387,28 @@ def get_constraints_scaling(self): # overloaded from NLP def evaluate_objective(self): - # objective is owned by the pyomo model - return self._pyomo_nlp.evaluate_objective() + # There is a check in the constructor to ensure + # that there is only one objective. The rest of + # the nlps will return zero. + obj = 0 + for nlp in self._nlps: + obj += nlp.evaluate_objective() + return obj # overloaded from NLP def evaluate_grad_objective(self, out=None): - return self._pyomo_nlp.evaluate_grad_objective(out=out) + # There is a check in the constructor to ensure + # that there is only one objective. The rest of + # the nlps will return arrays of zero. + ret = np.zeros(self.n_primals(), dtype=float) + for nlp in self._nlps: + ret += nlp.evaluate_grad_objective() + + if out is not None: + ret.copyto(out) + return out + + return ret # overloaded from NLP def evaluate_constraints(self, out=None): @@ -473,29 +516,21 @@ class _ExternalGreyBoxAsNLP(NLP): """ This class takes an ExternalGreyBoxModel and makes it look like an NLP so it can be used with other interfaces. Currently, - the ExternalGreyBoxModel supports constraints only (no objective), - so some of the methods are not appropriate and raise exceptions + the ExternalGreyBoxModel supports objectives and equality + constraints only, so some of the methods are not appropriate + and raise exceptions """ def __init__(self, external_grey_box_block): self._block = external_grey_box_block self._ex_model = external_grey_box_block.get_external_model() + self._obj_factor = 1.0 n_inputs = len(self._block.inputs) assert n_inputs == self._ex_model.n_inputs() n_eq_constraints = self._ex_model.n_equality_constraints() n_outputs = len(self._block.outputs) assert n_outputs == self._ex_model.n_outputs() - if ( - self._ex_model.n_outputs() == 0 - and self._ex_model.n_equality_constraints() == 0 - ): - raise ValueError( - 'ExternalGreyBoxModel has no equality constraints ' - 'or outputs. To use _ExternalGreyBoxAsNLP, it must' - ' have at least one or both.' - ) - # create the list of primals and constraint names # primals will be ordered inputs, followed by outputs self._primals_names = [ @@ -581,12 +616,18 @@ def __init__(self, external_grey_box_block): self._ex_model, 'evaluate_hessian_outputs' ): self._has_hessian_support = False + if self._ex_model.has_objective() and not hasattr( + self._ex_model, 'evaluate_hessian_objective' + ): + self._has_hessian_support = False self._nnz_jacobian = None self._nnz_hessian_lag = None self._cached_constraint_residuals = None self._cached_jacobian = None self._cached_hessian = None + self._cached_objective = None + self._cached_grad_objective = None def n_primals(self): return len(self._primals_names) @@ -640,6 +681,8 @@ def _cache_invalidate_primals(self): self._cached_constraint_residuals = None self._cached_jacobian = None self._cached_hessian = None + self._cached_objective = None + self._cached_grad_objective = None def set_primals(self, primals): self._cache_invalidate_primals() @@ -670,13 +713,16 @@ def get_duals(self): return np.copy(self._dual_values) def set_obj_factor(self, obj_factor): - raise NotImplementedError('_ExternalGreyBoxAsNLP does not support objectives') + self._cached_hessian = None + self._obj_factor = obj_factor def get_obj_factor(self): - raise NotImplementedError('_ExternalGreyBoxAsNLP does not support objectives') + return self._obj_factor def get_obj_scaling(self): - raise NotImplementedError('_ExternalGreyBoxAsNLP does not support objectives') + raise NotImplementedError( + '_ExternalGreyBoxAsNLP does not support objective scaling' + ) def get_primals_scaling(self): raise NotImplementedError( @@ -703,13 +749,31 @@ def get_constraints_scaling(self): return scaling return None + def _evaluate_objective_if_necessary_and_cache(self): + if self._ex_model.has_objective(): + if self._cached_objective is None: + self._cached_objective = self._ex_model.evaluate_objective() + else: + self._cached_objective = 0 + def evaluate_objective(self): - # todo: Should we return 0 here? - raise NotImplementedError('_ExternalGreyBoxNLP does not support objectives') + self._evaluate_objective_if_necessary_and_cache() + return self._cached_objective + + def _evaluate_grad_objective_if_necessary_and_cache(self): + if self._ex_model.has_objective(): + if self._cached_grad_objective is None: + self._cached_grad_objective = self._ex_model.evaluate_grad_objective() + else: + self._cached_grad_objective = np.zeros(self.n_primals(), dtype=float) def evaluate_grad_objective(self, out=None): - # todo: Should we return 0 here? - raise NotImplementedError('_ExternalGreyBoxNLP does not support objectives') + self._evaluate_grad_objective_if_necessary_and_cache() + if out is not None: + assert len(out) == self.n_primals() + np.copyto(out, self._cached_grad_objective) + return out + return np.copy(self._cached_grad_objective) def _evaluate_constraints_if_necessary_and_cache(self): if self._cached_constraint_residuals is None: @@ -771,10 +835,37 @@ def _evaluate_hessian_if_necessary_and_cache(self): hess.set_col_size(0, self._ex_model.n_inputs()) hess.set_col_size(1, self._ex_model.n_outputs()) + n = self._ex_model.n_inputs() + expected_shape = (n, n) + + # get the hessian of the objective + if self._ex_model.has_objective(): + obj_hess = ( + self._ex_model.evaluate_hessian_objective() * self.get_obj_factor() + ) + if obj_hess.shape != expected_shape: + raise ValueError( + 'ExternalGreyBoxModel objective hessian shape' + 'should be (n_inputs, n_inputs)' + ) + # let's check that it is lower triangular + if np.any(obj_hess.row < obj_hess.col): + raise ValueError( + 'ExternalGreyBoxModel must return lower' + 'triangular portion of the Hessian only' + ) + obj_hess = make_lower_triangular_full(obj_hess) + else: + obj_hess = coo_matrix(([], ([], [])), shape=expected_shape) + # get the hessian w.r.t. the equality constraints - eq_hess = None if self._ex_model.n_equality_constraints() > 0: eq_hess = self._ex_model.evaluate_hessian_equality_constraints() + if eq_hess.shape != expected_shape: + raise ValueError( + 'ExternalGreyBoxModel equality constraint hessian shape' + 'should be (n_inputs, n_inputs)' + ) # let's check that it is lower triangular if np.any(eq_hess.row < eq_hess.col): raise ValueError( @@ -783,10 +874,16 @@ def _evaluate_hessian_if_necessary_and_cache(self): ) eq_hess = make_lower_triangular_full(eq_hess) + else: + eq_hess = coo_matrix(([], ([], [])), shape=expected_shape) - output_hess = None if self._ex_model.n_outputs() > 0: output_hess = self._ex_model.evaluate_hessian_outputs() + if output_hess.shape != expected_shape: + raise ValueError( + 'ExternalGreyBoxModel output hessian shape' + 'should be (n_inputs, n_inputs)' + ) # let's check that it is lower triangular if np.any(output_hess.row < output_hess.col): raise ValueError( @@ -795,21 +892,13 @@ def _evaluate_hessian_if_necessary_and_cache(self): ) output_hess = make_lower_triangular_full(output_hess) + else: + output_hess = coo_matrix(([], ([], [])), shape=expected_shape) - input_hess = None - if eq_hess is not None and output_hess is not None: - # we may want to make this more efficient - row = np.concatenate((eq_hess.row, output_hess.row)) - col = np.concatenate((eq_hess.col, output_hess.col)) - data = np.concatenate((eq_hess.data, output_hess.data)) - - assert eq_hess.shape == output_hess.shape - input_hess = coo_matrix((data, (row, col)), shape=eq_hess.shape) - elif eq_hess is not None: - input_hess = eq_hess - elif output_hess is not None: - input_hess = output_hess - assert input_hess is not None # need equality or outputs or both + row = np.concatenate((obj_hess.row, eq_hess.row, output_hess.row)) + col = np.concatenate((obj_hess.col, eq_hess.col, output_hess.col)) + data = np.concatenate((obj_hess.data, eq_hess.data, output_hess.data)) + input_hess = coo_matrix((data, (row, col)), shape=expected_shape) hess.set_block(0, 0, input_hess) self._cached_hessian = hess.tocoo() diff --git a/pyomo/contrib/pynumero/interfaces/pyomo_nlp.py b/pyomo/contrib/pynumero/interfaces/pyomo_nlp.py index 51edd09311a..78e8ca5faa5 100644 --- a/pyomo/contrib/pynumero/interfaces/pyomo_nlp.py +++ b/pyomo/contrib/pynumero/interfaces/pyomo_nlp.py @@ -1,6 +1,6 @@ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,9 +22,11 @@ import pyomo.core.base as pyo from pyomo.common.collections import ComponentMap from pyomo.common.env import CtypesEnviron +from pyomo.solvers.amplfunc_merge import amplfunc_merge from ..sparse.block_matrix import BlockMatrix from pyomo.contrib.pynumero.interfaces.ampl_nlp import AslNLP from pyomo.contrib.pynumero.interfaces.nlp import NLP +from pyomo.core.base.suffix import SuffixFinder from .external_grey_box import ExternalGreyBoxBlock @@ -92,15 +94,8 @@ def __init__(self, pyomo_model, nl_file_options=None): # The NL writer advertises the external function libraries # through the PYOMO_AMPLFUNC environment variable; merge it # with any preexisting AMPLFUNC definitions - amplfunc = "\n".join( - filter( - None, - ( - os.environ.get('AMPLFUNC', None), - os.environ.get('PYOMO_AMPLFUNC', None), - ), - ) - ) + amplfunc = amplfunc_merge(os.environ) + with CtypesEnviron(AMPLFUNC=amplfunc): super(PyomoNLP, self).__init__(nl_file) @@ -303,35 +298,41 @@ def get_inequality_constraint_indices(self, constraints): # overloaded from NLP def get_obj_scaling(self): - obj = self.get_pyomo_objective() - scaling_suffix = self._pyomo_model.component('scaling_factor') - if scaling_suffix and scaling_suffix.ctype is pyo.Suffix: - if obj in scaling_suffix: - return scaling_suffix[obj] - return 1.0 - return None + scaling_finder = SuffixFinder( + 'scaling_factor', default=1.0, context=self._pyomo_model + ) + val = scaling_finder.find(self.get_pyomo_objective()) + if not scaling_finder.all_suffixes: + return None + return val # overloaded from NLP def get_primals_scaling(self): - scaling_suffix = self._pyomo_model.component('scaling_factor') - if scaling_suffix and scaling_suffix.ctype is pyo.Suffix: - primals_scaling = np.ones(self.n_primals()) - for i, v in enumerate(self.get_pyomo_variables()): - if v in scaling_suffix: - primals_scaling[i] = scaling_suffix[v] - return primals_scaling - return None + scaling_finder = SuffixFinder( + 'scaling_factor', default=1.0, context=self._pyomo_model + ) + primals_scaling = np.fromiter( + (scaling_finder.find(v) for v in self.get_pyomo_variables()), + count=self.n_primals(), + dtype=float, + ) + if not scaling_finder.all_suffixes: + return None + return primals_scaling # overloaded from NLP def get_constraints_scaling(self): - scaling_suffix = self._pyomo_model.component('scaling_factor') - if scaling_suffix and scaling_suffix.ctype is pyo.Suffix: - constraints_scaling = np.ones(self.n_constraints()) - for i, c in enumerate(self.get_pyomo_constraints()): - if c in scaling_suffix: - constraints_scaling[i] = scaling_suffix[c] - return constraints_scaling - return None + scaling_finder = SuffixFinder( + 'scaling_factor', default=1.0, context=self._pyomo_model + ) + constraints_scaling = np.fromiter( + (scaling_finder.find(v) for v in self.get_pyomo_constraints()), + count=self.n_constraints(), + dtype=float, + ) + if not scaling_finder.all_suffixes: + return None + return constraints_scaling def extract_subvector_grad_objective(self, pyomo_variables): """Compute the gradient of the objective and return the entries @@ -612,13 +613,15 @@ def __init__(self, pyomo_model): else: need_scaling = True - self._primals_scaling = np.ones(self.n_primals()) - scaling_suffix = self._pyomo_nlp._pyomo_model.component('scaling_factor') - if scaling_suffix and scaling_suffix.ctype is pyo.Suffix: - need_scaling = True - for i, v in enumerate(self.get_pyomo_variables()): - if v in scaling_suffix: - self._primals_scaling[i] = scaling_suffix[v] + scaling_finder = SuffixFinder( + 'scaling_factor', default=1.0, context=self._pyomo_model + ) + self._primals_scaling = np.fromiter( + (scaling_finder.find(v) for v in self.get_pyomo_variables()), + count=self.n_primals(), + dtype=float, + ) + need_scaling = bool(scaling_finder.all_suffixes) self._constraints_scaling = [] pyomo_nlp_scaling = self._pyomo_nlp.get_constraints_scaling() diff --git a/pyomo/contrib/pynumero/interfaces/tests/__init__.py b/pyomo/contrib/pynumero/interfaces/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/__init__.py +++ b/pyomo/contrib/pynumero/interfaces/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/tests/compare_utils.py b/pyomo/contrib/pynumero/interfaces/tests/compare_utils.py index 8296ea2d1af..24947729f3a 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/compare_utils.py +++ b/pyomo/contrib/pynumero/interfaces/tests/compare_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/tests/external_grey_box_models.py b/pyomo/contrib/pynumero/interfaces/tests/external_grey_box_models.py index b81731b209e..8452efe3e9d 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/external_grey_box_models.py +++ b/pyomo/contrib/pynumero/interfaces/tests/external_grey_box_models.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py index bbcd6d4f26d..877af23d86a 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -96,13 +96,17 @@ def hessian(self, x, y, obj_factor): problem.solve(x0) -def _get_model_nlp_interface(halt_on_evaluation_error=None): +def _get_model_nlp_interface(halt_on_evaluation_error=None, intermediate_callback=None): m = pyo.ConcreteModel() m.x = pyo.Var([1, 2, 3], initialize=1.0) m.obj = pyo.Objective(expr=m.x[1] * pyo.sqrt(m.x[2]) + m.x[1] * m.x[3]) m.eq1 = pyo.Constraint(expr=m.x[1] * pyo.sqrt(m.x[2]) == 1.0) nlp = PyomoNLP(m) - interface = CyIpoptNLP(nlp, halt_on_evaluation_error=halt_on_evaluation_error) + interface = CyIpoptNLP( + nlp, + halt_on_evaluation_error=halt_on_evaluation_error, + intermediate_callback=intermediate_callback, + ) bad_primals = np.array([1.0, -2.0, 3.0]) indices = nlp.get_primal_indices([m.x[1], m.x[2], m.x[3]]) bad_primals = bad_primals[indices] @@ -219,6 +223,64 @@ def test_error_in_hessian_halt(self): with self.assertRaisesRegex(PyNumeroEvaluationError, msg): interface.hessian(bad_x, [1.0], 0.0) + def test_intermediate_12arg(self): + iterate_data = [] + + def intermediate( + nlp, + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + ): + self.assertIsInstance(nlp, PyomoNLP) + iterate_data.append((inf_pr, inf_du)) + + m, nlp, interface, bad_x = _get_model_nlp_interface( + intermediate_callback=intermediate + ) + # The interface's callback is always called with 11 arguments (by CyIpopt/Ipopt) + # but we add the NLP object to the arguments. + interface.intermediate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) + self.assertEqual(iterate_data, [(4, 5)]) + + def test_intermediate_13arg(self): + iterate_data = [] + + def intermediate( + nlp, + problem, + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + ): + self.assertIsInstance(nlp, PyomoNLP) + self.assertIsInstance(problem, cyipopt.Problem) + iterate_data.append((inf_pr, inf_du)) + + m, nlp, interface, bad_x = _get_model_nlp_interface( + intermediate_callback=intermediate + ) + # The interface's callback is always called with 11 arguments (by CyIpopt/Ipopt) + # but we add the NLP object *and the cyipopt.Problem object* to the arguments. + interface.intermediate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) + self.assertEqual(iterate_data, [(4, 5)]) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_dynamic_model.py b/pyomo/contrib/pynumero/interfaces/tests/test_dynamic_model.py index 5b8a8d688dd..9946e583c02 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_dynamic_model.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_dynamic_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_external_asl_function.py b/pyomo/contrib/pynumero/interfaces/tests/test_external_asl_function.py index 9ca0aef4187..07554a620e0 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_external_asl_function.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_external_asl_function.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_external_grey_box_model.py b/pyomo/contrib/pynumero/interfaces/tests/test_external_grey_box_model.py index 0fc342c4e40..8fb12022a9b 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_external_grey_box_model.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_external_grey_box_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,9 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import os import pyomo.common.unittest as unittest import pyomo.environ as pyo +from pyomo.common.tempfiles import TempfileManager from pyomo.contrib.pynumero.dependencies import ( numpy as np, @@ -31,8 +31,11 @@ from pyomo.contrib.pynumero.algorithms.solvers.cyipopt_solver import cyipopt_available -from ..external_grey_box import ExternalGreyBoxModel, ExternalGreyBoxBlock -from ..pyomo_nlp import PyomoGreyBoxNLP +from pyomo.contrib.pynumero.interfaces.external_grey_box import ( + ExternalGreyBoxModel, + ExternalGreyBoxBlock, +) +from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoGreyBoxNLP from pyomo.contrib.pynumero.interfaces.tests.compare_utils import ( check_vectors_specific_order, check_sparse_matrix_specific_order, @@ -2074,24 +2077,23 @@ def test_external_greybox_solve_scaling(self): m.scaling_factor[m.mu] = 1.9 m.scaling_factor[m.pincon] = 2.2 - solver = pyo.SolverFactory('cyipopt') - solver.config.options = { - 'hessian_approximation': 'limited-memory', - 'nlp_scaling_method': 'user-scaling', - 'output_file': '_cyipopt-external-greybox-scaling.log', - 'file_print_level': 10, - 'max_iter': 0, - } - status = solver.solve(m, tee=False) - - with open('_cyipopt-external-greybox-scaling.log', 'r') as fd: - solver_trace = fd.read() - os.remove('_cyipopt-external-greybox-scaling.log') + with TempfileManager.new_context() as temp: + logfile = temp.create_tempfile('_cyipopt-external-greybox-scaling.log') + solver = pyo.SolverFactory('cyipopt') + solver.config.options = { + 'hessian_approximation': 'limited-memory', + 'nlp_scaling_method': 'user-scaling', + 'output_file': logfile, + 'file_print_level': 10, + 'max_iter': 0, + } + status = solver.solve(m, tee=False) + + with open(logfile, 'r') as fd: + solver_trace = fd.read() self.assertIn('nlp_scaling_method = user-scaling', solver_trace) - self.assertIn( - 'output_file = _cyipopt-external-greybox-scaling.log', solver_trace - ) + self.assertIn(f'output_file = {logfile}', solver_trace) self.assertIn('objective scaling factor = 0.1', solver_trace) self.assertIn('x scaling provided', solver_trace) self.assertIn('c scaling provided', solver_trace) @@ -2149,4 +2151,4 @@ def test_external_greybox_solve_scaling(self): if __name__ == '__main__': - TestPyomoGreyBoxNLP().test_external_greybox_solve(self) + TestPyomoGreyBoxNLP().test_external_greybox_solve() diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_external_pyomo_block.py b/pyomo/contrib/pynumero/interfaces/tests/test_external_pyomo_block.py index 913d3055c9c..132abb22dfd 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_external_pyomo_block.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_external_pyomo_block.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,10 +15,10 @@ from pyomo.core.expr.visitor import identify_variables import pyomo.environ as pyo +from pyomo.common.dependencies import networkx_available as nx_available from pyomo.contrib.pynumero.dependencies import ( numpy as np, numpy_available, - scipy, scipy_available, ) @@ -151,6 +151,7 @@ def flow_out_eqn(m, t): class TestExternalGreyBoxBlock(unittest.TestCase): + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_construct_scalar(self): m = pyo.ConcreteModel() m.ex_block = ExternalGreyBoxBlock(concrete=True) @@ -171,6 +172,7 @@ def test_construct_scalar(self): self.assertEqual(len(block.outputs), 0) self.assertEqual(len(block._equality_constraint_names), 2) + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_construct_indexed(self): block = ExternalGreyBoxBlock([0, 1, 2], concrete=True) self.assertIs(type(block), IndexedExternalGreyBoxBlock) @@ -192,6 +194,7 @@ def test_construct_indexed(self): self.assertEqual(len(b._equality_constraint_names), 2) @unittest.skipUnless(cyipopt_available, "cyipopt is not available") + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_solve_square(self): m = pyo.ConcreteModel() m.ex_block = ExternalGreyBoxBlock(concrete=True) @@ -234,6 +237,7 @@ def test_solve_square(self): self.assertAlmostEqual(m_ex.y.value, y.value, delta=1e-8) @unittest.skipUnless(cyipopt_available, "cyipopt is not available") + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_optimize(self): m = pyo.ConcreteModel() m.ex_block = ExternalGreyBoxBlock(concrete=True) @@ -292,6 +296,7 @@ def test_optimize(self): self.assertAlmostEqual(m_ex.y.value, y.value, delta=1e-8) @unittest.skipUnless(cyipopt_available, "cyipopt is not available") + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_optimize_with_cyipopt_for_inner_problem(self): # Use CyIpopt, rather than the default SciPy solvers, # for the inner problem @@ -427,6 +432,7 @@ def test_optimize_no_decomposition(self): self.assertAlmostEqual(m_ex.x.value, x.value, delta=1e-8) self.assertAlmostEqual(m_ex.y.value, y.value, delta=1e-8) + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_construct_dynamic(self): m = make_dynamic_model() time = m.time @@ -504,6 +510,7 @@ def test_construct_dynamic(self): ) @unittest.skipUnless(cyipopt_available, "cyipopt is not available") + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_solve_square_dynamic(self): # Create the "external model" m = make_dynamic_model() @@ -571,6 +578,7 @@ def linking_constraint_rule(m, i, t): self.assertStructuredAlmostEqual(values, target_values, delta=1e-5) @unittest.skipUnless(cyipopt_available, "cyipopt is not available") + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_optimize_dynamic(self): # Create the "external model" m = make_dynamic_model() @@ -653,6 +661,7 @@ def linking_constraint_rule(m, i, t): self.assertStructuredAlmostEqual(values, target_values, delta=1e-5) @unittest.skipUnless(cyipopt_available, "cyipopt is not available") + @unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") def test_optimize_dynamic_references(self): """ When when pre-existing variables are attached to the EGBB @@ -717,7 +726,8 @@ def test_optimize_dynamic_references(self): self.assertStructuredAlmostEqual(values, target_values, delta=1e-5) -class TestPyomoNLPWithGreyBoxBLocks(unittest.TestCase): +@unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") +class TestPyomoNLPWithGreyBoxBlocks(unittest.TestCase): def test_set_and_evaluate(self): m = pyo.ConcreteModel() m.ex_block = ExternalGreyBoxBlock(concrete=True) diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_external_pyomo_model.py b/pyomo/contrib/pynumero/interfaces/tests/test_external_pyomo_model.py index 9773fa7e4a8..f446951c643 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_external_pyomo_model.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_external_pyomo_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,6 +13,7 @@ import pyomo.common.unittest as unittest import pyomo.environ as pyo +from pyomo.common.dependencies import networkx_available as nx_available from pyomo.contrib.pynumero.dependencies import ( numpy as np, numpy_available, @@ -513,6 +514,7 @@ def test_explicit_zeros(self): np.testing.assert_allclose(hess.data, data, rtol=1e-8) +@unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") class TestExternalPyomoModel(unittest.TestCase): def test_evaluate_SimpleModel1(self): model = SimpleModel1() @@ -838,6 +840,7 @@ def test_evaluate_hessian_lagrangian_SimpleModel2x2_1(self): np.testing.assert_allclose(hess_lag, expected_hess_lag, rtol=1e-8) +@unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") class TestUpdatedHessianCalculationMethods(unittest.TestCase): """ These tests exercise the methods for fast Hessian-of-Lagrangian @@ -1021,6 +1024,7 @@ def test_evaluate_hessian_equality_constraints_order(self): ) +@unittest.skipUnless(nx_available, "SCCImplicitFunctionSolver requires networkx") class TestScaling(unittest.TestCase): def con_3_body(self, x, y, u, v): return 1e5 * x**2 + 1e4 * y**2 + 1e1 * u**2 + 1e0 * v**2 diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_nlp.py b/pyomo/contrib/pynumero/interfaces/tests/test_nlp.py index 4f735e06de7..7ab533b92c3 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_nlp.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_nlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -699,6 +699,42 @@ def test_indices_methods(self): dense_hess = hess.todense() self.assertTrue(np.array_equal(dense_hess, expected_hess)) + def test_subblock_scaling(self): + m = pyo.ConcreteModel() + m.b = b = pyo.Block() + b.x = pyo.Var(bounds=(5e-17, 5e-16), initialize=1e-16) + b.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) + b.scaling_factor[b.x] = 1e16 + + b.c = pyo.Constraint(rule=b.x == 1e-16) + b.scaling_factor[b.c] = 1e16 + + b.o = pyo.Objective(expr=b.x) + b.scaling_factor[b.o] = 1e16 + + nlp = PyomoNLP(m) + + assert nlp.get_obj_scaling() == 1e16 + assert nlp.get_primals_scaling()[0] == 1e16 + assert nlp.get_constraints_scaling()[0] == 1e16 + + def test_subblock_no_scaling(self): + m = pyo.ConcreteModel() + m.b = pyo.Block() + m.b.x = pyo.Var([1, 2], initialize={1: 100, 2: 20}) + + # Components so we don't have an empty NLP + m.b.eq = pyo.Constraint(expr=m.b.x[1] * m.b.x[2] == 2000) + m.b.obj = pyo.Objective(expr=m.b.x[1] ** 2 + m.b.x[2] ** 2) + + m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) + m.scaling_factor[m.b.x[1]] = 1e-2 + m.scaling_factor[m.b.x[2]] = 1e-1 + + nlp = PyomoNLP(m.b) + scaling = nlp.get_primals_scaling() + assert scaling is None + def test_no_objective(self): m = pyo.ConcreteModel() m.x = pyo.Var() diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_nlp_projections.py b/pyomo/contrib/pynumero/interfaces/tests/test_nlp_projections.py index 2fada5f679a..3fd107dd09d 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_nlp_projections.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_nlp_projections.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_pyomo_grey_box_nlp.py b/pyomo/contrib/pynumero/interfaces/tests/test_pyomo_grey_box_nlp.py index ecadf40e5cf..e45b1b75301 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_pyomo_grey_box_nlp.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_pyomo_grey_box_nlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,9 +9,9 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import os import pyomo.common.unittest as unittest import pyomo.environ as pyo +from pyomo.common.tempfiles import TempfileManager from pyomo.contrib.pynumero.dependencies import ( numpy as np, @@ -41,6 +41,11 @@ check_sparse_matrix_specific_order, ) import pyomo.contrib.pynumero.interfaces.tests.external_grey_box_models as ex_models +from pyomo.contrib.pynumero.examples.external_grey_box.external_with_objective import ( + solve_unconstrained, + solve_constrained, + solve_constrained_with_hessian, +) class TestExternalGreyBoxAsNLP(unittest.TestCase): @@ -135,17 +140,6 @@ def _test_pressure_drop_single_output(self, ex_model, hessian_support): y = egb_nlp.get_duals() self.assertTrue(np.array_equal(y, np.asarray([21], dtype=np.float64))) - with self.assertRaises(NotImplementedError): - fac = egb_nlp.get_obj_factor() - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(42) - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(1) - with self.assertRaises(NotImplementedError): - f = egb_nlp.evaluate_objective() - with self.assertRaises(NotImplementedError): - gradf = egb_nlp.evaluate_grad_objective() - c = egb_nlp.evaluate_constraints() comparison_c = np.asarray([-22], dtype=np.float64) check_vectors_specific_order(self, c, c_order, comparison_c, comparison_c_order) @@ -294,17 +288,6 @@ def _test_pressure_drop_single_equality(self, ex_model, hessian_support): y = egb_nlp.get_duals() self.assertTrue(np.array_equal(y, np.asarray([21], dtype=np.float64))) - with self.assertRaises(NotImplementedError): - fac = egb_nlp.get_obj_factor() - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(42) - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(1) - with self.assertRaises(NotImplementedError): - f = egb_nlp.evaluate_objective() - with self.assertRaises(NotImplementedError): - gradf = egb_nlp.evaluate_grad_objective() - c = egb_nlp.evaluate_constraints() comparison_c = np.asarray([22], dtype=np.float64) check_vectors_specific_order(self, c, c_order, comparison_c, comparison_c_order) @@ -459,17 +442,6 @@ def _test_pressure_drop_two_outputs(self, ex_model, hessian_support): y = egb_nlp.get_duals() self.assertTrue(np.array_equal(y, np.asarray([21, 5], dtype=np.float64))) - with self.assertRaises(NotImplementedError): - fac = egb_nlp.get_obj_factor() - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(42) - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(1) - with self.assertRaises(NotImplementedError): - f = egb_nlp.evaluate_objective() - with self.assertRaises(NotImplementedError): - gradf = egb_nlp.evaluate_grad_objective() - c = egb_nlp.evaluate_constraints() comparison_c = np.asarray([-16, -22], dtype=np.float64) check_vectors_specific_order(self, c, c_order, comparison_c, comparison_c_order) @@ -630,17 +602,6 @@ def _test_pressure_drop_two_equalities(self, ex_model, hessian_support): y = egb_nlp.get_duals() self.assertTrue(np.array_equal(y, np.asarray([21, 5], dtype=np.float64))) - with self.assertRaises(NotImplementedError): - fac = egb_nlp.get_obj_factor() - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(42) - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(1) - with self.assertRaises(NotImplementedError): - f = egb_nlp.evaluate_objective() - with self.assertRaises(NotImplementedError): - gradf = egb_nlp.evaluate_grad_objective() - c = egb_nlp.evaluate_constraints() comparison_c = np.asarray([16, 6], dtype=np.float64) check_vectors_specific_order(self, c, c_order, comparison_c, comparison_c_order) @@ -813,17 +774,6 @@ def _test_pressure_drop_two_equalities_two_outputs(self, ex_model, hessian_suppo y = egb_nlp.get_duals() self.assertTrue(np.array_equal(y, np.asarray([21, 5, 6, 7], dtype=np.float64))) - with self.assertRaises(NotImplementedError): - fac = egb_nlp.get_obj_factor() - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(42) - with self.assertRaises(NotImplementedError): - egb_nlp.set_obj_factor(1) - with self.assertRaises(NotImplementedError): - f = egb_nlp.evaluate_objective() - with self.assertRaises(NotImplementedError): - gradf = egb_nlp.evaluate_grad_objective() - c = egb_nlp.evaluate_constraints() comparison_c = np.asarray([-2, 26, -13, -22], dtype=np.float64) check_vectors_specific_order(self, c, c_order, comparison_c, comparison_c_order) @@ -2495,24 +2445,23 @@ def test_external_greybox_solve_scaling(self): m.scaling_factor[m.mu] = 1.9 m.scaling_factor[m.pincon] = 2.2 - solver = pyo.SolverFactory('cyipopt') - solver.config.options = { - 'hessian_approximation': 'limited-memory', - 'nlp_scaling_method': 'user-scaling', - 'output_file': '_cyipopt-external-greybox-scaling.log', - 'file_print_level': 10, - 'max_iter': 0, - } - status = solver.solve(m, tee=False) - - with open('_cyipopt-external-greybox-scaling.log', 'r') as fd: - solver_trace = fd.read() - os.remove('_cyipopt-external-greybox-scaling.log') + with TempfileManager.new_context() as temp: + logfile = temp.create_tempfile('_cyipopt-external-greybox-scaling.log') + solver = pyo.SolverFactory('cyipopt') + solver.config.options = { + 'hessian_approximation': 'limited-memory', + 'nlp_scaling_method': 'user-scaling', + 'output_file': logfile, + 'file_print_level': 10, + 'max_iter': 0, + } + status = solver.solve(m, tee=False) + + with open(logfile, 'r') as fd: + solver_trace = fd.read() self.assertIn('nlp_scaling_method = user-scaling', solver_trace) - self.assertIn( - 'output_file = _cyipopt-external-greybox-scaling.log', solver_trace - ) + self.assertIn(f'output_file = {logfile}', solver_trace) self.assertIn('objective scaling factor = 0.1', solver_trace) self.assertIn('x scaling provided', solver_trace) self.assertIn('c scaling provided', solver_trace) @@ -2640,5 +2589,19 @@ def test_duals_after_solve(self): self.assertAlmostEqual(m.dual[m.egb]['egb.u2_con'], 62.5, places=3) +class TestGreyBoxObjectives(unittest.TestCase): + @unittest.skipIf(not cyipopt_available, "CyIpopt needed to run tests with solve") + def test_unconstrained(self): + solve_unconstrained() + + @unittest.skipIf(not cyipopt_available, "CyIpopt needed to run tests with solve") + def test_constrained(self): + solve_constrained() + + @unittest.skipIf(not cyipopt_available, "CyIpopt needed to run tests with solve") + def test_constrained_with_hessian(self): + solve_constrained_with_hessian() + + if __name__ == '__main__': unittest.main() diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_utils.py b/pyomo/contrib/pynumero/interfaces/tests/test_utils.py index 474d26836b9..6106d88ce96 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_utils.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/interfaces/utils.py b/pyomo/contrib/pynumero/interfaces/utils.py index 2aa30fc5946..01f495eba09 100644 --- a/pyomo/contrib/pynumero/interfaces/utils.py +++ b/pyomo/contrib/pynumero/interfaces/utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/intrinsic.py b/pyomo/contrib/pynumero/intrinsic.py index 84675cc4c02..e3237378750 100644 --- a/pyomo/contrib/pynumero/intrinsic.py +++ b/pyomo/contrib/pynumero/intrinsic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,9 +11,7 @@ from pyomo.common.dependencies import numpy as np, attempt_import -block_vector = attempt_import( - 'pyomo.contrib.pynumero.sparse.block_vector', defer_check=True -)[0] +block_vector = attempt_import('pyomo.contrib.pynumero.sparse.block_vector')[0] def norm(x, ord=None): diff --git a/pyomo/contrib/pynumero/linalg/__init__.py b/pyomo/contrib/pynumero/linalg/__init__.py index c1d9ff38825..2f23be497e4 100644 --- a/pyomo/contrib/pynumero/linalg/__init__.py +++ b/pyomo/contrib/pynumero/linalg/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/base.py b/pyomo/contrib/pynumero/linalg/base.py index 21565b052a5..748a8b86114 100644 --- a/pyomo/contrib/pynumero/linalg/base.py +++ b/pyomo/contrib/pynumero/linalg/base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/ma27.py b/pyomo/contrib/pynumero/linalg/ma27.py index 40a7d0e1064..7d8cfc00fe3 100644 --- a/pyomo/contrib/pynumero/linalg/ma27.py +++ b/pyomo/contrib/pynumero/linalg/ma27.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/ma27_interface.py b/pyomo/contrib/pynumero/linalg/ma27_interface.py index 42ac6e73154..5e6b0b044bd 100644 --- a/pyomo/contrib/pynumero/linalg/ma27_interface.py +++ b/pyomo/contrib/pynumero/linalg/ma27_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/ma57.py b/pyomo/contrib/pynumero/linalg/ma57.py index baaa3f34100..6f07a8fea90 100644 --- a/pyomo/contrib/pynumero/linalg/ma57.py +++ b/pyomo/contrib/pynumero/linalg/ma57.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/ma57_interface.py b/pyomo/contrib/pynumero/linalg/ma57_interface.py index 93004406612..272df1734ee 100644 --- a/pyomo/contrib/pynumero/linalg/ma57_interface.py +++ b/pyomo/contrib/pynumero/linalg/ma57_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/mumps_interface.py b/pyomo/contrib/pynumero/linalg/mumps_interface.py index 8735994f16c..956e4197e40 100644 --- a/pyomo/contrib/pynumero/linalg/mumps_interface.py +++ b/pyomo/contrib/pynumero/linalg/mumps_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/scipy_interface.py b/pyomo/contrib/pynumero/linalg/scipy_interface.py index 025cc539245..f84a6e1341d 100644 --- a/pyomo/contrib/pynumero/linalg/scipy_interface.py +++ b/pyomo/contrib/pynumero/linalg/scipy_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/tests/__init__.py b/pyomo/contrib/pynumero/linalg/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/linalg/tests/__init__.py +++ b/pyomo/contrib/pynumero/linalg/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/tests/test_linear_solvers.py b/pyomo/contrib/pynumero/linalg/tests/test_linear_solvers.py index d2fa955434c..135aba78105 100644 --- a/pyomo/contrib/pynumero/linalg/tests/test_linear_solvers.py +++ b/pyomo/contrib/pynumero/linalg/tests/test_linear_solvers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/tests/test_ma27.py b/pyomo/contrib/pynumero/linalg/tests/test_ma27.py index 979be6f747a..6d398f31716 100644 --- a/pyomo/contrib/pynumero/linalg/tests/test_ma27.py +++ b/pyomo/contrib/pynumero/linalg/tests/test_ma27.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/tests/test_ma57.py b/pyomo/contrib/pynumero/linalg/tests/test_ma57.py index de245172f96..8034e94bdad 100644 --- a/pyomo/contrib/pynumero/linalg/tests/test_ma57.py +++ b/pyomo/contrib/pynumero/linalg/tests/test_ma57.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/tests/test_mumps_interface.py b/pyomo/contrib/pynumero/linalg/tests/test_mumps_interface.py index 8e5b924fb65..c9d43935a76 100644 --- a/pyomo/contrib/pynumero/linalg/tests/test_mumps_interface.py +++ b/pyomo/contrib/pynumero/linalg/tests/test_mumps_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/linalg/utils.py b/pyomo/contrib/pynumero/linalg/utils.py index adec9ae5f35..ed18a47f21f 100644 --- a/pyomo/contrib/pynumero/linalg/utils.py +++ b/pyomo/contrib/pynumero/linalg/utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/plugins.py b/pyomo/contrib/pynumero/plugins.py index c6890cbbb4d..6969c6f75ad 100644 --- a/pyomo/contrib/pynumero/plugins.py +++ b/pyomo/contrib/pynumero/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/sparse/__init__.py b/pyomo/contrib/pynumero/sparse/__init__.py index ee8196566db..0fc043de470 100644 --- a/pyomo/contrib/pynumero/sparse/__init__.py +++ b/pyomo/contrib/pynumero/sparse/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/sparse/base_block.py b/pyomo/contrib/pynumero/sparse/base_block.py index 0b923ce6efb..293ca34c6f0 100644 --- a/pyomo/contrib/pynumero/sparse/base_block.py +++ b/pyomo/contrib/pynumero/sparse/base_block.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,6 +11,8 @@ # These classes are for checking types consistently and raising errors +from ..dependencies import numpy as np + class BaseBlockVector(object): """Base class for block vectors""" @@ -177,3 +179,128 @@ def transpose(self, *axes): def tostring(self, order='C'): msg = "tostring not implemented for {}".format(self.__class__.__name__) raise NotImplementedError(msg) + + +#: NumPy ufuncs that take one vector and are compatible with pyNumero vectors +vec_unary_ufuncs = { + ## MATH ufuncs + np.negative, + np.positive, + np.absolute, + np.fabs, + np.rint, + np.sign, + np.conj, + np.conjugate, + np.exp, + np.exp2, + np.log, + np.log2, + np.log10, + np.expm1, + np.log1p, + np.sqrt, + np.square, + np.cbrt, + np.reciprocal, + ## TRIG ufuncs + np.sin, + np.cos, + np.tan, + np.arcsin, + np.arccos, + np.arctan, + np.sinh, + np.cosh, + np.tanh, + np.arcsinh, + np.arccosh, + np.arctanh, + np.degrees, + np.radians, + np.deg2rad, + np.rad2deg, + ## COMPARISON ufuncs + np.logical_not, + ## BIT-TWIDDLING ufuncs + np.invert, + ## FLOATING ufuncs + np.isfinite, + np.isinf, + np.isnan, + # np.isnat, # only defined for datetime + np.fabs, # numpy docs list here and in MATH + np.signbit, + np.spacing, + # np.modf, # disabled because shape is not preserved + # np.frexp, # disabled because shape is not preserved + np.floor, + np.ceil, + np.trunc, + # OTHER (not listed in ufuncs docs) + np.abs, +} + +#: NumPy ufuncs that take two vectors and are compatible with pyNumero vectors +vec_binary_ufuncs = { + ## MATH ufuncs + np.add, + np.subtract, + np.multiply, + # np.matmult, # disabled because shape is not preserved + np.divide, + np.logaddexp, + np.logaddexp2, + np.true_divide, + np.floor_divide, + np.power, + np.float_power, + np.remainder, + np.mod, + np.fmod, + # np.divmod, # disabled because shape is not preserved + np.heaviside, + np.gcd, + np.lcm, + ## TRIG ufuncs + np.arctan2, + np.hypot, + ## BIT-TWIDDLING ufuncs + np.bitwise_and, + np.bitwise_or, + np.bitwise_xor, + np.left_shift, + np.right_shift, + ## COMPARISON ufuncs + np.greater, + np.greater_equal, + np.less, + np.less_equal, + np.not_equal, + np.equal, + np.logical_and, + np.logical_or, + np.logical_xor, + np.maximum, + np.minimum, + np.fmax, + np.fmin, + ## FLOATING ufincs + np.copysign, + np.nextafter, + np.ldexp, + np.fmod, # numpy docs list here and in MATH +} + +#: NumPy ufuncs can be used as reductions for pyNumero vectors +vec_associative_reductions = { + np.add, + np.multiply, + np.bitwise_and, + np.bitwise_or, + np.bitwise_xor, + np.maximum, + np.minimum, + np.fmax, + np.fmin, +} diff --git a/pyomo/contrib/pynumero/sparse/block_matrix.py b/pyomo/contrib/pynumero/sparse/block_matrix.py index 02ad584928b..69826e21b27 100644 --- a/pyomo/contrib/pynumero/sparse/block_matrix.py +++ b/pyomo/contrib/pynumero/sparse/block_matrix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/sparse/block_vector.py b/pyomo/contrib/pynumero/sparse/block_vector.py index b636dd74203..808c222d3b5 100644 --- a/pyomo/contrib/pynumero/sparse/block_vector.py +++ b/pyomo/contrib/pynumero/sparse/block_vector.py @@ -1,31 +1,194 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -""" -The pyomo.contrib.pynumero.sparse.block_vector module includes methods that extend -linear algebra operations in numpy for case of structured problems -where linear algebra operations present an inherent block structure. -This interface consider vectors of the form: +"""Implementation of a general "block vector" + + +The `pyomo.contrib.pynumero.sparse.block_vector` module includes methods +that extend linear algebra operations in numpy for case of structured +problems where linear algebra operations present an inherent block +structure. This interface consider vectors of the form: + +.. math:: -v = [v_1, v_2, v_3, ... , v_n] + v = [v_1, v_2, v_3, ... , v_n] -where v_i are numpy arrays of dimension 1 +where `v_i` are numpy arrays of dimension 1 .. rubric:: Contents +Methods specific to :py:class:`BlockVector`: + + * :py:meth:`~BlockVector.set_block` + * :py:meth:`~BlockVector.get_block` + * :py:meth:`~BlockVector.block_sizes` + * :py:meth:`~BlockVector.get_block_size` + * :py:meth:`~BlockVector.is_block_defined` + * :py:meth:`~BlockVector.copyfrom` + * :py:meth:`~BlockVector.copyto` + * :py:meth:`~BlockVector.copy_structure` + * :py:meth:`~BlockVector.set_blocks` + * :py:meth:`~BlockVector.pprint` + +Attributes specific to :py:class:`BlockVector`: + + * :py:attr:`~BlockVector.nblocks` + * :py:attr:`~BlockVector.bshape` + * :py:attr:`~BlockVector.has_none` + + +NumPy compatible methods: + + * :py:meth:`~numpy.ndarray.dot` + * :py:meth:`~numpy.ndarray.sum` + * :py:meth:`~numpy.ndarray.all` + * :py:meth:`~numpy.ndarray.any` + * :py:meth:`~numpy.ndarray.max` + * :py:meth:`~numpy.ndarray.astype` + * :py:meth:`~numpy.ndarray.clip` + * :py:meth:`~numpy.ndarray.compress` + * :py:meth:`~numpy.ndarray.conj` + * :py:meth:`~numpy.ndarray.conjugate` + * :py:meth:`~numpy.ndarray.nonzero` + * :py:meth:`~numpy.ndarray.ptp` (NumPy 1.x only) + * :py:meth:`~numpy.ndarray.round` + * :py:meth:`~numpy.ndarray.std` + * :py:meth:`~numpy.ndarray.var` + * :py:meth:`~numpy.ndarray.tofile` + * :py:meth:`~numpy.ndarray.min` + * :py:meth:`~numpy.ndarray.mean` + * :py:meth:`~numpy.ndarray.prod` + * :py:meth:`~numpy.ndarray.fill` + * :py:meth:`~numpy.ndarray.tolist` + * :py:meth:`~numpy.ndarray.flatten` + * :py:meth:`~numpy.ndarray.ravel` + * :py:meth:`~numpy.ndarray.argmax` + * :py:meth:`~numpy.ndarray.argmin` + * :py:meth:`~numpy.ndarray.cumprod` + * :py:meth:`~numpy.ndarray.cumsum` + * :py:meth:`~numpy.ndarray.copy` + +For example, + +.. code-block:: python + + >>> import numpy as np + >>> from pyomo.contrib.pynumero.sparse import BlockVector + >>> v = BlockVector(2) + >>> v.set_block(0, np.random.normal(size=100)) + >>> v.set_block(1, np.random.normal(size=30)) + >>> avg = v.mean() + +NumPy compatible functions: + + * :py:func:`~numpy.log10` + * :py:func:`~numpy.sin` + * :py:func:`~numpy.cos` + * :py:func:`~numpy.exp` + * :py:func:`~numpy.ceil` + * :py:func:`~numpy.floor` + * :py:func:`~numpy.tan` + * :py:func:`~numpy.arctan` + * :py:func:`~numpy.arcsin` + * :py:func:`~numpy.arccos` + * :py:func:`~numpy.sinh` + * :py:func:`~numpy.cosh` + * :py:func:`~numpy.abs` + * :py:func:`~numpy.tanh` + * :py:func:`~numpy.arccosh` + * :py:func:`~numpy.arcsinh` + * :py:func:`~numpy.arctanh` + * :py:func:`~numpy.fabs` + * :py:func:`~numpy.sqrt` + * :py:func:`~numpy.log` + * :py:func:`~numpy.log2` + * :py:func:`~numpy.absolute` + * :py:func:`~numpy.isfinite` + * :py:func:`~numpy.isinf` + * :py:func:`~numpy.isnan` + * :py:func:`~numpy.log1p` + * :py:func:`~numpy.logical_not` + * :py:func:`~numpy.expm1` + * :py:func:`~numpy.exp2` + * :py:func:`~numpy.sign` + * :py:func:`~numpy.rint` + * :py:func:`~numpy.square` + * :py:func:`~numpy.positive` + * :py:func:`~numpy.negative` + * :py:func:`~numpy.rad2deg` + * :py:func:`~numpy.deg2rad` + * :py:func:`~numpy.conjugate` + * :py:func:`~numpy.reciprocal` + * :py:func:`~numpy.signbit` + * :py:func:`~numpy.add` + * :py:func:`~numpy.multiply` + * :py:func:`~numpy.divide` + * :py:func:`~numpy.subtract` + * :py:func:`~numpy.greater` + * :py:func:`~numpy.greater_equal` + * :py:func:`~numpy.less` + * :py:func:`~numpy.less_equal` + * :py:func:`~numpy.not_equal` + * :py:func:`~numpy.maximum` + * :py:func:`~numpy.minimum` + * :py:func:`~numpy.fmax` + * :py:func:`~numpy.fmin` + * :py:func:`~numpy.equal` + * :py:func:`~numpy.logical_and` + * :py:func:`~numpy.logical_or` + * :py:func:`~numpy.logical_xor` + * :py:func:`~numpy.logaddexp` + * :py:func:`~numpy.logaddexp2` + * :py:func:`~numpy.remainder` + * :py:func:`~numpy.heaviside` + * :py:func:`~numpy.hypot` + +For example, + +.. code-block:: python + + >>> import numpy as np + >>> from pyomo.contrib.pynumero.sparse import BlockVector + >>> v = BlockVector(2) + >>> v.set_block(0, np.random.normal(size=100)) + >>> v.set_block(1, np.random.normal(size=30)) + >>> inf_norm = np.max(np.abs(v)) + +.. autosummary:: + + BlockVector + BlockVector.set_block + BlockVector.get_block + BlockVector.block_sizes + BlockVector.get_block_size + BlockVector.is_block_defined + BlockVector.copyfrom + BlockVector.copyto + BlockVector.copy_structure + BlockVector.set_blocks + BlockVector.pprint + BlockVector.nblocks + BlockVector.bshape + BlockVector.has_none + """ import operator from ..dependencies import numpy as np -from .base_block import BaseBlockVector +from .base_block import ( + BaseBlockVector, + vec_unary_ufuncs, + vec_binary_ufuncs, + vec_associative_reductions, +) class NotFullyDefinedBlockVectorError(Exception): @@ -38,7 +201,7 @@ def assert_block_structure(vec): raise NotFullyDefinedBlockVectorError(msg) -class BlockVector(np.ndarray, BaseBlockVector): +class BlockVector(BaseBlockVector, np.ndarray): """ Structured vector interface. This interface can be used to perform operations on vectors composed by vectors. For example, @@ -107,115 +270,49 @@ def __array_wrap__(self, out_arr, context=None): return super(BlockVector, self).__array_wrap__(self, out_arr, context) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): - """Runs ufuncs speciallizations to BlockVector""" - # functions that take one vector - unary_funcs = [ - np.log10, - np.sin, - np.cos, - np.exp, - np.ceil, - np.floor, - np.tan, - np.arctan, - np.arcsin, - np.arccos, - np.sinh, - np.cosh, - np.abs, - np.tanh, - np.arccosh, - np.arcsinh, - np.arctanh, - np.fabs, - np.sqrt, - np.log, - np.log2, - np.absolute, - np.isfinite, - np.isinf, - np.isnan, - np.log1p, - np.logical_not, - np.expm1, - np.exp2, - np.sign, - np.rint, - np.square, - np.positive, - np.negative, - np.rad2deg, - np.deg2rad, - np.conjugate, - np.reciprocal, - np.signbit, - ] - - # functions that take two vectors - binary_funcs = [ - np.add, - np.multiply, - np.divide, - np.subtract, - np.greater, - np.greater_equal, - np.less, - np.less_equal, - np.not_equal, - np.maximum, - np.minimum, - np.fmax, - np.fmin, - np.equal, - np.logical_and, - np.logical_or, - np.logical_xor, - np.logaddexp, - np.logaddexp2, - np.remainder, - np.heaviside, - np.hypot, + """Runs ufuncs specializations to BlockVector""" + if kwargs.get('out', None) is not None: + return NotImplemented + if method == 'reduce' and ufunc in vec_associative_reductions: + (arg,) = inputs + return self._reduction_operation(ufunc, method, arg, kwargs) + if method == '__call__': + if ufunc in vec_unary_ufuncs: + (arg,) = inputs + return self._unary_operation(ufunc, method, arg, kwargs) + if ufunc in vec_binary_ufuncs: + return self._binary_operation(ufunc, method, inputs, kwargs) + return NotImplemented + + def _reduction_operation(self, ufunc, method, x, kwargs): + results = [ + self._unary_operation(ufunc, method, x.get_block(i), kwargs) + for i in range(x.nblocks) ] - - args = [input_ for i, input_ in enumerate(inputs)] - outputs = kwargs.pop('out', None) - if outputs is not None: - raise NotImplementedError( - str(ufunc) - + ' cannot be used with BlockVector if the out keyword argument is given.' - ) - - if ufunc in unary_funcs: - results = self._unary_operation(ufunc, method, *args, **kwargs) - return results - elif ufunc in binary_funcs: - results = self._binary_operation(ufunc, method, *args, **kwargs) - return results + if len(results) == 1: + return results[0] else: - raise NotImplementedError(str(ufunc) + "not supported for BlockVector") + return super().__array_ufunc__(ufunc, method, np.array(results), **kwargs) - def _unary_operation(self, ufunc, method, *args, **kwargs): + def _unary_operation(self, ufunc, method, x, kwargs): """Run recursion to perform unary_funcs on BlockVector""" # ToDo: deal with out - x = args[0] if isinstance(x, BlockVector): v = BlockVector(x.nblocks) for i in range(x.nblocks): - _args = [x.get_block(i)] + [args[j] for j in range(1, len(args))] - v.set_block(i, self._unary_operation(ufunc, method, *_args, **kwargs)) + v.set_block( + i, self._unary_operation(ufunc, method, x.get_block(i), kwargs) + ) return v elif type(x) == np.ndarray: - return super(BlockVector, self).__array_ufunc__( - ufunc, method, *args, **kwargs - ) + return super().__array_ufunc__(ufunc, method, x, **kwargs) else: - raise NotImplementedError() + return NotImplemented - def _binary_operation(self, ufunc, method, *args, **kwargs): + def _binary_operation(self, ufunc, method, args, kwargs): """Run recursion to perform binary_funcs on BlockVector""" # ToDo: deal with out - x1 = args[0] - x2 = args[1] + x1, x2 = args if isinstance(x1, BlockVector) and isinstance(x2, BlockVector): assert_block_structure(x1) assert_block_structure(x2) @@ -228,14 +325,8 @@ def _binary_operation(self, ufunc, method, *args, **kwargs): res = BlockVector(x1.nblocks) for i in range(x1.nblocks): - _args = ( - [x1.get_block(i)] - + [x2.get_block(i)] - + [args[j] for j in range(2, len(args))] - ) - res.set_block( - i, self._binary_operation(ufunc, method, *_args, **kwargs) - ) + _args = (x1.get_block(i), x2.get_block(i)) + res.set_block(i, self._binary_operation(ufunc, method, _args, kwargs)) return res elif type(x1) == np.ndarray and isinstance(x2, BlockVector): assert_block_structure(x2) @@ -246,14 +337,8 @@ def _binary_operation(self, ufunc, method, *args, **kwargs): accum = 0 for i in range(x2.nblocks): nelements = x2._brow_lengths[i] - _args = ( - [x1[accum : accum + nelements]] - + [x2.get_block(i)] - + [args[j] for j in range(2, len(args))] - ) - res.set_block( - i, self._binary_operation(ufunc, method, *_args, **kwargs) - ) + _args = (x1[accum : accum + nelements], x2.get_block(i)) + res.set_block(i, self._binary_operation(ufunc, method, _args, kwargs)) accum += nelements return res elif type(x2) == np.ndarray and isinstance(x1, BlockVector): @@ -265,37 +350,23 @@ def _binary_operation(self, ufunc, method, *args, **kwargs): accum = 0 for i in range(x1.nblocks): nelements = x1._brow_lengths[i] - _args = ( - [x1.get_block(i)] - + [x2[accum : accum + nelements]] - + [args[j] for j in range(2, len(args))] - ) - res.set_block( - i, self._binary_operation(ufunc, method, *_args, **kwargs) - ) + _args = (x1.get_block(i), x2[accum : accum + nelements]) + res.set_block(i, self._binary_operation(ufunc, method, _args, kwargs)) accum += nelements return res elif np.isscalar(x1) and isinstance(x2, BlockVector): assert_block_structure(x2) res = BlockVector(x2.nblocks) for i in range(x2.nblocks): - _args = ( - [x1] + [x2.get_block(i)] + [args[j] for j in range(2, len(args))] - ) - res.set_block( - i, self._binary_operation(ufunc, method, *_args, **kwargs) - ) + _args = (x1, x2.get_block(i)) + res.set_block(i, self._binary_operation(ufunc, method, _args, kwargs)) return res elif np.isscalar(x2) and isinstance(x1, BlockVector): assert_block_structure(x1) res = BlockVector(x1.nblocks) for i in range(x1.nblocks): - _args = ( - [x1.get_block(i)] + [x2] + [args[j] for j in range(2, len(args))] - ) - res.set_block( - i, self._binary_operation(ufunc, method, *_args, **kwargs) - ) + _args = (x1.get_block(i), x2) + res.set_block(i, self._binary_operation(ufunc, method, _args, kwargs)) return res elif (type(x1) == np.ndarray or np.isscalar(x1)) and ( type(x2) == np.ndarray or np.isscalar(x2) @@ -308,7 +379,7 @@ def _binary_operation(self, ufunc, method, *args, **kwargs): raise RuntimeError('Operation not supported by BlockVector') if x2.__class__.__name__ == 'MPIBlockVector': raise RuntimeError('Operation not supported by BlockVector') - raise NotImplementedError() + return NotImplemented @property def nblocks(self): @@ -584,13 +655,15 @@ def nonzero(self): result.set_block(idx, self.get_block(idx).nonzero()[0]) return (result,) - def ptp(self, axis=None, out=None, keepdims=False): - """ - Peak to peak (maximum - minimum) value along a given axis. - """ - assert_block_structure(self) - assert out is None, 'Out keyword not supported' - return self.max() - self.min() + if np.__version__[0] < '2': + + def ptp(self, axis=None, out=None, keepdims=False): + """ + Peak to peak (maximum - minimum) value along a given axis. + """ + assert_block_structure(self) + assert out is None, 'Out keyword not supported' + return self.max() - self.min() def round(self, decimals=0, out=None): """ @@ -725,7 +798,7 @@ def ravel(self, order='C'): def argmax(self, axis=None, out=None): """ - Returns the index of the larges element. + Returns the index of the largest element. """ assert_block_structure(self) return self.flatten().argmax(axis=axis, out=out) @@ -1453,6 +1526,9 @@ def _has_equal_structure(self, other): return True def __getitem__(self, item): + # numpy: __getitem__[()] is identity + if item.__class__ is tuple and not item: + return self if not self._has_equal_structure(item): raise ValueError( 'BlockVector.__getitem__ only accepts slices in the form of BlockVectors of the same structure' @@ -1592,86 +1668,3 @@ def toMPIBlockVector(self, rank_ownership, mpi_comm, assert_correct_owners=False mpi_bv.set_block(bid, self.get_block(bid)) return mpi_bv - - # the following methods are not supported by blockvector - - def argpartition(self, kth, axis=-1, kind='introselect', order=None): - BaseBlockVector.argpartition(self, kth, axis=axis, kind=kind, order=order) - - def argsort(self, axis=-1, kind='quicksort', order=None): - BaseBlockVector.argsort(self, axis=axis, kind=kind, order=order) - - def byteswap(self, inplace=False): - BaseBlockVector.byteswap(self, inplace=inplace) - - def choose(self, choices, out=None, mode='raise'): - BaseBlockVector.choose(self, choices, out=out, mode=mode) - - def diagonal(self, offset=0, axis1=0, axis2=1): - BaseBlockVector.diagonal(self, offset=offset, axis1=axis1, axis2=axis2) - - def dump(self, file): - BaseBlockVector.dump(self, file) - - def dumps(self): - BaseBlockVector.dumps(self) - - def getfield(self, dtype, offset=0): - BaseBlockVector.getfield(self, dtype, offset=offset) - - def item(self, *args): - BaseBlockVector.item(self, *args) - - def itemset(self, *args): - BaseBlockVector.itemset(self, *args) - - def newbyteorder(self, new_order='S'): - BaseBlockVector.newbyteorder(self, new_order=new_order) - - def put(self, indices, values, mode='raise'): - BaseBlockVector.put(self, indices, values, mode=mode) - - def partition(self, kth, axis=-1, kind='introselect', order=None): - BaseBlockVector.partition(self, kth, axis=axis, kind=kind, order=order) - - def repeat(self, repeats, axis=None): - BaseBlockVector.repeat(self, repeats, axis=axis) - - def reshape(self, shape, order='C'): - BaseBlockVector.reshape(self, shape, order=order) - - def resize(self, new_shape, refcheck=True): - BaseBlockVector.resize(self, new_shape, refcheck=refcheck) - - def searchsorted(self, v, side='left', sorter=None): - BaseBlockVector.searchsorted(self, v, side=side, sorter=sorter) - - def setfield(self, val, dtype, offset=0): - BaseBlockVector.setfield(self, val, dtype, offset=offset) - - def setflags(self, write=None, align=None, uic=None): - BaseBlockVector.setflags(self, write=write, align=align, uic=uic) - - def sort(self, axis=-1, kind='quicksort', order=None): - BaseBlockVector.sort(self, axis=axis, kind=kind, order=order) - - def squeeze(self, axis=None): - BaseBlockVector.squeeze(self, axis=axis) - - def swapaxes(self, axis1, axis2): - BaseBlockVector.swapaxes(self, axis1, axis2) - - def tobytes(self, order='C'): - BaseBlockVector.tobytes(self, order=order) - - def take(self, indices, axis=None, out=None, mode='raise'): - BaseBlockVector.take(self, indices, axis=axis, out=out, mode=mode) - - def trace(self, offset=0, axis1=0, axis2=1, dtype=None, out=None): - raise NotImplementedError('trace not implemented for BlockVector') - - def transpose(*axes): - BaseBlockVector.transpose(*axes) - - def tostring(order='C'): - BaseBlockVector.tostring(order=order) diff --git a/pyomo/contrib/pynumero/sparse/mpi_block_matrix.py b/pyomo/contrib/pynumero/sparse/mpi_block_matrix.py index d32adebce0e..5c556b7e337 100644 --- a/pyomo/contrib/pynumero/sparse/mpi_block_matrix.py +++ b/pyomo/contrib/pynumero/sparse/mpi_block_matrix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -148,14 +148,14 @@ def nnz(self): @property def owned_blocks(self): """ - Returns list with inidices of blocks owned by this processor. + Returns list with indices of blocks owned by this processor. """ return list(zip(*np.nonzero(self._owned_mask))) @property def shared_blocks(self): """ - Returns list of 2-tuples with inidices of blocks shared by all processors + Returns list of 2-tuples with indices of blocks shared by all processors """ return list(zip(*np.nonzero(self._rank_owner < 0))) diff --git a/pyomo/contrib/pynumero/sparse/mpi_block_vector.py b/pyomo/contrib/pynumero/sparse/mpi_block_vector.py index 89cf136a5f7..295f5e48c87 100644 --- a/pyomo/contrib/pynumero/sparse/mpi_block_vector.py +++ b/pyomo/contrib/pynumero/sparse/mpi_block_vector.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,12 @@ from pyomo.common.dependencies import mpi4py from pyomo.contrib.pynumero.sparse import BlockVector -from .base_block import BaseBlockVector +from .base_block import ( + BaseBlockVector, + vec_unary_ufuncs, + vec_binary_ufuncs, + vec_associative_reductions, +) from .block_vector import NotFullyDefinedBlockVectorError from .block_vector import assert_block_structure as block_vector_assert_block_structure import numpy as np @@ -24,7 +29,7 @@ def assert_block_structure(vec): raise NotFullyDefinedBlockVectorError(msg) -class MPIBlockVector(np.ndarray, BaseBlockVector): +class MPIBlockVector(BaseBlockVector, np.ndarray): """ Parallel structured vector interface. This interface can be used to perform parallel operations on vectors composed by vectors. The main @@ -136,74 +141,6 @@ def __array_wrap__(self, out_arr, context=None): def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Runs ufuncs speciallizations to MPIBlockVector""" - # functions that take one vector - unary_funcs = [ - np.log10, - np.sin, - np.cos, - np.exp, - np.ceil, - np.floor, - np.tan, - np.arctan, - np.arcsin, - np.arccos, - np.sinh, - np.cosh, - np.abs, - np.tanh, - np.arccosh, - np.arcsinh, - np.arctanh, - np.fabs, - np.sqrt, - np.log, - np.log2, - np.absolute, - np.isfinite, - np.isinf, - np.isnan, - np.log1p, - np.logical_not, - np.expm1, - np.exp2, - np.sign, - np.rint, - np.square, - np.positive, - np.negative, - np.rad2deg, - np.deg2rad, - np.conjugate, - np.reciprocal, - np.signbit, - ] - # functions that take two vectors - binary_funcs = [ - np.add, - np.multiply, - np.divide, - np.subtract, - np.greater, - np.greater_equal, - np.less, - np.less_equal, - np.not_equal, - np.maximum, - np.minimum, - np.fmax, - np.fmin, - np.equal, - np.logical_and, - np.logical_or, - np.logical_xor, - np.logaddexp, - np.logaddexp2, - np.remainder, - np.heaviside, - np.hypot, - ] - outputs = kwargs.pop('out', None) if outputs is not None: raise NotImplementedError( @@ -211,10 +148,10 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + ' cannot be used with MPIBlockVector if the out keyword argument is given.' ) - if ufunc in unary_funcs: + if ufunc in vec_unary_ufuncs: results = self._unary_operation(ufunc, method, *inputs, **kwargs) return results - elif ufunc in binary_funcs: + elif ufunc in vec_binary_ufuncs: results = self._binary_operation(ufunc, method, *inputs, **kwargs) return results else: @@ -368,14 +305,14 @@ def has_none(self): @property def owned_blocks(self): """ - Returns list with inidices of blocks owned by this processor. + Returns list with indices of blocks owned by this processor. """ return self._owned_blocks @property def shared_blocks(self): """ - Returns list with inidices of blocks shared by all processors + Returns list with indices of blocks shared by all processors """ return np.array([i for i in range(self.nblocks) if self._rank_owner[i] < 0]) @@ -1440,6 +1377,9 @@ def cumsum(self, axis=None, dtype=None, out=None): raise RuntimeError('Operation not supported by MPIBlockVector') def tolist(self): + """ + Disable `np.ndarray.tolist` as it is not supported. + """ raise RuntimeError('Operation not supported by MPIBlockVector') def flatten(self, order='C'): @@ -1447,81 +1387,3 @@ def flatten(self, order='C'): def ravel(self, order='C'): raise RuntimeError('Operation not supported by MPIBlockVector') - - def argpartition(self, kth, axis=-1, kind='introselect', order=None): - BaseBlockVector.argpartition(self, kth, axis=axis, kind=kind, order=order) - - def argsort(self, axis=-1, kind='quicksort', order=None): - BaseBlockVector.argsort(self, axis=axis, kind=kind, order=order) - - def byteswap(self, inplace=False): - BaseBlockVector.byteswap(self, inplace=inplace) - - def choose(self, choices, out=None, mode='raise'): - BaseBlockVector.choose(self, choices, out=out, mode=mode) - - def diagonal(self, offset=0, axis1=0, axis2=1): - BaseBlockVector.diagonal(self, offset=offset, axis1=axis1, axis2=axis2) - - def dump(self, file): - BaseBlockVector.dump(self, file) - - def dumps(self): - BaseBlockVector.dumps(self) - - def getfield(self, dtype, offset=0): - BaseBlockVector.getfield(self, dtype, offset=offset) - - def item(self, *args): - BaseBlockVector.item(self, *args) - - def itemset(self, *args): - BaseBlockVector.itemset(self, *args) - - def newbyteorder(self, new_order='S'): - BaseBlockVector.newbyteorder(self, new_order=new_order) - - def put(self, indices, values, mode='raise'): - BaseBlockVector.put(self, indices, values, mode=mode) - - def partition(self, kth, axis=-1, kind='introselect', order=None): - BaseBlockVector.partition(self, kth, axis=axis, kind=kind, order=order) - - def repeat(self, repeats, axis=None): - BaseBlockVector.repeat(self, repeats, axis=axis) - - def reshape(self, shape, order='C'): - BaseBlockVector.reshape(self, shape, order=order) - - def resize(self, new_shape, refcheck=True): - BaseBlockVector.resize(self, new_shape, refcheck=refcheck) - - def searchsorted(self, v, side='left', sorter=None): - BaseBlockVector.searchsorted(self, v, side=side, sorter=sorter) - - def setfield(self, val, dtype, offset=0): - BaseBlockVector.setfield(self, val, dtype, offset=offset) - - def setflags(self, write=None, align=None, uic=None): - BaseBlockVector.setflags(self, write=write, align=align, uic=uic) - - def sort(self, axis=-1, kind='quicksort', order=None): - BaseBlockVector.sort(self, axis=axis, kind=kind, order=order) - - def squeeze(self, axis=None): - BaseBlockVector.squeeze(self, axis=axis) - - def swapaxes(self, axis1, axis2): - BaseBlockVector.swapaxes(self, axis1, axis2) - - def tobytes(self, order='C'): - BaseBlockVector.tobytes(self, order=order) - - def argmax(self, axis=None, out=None): - BaseBlockVector.argmax(self, axis=axis, out=out) - - def argmin(self, axis=None, out=None): - BaseBlockVector.argmax(self, axis=axis, out=out) - - def take(self, indices, axis=None, out=None, mode='raise'): - BaseBlockVector.take(self, indices, axis=axis, out=out, mode=mode) diff --git a/pyomo/contrib/pynumero/sparse/tests/__init__.py b/pyomo/contrib/pynumero/sparse/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/sparse/tests/__init__.py +++ b/pyomo/contrib/pynumero/sparse/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/sparse/tests/test_block_matrix.py b/pyomo/contrib/pynumero/sparse/tests/test_block_matrix.py index 48c1d3dc77e..951b1d8111b 100644 --- a/pyomo/contrib/pynumero/sparse/tests/test_block_matrix.py +++ b/pyomo/contrib/pynumero/sparse/tests/test_block_matrix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/sparse/tests/test_block_vector.py b/pyomo/contrib/pynumero/sparse/tests/test_block_vector.py index 610d41a09a4..724c6a73637 100644 --- a/pyomo/contrib/pynumero/sparse/tests/test_block_vector.py +++ b/pyomo/contrib/pynumero/sparse/tests/test_block_vector.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -24,6 +24,9 @@ from pyomo.contrib.pynumero.sparse.block_vector import ( BlockVector, NotFullyDefinedBlockVectorError, + vec_associative_reductions, + vec_unary_ufuncs, + vec_binary_ufuncs, ) @@ -201,6 +204,7 @@ def test_nonzero(self): for bid, blk in enumerate(n[0]): self.assertTrue(np.allclose(blk, v2.get_block(bid))) + @unittest.skipUnless(np.__version__[0] == "1", "PTP only included in Numpy 1.x") def test_ptp(self): v = BlockVector(2) a = np.arange(5) @@ -1023,64 +1027,34 @@ def test_copy_structure(self): self.assertEqual(v.get_block(1).size, v2.get_block(1).size) def test_unary_ufuncs(self): - v = BlockVector(2) a = np.ones(3) * 0.5 b = np.ones(2) * 0.8 + v = BlockVector(2) v.set_block(0, a) v.set_block(1, b) + # Some operations only accept integers + ai = np.ones(3, dtype='i') * 5 + bi = np.ones(2, dtype='i') * 8 + vi = BlockVector(2) + vi.set_block(0, ai) + vi.set_block(1, bi) - v2 = BlockVector(2) + _int_ufuncs = {np.invert, np.arccosh} - unary_funcs = [ - np.log10, - np.sin, - np.cos, - np.exp, - np.ceil, - np.floor, - np.tan, - np.arctan, - np.arcsin, - np.arccos, - np.sinh, - np.cosh, - np.abs, - np.tanh, - np.arcsinh, - np.arctanh, - np.fabs, - np.sqrt, - np.log, - np.log2, - np.absolute, - np.isfinite, - np.isinf, - np.isnan, - np.log1p, - np.logical_not, - np.exp2, - np.expm1, - np.sign, - np.rint, - np.square, - np.positive, - np.negative, - np.rad2deg, - np.deg2rad, - np.conjugate, - np.reciprocal, - ] - - for fun in unary_funcs: - v2.set_block(0, fun(v.get_block(0))) - v2.set_block(1, fun(v.get_block(1))) - res = fun(v) + v2 = BlockVector(2) + for fun in vec_unary_ufuncs: + _v = vi if fun in _int_ufuncs else v + v2.set_block(0, fun(_v.get_block(0))) + v2.set_block(1, fun(_v.get_block(1))) + res = fun(_v) self.assertIsInstance(res, BlockVector) self.assertEqual(res.nblocks, 2) for i in range(2): self.assertTrue(np.allclose(res.get_block(i), v2.get_block(i))) - other_funcs = [np.cumsum, np.cumprod, np.cumproduct] + other_funcs = [np.cumsum, np.cumprod] + if np.__version__[0] == '1': + other_funcs.append(np.cumproduct) for fun in other_funcs: res = fun(v) @@ -1088,16 +1062,20 @@ def test_unary_ufuncs(self): self.assertEqual(res.nblocks, 2) self.assertTrue(np.allclose(fun(v.flatten()), res.flatten())) - with self.assertRaises(Exception) as context: - np.cbrt(v) + with self.assertRaises(TypeError): + np.modf(v) def test_reduce_ufuncs(self): v = BlockVector(2) - a = np.ones(3) * 0.5 - b = np.ones(2) * 0.8 + # Some operations only accept integers, so we will test with integers + a = np.ones(3, dtype='i') * 5 + b = np.ones(2, dtype='i') * 8 v.set_block(0, a) v.set_block(1, b) + for fun in vec_associative_reductions: + self.assertAlmostEqual(fun.reduce(v), fun.reduce(v.flatten())) + reduce_funcs = [np.sum, np.max, np.min, np.prod, np.mean] for fun in reduce_funcs: self.assertAlmostEqual(fun(v), fun(v.flatten())) @@ -1108,56 +1086,45 @@ def test_reduce_ufuncs(self): def test_binary_ufuncs(self): v = BlockVector(2) - a = np.ones(3) * 0.5 - b = np.ones(2) * 0.8 - v.set_block(0, a) - v.set_block(1, b) - + v.set_blocks([np.ones(3) * 0.5, np.ones(2) * 0.8]) v2 = BlockVector(2) - a2 = np.ones(3) * 3.0 - b2 = np.ones(2) * 2.8 - v2.set_block(0, a2) - v2.set_block(1, b2) - - binary_ufuncs = [ - np.add, - np.multiply, - np.divide, - np.subtract, - np.greater, - np.greater_equal, - np.less, - np.less_equal, - np.not_equal, - np.maximum, - np.minimum, - np.fmax, - np.fmin, - np.equal, - np.logaddexp, - np.logaddexp2, - np.remainder, - np.heaviside, - np.hypot, - ] - - for fun in binary_ufuncs: - flat_res = fun(v.flatten(), v2.flatten()) - res = fun(v, v2) + v2.set_blocks([np.ones(3) * 3.0, np.ones(2) * 2.8]) + + vi = BlockVector(2) + vi.set_blocks([np.ones(3, dtype='i') * 5, np.ones(2, dtype='i') * 8]) + v2i = BlockVector(2) + v2i.set_blocks([np.ones(3, dtype='i') * 3, np.ones(2, dtype='i') * 2]) + + _int_ufuncs = { + np.gcd, + np.lcm, + np.ldexp, + np.left_shift, + np.right_shift, + np.bitwise_and, + np.bitwise_or, + np.bitwise_xor, + } + + for fun in vec_binary_ufuncs: + _v, _v2, _s = (vi, v2i, 3) if fun in _int_ufuncs else (v, v2, 3.0) + + flat_res = fun(_v.flatten(), _v2.flatten()) + res = fun(_v, _v2) self.assertTrue(np.allclose(flat_res, res.flatten())) - res = fun(v, v2.flatten()) + res = fun(_v, _v2.flatten()) self.assertTrue(np.allclose(flat_res, res.flatten())) - res = fun(v.flatten(), v2) + res = fun(_v.flatten(), _v2) self.assertTrue(np.allclose(flat_res, res.flatten())) - flat_res = fun(v.flatten(), 5) - res = fun(v, 5) + flat_res = fun(_v.flatten(), 5) + res = fun(_v, 5) self.assertTrue(np.allclose(flat_res, res.flatten())) - flat_res = fun(3.0, v2.flatten()) - res = fun(3.0, v2) + flat_res = fun(_s, _v2.flatten()) + res = fun(_s, _v2) self.assertTrue(np.allclose(flat_res, res.flatten())) v = BlockVector(2) diff --git a/pyomo/contrib/pynumero/sparse/tests/test_intrinsics.py b/pyomo/contrib/pynumero/sparse/tests/test_intrinsics.py index ef0a5142849..ef2e33a24e9 100644 --- a/pyomo/contrib/pynumero/sparse/tests/test_intrinsics.py +++ b/pyomo/contrib/pynumero/sparse/tests/test_intrinsics.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/sparse/tests/test_mpi_block_matrix.py b/pyomo/contrib/pynumero/sparse/tests/test_mpi_block_matrix.py index 917b4433120..72b7e28e0e9 100644 --- a/pyomo/contrib/pynumero/sparse/tests/test_mpi_block_matrix.py +++ b/pyomo/contrib/pynumero/sparse/tests/test_mpi_block_matrix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,6 +12,7 @@ import warnings import pyomo.common.unittest as unittest +from pyomo.common.dependencies import mpi4py, mpi4py_available from pyomo.contrib.pynumero.dependencies import ( numpy_available, scipy_available, @@ -24,15 +25,13 @@ else: SKIPTESTS.append("Pynumero needs scipy and numpy>=1.13.0 to run BlockMatrix tests") -try: - from mpi4py import MPI - - comm = MPI.COMM_WORLD +if mpi4py_available: + comm = mpi4py.MPI.COMM_WORLD if comm.Get_size() < 3: SKIPTESTS.append( "Pynumero needs at least 3 processes to run BlockMatrix MPI tests" ) -except ImportError: +else: SKIPTESTS.append("Pynumero needs mpi4py to run BlockMatrix MPI tests") if not SKIPTESTS: diff --git a/pyomo/contrib/pynumero/sparse/tests/test_mpi_block_vector.py b/pyomo/contrib/pynumero/sparse/tests/test_mpi_block_vector.py index c28c524823a..b17c1210b66 100644 --- a/pyomo/contrib/pynumero/sparse/tests/test_mpi_block_vector.py +++ b/pyomo/contrib/pynumero/sparse/tests/test_mpi_block_vector.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,6 +10,7 @@ # ___________________________________________________________________________ import pyomo.common.unittest as unittest +from pyomo.common.dependencies import mpi4py, mpi4py_available from pyomo.contrib.pynumero.dependencies import ( numpy_available, scipy_available, @@ -22,19 +23,21 @@ else: SKIPTESTS.append("Pynumero needs scipy and numpy>=1.13.0 to run BlockMatrix tests") -try: - from mpi4py import MPI - - comm = MPI.COMM_WORLD +if mpi4py_available: + comm = mpi4py.MPI.COMM_WORLD if comm.Get_size() < 3: SKIPTESTS.append( "Pynumero needs at least 3 processes to run BlockVector MPI tests" ) -except ImportError: +else: SKIPTESTS.append("Pynumero needs mpi4py to run BlockVector MPI tests") if not SKIPTESTS: from pyomo.contrib.pynumero.sparse import BlockVector + from pyomo.contrib.pynumero.sparse.base_block import ( + vec_unary_ufuncs, + vec_binary_ufuncs, + ) from pyomo.contrib.pynumero.sparse.mpi_block_vector import MPIBlockVector @@ -1403,48 +1406,13 @@ def test_unary_ufuncs(self): bv.set_block(0, a) bv.set_block(1, b) - unary_funcs = [ - np.log10, - np.sin, - np.cos, - np.exp, - np.ceil, - np.floor, - np.tan, - np.arctan, - np.arcsin, - np.arccos, - np.sinh, - np.cosh, - np.abs, - np.tanh, - np.arcsinh, - np.arctanh, - np.fabs, - np.sqrt, - np.log, - np.log2, - np.absolute, - np.isfinite, - np.isinf, - np.isnan, - np.log1p, - np.logical_not, - np.exp2, - np.expm1, - np.sign, - np.rint, - np.square, - np.positive, - np.negative, - np.rad2deg, - np.deg2rad, - np.conjugate, - np.reciprocal, - ] + _int_ufuncs = {np.invert, np.arccosh} bv2 = BlockVector(2) - for fun in unary_funcs: + for fun in vec_unary_ufuncs: + if fun in _int_ufuncs: + continue + bv2.set_block(0, fun(bv.get_block(0))) bv2.set_block(1, fun(bv.get_block(1))) res = fun(v) @@ -1454,7 +1422,7 @@ def test_unary_ufuncs(self): self.assertTrue(np.allclose(res.get_block(i), bv2.get_block(i))) with self.assertRaises(Exception) as context: - np.cbrt(v) + np.modf(v) with self.assertRaises(Exception) as context: np.cumsum(v) @@ -1504,29 +1472,21 @@ def test_binary_ufuncs(self): bv2.set_block(0, np.ones(3) * 3.0) bv2.set_block(1, np.ones(2) * 2.8) - binary_ufuncs = [ - np.add, - np.multiply, - np.divide, - np.subtract, - np.greater, - np.greater_equal, - np.less, - np.less_equal, - np.not_equal, - np.maximum, - np.minimum, - np.fmax, - np.fmin, - np.equal, - np.logaddexp, - np.logaddexp2, - np.remainder, - np.heaviside, - np.hypot, - ] + _int_ufuncs = { + np.gcd, + np.lcm, + np.ldexp, + np.left_shift, + np.right_shift, + np.bitwise_and, + np.bitwise_or, + np.bitwise_xor, + } + + for fun in vec_binary_ufuncs: + if fun in _int_ufuncs: + continue - for fun in binary_ufuncs: serial_res = fun(bv, bv2) res = fun(v, v2) diff --git a/pyomo/contrib/pynumero/src/AmplInterface.cpp b/pyomo/contrib/pynumero/src/AmplInterface.cpp index 805955f7671..bb354550581 100644 --- a/pyomo/contrib/pynumero/src/AmplInterface.cpp +++ b/pyomo/contrib/pynumero/src/AmplInterface.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/src/AmplInterface.hpp b/pyomo/contrib/pynumero/src/AmplInterface.hpp index bedc6d4f669..f788841346b 100644 --- a/pyomo/contrib/pynumero/src/AmplInterface.hpp +++ b/pyomo/contrib/pynumero/src/AmplInterface.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/src/AssertUtils.hpp b/pyomo/contrib/pynumero/src/AssertUtils.hpp index 061442eb6e9..7886a44b257 100644 --- a/pyomo/contrib/pynumero/src/AssertUtils.hpp +++ b/pyomo/contrib/pynumero/src/AssertUtils.hpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/src/CMakeLists.txt b/pyomo/contrib/pynumero/src/CMakeLists.txt index c481ea310df..0d9d76e3398 100644 --- a/pyomo/contrib/pynumero/src/CMakeLists.txt +++ b/pyomo/contrib/pynumero/src/CMakeLists.txt @@ -1,7 +1,16 @@ -cmake_minimum_required(VERSION 3.0) -# CMake 3.0 added GIT_SUBMODULES to ExternalProject_ADD, and without it -# the Ampl/MP checkout fails because one of the submodules (gecode) is a -# private repository. +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +cmake_minimum_required(VERSION 3.0...3.31) +# This was developed against CMake 3.0, and appears to comply with 3.5 PROJECT( pynumero ) diff --git a/pyomo/contrib/pynumero/src/ma27Interface.cpp b/pyomo/contrib/pynumero/src/ma27Interface.cpp index 4816e1274e3..e57033019e7 100644 --- a/pyomo/contrib/pynumero/src/ma27Interface.cpp +++ b/pyomo/contrib/pynumero/src/ma27Interface.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/src/ma57Interface.cpp b/pyomo/contrib/pynumero/src/ma57Interface.cpp index fa9cf4e6811..fe02907bc9f 100644 --- a/pyomo/contrib/pynumero/src/ma57Interface.cpp +++ b/pyomo/contrib/pynumero/src/ma57Interface.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/src/tests/simple_test.cpp b/pyomo/contrib/pynumero/src/tests/simple_test.cpp index 9f39fbbd8ff..b6dbea2593f 100644 --- a/pyomo/contrib/pynumero/src/tests/simple_test.cpp +++ b/pyomo/contrib/pynumero/src/tests/simple_test.cpp @@ -1,7 +1,7 @@ /**___________________________________________________________________________ * * Pyomo: Python Optimization Modeling Objects - * Copyright (c) 2008-2024 + * Copyright (c) 2008-2025 * National Technology and Engineering Solutions of Sandia, LLC * Under the terms of Contract DE-NA0003525 with National Technology and * Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pynumero/tests/__init__.py b/pyomo/contrib/pynumero/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pynumero/tests/__init__.py +++ b/pyomo/contrib/pynumero/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pyros/CHANGELOG.txt b/pyomo/contrib/pyros/CHANGELOG.txt index 94f4848edb2..e58b8648ef7 100644 --- a/pyomo/contrib/pyros/CHANGELOG.txt +++ b/pyomo/contrib/pyros/CHANGELOG.txt @@ -2,6 +2,92 @@ PyROS CHANGELOG =============== + +------------------------------------------------------------------------------- +PyROS 1.3.7 06 Mar 2025 +------------------------------------------------------------------------------- +- Modify reformulation of state-variable independent second-stage + equality constraints for problems with discrete uncertainty sets + + +------------------------------------------------------------------------------- +PyROS 1.3.6 06 Mar 2025 +------------------------------------------------------------------------------- +- Add uncertain parameter reduction to preprocessor and subproblems + + +------------------------------------------------------------------------------- +PyROS 1.3.5 13 Feb 2025 +------------------------------------------------------------------------------- +- Tweak online documentation, including solver logging output example +- Adjust UTC invocation time retrieval in PyROS logging to + deprecation of `datetime.datetime.now()` in Python 3.12 +- Suppress error message emitted to console when PyROS + attempts to retrieve git commit hash of Pyomo installation that + is not a git repository +- Add more information to solver output logging message emitted upon + failure to solve deterministic (i.e., initial master) problem + + +------------------------------------------------------------------------------- +PyROS 1.3.4 22 Jan 2025 +------------------------------------------------------------------------------- +- Fix typo that prevents fixed Vars from being included in model scope +- Fix typo that prevents proper initialization of auxiliary uncertain + parameters in the separation problems +- Unit test method for initializing separation problems +- Add tests checking model scope determined by solver argument validation + routine + + +------------------------------------------------------------------------------- +PyROS 1.3.3 03 Dec 2024 +------------------------------------------------------------------------------- +- Add efficiency for handling PyROS separation problem sub-solver errors +- Add logger warnings to report sub-solver errors and inform that PyROS + will continue to solve if a violation is found +- Add unit tests for new sub-solver error handling for continuous + and discrete uncertainty sets + + +------------------------------------------------------------------------------- +PyROS 1.3.2 29 Nov 2024 +------------------------------------------------------------------------------- +- Allow Var/VarData objects to be specified as uncertain parameters + through the `uncertain_params` argument to `PyROS.solve()` + + +------------------------------------------------------------------------------- +PyROS 1.3.1 25 Nov 2024 +------------------------------------------------------------------------------- +- Add new EllipsoidalSet attribute for specifying a + confidence level in lieu of a (squared) scale factor + + +------------------------------------------------------------------------------- +PyROS 1.3.0 12 Aug 2024 +------------------------------------------------------------------------------- +- Fix interactions between PyROS and NL writer-based solvers +- Overhaul the preprocessor +- Update subproblem formulations and modeling objects +- Update `UncertaintySet` class and pre-implemented subclasses to + facilitate new changes to the subproblems +- Update documentation and logging system in light of new preprocessor + and subproblem changes +- Make all tests more rigorous and extensive + + +------------------------------------------------------------------------------- +PyROS 1.2.11 17 Mar 2024 +------------------------------------------------------------------------------- +- Standardize calls to subordinate solvers across all PyROS subproblem types +- Account for user-specified subsolver time limits when automatically + adjusting subsolver time limits +- Add support for automatic adjustment of SCIP subsolver time limit +- Move start point of main PyROS solver timer to just before argument + validation begins + + ------------------------------------------------------------------------------- PyROS 1.2.10 07 Feb 2024 ------------------------------------------------------------------------------- diff --git a/pyomo/contrib/pyros/__init__.py b/pyomo/contrib/pyros/__init__.py index 4e134ef1166..998e733fa65 100644 --- a/pyomo/contrib/pyros/__init__.py +++ b/pyomo/contrib/pyros/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ from pyomo.contrib.pyros.pyros import PyROS -from pyomo.contrib.pyros.pyros import ObjectiveType, pyrosTerminationCondition +from pyomo.contrib.pyros.util import ObjectiveType, pyrosTerminationCondition from pyomo.contrib.pyros.uncertainty_sets import ( UncertaintySet, EllipsoidalSet, diff --git a/pyomo/contrib/pyros/config.py b/pyomo/contrib/pyros/config.py index a7ca41d095f..fb1e2001e8b 100644 --- a/pyomo/contrib/pyros/config.py +++ b/pyomo/contrib/pyros/config.py @@ -2,10 +2,8 @@ Interfaces for managing PyROS solver options. """ -from collections.abc import Iterable import logging -from pyomo.common.collections import ComponentSet from pyomo.common.config import ( ConfigDict, ConfigValue, @@ -16,141 +14,142 @@ Path, ) from pyomo.common.errors import ApplicationError, PyomoException -from pyomo.core.base import Var, _VarData -from pyomo.core.base.param import Param, _ParamData +from pyomo.core.base import Var, VarData +from pyomo.core.base.param import Param, ParamData from pyomo.opt import SolverFactory -from pyomo.contrib.pyros.util import ObjectiveType, setup_pyros_logger +from pyomo.contrib.pyros.util import ( + ObjectiveType, + setup_pyros_logger, + standardize_component_data, +) from pyomo.contrib.pyros.uncertainty_sets import UncertaintySet default_pyros_solver_logger = setup_pyros_logger() -class LoggerType: +def logger_domain(obj): """ - Domain validator for objects castable to logging.Logger. + Domain validator for logger-type arguments. + + This admits any object of type ``logging.Logger``, + or which can be cast to ``logging.Logger``. """ + if isinstance(obj, logging.Logger): + return obj + else: + return logging.getLogger(obj) - def __call__(self, obj): - """ - Cast object to logger. - Parameters - ---------- - obj : object - Object to be cast. - - Returns - ------- - logging.Logger - If `str_or_logger` is of type `logging.Logger`,then - `str_or_logger` is returned. - Otherwise, ``logging.getLogger(str_or_logger)`` - is returned. - """ - if isinstance(obj, logging.Logger): - return obj - else: - return logging.getLogger(obj) - - def domain_name(self): - """Return str briefly describing domain encompassed by self.""" - return "None, str or logging.Logger" +logger_domain.domain_name = "None, str or logging.Logger" -class PositiveIntOrMinusOne: +def positive_int_or_minus_one(obj): """ - Domain validator for objects castable to a - strictly positive int or -1. + Domain validator for objects castable to a strictly + positive int or -1. """ + ans = int(obj) + if ans != float(obj) or (ans <= 0 and ans != -1): + raise ValueError(f"Expected positive int or -1, but received value {obj!r}") + return ans - def __call__(self, obj): - """ - Cast object to positive int or -1. - Parameters - ---------- - obj : object - Object of interest. +positive_int_or_minus_one.domain_name = "positive int or -1" - Returns - ------- - int - Positive int, or -1. - Raises - ------ - ValueError - If object not castable to positive int, or -1. - """ - ans = int(obj) - if ans != float(obj) or (ans <= 0 and ans != -1): - raise ValueError(f"Expected positive int or -1, but received value {obj!r}") - return ans +def uncertain_param_validator(uncertain_obj): + """ + Check that a component object modeling an + uncertain parameter in PyROS is appropriately constructed, + initialized, and/or mutable, where applicable. - def domain_name(self): - """Return str briefly describing domain encompassed by self.""" - return "positive int or -1" + Parameters + ---------- + uncertain_obj : Param or Var + Object on which to perform checks. + + Raises + ------ + ValueError + If the length of the component (data) object does not + match that of its index set, or the object is a Param + with attribute `mutable=False`. + """ + if len(uncertain_obj) != len(uncertain_obj.index_set()): + raise ValueError( + f"Length of {type(uncertain_obj).__name__} object with " + f"name {uncertain_obj.name!r} is {len(uncertain_obj)}, " + "and does not match that of its index set, " + f"which is of length {len(uncertain_obj.index_set())}. " + "Check that the component has been properly constructed, " + "and all entries have been initialized. " + ) + if uncertain_obj.ctype is Param and not uncertain_obj.mutable: + raise ValueError( + f"{type(uncertain_obj).__name__} object with name {uncertain_obj.name!r} " + "is immutable." + ) -def mutable_param_validator(param_obj): +def uncertain_param_data_validator(uncertain_obj): """ - Check that Param-like object has attribute `mutable=True`. + Validator for component data object specified as an + uncertain parameter. Parameters ---------- - param_obj : Param or _ParamData - Param-like object of interest. + uncertain_obj : ParamData or VarData + Object on which to perform checks. Raises ------ ValueError - If lengths of the param object and the accompanying - index set do not match. This may occur if some entry - of the Param is not initialized. - ValueError - If attribute `mutable` is of value False. + If `uncertain_obj` is a VarData object + that is not fixed explicitly via VarData.fixed + or implicitly via bounds. """ - if len(param_obj) != len(param_obj.index_set()): - raise ValueError( - f"Length of Param component object with " - f"name {param_obj.name!r} is {len(param_obj)}, " - "and does not match that of its index set, " - f"which is of length {len(param_obj.index_set())}. " - "Check that all entries of the component object " - "have been initialized." + if isinstance(uncertain_obj, VarData): + is_fixed_var = uncertain_obj.fixed or ( + uncertain_obj.lower is uncertain_obj.upper + and uncertain_obj.lower is not None ) - if not param_obj.mutable: - raise ValueError(f"Param object with name {param_obj.name!r} is immutable.") + if not is_fixed_var: + raise ValueError( + f"{type(uncertain_obj).__name__} object with name " + f"{uncertain_obj.name!r} is not fixed." + ) class InputDataStandardizer(object): """ - Standardizer for objects castable to a list of Pyomo - component types. + Domain validator for an object that is castable to + a list of Pyomo component data objects. Parameters ---------- - ctype : type - Pyomo component type, such as Component, Var or Param. - cdatatype : type - Corresponding Pyomo component data type, such as - _ComponentData, _VarData, or _ParamData. + ctype : type or tuple of type + Valid Pyomo component type(s), + such as Component, Var or Param. + cdatatype : type or tuple of type + Valid Pyomo component data type(s), such as + ComponentData, VarData, or ParamData. ctype_validator : callable, optional Validator function for objects of type `ctype`. cdatatype_validator : callable, optional Validator function for objects of type `cdatatype`. allow_repeats : bool, optional - True to allow duplicate component data entries in final - list to which argument is cast, False otherwise. + True to allow duplicate component data object + entries in final list to which argument is cast, + False otherwise. Attributes ---------- - ctype - cdatatype - ctype_validator - cdatatype_validator - allow_repeats + ctype : type or tuple of type + cdatatype : type or tuple of type + ctype_validator : callable or None + cdatatype_validator : callable or None + allow_repeats : bool """ def __init__( @@ -168,24 +167,6 @@ def __init__( self.cdatatype_validator = cdatatype_validator self.allow_repeats = allow_repeats - def standardize_ctype_obj(self, obj): - """ - Standardize object of type ``self.ctype`` to list - of objects of type ``self.cdatatype``. - """ - if self.ctype_validator is not None: - self.ctype_validator(obj) - return list(obj.values()) - - def standardize_cdatatype_obj(self, obj): - """ - Standardize object of type ``self.cdatatype`` to - ``[obj]``. - """ - if self.cdatatype_validator is not None: - self.cdatatype_validator(obj) - return [obj] - def __call__(self, obj, from_iterable=None, allow_repeats=None): """ Cast object to a flat list of Pyomo component data type @@ -201,55 +182,29 @@ def __call__(self, obj, from_iterable=None, allow_repeats=None): True if list can contain repeated entries, False otherwise. - Raises - ------ - TypeError - If all entries in the resulting list - are not of type ``self.cdatatype``. - ValueError - If the resulting list contains duplicate entries. + Returns + ------- + list of ComponentData + Each entry is an instance of ``self.cdatatype``. """ - if allow_repeats is None: - allow_repeats = self.allow_repeats - - if isinstance(obj, self.ctype): - ans = self.standardize_ctype_obj(obj) - elif isinstance(obj, self.cdatatype): - ans = self.standardize_cdatatype_obj(obj) - elif isinstance(obj, Iterable) and not isinstance(obj, str): - ans = [] - for item in obj: - ans.extend(self.__call__(item, from_iterable=obj)) - else: - from_iterable_qual = ( - f" (entry of iterable {from_iterable})" - if from_iterable is not None - else "" - ) - raise TypeError( - f"Input object {obj!r}{from_iterable_qual} " - "is not of valid component type " - f"{self.ctype.__name__} or component data type " - f"{self.cdatatype.__name__}." - ) - - # check for duplicates if desired - if not allow_repeats and len(ans) != len(ComponentSet(ans)): - comp_name_list = [comp.name for comp in ans] - raise ValueError( - f"Standardized component list {comp_name_list} " - f"derived from input {obj} " - "contains duplicate entries." - ) - - return ans + return standardize_component_data( + obj=obj, + valid_ctype=self.ctype, + valid_cdatatype=self.cdatatype, + ctype_validator=self.ctype_validator, + cdatatype_validator=self.cdatatype_validator, + allow_repeats=allow_repeats, + from_iterable=from_iterable, + ) def domain_name(self): """Return str briefly describing domain encompassed by self.""" - return ( - f"{self.cdatatype.__name__}, {self.ctype.__name__}, " - f"or Iterable of {self.cdatatype.__name__}/{self.ctype.__name__}" + ctypes_tup = (self.ctype,) if isinstance(self.ctype, type) else self.ctype + cdtypes_tup = ( + (self.cdatatype,) if isinstance(self.cdatatype, type) else self.cdatatype ) + alltypes_desc = ", ".join(vtype.__name__ for vtype in ctypes_tup + cdtypes_tup) + return f"(iterable of) {alltypes_desc}" class SolverNotResolvable(PyomoException): @@ -540,6 +495,21 @@ def pyros_config(): ), ), ) + CONFIG.declare( + 'symbolic_solver_labels', + ConfigValue( + default=False, + domain=bool, + description=( + """ + True to ensure the component names given to the + subordinate solvers for every subproblem reflect + the names of the corresponding Pyomo modeling components, + False otherwise. + """ + ), + ), + ) # ================================================ # === Required User Inputs @@ -548,7 +518,7 @@ def pyros_config(): "first_stage_variables", ConfigValue( default=[], - domain=InputDataStandardizer(Var, _VarData, allow_repeats=False), + domain=InputDataStandardizer(Var, VarData, allow_repeats=False), description="First-stage (or design) variables.", visibility=1, ), @@ -557,7 +527,7 @@ def pyros_config(): "second_stage_variables", ConfigValue( default=[], - domain=InputDataStandardizer(Var, _VarData, allow_repeats=False), + domain=InputDataStandardizer(Var, VarData, allow_repeats=False), description="Second-stage (or control) variables.", visibility=1, ), @@ -567,16 +537,19 @@ def pyros_config(): ConfigValue( default=[], domain=InputDataStandardizer( - ctype=Param, - cdatatype=_ParamData, - ctype_validator=mutable_param_validator, + ctype=(Param, Var), + cdatatype=(ParamData, VarData), + ctype_validator=uncertain_param_validator, + cdatatype_validator=uncertain_param_data_validator, allow_repeats=False, ), description=( """ Uncertain model parameters. - The `mutable` attribute for all uncertain parameter - objects should be set to True. + Of every constituent `Param` object, + the `mutable` attribute must be set to True. + All constituent `Var`/`VarData` objects should be + fixed. """ ), visibility=1, @@ -721,7 +694,7 @@ def pyros_config(): "max_iter", ConfigValue( default=-1, - domain=PositiveIntOrMinusOne(), + domain=positive_int_or_minus_one, description=( """ Iteration limit. If -1 is provided, then no iteration @@ -766,7 +739,7 @@ def pyros_config(): "progress_logger", ConfigValue( default=default_pyros_solver_logger, - domain=LoggerType(), + domain=logger_domain, doc=( """ Logger (or name thereof) used for reporting PyROS solver diff --git a/pyomo/contrib/pyros/master_problem_methods.py b/pyomo/contrib/pyros/master_problem_methods.py index abf02809396..50ebe78ec2b 100644 --- a/pyomo/contrib/pyros/master_problem_methods.py +++ b/pyomo/contrib/pyros/master_problem_methods.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,164 +10,207 @@ # ___________________________________________________________________________ """ -Functions for handling the construction and solving of the GRCS master problem via ROSolver +Functions for construction and solution of the PyROS master problem. """ -from pyomo.core.base import ( - ConcreteModel, - Block, - Var, - Objective, - Constraint, - ConstraintList, - SortComponents, -) -from pyomo.opt import TerminationCondition as tc -from pyomo.opt import SolverResults -from pyomo.core.expr import value +import os + +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.modeling import unique_component_name +from pyomo.core import TransformationFactory +from pyomo.core.base import ConcreteModel, Block, Var, Objective, Constraint from pyomo.core.base.set_types import NonNegativeIntegers, NonNegativeReals +from pyomo.core.expr import identify_variables, value +from pyomo.core.util import prod +from pyomo.opt import TerminationCondition as tc +from pyomo.repn.standard_repn import generate_standard_repn + +from pyomo.contrib.pyros.solve_data import MasterResults from pyomo.contrib.pyros.util import ( - selective_clone, + call_solver, + DR_POLISHING_PARAM_PRODUCT_ZERO_TOL, + enforce_dr_degree, + get_all_first_stage_eq_cons, + get_dr_expression, + check_time_limit_reached, + generate_all_decision_rule_var_data_objects, ObjectiveType, pyrosTerminationCondition, - process_termination_condition_master_problem, - adjust_solver_time_settings, - revert_solver_max_time_adjustment, - get_main_elapsed_time, + TIC_TOC_SOLVE_TIME_ATTR, ) -from pyomo.contrib.pyros.solve_data import MasterProblemData, MasterResult -from pyomo.opt.results import check_optimal_termination -from pyomo.core.expr.visitor import replace_expressions, identify_variables -from pyomo.common.collections import ComponentMap, ComponentSet -from pyomo.repn.standard_repn import generate_standard_repn -from pyomo.core import TransformationFactory -import itertools as it -import os -from copy import deepcopy -from pyomo.common.errors import ApplicationError -from pyomo.common.modeling import unique_component_name - -from pyomo.common.timing import TicTocTimer -from pyomo.contrib.pyros.util import TIC_TOC_SOLVE_TIME_ATTR, enforce_dr_degree - - -def initial_construct_master(model_data): - """ - Constructs the iteration 0 master problem - return: a MasterProblemData object containing the master_model object - """ - m = ConcreteModel() - m.scenarios = Block(NonNegativeIntegers, NonNegativeIntegers) - - master_data = MasterProblemData() - master_data.original = model_data.working_model.clone() - master_data.master_model = m - master_data.timing = model_data.timing - return master_data - -def get_state_vars(model, iterations): +def construct_initial_master_problem(model_data): """ - Obtain the state variables of a two-stage model - for a given (sequence of) iterations corresponding - to model blocks. + Construct the initial master problem model object + from the preprocessed working model. Parameters ---------- - model : ConcreteModel - PyROS model. - iterations : iterable - Iterations to consider. + model_data : model data object + Main model data object, + containing the preprocessed working model. Returns ------- - iter_state_var_map : dict - Mapping from iterations to list(s) of state vars. + master_model : ConcreteModel + Initial master problem model object. + Contains a single scenario block fully cloned from + the working model. """ - iter_state_var_map = dict() - for itn in iterations: - state_vars = [ - var for blk in model.scenarios[itn, :] for var in blk.util.state_vars - ] - iter_state_var_map[itn] = state_vars + master_model = ConcreteModel() + master_model.scenarios = Block(NonNegativeIntegers, NonNegativeIntegers) + add_scenario_block_to_master_problem( + master_model=master_model, + scenario_idx=(0, 0), + param_realization=model_data.config.nominal_uncertain_param_vals, + from_block=model_data.working_model, + clone_first_stage_components=True, + ) + + # epigraph Objective was not added during preprocessing, + # as we wanted to add it to the root block of the master + # model rather than to the model to prevent + # duplication across scenario sub-blocks + master_model.epigraph_obj = Objective( + expr=master_model.scenarios[0, 0].first_stage.epigraph_var + ) - return iter_state_var_map + return master_model -def construct_master_feasibility_problem(model_data, config): +def add_scenario_block_to_master_problem( + master_model, + scenario_idx, + param_realization, + from_block, + clone_first_stage_components, +): """ - Construct a slack-variable based master feasibility model. - Initialize all model variables appropriately, and scale slack variables - as well. + Add new scenario block to the master model. Parameters ---------- - model_data : MasterProblemData + master_model : ConcreteModel + Master model. + scenario_idx : tuple + Index of ``master_model.scenarios`` for the new block. + param_realization : Iterable of numeric type + Uncertain parameter realization for new block. + from_block : BlockData + Block from which to transfer attributes. + This can be an existing scenario block, or a block + with the same hierarchical structure as the + preprocessed working model. + clone_first_stage_components : bool + True to clone first-stage variables + when transferring attributes to the new block + to the new block (as opposed to using the objects as + they are in `from_block`), False otherwise. + """ + # Note for any of the Vars not copied: + # - if Var is not a member of an indexed var, then + # the 'name' attribute changes from + # '{from_block.name}.{var.name}' + # to 'scenarios[{scenario_idx}].{var.name}' + # - otherwise, the name stays the same + memo = dict() + if not clone_first_stage_components: + nonadjustable_comps = from_block.all_nonadjustable_variables + memo = {id(comp): comp for comp in nonadjustable_comps} + + # we will clone the first-stage constraints + # (mostly to prevent symbol map name clashes). + # the duplicate constraints are redundant. + # consider deactivating these constraints in the + # off-nominal blocks? + + new_block = from_block.clone(memo=memo) + master_model.scenarios[scenario_idx].transfer_attributes_from(new_block) + + # update uncertain parameter values in new block + new_uncertain_params = master_model.scenarios[scenario_idx].uncertain_params + for param, val in zip(new_uncertain_params, param_realization): + param.set_value(val) + + # deactivate the first-stage constraints: they are duplicate + if scenario_idx != (0, 0): + new_blk = master_model.scenarios[scenario_idx] + for con in new_blk.first_stage.inequality_cons.values(): + con.deactivate() + for con in get_all_first_stage_eq_cons(new_blk): + con.deactivate() + + +def construct_master_feasibility_problem(master_data): + """ + Construct slack variable minimization problem from the master + model. + + Slack variables are added only to the seconds-stage + inequality constraints of the blocks added for the + current PyROS iteration. + + Parameters + ---------- + master_data : MasterProblemData Master problem data. - config : ConfigDict - PyROS solver config. Returns ------- - model : ConcreteModel + slack_model : ConcreteModel Slack variable model. """ - - # clone master model. current state: - # - variables for all but newest block are set to values from - # master solution from previous iteration - # - variables for newest block are set to values from separation - # solution chosen in previous iteration - model = model_data.master_model.clone() - - # obtain mapping from master problem to master feasibility - # problem variables - varmap_name = unique_component_name(model_data.master_model, 'pyros_var_map') + # to prevent use of find_component when copying variable values + # from the slack model to the master problem later, we will + # map corresponding variables before/during slack model construction + varmap_name = unique_component_name(master_data.master_model, 'pyros_var_map') setattr( - model_data.master_model, + master_data.master_model, varmap_name, - list(model_data.master_model.component_data_objects(Var)), + list(master_data.master_model.component_data_objects(Var)), ) - model = model_data.master_model.clone() - model_data.feasibility_problem_varmap = list( - zip(getattr(model_data.master_model, varmap_name), getattr(model, varmap_name)) + + slack_model = master_data.master_model.clone() + + master_data.feasibility_problem_varmap = list( + zip( + getattr(master_data.master_model, varmap_name), + getattr(slack_model, varmap_name), + ) ) - delattr(model_data.master_model, varmap_name) - delattr(model, varmap_name) + delattr(master_data.master_model, varmap_name) + delattr(slack_model, varmap_name) - for obj in model.component_data_objects(Objective): + for obj in slack_model.component_data_objects(Objective): obj.deactivate() - iteration = model_data.iteration + iteration = master_data.iteration - # add slacks only to inequality constraints for the newest - # master block. these should be the only constraints which + # add slacks only to second-stage inequality constraints for the + # newest master block(s). + # these should be the only constraints that # may have been violated by the previous master and separation # solution(s) targets = [] - for blk in model.scenarios[iteration, :]: - targets.extend( - [ - con - for con in blk.component_data_objects( - Constraint, active=True, descend_into=True - ) - if not con.equality - ] - ) + for blk in slack_model.scenarios[iteration, :]: + targets.extend(blk.second_stage.inequality_cons.values()) - # retain original constraint expressions - # (for slack initialization and scaling) + # retain original constraint expressions before adding slacks + # (to facilitate slack initialization and scaling) pre_slack_con_exprs = ComponentMap((con, con.body - con.upper) for con in targets) # add slack variables and objective # inequalities g(v) <= b become g(v) - s^- <= b - TransformationFactory("core.add_slack_variables").apply_to(model, targets=targets) + TransformationFactory("core.add_slack_variables").apply_to( + slack_model, targets=targets + ) slack_vars = ComponentSet( - model._core_add_slack_variables.component_data_objects(Var, descend_into=True) + slack_model._core_add_slack_variables.component_data_objects( + Var, descend_into=True + ) ) - # initialize and scale slack variables + # initialize slack variables for con in pre_slack_con_exprs: # get mapping from slack variables to their (linear) # coefficients (+/-1) in the updated constraint expressions @@ -178,7 +221,6 @@ def construct_master_feasibility_problem(model_data, config): if var in slack_vars: slack_var_coef_map[var] = repn.linear_coefs[idx] - slack_substitution_map = dict() for slack_var in slack_var_coef_map: # coefficient determines whether the slack # is a +ve or -ve slack @@ -187,26 +229,12 @@ def construct_master_feasibility_problem(model_data, config): else: con_slack = max(0, -value(pre_slack_con_exprs[con])) - # initialize slack variable, evaluate scaling coefficient slack_var.set_value(con_slack) - scaling_coeff = 1 - # update expression replacement map for slack scaling - slack_substitution_map[id(slack_var)] = scaling_coeff * slack_var + return slack_model - # finally, scale slack(s) - con.set_value( - ( - replace_expressions(con.lower, slack_substitution_map), - replace_expressions(con.body, slack_substitution_map), - replace_expressions(con.upper, slack_substitution_map), - ) - ) - - return model - -def solve_master_feasibility_problem(model_data, config): +def solve_master_feasibility_problem(master_data): """ Solve a slack variable-based feasibility model derived from the master problem. Initialize the master problem @@ -215,20 +243,19 @@ def solve_master_feasibility_problem(model_data, config): Parameters ---------- - model_data : MasterProblemData + master_data : MasterProblemData Master problem data. - config : ConfigDict - PyROS solver settings. Returns ------- results : SolverResults Solver results. """ - model = construct_master_feasibility_problem(model_data, config) + model = construct_master_feasibility_problem(master_data) active_obj = next(model.component_data_objects(Objective, active=True)) + config = master_data.config config.progress_logger.debug("Solving master feasibility problem") config.progress_logger.debug( f" Initial objective (total slack): {value(active_obj)}" @@ -239,31 +266,18 @@ def solve_master_feasibility_problem(model_data, config): else: solver = config.local_solver - timer = TicTocTimer() - orig_setting, custom_setting_present = adjust_solver_time_settings( - model_data.timing, solver, config - ) - model_data.timing.start_timer("main.master_feasibility") - timer.tic(msg=None) - try: - results = solver.solve(model, tee=config.tee, load_solutions=False) - except ApplicationError: - # account for possible external subsolver errors - # (such as segmentation faults, function evaluation - # errors, etc.) - config.progress_logger.error( + results = call_solver( + model=model, + solver=solver, + config=config, + timing_obj=master_data.timing, + timer_name="main.master_feasibility", + err_msg=( f"Optimizer {repr(solver)} encountered exception " "attempting to solve master feasibility problem in iteration " - f"{model_data.iteration}." - ) - raise - else: - setattr(results.solver, TIC_TOC_SOLVE_TIME_ATTR, timer.toc(msg=None)) - model_data.timing.stop_timer("main.master_feasibility") - finally: - revert_solver_max_time_adjustment( - solver, orig_setting, custom_setting_present, config - ) + f"{master_data.iteration}." + ), + ) feasible_terminations = { tc.optimal, @@ -285,7 +299,7 @@ def solve_master_feasibility_problem(model_data, config): else: config.progress_logger.warning( "Could not successfully solve master feasibility problem " - f"of iteration {model_data.iteration} with primary subordinate " + f"of iteration {master_data.iteration} with primary subordinate " f"{'global' if config.solve_master_globally else 'local'} solver " "to acceptable level. " f"Termination stats:\n{results.solver}\n" @@ -293,23 +307,20 @@ def solve_master_feasibility_problem(model_data, config): ) # load master feasibility point to master model - for master_var, feas_var in model_data.feasibility_problem_varmap: + for master_var, feas_var in master_data.feasibility_problem_varmap: master_var.set_value(feas_var.value, skip_validation=True) return results -def construct_dr_polishing_problem(model_data, config): +def construct_dr_polishing_problem(master_data): """ - Construct DR polishing problem from most recently added - master problem. + Construct DR polishing problem from the master problem. Parameters ---------- - model_data : MasterProblemData + master_data : MasterProblemData Master problem data. - config : ConfigDict - PyROS solver settings. Returns ------- @@ -324,112 +335,150 @@ def construct_dr_polishing_problem(model_data, config): (including epigraph) fixed. Optimality of the polished DR with respect to the master objective is also enforced. """ - # clone master problem - master_model = model_data.master_model + master_model = master_data.master_model polishing_model = master_model.clone() nominal_polishing_block = polishing_model.scenarios[0, 0] - # fix first-stage variables (including epigraph, where applicable) - decision_rule_var_set = ComponentSet( - var - for indexed_dr_var in nominal_polishing_block.util.decision_rule_vars - for var in indexed_dr_var.values() - ) - first_stage_vars = nominal_polishing_block.util.first_stage_variables - for var in first_stage_vars: - if var not in decision_rule_var_set: - var.fix() - - # ensure master optimality constraint enforced - if config.objective_focus == ObjectiveType.worst_case: - polishing_model.zeta.fix() - else: - optimal_master_obj_value = value(polishing_model.obj) - polishing_model.nominal_optimality_con = Constraint( - expr=( - nominal_polishing_block.first_stage_objective - + nominal_polishing_block.second_stage_objective - <= optimal_master_obj_value - ) - ) + nominal_eff_var_partitioning = nominal_polishing_block.effective_var_partitioning - # deactivate master problem objective - polishing_model.obj.deactivate() - - decision_rule_vars = nominal_polishing_block.util.decision_rule_vars - nominal_polishing_block.util.polishing_vars = polishing_vars = [] - for idx, indexed_dr_var in enumerate(decision_rule_vars): - # declare auxiliary 'polishing' variables. + nondr_nonadjustable_vars = ( + nominal_eff_var_partitioning.first_stage_variables + # fixing epigraph variable constrains the problem + # to the optimal master problem solution set + + [nominal_polishing_block.first_stage.epigraph_var] + ) + for var in nondr_nonadjustable_vars: + var.fix() + + # deactivate original constraints that involved + # only vars that have been fixed. + # we do this mostly to ensure that the active equality constraints + # do not grossly outnumber the unfixed Vars + fixed_dr_vars = [ + var + for var in generate_all_decision_rule_var_data_objects(nominal_polishing_block) + if var.fixed + ] + fixed_nonadjustable_vars = ComponentSet(nondr_nonadjustable_vars + fixed_dr_vars) + for blk in polishing_model.scenarios.values(): + for con in blk.component_data_objects(Constraint, active=True): + vars_in_con = ComponentSet(identify_variables(con.body)) + if not (vars_in_con - fixed_nonadjustable_vars): + con.deactivate() + + # we will add the polishing objective later + polishing_model.epigraph_obj.deactivate() + + polishing_model.polishing_vars = polishing_vars = [] + indexed_dr_var_list = nominal_polishing_block.first_stage.decision_rule_vars + for idx, indexed_dr_var in enumerate(indexed_dr_var_list): + # auxiliary 'polishing' variables. # these are meant to represent the absolute values - # of the terms of DR polynomial + # of the terms of DR polynomial; + # we need these for the L1-norm indexed_polishing_var = Var( list(indexed_dr_var.keys()), domain=NonNegativeReals ) - nominal_polishing_block.add_component( - unique_component_name(nominal_polishing_block, f"dr_polishing_var_{idx}"), - indexed_polishing_var, - ) + polishing_model.add_component(f"dr_polishing_var_{idx}", indexed_polishing_var) polishing_vars.append(indexed_polishing_var) - dr_eq_var_zip = zip( - nominal_polishing_block.util.decision_rule_eqns, - polishing_vars, - nominal_polishing_block.util.second_stage_variables, - ) - nominal_polishing_block.util.polishing_abs_val_lb_cons = all_lb_cons = [] - nominal_polishing_block.util.polishing_abs_val_ub_cons = all_ub_cons = [] - for idx, (dr_eq, indexed_polishing_var, ss_var) in enumerate(dr_eq_var_zip): + # we need the DR expressions to set up the + # absolute value constraints and initialize the + # auxiliary polishing variables + eff_ss_var_to_dr_expr_pairs = [ + (ss_var, get_dr_expression(nominal_polishing_block, ss_var)) + for ss_var in nominal_eff_var_partitioning.second_stage_variables + ] + + dr_eq_var_zip = zip(polishing_vars, eff_ss_var_to_dr_expr_pairs) + polishing_model.polishing_abs_val_lb_cons = all_lb_cons = [] + polishing_model.polishing_abs_val_ub_cons = all_ub_cons = [] + for idx, (indexed_polishing_var, (ss_var, dr_expr)) in enumerate(dr_eq_var_zip): # set up absolute value constraint components polishing_absolute_value_lb_cons = Constraint(indexed_polishing_var.index_set()) polishing_absolute_value_ub_cons = Constraint(indexed_polishing_var.index_set()) - # add constraints to polishing model - nominal_polishing_block.add_component( - unique_component_name(polishing_model, f"polishing_abs_val_lb_con_{idx}"), - polishing_absolute_value_lb_cons, + # add indexed constraints to polishing model + polishing_model.add_component( + f"polishing_abs_val_lb_con_{idx}", polishing_absolute_value_lb_cons ) - nominal_polishing_block.add_component( - unique_component_name(polishing_model, f"polishing_abs_val_ub_con_{idx}"), - polishing_absolute_value_ub_cons, + polishing_model.add_component( + f"polishing_abs_val_ub_con_{idx}", polishing_absolute_value_ub_cons ) - # update list of absolute value cons + # update list of absolute value (i.e., polishing) cons all_lb_cons.append(polishing_absolute_value_lb_cons) all_ub_cons.append(polishing_absolute_value_ub_cons) - # get monomials; ensure second-stage variable term excluded - dr_expr_terms = dr_eq.body.args[:-1] + for dr_monomial in dr_expr.args: + is_a_nonstatic_dr_term = dr_monomial.is_expression_type() + if is_a_nonstatic_dr_term: + # degree >= 1 monomial expression of form + # (product of uncertain params) * dr variable + dr_var_in_term = dr_monomial.args[-1] + else: + # the static term (intercept) + dr_var_in_term = dr_monomial - for dr_eq_term in dr_expr_terms: - dr_var_in_term = dr_eq_term.args[-1] + # we want the DR variable and corresponding polishing + # constraints to have the same index in the indexed + # components dr_var_in_term_idx = dr_var_in_term.index() - - # get corresponding polishing variable polishing_var = indexed_polishing_var[dr_var_in_term_idx] + # Fix DR variable if: + # (1) it has already been fixed from master due to + # DR efficiencies (already done) + # (2) coefficient of term + # (i.e. product of uncertain parameter values) + # in DR expression is 0 + # across all master blocks + dr_term_copies = [ + ( + scenario_blk.second_stage.decision_rule_eqns[idx].body.args[ + dr_var_in_term_idx + ] + ) + for scenario_blk in master_model.scenarios.values() + ] + all_copy_coeffs_zero = is_a_nonstatic_dr_term and all( + abs(value(prod(term.args[:-1]))) <= DR_POLISHING_PARAM_PRODUCT_ZERO_TOL + for term in dr_term_copies + ) + if all_copy_coeffs_zero: + # increment static DR variable value + # to maintain feasibility of the initial point + # as much as possible + static_dr_var_in_expr = dr_expr.args[0] + static_dr_var_in_expr.set_value( + value(static_dr_var_in_expr) + value(dr_monomial) + ) + dr_var_in_term.fix(0) + # add polishing constraints polishing_absolute_value_lb_cons[dr_var_in_term_idx] = ( - -polishing_var - dr_eq_term <= 0 + -polishing_var - dr_monomial <= 0 ) polishing_absolute_value_ub_cons[dr_var_in_term_idx] = ( - dr_eq_term - polishing_var <= 0 + dr_monomial - polishing_var <= 0 ) - # if DR var is fixed, then fix corresponding polishing - # variable, and deactivate the absolute value constraints - if dr_var_in_term.fixed: + # some DR variables may be fixed, + # due to the PyROS DR order efficiency instituted + # in the first few iterations. + # these need not be polished + if dr_var_in_term.fixed or not is_a_nonstatic_dr_term: polishing_var.fix() polishing_absolute_value_lb_cons[dr_var_in_term_idx].deactivate() polishing_absolute_value_ub_cons[dr_var_in_term_idx].deactivate() - # initialize polishing variable to absolute value of - # the DR term. polishing constraints should now be - # satisfied (to equality) at the initial point - polishing_var.set_value(abs(value(dr_eq_term))) + # ensure polishing var properly initialized + polishing_var.set_value(abs(value(dr_monomial))) - # polishing problem objective is taken to be 1-norm - # of DR monomials, or equivalently, sum of the polishing - # variables. + # L1-norm objective + # TODO: if dropping nonstatic terms, ensure the + # corresponding polishing variables are excluded + # from this expression polishing_model.polishing_obj = Objective( expr=sum(sum(polishing_var.values()) for polishing_var in polishing_vars) ) @@ -437,16 +486,14 @@ def construct_dr_polishing_problem(model_data, config): return polishing_model -def minimize_dr_vars(model_data, config): +def minimize_dr_vars(master_data): """ Polish decision rule of most recent master problem solution. Parameters ---------- - model_data : MasterProblemData + master_data : MasterProblemData Master problem data. - config : ConfigDict - PyROS solver settings. Returns ------- @@ -456,10 +503,10 @@ def minimize_dr_vars(model_data, config): True if polishing model was solved to acceptable level, False otherwise. """ + config = master_data.config + # create polishing NLP - polishing_model = construct_dr_polishing_problem( - model_data=model_data, config=config - ) + polishing_model = construct_dr_polishing_problem(master_data) if config.solve_master_globally: solver = config.global_solver @@ -475,28 +522,18 @@ def minimize_dr_vars(model_data, config): config.progress_logger.debug(f" Initial DR norm: {value(polishing_obj)}") # === Solve the polishing model - timer = TicTocTimer() - orig_setting, custom_setting_present = adjust_solver_time_settings( - model_data.timing, solver, config - ) - model_data.timing.start_timer("main.dr_polishing") - timer.tic(msg=None) - try: - results = solver.solve(polishing_model, tee=config.tee, load_solutions=False) - except ApplicationError: - config.progress_logger.error( + results = call_solver( + model=polishing_model, + solver=solver, + config=config, + timing_obj=master_data.timing, + timer_name="main.dr_polishing", + err_msg=( f"Optimizer {repr(solver)} encountered an exception " "attempting to solve decision rule polishing problem " - f"in iteration {model_data.iteration}" - ) - raise - else: - setattr(results.solver, TIC_TOC_SOLVE_TIME_ATTR, timer.toc(msg=None)) - model_data.timing.stop_timer("main.dr_polishing") - finally: - revert_solver_max_time_adjustment( - solver, orig_setting, custom_setting_present, config - ) + f"in iteration {master_data.iteration}" + ), + ) # interested in the time and termination status for debugging # purposes @@ -514,7 +551,7 @@ def minimize_dr_vars(model_data, config): # continue with "unpolished" master model solution config.progress_logger.warning( "Could not successfully solve DR polishing problem " - f"of iteration {model_data.iteration} with primary subordinate " + f"of iteration {master_data.iteration} with primary subordinate " f"{'global' if config.solve_master_globally else 'local'} solver " "to acceptable level. " f"Termination stats:\n{results.solver}\n" @@ -526,144 +563,80 @@ def minimize_dr_vars(model_data, config): # variables to polishing model solution polishing_model.solutions.load_from(results) - for idx, blk in model_data.master_model.scenarios.items(): - ssv_zip = zip( - blk.util.second_stage_variables, - polishing_model.scenarios[idx].util.second_stage_variables, - ) - sv_zip = zip( - blk.util.state_vars, polishing_model.scenarios[idx].util.state_vars - ) - for master_ssv, polish_ssv in ssv_zip: - master_ssv.set_value(value(polish_ssv)) - for master_sv, polish_sv in sv_zip: - master_sv.set_value(value(polish_sv)) - - # update master problem decision rule variables + # update master problem variable values + for idx, blk in master_data.master_model.scenarios.items(): + master_adjustable_vars = blk.all_adjustable_variables + polishing_adjustable_vars = polishing_model.scenarios[ + idx + ].all_adjustable_variables + adjustable_vars_zip = zip(master_adjustable_vars, polishing_adjustable_vars) + for master_var, polish_var in adjustable_vars_zip: + master_var.set_value(value(polish_var)) dr_var_zip = zip( - blk.util.decision_rule_vars, - polishing_model.scenarios[idx].util.decision_rule_vars, + blk.first_stage.decision_rule_vars, + polishing_model.scenarios[idx].first_stage.decision_rule_vars, ) for master_dr, polish_dr in dr_var_zip: for mvar, pvar in zip(master_dr.values(), polish_dr.values()): mvar.set_value(value(pvar), skip_validation=True) config.progress_logger.debug(f" Optimized DR norm: {value(polishing_obj)}") - config.progress_logger.debug(" Polished master objective:") - - # print breakdown of objective value of polished master solution - if config.objective_focus == ObjectiveType.worst_case: - eval_obj_blk_idx = max( - model_data.master_model.scenarios.keys(), - key=lambda idx: value( - model_data.master_model.scenarios[idx].second_stage_objective - ), - ) - else: - eval_obj_blk_idx = (0, 0) - - # debugging: summarize objective breakdown - eval_obj_blk = model_data.master_model.scenarios[eval_obj_blk_idx] - config.progress_logger.debug( - " First-stage objective: " f"{value(eval_obj_blk.first_stage_objective)}" - ) - config.progress_logger.debug( - " Second-stage objective: " f"{value(eval_obj_blk.second_stage_objective)}" - ) - polished_master_obj = value( - eval_obj_blk.first_stage_objective + eval_obj_blk.second_stage_objective - ) - config.progress_logger.debug(f" Objective: {polished_master_obj}") + log_master_solve_results(polishing_model, config, results, desc="polished") return results, True -def add_p_robust_constraint(model_data, config): - """ - p-robustness--adds constraints to the master problem ensuring that the - optimal k-th iteration solution is within (1+rho) of the nominal - objective. The parameter rho is specified by the user and should be between. - """ - rho = config.p_robustness['rho'] - model = model_data.master_model - block_0 = model.scenarios[0, 0] - frac_nom_cost = (1 + rho) * ( - block_0.first_stage_objective + block_0.second_stage_objective - ) - - for block_k in model.scenarios[model_data.iteration, :]: - model.p_robust_constraints.add( - block_k.first_stage_objective + block_k.second_stage_objective - <= frac_nom_cost - ) - return - - -def add_scenario_to_master(model_data, violations): - """ - Add block to master, without cloning the master_model.first_stage_variables - """ - - m = model_data.master_model - i = max(m.scenarios.keys())[0] + 1 - - # === Add a block to master for each violation - idx = 0 # Only supporting adding single violation back to master in v1 - new_block = selective_clone( - m.scenarios[0, 0], m.scenarios[0, 0].util.first_stage_variables - ) - m.scenarios[i, idx].transfer_attributes_from(new_block) - - # === Set uncertain params in new block(s) to correct value(s) - for j, p in enumerate(m.scenarios[i, idx].util.uncertain_params): - p.set_value(violations[j]) - - return - - -def get_master_dr_degree(model_data, config): +def get_master_dr_degree(master_data): """ Determine DR polynomial degree to enforce based on - the iteration number. + the iteration number and/or the presence of first-stage + equality constraints that depend on the decision rule variables. + + If there are first-stage equality constraints that depend + on the decision rule variables, such as equalities derived + from coefficient matching or discretization of state-variable + independent equalities, then the degree is set to + ``config.decision_rule_order``. - Currently, the degree is set to: + Otherwise, the degree is set to: - 0 if iteration number is 0 - min(1, config.decision_rule_order) if iteration number - otherwise does not exceed number of uncertain parameters + otherwise does not exceed number of effective + uncertain parameters - min(2, config.decision_rule_order) otherwise. Parameters ---------- - model_data : MasterProblemData + master_data : MasterProblemData Master problem data. - config : ConfigDict - PyROS solver options. Returns ------- int DR order, or polynomial degree, to enforce. """ - if model_data.iteration == 0: + nom_scenario_blk = master_data.master_model.scenarios[0, 0] + if nom_scenario_blk.first_stage.dr_dependent_equality_cons: + return master_data.config.decision_rule_order + + if master_data.iteration == 0: return 0 - elif model_data.iteration <= len(config.uncertain_params): - return min(1, config.decision_rule_order) + elif master_data.iteration <= len(nom_scenario_blk.effective_uncertain_params): + return min(1, master_data.config.decision_rule_order) else: - return min(2, config.decision_rule_order) + return min(2, master_data.config.decision_rule_order) -def higher_order_decision_rule_efficiency(model_data, config): +def higher_order_decision_rule_efficiency(master_data): """ Enforce DR coefficient variable efficiencies for master problem-like formulation. Parameters ---------- - model_data : MasterProblemData + master_data : MasterProblemData Master problem data. - config : ConfigDict - PyROS solver options. Note ---- @@ -673,186 +646,184 @@ def higher_order_decision_rule_efficiency(model_data, config): to be set depends on the iteration number; see ``get_master_dr_degree``. """ - order_to_enforce = get_master_dr_degree(model_data, config) + order_to_enforce = get_master_dr_degree(master_data) enforce_dr_degree( - blk=model_data.master_model.scenarios[0, 0], - config=config, + working_blk=master_data.master_model.scenarios[0, 0], + config=master_data.config, degree=order_to_enforce, ) -def solver_call_master(model_data, config, solver, solve_data): +def log_master_solve_results(master_model, config, results, desc="Optimized"): + """ + Log master problem solve results. + """ + if config.objective_focus == ObjectiveType.worst_case: + eval_obj_blk_idx = max( + master_model.scenarios.keys(), + key=lambda idx: value(master_model.scenarios[idx].second_stage_objective), + ) + else: + eval_obj_blk_idx = (0, 0) + + eval_obj_blk = master_model.scenarios[eval_obj_blk_idx] + config.progress_logger.debug(f" {desc.capitalize()} master objective breakdown:") + config.progress_logger.debug( + f" First-stage objective: {value(eval_obj_blk.first_stage_objective)}" + ) + config.progress_logger.debug( + f" Second-stage objective: {value(eval_obj_blk.second_stage_objective)}" + ) + master_obj = eval_obj_blk.full_objective + config.progress_logger.debug(f" Overall Objective: {value(master_obj)}") + config.progress_logger.debug( + f" Termination condition: {results.solver.termination_condition}" + ) + config.progress_logger.debug( + f" Solve time: {getattr(results.solver, TIC_TOC_SOLVE_TIME_ATTR)}s" + ) + + +def process_termination_condition_master_problem(config, results): """ - Invoke subsolver(s) on PyROS master problem. + Process master problem solve termination condition. Parameters ---------- - model_data : MasterProblemData - Container for current master problem and related data. config : ConfigDict - PyROS solver settings. - solver : solver type - Primary subordinate optimizer with which to solve - the master problem. This may be a local or global - NLP solver. - solve_data : MasterResult - Master problem results object. May be empty or contain - master feasibility problem results. + PyROS solver options. + results : SolverResults + Solver results. Returns ------- - master_soln : MasterResult - Master problem results object, containing master - model and subsolver results. + optimality_acceptable : bool + True if problem was solved to an acceptable optimality target, + False otherwise. + infeasible : bool + True if problem was found to be infeasible, False otherwise. + + Raises + ------ + NotImplementedError + If a particular solver termination is not supported by + PyROS. """ - nlp_model = model_data.master_model - master_soln = solve_data - solver_term_cond_dict = {} + locally_acceptable = [tc.optimal, tc.locallyOptimal, tc.globallyOptimal] + globally_acceptable = [tc.optimal, tc.globallyOptimal] + robust_infeasible = [tc.infeasible] + try_backups = [ + tc.feasible, + tc.maxTimeLimit, + tc.maxIterations, + tc.maxEvaluations, + tc.minStepLength, + tc.minFunctionValue, + tc.other, + tc.solverFailure, + tc.internalSolverError, + tc.error, + tc.unbounded, + tc.infeasibleOrUnbounded, + tc.invalidProblem, + tc.intermediateNonInteger, + tc.noSolution, + tc.unknown, + ] + + termination_condition = results.solver.termination_condition + optimality_acceptable = ( + (termination_condition in globally_acceptable) + if config.solve_master_globally + else (termination_condition in locally_acceptable) + ) + infeasible = termination_condition in robust_infeasible + try_backup_solver = termination_condition in try_backups + + unsupported_termination = not ( + optimality_acceptable or try_backup_solver or infeasible + ) + if unsupported_termination: + solve_type = "global" if config.solve_master_globally else "local" + raise NotImplementedError( + f"Processing of termination condition {termination_condition} " + f"for attempt at {solve_type} solution of master problem " + "is currently not supported by PyROS. " + "Please report this issue to the PyROS developers." + ) + + return optimality_acceptable, infeasible + + +def solver_call_master(master_data): + """ + Invoke subsolver(s) on PyROS master problem, + and update the MasterResults object accordingly. + + Parameters + ---------- + master_data : MasterProblemData + Container for current master problem and related data. + + Returns + ------- + master_soln : MasterResults + Master solution results object. + """ + config = master_data.config + master_model = master_data.master_model + master_soln = MasterResults( + master_model=master_model, pyros_termination_condition=None + ) if config.solve_master_globally: - solvers = [solver] + config.backup_global_solvers + solvers = [config.global_solver] + config.backup_global_solvers else: - solvers = [solver] + config.backup_local_solvers - - higher_order_decision_rule_efficiency(model_data=model_data, config=config) + solvers = [config.local_solver] + config.backup_local_solvers solve_mode = "global" if config.solve_master_globally else "local" config.progress_logger.debug("Solving master problem") - timer = TicTocTimer() + higher_order_decision_rule_efficiency(master_data) + for idx, opt in enumerate(solvers): if idx > 0: config.progress_logger.warning( f"Invoking backup solver {opt!r} " f"(solver {idx + 1} of {len(solvers)}) for " - f"master problem of iteration {model_data.iteration}." - ) - orig_setting, custom_setting_present = adjust_solver_time_settings( - model_data.timing, opt, config - ) - model_data.timing.start_timer("main.master") - timer.tic(msg=None) - try: - results = opt.solve( - nlp_model, - tee=config.tee, - load_solutions=False, - symbolic_solver_labels=True, + f"master problem of iteration {master_data.iteration}." ) - except ApplicationError: - # account for possible external subsolver errors - # (such as segmentation faults, function evaluation - # errors, etc.) - config.progress_logger.error( + results = call_solver( + model=master_model, + solver=opt, + config=config, + timing_obj=master_data.timing, + timer_name="main.master", + err_msg=( f"Optimizer {repr(opt)} ({idx + 1} of {len(solvers)}) " "encountered exception attempting to " - f"solve master problem in iteration {model_data.iteration}" - ) - raise - else: - setattr(results.solver, TIC_TOC_SOLVE_TIME_ATTR, timer.toc(msg=None)) - model_data.timing.stop_timer("main.master") - finally: - revert_solver_max_time_adjustment( - solver, orig_setting, custom_setting_present, config - ) - - optimal_termination = check_optimal_termination(results) - infeasible = results.solver.termination_condition == tc.infeasible - - if optimal_termination: - nlp_model.solutions.load_from(results) + f"solve master problem in iteration {master_data.iteration}" + ), + ) - # record master problem termination conditions - # for this particular subsolver - # pyros termination condition is determined later in the - # algorithm - solver_term_cond_dict[str(opt)] = str(results.solver.termination_condition) - master_soln.termination_condition = results.solver.termination_condition - master_soln.pyros_termination_condition = None - (try_backup, _) = master_soln.master_subsolver_results = ( + master_soln.master_results_list.append(results) + optimality_acceptable, infeasible = ( process_termination_condition_master_problem(config=config, results=results) ) - - master_soln.nominal_block = nlp_model.scenarios[0, 0] - master_soln.results = results - master_soln.master_model = nlp_model - - # if model was solved successfully, update/record the results - # (nominal block DOF variable and objective values) - if not try_backup and not infeasible: - master_soln.fsv_vals = list( - v.value for v in nlp_model.scenarios[0, 0].util.first_stage_variables - ) - if config.objective_focus is ObjectiveType.nominal: - master_soln.ssv_vals = list( - v.value - for v in nlp_model.scenarios[0, 0].util.second_stage_variables - ) - master_soln.second_stage_objective = value( - nlp_model.scenarios[0, 0].second_stage_objective - ) - else: - idx = max(nlp_model.scenarios.keys())[0] - master_soln.ssv_vals = list( - v.value - for v in nlp_model.scenarios[idx, 0].util.second_stage_variables - ) - master_soln.second_stage_objective = value( - nlp_model.scenarios[idx, 0].second_stage_objective - ) - master_soln.first_stage_objective = value( - nlp_model.scenarios[0, 0].first_stage_objective - ) - - # debugging: log breakdown of master objective - if config.objective_focus == ObjectiveType.worst_case: - eval_obj_blk_idx = max( - nlp_model.scenarios.keys(), - key=lambda idx: value( - nlp_model.scenarios[idx].second_stage_objective - ), - ) - else: - eval_obj_blk_idx = (0, 0) - - eval_obj_blk = nlp_model.scenarios[eval_obj_blk_idx] - config.progress_logger.debug(" Optimized master objective breakdown:") - config.progress_logger.debug( - f" First-stage objective: {value(eval_obj_blk.first_stage_objective)}" - ) - config.progress_logger.debug( - f" Second-stage objective: {value(eval_obj_blk.second_stage_objective)}" - ) - master_obj = ( - eval_obj_blk.first_stage_objective + eval_obj_blk.second_stage_objective - ) - config.progress_logger.debug(f" Objective: {value(master_obj)}") - config.progress_logger.debug( - f" Termination condition: {results.solver.termination_condition}" - ) - config.progress_logger.debug( - f" Solve time: {getattr(results.solver, TIC_TOC_SOLVE_TIME_ATTR)}s" + time_out = check_time_limit_reached(master_data.timing, config) + + if optimality_acceptable: + master_model.solutions.load_from(results) + log_master_solve_results(master_model, config, results) + if time_out: + master_soln.pyros_termination_condition = pyrosTerminationCondition.time_out + if infeasible: + master_soln.pyros_termination_condition = ( + pyrosTerminationCondition.robust_infeasible ) - master_soln.nominal_block = nlp_model.scenarios[0, 0] - master_soln.results = results - master_soln.master_model = nlp_model - - # if PyROS time limit exceeded, exit loop and return solution - elapsed = get_main_elapsed_time(model_data.timing) - if config.time_limit: - if elapsed >= config.time_limit: - try_backup = False - master_soln.master_subsolver_results = ( - None, - pyrosTerminationCondition.time_out, - ) - master_soln.pyros_termination_condition = ( - pyrosTerminationCondition.time_out - ) - - if not try_backup: + final_result_established = optimality_acceptable or time_out or infeasible + if final_result_established: return master_soln # all solvers have failed to return an acceptable status. @@ -868,13 +839,13 @@ def solver_call_master(model_data, config, solver, solve_data): ( config.uncertainty_set.type + "_" - + model_data.original.name + + master_data.original_model_name + "_master_" - + str(model_data.iteration) + + str(master_data.iteration) + ".bar" ), ) - nlp_model.write( + master_model.write( output_problem_path, io_options={'symbolic_solver_labels': True} ) serialization_msg = ( @@ -883,24 +854,30 @@ def solver_call_master(model_data, config, solver, solve_data): ) deterministic_model_qual = ( - " (i.e., the deterministic model)" if model_data.iteration == 0 else "" + " (i.e., the deterministic model)" if master_data.iteration == 0 else "" ) deterministic_msg = ( ( - " Please ensure your deterministic model " + " Please ensure that your deterministic model, " + "subject to the nominal uncertain parameter realization " + "you have provided, " f"is solvable by at least one of the subordinate {solve_mode} " "optimizers provided." ) - if model_data.iteration == 0 + if master_data.iteration == 0 else "" ) + master_soln.pyros_termination_condition = pyrosTerminationCondition.subsolver_error + subsolver_termination_conditions = [ + res.solver.termination_condition for res in master_soln.master_results_list + ] config.progress_logger.warning( f"Could not successfully solve master problem of iteration " - f"{model_data.iteration}{deterministic_model_qual} with any of the " + f"{master_data.iteration}{deterministic_model_qual} with any of the " f"provided subordinate {solve_mode} optimizers. " f"(Termination statuses: " - f"{[term_cond for term_cond in solver_term_cond_dict.values()]}.)" + f"{[term_cond for term_cond in subsolver_termination_conditions]}.)" f"{deterministic_msg}" f"{serialization_msg}" ) @@ -908,44 +885,78 @@ def solver_call_master(model_data, config, solver, solve_data): return master_soln -def solve_master(model_data, config): +def solve_master(master_data): """ - Solve the master problem + Solve the master problem. + + Returns + ------- + master_soln : MasterResults + Master problem solve results. """ - master_soln = MasterResult() - - # no master feas problem for iteration 0 - if model_data.iteration > 0: - results = solve_master_feasibility_problem(model_data, config) - master_soln.feasibility_problem_results = results - - # if pyros time limit reached, load time out status - # to master results and return to caller - elapsed = get_main_elapsed_time(model_data.timing) - if config.time_limit: - if elapsed >= config.time_limit: - # load master model - master_soln.master_model = model_data.master_model - master_soln.nominal_block = model_data.master_model.scenarios[0, 0] - - # empty results object, with master solve time of zero - master_soln.results = SolverResults() - setattr(master_soln.results.solver, TIC_TOC_SOLVE_TIME_ATTR, 0) - - # PyROS time out status - master_soln.pyros_termination_condition = ( - pyrosTerminationCondition.time_out - ) - master_soln.master_subsolver_results = ( - None, - pyrosTerminationCondition.time_out, - ) - return master_soln + feasibility_problem_results = None + time_out_after_feasibility = False + if master_data.iteration > 0: + feasibility_problem_results = solve_master_feasibility_problem(master_data) + time_out_after_feasibility = check_time_limit_reached( + master_data.timing, master_data.config + ) - solver = ( - config.global_solver if config.solve_master_globally else config.local_solver - ) + if time_out_after_feasibility: + master_soln = MasterResults( + master_model=master_data.master_model, + feasibility_problem_results=feasibility_problem_results, + master_results_list=None, + pyros_termination_condition=pyrosTerminationCondition.time_out, + ) + else: + master_soln = solver_call_master(master_data) + master_soln.feasibility_problem_results = feasibility_problem_results - return solver_call_master( - model_data=model_data, config=config, solver=solver, solve_data=master_soln - ) + return master_soln + + +class MasterProblemData: + """ + Container for objects pertaining to the PyROS master problem. + + Parameters + ---------- + model_data : ModelData + PyROS model data object, equipped with the + fully preprocessed working model. + + Attributes + ---------- + master_model : BlockData + Master problem model object. + original_model_name : str + Name of the user-provided deterministic model object. + iteration : int + Index of the current PyROS cutting set iteration. + timing : TimingData + Main timer for the current problem being solved. + config : ConfigDict + PyROS solver options. + """ + + def __init__(self, model_data): + """Initialize self (see docstring).""" + self.master_model = construct_initial_master_problem(model_data) + # we track the original model name for serialization purposes + self.original_model_name = model_data.original_model.name + self.iteration = 0 + self.timing = model_data.timing + self.config = model_data.config + + def solve_master(self): + """ + Solve the master problem. + """ + return solve_master(self) + + def solve_dr_polishing(self): + """ + Solve the DR polishing problem. + """ + return minimize_dr_vars(self) diff --git a/pyomo/contrib/pyros/pyros.py b/pyomo/contrib/pyros/pyros.py index 6de42d7299e..ed4247ff89a 100644 --- a/pyomo/contrib/pyros/pyros.py +++ b/pyomo/contrib/pyros/pyros.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,41 +10,30 @@ # ___________________________________________________________________________ # pyros.py: Generalized Robust Cutting-Set Algorithm for Pyomo +from datetime import datetime, timezone import logging + from pyomo.common.config import document_kwargs_from_configdict -from pyomo.common.collections import Bunch -from pyomo.core.base.block import Block from pyomo.core.expr import value -from pyomo.core.base.var import Var -from pyomo.core.base.objective import Objective -from pyomo.contrib.pyros.util import time_code -from pyomo.common.modeling import unique_component_name from pyomo.opt import SolverFactory -from pyomo.contrib.pyros.config import pyros_config + +from pyomo.contrib.pyros.config import pyros_config, logger_domain +from pyomo.contrib.pyros.pyros_algorithm_methods import ROSolver_iterative_solve +from pyomo.contrib.pyros.solve_data import ROSolveResults from pyomo.contrib.pyros.util import ( - recast_to_min_obj, - add_decision_rule_constraints, - add_decision_rule_variables, load_final_solution, pyrosTerminationCondition, - ObjectiveType, - identify_objective_functions, validate_pyros_inputs, - transform_to_standard_form, - turn_bounds_to_constraints, - replace_uncertain_bounds_with_constraints, + log_model_statistics, IterationLogRecord, setup_pyros_logger, + time_code, TimingData, + ModelData, ) -from pyomo.contrib.pyros.solve_data import ROSolveResults -from pyomo.contrib.pyros.pyros_algorithm_methods import ROSolver_iterative_solve -from pyomo.core.base import Constraint - -from datetime import datetime -__version__ = "1.2.10" +__version__ = "1.3.7" default_pyros_solver_logger = setup_pyros_logger() @@ -72,7 +61,14 @@ def _get_pyomo_version_info(): ] try: commit_hash = ( - subprocess.check_output(commit_hash_command_args).decode("ascii").strip() + subprocess.check_output( + commit_hash_command_args, + # suppress git error if Pyomo installation + # is not a git repo + stderr=subprocess.DEVNULL, + ) + .decode("ascii") + .strip() ) except subprocess.CalledProcessError: commit_hash = "unknown" @@ -144,7 +140,8 @@ def _log_intro(self, logger, **log_kwargs): logger.log( msg=( f"{' ' * len('PyROS:')} " - f"Invoked at UTC {datetime.utcnow().isoformat()}" + "Invoked at UTC " + f"{datetime.now(timezone.utc).isoformat()}" ), **log_kwargs, ) @@ -262,6 +259,8 @@ def _resolve_and_validate_pyros_args(self, model, **kwds): ------- config : ConfigDict Standardized arguments. + user_var_partitioning : util.VarPartitioning + User-based partitioning of the in-scope model variables. Note ---- @@ -276,9 +275,9 @@ def _resolve_and_validate_pyros_args(self, model, **kwds): """ config = self.CONFIG(kwds.pop("options", {})) config = config(kwds) - state_vars = validate_pyros_inputs(model, config) + user_var_partitioning = validate_pyros_inputs(model, config) - return config, state_vars + return config, user_var_partitioning @document_kwargs_from_configdict( config=CONFIG, @@ -308,10 +307,12 @@ def solve( First-stage model variables (or design variables). second_stage_variables: VarData, Var, or iterable of VarData/Var Second-stage model variables (or control variables). - uncertain_params: ParamData, Param, or iterable of ParamData/Param + uncertain_params: (iterable of) Param, Var, ParamData, or VarData Uncertain model parameters. - The `mutable` attribute for all uncertain parameter objects - must be set to True. + Of every constituent `Param` object, + the `mutable` attribute must be set to True. + All constituent `Var`/`VarData` objects should be + fixed. uncertainty_set: UncertaintySet Uncertainty set against which the solution(s) returned will be confirmed to be robust. @@ -330,110 +331,53 @@ def solve( Summary of PyROS termination outcome. """ - kwds.update( - dict( - first_stage_variables=first_stage_variables, - second_stage_variables=second_stage_variables, - uncertain_params=uncertain_params, - uncertainty_set=uncertainty_set, - local_solver=local_solver, - global_solver=global_solver, - ) - ) - config, state_vars = self._resolve_and_validate_pyros_args(model, **kwds) - - # === Create data containers - model_data = ROSolveResults() - model_data.timing = Bunch() - - # === Start timer, run the algorithm - model_data.timing = TimingData() + model_data = ModelData(original_model=model, timing=TimingData(), config=None) with time_code( timing_data_obj=model_data.timing, code_block_name="main", is_main_timer=True, ): - # output intro and disclaimer - self._log_intro(logger=config.progress_logger, level=logging.INFO) - self._log_disclaimer(logger=config.progress_logger, level=logging.INFO) + kwds.update( + dict( + first_stage_variables=first_stage_variables, + second_stage_variables=second_stage_variables, + uncertain_params=uncertain_params, + uncertainty_set=uncertainty_set, + local_solver=local_solver, + global_solver=global_solver, + ) + ) + + # we want to log the intro and disclaimer in + # advance of assembling the config. + # this helps clarify to the user that any + # messages logged during assembly of the config + # were, in fact, logged after PyROS was initiated + progress_logger = logger_domain( + kwds.get( + "progress_logger", + kwds.get("options", dict()).get( + "progress_logger", default_pyros_solver_logger + ), + ) + ) + self._log_intro(logger=progress_logger, level=logging.INFO) + self._log_disclaimer(logger=progress_logger, level=logging.INFO) + + config, user_var_partitioning = self._resolve_and_validate_pyros_args( + model, **kwds + ) self._log_config( logger=config.progress_logger, config=config, exclude_options=None, level=logging.INFO, ) + model_data.config = config - # begin preprocessing config.progress_logger.info("Preprocessing...") model_data.timing.start_timer("main.preprocessing") - - # === A block to hold list-type data to make cloning easy - util = Block(concrete=True) - util.first_stage_variables = config.first_stage_variables - util.second_stage_variables = config.second_stage_variables - util.state_vars = state_vars - util.uncertain_params = config.uncertain_params - - model_data.util_block = unique_component_name(model, 'util') - model.add_component(model_data.util_block, util) - # Note: model.component(model_data.util_block) is util - - # === Leads to a logger warning here for inactive obj when cloning - model_data.original_model = model - # === For keeping track of variables after cloning - cname = unique_component_name(model_data.original_model, 'tmp_var_list') - src_vars = list(model_data.original_model.component_data_objects(Var)) - setattr(model_data.original_model, cname, src_vars) - model_data.working_model = model_data.original_model.clone() - - # identify active objective function - # (there should only be one at this point) - # recast to minimization if necessary - active_objs = list( - model_data.working_model.component_data_objects( - Objective, active=True, descend_into=True - ) - ) - assert len(active_objs) == 1 - active_obj = active_objs[0] - active_obj_original_sense = active_obj.sense - recast_to_min_obj(model_data.working_model, active_obj) - - # === Determine first and second-stage objectives - identify_objective_functions(model_data.working_model, active_obj) - active_obj.deactivate() - - # === Put model in standard form - transform_to_standard_form(model_data.working_model) - - # === Replace variable bounds depending on uncertain params with - # explicit inequality constraints - replace_uncertain_bounds_with_constraints( - model_data.working_model, model_data.working_model.util.uncertain_params - ) - - # === Add decision rule information - add_decision_rule_variables(model_data, config) - add_decision_rule_constraints(model_data, config) - - # === Move bounds on control variables to explicit ineq constraints - wm_util = model_data.working_model - - # cast bounds on second-stage and state variables to - # explicit constraints for separation objectives - for c in model_data.working_model.util.second_stage_variables: - turn_bounds_to_constraints(c, wm_util, config) - for c in model_data.working_model.util.state_vars: - turn_bounds_to_constraints(c, wm_util, config) - - # === Make control_variable_bounds array - wm_util.ssv_bounds = [] - for c in model_data.working_model.component_data_objects( - Constraint, descend_into=True - ): - if "bound_con" in c.name: - wm_util.ssv_bounds.append(c) - + robust_infeasible = model_data.preprocess(user_var_partitioning) model_data.timing.stop_timer("main.preprocessing") preprocessing_time = model_data.timing.get_total_time("main.preprocessing") config.progress_logger.info( @@ -441,46 +385,43 @@ def solve( f"{preprocessing_time:.3f}s." ) - # === Solve and load solution into model - pyros_soln, final_iter_separation_solns = ROSolver_iterative_solve( - model_data, config - ) - IterationLogRecord.log_header_rule(config.progress_logger.info) + log_model_statistics(model_data) + # === Solve and load solution into model return_soln = ROSolveResults() - if pyros_soln is not None and final_iter_separation_solns is not None: - if config.load_solution and ( - pyros_soln.pyros_termination_condition - is pyrosTerminationCondition.robust_optimal - or pyros_soln.pyros_termination_condition - is pyrosTerminationCondition.robust_feasible - ): - load_final_solution(model_data, pyros_soln.master_soln, config) - - # account for sense of the original model objective - # when reporting the final PyROS (master) objective, - # since maximization objective is changed to - # minimization objective during preprocessing - if config.objective_focus == ObjectiveType.nominal: - return_soln.final_objective_value = ( - active_obj_original_sense - * value(pyros_soln.master_soln.master_model.obj) + if not robust_infeasible: + pyros_soln = ROSolver_iterative_solve(model_data) + IterationLogRecord.log_header_rule(config.progress_logger.info) + + termination_acceptable = pyros_soln.pyros_termination_condition in { + pyrosTerminationCondition.robust_optimal, + pyrosTerminationCondition.robust_feasible, + } + if termination_acceptable: + load_final_solution( + model_data=model_data, + master_soln=pyros_soln.master_results, + original_user_var_partitioning=user_var_partitioning, ) - elif config.objective_focus == ObjectiveType.worst_case: + + # get the most recent master objective, if available + return_soln.final_objective_value = None + master_epigraph_obj_value = value( + pyros_soln.master_results.master_model.epigraph_obj, exception=False + ) + if master_epigraph_obj_value is not None: + # account for sense of the original model objective + # when reporting the final PyROS (master) objective, + # since maximization objective is changed to + # minimization objective during preprocessing return_soln.final_objective_value = ( - active_obj_original_sense - * value(pyros_soln.master_soln.master_model.zeta) + model_data.active_obj_original_sense * master_epigraph_obj_value ) + return_soln.pyros_termination_condition = ( pyros_soln.pyros_termination_condition ) - return_soln.iterations = pyros_soln.total_iters + 1 - - # === Remove util block - model.del_component(model_data.util_block) - - del pyros_soln.util_block - del pyros_soln.working_model + return_soln.iterations = pyros_soln.iterations else: return_soln.final_objective_value = None return_soln.pyros_termination_condition = ( diff --git a/pyomo/contrib/pyros/pyros_algorithm_methods.py b/pyomo/contrib/pyros/pyros_algorithm_methods.py index 45b652447ff..f944a310052 100644 --- a/pyomo/contrib/pyros/pyros_algorithm_methods.py +++ b/pyomo/contrib/pyros/pyros_algorithm_methods.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,599 +9,166 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -''' -Methods for the execution of the grcs algorithm -''' +""" +Methods for execution of the main PyROS cutting set algorithm. +""" -from pyomo.core.base import Objective, ConstraintList, Var, Constraint, Block -from pyomo.opt.results import TerminationCondition -from pyomo.contrib.pyros import master_problem_methods, separation_problem_methods -from pyomo.contrib.pyros.solve_data import SeparationProblemData, MasterResult -from pyomo.contrib.pyros.uncertainty_sets import Geometry +from collections import namedtuple + +from pyomo.common.dependencies import numpy as np +from pyomo.common.collections import ComponentMap +from pyomo.core.base import value + +import pyomo.contrib.pyros.master_problem_methods as mp_methods +import pyomo.contrib.pyros.separation_problem_methods as sp_methods from pyomo.contrib.pyros.util import ( + check_time_limit_reached, ObjectiveType, - get_time_from_solver, pyrosTerminationCondition, IterationLogRecord, + get_main_elapsed_time, + get_dr_var_to_monomial_map, ) -from pyomo.contrib.pyros.util import get_main_elapsed_time, coefficient_matching -from pyomo.core.base import value -from pyomo.common.collections import ComponentSet, ComponentMap -from pyomo.core.base.var import _VarData as VarData -from itertools import chain -from pyomo.common.dependencies import numpy as np -def update_grcs_solve_data( - pyros_soln, term_cond, nominal_data, timing_data, separation_data, master_soln, k -): - ''' - This function updates the results data container object to return to the user so that they have all pertinent - information from the PyROS run. - :param grcs_soln: PyROS solution data container object - :param term_cond: PyROS termination condition - :param nominal_data: Contains information on all nominal data (var values, objective) - :param timing_data: Contains timing information on subsolver calls in PyROS - :param separation_data: Separation model data container - :param master_problem_subsolver_statuses: All master problem sub-solver termination conditions from the PyROS run - :param separation_problem_subsolver_statuses: All separation problem sub-solver termination conditions from the PyROS run - :param k: Iteration counter - :return: None - ''' - pyros_soln.pyros_termination_condition = term_cond - pyros_soln.total_iters = k - pyros_soln.nominal_data = nominal_data - pyros_soln.timing_data = timing_data - pyros_soln.separation_data = separation_data - pyros_soln.master_soln = master_soln - - return - - -def get_dr_var_to_scaled_expr_map( - decision_rule_eqns, second_stage_vars, uncertain_params, decision_rule_vars -): +class GRCSResults: """ - Generate mapping from decision rule variables - to their terms in a model's DR expression. + Cutting set RO algorithm solve results. + + Attributes + ---------- + master_results : MasterResults + Solve results for most recent master problem. + separation_results : SeparationResults or None + Solve results for separation problem(s) of last iteration. + If the separation subroutine was not invoked in the last + iteration, then None. + pyros_termination_condition : pyrosTerminationCondition + PyROS termination condition. + iterations : int + Number of iterations required. """ - var_to_scaled_expr_map = ComponentMap() - ssv_dr_eq_zip = zip(second_stage_vars, decision_rule_eqns) - for ssv_idx, (ssv, dr_eq) in enumerate(ssv_dr_eq_zip): - for term in dr_eq.body.args: - is_ssv_term = ( - isinstance(term.args[0], int) - and term.args[0] == -1 - and isinstance(term.args[1], VarData) - ) - if not is_ssv_term: - dr_var = term.args[1] - var_to_scaled_expr_map[dr_var] = term - return var_to_scaled_expr_map + def __init__( + self, + master_results, + separation_results, + pyros_termination_condition, + iterations, + ): + self.master_results = master_results + self.separation_results = separation_results + self.pyros_termination_condition = pyros_termination_condition + self.iterations = iterations -def evaluate_and_log_component_stats(model_data, separation_model, config): +def _evaluate_shift(current, prev, initial, norm=None): + if current.size == 0: + return None + else: + normalizers = np.max( + np.vstack((np.ones(initial.size), np.abs(initial))), axis=0 + ) + return np.max(np.abs(current - prev) / normalizers) + + +VariableValueData = namedtuple( + "VariableValueData", + ("first_stage_variables", "second_stage_variables", "decision_rule_monomials"), +) + + +def get_variable_value_data(working_blk, dr_var_to_monomial_map): """ - Evaluate and log model component statistics. + Get variable value data. """ - IterationLogRecord.log_header_rule(config.progress_logger.info) - config.progress_logger.info("Model statistics:") - # print model statistics - dr_var_set = ComponentSet( - chain( - *tuple( - indexed_dr_var.values() - for indexed_dr_var in model_data.working_model.util.decision_rule_vars - ) - ) - ) - first_stage_vars = [ - var - for var in model_data.working_model.util.first_stage_variables - if var not in dr_var_set - ] - - # account for epigraph constraint - sep_model_epigraph_con = getattr(separation_model, "epigraph_constr", None) - has_epigraph_con = sep_model_epigraph_con is not None - - num_fsv = len(first_stage_vars) - num_ssv = len(model_data.working_model.util.second_stage_variables) - num_sv = len(model_data.working_model.util.state_vars) - num_dr_vars = len(dr_var_set) - num_vars = int(has_epigraph_con) + num_fsv + num_ssv + num_sv + num_dr_vars - - num_uncertain_params = len(model_data.working_model.util.uncertain_params) - - eq_cons = [ - con - for con in model_data.working_model.component_data_objects( - Constraint, active=True - ) - if con.equality - ] - dr_eq_set = ComponentSet( - chain( - *tuple( - indexed_dr_eq.values() - for indexed_dr_eq in model_data.working_model.util.decision_rule_eqns - ) - ) - ) - num_eq_cons = len(eq_cons) - num_dr_cons = len(dr_eq_set) - num_coefficient_matching_cons = len( - getattr(model_data.working_model, "coefficient_matching_constraints", []) - ) - num_other_eq_cons = num_eq_cons - num_dr_cons - num_coefficient_matching_cons - - # get performance constraints as referenced in the separation - # model object - new_sep_con_map = separation_model.util.map_new_constraint_list_to_original_con - perf_con_set = ComponentSet( - new_sep_con_map.get(con, con) - for con in separation_model.util.performance_constraints - ) - is_epigraph_con_first_stage = ( - has_epigraph_con and sep_model_epigraph_con not in perf_con_set - ) - working_model_perf_con_set = ComponentSet( - model_data.working_model.find_component(new_sep_con_map.get(con, con)) - for con in separation_model.util.performance_constraints - if con is not None - ) + ep = working_blk.effective_var_partitioning - num_perf_cons = len(separation_model.util.performance_constraints) - num_fsv_bounds = sum( - int(var.lower is not None) + int(var.upper is not None) - for var in first_stage_vars - ) - ineq_con_set = [ - con - for con in model_data.working_model.component_data_objects( - Constraint, active=True - ) - if not con.equality - ] - num_fsv_ineqs = ( - num_fsv_bounds - + len([con for con in ineq_con_set if con not in working_model_perf_con_set]) - + is_epigraph_con_first_stage + first_stage_data = ComponentMap( + (var, var.value) for var in ep.first_stage_variables ) - num_ineq_cons = len(ineq_con_set) + has_epigraph_con + num_fsv_bounds - - config.progress_logger.info(f"{' Number of variables'} : {num_vars}") - config.progress_logger.info(f"{' Epigraph variable'} : {int(has_epigraph_con)}") - config.progress_logger.info(f"{' First-stage variables'} : {num_fsv}") - config.progress_logger.info(f"{' Second-stage variables'} : {num_ssv}") - config.progress_logger.info(f"{' State variables'} : {num_sv}") - config.progress_logger.info(f"{' Decision rule variables'} : {num_dr_vars}") - config.progress_logger.info( - f"{' Number of uncertain parameters'} : {num_uncertain_params}" + second_stage_data = ComponentMap( + (var, var.value) for var in ep.second_stage_variables ) - config.progress_logger.info( - f"{' Number of constraints'} : " f"{num_ineq_cons + num_eq_cons}" + dr_term_data = ComponentMap( + (dr_var, value(monomial)) + for dr_var, monomial in get_dr_var_to_monomial_map(working_blk).items() ) - config.progress_logger.info(f"{' Equality constraints'} : {num_eq_cons}") - config.progress_logger.info( - f"{' Coefficient matching constraints'} : " - f"{num_coefficient_matching_cons}" - ) - config.progress_logger.info(f"{' Decision rule equations'} : {num_dr_cons}") - config.progress_logger.info( - f"{' All other equality constraints'} : " f"{num_other_eq_cons}" - ) - config.progress_logger.info(f"{' Inequality constraints'} : {num_ineq_cons}") - config.progress_logger.info( - f"{' First-stage inequalities (incl. certain var bounds)'} : " - f"{num_fsv_ineqs}" - ) - config.progress_logger.info( - f"{' Performance constraints (incl. var bounds)'} : {num_perf_cons}" + + return VariableValueData( + first_stage_variables=first_stage_data, + second_stage_variables=second_stage_data, + decision_rule_monomials=dr_term_data, ) -def evaluate_first_stage_var_shift( - current_master_fsv_vals, previous_master_fsv_vals, first_iter_master_fsv_vals -): +def evaluate_variable_shifts(current_var_data, previous_var_data, initial_var_data): """ - Evaluate first-stage variable "shift": the maximum relative - difference between first-stage variable values from the current - and previous master iterations. - - Parameters - ---------- - current_master_fsv_vals : ComponentMap - First-stage variable values from the current master - iteration. - previous_master_fsv_vals : ComponentMap - First-stage variable values from the previous master - iteration. - first_iter_master_fsv_vals : ComponentMap - First-stage variable values from the first master - iteration. - - Returns - ------- - None - Returned only if `current_master_fsv_vals` is empty, - which should occur only if the problem has no first-stage - variables. - float - The maximum relative difference - Returned only if `current_master_fsv_vals` is not empty. + Evaluate relative changes in the variable values + across solutions to a working model block, such as the + nominal master block. """ - if not current_master_fsv_vals: - # there are no first-stage variables - return None + if previous_var_data is None: + return None, None, None else: - return max( - abs(current_master_fsv_vals[var] - previous_master_fsv_vals[var]) - / max((abs(first_iter_master_fsv_vals[var]), 1)) - for var in previous_master_fsv_vals - ) + var_shifts = [] + for attr in current_var_data._fields: + var_shifts.append( + _evaluate_shift( + current=np.array(list(getattr(current_var_data, attr).values())), + prev=np.array(list(getattr(previous_var_data, attr).values())), + initial=np.array(list(getattr(initial_var_data, attr).values())), + ) + ) + return tuple(var_shifts) -def evaluate_second_stage_var_shift( - current_master_nom_ssv_vals, - previous_master_nom_ssv_vals, - first_iter_master_nom_ssv_vals, -): - """ - Evaluate second-stage variable "shift": the maximum relative - difference between second-stage variable values from the current - and previous master iterations as evaluated subject to the - nominal uncertain parameter realization. - Parameters - ---------- - current_master_nom_ssv_vals : ComponentMap - Second-stage variable values from the current master - iteration, evaluated subject to the nominal uncertain - parameter realization. - previous_master_nom_ssv_vals : ComponentMap - Second-stage variable values from the previous master - iteration, evaluated subject to the nominal uncertain - parameter realization. - first_iter_master_nom_ssv_vals : ComponentMap - Second-stage variable values from the first master - iteration, evaluated subject to the nominal uncertain - parameter realization. - - Returns - ------- - None - Returned only if `current_master_nom_ssv_vals` is empty, - which should occur only if the problem has no second-stage - variables. - float - The maximum relative difference. - Returned only if `current_master_nom_ssv_vals` is not empty. +def ROSolver_iterative_solve(model_data): """ - if not current_master_nom_ssv_vals: - return None - else: - return max( - abs(current_master_nom_ssv_vals[ssv] - previous_master_nom_ssv_vals[ssv]) - / max((abs(first_iter_master_nom_ssv_vals[ssv]), 1)) - for ssv in previous_master_nom_ssv_vals - ) - - -def evaluate_dr_var_shift( - current_master_dr_var_vals, - previous_master_dr_var_vals, - first_iter_master_nom_ssv_vals, - dr_var_to_ssv_map, -): - """ - Evaluate decision rule variable "shift": the maximum relative - difference between scaled decision rule (DR) variable expressions - (terms in the DR equations) from the current - and previous master iterations. + Solve an RO problem with the iterative GRCS algorithm. Parameters ---------- - current_master_dr_var_vals : ComponentMap - DR variable values from the current master - iteration. - previous_master_dr_var_vals : ComponentMap - DR variable values from the previous master - iteration. - first_iter_master_nom_ssv_vals : ComponentMap - Second-stage variable values (evaluated subject to the - nominal uncertain parameter realization) - from the first master iteration. - dr_var_to_ssv_map : ComponentMap - Mapping from each DR variable to the - second-stage variable whose value is a function of the - DR variable. + model_data : model data object + Model data object, equipped with the + fully preprocessed working model. Returns ------- - None - Returned only if `current_master_dr_var_vals` is empty, - which should occur only if the problem has no decision rule - (or equivalently, second-stage) variables. - float - The maximum relative difference. - Returned only if `current_master_dr_var_vals` is not empty. + GRCSResults + Iterative solve results. """ - if not current_master_dr_var_vals: - return None - else: - return max( - abs(current_master_dr_var_vals[drvar] - previous_master_dr_var_vals[drvar]) - / max((1, abs(first_iter_master_nom_ssv_vals[dr_var_to_ssv_map[drvar]]))) - for drvar in previous_master_dr_var_vals - ) - - -def ROSolver_iterative_solve(model_data, config): - ''' - GRCS algorithm implementation - :model_data: ROSolveData object with deterministic model information - :config: ConfigBlock for the instance being solved - ''' - - # === The "violation" e.g. uncertain parameter values added to the master problem are nominal in iteration 0 - # User can supply a nominal_uncertain_param_vals if they want to set nominal to a certain point, - # Otherwise, the default init value for the params is used as nominal_uncertain_param_vals - violation = list(p for p in config.nominal_uncertain_param_vals) - - # === Do coefficient matching - constraints = [ - c - for c in model_data.working_model.component_data_objects(Constraint) - if c.equality - and c not in ComponentSet(model_data.working_model.util.decision_rule_eqns) - ] - model_data.working_model.util.h_x_q_constraints = ComponentSet() - for c in constraints: - coeff_matching_success, robust_infeasible = coefficient_matching( - model=model_data.working_model, - constraint=c, - uncertain_params=model_data.working_model.util.uncertain_params, - config=config, - ) - if not coeff_matching_success and not robust_infeasible: - config.progress_logger.error( - f"Equality constraint {c.name!r} cannot be guaranteed to " - "be robustly feasible, given the current partitioning " - "among first-stage, second-stage, and state variables. " - "Consider editing this constraint to reference some " - "second-stage and/or state variable(s)." - ) - raise ValueError("Coefficient matching unsuccessful. See the solver logs.") - elif not coeff_matching_success and robust_infeasible: - config.progress_logger.info( - "PyROS has determined that the model is robust infeasible. " - f"One reason for this is that the equality constraint {c.name} " - "cannot be satisfied against all realizations of uncertainty, " - "given the current partitioning between " - "first-stage, second-stage, and state variables. " - "Consider editing this constraint to reference some (additional) " - "second-stage and/or state variable(s)." - ) - return None, None - else: - pass - - # h(x,q) == 0 becomes h'(x) == 0 - for c in model_data.working_model.util.h_x_q_constraints: - c.deactivate() - - # === Build the master problem and master problem data container object - master_data = master_problem_methods.initial_construct_master(model_data) - - # === If using p_robustness, add ConstraintList for additional constraints - if config.p_robustness: - master_data.master_model.p_robust_constraints = ConstraintList() - - # === Add scenario_0 - master_data.master_model.scenarios[0, 0].transfer_attributes_from( - master_data.original.clone() - ) - if len(master_data.master_model.scenarios[0, 0].util.uncertain_params) != len( - violation - ): - raise ValueError - - # === Set the nominal uncertain parameters to the violation values - for i, v in enumerate(violation): - master_data.master_model.scenarios[0, 0].util.uncertain_params[i].value = v - - # === Add objective function (assuming minimization of costs) with nominal second-stage costs - if config.objective_focus is ObjectiveType.nominal: - master_data.master_model.obj = Objective( - expr=master_data.master_model.scenarios[0, 0].first_stage_objective - + master_data.master_model.scenarios[0, 0].second_stage_objective - ) - elif config.objective_focus is ObjectiveType.worst_case: - # === Worst-case cost objective - master_data.master_model.zeta = Var( - initialize=value( - master_data.master_model.scenarios[0, 0].first_stage_objective - + master_data.master_model.scenarios[0, 0].second_stage_objective, - exception=False, - ) - ) - master_data.master_model.obj = Objective(expr=master_data.master_model.zeta) - master_data.master_model.scenarios[0, 0].epigraph_constr = Constraint( - expr=master_data.master_model.scenarios[0, 0].first_stage_objective - + master_data.master_model.scenarios[0, 0].second_stage_objective - <= master_data.master_model.zeta - ) - master_data.master_model.scenarios[0, 0].util.first_stage_variables.append( - master_data.master_model.zeta - ) - - # === Add deterministic constraints to ComponentSet on original so that these become part of separation model - master_data.original.util.deterministic_constraints = ComponentSet( - c - for c in master_data.original.component_data_objects( - Constraint, descend_into=True - ) - ) - - # === Make separation problem model once before entering the solve loop - separation_model = separation_problem_methods.make_separation_problem( - model_data=master_data, config=config - ) - - evaluate_and_log_component_stats( - model_data=model_data, separation_model=separation_model, config=config - ) - - # === Create separation problem data container object and add information to catalog during solve - separation_data = SeparationProblemData() - separation_data.separation_model = separation_model - separation_data.points_separated = ( - [] - ) # contains last point separated in the separation problem - separation_data.points_added_to_master = [ - config.nominal_uncertain_param_vals - ] # explicitly robust against in master - separation_data.constraint_violations = ( - [] - ) # list of constraint violations for each iteration - separation_data.total_global_separation_solves = ( - 0 # number of times global solve is used - ) - separation_data.timing = master_data.timing # timing object - - # === Keep track of subsolver termination statuses from each iteration - separation_data.separation_problem_subsolver_statuses = [] - - # for discrete set types, keep track of scenarios added to master - if config.uncertainty_set.geometry == Geometry.DISCRETE_SCENARIOS: - separation_data.idxs_of_master_scenarios = [ - config.uncertainty_set.scenarios.index( - tuple(config.nominal_uncertain_param_vals) - ) - ] - else: - separation_data.idxs_of_master_scenarios = None - - # === Nominal information - nominal_data = Block() - nominal_data.nom_fsv_vals = [] - nominal_data.nom_ssv_vals = [] - nominal_data.nom_first_stage_cost = 0 - nominal_data.nom_second_stage_cost = 0 - nominal_data.nom_obj = 0 - - # === Time information - timing_data = Block() - timing_data.total_master_solve_time = 0 - timing_data.total_separation_local_time = 0 - timing_data.total_separation_global_time = 0 - timing_data.total_dr_polish_time = 0 - - dr_var_lists_original = [] - dr_var_lists_polished = [] + config = model_data.config + master_data = mp_methods.MasterProblemData(model_data) + separation_data = sp_methods.SeparationProblemData(model_data) # set up first-stage variable and DR variable sets - master_dr_var_set = ComponentSet( - chain( - *tuple( - indexed_var.values() - for indexed_var in master_data.master_model.scenarios[ - 0, 0 - ].util.decision_rule_vars - ) - ) - ) - master_fsv_set = ComponentSet( - var - for var in master_data.master_model.scenarios[0, 0].util.first_stage_variables - if var not in master_dr_var_set - ) - master_nom_ssv_set = ComponentSet( - master_data.master_model.scenarios[0, 0].util.second_stage_variables - ) - previous_master_fsv_vals = ComponentMap((var, None) for var in master_fsv_set) - previous_master_dr_var_vals = ComponentMap((var, None) for var in master_dr_var_set) - previous_master_nom_ssv_vals = ComponentMap( - (var, None) for var in master_nom_ssv_set - ) + nominal_master_blk = master_data.master_model.scenarios[0, 0] + dr_var_monomial_map = get_dr_var_to_monomial_map(nominal_master_blk) - first_iter_master_fsv_vals = ComponentMap((var, None) for var in master_fsv_set) - first_iter_master_nom_ssv_vals = ComponentMap( - (var, None) for var in master_nom_ssv_set - ) - first_iter_dr_var_vals = ComponentMap((var, None) for var in master_dr_var_set) - nom_master_util_blk = master_data.master_model.scenarios[0, 0].util - dr_var_scaled_expr_map = get_dr_var_to_scaled_expr_map( - decision_rule_vars=nom_master_util_blk.decision_rule_vars, - decision_rule_eqns=nom_master_util_blk.decision_rule_eqns, - second_stage_vars=nom_master_util_blk.second_stage_variables, - uncertain_params=nom_master_util_blk.uncertain_params, - ) - dr_var_to_ssv_map = ComponentMap() - dr_ssv_zip = zip( - nom_master_util_blk.decision_rule_vars, - nom_master_util_blk.second_stage_variables, - ) - for indexed_dr_var, ssv in dr_ssv_zip: - for drvar in indexed_dr_var.values(): - dr_var_to_ssv_map[drvar] = ssv + # keep track of variable values for iteration logging + first_iter_var_data = None + previous_iter_var_data = None + current_iter_var_data = None + num_second_stage_ineq_cons = len( + separation_data.separation_model.second_stage.inequality_cons + ) IterationLogRecord.log_header(config.progress_logger.info) k = 0 - master_statuses = [] while config.max_iter == -1 or k < config.max_iter: master_data.iteration = k - - # === Add p-robust constraint if iteration > 0 - if k > 0 and config.p_robustness: - master_problem_methods.add_p_robust_constraint( - model_data=master_data, config=config - ) - - # === Solve Master Problem config.progress_logger.debug(f"PyROS working on iteration {k}...") - master_soln = master_problem_methods.solve_master( - model_data=master_data, config=config - ) - # config.progress_logger.info("Done solving Master Problem!") - - # === Keep track of total time and subsolver termination conditions - timing_data.total_master_solve_time += get_time_from_solver(master_soln.results) - if k > 0: # master feas problem not solved for iteration 0 - timing_data.total_master_solve_time += get_time_from_solver( - master_soln.feasibility_problem_results - ) - - master_statuses.append(master_soln.results.solver.termination_condition) - master_soln.master_problem_subsolver_statuses = master_statuses - - # === Check for robust infeasibility or error or time-out in master problem solve - if ( - master_soln.master_subsolver_results[1] - is pyrosTerminationCondition.robust_infeasible - ): - term_cond = pyrosTerminationCondition.robust_infeasible - elif ( - master_soln.pyros_termination_condition - is pyrosTerminationCondition.subsolver_error - ): - term_cond = pyrosTerminationCondition.subsolver_error - elif ( - master_soln.pyros_termination_condition - is pyrosTerminationCondition.time_out - ): - term_cond = pyrosTerminationCondition.time_out - else: - term_cond = None - if term_cond in { - pyrosTerminationCondition.subsolver_error, - pyrosTerminationCondition.time_out, + master_soln = master_data.solve_master() + master_termination_not_acceptable = master_soln.pyros_termination_condition in { pyrosTerminationCondition.robust_infeasible, - }: - log_record = IterationLogRecord( + pyrosTerminationCondition.time_out, + pyrosTerminationCondition.subsolver_error, + } + if master_termination_not_acceptable: + iter_log_record = IterationLogRecord( iteration=k, objective=None, first_stage_var_shift=None, @@ -614,181 +181,64 @@ def ROSolver_iterative_solve(model_data, config): global_separation=None, elapsed_time=get_main_elapsed_time(model_data.timing), ) - log_record.log(config.progress_logger.info) - update_grcs_solve_data( - pyros_soln=model_data, - k=k, - term_cond=term_cond, - nominal_data=nominal_data, - timing_data=timing_data, - separation_data=separation_data, - master_soln=master_soln, + iter_log_record.log(config.progress_logger.info) + return GRCSResults( + master_results=master_soln, + separation_results=None, + pyros_termination_condition=master_soln.pyros_termination_condition, + iterations=k + 1, ) - return model_data, [] - - # === Save nominal information - if k == 0: - for val in master_soln.fsv_vals: - nominal_data.nom_fsv_vals.append(val) - - for val in master_soln.ssv_vals: - nominal_data.nom_ssv_vals.append(val) - - nominal_data.nom_first_stage_cost = master_soln.first_stage_objective - nominal_data.nom_second_stage_cost = master_soln.second_stage_objective - nominal_data.nom_obj = value(master_data.master_model.obj) polishing_successful = True - if ( + polish_master_solution = ( config.decision_rule_order != 0 - and len(config.second_stage_variables) > 0 + and nominal_master_blk.first_stage.decision_rule_vars and k != 0 - ): - # === Save initial values of DR vars to file - for varslist in master_data.master_model.scenarios[ - 0, 0 - ].util.decision_rule_vars: - vals = [] - for dvar in varslist.values(): - vals.append(dvar.value) - dr_var_lists_original.append(vals) - - (polishing_results, polishing_successful) = ( - master_problem_methods.minimize_dr_vars( - model_data=master_data, config=config - ) - ) - timing_data.total_dr_polish_time += get_time_from_solver(polishing_results) - - # === Save after polish - for varslist in master_data.master_model.scenarios[ - 0, 0 - ].util.decision_rule_vars: - vals = [] - for dvar in varslist.values(): - vals.append(dvar.value) - dr_var_lists_polished.append(vals) - - # get current first-stage and DR variable values - # and compare with previous first-stage and DR variable - # values - current_master_fsv_vals = ComponentMap( - (var, value(var)) for var in master_fsv_set ) - current_master_nom_ssv_vals = ComponentMap( - (var, value(var)) for var in master_nom_ssv_set + if polish_master_solution: + _, polishing_successful = master_data.solve_dr_polishing() + + # track variable values + current_iter_var_data = get_variable_value_data( + nominal_master_blk, dr_var_monomial_map ) - current_master_dr_var_vals = ComponentMap( - (var, value(expr)) for var, expr in dr_var_scaled_expr_map.items() + if k == 0: + first_iter_var_data = current_iter_var_data + previous_iter_var_data = None + + fsv_shift, ssv_shift, dr_var_shift = evaluate_variable_shifts( + current_var_data=current_iter_var_data, + previous_var_data=previous_iter_var_data, + initial_var_data=first_iter_var_data, ) - if k > 0: - first_stage_var_shift = evaluate_first_stage_var_shift( - current_master_fsv_vals=current_master_fsv_vals, - previous_master_fsv_vals=previous_master_fsv_vals, - first_iter_master_fsv_vals=first_iter_master_fsv_vals, - ) - second_stage_var_shift = evaluate_second_stage_var_shift( - current_master_nom_ssv_vals=current_master_nom_ssv_vals, - previous_master_nom_ssv_vals=previous_master_nom_ssv_vals, - first_iter_master_nom_ssv_vals=first_iter_master_nom_ssv_vals, - ) - dr_var_shift = evaluate_dr_var_shift( - current_master_dr_var_vals=current_master_dr_var_vals, - previous_master_dr_var_vals=previous_master_dr_var_vals, - first_iter_master_nom_ssv_vals=first_iter_master_nom_ssv_vals, - dr_var_to_ssv_map=dr_var_to_ssv_map, - ) - else: - for fsv in first_iter_master_fsv_vals: - first_iter_master_fsv_vals[fsv] = value(fsv) - for ssv in first_iter_master_nom_ssv_vals: - first_iter_master_nom_ssv_vals[ssv] = value(ssv) - for drvar in first_iter_dr_var_vals: - first_iter_dr_var_vals[drvar] = value(dr_var_scaled_expr_map[drvar]) - first_stage_var_shift = None - second_stage_var_shift = None - dr_var_shift = None # === Check if time limit reached after polishing - if config.time_limit: - elapsed = get_main_elapsed_time(model_data.timing) - if elapsed >= config.time_limit: - iter_log_record = IterationLogRecord( - iteration=k, - objective=value(master_data.master_model.obj), - first_stage_var_shift=first_stage_var_shift, - second_stage_var_shift=second_stage_var_shift, - dr_var_shift=dr_var_shift, - num_violated_cons=None, - max_violation=None, - dr_polishing_success=polishing_successful, - all_sep_problems_solved=None, - global_separation=None, - elapsed_time=elapsed, - ) - update_grcs_solve_data( - pyros_soln=model_data, - k=k, - term_cond=pyrosTerminationCondition.time_out, - nominal_data=nominal_data, - timing_data=timing_data, - separation_data=separation_data, - master_soln=master_soln, - ) - iter_log_record.log(config.progress_logger.info) - return model_data, [] - - # === Set up for the separation problem - separation_data.opt_fsv_vals = [ - v.value - for v in master_soln.master_model.scenarios[0, 0].util.first_stage_variables - ] - separation_data.opt_ssv_vals = master_soln.ssv_vals - - # === Provide master model scenarios to separation problem for initialization options - separation_data.master_scenarios = master_data.master_model.scenarios - - if config.objective_focus is ObjectiveType.worst_case: - separation_model.util.zeta = value(master_soln.master_model.obj) + if check_time_limit_reached(model_data.timing, config): + iter_log_record = IterationLogRecord( + iteration=k, + objective=value(master_data.master_model.epigraph_obj), + first_stage_var_shift=fsv_shift, + second_stage_var_shift=ssv_shift, + dr_var_shift=dr_var_shift, + num_violated_cons=None, + max_violation=None, + dr_polishing_success=polishing_successful, + all_sep_problems_solved=None, + global_separation=None, + elapsed_time=model_data.timing.get_main_elapsed_time(), + ) + iter_log_record.log(config.progress_logger.info) + return GRCSResults( + master_results=master_soln, + separation_results=None, + pyros_termination_condition=pyrosTerminationCondition.time_out, + iterations=k + 1, + ) # === Solve Separation Problem separation_data.iteration = k - separation_data.master_nominal_scenario = master_data.master_model.scenarios[ - 0, 0 - ] - separation_data.master_model = master_data.master_model - - separation_results = separation_problem_methods.solve_separation_problem( - model_data=separation_data, config=config - ) - - separation_data.separation_problem_subsolver_statuses.extend( - [ - res.solver.termination_condition - for res in separation_results.generate_subsolver_results() - ] - ) - - if separation_results.solved_globally: - separation_data.total_global_separation_solves += 1 - - # make updates based on separation results - timing_data.total_separation_local_time += ( - separation_results.evaluate_local_solve_time(get_time_from_solver) - ) - timing_data.total_separation_global_time += ( - separation_results.evaluate_global_solve_time(get_time_from_solver) - ) - if separation_results.found_violation: - scaled_violations = separation_results.scaled_violations - if scaled_violations is not None: - # can be None if time out or subsolver error - # reported in separation - separation_data.constraint_violations.append(scaled_violations.values()) - separation_data.points_separated = ( - separation_results.violating_param_realization - ) + separation_results = separation_data.solve_separation(master_data) scaled_violations = [ solve_call_res.scaled_violations[con] @@ -799,19 +249,19 @@ def ROSolver_iterative_solve(model_data, config): max_sep_con_violation = max(scaled_violations) else: max_sep_con_violation = None - num_violated_cons = len(separation_results.violated_performance_constraints) + num_violated_cons = len(separation_results.violated_second_stage_ineq_cons) all_sep_problems_solved = ( - len(scaled_violations) == len(separation_model.util.performance_constraints) + len(scaled_violations) == num_second_stage_ineq_cons and not separation_results.subsolver_error and not separation_results.time_out - ) + ) or separation_results.all_discrete_scenarios_exhausted iter_log_record = IterationLogRecord( iteration=k, - objective=value(master_data.master_model.obj), - first_stage_var_shift=first_stage_var_shift, - second_stage_var_shift=second_stage_var_shift, + objective=value(master_data.master_model.epigraph_obj), + first_stage_var_shift=fsv_shift, + second_stage_var_shift=ssv_shift, dr_var_shift=dr_var_shift, num_violated_cons=num_violated_cons, max_violation=max_sep_con_violation, @@ -822,35 +272,26 @@ def ROSolver_iterative_solve(model_data, config): ) # terminate on time limit - elapsed = get_main_elapsed_time(model_data.timing) - if separation_results.time_out: - termination_condition = pyrosTerminationCondition.time_out - update_grcs_solve_data( - pyros_soln=model_data, - k=k, - term_cond=termination_condition, - nominal_data=nominal_data, - timing_data=timing_data, - separation_data=separation_data, - master_soln=master_soln, + if separation_results.time_out or separation_results.subsolver_error: + # report PyROS failure to find violated constraint for subsolver error + if separation_results.subsolver_error: + config.progress_logger.warning( + "PyROS failed to find a constraint violation and " + "will terminate with sub-solver error." + ) + + pyros_term_cond = ( + pyrosTerminationCondition.time_out + if separation_results.time_out + else pyrosTerminationCondition.subsolver_error ) iter_log_record.log(config.progress_logger.info) - return model_data, separation_results - - # terminate on separation subsolver error - if separation_results.subsolver_error: - termination_condition = pyrosTerminationCondition.subsolver_error - update_grcs_solve_data( - pyros_soln=model_data, - k=k, - term_cond=termination_condition, - nominal_data=nominal_data, - timing_data=timing_data, - separation_data=separation_data, - master_soln=master_soln, + return GRCSResults( + master_results=master_soln, + separation_results=separation_results, + pyros_termination_condition=pyros_term_cond, + iterations=k + 1, ) - iter_log_record.log(config.progress_logger.info) - return model_data, separation_results # === Check if we terminate due to robust optimality or feasibility, # or in the event of bypassing global separation, no violations @@ -870,30 +311,32 @@ def ROSolver_iterative_solve(model_data, config): termination_condition = pyrosTerminationCondition.robust_optimal else: termination_condition = pyrosTerminationCondition.robust_feasible - update_grcs_solve_data( - pyros_soln=model_data, - k=k, - term_cond=termination_condition, - nominal_data=nominal_data, - timing_data=timing_data, - separation_data=separation_data, - master_soln=master_soln, - ) iter_log_record.log(config.progress_logger.info) - return model_data, separation_results + return GRCSResults( + master_results=master_soln, + separation_results=separation_results, + pyros_termination_condition=termination_condition, + iterations=k + 1, + ) # === Add block to master at violation - master_problem_methods.add_scenario_to_master( - model_data=master_data, - violations=separation_results.violating_param_realization, + mp_methods.add_scenario_block_to_master_problem( + master_model=master_data.master_model, + scenario_idx=(k + 1, 0), + param_realization=separation_results.violating_param_realization, + from_block=nominal_master_blk, + clone_first_stage_components=False, ) - separation_data.points_added_to_master.append( + separation_data.points_added_to_master[(k + 1, 0)] = ( separation_results.violating_param_realization ) + separation_data.auxiliary_values_for_master_points[(k + 1, 0)] = ( + separation_results.auxiliary_param_values + ) config.progress_logger.debug("Points added to master:") config.progress_logger.debug( - np.array([pt for pt in separation_data.points_added_to_master]) + np.array([pt for pt in separation_data.points_added_to_master.values()]) ) # initialize second-stage and state variables @@ -909,18 +352,12 @@ def ROSolver_iterative_solve(model_data, config): k += 1 iter_log_record.log(config.progress_logger.info) - previous_master_fsv_vals = current_master_fsv_vals - previous_master_nom_ssv_vals = current_master_nom_ssv_vals - previous_master_dr_var_vals = current_master_dr_var_vals + previous_iter_var_data = current_iter_var_data # Iteration limit reached - update_grcs_solve_data( - pyros_soln=model_data, - k=k - 1, # remove last increment to fix iteration count - term_cond=pyrosTerminationCondition.max_iter, - nominal_data=nominal_data, - timing_data=timing_data, - separation_data=separation_data, - master_soln=master_soln, + return GRCSResults( + master_results=master_soln, + separation_results=separation_results, + pyros_termination_condition=pyrosTerminationCondition.max_iter, + iterations=k, # iteration count was already incremented ) - return model_data, separation_results diff --git a/pyomo/contrib/pyros/separation_problem_methods.py b/pyomo/contrib/pyros/separation_problem_methods.py index 084b0442ae6..5d1953a5a51 100644 --- a/pyomo/contrib/pyros/separation_problem_methods.py +++ b/pyomo/contrib/pyros/separation_problem_methods.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,253 +10,207 @@ # ___________________________________________________________________________ """ -Functions for the construction and solving of the GRCS separation problem via ROsolver +Methods for constructing and solving PyROS separation problems +and related objects. """ -from pyomo.core.base.constraint import Constraint, ConstraintList -from pyomo.core.base.objective import Objective, maximize, value -from pyomo.core.base import Var, Param +from itertools import product +import os + from pyomo.common.collections import ComponentSet, ComponentMap from pyomo.common.dependencies import numpy as np -from pyomo.contrib.pyros.util import ObjectiveType, get_time_from_solver +from pyomo.core.base import Block, Constraint, maximize, Objective, value, Var +from pyomo.opt import TerminationCondition as tc +from pyomo.core.expr import ( + replace_expressions, + identify_variables, + identify_mutable_parameters, +) + from pyomo.contrib.pyros.solve_data import ( DiscreteSeparationSolveCallResults, SeparationSolveCallResults, SeparationLoopResults, SeparationResults, ) -from pyomo.opt import TerminationCondition as tc -from pyomo.core.expr import ( - replace_expressions, - identify_mutable_parameters, - identify_variables, -) -from pyomo.contrib.pyros.util import get_main_elapsed_time, is_certain_parameter from pyomo.contrib.pyros.uncertainty_sets import Geometry -from pyomo.common.errors import ApplicationError -from pyomo.contrib.pyros.util import ABS_CON_CHECK_FEAS_TOL -from pyomo.common.timing import TicTocTimer from pyomo.contrib.pyros.util import ( - TIC_TOC_SOLVE_TIME_ATTR, - adjust_solver_time_settings, - revert_solver_max_time_adjustment, + ABS_CON_CHECK_FEAS_TOL, + call_solver, + check_time_limit_reached, + get_all_first_stage_eq_cons, ) -import os -from copy import deepcopy -from itertools import product -def add_uncertainty_set_constraints(model, config): +def add_uncertainty_set_constraints(separation_model, config): """ - Add inequality constraint(s) representing the uncertainty set. + Add to the separation model constraints restricting + the uncertain parameter proxy variables to the user-provided + uncertainty set. Note that inferred interval enclosures + on the uncertain parameters are also imposed as bounds + specified on the proxy variables. """ - - model.util.uncertainty_set_constraint = config.uncertainty_set.set_as_constraint( - uncertain_params=model.util.uncertain_param_vars, model=model, config=config + separation_model.uncertainty = Block() + separation_model.uncertainty.uncertain_param_indexed_var = Var( + range(config.uncertainty_set.dim), + initialize={ + idx: nom_val + for idx, nom_val in enumerate(config.nominal_uncertain_param_vals) + }, ) - - config.uncertainty_set.add_bounds_on_uncertain_parameters( - model=model, config=config + indexed_param_var = separation_model.uncertainty.uncertain_param_indexed_var + uncertainty_quantification = config.uncertainty_set.set_as_constraint( + uncertain_params=indexed_param_var, block=separation_model.uncertainty ) - # === Pre-process out any uncertain parameters which have q_LB = q_ub via (q_ub - q_lb)/max(1,|q_UB|) <= TOL - # before building the uncertainty set constraint(s) - uncertain_params = config.uncertain_params - for i in range(len(uncertain_params)): - if is_certain_parameter(uncertain_param_index=i, config=config): - # This parameter is effectively certain for this set, can remove it from the uncertainty set - # We do this by fixing it in separation to its nominal value - model.util.uncertain_param_vars[i].fix( - config.nominal_uncertain_param_vals[i] - ) - - return - + # facilitate retrieval later + _, uncertainty_cons, param_var_list, aux_vars = uncertainty_quantification + separation_model.uncertainty.uncertain_param_var_list = param_var_list + separation_model.uncertainty.auxiliary_var_list = aux_vars + separation_model.uncertainty.uncertainty_cons_list = uncertainty_cons -def make_separation_objective_functions(model, config): - """ - Inequality constraints referencing control variables, state variables, or uncertain parameters - must be separated against in separation problem. - """ - performance_constraints = [] - for c in model.component_data_objects(Constraint, active=True, descend_into=True): - _vars = ComponentSet(identify_variables(expr=c.expr)) - uncertain_params_in_expr = list( - v for v in model.util.uncertain_param_vars.values() if v in _vars + config.uncertainty_set._add_bounds_on_uncertain_parameters( + uncertain_param_vars=param_var_list, global_solver=config.global_solver + ) + if aux_vars: + aux_var_vals = config.uncertainty_set.compute_auxiliary_uncertain_param_vals( + point=config.nominal_uncertain_param_vals, solver=config.global_solver ) - state_vars_in_expr = list(v for v in model.util.state_vars if v in _vars) - second_stage_variables_in_expr = list( - v for v in model.util.second_stage_variables if v in _vars + for auxvar, auxval in zip(aux_vars, aux_var_vals): + auxvar.set_value(auxval) + + # fix the effectively certain parameters + param_val_enum_zip = enumerate( + zip(param_var_list, config.nominal_uncertain_param_vals) + ) + fixed_param_var_set = ComponentSet() + for idx, (param_var, nomval) in param_val_enum_zip: + if idx not in separation_model.effective_uncertain_dimensions: + param_var.fix(nomval) + fixed_param_var_set.add(param_var) + + # deactivate constraints that depend on only the + # effective certain parameters + separation_model.uncertainty.certain_param_var_cons = [] + for con in uncertainty_cons: + unfixed_param_vars_in_con = ( + ComponentSet(identify_variables(con.expr)) - fixed_param_var_set ) - if not c.equality and ( - uncertain_params_in_expr - or state_vars_in_expr - or second_stage_variables_in_expr - ): - # This inequality constraint depends on uncertain parameters therefore it must be separated against - performance_constraints.append(c) - elif not c.equality and not ( - uncertain_params_in_expr - or state_vars_in_expr - or second_stage_variables_in_expr - ): - c.deactivate() # These are x \in X constraints, not active in separation because x is fixed to x* from previous master - model.util.performance_constraints = performance_constraints - model.util.separation_objectives = [] - map_obj_to_constr = ComponentMap() - - for idx, c in enumerate(performance_constraints): - # Separation objective constraints standardized to be MAXIMIZATION of <= constraints - c.deactivate() - if c.upper is not None: - # This is an <= constraint, maximized in separation - obj = Objective(expr=c.body - c.upper, sense=maximize) - map_obj_to_constr[c] = obj - model.add_component("separation_obj_" + str(idx), obj) - model.util.separation_objectives.append(obj) - elif c.lower is not None: - # This is an >= constraint, not supported - raise ValueError( - "All inequality constraints in model must be in standard form (<= RHS)" - ) + if not unfixed_param_vars_in_con: + con.deactivate() + separation_model.uncertainty.certain_param_var_cons.append(con) - model.util.map_obj_to_constr = map_obj_to_constr - for obj in model.util.separation_objectives: - obj.deactivate() - return +def construct_separation_problem(model_data): + """ + Construct the separation problem model from the fully preprocessed + working model. + Parameters + ---------- + model_data : model data object + Main model data object. -def make_separation_problem(model_data, config): - """ - Swap out uncertain param Param objects for Vars - Add uncertainty set constraints and separation objectives + Returns + ------- + separation_model : ConcreteModel + Separation problem model. """ - separation_model = model_data.original.clone() - separation_model.del_component("coefficient_matching_constraints") - separation_model.del_component("coefficient_matching_constraints_index") + config = model_data.config + separation_model = model_data.working_model.clone() + + # fix/deactivate all nonadjustable components + for var in separation_model.all_nonadjustable_variables: + var.fix() + for fs_eqcon in get_all_first_stage_eq_cons(separation_model): + fs_eqcon.deactivate() + for fs_ineqcon in separation_model.first_stage.inequality_cons.values(): + fs_ineqcon.deactivate() + + # add block for the uncertainty set quantification + add_uncertainty_set_constraints(separation_model, config) - uncertain_params = separation_model.util.uncertain_params - separation_model.util.uncertain_param_vars = param_vars = Var( - range(len(uncertain_params)) + # the uncertain params function as decision variables + # in the separation problems. + # note: expression replacement is performed only for + # the active constraints + uncertain_params = separation_model.uncertain_params + uncertain_param_vars = separation_model.uncertainty.uncertain_param_var_list + param_id_to_var_map = { + id(param): var for param, var in zip(uncertain_params, uncertain_param_vars) + } + uncertain_params_set = ComponentSet(uncertain_params) + adjustable_cons = ( + list(separation_model.second_stage.inequality_cons.values()) + + list(separation_model.second_stage.equality_cons.values()) + + list(separation_model.second_stage.decision_rule_eqns.values()) ) - map_new_constraint_list_to_original_con = ComponentMap() - - if config.objective_focus is ObjectiveType.worst_case: - separation_model.util.zeta = Param(initialize=0, mutable=True) - constr = Constraint( - expr=separation_model.first_stage_objective - + separation_model.second_stage_objective - - separation_model.util.zeta - <= 0 + for adjcon in adjustable_cons: + uncertain_params_in_con = ( + ComponentSet(identify_mutable_parameters(adjcon.expr)) + & uncertain_params_set ) - separation_model.add_component("epigraph_constr", constr) - - substitution_map = {} - # Separation problem initialized to nominal uncertain parameter values - for idx, var in enumerate(list(param_vars.values())): - param = uncertain_params[idx] - var.set_value(param.value, skip_validation=True) - substitution_map[id(param)] = var - - separation_model.util.new_constraints = constraints = ConstraintList() - - uncertain_param_set = ComponentSet(uncertain_params) - for c in separation_model.component_data_objects(Constraint): - if any(v in uncertain_param_set for v in identify_mutable_parameters(c.expr)): - if c.equality: - if c in separation_model.util.h_x_q_constraints: - # ensure that constraints subject to - # coefficient matching are not involved in - # separation problem. - # keeping them may induce numerical sensitivity - # issues, possibly leading to incorrect result - c.deactivate() - else: - constraints.add( - replace_expressions( - expr=c.lower, substitution_map=substitution_map - ) - == replace_expressions( - expr=c.body, substitution_map=substitution_map - ) - ) - elif c.lower is not None: - constraints.add( - replace_expressions(expr=c.lower, substitution_map=substitution_map) - <= replace_expressions( - expr=c.body, substitution_map=substitution_map - ) - ) - elif c.upper is not None: - constraints.add( - replace_expressions(expr=c.upper, substitution_map=substitution_map) - >= replace_expressions( - expr=c.body, substitution_map=substitution_map - ) - ) - else: - raise ValueError( - "Unable to parse constraint for building the separation problem." - ) - c.deactivate() - map_new_constraint_list_to_original_con[ - constraints[constraints.index_set().last()] - ] = c - - separation_model.util.map_new_constraint_list_to_original_con = ( - map_new_constraint_list_to_original_con - ) - - # === Add objectives first so that the uncertainty set - # Constraints do not get picked up into the set - # of performance constraints which become objectives - make_separation_objective_functions(separation_model, config) - add_uncertainty_set_constraints(separation_model, config) + if uncertain_params_in_con: + adjcon.set_value( + replace_expressions(adjcon.expr, substitution_map=param_id_to_var_map) + ) - # === Deactivate h(x,q) == 0 constraints - for c in separation_model.util.h_x_q_constraints: - c.deactivate() + # second-stage inequality constraint expressions + # become maximization objectives in the separation problems + separation_model.second_stage_ineq_con_to_obj_map = ComponentMap() + ss_ineq_cons = separation_model.second_stage.inequality_cons.values() + for idx, ss_ineq_con in enumerate(ss_ineq_cons): + ss_ineq_con.deactivate() + separation_obj = Objective( + expr=ss_ineq_con.body - ss_ineq_con.upper, sense=maximize + ) + separation_model.add_component(f"separation_obj_{idx}", separation_obj) + separation_model.second_stage_ineq_con_to_obj_map[ss_ineq_con] = separation_obj + separation_obj.deactivate() return separation_model -def get_sep_objective_values(model_data, config, perf_cons): +def get_sep_objective_values(separation_data, ss_ineq_cons): """ - Evaluate performance constraint functions at current + Evaluate second-stage inequality constraint functions at current separation solution. Parameters ---------- - model_data : SeparationProblemData + separation_data : SeparationProblemData Separation problem data. - config : ConfigDict - PyROS solver settings. - perf_cons : list of Constraint - Performance constraints to be evaluated. + ss_ineq_cons : list of Constraint + Second-stage inequality constraints to be evaluated. Returns ------- violations : ComponentMap - Mapping from performance constraints to violation values. + Mapping from second-stage inequality constraints + to violation values. """ - con_to_obj_map = model_data.separation_model.util.map_obj_to_constr + config = separation_data.config + con_to_obj_map = separation_data.separation_model.second_stage_ineq_con_to_obj_map violations = ComponentMap() - for perf_con in perf_cons: - obj = con_to_obj_map[perf_con] + user_var_partitioning = separation_data.separation_model.user_var_partitioning + first_stage_variables = user_var_partitioning.first_stage_variables + second_stage_variables = user_var_partitioning.second_stage_variables + + for ss_ineq_con in ss_ineq_cons: + obj = con_to_obj_map[ss_ineq_con] try: - violations[perf_con] = value(obj.expr) + violations[ss_ineq_con] = value(obj.expr) except ValueError: - for v in model_data.separation_model.util.first_stage_variables: + for v in first_stage_variables: config.progress_logger.info(v.name + " " + str(v.value)) - for v in model_data.separation_model.util.second_stage_variables: + for v in second_stage_variables: config.progress_logger.info(v.name + " " + str(v.value)) raise ArithmeticError( - f"Evaluation of performance constraint {perf_con.name} " + f"Evaluation of second-stage inequality constraint {ss_ineq_con.name} " f"(separation objective {obj.name}) " "led to a math domain error. " - "Does the performance constraint expression " + "Does the constraint expression " "contain log(x) or 1/x functions " "or others with tricky domains?" ) @@ -264,41 +218,43 @@ def get_sep_objective_values(model_data, config, perf_cons): return violations -def get_argmax_sum_violations(solver_call_results_map, perf_cons_to_evaluate): +def get_argmax_sum_violations(solver_call_results_map, ss_ineq_cons_to_evaluate): """ Get key of entry of `solver_call_results_map` which contains - separation problem solution with maximal sum of performance - constraint violations over a specified sequence of performance - constraints. + separation problem solution with maximal sum of second-stage + inequality constraint violations over a specified sequence of + second-stage inequality constraints. Parameters ---------- solver_call_results : ComponentMap - Mapping from performance constraints to corresponding + Mapping from second-stage inequality constraints to corresponding separation solver call results. - perf_cons_to_evaluate : list of Constraints - Performance constraints to consider for evaluating + ss_ineq_cons_to_evaluate : list of Constraints + Second-stage inequality constraints to consider for evaluating maximal sum. Returns ------- - worst_perf_con : None or Constraint - Performance constraint corresponding to solver call + worst_ss_ineq_con : None or Constraint + Second-stage inequality constraint corresponding to solver call results object containing solution with maximal sum - of violations across all performance constraints. + of violations across all second-stage inequality constraints. If ``found_violation`` attribute of all value entries of `solver_call_results_map` is False, then `None` is - returned, as this means none of the performance constraints + returned, as this means + none of the second-stage inequality constraints were found to be violated. """ - # get indices of performance constraints for which violation found - idx_to_perf_con_map = { - idx: perf_con for idx, perf_con in enumerate(solver_call_results_map) + # get indices of second-stage ineq constraints + # for which violation found + idx_to_ss_ineq_con_map = { + idx: ss_ineq_con for idx, ss_ineq_con in enumerate(solver_call_results_map) } idxs_of_violated_cons = [ idx - for idx, perf_con in idx_to_perf_con_map.items() - if solver_call_results_map[perf_con].found_violation + for idx, ss_ineq_con in idx_to_ss_ineq_con_map.items() + if solver_call_results_map[ss_ineq_con].found_violation ] num_violated_cons = len(idxs_of_violated_cons) @@ -308,7 +264,7 @@ def get_argmax_sum_violations(solver_call_results_map, perf_cons_to_evaluate): # assemble square matrix (2D array) of constraint violations. # matrix size: number of constraints for which violation was found - # each row corresponds to a performance constraint + # each row corresponds to a second-stage inequality constraint # each column corresponds to a separation problem solution violations_arr = np.zeros(shape=(num_violated_cons, num_violated_cons)) idxs_product = product( @@ -318,37 +274,38 @@ def get_argmax_sum_violations(solver_call_results_map, perf_cons_to_evaluate): violations_arr[row_idx, col_idx] = max( 0, ( - # violation of this row's performance constraint + # violation of this row's second-stage inequality con # by this column's separation solution # if separation problems were solved globally, # then diagonal entries should be the largest in each row solver_call_results_map[ - idx_to_perf_con_map[viol_param_idx] - ].scaled_violations[idx_to_perf_con_map[viol_con_idx]] + idx_to_ss_ineq_con_map[viol_param_idx] + ].scaled_violations[idx_to_ss_ineq_con_map[viol_con_idx]] ), ) worst_col_idx = np.argmax(np.sum(violations_arr, axis=0)) - return idx_to_perf_con_map[idxs_of_violated_cons[worst_col_idx]] + return idx_to_ss_ineq_con_map[idxs_of_violated_cons[worst_col_idx]] -def solve_separation_problem(model_data, config): +def solve_separation_problem(separation_data, master_data): """ Solve PyROS separation problems. Parameters ---------- - model_data : SeparationProblemData + separation_data : SeparationProblemData Separation problem data. - config : ConfigDict - PyROS solver settings. + master_data : MasterProblemData + Master problem data. Returns ------- pyros.solve_data.SeparationResults Separation problem solve results. """ + config = separation_data.config run_local = not config.bypass_local_separation run_global = config.bypass_local_separation @@ -358,7 +315,9 @@ def solve_separation_problem(model_data, config): if run_local: local_separation_loop_results = perform_separation_loop( - model_data=model_data, config=config, solve_globally=False + separation_data=separation_data, + master_data=master_data, + solve_globally=False, ) run_global = not ( local_separation_loop_results.found_violation @@ -372,7 +331,9 @@ def solve_separation_problem(model_data, config): if run_global: global_separation_loop_results = perform_separation_loop( - model_data=model_data, config=config, solve_globally=True + separation_data=separation_data, + master_data=master_data, + solve_globally=True, ) else: global_separation_loop_results = None @@ -383,106 +344,83 @@ def solve_separation_problem(model_data, config): ) -def evaluate_violations_by_nominal_master(model_data, performance_cons): +def evaluate_violations_by_nominal_master(separation_data, master_data, ss_ineq_cons): """ - Evaluate violation of performance constraints by + Evaluate violation of second-stage inequality constraints by variables in nominal block of most recent master problem. Returns ------- - nom_perf_con_violations : dict - Mapping from performance constraint names + nom_ss_ineq_con_violations : dict + Mapping from second-stage inequality constraint names to floats equal to violations by nominal master problem variables. """ - constraint_map_to_master = ( - model_data.separation_model.util.map_new_constraint_list_to_original_con - ) - - # get deterministic model constraints (include epigraph) - set_of_deterministic_constraints = ( - model_data.separation_model.util.deterministic_constraints - ) - if hasattr(model_data.separation_model, "epigraph_constr"): - set_of_deterministic_constraints.add( - model_data.separation_model.epigraph_constr - ) - nom_perf_con_violations = {} - - for perf_con in performance_cons: - if perf_con in set_of_deterministic_constraints: - nom_constraint = perf_con - else: - nom_constraint = constraint_map_to_master[perf_con] + nom_ss_ineq_con_violations = ComponentMap() + for ss_ineq_con in ss_ineq_cons: nom_violation = value( - model_data.master_nominal_scenario.find_component(nom_constraint) + master_data.master_model.scenarios[0, 0].find_component(ss_ineq_con) ) - nom_perf_con_violations[perf_con] = nom_violation + nom_ss_ineq_con_violations[ss_ineq_con] = nom_violation - return nom_perf_con_violations + return nom_ss_ineq_con_violations -def group_performance_constraints_by_priority(model_data, config): +def group_ss_ineq_constraints_by_priority(separation_data): """ - Group model performance constraints by separation priority. + Group model second-stage inequality constraints + by separation priority. Parameters ---------- - model_data : SeparationProblemData + separation_data : SeparationProblemData Separation problem data. - config : ConfigDict - User-specified PyROS solve options. Returns ------- dict - Mapping from an int to a list of performance constraints + Mapping from an int to a list of second-stage + inequality constraints (Constraint objects), for which the int is equal to the specified priority. Keys are sorted in descending order (i.e. highest priority first). """ + ss_ineq_cons = separation_data.separation_model.second_stage.inequality_cons separation_priority_groups = dict() - config_sep_priority_dict = config.separation_priority_order - for perf_con in model_data.separation_model.util.performance_constraints: + for name, ss_ineq_con in ss_ineq_cons.items(): # by default, priority set to 0 - priority = config_sep_priority_dict.get(perf_con.name, 0) + priority = separation_data.separation_priority_order[name] cons_with_same_priority = separation_priority_groups.setdefault(priority, []) - cons_with_same_priority.append(perf_con) + cons_with_same_priority.append(ss_ineq_con) # sort separation priority groups return { - priority: perf_cons - for priority, perf_cons in sorted( + priority: ss_ineq_cons + for priority, ss_ineq_cons in sorted( separation_priority_groups.items(), reverse=True ) } def get_worst_discrete_separation_solution( - performance_constraint, - model_data, - config, - perf_cons_to_evaluate, - discrete_solve_results, + ss_ineq_con, config, ss_ineq_cons_to_evaluate, discrete_solve_results ): """ Determine separation solution (and therefore worst-case uncertain parameter realization) with maximum violation - of specified performance constraint. + of specified second-stage inequality constraint. Parameters ---------- - performance_constraint : Constraint - Performance constraint of interest. - model_data : SeparationProblemData - Separation problem data. + ss_ineq_con : Constraint + Second-stage inequality constraint of interest. config : ConfigDict User-specified PyROS solver settings. - perf_cons_to_evaluate : list of Constraint - Performance constraints for which to report violations - by separation solution. + ss_ineq_cons_to_evaluate : list of Constraint + Second-stage inequality constraints for which to report + violations by separation solution. discrete_solve_results : DiscreteSeparationSolveCallResults Separation problem solutions corresponding to the uncertain parameter scenarios listed in @@ -491,42 +429,48 @@ def get_worst_discrete_separation_solution( Returns ------- SeparationSolveCallResult - Solver call result for performance constraint of interest. + Solver call result for second-stage inequality constraint of interest. """ - # violation of specified performance constraint by separation + # violation of specified second-stage inequality + # constraint by separation # problem solutions for all scenarios - violations_of_perf_con = [ - solve_call_res.scaled_violations[performance_constraint] + # scenarios with subsolver errors are replaced with nan + violations_of_ss_ineq_con = [ + ( + solve_call_res.scaled_violations[ss_ineq_con] + if not solve_call_res.subsolver_error + else np.nan + ) for solve_call_res in discrete_solve_results.solver_call_results.values() ] list_of_scenario_idxs = list(discrete_solve_results.solver_call_results.keys()) # determine separation solution for which scaled violation of this - # performance constraint is the worst + # second-stage inequality constraint is the worst worst_case_res = discrete_solve_results.solver_call_results[ - list_of_scenario_idxs[np.argmax(violations_of_perf_con)] + list_of_scenario_idxs[np.nanargmax(violations_of_ss_ineq_con)] ] - worst_case_violation = np.max(violations_of_perf_con) + worst_case_violation = np.nanmax(violations_of_ss_ineq_con) assert worst_case_violation in worst_case_res.scaled_violations.values() - # evaluate violations for specified performance constraints - eval_perf_con_scaled_violations = ComponentMap( - (perf_con, worst_case_res.scaled_violations[perf_con]) - for perf_con in perf_cons_to_evaluate + # evaluate violations for specified second-stage inequality constraints + eval_ss_ineq_con_scaled_violations = ComponentMap( + (ss_ineq_con, worst_case_res.scaled_violations[ss_ineq_con]) + for ss_ineq_con in ss_ineq_cons_to_evaluate ) # discrete separation solutions were obtained by optimizing - # just one performance constraint, as an efficiency. + # just one second-stage inequality constraint, as an efficiency. # if the constraint passed to this routine is the same as the # constraint used to obtain the solutions, then we bundle # the separation solve call results into a single list. # otherwise, we return an empty list, as we did not need to call - # subsolvers for the other performance constraints - is_optimized_performance_con = ( - performance_constraint is discrete_solve_results.performance_constraint + # subsolvers for the other second-stage inequality constraints + is_optimized_ss_ineq_con = ( + ss_ineq_con is discrete_solve_results.second_stage_ineq_con ) - if is_optimized_performance_con: + if is_optimized_ss_ineq_con: results_list = [ res for solve_call_results in discrete_solve_results.solver_call_results.values() @@ -535,24 +479,30 @@ def get_worst_discrete_separation_solution( else: results_list = [] + # check if there were any failed scenarios for subsolver_error + # if there are failed scenarios, subsolver error triggers for all ineq + if any(np.isnan(violations_of_ss_ineq_con)): + subsolver_error_flag = True + else: + subsolver_error_flag = False + return SeparationSolveCallResults( solved_globally=worst_case_res.solved_globally, results_list=results_list, - scaled_violations=eval_perf_con_scaled_violations, + scaled_violations=eval_ss_ineq_con_scaled_violations, violating_param_realization=worst_case_res.violating_param_realization, variable_values=worst_case_res.variable_values, found_violation=(worst_case_violation > config.robust_feasibility_tolerance), time_out=False, - subsolver_error=False, + subsolver_error=subsolver_error_flag, discrete_set_scenario_index=worst_case_res.discrete_set_scenario_index, ) -def get_con_name_repr(separation_model, con, with_orig_name=True, with_obj_name=True): +def get_con_name_repr(separation_model, con, with_obj_name=True): """ - Get string representation of performance constraint - and any other modeling components to which it has - been mapped. + Get string representation of second-stage inequality constraint + and the objective to which it has been mapped. Parameters ---------- @@ -560,15 +510,9 @@ def get_con_name_repr(separation_model, con, with_orig_name=True, with_obj_name= Separation model. con : ScalarConstraint or ConstraintData Constraint for which to get the representation. - with_orig_name : bool, optional - If constraint was added during construction of the - separation problem (i.e. if the constraint is a member of - in `separation_model.util.new_constraints`), - include the name of the original constraint from which - `perf_con` was created. with_obj_name : bool, optional Include name of separation model objective to which - constraint is mapped. Applicable only to performance + constraint is mapped. Applicable only to second-stage inequality constraints of the separation problem. Returns @@ -576,37 +520,26 @@ def get_con_name_repr(separation_model, con, with_orig_name=True, with_obj_name= str Constraint name representation. """ - - qual_strs = [] - if with_orig_name: - # check performance constraint was not added - # at construction of separation problem - orig_con = separation_model.util.map_new_constraint_list_to_original_con.get( - con, con - ) - if orig_con is not con: - qual_strs.append(f"originally {orig_con.name!r}") + qual_str = "" if with_obj_name: - objectives_map = separation_model.util.map_obj_to_constr + objectives_map = separation_model.second_stage_ineq_con_to_obj_map separation_obj = objectives_map[con] - qual_strs.append(f"mapped to objective {separation_obj.name!r}") + qual_str = f" (mapped to objective {separation_obj.name!r})" - final_qual_str = f" ({', '.join(qual_strs)})" if qual_strs else "" + return f"{con.index()!r}{qual_str}" - return f"{con.name!r}{final_qual_str}" - -def perform_separation_loop(model_data, config, solve_globally): +def perform_separation_loop(separation_data, master_data, solve_globally): """ Loop through, and solve, PyROS separation problems to desired optimality condition. Parameters ---------- - model_data : SeparationProblemData + separation_data : SeparationProblemData Separation problem data. - config : ConfigDict - PyROS solver settings. + master_data : MasterProblemData + Master problem data. solve_globally : bool True to solve separation problems globally, False to solve separation problems locally. @@ -616,30 +549,31 @@ def perform_separation_loop(model_data, config, solve_globally): pyros.solve_data.SeparationLoopResults Separation problem solve results. """ - all_performance_constraints = ( - model_data.separation_model.util.performance_constraints + config = separation_data.config + all_ss_ineq_constraints = list( + separation_data.separation_model.second_stage.inequality_cons.values() ) - if not all_performance_constraints: + if not all_ss_ineq_constraints: # robustness certified: no separation problems to solve return SeparationLoopResults( solver_call_results=ComponentMap(), solved_globally=solve_globally, - worst_case_perf_con=None, + worst_case_ss_ineq_con=None, ) # needed for normalizing separation solution constraint violations - model_data.nom_perf_con_violations = evaluate_violations_by_nominal_master( - model_data=model_data, performance_cons=all_performance_constraints - ) - sorted_priority_groups = group_performance_constraints_by_priority( - model_data, config + separation_data.nom_ss_ineq_con_violations = evaluate_violations_by_nominal_master( + separation_data=separation_data, + master_data=master_data, + ss_ineq_cons=all_ss_ineq_constraints, ) + sorted_priority_groups = group_ss_ineq_constraints_by_priority(separation_data) uncertainty_set_is_discrete = ( config.uncertainty_set.geometry == Geometry.DISCRETE_SCENARIOS ) if uncertainty_set_is_discrete: - all_scenarios_exhausted = len(model_data.idxs_of_master_scenarios) == len( + all_scenarios_exhausted = len(separation_data.idxs_of_master_scenarios) == len( config.uncertainty_set.scenarios ) if all_scenarios_exhausted: @@ -648,21 +582,22 @@ def perform_separation_loop(model_data, config, solve_globally): return SeparationLoopResults( solver_call_results=ComponentMap(), solved_globally=solve_globally, - worst_case_perf_con=None, + worst_case_ss_ineq_con=None, + all_discrete_scenarios_exhausted=True, ) - perf_con_to_maximize = sorted_priority_groups[ + ss_ineq_con_to_maximize = sorted_priority_groups[ max(sorted_priority_groups.keys()) ][0] # efficiency: evaluate all separation problem solutions in # advance of entering loop discrete_sep_results = discrete_solve( - model_data=model_data, - config=config, + separation_data=separation_data, + master_data=master_data, solve_globally=solve_globally, - perf_con_to_maximize=perf_con_to_maximize, - perf_cons_to_evaluate=all_performance_constraints, + ss_ineq_con_to_maximize=ss_ineq_con_to_maximize, + ss_ineq_cons_to_evaluate=all_ss_ineq_constraints, ) termination_not_ok = ( @@ -675,7 +610,7 @@ def perform_separation_loop(model_data, config, solve_globally): for solve_call_results in discrete_sep_results.solver_call_results.values() for res in solve_call_results.results_list ] - single_solver_call_res[perf_con_to_maximize] = ( + single_solver_call_res[ss_ineq_con_to_maximize] = ( # not the neatest assembly, # but should maintain accuracy of total solve times # and overall outcome @@ -689,78 +624,84 @@ def perform_separation_loop(model_data, config, solve_globally): return SeparationLoopResults( solver_call_results=single_solver_call_res, solved_globally=solve_globally, - worst_case_perf_con=None, + worst_case_ss_ineq_con=None, ) all_solve_call_results = ComponentMap() priority_groups_enum = enumerate(sorted_priority_groups.items()) - for group_idx, (priority, perf_constraints) in priority_groups_enum: + for group_idx, (priority, ss_ineq_constraints) in priority_groups_enum: priority_group_solve_call_results = ComponentMap() - for idx, perf_con in enumerate(perf_constraints): + for idx, ss_ineq_con in enumerate(ss_ineq_constraints): # log progress of separation loop solve_adverb = "Globally" if solve_globally else "Locally" config.progress_logger.debug( - f"{solve_adverb} separating performance constraint " - f"{get_con_name_repr(model_data.separation_model, perf_con)} " + f"{solve_adverb} separating second-stage inequality constraint " + f"{get_con_name_repr(separation_data.separation_model, ss_ineq_con)} " f"(priority {priority}, priority group {group_idx + 1} of " f"{len(sorted_priority_groups)}, " - f"constraint {idx + 1} of {len(perf_constraints)} " + f"constraint {idx + 1} of {len(ss_ineq_constraints)} " "in priority group, " f"{len(all_solve_call_results) + idx + 1} of " - f"{len(all_performance_constraints)} total)" + f"{len(all_ss_ineq_constraints)} total)" ) - # solve separation problem for this performance constraint + # solve separation problem for + # this second-stage inequality constraint if uncertainty_set_is_discrete: solve_call_results = get_worst_discrete_separation_solution( - performance_constraint=perf_con, - model_data=model_data, + ss_ineq_con=ss_ineq_con, config=config, - perf_cons_to_evaluate=all_performance_constraints, + ss_ineq_cons_to_evaluate=all_ss_ineq_constraints, discrete_solve_results=discrete_sep_results, ) else: solve_call_results = solver_call_separation( - model_data=model_data, - config=config, + separation_data=separation_data, + master_data=master_data, solve_globally=solve_globally, - perf_con_to_maximize=perf_con, - perf_cons_to_evaluate=all_performance_constraints, + ss_ineq_con_to_maximize=ss_ineq_con, + ss_ineq_cons_to_evaluate=all_ss_ineq_constraints, ) - priority_group_solve_call_results[perf_con] = solve_call_results + priority_group_solve_call_results[ss_ineq_con] = solve_call_results - termination_not_ok = ( - solve_call_results.time_out or solve_call_results.subsolver_error - ) + termination_not_ok = solve_call_results.time_out if termination_not_ok: all_solve_call_results.update(priority_group_solve_call_results) return SeparationLoopResults( solver_call_results=all_solve_call_results, solved_globally=solve_globally, - worst_case_perf_con=None, + worst_case_ss_ineq_con=None, + ) + + # provide message that PyROS will attempt to find a violation and move + # to the next iteration even after subsolver error + if solve_call_results.subsolver_error: + config.progress_logger.warning( + "PyROS is attempting to recover and will continue to " + "the next iteration if a constraint violation is found." ) all_solve_call_results.update(priority_group_solve_call_results) # there may be multiple separation problem solutions - # found to have violated a performance constraint. + # found to have violated a second-stage inequality constraint. # we choose just one for master problem of next iteration - worst_case_perf_con = get_argmax_sum_violations( + worst_case_ss_ineq_con = get_argmax_sum_violations( solver_call_results_map=all_solve_call_results, - perf_cons_to_evaluate=perf_constraints, + ss_ineq_cons_to_evaluate=ss_ineq_constraints, ) - if worst_case_perf_con is not None: + if worst_case_ss_ineq_con is not None: # take note of chosen separation solution - worst_case_res = all_solve_call_results[worst_case_perf_con] + worst_case_res = all_solve_call_results[worst_case_ss_ineq_con] if uncertainty_set_is_discrete: - model_data.idxs_of_master_scenarios.append( + separation_data.idxs_of_master_scenarios.append( worst_case_res.discrete_set_scenario_index ) # # auxiliary log messages violated_con_names = "\n ".join( - get_con_name_repr(model_data.separation_model, con) + get_con_name_repr(separation_data.separation_model, con) for con, res in all_solve_call_results.items() if res.found_violation ) @@ -769,13 +710,13 @@ def perform_separation_loop(model_data, config, solve_globally): ) config.progress_logger.debug( "Worst-case constraint: " - f"{get_con_name_repr(model_data.separation_model, worst_case_perf_con)} " + f"{get_con_name_repr(separation_data.separation_model, worst_case_ss_ineq_con)} " "under realization " f"{worst_case_res.violating_param_realization}." ) config.progress_logger.debug( f"Maximal scaled violation " - f"{worst_case_res.scaled_violations[worst_case_perf_con]} " + f"{worst_case_res.scaled_violations[worst_case_ss_ineq_con]} " "from this constraint " "exceeds the robust feasibility tolerance " f"{config.robust_feasibility_tolerance}" @@ -785,17 +726,19 @@ def perform_separation_loop(model_data, config, solve_globally): # exit loop break else: - config.progress_logger.debug("No violated performance constraints found.") + config.progress_logger.debug( + "No violated second-stage inequality constraints found." + ) return SeparationLoopResults( solver_call_results=all_solve_call_results, solved_globally=solve_globally, - worst_case_perf_con=worst_case_perf_con, + worst_case_ss_ineq_con=worst_case_ss_ineq_con, ) -def evaluate_performance_constraint_violations( - model_data, config, perf_con_to_maximize, perf_cons_to_evaluate +def evaluate_ss_ineq_con_violations( + separation_data, ss_ineq_con_to_maximize, ss_ineq_cons_to_evaluate ): """ Evaluate the inequality constraint function violations @@ -807,12 +750,13 @@ def evaluate_performance_constraint_violations( Parameters ---------- - model_data : SeparationProblemData + separation_data : SeparationProblemData Object containing the separation model. - config : ConfigDict - PyROS solver settings. - perf_cons_to_evaluate : list of Constraint - Performance constraints whose expressions are to + ss_ineq_con_to_maximize : ConstraintData + Second-stage inequality constraint + to which the current solution is mapped. + ss_ineq_cons_to_evaluate : list of Constraint + Second-stage inequality constraints whose expressions are to be evaluated at the current separation problem solution. Exactly one of these constraints should be mapped @@ -824,41 +768,46 @@ def evaluate_performance_constraint_violations( Uncertain parameter realization corresponding to maximum constraint violation. scaled_violations : ComponentMap - Mapping from performance constraints to be evaluated + Mapping from second-stage inequality constraints to be evaluated to their violations by the separation problem solution. constraint_violated : bool - True if performance constraint mapped to active + True if second-stage inequality constraint mapped to active separation model Objective is violated (beyond tolerance), False otherwise Raises ------ ValueError - If `perf_cons_to_evaluate` does not contain exactly + If `ss_ineq_cons_to_evaluate` does not contain exactly 1 entry which can be mapped to an active Objective of ``model_data.separation_model``. """ + config = separation_data.config + # parameter realization for current separation problem solution + uncertain_param_vars = ( + separation_data.separation_model.uncertainty.uncertain_param_var_list + ) violating_param_realization = list( - param.value - for param in model_data.separation_model.util.uncertain_param_vars.values() + param_var.value for param_var in uncertain_param_vars ) - # evaluate violations for all performance constraints provided + # evaluate violations for all second-stage inequality + # constraints provided violations_by_sep_solution = get_sep_objective_values( - model_data=model_data, config=config, perf_cons=perf_cons_to_evaluate + separation_data=separation_data, ss_ineq_cons=ss_ineq_cons_to_evaluate ) # normalize constraint violation: i.e. divide by # absolute value of constraint expression evaluated at # nominal master solution (if expression value is large enough) scaled_violations = ComponentMap() - for perf_con, sep_sol_violation in violations_by_sep_solution.items(): + for ss_ineq_con, sep_sol_violation in violations_by_sep_solution.items(): scaled_violation = sep_sol_violation / max( - 1, abs(model_data.nom_perf_con_violations[perf_con]) + 1, abs(separation_data.nom_ss_ineq_con_violations[ss_ineq_con]) ) - scaled_violations[perf_con] = scaled_violation - if perf_con is perf_con_to_maximize: + scaled_violations[ss_ineq_con] = scaled_violation + if ss_ineq_con is ss_ineq_con_to_maximize: scaled_active_obj_violation = scaled_violation constraint_violated = ( @@ -868,127 +817,76 @@ def evaluate_performance_constraint_violations( return (violating_param_realization, scaled_violations, constraint_violated) -def initialize_separation(perf_con_to_maximize, model_data, config): +def initialize_separation(ss_ineq_con_to_maximize, separation_data, master_data): """ - Initialize separation problem variables, and fix all first-stage - variables to their corresponding values from most recent - master problem solution. + Initialize separation problem variables using the solution + to the most recent master problem. Parameters ---------- - perf_con_to_maximize : ConstraintData - Performance constraint whose violation is to be maximized + ss_ineq_con_to_maximize : ConstraintData + Second-stage inequality constraint + whose violation is to be maximized for the separation problem of interest. - model_data : SeparationProblemData + separation_data : SeparationProblemData Separation problem data. - config : ConfigDict - PyROS solver settings. + master_data : MasterProblemData + Master problem data. Note ---- - If a static DR policy is used, then all second-stage variables - are fixed and the decision rule equations are deactivated. - The point to which the separation model is initialized should, in general, be feasible, provided the set does not have a discrete geometry (as there is no master model block corresponding to any of the remaining discrete scenarios against which we separate). - - This method assumes that the master model has only one block - per iteration. """ + config = separation_data.config + master_model = master_data.master_model + sep_model = separation_data.separation_model - def eval_master_violation(block_idx): + def eval_master_violation(scenario_idx): """ - Evaluate violation of `perf_con` by variables of + Evaluate violation of `ss_ineq_con` by variables of specified master block. """ - new_con_map = ( - model_data.separation_model.util.map_new_constraint_list_to_original_con - ) - in_new_cons = perf_con_to_maximize in new_con_map - if in_new_cons: - sep_con = new_con_map[perf_con_to_maximize] - else: - sep_con = perf_con_to_maximize - master_con = model_data.master_model.scenarios[block_idx, 0].find_component( - sep_con + master_con = master_model.scenarios[scenario_idx].find_component( + ss_ineq_con_to_maximize ) return value(master_con) # initialize from master block with max violation of the - # performance constraint of interest. This gives the best known + # second-stage ineq constraint of interest. Gives the best known # feasible solution (for case of non-discrete uncertainty sets). - block_num = max(range(model_data.iteration + 1), key=eval_master_violation) - - master_blk = model_data.master_model.scenarios[block_num, 0] - master_blks = list(model_data.master_model.scenarios.values()) - fsv_set = ComponentSet(master_blk.util.first_stage_variables) - sep_model = model_data.separation_model - - def get_parent_master_blk(var): - """ - Determine the master model scenario block of which - a given variable is a child component (or descendant). - """ - parent = var.parent_block() - while parent not in master_blks: - parent = parent.parent_block() - return parent - - for master_var in master_blk.component_data_objects(Var, active=True): - # parent block of the variable need not be `master_blk` - # (e.g. for first stage and decision rule variables, it - # may be the nominal block) - parent_master_blk = get_parent_master_blk(master_var) - sep_var_name = master_var.getname( - relative_to=parent_master_blk, fully_qualified=True - ) - - # initialize separation problem var to value from master block - sep_var = sep_model.find_component(sep_var_name) + worst_master_block_idx = max( + master_model.scenarios.keys(), key=eval_master_violation + ) + worst_case_master_blk = master_model.scenarios[worst_master_block_idx] + for sep_var in sep_model.all_variables: + master_var = worst_case_master_blk.find_component(sep_var) sep_var.set_value(value(master_var, exception=False)) - # fix first-stage variables (including decision rule vars) - if master_var in fsv_set: - sep_var.fix() - - # initialize uncertain parameter variables to most recent - # point added to master + # for discrete uncertainty sets, the uncertain parameters + # have already been addressed if config.uncertainty_set.geometry != Geometry.DISCRETE_SCENARIOS: - param_vars = sep_model.util.uncertain_param_vars - latest_param_values = model_data.points_added_to_master[block_num] - for param_var, val in zip(param_vars.values(), latest_param_values): + param_vars = sep_model.uncertainty.uncertain_param_var_list + param_values = separation_data.points_added_to_master[worst_master_block_idx] + for param_var, val in zip(param_vars, param_values): param_var.set_value(val) - # if static approximation, fix second-stage variables - # and deactivate the decision rule equations - for c in model_data.separation_model.util.second_stage_variables: - if config.decision_rule_order != 0: - c.unfix() - else: - c.fix() - if config.decision_rule_order == 0: - for v in model_data.separation_model.util.decision_rule_eqns: - v.deactivate() - for v in model_data.separation_model.util.decision_rule_vars: - v.fix() - - if any(c.active for c in model_data.separation_model.util.h_x_q_constraints): - raise AttributeError( - "All h(x,q) type constraints must be deactivated in separation." - ) + aux_param_vars = sep_model.uncertainty.auxiliary_var_list + aux_param_values = separation_data.auxiliary_values_for_master_points[ + worst_master_block_idx + ] + for aux_param_var, aux_val in zip(aux_param_vars, aux_param_values): + aux_param_var.set_value(aux_val) # confirm the initial point is feasible for cases where # we expect it to be (i.e. non-discrete uncertainty sets). # otherwise, log the violated constraints tol = ABS_CON_CHECK_FEAS_TOL - perf_con_name_repr = get_con_name_repr( - separation_model=model_data.separation_model, - con=perf_con_to_maximize, - with_orig_name=True, - with_obj_name=True, + ss_ineq_con_name_repr = get_con_name_repr( + separation_model=sep_model, con=ss_ineq_con_to_maximize, with_obj_name=True ) uncertainty_set_is_discrete = ( config.uncertainty_set.geometry is Geometry.DISCRETE_SCENARIOS @@ -996,17 +894,34 @@ def get_parent_master_blk(var): for con in sep_model.component_data_objects(Constraint, active=True): lslack, uslack = con.lslack(), con.uslack() if (lslack < -tol or uslack < -tol) and not uncertainty_set_is_discrete: - con_name_repr = get_con_name_repr( - separation_model=model_data.separation_model, - con=con, - with_orig_name=True, - with_obj_name=False, - ) config.progress_logger.debug( - f"Initial point for separation of performance constraint " - f"{perf_con_name_repr} violates the model constraint " - f"{con_name_repr} by more than {tol}. " - f"(lslack={con.lslack()}, uslack={con.uslack()})" + f"Initial point for separation of second-stage ineq constraint " + f"{ss_ineq_con_name_repr} violates the model constraint " + f"{con.name!r} by more than {tol} ({lslack=}, {uslack=})" + ) + + for con in sep_model.uncertainty.certain_param_var_cons: + trivially_infeasible = ( + con.lslack() < -ABS_CON_CHECK_FEAS_TOL + or con.uslack() < -ABS_CON_CHECK_FEAS_TOL + ) + if trivially_infeasible: + # this should never happen in the context of a full solve, + # since the certain parameters should be at their + # nominal values, and the nominal point was already + # confirmed to be a member of the set + config.progress_logger.error( + f"Uncertainty set " + f"(type {type(config.uncertainty_set).__name__}) " + f"constraint {con.name!r}, with expression {con.expr}, " + "is trivially infeasible at the parameter realization " + f"{config.nominal_uncertain_param_vals}. " + f"Check implementation of " + f"{config.uncertainty_set.set_as_constraint.__name__}." + ) + raise ValueError( + f"Trivial infeasibility detected in the uncertainty set " + f"(type {type(config.uncertainty_set).__name__}) constraints." ) @@ -1015,25 +930,30 @@ def get_parent_master_blk(var): def solver_call_separation( - model_data, config, solve_globally, perf_con_to_maximize, perf_cons_to_evaluate + separation_data, + master_data, + solve_globally, + ss_ineq_con_to_maximize, + ss_ineq_cons_to_evaluate, ): """ Invoke subordinate solver(s) on separation problem. Parameters ---------- - model_data : SeparationProblemData + separation_data : SeparationProblemData Separation problem data. - config : ConfigDict - PyROS solver settings. + master_data : MasterProblemData + Master problem data. solve_globally : bool True to solve separation problems globally, False to solve locally. - perf_con_to_maximize : Constraint - Performance constraint for which to solve separation problem. + ss_ineq_con_to_maximize : Constraint + Second-stage inequality constraint + for which to solve separation problem. Informs the objective (constraint violation) to maximize. - perf_cons_to_evaluate : list of Constraint - Performance constraints whose expressions are to be + ss_ineq_cons_to_evaluate : list of Constraint + Second-stage inequality constraints whose expressions are to be evaluated at the separation problem solution obtained. @@ -1042,32 +962,29 @@ def solver_call_separation( solve_call_results : pyros.solve_data.SeparationSolveCallResults Solve results for separation problem of interest. """ - # objective corresponding to specified performance constraint - objectives_map = model_data.separation_model.util.map_obj_to_constr - separation_obj = objectives_map[perf_con_to_maximize] - - if solve_globally: - solvers = [config.global_solver] + config.backup_global_solvers - else: - solvers = [config.local_solver] + config.backup_local_solvers - - # keep track of solver statuses for output logging - solver_status_dict = {} - nlp_model = model_data.separation_model + config = separation_data.config + # prepare the problem + separation_model = separation_data.separation_model + objectives_map = separation_data.separation_model.second_stage_ineq_con_to_obj_map + separation_obj = objectives_map[ss_ineq_con_to_maximize] + initialize_separation(ss_ineq_con_to_maximize, separation_data, master_data) + separation_obj.activate() - # get name of constraint for loggers + # get name (index) of constraint for loggers con_name_repr = get_con_name_repr( - separation_model=nlp_model, - con=perf_con_to_maximize, - with_orig_name=True, + separation_model=separation_model, + con=ss_ineq_con_to_maximize, with_obj_name=True, ) - solve_mode = "global" if solve_globally else "local" - # === Initialize separation problem; fix first-stage variables - initialize_separation(perf_con_to_maximize, model_data, config) - - separation_obj.activate() + # keep track of solver statuses for output logging + solve_mode = "global" if solve_globally else "local" + solver_status_dict = {} + if solve_globally: + solvers = [config.global_solver] + config.backup_global_solvers + else: + solvers = [config.local_solver] + config.backup_local_solvers + solve_mode_adverb = "globally" if solve_globally else "locally" solve_call_results = SeparationSolveCallResults( solved_globally=solve_globally, @@ -1076,58 +993,37 @@ def solver_call_separation( found_violation=False, subsolver_error=False, ) - timer = TicTocTimer() for idx, opt in enumerate(solvers): if idx > 0: config.progress_logger.warning( f"Invoking backup solver {opt!r} " f"(solver {idx + 1} of {len(solvers)}) for {solve_mode} " - f"separation of performance constraint {con_name_repr} " - f"in iteration {model_data.iteration}." - ) - orig_setting, custom_setting_present = adjust_solver_time_settings( - model_data.timing, opt, config - ) - model_data.timing.start_timer(f"main.{solve_mode}_separation") - timer.tic(msg=None) - try: - results = opt.solve( - nlp_model, - tee=config.tee, - load_solutions=False, - symbolic_solver_labels=True, + f"separation of second-stage inequality constraint {con_name_repr} " + f"in iteration {separation_data.iteration}." ) - except ApplicationError: - # account for possible external subsolver errors - # (such as segmentation faults, function evaluation - # errors, etc.) - adverb = "globally" if solve_globally else "locally" - config.progress_logger.error( + results = call_solver( + model=separation_model, + solver=opt, + config=config, + timing_obj=separation_data.timing, + timer_name=f"main.{solve_mode}_separation", + err_msg=( f"Optimizer {repr(opt)} ({idx + 1} of {len(solvers)}) " f"encountered exception attempting " - f"to {adverb} solve separation problem for constraint " - f"{con_name_repr} in iteration {model_data.iteration}." - ) - raise - else: - setattr(results.solver, TIC_TOC_SOLVE_TIME_ATTR, timer.toc(msg=None)) - model_data.timing.stop_timer(f"main.{solve_mode}_separation") - finally: - revert_solver_max_time_adjustment( - opt, orig_setting, custom_setting_present, config - ) + f"to {solve_mode_adverb} solve separation problem for constraint " + f"{con_name_repr} in iteration {separation_data.iteration}." + ), + ) # record termination condition for this particular solver solver_status_dict[str(opt)] = results.solver.termination_condition solve_call_results.results_list.append(results) # has PyROS time limit been reached? - elapsed = get_main_elapsed_time(model_data.timing) - if config.time_limit: - if elapsed >= config.time_limit: - solve_call_results.time_out = True - separation_obj.deactivate() - return solve_call_results + if check_time_limit_reached(separation_data.timing, config): + solve_call_results.time_out = True + separation_obj.deactivate() + return solve_call_results # if separation problem solved to optimality, record results # and exit @@ -1138,13 +1034,11 @@ def solver_call_separation( acceptable_conditions ) if optimal_termination: - nlp_model.solutions.load_from(results) + separation_model.solutions.load_from(results) # record second-stage and state variable values solve_call_results.variable_values = ComponentMap() - for var in nlp_model.util.second_stage_variables: - solve_call_results.variable_values[var] = value(var) - for var in nlp_model.util.state_vars: + for var in separation_model.all_adjustable_variables: solve_call_results.variable_values[var] = value(var) # record uncertain parameter realization @@ -1153,9 +1047,15 @@ def solver_call_separation( solve_call_results.violating_param_realization, solve_call_results.scaled_violations, solve_call_results.found_violation, - ) = evaluate_performance_constraint_violations( - model_data, config, perf_con_to_maximize, perf_cons_to_evaluate + ) = evaluate_ss_ineq_con_violations( + separation_data=separation_data, + ss_ineq_con_to_maximize=ss_ineq_con_to_maximize, + ss_ineq_cons_to_evaluate=ss_ineq_cons_to_evaluate, ) + solve_call_results.auxiliary_param_values = [ + auxvar.value + for auxvar in separation_model.uncertainty.auxiliary_var_list + ] separation_obj.deactivate() @@ -1163,9 +1063,9 @@ def solver_call_separation( else: config.progress_logger.debug( f"Solver {opt} ({idx + 1} of {len(solvers)}) " - f"failed for {solve_mode} separation of performance " + f"failed for {solve_mode} separation of second-stage inequality " f"constraint {con_name_repr} in iteration " - f"{model_data.iteration}. Termination condition: " + f"{separation_data.iteration}. Termination condition: " f"{results.solver.termination_condition!r}." ) config.progress_logger.debug(f"Results:\n{results.solver}") @@ -1183,15 +1083,15 @@ def solver_call_separation( ( config.uncertainty_set.type + "_" - + nlp_model.name + + separation_model.name + "_separation_" - + str(model_data.iteration) + + str(separation_data.iteration) + "_obj_" + objective + ".bar" ), ) - nlp_model.write( + separation_model.write( output_problem_path, io_options={'symbolic_solver_labels': True} ) serialization_msg = ( @@ -1200,8 +1100,8 @@ def solver_call_separation( ) solve_call_results.message = ( "Could not successfully solve separation problem of iteration " - f"{model_data.iteration} " - f"for performance constraint {con_name_repr} with any of the " + f"{separation_data.iteration} " + f"for second-stage inequality constraint {con_name_repr} with any of the " f"provided subordinate {solve_mode} optimizers. " f"(Termination statuses: " f"{[str(term_cond) for term_cond in solver_status_dict.values()]}.)" @@ -1215,7 +1115,11 @@ def solver_call_separation( def discrete_solve( - model_data, config, solve_globally, perf_con_to_maximize, perf_cons_to_evaluate + separation_data, + master_data, + solve_globally, + ss_ineq_con_to_maximize, + ss_ineq_cons_to_evaluate, ): """ Obtain separation problem solution for each scenario @@ -1224,27 +1128,27 @@ def discrete_solve( Parameters ---------- - model_data : SeparationProblemData + separation_data : SeparationProblemData Separation problem data. - config : ConfigDict - PyROS solver settings. + master_data : MasterProblemData + Master problem data. solver : solver type Primary subordinate optimizer with which to solve the model. solve_globally : bool Is separation problem to be solved globally. - perf_con_to_maximize : Constraint - Performance constraint for which to solve separation + ss_ineq_con_to_maximize : Constraint + Second-stage inequality constraint for which to solve separation problem. - perf_cons_to_evaluate : list of Constraint - Performance constraints whose expressions are to be + ss_ineq_cons_to_evaluate : list of Constraint + Secnod-stage inequality constraints whose expressions are to be evaluated at the each of separation problem solutions obtained. Returns ------- discrete_separation_results : DiscreteSeparationSolveCallResults - Separation solver call results on performance constraint + Separation solver call results on second-stage inequality constraint of interest for every scenario considered. Notes @@ -1253,23 +1157,23 @@ def discrete_solve( variables and uncertain parameter values uniquely define the state variables, this method need be only be invoked once per separation loop. Subject to our assumption, the choice of objective - (``perf_con_to_maximize``) should not affect the solutions returned - beyond subsolver tolerances. For other performance constraints, the + (``ss_ineq_con_to_maximize``) should not affect the solutions returned + beyond subsolver tolerances. + For other second-stage inequality constraints, the optimal separation problem solution can then be evaluated by simple enumeration of the solutions returned by this function, since for discrete uncertainty sets, the number of feasible separation solutions is, under our assumption, merely equal to the number of scenarios in the uncertainty set. """ + config = separation_data.config - # Ensure uncertainty set constraints deactivated - model_data.separation_model.util.uncertainty_set_constraint.deactivate() uncertain_param_vars = list( - model_data.separation_model.util.uncertain_param_vars.values() + separation_data.separation_model.uncertainty.uncertain_param_var_list ) # skip scenarios already added to most recent master problem - master_scenario_idxs = model_data.idxs_of_master_scenarios + master_scenario_idxs = separation_data.idxs_of_master_scenarios scenario_idxs_to_separate = [ idx for idx, _ in enumerate(config.uncertainty_set.scenarios) @@ -1277,33 +1181,121 @@ def discrete_solve( ] solve_call_results_dict = {} - for scenario_idx in scenario_idxs_to_separate: + for idx, scenario_idx in enumerate(scenario_idxs_to_separate): # fix uncertain parameters to scenario value # hence, no need to activate uncertainty set constraints scenario = config.uncertainty_set.scenarios[scenario_idx] for param, coord_val in zip(uncertain_param_vars, scenario): param.fix(coord_val) + # debug statement for solving square problem for each scenario + config.progress_logger.debug( + f"Attempting to solve square problem for discrete scenario {scenario}" + f", {idx + 1} of {len(scenario_idxs_to_separate)} total" + ) + # obtain separation problem solution solve_call_results = solver_call_separation( - model_data=model_data, - config=config, + separation_data=separation_data, + master_data=master_data, solve_globally=solve_globally, - perf_con_to_maximize=perf_con_to_maximize, - perf_cons_to_evaluate=perf_cons_to_evaluate, + ss_ineq_con_to_maximize=ss_ineq_con_to_maximize, + ss_ineq_cons_to_evaluate=ss_ineq_cons_to_evaluate, ) solve_call_results.discrete_set_scenario_index = scenario_idx solve_call_results_dict[scenario_idx] = solve_call_results # halt at first encounter of unacceptable termination - termination_not_ok = ( - solve_call_results.subsolver_error or solve_call_results.time_out - ) + termination_not_ok = solve_call_results.time_out if termination_not_ok: break + # report any subsolver errors, but continue + if solve_call_results.subsolver_error: + config.progress_logger.warning( + f"All solvers failed to solve discrete scenario {scenario_idx}: " + f"{scenario}" + ) + return DiscreteSeparationSolveCallResults( solved_globally=solve_globally, solver_call_results=solve_call_results_dict, - performance_constraint=perf_con_to_maximize, + second_stage_ineq_con=ss_ineq_con_to_maximize, ) + + +class SeparationProblemData: + """ + Container for objects related to the PyROS separation problem. + + Parameters + ---------- + model_data : ModelData + PyROS model data object, equipped with the + fully preprocessed working model. + + Attributes + ---------- + separation_model : BlockData + Separation problem model object. + timing : TimingData + Main timer for the current problem being solved. + config : ConfigDict + PyROS solver options. + separation_priority_order : dict + Standardized/preprocessed mapping from names of the + second-stage inequality constraint objects to integers + specifying their priorities. + iteration : int + Index of the current PyROS cutting set iteration. + points_added_to_master : dict + Maps each scenario index (2-tuple of ints) of the + master problem model object to the corresponding + uncertain parameter realization. + auxiliary_values_for_master_points : dict + Maps each scenario index (2-tuple of ints) of the + master problem model object to the auxiliary parameter + values corresponding to the associated uncertain parameter + realization. + idxs_of_master_scenarios : None or list of int + If ``config.uncertainty_set`` is of type + :class:`~pyomo.contrib.pyros.uncertainty_sets.DiscreteScenarioSet`, + then this attribute is a list + of ints, each entry of which is a list index for + an entry in the ``scenarios`` attribute of the + uncertainty set. Otherwise, this attribute is set to None. + """ + + def __init__(self, model_data): + """Initialize self (see class docstring).""" + self.separation_model = construct_separation_problem(model_data) + self.timing = model_data.timing + self.separation_priority_order = model_data.separation_priority_order.copy() + self.iteration = 0 + + config = model_data.config + self.config = config + self.points_added_to_master = {(0, 0): config.nominal_uncertain_param_vals} + self.auxiliary_values_for_master_points = { + (0, 0): [ + # auxiliary variable values for nominal point have already + # been computed and loaded into separation model + aux_var.value + for aux_var in self.separation_model.uncertainty.auxiliary_var_list + ] + } + + if config.uncertainty_set.geometry == Geometry.DISCRETE_SCENARIOS: + self.idxs_of_master_scenarios = [ + config.uncertainty_set.scenarios.index( + tuple(config.nominal_uncertain_param_vals) + ) + ] + else: + self.idxs_of_master_scenarios = None + + def solve_separation(self, master_data): + """ + Solve the separation problem. + """ + return solve_separation_problem(self, master_data) diff --git a/pyomo/contrib/pyros/solve_data.py b/pyomo/contrib/pyros/solve_data.py index bc6c071c9a3..03f9a9b1de3 100644 --- a/pyomo/contrib/pyros/solve_data.py +++ b/pyomo/contrib/pyros/solve_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ """ -Objects to contain all model data and solve results for the ROSolver +Containers for PyROS subproblem solve results. """ @@ -33,15 +33,19 @@ class ROSolveResults(object): Attributes ---------- - config : ConfigDict, optional + config : ConfigDict User-specified solver settings. - iterations : int, optional + iterations : int Number of iterations required by PyROS. - time : float, optional + time : float Total elapsed time (or wall time), in seconds. - final_objective_value : float, optional + final_objective_value : float Final objective function value to report. - pyros_termination_condition : pyros.util.pyrosTerminationStatus + If a nominal objective focus was elected, then the + value of the nominal objective function is reported. + If a worst-case objective focus was elected, then + the value of the worst-case objective function is reported. + pyros_termination_condition : pyrosTerminationCondition Indicator of the manner of termination. """ @@ -83,46 +87,39 @@ def __str__(self): return "\n".join(lines) -class MasterProblemData(object): +class MasterResults: """ - Container for the grcs master problem + Result of solving the master problem in a single PyROS iteration. - Attributes: - :master_model: master problem model object - :base_model: block representing the original model object - :iteration: current iteration of the algorithm - """ - - -class SeparationProblemData(object): - """ - Container for the grcs separation problem - - Attributes: - :separation_model: separation problem model object - :points_added_to_master: list of parameter violations added to the master problem over the course of the algorithm - :separation_problem_subsolver_statuses: list of subordinate sub-solver statuses throughout separations - :total_global_separation_solvers: Counter for number of times global solvers were employed in separation - :constraint_violations: List of constraint violations identified in separation + Attributes + ---------- + master_model : ConcreteModel + Master model. + feasibility_problem_results : SolverResults + Feasibility problem subsolver results. + master_results_list : list of SolverResults + List of subsolver results for the master problem. + pyros_termination_condition : None or pyrosTerminationCondition + PyROS termination status established via solution of + the master problem. + If `None`, then no termination status has been established. """ - pass - - -class MasterResult(object): - """Data class for master problem results data. - - Attributes: - - termination_condition: Solver termination condition - - fsv_values: list of design variable values - - ssv_values: list of control variable values - - first_stage_objective: objective contribution due to first-stage degrees of freedom - - second_stage_objective: objective contribution due to second-stage degrees of freedom - - grcs_termination_condition: the conditions under which the grcs terminated - (max_iter, robust_optimal, error) - - pyomo_results: results object from solve() statement - - """ + def __init__( + self, + master_model=None, + feasibility_problem_results=None, + master_results_list=None, + pyros_termination_condition=None, + ): + """Initialize self (see class docstring).""" + self.master_model = master_model + self.feasibility_problem_results = feasibility_problem_results + if master_results_list is None: + self.master_results_list = [] + else: + self.master_results_list = list(master_results_list) + self.pyros_termination_condition = pyros_termination_condition class SeparationSolveCallResults: @@ -147,19 +144,22 @@ class SeparationSolveCallResults: subordinate local/global solvers provided (including backup) and the number of scenarios in the uncertainty set. scaled_violations : ComponentMap, optional - Mapping from performance constraints to floats equal + Mapping from second-stage inequality constraints to floats equal to their scaled violations by separation problem solution stored in this result. violating_param_realization : list of float, optional Uncertain parameter realization for reported separation problem solution. + auxiliary_param_values : list of float, optional + Auxiliary parameter values corresponding to the + uncertain parameter realization `violating_param_realization`. variable_values : ComponentMap, optional Second-stage DOF and state variable values for reported separation problem solution. found_violation : bool, optional - True if violation of performance constraint (i.e. constraint - expression value) by reported separation solution was found to - exceed tolerance, False otherwise. + True if violation of second-stage inequality constraint + (i.e. constraint expression value) by reported separation + solution was found to exceed tolerance, False otherwise. time_out : bool, optional True if PyROS time limit reached attempting to solve the separation problem, False otherwise. @@ -178,6 +178,7 @@ class SeparationSolveCallResults: results_list scaled_violations violating_param_realizations + auxiliary_param_values variable_values found_violation time_out @@ -191,6 +192,7 @@ def __init__( results_list=None, scaled_violations=None, violating_param_realization=None, + auxiliary_param_values=None, variable_values=None, found_violation=None, time_out=None, @@ -202,6 +204,7 @@ def __init__( self.solved_globally = solved_globally self.scaled_violations = scaled_violations self.violating_param_realization = violating_param_realization + self.auxiliary_param_values = auxiliary_param_values self.variable_values = variable_values self.found_violation = found_violation self.time_out = time_out @@ -228,31 +231,6 @@ def termination_acceptable(self, acceptable_terminations): for res in self.results_list ) - def evaluate_total_solve_time(self, evaluator_func, **evaluator_func_kwargs): - """ - Evaluate total time required by subordinate solvers - for separation problem of interest, according to Pyomo - ``SolverResults`` objects stored in ``self.results_list``. - - Parameters - ---------- - evaluator_func : callable - Solve time evaluator function. - This callable should accept an object of type - ``pyomo.opt.results.SolverResults``, and - return a float equal to the time required. - **evaluator_func_kwargs : dict, optional - Keyword arguments to evaluator function. - - Returns - ------- - float - Total time spent by solvers. - """ - return sum( - evaluator_func(res, **evaluator_func_kwargs) for res in self.results_list - ) - class DiscreteSeparationSolveCallResults: """ @@ -268,27 +246,24 @@ class DiscreteSeparationSolveCallResults: Mapping from discrete uncertainty set scenario list indexes to solver call results for separation problems subject to the scenarios. - performance_constraint : Constraint - Separation problem performance constraint for which + second_stage_ineq_con : Constraint + Separation problem second-stage inequality constraint for which `self` was generated. Attributes ---------- solved_globally - scenario_indexes solver_call_results - performance_constraint - time_out - subsolver_error + second_stage_ineq_con """ def __init__( - self, solved_globally, solver_call_results=None, performance_constraint=None + self, solved_globally, solver_call_results=None, second_stage_ineq_con=None ): """Initialize self (see class docstring).""" self.solved_globally = solved_globally self.solver_call_results = solver_call_results - self.performance_constraint = performance_constraint + self.second_stage_ineq_con = second_stage_ineq_con @property def time_out(self): @@ -302,36 +277,11 @@ def time_out(self): @property def subsolver_error(self): """ - bool : True if there is a subsolver error status for at least - one of the the ``SeparationSolveCallResults`` objects listed + bool : True if there is a subsolver error status for all + of the ``SeparationSolveCallResults`` objects listed in `self`, False otherwise. """ - return any(res.subsolver_error for res in self.solver_call_results.values()) - - def evaluate_total_solve_time(self, evaluator_func, **evaluator_func_kwargs): - """ - Evaluate total time required by subordinate solvers - for separation problem of interest. - - Parameters - ---------- - evaluator_func : callable - Solve time evaluator function. - This callable should accept an object of type - ``pyomo.opt.results.SolveResults``, and - return a float equal to the time required. - **evaluator_func_kwargs : dict, optional - Keyword arguments to evaluator function. - - Returns - ------- - float - Total time spent by solvers. - """ - return sum( - solver_call_res.evaluate_total_solve_time(evaluator_func) - for solver_call_res in self.solver_call_results.values() - ) + return all(res.subsolver_error for res in self.solver_call_results.values()) class SeparationLoopResults: @@ -345,39 +295,55 @@ class SeparationLoopResults: True if separation problems were solved to global optimality, False otherwise. solver_call_results : ComponentMap - Mapping from performance constraints to corresponding + Mapping from second-stage inequality constraints to corresponding ``SeparationSolveCallResults`` objects. - worst_case_perf_con : None or int, optional - Performance constraint mapped to ``SeparationSolveCallResults`` + worst_case_ss_ineq_con : None or Constraint + Second-stage inequality constraint mapped to + ``SeparationSolveCallResults`` object in `self` corresponding to maximally violating separation problem solution. + all_discrete_scenarios_exhausted : bool, optional + For problems with discrete uncertainty sets, + True if all scenarios were explicitly accounted for in master + (which occurs if there have been + as many PyROS iterations as there are scenarios in the set) + False otherwise. Attributes ---------- - solver_call_results - solved_globally - worst_case_perf_con - found_violation - violating_param_realization - scaled_violations - violating_separation_variable_values - subsolver_error - time_out + solved_globally : bool + True if global solver was used, False otherwise. + solver_call_results : ComponentMap + Mapping from second-stage inequality constraints to corresponding + ``SeparationSolveCallResults`` objects. + worst_case_ss_ineq_con : None or ConstraintData + Worst-case second-stage inequality constraint. + all_discrete_scenarios_exhausted : bool + True if all scenarios of the discrete set were exhausted + already explicitly accounted for in the master problems, + False otherwise. """ - def __init__(self, solved_globally, solver_call_results, worst_case_perf_con): + def __init__( + self, + solved_globally, + solver_call_results, + worst_case_ss_ineq_con, + all_discrete_scenarios_exhausted=False, + ): """Initialize self (see class docstring).""" self.solver_call_results = solver_call_results self.solved_globally = solved_globally - self.worst_case_perf_con = worst_case_perf_con + self.worst_case_ss_ineq_con = worst_case_ss_ineq_con + self.all_discrete_scenarios_exhausted = all_discrete_scenarios_exhausted @property def found_violation(self): """ bool : True if separation solution for at least one ``SeparationSolveCallResults`` object listed in self - was reported to violate its corresponding performance - constraint, False otherwise. + was reported to violate its corresponding second-stage + inequality constraint, False otherwise. """ return any( solver_call_res.found_violation @@ -390,29 +356,45 @@ def violating_param_realization(self): None or list of float : Uncertain parameter values for for maximally violating separation problem solution, specified according to solver call results object - listed in self at index ``self.worst_case_perf_con``. - If ``self.worst_case_perf_con`` is not specified, + listed in self at index ``self.worst_case_ss_ineq_con``. + If ``self.worst_case_ss_ineq_con`` is not specified, then None is returned. """ - if self.worst_case_perf_con is not None: + if self.worst_case_ss_ineq_con is not None: return self.solver_call_results[ - self.worst_case_perf_con + self.worst_case_ss_ineq_con ].violating_param_realization else: return None + @property + def auxiliary_param_values(self): + """ + None or list of float : Auxiliary parameter values for the + maximially violating separation problem solution. + """ + if self.worst_case_ss_ineq_con is not None: + return self.solver_call_results[ + self.worst_case_ss_ineq_con + ].auxiliary_param_values + else: + return None + @property def scaled_violations(self): """ - None or ComponentMap : Scaled performance constraint violations + None or ComponentMap : Scaled second-stage inequality + constraint violations for maximally violating separation problem solution, specified according to solver call results object - listed in self at index ``self.worst_case_perf_con``. - If ``self.worst_case_perf_con`` is not specified, + listed in self at index ``self.worst_case_ss_ineq_con``. + If ``self.worst_case_ss_ineq_con`` is not specified, then None is returned. """ - if self.worst_case_perf_con is not None: - return self.solver_call_results[self.worst_case_perf_con].scaled_violations + if self.worst_case_ss_ineq_con is not None: + return self.solver_call_results[ + self.worst_case_ss_ineq_con + ].scaled_violations else: return None @@ -422,20 +404,20 @@ def violating_separation_variable_values(self): None or ComponentMap : Second-stage and state variable values for maximally violating separation problem solution, specified according to solver call results object - listed in self at index ``self.worst_case_perf_con``. - If ``self.worst_case_perf_con`` is not specified, + listed in self at index ``self.worst_case_ss_ineq_con``. + If ``self.worst_case_ss_ineq_con`` is not specified, then None is returned. """ - if self.worst_case_perf_con is not None: - return self.solver_call_results[self.worst_case_perf_con].variable_values + if self.worst_case_ss_ineq_con is not None: + return self.solver_call_results[self.worst_case_ss_ineq_con].variable_values else: return None @property - def violated_performance_constraints(self): + def violated_second_stage_ineq_cons(self): """ - list of Constraint : Performance constraints for which violation - found. + list of Constraint : Second-stage inequality constraints + for which violation found. """ return [ con @@ -448,11 +430,14 @@ def subsolver_error(self): """ bool : Return True if subsolver error reported for at least one ``SeparationSolveCallResults`` stored in - `self`, False otherwise. + `self` and no violations are found, False otherwise. """ - return any( - solver_call_res.subsolver_error - for solver_call_res in self.solver_call_results.values() + return ( + any( + solver_call_res.subsolver_error + for solver_call_res in self.solver_call_results.values() + ) + and not self.found_violation ) @property @@ -467,31 +452,6 @@ def time_out(self): for solver_call_res in self.solver_call_results.values() ) - def evaluate_total_solve_time(self, evaluator_func, **evaluator_func_kwargs): - """ - Evaluate total time required by subordinate solvers - for separation problem of interest. - - Parameters - ---------- - evaluator_func : callable - Solve time evaluator function. - This callable should accept an object of type - ``pyomo.opt.results.SolveResults``, and - return a float equal to the time required. - **evaluator_func_kwargs : dict, optional - Keyword arguments to evaluator function. - - Returns - ------- - float - Total time spent by solvers. - """ - return sum( - res.evaluate_total_solve_time(evaluator_func) - for res in self.solver_call_results.values() - ) - class SeparationResults: """ @@ -506,18 +466,14 @@ class SeparationResults: Attributes ---------- - local_separation_loop_results - global_separation_loop_results - main_loop_results - subsolver_error - time_out - solved_locally - solved_globally - found_violation - violating_param_realization - scaled_violations - violating_separation_variable_values - robustness_certified + local_separation_loop_results : None or SeparationLoopResults + Local separation results. If separation problems + were not solved locally, then this attribute is set + to None. + global_separation_loop_results : None or SeparationLoopResults + Global separation results. If separation problems + were not solved globally, then this attribute is set + to None. """ def __init__(self, local_separation_loop_results, global_separation_loop_results): @@ -600,12 +556,24 @@ def get_violating_attr(self, attr_name): return getattr(self.main_loop_results, attr_name, None) @property - def worst_case_perf_con(self): + def all_discrete_scenarios_exhausted(self): """ - ConstraintData : Performance constraint corresponding to the + bool : For problems where the uncertainty set is of type + DiscreteScenarioSet, + True if last master problem solved explicitly + accounts for all scenarios in the uncertainty set, + False otherwise. + """ + return self.get_violating_attr("all_discrete_scenarios_exhausted") + + @property + def worst_case_ss_ineq_con(self): + """ + ConstraintData : Second-stage inequality constraint + corresponding to the separation solution chosen for the next master problem. """ - return self.get_violating_attr("worst_case_perf_con") + return self.get_violating_attr("worst_case_ss_ineq_con") @property def main_loop_results(self): @@ -636,19 +604,28 @@ def violating_param_realization(self): None or list of float : Uncertain parameter values for maximally violating separation problem solution reported in local or global separation loop results. - If no such solution found, (i.e. ``worst_case_perf_con`` + If no such solution found, (i.e. ``worst_case_ss_ineq_con`` set to None for both local and global loop results), then None is returned. """ return self.get_violating_attr("violating_param_realization") + @property + def auxiliary_param_values(self): + """ + None or list of float: Auxiliary parameter values accompanying + `self.violating_param_realization`. + """ + return self.get_violating_attr("auxiliary_param_values") + @property def scaled_violations(self): """ - None or ComponentMap : Scaled performance constraint violations + None or ComponentMap : + Scaled second-stage inequality constraint violations for maximally violating separation problem solution reported in local or global separation loop results. - If no such solution found, (i.e. ``worst_case_perf_con`` + If no such solution found, (i.e. ``worst_case_ss_ineq_con`` set to None for both local and global loop results), then None is returned. """ @@ -660,72 +637,18 @@ def violating_separation_variable_values(self): None or ComponentMap : Second-stage and state variable values for maximally violating separation problem solution reported in local or global separation loop results. - If no such solution found, (i.e. ``worst_case_perf_con`` + If no such solution found, (i.e. ``worst_case_ss_ineq_con`` set to None for both local and global loop results), then None is returned. """ return self.get_violating_attr("violating_separation_variable_values") @property - def violated_performance_constraints(self): - """ - Return list of violated performance constraints. - """ - return self.get_violating_attr("violated_performance_constraints") - - def evaluate_local_solve_time(self, evaluator_func, **evaluator_func_kwargs): - """ - Evaluate total time required by local subordinate solvers - for separation problem of interest. - - Parameters - ---------- - evaluator_func : callable - Solve time evaluator function. - This callable should accept an object of type - ``pyomo.opt.results.SolverResults``, and - return a float equal to the time required. - **evaluator_func_kwargs : dict, optional - Keyword arguments to evaluator function. - - Returns - ------- - float - Total time spent by local solvers. - """ - if self.solved_locally: - return self.local_separation_loop_results.evaluate_total_solve_time( - evaluator_func, **evaluator_func_kwargs - ) - else: - return 0 - - def evaluate_global_solve_time(self, evaluator_func, **evaluator_func_kwargs): + def violated_second_stage_ineq_cons(self): """ - Evaluate total time required by global subordinate solvers - for separation problem of interest. - - Parameters - ---------- - evaluator_func : callable - Solve time evaluator function. - This callable should accept an object of type - ``pyomo.opt.results.SolverResults``, and - return a float equal to the time required. - **evaluator_func_kwargs : dict, optional - Keyword arguments to evaluator function. - - Returns - ------- - float - Total time spent by global solvers. + Return list of violated second-stage inequality constraints. """ - if self.solved_globally: - return self.global_separation_loop_results.evaluate_total_solve_time( - evaluator_func, **evaluator_func_kwargs - ) - else: - return 0 + return self.get_violating_attr("violated_second_stage_ineq_cons") @property def robustness_certified(self): @@ -754,30 +677,3 @@ def robustness_certified(self): is_robust = heuristically_robust return is_robust - - def generate_subsolver_results(self, include_local=True, include_global=True): - """ - Generate flattened sequence all Pyomo SolverResults objects - for all ``SeparationSolveCallResults`` objects listed in - the local and global ``SeparationLoopResults`` - attributes of `self`. - - Yields - ------ - pyomo.opt.SolverResults - """ - if include_local and self.local_separation_loop_results is not None: - all_local_call_results = ( - self.local_separation_loop_results.solver_call_results.values() - ) - for solve_call_res in all_local_call_results: - for res in solve_call_res.results_list: - yield res - - if include_global and self.global_separation_loop_results is not None: - all_global_call_results = ( - self.global_separation_loop_results.solver_call_results.values() - ) - for solve_call_res in all_global_call_results: - for res in solve_call_res.results_list: - yield res diff --git a/pyomo/contrib/pyros/tests/__init__.py b/pyomo/contrib/pyros/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/pyros/tests/__init__.py +++ b/pyomo/contrib/pyros/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/pyros/tests/test_config.py b/pyomo/contrib/pyros/tests/test_config.py index 76b9114b9e6..9adb10432b5 100644 --- a/pyomo/contrib/pyros/tests/test_config.py +++ b/pyomo/contrib/pyros/tests/test_config.py @@ -1,28 +1,38 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + """ Test objects for construction of PyROS ConfigDict. """ import logging -import unittest +import pyomo.common.unittest as unittest -from pyomo.core.base import ConcreteModel, Var, _VarData +from pyomo.core.base import ConcreteModel, Var, VarData from pyomo.common.log import LoggingIntercept from pyomo.common.errors import ApplicationError -from pyomo.core.base.param import Param, _ParamData +from pyomo.core.base.param import Param, ParamData from pyomo.contrib.pyros.config import ( InputDataStandardizer, - mutable_param_validator, - LoggerType, + uncertain_param_validator, + uncertain_param_data_validator, + logger_domain, SolverNotResolvable, - PositiveIntOrMinusOne, + positive_int_or_minus_one, pyros_config, SolverIterable, SolverResolvable, ) from pyomo.contrib.pyros.util import ObjectiveType from pyomo.opt import SolverFactory, SolverResults -from pyomo.contrib.pyros.uncertainty_sets import BoxSet -from pyomo.common.dependencies import numpy_available class TestInputDataStandardizer(unittest.TestCase): @@ -38,7 +48,7 @@ def test_single_component_data(self): mdl = ConcreteModel() mdl.v = Var([0, 1]) - standardizer_func = InputDataStandardizer(Var, _VarData) + standardizer_func = InputDataStandardizer(Var, VarData) standardizer_input = mdl.v[0] standardizer_output = standardizer_func(standardizer_input) @@ -74,7 +84,7 @@ def test_standardizer_indexed_component(self): mdl = ConcreteModel() mdl.v = Var([0, 1]) - standardizer_func = InputDataStandardizer(Var, _VarData) + standardizer_func = InputDataStandardizer(Var, VarData) standardizer_input = mdl.v standardizer_output = standardizer_func(standardizer_input) @@ -113,7 +123,7 @@ def test_standardizer_multiple_components(self): mdl.v = Var([0, 1]) mdl.x = Var(["a", "b"]) - standardizer_func = InputDataStandardizer(Var, _VarData) + standardizer_func = InputDataStandardizer(Var, VarData) standardizer_input = [mdl.v[0], mdl.x] standardizer_output = standardizer_func(standardizer_input) @@ -154,7 +164,7 @@ def test_standardizer_invalid_duplicates(self): mdl.v = Var([0, 1]) mdl.x = Var(["a", "b"]) - standardizer_func = InputDataStandardizer(Var, _VarData, allow_repeats=False) + standardizer_func = InputDataStandardizer(Var, VarData, allow_repeats=False) exc_str = r"Standardized.*list.*contains duplicate entries\." with self.assertRaisesRegex(ValueError, exc_str): @@ -165,7 +175,7 @@ def test_standardizer_invalid_type(self): Test standardizer raises exception as expected when input is of invalid type. """ - standardizer_func = InputDataStandardizer(Var, _VarData) + standardizer_func = InputDataStandardizer(Var, VarData) exc_str = r"Input object .*is not of valid component type.*" with self.assertRaisesRegex(TypeError, exc_str): @@ -178,7 +188,7 @@ def test_standardizer_iterable_with_invalid_type(self): """ mdl = ConcreteModel() mdl.v = Var([0, 1]) - standardizer_func = InputDataStandardizer(Var, _VarData) + standardizer_func = InputDataStandardizer(Var, VarData) exc_str = r"Input object .*entry of iterable.*is not of valid component type.*" with self.assertRaisesRegex(TypeError, exc_str): @@ -189,7 +199,7 @@ def test_standardizer_invalid_str_passed(self): Test standardizer raises exception as expected when input is of invalid type str. """ - standardizer_func = InputDataStandardizer(Var, _VarData) + standardizer_func = InputDataStandardizer(Var, VarData) exc_str = r"Input object .*is not of valid component type.*" with self.assertRaisesRegex(TypeError, exc_str): @@ -201,7 +211,7 @@ def test_standardizer_invalid_uninitialized_params(self): uninitialized entries passed. """ standardizer_func = InputDataStandardizer( - ctype=Param, cdatatype=_ParamData, ctype_validator=mutable_param_validator + ctype=Param, cdatatype=ParamData, ctype_validator=uncertain_param_validator ) mdl = ConcreteModel() @@ -217,7 +227,7 @@ def test_standardizer_invalid_immutable_params(self): Param object(s) passed. """ standardizer_func = InputDataStandardizer( - ctype=Param, cdatatype=_ParamData, ctype_validator=mutable_param_validator + ctype=Param, cdatatype=ParamData, ctype_validator=uncertain_param_validator ) mdl = ConcreteModel() @@ -227,6 +237,19 @@ def test_standardizer_invalid_immutable_params(self): with self.assertRaisesRegex(ValueError, exc_str): standardizer_func(mdl.p) + def test_standardizer_invalid_vars_not_constructed(self): + """ + Test standardizer with uncertain param validator + raises exception when Var that is not constructed is passed. + """ + standardizer_func = InputDataStandardizer( + ctype=Var, cdatatype=VarData, ctype_validator=uncertain_param_validator + ) + bad_var = Var() + exc_str = r"Length of .*does not match that of.*index set" + with self.assertRaisesRegex(ValueError, exc_str): + standardizer_func(bad_var) + def test_standardizer_valid_mutable_params(self): """ Test Param-like standardizer works as expected for sequence @@ -237,7 +260,7 @@ def test_standardizer_valid_mutable_params(self): mdl.p2 = Param(["a", "b"], initialize=1, mutable=True) standardizer_func = InputDataStandardizer( - ctype=Param, cdatatype=_ParamData, ctype_validator=mutable_param_validator + ctype=Param, cdatatype=ParamData, ctype_validator=uncertain_param_validator ) standardizer_input = [mdl.p1[0], mdl.p2] @@ -270,6 +293,132 @@ def test_standardizer_valid_mutable_params(self): ), ) + def test_standardizer_multiple_ctypes_with_validator(self): + """ + Test input data standardizer when there are + multiple component/component data types. + """ + mdl = ConcreteModel() + mdl.p = Param([0, 1], initialize=0, mutable=True) + mdl.v = Var(["a", "b"], initialize=1) + + standardizer_func = InputDataStandardizer( + ctype=(Var, Param), + cdatatype=(VarData, ParamData), + ctype_validator=uncertain_param_validator, + ) + standardizer_input = [mdl.p, mdl.v] + standardizer_output = standardizer_func(standardizer_input) + expected_standardizer_output = [mdl.p[0], mdl.p[1], mdl.v["a"], mdl.v["b"]] + + self.assertIsInstance( + standardizer_output, + list, + msg=( + "Standardized output should be of type list, " + f"but is of type {standardizer_output.__class__.__name__}." + ), + ) + self.assertEqual( + len(standardizer_output), + len(expected_standardizer_output), + msg="Length of standardizer output is not as expected.", + ) + enum_zip = enumerate(zip(expected_standardizer_output, standardizer_output)) + for idx, (input, output) in enum_zip: + self.assertIs( + input, + output, + msg=( + f"Entry {input} (id {id(input)}) " + "is not identical to " + f"input component data object {output} " + f"(id {id(output)})" + ), + ) + + def test_standardizer_with_both_validators(self): + """ + Test input data standardizer when there is + a validator for the component and component data types. + """ + mdl = ConcreteModel() + mdl.p = Param([0, 1], initialize=0, mutable=True) + mdl.v = Var(["a", "b"], initialize=1) + + standardizer_func = InputDataStandardizer( + ctype=(Var, Param), + cdatatype=(VarData, ParamData), + ctype_validator=uncertain_param_validator, + cdatatype_validator=uncertain_param_data_validator, + ) + + err_str_a = r".*VarData object with name 'v\[a\]' is not fixed" + err_str_b = r".*VarData object with name 'v\[b\]' is not fixed" + + with self.assertRaisesRegex(ValueError, err_str_a): + standardizer_func([mdl.p, mdl.v]) + + with self.assertRaisesRegex(ValueError, err_str_a): + standardizer_func([mdl.p, mdl.v["a"], mdl.v["b"]]) + + with self.assertRaisesRegex(ValueError, err_str_a): + standardizer_func(mdl.v["a"]) + + mdl.v["a"].fix() + va_output = standardizer_func(mdl.v["a"]) + self.assertEqual(va_output, [mdl.v["a"]]) + + with self.assertRaisesRegex(ValueError, err_str_b): + standardizer_func([mdl.p, mdl.v["a"], mdl.v["b"]]) + + mdl.v["b"].fix() + va_vb_output = standardizer_func([mdl.v["a"], mdl.v["b"]]) + self.assertEqual(va_vb_output, [mdl.v["a"], mdl.v["b"]]) + + va_vb_unraveled_output = standardizer_func(mdl.v) + self.assertEqual(va_vb_unraveled_output, [mdl.v["a"], mdl.v["b"]]) + + # the param data validator supports unfixed Vars that + # have identical bounds + mdl.v["a"].unfix() + mdl.v["a"].setlb(1) + mdl.v["a"].setub(1) + va_vb_unraveled_output_2 = standardizer_func(mdl.v) + self.assertEqual(va_vb_unraveled_output_2, [mdl.v["a"], mdl.v["b"]]) + + # ensure exception raised if the bounds are not identical + # (even if equal in value) + mdl.v["a"].setlb(1.0) + with self.assertRaisesRegex(ValueError, err_str_a): + standardizer_func([mdl.p, mdl.v["a"], mdl.v["b"]]) + + mdl.q = Param(initialize=1, mutable=True) + mdl.v["a"].setlb(mdl.q) + with self.assertRaisesRegex(ValueError, err_str_a): + standardizer_func([mdl.p, mdl.v["a"], mdl.v["b"]]) + + # support fixing by bounds that are identical mutable expressions + mdl.v["a"].setub(mdl.q) + va_vb_unraveled_output_2 = standardizer_func(mdl.v) + self.assertEqual(va_vb_unraveled_output_2, [mdl.v["a"], mdl.v["b"]]) + + def test_standardizer_domain_name(self): + """ + Test domain name function works as expected. + """ + std1 = InputDataStandardizer(ctype=Param, cdatatype=ParamData) + self.assertEqual( + std1.domain_name(), f"(iterable of) {Param.__name__}, {ParamData.__name__}" + ) + + std2 = InputDataStandardizer(ctype=(Param, Var), cdatatype=(ParamData, VarData)) + self.assertEqual( + std2.domain_name(), + f"(iterable of) {Param.__name__}, {Var.__name__}, " + f"{ParamData.__name__}, {VarData.__name__}", + ) + AVAILABLE_SOLVER_TYPE_NAME = "available_pyros_test_solver" @@ -557,16 +706,29 @@ def test_positive_int_or_minus_one(self): """ Test positive int or -1 validator works as expected. """ - standardizer_func = PositiveIntOrMinusOne() - self.assertIs( - standardizer_func(1.0), + standardizer_func = positive_int_or_minus_one + ans = standardizer_func(1.0) + self.assertEqual( + ans, 1, - msg=(f"{PositiveIntOrMinusOne.__name__} does not standardize as expected."), + msg=f"{positive_int_or_minus_one.__name__} output value not as expected.", ) + self.assertIs( + type(ans), + int, + msg=f"{positive_int_or_minus_one.__name__} output type not as expected.", + ) + + ans = standardizer_func(-1.0) self.assertEqual( - standardizer_func(-1.00), + ans, -1, - msg=(f"{PositiveIntOrMinusOne.__name__} does not standardize as expected."), + msg=f"{positive_int_or_minus_one.__name__} output value not as expected.", + ) + self.assertIs( + type(ans), + int, + msg=f"{positive_int_or_minus_one.__name__} output type not as expected.", ) exc_str = r"Expected positive int or -1, but received value.*" @@ -576,26 +738,26 @@ def test_positive_int_or_minus_one(self): standardizer_func(0) -class TestLoggerType(unittest.TestCase): +class TestLoggerDomain(unittest.TestCase): """ - Test logger type validator. + Test logger type domain validator. """ def test_logger_type(self): """ Test logger type validator. """ - standardizer_func = LoggerType() + standardizer_func = logger_domain mylogger = logging.getLogger("example") self.assertIs( standardizer_func(mylogger), mylogger, - msg=f"{LoggerType.__name__} output not as expected", + msg=f"{standardizer_func.__name__} output not as expected", ) self.assertIs( standardizer_func(mylogger.name), mylogger, - msg=f"{LoggerType.__name__} output not as expected", + msg=f"{standardizer_func.__name__} output not as expected", ) exc_str = r"A logger name must be a string" diff --git a/pyomo/contrib/pyros/tests/test_grcs.py b/pyomo/contrib/pyros/tests/test_grcs.py index df3568e42a4..e4ce53df14c 100644 --- a/pyomo/contrib/pyros/tests/test_grcs.py +++ b/pyomo/contrib/pyros/tests/test_grcs.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,65 +9,31 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -''' -Unit tests for the grcs API -One class per function being tested, minimum one test per class -''' +""" +Tests for the PyROS solver. +""" + +import logging +import math +import time import pyomo.common.unittest as unittest +from pyomo.common.collections import Bunch +from pyomo.common.errors import InvalidValueError from pyomo.common.log import LoggingIntercept -from pyomo.common.collections import ComponentSet, ComponentMap -from pyomo.common.config import ConfigBlock, ConfigValue +from pyomo.common.tee import capture_output from pyomo.core.base.set_types import NonNegativeIntegers -from pyomo.core.expr import ( - identify_variables, - identify_mutable_parameters, - MonomialTermExpression, - SumExpression, -) -from pyomo.contrib.pyros.util import ( - selective_clone, - add_decision_rule_variables, - add_decision_rule_constraints, - turn_bounds_to_constraints, - transform_to_standard_form, - ObjectiveType, - pyrosTerminationCondition, - coefficient_matching, - TimingData, - IterationLogRecord, -) -from pyomo.contrib.pyros.util import replace_uncertain_bounds_with_constraints -from pyomo.contrib.pyros.util import get_vars_from_component -from pyomo.contrib.pyros.util import identify_objective_functions -from pyomo.common.collections import Bunch -import time -import math -from pyomo.contrib.pyros.util import time_code -from pyomo.contrib.pyros.uncertainty_sets import ( - UncertaintySet, - BoxSet, - CardinalitySet, - BudgetSet, - FactorModelSet, - PolyhedralSet, - EllipsoidalSet, - AxisAlignedEllipsoidalSet, - IntersectionSet, - DiscreteScenarioSet, - Geometry, +from pyomo.repn.plugins import nl_writer as pyomo_nl_writer +import pyomo.repn.ampl as pyomo_ampl_repn +from pyomo.common.dependencies import ( + attempt_import, + numpy as np, + numpy_available, + scipy_available, ) -from pyomo.contrib.pyros.master_problem_methods import ( - add_scenario_to_master, - initial_construct_master, - solve_master, - minimize_dr_vars, -) -from pyomo.contrib.pyros.solve_data import MasterProblemData, ROSolveResults -from pyomo.common.dependencies import numpy as np, numpy_available -from pyomo.common.dependencies import scipy as sp, scipy_available -from pyomo.environ import maximize as pyo_max -from pyomo.common.errors import ApplicationError +from pyomo.common.errors import ApplicationError, InfeasibleConstraintException +from pyomo.core.expr import replace_expressions +from pyomo.environ import maximize as pyo_max, units as u from pyomo.opt import ( SolverResults, SolverStatus, @@ -79,7 +45,6 @@ Reals, Set, Block, - ConstraintList, ConcreteModel, Constraint, Expression, @@ -87,24 +52,36 @@ Param, SolverFactory, Var, - cos, exp, log, - sin, sqrt, value, maximize, minimize, ) -import logging -from itertools import chain +from pyomo.contrib.pyros.solve_data import ROSolveResults +from pyomo.contrib.pyros.uncertainty_sets import ( + BoxSet, + AxisAlignedEllipsoidalSet, + FactorModelSet, + IntersectionSet, + DiscreteScenarioSet, +) +from pyomo.contrib.pyros.util import ( + IterationLogRecord, + ObjectiveType, + pyrosTerminationCondition, +) logger = logging.getLogger(__name__) +parameterized, param_available = attempt_import('parameterized') -if not (numpy_available and scipy_available): - raise unittest.SkipTest('PyROS unit tests require numpy and scipy') +if not (numpy_available and scipy_available and param_available): + raise unittest.SkipTest('PyROS unit tests require parameterized, numpy, and scipy') + +parameterized = parameterized.parameterized # === Config args for testing nlp_solver = 'ipopt' @@ -213,2779 +190,422 @@ def solve(self, model, **kwargs): return results -# === util.py -class testSelectiveClone(unittest.TestCase): - ''' - Testing for the selective_clone function. This function takes as input a Pyomo model object - and a list of variables objects "first_stage_vars" in that Pyomo model which should *not* be cloned. - It returns a clone of the original Pyomo model object wherein the "first_stage_vars" members are unchanged, - i.e. all cloned model expressions still reference the "first_stage_vars" of the original model object. - ''' +def build_leyffer(): + """ + Build original Leyffer two-variable test problem. + """ + m = ConcreteModel() - def test_cloning_negative_case(self): - ''' - Testing correct behavior if incorrect first_stage_vars list object is passed to selective_clone - ''' - m = ConcreteModel() - m.x = Var(initialize=2) - m.y = Var(initialize=2) - m.p = Param(initialize=1) - m.con = Constraint(expr=m.x * m.p + m.y <= 0) + m.u = Param(initialize=1.125, mutable=True) - n = ConcreteModel() - n.x = Var() - m.first_stage_vars = [n.x] + m.x1 = Var(initialize=0, bounds=(0, None)) + m.x2 = Var(initialize=0, bounds=(0, None)) - cloned_model = selective_clone(block=m, first_stage_vars=m.first_stage_vars) + m.con = Constraint(expr=m.x1 * sqrt(m.u) - m.u * m.x2 <= 2) + m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - self.assertNotEqual( - id(m.first_stage_vars), - id(cloned_model.first_stage_vars), - msg="First stage variables should not be equal.", - ) + return m - def test_cloning_positive_case(self): - ''' - Testing if selective_clone works correctly for correct first_stage_var object definition. - ''' - m = ConcreteModel() - m.x = Var(initialize=2) - m.y = Var(initialize=2) - m.p = Param(initialize=1) - m.con = Constraint(expr=m.x * m.p + m.y <= 0) - m.first_stage_vars = [m.x] - cloned_model = selective_clone(block=m, first_stage_vars=m.first_stage_vars) +def build_leyffer_two_cons(): + """ + Build extended Leyffer problem with single uncertain parameter. + """ + m = ConcreteModel() - self.assertEqual( - id(m.x), id(cloned_model.x), msg="First stage variables should be equal." - ) - self.assertNotEqual( - id(m.y), - id(cloned_model.y), - msg="Non-first-stage variables should not be equal.", - ) - self.assertNotEqual( - id(m.p), id(cloned_model.p), msg="Params should not be equal." - ) - self.assertNotEqual( - id(m.con), - id(cloned_model.con), - msg="Constraint objects should not be equal.", - ) + m.u = Param(initialize=1.125, mutable=True) + + m.x1 = Var(initialize=0, bounds=(0, None)) + m.x2 = Var(initialize=0, bounds=(0, None)) + m.x3 = Var(initialize=0, bounds=(None, None)) + m.con1 = Constraint(expr=m.x1 * sqrt(m.u) - m.x2 * m.u <= 2) + m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) -class testAddDecisionRuleVars(unittest.TestCase): + m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + + return m + + +def build_leyffer_two_cons_two_params(): + """ + Build extended Leyffer problem with two uncertain parameters. """ - Test method for adding decision rule variables to working model. - The number of decision rule variables per control variable - should depend on: + m = ConcreteModel() + + m.u1 = Param(initialize=1.125, mutable=True) + m.u2 = Param(initialize=1, mutable=True) + + m.x1 = Var(initialize=0, bounds=(0, None)) + m.x2 = Var(initialize=0, bounds=(0, None)) + m.x3 = Var(initialize=0, bounds=(None, None)) + + m.con1 = Constraint(expr=m.x1 * sqrt(m.u1) - m.x2 * m.u1 <= 2) + m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u1 == m.x3) + + m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) - - the number of uncertain parameters in the model - - the decision rule order specified by the user. + return m + + +class TestPyROSSolveFactorModelSet(unittest.TestCase): + """ + Test PyROS successfully solves model with factor model uncertainty. """ - def make_simple_test_model(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_two_stg_mod_with_factor_model_set(self): """ - Make simple test model for DR variable - declaration testing. + Test two-stage model with `FactorModelSet` + as the uncertainty set. """ - m = ConcreteModel() + m = build_leyffer_two_cons_two_params() - # uncertain parameters - m.p = Param(range(3), initialize=0, mutable=True) + # Define the uncertainty set + # we take the parameter `u2` to be 'fixed' + fset = FactorModelSet( + origin=[1.125, 1], beta=1, number_of_factors=1, psi_mat=[[0.5], [0.5]] + ) - # second-stage variables - m.z = Var([0, 1], initialize=0) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - # util block - m.util = Block() - m.util.first_stage_variables = [] - m.util.second_stage_variables = list(m.z.values()) - m.util.uncertain_params = list(m.p.values()) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('baron') + global_subsolver = SolverFactory("baron") - return m + # Call the PyROS solver + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u1, m.u2], + uncertainty_set=fset, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, + ) + + # check successful termination + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", + ) - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - def test_correct_num_dr_vars_static(self): + +class TestPyROSSolveAxisAlignedEllipsoidalSet(unittest.TestCase): + """ + Unit tests for the AxisAlignedEllipsoidalSet. + """ + + @unittest.skipUnless( + scip_available and scip_license_is_valid, "SCIP is not available and licensed" + ) + def test_two_stg_mod_with_axis_aligned_set(self): """ - Test DR variable setup routines declare the correct - number of DR coefficient variables, static DR case. + Test two-stage model with `AxisAlignedEllipsoidalSet` + as the uncertainty set. """ - model_data = ROSolveResults() - model_data.working_model = m = self.make_simple_test_model() + # define model + m = build_leyffer_two_cons_two_params() + + # Define the uncertainty set + # we take the parameter `u2` to be 'fixed' + ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[1, 0]) - config = Bunch() - config.decision_rule_order = 0 + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - add_decision_rule_variables(model_data=model_data, config=config) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory("scip") + global_subsolver = SolverFactory("scip") - for indexed_dr_var in m.util.decision_rule_vars: - self.assertEqual( - len(indexed_dr_var), - 1, - msg=( - "Number of decision rule coefficient variables " - f"in indexed Var object {indexed_dr_var.name!r}" - "does not match correct value." - ), - ) + # Call the PyROS solver + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u1, m.u2], + uncertainty_set=ellipsoid, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, + ) + # check successful termination self.assertEqual( - len(ComponentSet(m.util.decision_rule_vars)), - len(m.util.second_stage_variables), - msg=( - "Number of unique indexed DR variable components should equal " - "number of second-stage variables." - ), + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", + ) + self.assertGreater( + results.iterations, + 0, + msg="Robust infeasible model terminated in 0 iterations (nominal case).", ) - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - def test_correct_num_dr_vars_affine(self): + +class TestPyROSSolveDiscreteSet(unittest.TestCase): + """ + Test PyROS solves models with discrete uncertainty sets. + """ + + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_two_stg_model_discrete_set_single_scenario(self): """ - Test DR variable setup routines declare the correct - number of DR coefficient variables, affine DR case. + Test two-stage model under discrete uncertainty with + a single scenario. """ - model_data = ROSolveResults() - model_data.working_model = m = self.make_simple_test_model() + m = build_leyffer_two_cons_two_params() - config = Bunch() - config.decision_rule_order = 1 + # uncertainty set + discrete_set = DiscreteScenarioSet(scenarios=[(1.125, 1)]) - add_decision_rule_variables(model_data=model_data, config=config) + # Instantiate PyROS solver + pyros_solver = SolverFactory("pyros") - for indexed_dr_var in m.util.decision_rule_vars: - self.assertEqual( - len(indexed_dr_var), - 1 + len(m.util.uncertain_params), - msg=( - "Number of decision rule coefficient variables " - f"in indexed Var object {indexed_dr_var.name!r}" - "does not match correct value." - ), - ) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('baron') + global_subsolver = SolverFactory("baron") + + # Call the PyROS solver + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.u1, m.u2], + uncertainty_set=discrete_set, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, + ) + + # check successful termination + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", + ) + # only one iteration required self.assertEqual( - len(ComponentSet(m.util.decision_rule_vars)), - len(m.util.second_stage_variables), + results.iterations, + 1, msg=( - "Number of unique indexed DR variable components should equal " - "number of second-stage variables." + "PyROS was unable to solve a singleton discrete set instance " + " successfully within a single iteration." ), ) - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - def test_correct_num_dr_vars_quadratic(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_two_stg_model_discrete_set(self): """ - Test DR variable setup routines declare the correct - number of DR coefficient variables, quadratic DR case. + Test PyROS successfully solves two-stage model with + multiple scenarios. """ - model_data = ROSolveResults() - model_data.working_model = m = self.make_simple_test_model() + m = build_leyffer() - config = Bunch() - config.decision_rule_order = 2 + discrete_set = DiscreteScenarioSet(scenarios=[[0.25], [1.125], [2]]) - add_decision_rule_variables(model_data=model_data, config=config) + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - num_params = len(m.util.uncertain_params) - correct_num_dr_vars = ( - 1 # static term - + num_params # affine terms - + sp.special.comb(num_params, 2, repetition=True, exact=True) - # quadratic terms + res = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.u], + uncertainty_set=discrete_set, + local_solver=global_solver, + global_solver=global_solver, + decision_rule_order=0, + solve_master_globally=True, + objective_focus=ObjectiveType.worst_case, ) - for indexed_dr_var in m.util.decision_rule_vars: - self.assertEqual( - len(indexed_dr_var), - correct_num_dr_vars, - msg=( - "Number of decision rule coefficient variables " - f"in indexed Var object {indexed_dr_var.name!r}" - "does not match correct value." - ), - ) self.assertEqual( - len(ComponentSet(m.util.decision_rule_vars)), - len(m.util.second_stage_variables), + res.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, msg=( - "Number of unique indexed DR variable components should equal " - "number of second-stage variables." + "Failed to solve discrete set multiple scenarios instance to " + "robust optimality" ), ) -class testAddDecisionRuleConstraints(unittest.TestCase): - """ - Test method for adding decision rule equality constraints - to the working model. There should be as many decision - rule equality constraints as there are second-stage - variables, and each constraint should relate a second-stage - variable to the uncertain parameters and corresponding - decision rule variables. - """ - - def make_simple_test_model(self): +class TestPyROSRobustInfeasible(unittest.TestCase): + @unittest.skipUnless(baron_available, "BARON is not available and licensed") + def test_pyros_robust_infeasible(self): """ - Make simple model for DR constraint testing. + Test PyROS behavior when robust infeasibility detected + from a master problem. """ m = ConcreteModel() + m.q = Param(initialize=0.5, mutable=True) + m.x = Var(bounds=(m.q, 1)) + # makes model infeasible since 2 is outside bounds + m.con1 = Constraint(expr=m.x == 2) + m.obj = Objective(expr=m.x) + baron = SolverFactory("baron") + pyros = SolverFactory("pyros") + results = pyros.solve( + model=m, + first_stage_variables=[m.x], + second_stage_variables=[], + uncertain_params=m.q, + uncertainty_set=BoxSet([[0, 1]]), + local_solver=baron, + global_solver=baron, + solve_master_globally=True, + ) - # uncertain parameters - m.p = Param(range(3), initialize=0, mutable=True) + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, + ) + self.assertEqual(results.iterations, 1) + # since x was not initialized + self.assertEqual(results.final_objective_value, None) - # second-stage variables - m.z = Var([0, 1], initialize=0) - # util block - m.util = Block() - m.util.first_stage_variables = [] - m.util.second_stage_variables = list(m.z.values()) - m.util.uncertain_params = list(m.p.values()) +global_solver = "baron" - return m - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - def test_num_dr_eqns_added_correct(self): +# === regression test for the solver +@unittest.skipUnless(baron_available, "Global NLP solver is not available.") +class RegressionTest(unittest.TestCase): + """ + Collection of regression tests. + """ + + def build_regression_test_model(self): """ - Check that number of DR equality constraints added - by constraint declaration routines matches the number - of second-stage variables in the model. + Create model used for regression tests. """ - model_data = ROSolveResults() - model_data.working_model = m = self.make_simple_test_model() + m = ConcreteModel() + m.name = "s381" - # === Decision rule vars have been added - m.decision_rule_var_0 = Var([0], initialize=0) - m.decision_rule_var_1 = Var([0], initialize=0) - m.util.decision_rule_vars = [m.decision_rule_var_0, m.decision_rule_var_1] + m.set_params = Set(initialize=list(range(4))) + m.p = Param(m.set_params, initialize=2, mutable=True) - # set up simple config-like object - config = Bunch() - config.decision_rule_order = 0 + m.x1 = Var(within=Reals, bounds=(0, None), initialize=0.1) + m.x2 = Var(within=Reals, bounds=(0, None), initialize=0.1) + m.x3 = Var(within=Reals, bounds=(0, None), initialize=0.1) - add_decision_rule_constraints(model_data=model_data, config=config) + m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) - self.assertEqual( - len(m.util.decision_rule_eqns), - len(m.util.second_stage_variables), - msg="The number of decision rule constraints added to model should equal" - "the number of control variables in the model.", - ) - - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - def test_dr_eqns_form_correct(self): - """ - Check that form of decision rule equality constraints - is as expected. - - Decision rule equations should be of the standard form: - (sum of DR monomial terms) - (second-stage variable) == 0 - where each monomial term should be of form: - (product of uncertain parameters) * (decision rule variable) - - This test checks that the equality constraints are of this - standard form. - """ - # set up simple model data like object - model_data = ROSolveResults() - model_data.working_model = m = self.make_simple_test_model() - - # set up simple config-like object - config = Bunch() - config.decision_rule_order = 2 - - # add DR variables and constraints - add_decision_rule_variables(model_data, config) - add_decision_rule_constraints(model_data, config) - - # DR polynomial terms and order in which they should - # appear depends on number of uncertain parameters - # and order in which the parameters are listed. - # so uncertain parameters participating in each term - # of the monomial is known, and listed out here. - dr_monomial_param_combos = [ - (1,), - (m.p[0],), - (m.p[1],), - (m.p[2],), - (m.p[0], m.p[0]), - (m.p[0], m.p[1]), - (m.p[0], m.p[2]), - (m.p[1], m.p[1]), - (m.p[1], m.p[2]), - (m.p[2], m.p[2]), - ] - - dr_zip = zip( - m.util.second_stage_variables, - m.util.decision_rule_vars, - m.util.decision_rule_eqns, - ) - for ss_var, indexed_dr_var, dr_eq in dr_zip: - dr_eq_terms = dr_eq.body.args - - # check constraint body is sum expression - self.assertTrue( - isinstance(dr_eq.body, SumExpression), - msg=( - f"Body of DR constraint {dr_eq.name!r} is not of type " - f"{SumExpression.__name__}." - ), - ) - - # ensure DR equation has correct number of (additive) terms - self.assertEqual( - len(dr_eq_terms), - len(dr_monomial_param_combos) + 1, - msg=( - "Number of additive terms in the DR expression of " - f"DR constraint with name {dr_eq.name!r} does not match " - "expected value." - ), - ) - - # check last term is negative of second-stage variable - second_stage_var_term = dr_eq_terms[-1] - last_term_is_neg_ss_var = ( - isinstance(second_stage_var_term, MonomialTermExpression) - and (second_stage_var_term.args[0] == -1) - and (second_stage_var_term.args[1] is ss_var) - and len(second_stage_var_term.args) == 2 - ) - self.assertTrue( - last_term_is_neg_ss_var, - msg=( - "Last argument of last term in second-stage variable" - f"term of DR constraint with name {dr_eq.name!r} " - "is not the negative corresponding second-stage variable " - f"{ss_var.name!r}" - ), - ) - - # now we check the other terms. - # these should comprise the DR polynomial expression - dr_polynomial_terms = dr_eq_terms[:-1] - dr_polynomial_zip = zip( - dr_polynomial_terms, indexed_dr_var.values(), dr_monomial_param_combos - ) - for idx, (term, dr_var, param_combo) in enumerate(dr_polynomial_zip): - # term should be a monomial expression of form - # (uncertain parameter product) * (decision rule variable) - # so length of expression object should be 2 - self.assertEqual( - len(term.args), - 2, - msg=( - f"Length of `args` attribute of term {str(term)} " - f"of DR equation {dr_eq.name!r} is not as expected. " - f"Args: {term.args}" - ), - ) - - # check that uncertain parameters participating in - # the monomial are as expected - param_product_multiplicand = term.args[0] - if idx == 0: - # static DR term - param_combo_found_in_term = (param_product_multiplicand,) - param_names = (str(param) for param in param_combo) - elif len(param_combo) == 1: - # affine DR terms - param_combo_found_in_term = (param_product_multiplicand,) - param_names = (param.name for param in param_combo) - else: - # higher-order DR terms - param_combo_found_in_term = param_product_multiplicand.args - param_names = (param.name for param in param_combo) - - self.assertEqual( - param_combo_found_in_term, - param_combo, - msg=( - f"All but last multiplicand of DR monomial {str(term)} " - f"is not the uncertain parameter tuple " - f"({', '.join(param_names)})." - ), - ) - - # check that DR variable participating in the monomial - # is as expected - dr_var_multiplicand = term.args[1] - self.assertIs( - dr_var_multiplicand, - dr_var, - msg=( - f"Last multiplicand of DR monomial {str(term)} " - f"is not the DR variable {dr_var.name!r}." - ), - ) - - -class testTurnBoundsToConstraints(unittest.TestCase): - def test_bounds_to_constraints(self): - m = ConcreteModel() - m.x = Var(initialize=1, bounds=(0, 1)) - m.y = Var(initialize=0, bounds=(None, 1)) - m.w = Var(initialize=0, bounds=(1, None)) - m.z = Var(initialize=0, bounds=(None, None)) - turn_bounds_to_constraints(m.z, m) - self.assertEqual( - len(list(m.component_data_objects(Constraint))), - 0, - msg="Inequality constraints were written for bounds on a variable with no bounds.", - ) - turn_bounds_to_constraints(m.y, m) - self.assertEqual( - len(list(m.component_data_objects(Constraint))), - 1, - msg="Inequality constraints were not " - "written correctly for a variable with an upper bound and no lower bound.", - ) - turn_bounds_to_constraints(m.w, m) - self.assertEqual( - len(list(m.component_data_objects(Constraint))), - 2, - msg="Inequality constraints were not " - "written correctly for a variable with a lower bound and no upper bound.", - ) - turn_bounds_to_constraints(m.x, m) - self.assertEqual( - len(list(m.component_data_objects(Constraint))), - 4, - msg="Inequality constraints were not " - "written correctly for a variable with both lower and upper bound.", - ) - - def test_uncertain_bounds_to_constraints(self): - # test model - m = ConcreteModel() - # parameters - m.p = Param(initialize=8, mutable=True) - m.r = Param(initialize=-5, mutable=True) - m.q = Param(initialize=1, mutable=False) - m.s = Param(initialize=1, mutable=True) - m.n = Param(initialize=1, mutable=True) - - # variables, with bounds contingent on params - m.u = Var(initialize=0, bounds=(0, m.p)) - m.v = Var(initialize=1, bounds=(m.r, m.p)) - m.w = Var(initialize=1, bounds=(None, None)) - m.x = Var(initialize=1, bounds=(0, exp(-1 * m.p / 8) * m.q * m.s)) - m.y = Var(initialize=-1, bounds=(m.r * m.p, 0)) - m.z = Var(initialize=1, bounds=(0, m.s)) - m.t = Var(initialize=1, bounds=(0, m.p**2)) - - # objective - m.obj = Objective(sense=maximize, expr=m.x**2 - m.y + m.t**2 + m.v) - - # clone model - mod = m.clone() - uncertain_params = [mod.n, mod.p, mod.r] - - # check variable replacement without any active objective - # or active performance constraints - mod.obj.deactivate() - replace_uncertain_bounds_with_constraints(mod, uncertain_params) - self.assertTrue( - hasattr(mod, 'uncertain_var_bound_cons'), - msg='Uncertain variable bounds erroneously added. ' - 'Check only variables participating in active ' - 'objective and constraints are added.', - ) - self.assertFalse(mod.uncertain_var_bound_cons) - mod.obj.activate() - - # add performance constraints - constraints_m = ConstraintList() - m.add_component('perf_constraints', constraints_m) - constraints_m.add(m.w == 2 * m.x + m.y) - constraints_m.add(m.v + m.x + m.y >= 0) - constraints_m.add(m.y**2 + m.z >= 0) - constraints_m.add(m.x**2 + m.u <= 1) - constraints_m[4].deactivate() - - # clone model with constraints added - mod_2 = m.clone() - - # manually replace uncertain parameter bounds with explicit constraints - uncertain_cons = ConstraintList() - m.add_component('uncertain_var_bound_cons', uncertain_cons) - uncertain_cons.add(m.x - m.x.upper <= 0) - uncertain_cons.add(m.y.lower - m.y <= 0) - uncertain_cons.add(m.v - m.v._ub <= 0) - uncertain_cons.add(m.v.lower - m.v <= 0) - uncertain_cons.add(m.t - m.t.upper <= 0) - - # remove corresponding variable bounds - m.x.setub(None) - m.y.setlb(None) - m.v.setlb(None) - m.v.setub(None) - m.t.setub(None) - - # check that vars participating in - # active objective and activated constraints correctly determined - svars_con = ComponentSet(get_vars_from_component(mod_2, Constraint)) - svars_obj = ComponentSet(get_vars_from_component(mod_2, Objective)) - vars_in_active_cons = ComponentSet( - [mod_2.z, mod_2.w, mod_2.y, mod_2.x, mod_2.v] - ) - vars_in_active_obj = ComponentSet([mod_2.x, mod_2.y, mod_2.t, mod_2.v]) - self.assertEqual( - svars_con, - vars_in_active_cons, - msg='Mismatch of variables participating in activated constraints.', - ) - self.assertEqual( - svars_obj, - vars_in_active_obj, - msg='Mismatch of variables participating in activated objectives.', - ) - - # replace bounds in model with performance constraints - uncertain_params = [mod_2.p, mod_2.r] - replace_uncertain_bounds_with_constraints(mod_2, uncertain_params) - - # check that same number of constraints added to model - self.assertEqual( - len(list(m.component_data_objects(Constraint))), - len(list(mod_2.component_data_objects(Constraint))), - msg='Mismatch between number of explicit variable ' - 'bound inequality constraints added ' - 'automatically and added manually.', - ) - - # check that explicit constraints contain correct vars and params - vars_in_cons = ComponentSet() - params_in_cons = ComponentSet() - - # get variables, mutable params in the explicit constraints - cons = mod_2.uncertain_var_bound_cons - for idx in cons: - for p in identify_mutable_parameters(cons[idx].expr): - params_in_cons.add(p) - for v in identify_variables(cons[idx].expr): - vars_in_cons.add(v) - # reduce only to uncertain mutable params found - params_in_cons = params_in_cons & uncertain_params - - # expected participating variables - vars_with_bounds_removed = ComponentSet([mod_2.x, mod_2.y, mod_2.v, mod_2.t]) - # complete the check - self.assertEqual( - params_in_cons, - ComponentSet([mod_2.p, mod_2.r]), - msg='Mismatch of parameters added to explicit inequality constraints.', - ) - self.assertEqual( - vars_in_cons, - vars_with_bounds_removed, - msg='Mismatch of variables added to explicit inequality constraints.', - ) - - -class testTransformToStandardForm(unittest.TestCase): - def test_transform_to_std_form(self): - """Check that `pyros.util.transform_to_standard_form` works - correctly for an example model. That is: - - all Constraints with a finite `upper` or `lower` attribute - are either equality constraints, or inequalities - of the standard form `expression(vars) <= upper`; - - every inequality Constraint for which the `upper` and `lower` - attribute are identical is converted to an equality constraint; - - every inequality Constraint with distinct finite `upper` and - `lower` attributes is split into two standard form inequality - Constraints. - """ - - m = ConcreteModel() - - m.p = Param(initialize=1, mutable=True) - - m.x = Var(initialize=0) - m.y = Var(initialize=1) - m.z = Var(initialize=1) - - # example constraints - m.c1 = Constraint(expr=m.x >= 1) - m.c2 = Constraint(expr=-m.y <= 0) - m.c3 = Constraint(rule=(None, m.x + m.y, None)) - m.c4 = Constraint(rule=(1, m.x + m.y, 2)) - m.c5 = Constraint(rule=(m.p, m.x, m.p)) - m.c6 = Constraint(rule=(1.0000, m.z, 1.0)) - - # example ConstraintList - clist = ConstraintList() - m.add_component('clist', clist) - clist.add(m.y <= 0) - clist.add(m.x >= 1) - clist.add((0, m.x, 1)) - - num_orig_cons = len( - [ - con - for con in m.component_data_objects( - Constraint, active=True, descend_into=True - ) - ] - ) - # constraints with finite, distinct lower & upper bounds - num_lbub_cons = len( - [ - con - for con in m.component_data_objects( - Constraint, active=True, descend_into=True - ) - if con.lower is not None - and con.upper is not None - and con.lower is not con.upper - ] - ) - - # count constraints with no bounds - num_nobound_cons = len( - [ - con - for con in m.component_data_objects( - Constraint, active=True, descend_into=True - ) - if con.lower is None and con.upper is None - ] - ) - - transform_to_standard_form(m) - cons = [ - con - for con in m.component_data_objects( - Constraint, active=True, descend_into=True - ) - ] - for con in cons: - has_lb_or_ub = not (con.lower is None and con.upper is None) - if has_lb_or_ub and not con.equality: - self.assertTrue( - con.lower is None, - msg="Constraint %s not in standard form" % con.name, - ) - lb_is_ub = con.lower is con.upper - self.assertFalse( - lb_is_ub, - msg="Constraint %s should be converted to equality" % con.name, - ) - if con is not m.c3: - self.assertTrue( - has_lb_or_ub, - msg="Constraint %s should have" - " a lower or upper bound" % con.name, - ) - - self.assertEqual( - len( - [ - con - for con in m.component_data_objects( - Constraint, active=True, descend_into=True - ) - ] - ), - num_orig_cons + num_lbub_cons - num_nobound_cons, - msg="Expected number of constraints after\n " - "standardizing constraints not matched. " - "Number of constraints after\n " - "transformation" - " should be (number constraints in original " - "model) \n + (number of constraints with " - "distinct finite lower and upper bounds).", - ) - - def test_transform_does_not_alter_num_of_constraints(self): - """ - Check that if model does not contain any constraints - for which both the `lower` and `upper` attributes are - distinct and not None, then number of constraints remains the same - after constraint standardization. - Standard form for the purpose of PyROS is all inequality constraints - as `g(.)<=0`. - """ - m = ConcreteModel() - m.x = Var(initialize=1, bounds=(0, 1)) - m.y = Var(initialize=0, bounds=(None, 1)) - m.con1 = Constraint(expr=m.x >= 1 + m.y) - m.con2 = Constraint(expr=m.x**2 + m.y**2 >= 9) - original_num_constraints = len(list(m.component_data_objects(Constraint))) - transform_to_standard_form(m) - final_num_constraints = len(list(m.component_data_objects(Constraint))) - self.assertEqual( - original_num_constraints, - final_num_constraints, - msg="Transform to standard form function led to a " - "different number of constraints than in the original model.", - ) - number_of_non_standard_form_inequalities = len( - list( - c for c in list(m.component_data_objects(Constraint)) if c.lower != None - ) - ) - self.assertEqual( - number_of_non_standard_form_inequalities, - 0, - msg="All inequality constraints were not transformed to standard form.", - ) - - -# === UncertaintySets.py -# Mock abstract class -class myUncertaintySet(UncertaintySet): - ''' - returns single Constraint representing the uncertainty set which is - simply a linear combination of uncertain_params - ''' - - def set_as_constraint(self, uncertain_params, **kwargs): - return Constraint(expr=sum(v for v in uncertain_params) <= 0) - - def point_in_set(self, uncertain_params, **kwargs): - return True - - def geometry(self): - self.geometry = Geometry.LINEAR - - def dim(self): - self.dim = 1 - - def parameter_bounds(self): - return [(0, 1)] - - -class testAbstractUncertaintySetClass(unittest.TestCase): - ''' - The UncertaintySet class has an abstract base class implementing set_as_constraint method, as well as a couple - basic uncertainty sets (ellipsoidal, polyhedral). The set_as_constraint method must return a Constraint object - which references the Param objects from the uncertain_params list in the original model object. - ''' - - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = m.uncertain_params - - _set = myUncertaintySet() - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - uncertain_params_in_expr = list( - v - for v in m.uncertain_param_vars - if v in ComponentSet(identify_variables(expr=m.uncertainty_set_contr.expr)) - ) - - self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - "be the same uncertain param Var objects in the original model.", - ) - - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the UncertaintySet is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - m.p1 = Param(initialize=0, mutable=True) - m.p2 = Param(initialize=0, mutable=True) - m.uncertain_params = [m.p1, m.p2] - - _set = myUncertaintySet() - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_params - ) - variables_in_constr = list( - v - for v in m.uncertain_params - if v in ComponentSet(identify_variables(expr=m.uncertainty_set_contr.expr)) - ) - - self.assertEqual( - len(variables_in_constr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - "variable expression.", - ) - - -class testEllipsoidalUncertaintySetClass(unittest.TestCase): - """ - Unit tests for the EllipsoidalSet - """ - - def test_normal_construction_and_update(self): - """ - Test EllipsoidalSet constructor and setter - work normally when arguments are appropriate. - """ - center = [0, 0] - shape_matrix = [[1, 0], [0, 2]] - scale = 2 - eset = EllipsoidalSet(center, shape_matrix, scale) - np.testing.assert_allclose( - center, eset.center, err_msg="EllipsoidalSet center not as expected" - ) - np.testing.assert_allclose( - shape_matrix, - eset.shape_matrix, - err_msg="EllipsoidalSet shape matrix not as expected", - ) - np.testing.assert_allclose( - scale, eset.scale, err_msg="EllipsoidalSet scale not as expected" - ) - - # check attributes update - new_center = [-1, -3] - new_shape_matrix = [[2, 1], [1, 3]] - new_scale = 1 - - eset.center = new_center - eset.shape_matrix = new_shape_matrix - eset.scale = new_scale - - np.testing.assert_allclose( - new_center, - eset.center, - err_msg="EllipsoidalSet center update not as expected", - ) - np.testing.assert_allclose( - new_shape_matrix, - eset.shape_matrix, - err_msg="EllipsoidalSet shape matrix update not as expected", - ) - np.testing.assert_allclose( - new_scale, eset.scale, err_msg="EllipsoidalSet scale update not as expected" - ) - - def test_error_on_ellipsoidal_dim_change(self): - """ - EllipsoidalSet dimension is considered immutable. - Test ValueError raised when center size is not equal - to set dimension. - """ - invalid_center = [0, 0] - shape_matrix = [[1, 0], [0, 1]] - scale = 2 - - eset = EllipsoidalSet([0, 0], shape_matrix, scale) - - exc_str = r"Attempting to set.*dimension 2 to value of dimension 3" - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - eset.center = [0, 0, 0] - - def test_error_on_neg_scale(self): - """ - Test ValueError raised if scale attribute set to negative - value. - """ - center = [0, 0] - shape_matrix = [[1, 0], [0, 2]] - neg_scale = -1 - - exc_str = r".*must be a non-negative real \(provided.*-1\)" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - EllipsoidalSet(center, shape_matrix, neg_scale) - - # construct a valid EllipsoidalSet - eset = EllipsoidalSet(center, shape_matrix, scale=2) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - eset.scale = neg_scale - - def test_error_on_shape_matrix_with_wrong_size(self): - """ - Test error in event EllipsoidalSet shape matrix - is not in accordance with set dimension. - """ - center = [0, 0] - invalid_shape_matrix = [[1, 0]] - scale = 1 - - exc_str = r".*must be a square matrix of size 2.*\(provided.*shape \(1, 2\)\)" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - EllipsoidalSet(center, invalid_shape_matrix, scale) - - # construct a valid EllipsoidalSet - eset = EllipsoidalSet(center, [[1, 0], [0, 1]], scale) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - eset.shape_matrix = invalid_shape_matrix - - def test_error_on_invalid_shape_matrix(self): - """ - Test exceptional cases of invalid square shape matrix - arguments - """ - center = [0, 0] - scale = 3 - - # assert error on construction - with self.assertRaisesRegex( - ValueError, - r"Shape matrix must be symmetric", - msg="Asymmetric shape matrix test failed", - ): - EllipsoidalSet(center, [[1, 1], [0, 1]], scale) - with self.assertRaises( - np.linalg.LinAlgError, msg="Singular shape matrix test failed" - ): - EllipsoidalSet(center, [[0, 0], [0, 0]], scale) - with self.assertRaisesRegex( - ValueError, - r"Non positive-definite.*", - msg="Indefinite shape matrix test failed", - ): - EllipsoidalSet(center, [[1, 0], [0, -2]], scale) - - # construct a valid EllipsoidalSet - eset = EllipsoidalSet(center, [[1, 0], [0, 2]], scale) - - # assert error on update - with self.assertRaisesRegex( - ValueError, - r"Shape matrix must be symmetric", - msg="Asymmetric shape matrix test failed", - ): - eset.shape_matrix = [[1, 1], [0, 1]] - with self.assertRaises( - np.linalg.LinAlgError, msg="Singular shape matrix test failed" - ): - eset.shape_matrix = [[0, 0], [0, 0]] - with self.assertRaisesRegex( - ValueError, - r"Non positive-definite.*", - msg="Indefinite shape matrix test failed", - ): - eset.shape_matrix = [[1, 0], [0, -2]] - - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - cov = [[1, 0], [0, 1]] - s = 1 - - _set = EllipsoidalSet(center=[0, 0], shape_matrix=cov, scale=s) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - uncertain_params_in_expr = list( - v - for v in m.uncertain_param_vars.values() - if v - in ComponentSet(identify_variables(expr=m.uncertainty_set_contr[1].expr)) - ) - - self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars.values()], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", - ) - - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the EllipsoidalSet is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - m.p1 = Param(initialize=0, mutable=True) - m.p2 = Param(initialize=0, mutable=True) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True - ) - cov = [[1, 0], [0, 1]] - s = 1 - - _set = EllipsoidalSet(center=[0, 0], shape_matrix=cov, scale=s) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - variables_in_constr = list( - v - for v in m.uncertain_params - if v - in ComponentSet(identify_variables(expr=m.uncertainty_set_contr[1].expr)) - ) - - self.assertEqual( - len(variables_in_constr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) - - def test_point_in_set(self): - m = ConcreteModel() - m.p1 = Param(initialize=0, mutable=True) - m.p2 = Param(initialize=0, mutable=True) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - cov = [[1, 0], [0, 1]] - s = 1 - - _set = EllipsoidalSet(center=[0, 0], shape_matrix=cov, scale=s) - self.assertTrue( - _set.point_in_set([0, 0]), msg="Point is not in the EllipsoidalSet." - ) - - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0.5) - cov = [[1, 0], [0, 1]] - s = 1 - - _set = EllipsoidalSet(center=[0, 0], shape_matrix=cov, scale=s) - config = Block() - config.uncertainty_set = _set - - EllipsoidalSet.add_bounds_on_uncertain_parameters(model=m, config=config) - - self.assertNotEqual( - m.util.uncertain_param_vars[0].lb, - None, - "Bounds not added correctly for EllipsoidalSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[0].ub, - None, - "Bounds not added correctly for EllipsoidalSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for EllipsoidalSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for EllipsoidalSet", - ) - - def test_ellipsoidal_set_bounds(self): - """Check `EllipsoidalSet` parameter bounds method correct.""" - cov = [[2, 1], [1, 2]] - scales = [0.5, 2] - mean = [1, 1] - - for scale in scales: - ell = EllipsoidalSet(center=mean, shape_matrix=cov, scale=scale) - bounds = ell.parameter_bounds - actual_bounds = list() - for idx, val in enumerate(mean): - diff = (cov[idx][idx] * scale) ** 0.5 - actual_bounds.append((val - diff, val + diff)) - self.assertTrue( - np.allclose(np.array(bounds), np.array(actual_bounds)), - msg=( - f"EllipsoidalSet bounds {bounds} do not match their actual" - f" values {actual_bounds} (for scale {scale}" - f" and shape matrix {cov})." - " Check the `parameter_bounds`" - " method for the EllipsoidalSet." - ), - ) - - -class testAxisAlignedEllipsoidalUncertaintySetClass(unittest.TestCase): - """ - Unit tests for the AxisAlignedEllipsoidalSet. - """ - - def test_normal_construction_and_update(self): - """ - Test AxisAlignedEllipsoidalSet constructor and setter - work normally when bounds are appropriate. - """ - center = [0, 0] - half_lengths = [1, 3] - aset = AxisAlignedEllipsoidalSet(center, half_lengths) - np.testing.assert_allclose( - center, - aset.center, - err_msg="AxisAlignedEllipsoidalSet center not as expected", - ) - np.testing.assert_allclose( - half_lengths, - aset.half_lengths, - err_msg="AxisAlignedEllipsoidalSet half-lengths not as expected", - ) - - # check attributes update - new_center = [-1, -3] - new_half_lengths = [0, 1] - aset.center = new_center - aset.half_lengths = new_half_lengths - - np.testing.assert_allclose( - new_center, - aset.center, - err_msg="AxisAlignedEllipsoidalSet center update not as expected", - ) - np.testing.assert_allclose( - new_half_lengths, - aset.half_lengths, - err_msg=("AxisAlignedEllipsoidalSet half lengths update not as expected"), - ) - - def test_error_on_axis_aligned_dim_change(self): - """ - AxisAlignedEllipsoidalSet dimension is considered immutable. - Test ValueError raised when attempting to alter the - box set dimension (i.e. number of rows of `bounds`). - """ - center = [0, 0] - half_lengths = [1, 3] - aset = AxisAlignedEllipsoidalSet(center, half_lengths) - - exc_str = r"Attempting to set.*dimension 2 to value of dimension 3" - with self.assertRaisesRegex(ValueError, exc_str): - aset.center = [0, 0, 1] - - with self.assertRaisesRegex(ValueError, exc_str): - aset.half_lengths = [0, 0, 1] - - def test_error_on_negative_axis_aligned_half_lengths(self): - """ - Test ValueError if half lengths for AxisAlignedEllipsoidalSet - contains a negative value. - """ - center = [1, 1] - invalid_half_lengths = [1, -1] - exc_str = r"Entry -1 of.*'half_lengths' is negative.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - AxisAlignedEllipsoidalSet(center, invalid_half_lengths) - - # construct a valid axis-aligned ellipsoidal set - aset = AxisAlignedEllipsoidalSet(center, [1, 0]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - aset.half_lengths = invalid_half_lengths - - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - _set = AxisAlignedEllipsoidalSet(center=[0, 0], half_lengths=[2, 1]) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - uncertain_params_in_expr = list( - v - for v in m.uncertain_param_vars.values() - if v - in ComponentSet(identify_variables(expr=m.uncertainty_set_contr[1].expr)) - ) - - self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars.values()], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", - ) - - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the set is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - m.p1 = Param(initialize=0, mutable=True) - m.p2 = Param(initialize=0, mutable=True) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True - ) - _set = AxisAlignedEllipsoidalSet(center=[0, 0], half_lengths=[2, 1]) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - variables_in_constr = list( - v - for v in m.uncertain_params - if v - in ComponentSet(identify_variables(expr=m.uncertainty_set_contr[1].expr)) - ) - - self.assertEqual( - len(variables_in_constr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) - - def test_point_in_set(self): - m = ConcreteModel() - m.p1 = Param(initialize=0, mutable=True) - m.p2 = Param(initialize=0, mutable=True) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - _set = AxisAlignedEllipsoidalSet(center=[0, 0], half_lengths=[2, 1]) - self.assertTrue( - _set.point_in_set([0, 0]), - msg="Point is not in the AxisAlignedEllipsoidalSet.", - ) - - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0.5) - - _set = AxisAlignedEllipsoidalSet(center=[0, 0], half_lengths=[2, 1]) - config = Block() - config.uncertainty_set = _set - - AxisAlignedEllipsoidalSet.add_bounds_on_uncertain_parameters( - model=m, config=config - ) - - self.assertNotEqual( - m.util.uncertain_param_vars[0].lb, - None, - "Bounds not added correctly for AxisAlignedEllipsoidalSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[0].ub, - None, - "Bounds not added correctly for AxisAlignedEllipsoidalSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for AxisAlignedEllipsoidalSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for AxisAlignedEllipsoidalSet", - ) - - def test_set_with_zero_half_lengths(self): - # construct ellipsoid - half_lengths = [1, 0, 2, 0] - center = [1, 1, 1, 1] - ell = AxisAlignedEllipsoidalSet(center, half_lengths) - - # construct model - m = ConcreteModel() - m.v1 = Var() - m.v2 = Var([1, 2]) - m.v3 = Var() - - # test constraints - conlist = ell.set_as_constraint([m.v1, m.v2, m.v3]) - eq_cons = [con for con in conlist.values() if con.equality] - - self.assertEqual( - len(conlist), - 3, - msg=( - "Constraint list for this `AxisAlignedEllipsoidalSet` should" - f" be of length 3, but is of length {len(conlist)}" - ), - ) - self.assertEqual( - len(eq_cons), - 2, - msg=( - "Number of equality constraints for this" - "`AxisAlignedEllipsoidalSet` should be 2," - f" there are {len(eq_cons)} such constraints" - ), - ) - - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_two_stg_mod_with_axis_aligned_set(self): - """ - Test two-stage model with `AxisAlignedEllipsoidalSet` - as the uncertainty set. - """ - # define model - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u1 = Param(initialize=1.125, mutable=True) - m.u2 = Param(initialize=1, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u1 ** (0.5) - m.x2 * m.u1 <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u1 == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) - - # Define the uncertainty set - # we take the parameter `u2` to be 'fixed' - ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[1, 0]) - - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") - - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") - - # Call the PyROS solver - results = pyros_solver.solve( - model=m, - first_stage_variables=[m.x1, m.x2], - second_stage_variables=[], - uncertain_params=[m.u1, m.u2], - uncertainty_set=ellipsoid, - local_solver=local_subsolver, - global_solver=global_subsolver, - options={ - "objective_focus": ObjectiveType.worst_case, - "solve_master_globally": True, - }, - ) - - # check successful termination - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg="Did not identify robust optimal solution to problem instance.", - ) - self.assertGreater( - results.iterations, - 0, - msg="Robust infeasible model terminated in 0 iterations (nominal case).", - ) - - -class testPolyhedralUncertaintySetClass(unittest.TestCase): - """ - Unit tests for the Polyhedral set. - """ - - def test_normal_construction_and_update(self): - """ - Test PolyhedralSet constructor and attribute setters work - appropriately. - """ - lhs_coefficients_mat = [[1, 2, 3], [4, 5, 6]] - rhs_vec = [1, 3] - - pset = PolyhedralSet(lhs_coefficients_mat, rhs_vec) - - # check attributes are as expected - np.testing.assert_allclose(lhs_coefficients_mat, pset.coefficients_mat) - np.testing.assert_allclose(rhs_vec, pset.rhs_vec) - - # update the set - pset.coefficients_mat = [[1, 0, 1], [1, 1, 1.5]] - pset.rhs_vec = [3, 4] - - # check updates work - np.testing.assert_allclose([[1, 0, 1], [1, 1, 1.5]], pset.coefficients_mat) - np.testing.assert_allclose([3, 4], pset.rhs_vec) - - def test_error_on_polyhedral_set_dim_change(self): - """ - PolyhedralSet dimension (number columns of 'coefficients_mat') - is considered immutable. - Test ValueError raised if attempt made to change dimension. - """ - # construct valid set - pset = PolyhedralSet([[1, 2, 3], [4, 5, 6]], [1, 3]) - - exc_str = ( - r".*must have 3 columns to match set dimension \(provided.*2 columns\)" - ) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - pset.coefficients_mat = [[1, 2], [3, 4]] - - def test_error_on_inconsistent_rows(self): - """ - Number of rows of budget membership mat is immutable. - Similarly, size of rhs_vec is immutable. - Check ValueError raised in event of attempted change. - """ - coeffs_mat_exc_str = ( - r".*must have 2 rows to match shape of attribute 'rhs_vec' " - r"\(provided.*3 rows\)" - ) - rhs_vec_exc_str = ( - r".*must have 2 entries to match shape of attribute " - r"'coefficients_mat' \(provided.*3 entries\)" - ) - # assert error on construction - with self.assertRaisesRegex(ValueError, rhs_vec_exc_str): - PolyhedralSet([[1, 2], [3, 4]], rhs_vec=[1, 3, 3]) - - # construct a valid polyhedral set - # (2 x 2 coefficients, 2-vector for RHS) - pset = PolyhedralSet([[1, 2], [3, 4]], rhs_vec=[1, 3]) - - # assert error on update - with self.assertRaisesRegex(ValueError, coeffs_mat_exc_str): - # 3 x 2 matrix row mismatch - pset.coefficients_mat = [[1, 2], [3, 4], [5, 6]] - with self.assertRaisesRegex(ValueError, rhs_vec_exc_str): - # 3-vector mismatches 2 rows - pset.rhs_vec = [1, 3, 2] - - def test_error_on_empty_set(self): - """ - Check ValueError raised if nonemptiness check performed - at construction returns a negative result. - """ - exc_str = r"PolyhedralSet.*is empty.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - PolyhedralSet([[1], [-1]], rhs_vec=[1, -3]) - - def test_error_on_polyhedral_mat_all_zero_columns(self): - """ - Test ValueError raised if budget membership mat - has a column with all zeros. - """ - invalid_col_mat = [[0, 0, 1], [0, 0, 1], [0, 0, 1]] - rhs_vec = [1, 1, 2] - - exc_str = r".*all entries zero in columns at indexes: 0, 1.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - PolyhedralSet(invalid_col_mat, rhs_vec) - - # construct a valid budget set - pset = PolyhedralSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], rhs_vec) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - pset.coefficients_mat = invalid_col_mat - - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - A = [[0, 1], [1, 0]] - b = [0, 0] - - _set = PolyhedralSet(lhs_coefficients_mat=A, rhs_vec=b) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - uncertain_params_in_expr = ComponentSet() - for con in m.uncertainty_set_contr.values(): - con_vars = ComponentSet(identify_variables(expr=con.expr)) - for v in m.uncertain_param_vars.values(): - if v in con_vars: - uncertain_params_in_expr.add(v) - - self.assertEqual( - uncertain_params_in_expr, - ComponentSet(m.uncertain_param_vars.values()), - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", - ) - - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the PolyHedral is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True - ) - A = [[0, 1], [1, 0]] - b = [0, 0] - - _set = PolyhedralSet(lhs_coefficients_mat=A, rhs_vec=b) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - vars_in_expr = [] - for con in m.uncertainty_set_contr.values(): - vars_in_expr.extend( - v - for v in m.uncertain_param_vars - if v in ComponentSet(identify_variables(expr=con.expr)) - ) - - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) - - def test_polyhedral_set_as_constraint(self): - ''' - The set_as_constraint method must return an indexed uncertainty_set_constr - which has as many elements at their are dimensions in A. - ''' - - A = [[1, 0], [0, 1]] - b = [0, 0] - - m = ConcreteModel() - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - - polyhedral_set = PolyhedralSet(lhs_coefficients_mat=A, rhs_vec=b) - m.uncertainty_set_constr = polyhedral_set.set_as_constraint( - uncertain_params=[m.p1, m.p2] - ) - - self.assertEqual( - len(A), - len(m.uncertainty_set_constr.index_set()), - msg="Polyhedral uncertainty set constraints must be as many as the" - "number of rows in the matrix A.", - ) - - def test_point_in_set(self): - A = [[1, 0], [0, 1]] - b = [0, 0] - - m = ConcreteModel() - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - polyhedral_set = PolyhedralSet(lhs_coefficients_mat=A, rhs_vec=b) - self.assertTrue( - polyhedral_set.point_in_set([0, 0]), - msg="Point is not in the PolyhedralSet.", - ) - - @unittest.skipUnless(baron_available, "Global NLP solver is not available.") - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0.5) - - A = [[1, 0], [0, 1]] - b = [0, 0] - - polyhedral_set = PolyhedralSet(lhs_coefficients_mat=A, rhs_vec=b) - config = Block() - config.uncertainty_set = polyhedral_set - config.global_solver = SolverFactory("baron") - - PolyhedralSet.add_bounds_on_uncertain_parameters(model=m, config=config) - - self.assertNotEqual( - m.util.uncertain_param_vars[0].lb, - None, - "Bounds not added correctly for PolyhedralSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[0].ub, - None, - "Bounds not added correctly for PolyhedralSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for PolyhedralSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for PolyhedralSet", - ) - - -class testBudgetUncertaintySetClass(unittest.TestCase): - ''' - Budget uncertainty sets. - Required inputs are matrix budget_membership_mat, rhs_vec. - ''' - - def test_normal_budget_construction_and_update(self): - """ - Test BudgetSet constructor and attribute setters work - appropriately. - """ - budget_mat = [[1, 0, 1], [0, 1, 0]] - budget_rhs_vec = [1, 3] - - # check attributes are as expected - buset = BudgetSet(budget_mat, budget_rhs_vec) - - np.testing.assert_allclose(budget_mat, buset.budget_membership_mat) - np.testing.assert_allclose(budget_rhs_vec, buset.budget_rhs_vec) - np.testing.assert_allclose( - [[1, 0, 1], [0, 1, 0], [-1, 0, 0], [0, -1, 0], [0, 0, -1]], - buset.coefficients_mat, - ) - np.testing.assert_allclose([1, 3, 0, 0, 0], buset.rhs_vec) - np.testing.assert_allclose(np.zeros(3), buset.origin) - - # update the set - buset.budget_membership_mat = [[1, 1, 0], [0, 0, 1]] - buset.budget_rhs_vec = [3, 4] - - # check updates work - np.testing.assert_allclose([[1, 1, 0], [0, 0, 1]], buset.budget_membership_mat) - np.testing.assert_allclose([3, 4], buset.budget_rhs_vec) - np.testing.assert_allclose( - [[1, 1, 0], [0, 0, 1], [-1, 0, 0], [0, -1, 0], [0, 0, -1]], - buset.coefficients_mat, - ) - np.testing.assert_allclose([3, 4, 0, 0, 0], buset.rhs_vec) - - # update origin - buset.origin = [1, 0, -1.5] - np.testing.assert_allclose([1, 0, -1.5], buset.origin) - - def test_error_on_budget_set_dim_change(self): - """ - BudgetSet dimension is considered immutable. - Test ValueError raised when attempting to alter the - budget set dimension. - """ - budget_mat = [[1, 0, 1], [0, 1, 0]] - budget_rhs_vec = [1, 3] - bu_set = BudgetSet(budget_mat, budget_rhs_vec) - - # error on budget incidence matrix update - exc_str = ( - r".*must have 3 columns to match set dimension \(provided.*1 columns\)" - ) - with self.assertRaisesRegex(ValueError, exc_str): - bu_set.budget_membership_mat = [[1], [1]] - - # error on origin update - exc_str = ( - r".*must have 3 entries to match set dimension \(provided.*4 entries\)" - ) - with self.assertRaisesRegex(ValueError, exc_str): - bu_set.origin = [1, 2, 1, 0] - - def test_error_on_budget_member_mat_row_change(self): - """ - Number of rows of budget membership mat is immutable. - Hence, size of budget_rhs_vec is also immutable. - """ - budget_mat = [[1, 0, 1], [0, 1, 0]] - budget_rhs_vec = [1, 3] - bu_set = BudgetSet(budget_mat, budget_rhs_vec) - - exc_str = ( - r".*must have 2 rows to match shape of attribute 'budget_rhs_vec' " - r"\(provided.*1 rows\)" - ) - with self.assertRaisesRegex(ValueError, exc_str): - bu_set.budget_membership_mat = [[1, 0, 1]] - - exc_str = ( - r".*must have 2 entries to match shape of attribute " - r"'budget_membership_mat' \(provided.*1 entries\)" - ) - with self.assertRaisesRegex(ValueError, exc_str): - bu_set.budget_rhs_vec = [1] - - def test_error_on_neg_budget_rhs_vec_entry(self): - """ - Test ValueError raised if budget RHS vec has entry - with negative value entry. - """ - budget_mat = [[1, 0, 1], [1, 1, 0]] - neg_val_rhs_vec = [1, -1] - - exc_str = r"Entry -1 of.*'budget_rhs_vec' is negative*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(budget_mat, neg_val_rhs_vec) - - # construct a valid budget set - buset = BudgetSet(budget_mat, [1, 1]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_rhs_vec = neg_val_rhs_vec - - def test_error_on_non_bool_budget_mat_entry(self): - """ - Test ValueError raised if budget membership mat has - entry which is not a 0-1 value. - """ - invalid_budget_mat = [[1, 0, 1], [1, 1, 0.1]] - budget_rhs_vec = [1, 1] - - exc_str = r"Attempting.*entries.*not 0-1 values \(example: 0.1\).*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(invalid_budget_mat, budget_rhs_vec) - - # construct a valid budget set - buset = BudgetSet([[1, 0, 1], [1, 1, 0]], budget_rhs_vec) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_membership_mat = invalid_budget_mat - - def test_error_on_budget_mat_all_zero_rows(self): - """ - Test ValueError raised if budget membership mat - has a row with all zeros. - """ - invalid_row_mat = [[0, 0, 0], [1, 1, 1], [0, 0, 0]] - budget_rhs_vec = [1, 1, 2] - - exc_str = r".*all entries zero in rows at indexes: 0, 2.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(invalid_row_mat, budget_rhs_vec) - - # construct a valid budget set - buset = BudgetSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], budget_rhs_vec) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_membership_mat = invalid_row_mat - - def test_error_on_budget_mat_all_zero_columns(self): - """ - Test ValueError raised if budget membership mat - has a column with all zeros. - """ - invalid_col_mat = [[0, 0, 1], [0, 0, 1], [0, 0, 1]] - budget_rhs_vec = [1, 1, 2] - - exc_str = r".*all entries zero in columns at indexes: 0, 1.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(invalid_col_mat, budget_rhs_vec) - - # construct a valid budget set - buset = BudgetSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], budget_rhs_vec) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_membership_mat = invalid_col_mat - - @unittest.skipUnless( - SolverFactory("cbc").available(exception_flag=False), - "LP solver CBC not available", - ) - def test_budget_set_parameter_bounds_correct(self): - """ - If LP solver is available, test parameter bounds method - for factor model set is correct (check against - results from an LP solver). - """ - solver = SolverFactory("cbc") - - # construct budget set instances - buset1 = BudgetSet( - budget_membership_mat=[[1, 1], [0, 1]], rhs_vec=[2, 3], origin=None - ) - buset2 = BudgetSet( - budget_membership_mat=[[1, 0], [1, 1]], rhs_vec=[3, 2], origin=[1, 1] - ) - - # check parameter bounds matches LP results - # exactly for each case - for buset in [buset1, buset2]: - param_bounds = buset.parameter_bounds - lp_param_bounds = eval_parameter_bounds(buset, solver) - - self.assertTrue( - np.allclose(param_bounds, lp_param_bounds), - msg=( - "Parameter bounds not consistent with LP values for " - "BudgetSet with parameterization:\n" - f"budget_membership_mat={buset.budget_membership_mat},\n" - f"budget_rhs_vec={buset.budget_rhs_vec},\n" - f"origin={buset.origin}.\n" - f"({param_bounds} does not match {lp_param_bounds})" - ), - ) - - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - # Single budget - budget_membership_mat = [[1 for i in range(len(m.uncertain_param_vars))]] - rhs_vec = [ - 0.1 * len(m.uncertain_param_vars) - + sum(p.value for p in m.uncertain_param_vars.values()) - ] - - _set = BudgetSet(budget_membership_mat=budget_membership_mat, rhs_vec=rhs_vec) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - uncertain_params_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if v in ComponentSet(identify_variables(expr=con.expr)): - if id(v) not in list(id(u) for u in uncertain_params_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - uncertain_params_in_expr.append(v) - - self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars.values()], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", - ) - - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the BudgetSet is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True - ) - # Single budget - budget_membership_mat = [[1 for i in range(len(m.uncertain_param_vars))]] - rhs_vec = [ - 0.1 * len(m.uncertain_param_vars) - + sum(p.value for p in m.uncertain_param_vars.values()) - ] - - _set = BudgetSet(budget_membership_mat=budget_membership_mat, rhs_vec=rhs_vec) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - vars_in_expr = [] - for con in m.uncertainty_set_contr.values(): - vars_in_expr.extend( - v - for v in m.uncertain_param_vars.values() - if v in ComponentSet(identify_variables(expr=con.expr)) - ) - - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) - - def test_budget_set_as_constraint(self): - ''' - The set_as_constraint method must return an indexed uncertainty_set_constr - which has as many elements at their are dimensions in A. - ''' - - m = ConcreteModel() - m.p1 = Var(initialize=1) - m.p2 = Var(initialize=1) - m.uncertain_params = [m.p1, m.p2] - - # Single budget - budget_membership_mat = [[1 for i in range(len(m.uncertain_params))]] - rhs_vec = [ - 0.1 * len(m.uncertain_params) + sum(p.value for p in m.uncertain_params) - ] - - budget_set = BudgetSet( - budget_membership_mat=budget_membership_mat, rhs_vec=rhs_vec - ) - m.uncertainty_set_constr = budget_set.set_as_constraint( - uncertain_params=m.uncertain_params - ) - - self.assertEqual( - len(budget_set.coefficients_mat), - len(m.uncertainty_set_constr.index_set()), - msg=( - "Number of budget set constraints should be equal to the " - "number of rows in the 'coefficients_mat' attribute" - ), - ) - - def test_point_in_set(self): - m = ConcreteModel() - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - - budget_membership_mat = [[1 for i in range(len(m.uncertain_params))]] - rhs_vec = [ - 0.1 * len(m.uncertain_params) + sum(p.value for p in m.uncertain_params) - ] - - budget_set = BudgetSet( - budget_membership_mat=budget_membership_mat, rhs_vec=rhs_vec - ) - self.assertTrue( - budget_set.point_in_set([0, 0]), msg="Point is not in the BudgetSet." - ) - - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0.5) - - budget_membership_mat = [[1 for i in range(len(m.util.uncertain_param_vars))]] - rhs_vec = [ - 0.1 * len(m.util.uncertain_param_vars) - + sum(value(p) for p in m.util.uncertain_param_vars.values()) - ] - - budget_set = BudgetSet( - budget_membership_mat=budget_membership_mat, rhs_vec=rhs_vec - ) - config = Block() - config.uncertainty_set = budget_set - - BudgetSet.add_bounds_on_uncertain_parameters(model=m, config=config) - - self.assertNotEqual( - m.util.uncertain_param_vars[0].lb, - None, - "Bounds not added correctly for BudgetSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[0].ub, - None, - "Bounds not added correctly for BudgetSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for BudgetSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for BudgetSet", - ) - - -class testCardinalityUncertaintySetClass(unittest.TestCase): - ''' - Cardinality uncertainty sets. Required inputs are origin, positive_deviation, gamma. - Because Cardinality adds cassi vars to model, must pass model to set_as_constraint() - ''' - - def test_normal_cardinality_construction_and_update(self): - """ - Test CardinalitySet constructor and setter work normally - when bounds are appropriate. - """ - # valid inputs - cset = CardinalitySet(origin=[0, 0], positive_deviation=[1, 3], gamma=2) - - # check attributes are as expected - np.testing.assert_allclose(cset.origin, [0, 0]) - np.testing.assert_allclose(cset.positive_deviation, [1, 3]) - np.testing.assert_allclose(cset.gamma, 2) - self.assertEqual(cset.dim, 2) - - # update the set - cset.origin = [1, 2] - cset.positive_deviation = [3, 0] - cset.gamma = 0.5 - - # check updates work - np.testing.assert_allclose(cset.origin, [1, 2]) - np.testing.assert_allclose(cset.positive_deviation, [3, 0]) - np.testing.assert_allclose(cset.gamma, 0.5) - - def test_error_on_neg_positive_deviation(self): - """ - Cardinality set positive deviation attribute should - contain nonnegative numerical entries. - - Check ValueError raised if any negative entries provided. - """ - origin = [0, 0] - positive_deviation = [1, -2] # invalid - gamma = 2 - - exc_str = r"Entry -2 of attribute 'positive_deviation' is negative value" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - cset = CardinalitySet(origin, positive_deviation, gamma) - - # construct a valid cardinality set - cset = CardinalitySet(origin, [1, 1], gamma) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - cset.positive_deviation = positive_deviation - - def test_error_on_invalid_gamma(self): - """ - Cardinality set gamma attribute should be a float-like - between 0 and the set dimension. - - Check ValueError raised if gamma attribute is set - to an invalid value. - """ - origin = [0, 0] - positive_deviation = [1, 1] - gamma = 3 # should be invalid - - exc_str = ( - r".*attribute 'gamma' must be a real number " - r"between 0 and dimension 2 \(provided value 3\)" - ) - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - CardinalitySet(origin, positive_deviation, gamma) - - # construct a valid cardinality set - cset = CardinalitySet(origin, positive_deviation, gamma=2) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - cset.gamma = gamma - - def test_error_on_cardinality_set_dim_change(self): - """ - Dimension is considered immutable. - Test ValueError raised when attempting to alter the - set dimension (i.e. number of entries of `origin`). - """ - # construct a valid cardinality set - cset = CardinalitySet(origin=[0, 0], positive_deviation=[1, 1], gamma=2) - - exc_str = r"Attempting to set.*dimension 2 to value of dimension 3" - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - cset.origin = [0, 0, 0] - with self.assertRaisesRegex(ValueError, exc_str): - cset.positive_deviation = [1, 1, 1] - - @unittest.skipIf(not numpy_available, 'Numpy is not available.') - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - m.util = Block() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - - center = list(p.value for p in m.uncertain_param_vars.values()) - positive_deviation = list(0.3 for j in range(len(center))) - gamma = np.ceil(len(m.uncertain_param_vars) / 2) - - _set = CardinalitySet( - origin=center, positive_deviation=positive_deviation, gamma=gamma - ) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars, model=m - ) - uncertain_params_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if v in ComponentSet(identify_variables(expr=con.expr)): - if id(v) not in list(id(u) for u in uncertain_params_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - uncertain_params_in_expr.append(v) - - self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars.values()], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", - ) - - @unittest.skipIf(not numpy_available, 'Numpy is not available.') - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the CardinalitySet is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - m.util = Block() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True - ) - - center = list(p.value for p in m.uncertain_param_vars.values()) - positive_deviation = list(0.3 for j in range(len(center))) - gamma = np.ceil(len(m.uncertain_param_vars) / 2) - - _set = CardinalitySet( - origin=center, positive_deviation=positive_deviation, gamma=gamma - ) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars, model=m - ) - vars_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if id(v) in [id(u) for u in list(identify_variables(expr=con.expr))]: - if id(v) not in list(id(u) for u in vars_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - vars_in_expr.append(v) - - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) - - def test_point_in_set(self): - m = ConcreteModel() - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - - center = list(p.value for p in m.uncertain_param_vars.values()) - positive_deviation = list(0.3 for j in range(len(center))) - gamma = np.ceil(len(m.uncertain_param_vars) / 2) - - _set = CardinalitySet( - origin=center, positive_deviation=positive_deviation, gamma=gamma - ) - - self.assertTrue( - _set.point_in_set([0, 0]), msg="Point is not in the CardinalitySet." - ) - - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0.5) - - center = list(p.value for p in m.util.uncertain_param_vars.values()) - positive_deviation = list(0.3 for j in range(len(center))) - gamma = np.ceil(len(center) / 2) - - cardinality_set = CardinalitySet( - origin=center, positive_deviation=positive_deviation, gamma=gamma - ) - config = Block() - config.uncertainty_set = cardinality_set - - CardinalitySet.add_bounds_on_uncertain_parameters(model=m, config=config) - - self.assertNotEqual( - m.util.uncertain_param_vars[0].lb, - None, - "Bounds not added correctly for CardinalitySet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[0].ub, - None, - "Bounds not added correctly for CardinalitySet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for CardinalitySet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for CardinalitySet", - ) - - -def eval_parameter_bounds(uncertainty_set, solver): - """ - Evaluate parameter bounds of uncertainty set by solving - bounding problems (as opposed to via the `parameter_bounds` - method). - """ - bounding_mdl = uncertainty_set.bounding_model() - - param_bounds = [] - for idx, obj in bounding_mdl.param_var_objectives.items(): - # activate objective for corresponding dimension - obj.activate() - bounds = [] - - # solve for lower bound, then upper bound - # solve should be successful - for sense in (minimize, maximize): - obj.sense = sense - solver.solve(bounding_mdl) - bounds.append(value(obj)) - - # add parameter bounds for current dimension - param_bounds.append(tuple(bounds)) - - # ensure sense is minimize when done, deactivate - obj.sense = minimize - obj.deactivate() - - return param_bounds - - -class testBoxUncertaintySetClass(unittest.TestCase): - """ - Unit tests for the box uncertainty set (BoxSet). - """ - - def test_normal_construction_and_update(self): - """ - Test BoxSet constructor and setter work normally - when bounds are appropriate. - """ - bounds = [[1, 2], [3, 4]] - bset = BoxSet(bounds=bounds) - np.testing.assert_allclose( - bounds, bset.bounds, err_msg="BoxSet bounds not as expected" - ) - - # check bounds update - new_bounds = [[3, 4], [5, 6]] - bset.bounds = new_bounds - np.testing.assert_allclose( - new_bounds, bset.bounds, err_msg="BoxSet bounds not as expected" - ) - - def test_error_on_box_set_dim_change(self): - """ - BoxSet dimension is considered immutable. - Test ValueError raised when attempting to alter the - box set dimension (i.e. number of rows of `bounds`). - """ - bounds = [[1, 2], [3, 4]] - bset = BoxSet(bounds=bounds) # 2-dimensional set - - exc_str = r"Attempting to set.*dimension 2 to a value of dimension 3" - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = [[1, 2], [3, 4], [5, 6]] - - def test_error_on_lb_exceeds_ub(self): - """ - Test exception raised when an LB exceeds a UB. - """ - bad_bounds = [[1, 2], [4, 3]] - - exc_str = r"Lower bound 4 exceeds upper bound 3" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(bad_bounds) - - # construct a valid box set - bset = BoxSet([[1, 2], [3, 4]]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = bad_bounds - - def test_error_on_ragged_bounds_array(self): - """ - Test ValueError raised on attempting to set BoxSet bounds - to a ragged array. - - This test also validates `uncertainty_sets.is_ragged` for all - pre-defined array-like attributes of all set-types, as the - `is_ragged` method is used throughout. - """ - # example ragged arrays - ragged_arrays = ( - [[1, 2], 3], # list and int in same sequence - [[1, 2], [3, [4, 5]]], # 2nd row ragged (list and int) - [[1, 2], [3]], # variable row lengths - ) - - # construct valid box set - bset = BoxSet(bounds=[[1, 2], [3, 4]]) - - # exception message should match this regex - exc_str = r"Argument `bounds` should not be a ragged array-like.*" - for ragged_arr in ragged_arrays: - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(bounds=ragged_arr) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = ragged_arr - - def test_error_on_invalid_bounds_shape(self): - """ - Test ValueError raised when attempting to set - Box set bounds to array of incorrect shape - (should be a 2-D array with 2 columns). - """ - # 3d array - three_d_arr = [[[1, 2], [3, 4], [5, 6]]] - exc_str = ( - r"Argument `bounds` must be a 2-dimensional.*" - r"\(detected 3 dimensions.*\)" - ) - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(three_d_arr) - - # construct valid box set - bset = BoxSet([[1, 2], [3, 4], [5, 6]]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = three_d_arr - - def test_error_on_wrong_number_columns(self): - """ - BoxSet bounds should be a 2D array-like with 2 columns. - ValueError raised if number columns wrong - """ - three_col_arr = [[1, 2, 3], [4, 5, 6]] - exc_str = ( - r"Attribute 'bounds' should be of shape \(\.{3},2\), " - r"but detected shape \(\.{3},3\)" - ) - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(three_col_arr) - - # construct a valid box set - bset = BoxSet([[1, 2], [3, 4]]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = three_col_arr - - def test_error_on_empty_last_dimension(self): - """ - Check ValueError raised when last dimension of BoxSet bounds is - empty. - """ - empty_2d_arr = [[], [], []] - exc_str = ( - r"Last dimension of argument `bounds` must be non-empty " - r"\(detected shape \(3, 0\)\)" - ) - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(bounds=empty_2d_arr) - - # create a valid box set - bset = BoxSet([[1, 2], [3, 4]]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = empty_2d_arr - - def test_error_on_non_numeric_bounds(self): - """ - Test that ValueError is raised if box set bounds - are set to array-like with entries of a non-numeric - type (such as int, float). - """ - # invalid bounds (contains an entry type str) - new_bounds = [[1, "test"], [3, 2]] - - exc_str = ( - r"Entry 'test' of the argument `bounds` " - r"is not a valid numeric type \(provided type 'str'\)" - ) - - # assert error on construction - with self.assertRaisesRegex(TypeError, exc_str): - BoxSet(new_bounds) - - # construct a valid box set - bset = BoxSet(bounds=[[1, 2], [3, 4]]) - - # assert error on update - with self.assertRaisesRegex(TypeError, exc_str): - bset.bounds = new_bounds - - def test_error_on_bounds_with_nan_or_inf(self): - """ - Box set bounds set to array-like with inf or nan. - """ - # construct a valid box set - bset = BoxSet(bounds=[[1, 2], [3, 4]]) - - for val_str in ["inf", "nan"]: - bad_bounds = [[1, float(val_str)], [2, 3]] - exc_str = ( - fr"Entry '{val_str}' of the argument `bounds` " - fr"is not a finite numeric value" - ) - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(bad_bounds) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = bad_bounds - - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - bounds = [(-1, 1), (-1, 1)] - _set = BoxSet(bounds=bounds) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - uncertain_params_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if v in ComponentSet(identify_variables(expr=con.expr)): - if id(v) not in list(id(u) for u in uncertain_params_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - uncertain_params_in_expr.append(v) - - self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars.values()], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", - ) - - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the set is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True - ) - bounds = [(-1, 1), (-1, 1)] - _set = BoxSet(bounds=bounds) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - vars_in_expr = [] - vars_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if id(v) in [id(u) for u in list(identify_variables(expr=con.expr))]: - if id(v) not in list(id(u) for u in vars_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - vars_in_expr.append(v) - - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) - - def test_point_in_set(self): - m = ConcreteModel() - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - - bounds = [(-1, 1), (-1, 1)] - _set = BoxSet(bounds=bounds) - self.assertTrue(_set.point_in_set([0, 0]), msg="Point is not in the BoxSet.") - - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0) - - bounds = [(-1, 1), (-1, 1)] - box_set = BoxSet(bounds=bounds) - config = Block() - config.uncertainty_set = box_set - - BoxSet.add_bounds_on_uncertain_parameters(model=m, config=config) - - self.assertEqual( - m.util.uncertain_param_vars[0].lb, - -1, - "Bounds not added correctly for BoxSet", - ) - self.assertEqual( - m.util.uncertain_param_vars[0].ub, - 1, - "Bounds not added correctly for BoxSet", - ) - self.assertEqual( - m.util.uncertain_param_vars[1].lb, - -1, - "Bounds not added correctly for BoxSet", - ) - self.assertEqual( - m.util.uncertain_param_vars[1].ub, - 1, - "Bounds not added correctly for BoxSet", - ) - - -class testDiscreteUncertaintySetClass(unittest.TestCase): - ''' - Discrete uncertainty sets. Required inputis a scenarios list. - ''' - - def test_normal_discrete_set_construction_and_update(self): - """ - Test DiscreteScenarioSet constructor and setter work normally - when scenarios are appropriate. - """ - scenarios = [[0, 0, 0], [1, 2, 3]] + m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) - # normal construction should work - dset = DiscreteScenarioSet(scenarios) + m.decision_vars = [m.x1, m.x2, m.x3] - # check scenarios added appropriately - np.testing.assert_allclose( - scenarios, dset.scenarios, err_msg="BoxSet bounds not as expected" - ) + m.uncertain_params = [m.p] - # check scenarios updated appropriately - new_scenarios = [[0, 1, 2], [1, 2, 0], [3, 5, 4]] - dset.scenarios = new_scenarios - np.testing.assert_allclose( - new_scenarios, dset.scenarios, err_msg="BoxSet bounds not as expected" - ) + return m - def test_error_on_discrete_set_dim_change(self): - """ - Test ValueError raised when attempting to update - DiscreteScenarioSet dimension. - """ - scenarios = [[1, 2], [3, 4]] - dset = DiscreteScenarioSet(scenarios) # 2-dimensional set + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_regression_constant_drs(self): + m = self.build_regression_test_model() - exc_str = ( - r".*must have 2 columns.* to match set dimension " - r"\(provided.*with 3 columns\)" + box_set = BoxSet(bounds=[(1.8, 2.2)]) + solver = SolverFactory("baron") + pyros = SolverFactory("pyros") + results = pyros.solve( + model=m, + first_stage_variables=m.decision_vars, + second_stage_variables=[], + uncertain_params=[m.p[1]], + uncertainty_set=box_set, + local_solver=solver, + global_solver=solver, + options={"objective_focus": ObjectiveType.nominal}, ) - with self.assertRaisesRegex(ValueError, exc_str): - dset.scenarios = [[1, 2, 3], [4, 5, 6]] - - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - scenarios = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0)] - _set = DiscreteScenarioSet(scenarios=scenarios) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - uncertain_params_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if v in ComponentSet(identify_variables(expr=con.expr)): - if id(v) not in list(id(u) for u in uncertain_params_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - uncertain_params_in_expr.append(v) - - self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars.values()], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", + self.assertTrue( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the set is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True - ) - scenarios = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0)] - _set = DiscreteScenarioSet(scenarios=scenarios) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars - ) - vars_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if id(v) in [id(u) for u in list(identify_variables(expr=con.expr))]: - if id(v) not in list(id(u) for u in vars_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - vars_in_expr.append(v) + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_regression_affine_drs(self): + m = self.build_regression_test_model() - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", + box_set = BoxSet(bounds=[(1.8, 2.2)]) + solver = SolverFactory("baron") + pyros = SolverFactory("pyros") + results = pyros.solve( + model=m, + first_stage_variables=m.decision_vars, + second_stage_variables=[], + uncertain_params=[m.p[1]], + uncertainty_set=box_set, + local_solver=solver, + global_solver=solver, + options={ + "objective_focus": ObjectiveType.nominal, + "decision_rule_order": 1, + }, ) - - def test_point_in_set(self): - m = ConcreteModel() - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - - scenarios = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0)] - _set = DiscreteScenarioSet(scenarios=scenarios) self.assertTrue( - _set.point_in_set([0, 0]), msg="Point is not in the DiscreteScenarioSet." + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0) - - scenarios = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0)] - _set = DiscreteScenarioSet(scenarios=scenarios) - config = Block() - config.uncertainty_set = _set - - DiscreteScenarioSet.add_bounds_on_uncertain_parameters(model=m, config=config) + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_regression_quadratic_drs(self): + m = self.build_regression_test_model() - self.assertNotEqual( - m.util.uncertain_param_vars[0].lb, - None, - "Bounds not added correctly for DiscreteScenarioSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[0].ub, - None, - "Bounds not added correctly for DiscreteScenarioSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for DiscreteScenarioSet", + box_set = BoxSet(bounds=[(1.8, 2.2)]) + solver = SolverFactory("baron") + pyros = SolverFactory("pyros") + results = pyros.solve( + model=m, + first_stage_variables=m.decision_vars, + second_stage_variables=[], + uncertain_params=[m.p[1]], + uncertainty_set=box_set, + local_solver=solver, + global_solver=solver, + options={ + "objective_focus": ObjectiveType.nominal, + "decision_rule_order": 2, + }, ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for DiscreteScenarioSet", + self.assertTrue( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) @unittest.skipUnless( baron_license_is_valid, "Global NLP solver is not available and licensed." ) - def test_two_stg_model_discrete_set_single_scenario(self): - """ - Test two-stage model under discrete uncertainty with - a single scenario. - """ - m = ConcreteModel() - - # model params - m.u1 = Param(initialize=1.125, mutable=True) - m.u2 = Param(initialize=1, mutable=True) - - # model vars - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - - # model constraints - m.con1 = Constraint(expr=m.x1 * m.u1 ** (0.5) - m.x2 * m.u1 <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u1 == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) + def test_identifying_violating_param_realization(self): + m = build_leyffer_two_cons() - # uncertainty set - discrete_set = DiscreteScenarioSet(scenarios=[(1.125, 1)]) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - # Instantiate PyROS solver + # Instantiate the PyROS solver pyros_solver = SolverFactory("pyros") # Define subsolvers utilized in the algorithm @@ -2995,10 +615,10 @@ def test_two_stg_model_discrete_set_single_scenario(self): # Call the PyROS solver results = pyros_solver.solve( model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - uncertain_params=[m.u1, m.u2], - uncertainty_set=discrete_set, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u], + uncertainty_set=interval, local_solver=local_subsolver, global_solver=global_subsolver, options={ @@ -3007,1121 +627,706 @@ def test_two_stg_model_discrete_set_single_scenario(self): }, ) - # check successful termination self.assertEqual( results.pyros_termination_condition, pyrosTerminationCondition.robust_optimal, msg="Did not identify robust optimal solution to problem instance.", ) - - # only one iteration required - self.assertEqual( + self.assertGreater( results.iterations, - 1, - msg=( - "PyROS was unable to solve a singleton discrete set instance " - " successfully within a single iteration." - ), + 0, + msg="Robust infeasible model terminated in 0 iterations (nominal case).", ) @unittest.skipUnless( baron_license_is_valid, "Global NLP solver is not available and licensed." ) - def test_two_stg_model_discrete_set(self): - """ - Test PyROS successfully solves two-stage model with - multiple scenarios. - """ - m = ConcreteModel() - m.x1 = Var(bounds=(0, 10)) - m.x2 = Var(bounds=(0, 10)) - m.u = Param(mutable=True, initialize=1.125) - m.con = Constraint(expr=sqrt(m.u) * m.x1 - m.u * m.x2 <= 2) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u) ** 2) + @unittest.skipUnless( + baron_version < (23, 1, 5) or baron_version >= (23, 6, 23), + "Test known to fail for BARON 23.1.5 and versions preceding 23.6.23", + ) + def test_terminate_with_max_iter(self): + m = build_leyffer_two_cons() - discrete_set = DiscreteScenarioSet(scenarios=[[0.25], [1.125], [2]]) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - global_solver = SolverFactory("baron") + # Instantiate the PyROS solver pyros_solver = SolverFactory("pyros") - res = pyros_solver.solve( + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('baron') + global_subsolver = SolverFactory("baron") + + # Call the PyROS solver + results = pyros_solver.solve( model=m, first_stage_variables=[m.x1], second_stage_variables=[m.x2], uncertain_params=[m.u], - uncertainty_set=discrete_set, - local_solver=global_solver, - global_solver=global_solver, - decision_rule_order=0, - solve_master_globally=True, - objective_focus=ObjectiveType.worst_case, - ) - - self.assertEqual( - res.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg=( - "Failed to solve discrete set multiple scenarios instance to " - "robust optimality" - ), - ) - - -class testFactorModelUncertaintySetClass(unittest.TestCase): - ''' - FactorModelSet uncertainty sets. Required inputs are psi_matrix, number_of_factors, origin and beta. - ''' - - def test_normal_factor_model_construction_and_update(self): - """ - Test FactorModelSet constructor and setter work normally - when attribute values are appropriate. - """ - # valid inputs - fset = FactorModelSet( - origin=[0, 0, 1], - number_of_factors=2, - psi_mat=[[1, 2], [0, 1], [1, 0]], - beta=0.1, - ) - - # check attributes are as expected - np.testing.assert_allclose(fset.origin, [0, 0, 1]) - np.testing.assert_allclose(fset.psi_mat, [[1, 2], [0, 1], [1, 0]]) - np.testing.assert_allclose(fset.number_of_factors, 2) - np.testing.assert_allclose(fset.beta, 0.1) - self.assertEqual(fset.dim, 3) - - # update the set - fset.origin = [1, 1, 0] - fset.psi_mat = [[1, 0], [0, 1], [1, 1]] - fset.beta = 0.5 - - # check updates work - np.testing.assert_allclose(fset.origin, [1, 1, 0]) - np.testing.assert_allclose(fset.psi_mat, [[1, 0], [0, 1], [1, 1]]) - np.testing.assert_allclose(fset.beta, 0.5) - - def test_error_on_factor_model_set_dim_change(self): - """ - Test ValueError raised when attempting to change FactorModelSet - dimension (by changing number of entries in origin - or number of rows of psi_mat). - """ - origin = [0, 0, 0] - number_of_factors = 2 - psi_mat = [[1, 0], [0, 1], [1, 1]] - beta = 0.5 - - # construct factor model set - fset = FactorModelSet(origin, number_of_factors, psi_mat, beta) - - # assert error on psi mat update - exc_str = ( - r"should be of shape \(3, 2\) to match.*dimensions " - r"\(provided shape \(2, 2\)\)" - ) - with self.assertRaisesRegex(ValueError, exc_str): - fset.psi_mat = [[1, 0], [1, 2]] - - # assert error on origin update - exc_str = r"Attempting.*factor model set of dimension 3 to value of dimension 2" - with self.assertRaisesRegex(ValueError, exc_str): - fset.origin = [1, 3] - - def test_error_on_invalid_number_of_factors(self): - """ - Test ValueError raised if number of factors - is negative int, or AttributeError - if attempting to update (should be immutable). - """ - exc_str = r".*'number_of_factors' must be a positive int \(provided value -1\)" - with self.assertRaisesRegex(ValueError, exc_str): - FactorModelSet(origin=[0], number_of_factors=-1, psi_mat=[[1, 1]], beta=0.1) - - fset = FactorModelSet( - origin=[0], number_of_factors=2, psi_mat=[[1, 1]], beta=0.1 + uncertainty_set=interval, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + "max_iter": 1, + "decision_rule_order": 2, + }, ) - exc_str = r".*'number_of_factors' is immutable" - with self.assertRaisesRegex(AttributeError, exc_str): - fset.number_of_factors = 3 - - def test_error_on_invalid_beta(self): - """ - Test ValueError raised if beta is invalid (exceeds 1 or - is negative) - """ - origin = [0, 0, 0] - number_of_factors = 2 - psi_mat = [[1, 0], [0, 1], [1, 1]] - neg_beta = -0.5 - big_beta = 1.5 - - # assert error on construction - neg_exc_str = ( - r".*must be a real number between 0 and 1.*\(provided value -0.5\)" + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.max_iter, + msg="Returned termination condition is not return max_iter.", ) - big_exc_str = r".*must be a real number between 0 and 1.*\(provided value 1.5\)" - with self.assertRaisesRegex(ValueError, neg_exc_str): - FactorModelSet(origin, number_of_factors, psi_mat, neg_beta) - with self.assertRaisesRegex(ValueError, big_exc_str): - FactorModelSet(origin, number_of_factors, psi_mat, big_beta) - # create a valid factor model set - fset = FactorModelSet(origin, number_of_factors, psi_mat, 1) - - # assert error on update - with self.assertRaisesRegex(ValueError, neg_exc_str): - fset.beta = neg_beta - with self.assertRaisesRegex(ValueError, big_exc_str): - fset.beta = big_beta + self.assertEqual( + results.iterations, + 1, + msg=( + f"Number of iterations in results object is {results.iterations}, " + f"but expected value 1." + ), + ) @unittest.skipUnless( - SolverFactory("cbc").available(exception_flag=False), - "LP solver CBC not available", + baron_license_is_valid, "Global NLP solver is not available and licensed." ) - def test_factor_model_parameter_bounds_correct(self): - """ - If LP solver is available, test parameter bounds method - for factor model set is correct (check against - results from an LP solver). - """ - solver = SolverFactory("cbc") - - # four cases where prior parameter bounds - # approximations were probably too tight - fset1 = FactorModelSet( - origin=[0, 0], - number_of_factors=3, - psi_mat=[[1, -1, 1], [1, 0.1, 1]], - beta=1 / 6, - ) - fset2 = FactorModelSet( - origin=[0], number_of_factors=3, psi_mat=[[1, 6, 8]], beta=1 / 2 - ) - fset3 = FactorModelSet( - origin=[1], number_of_factors=2, psi_mat=[[1, 2]], beta=1 / 4 - ) - fset4 = FactorModelSet( - origin=[1], number_of_factors=3, psi_mat=[[-1, -6, -8]], beta=1 / 2 - ) + def test_terminate_with_time_limit(self): + m = build_leyffer_two_cons() - # check parameter bounds matches LP results - # exactly for each case - for fset in [fset1, fset2, fset3, fset4]: - param_bounds = fset.parameter_bounds - lp_param_bounds = eval_parameter_bounds(fset, solver) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - self.assertTrue( - np.allclose(param_bounds, lp_param_bounds), - msg=( - "Parameter bounds not consistent with LP values for " - "FactorModelSet with parameterization:\n" - f"F={fset.number_of_factors},\n" - f"beta={fset.beta},\n" - f"psi_mat={fset.psi_mat},\n" - f"origin={fset.origin}." - ), - ) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - @unittest.skipIf(not numpy_available, 'Numpy is not available.') - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.util = Block() - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - F = 1 - psi_mat = np.zeros(shape=(len(m.uncertain_params), F)) - for i in range(len(psi_mat)): - random_row_entries = list(np.random.uniform(low=0, high=0.2, size=F)) - for j in range(len(psi_mat[i])): - psi_mat[i][j] = random_row_entries[j] - _set = FactorModelSet( - origin=[0, 0], psi_mat=psi_mat, number_of_factors=F, beta=1 - ) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars, model=m - ) - uncertain_params_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if v in ComponentSet(identify_variables(expr=con.expr)): - if id(v) not in list(id(u) for u in uncertain_params_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - uncertain_params_in_expr.append(v) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('baron') + global_subsolver = SolverFactory("baron") - self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars.values()], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", - ) - - @unittest.skipIf(not numpy_available, 'Numpy is not available.') - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the set is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.util = Block() - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True - ) - F = 1 - psi_mat = np.zeros(shape=(len(m.uncertain_params), F)) - for i in range(len(psi_mat)): - random_row_entries = list(np.random.uniform(low=0, high=0.2, size=F)) - for j in range(len(psi_mat[i])): - psi_mat[i][j] = random_row_entries[j] - _set = FactorModelSet( - origin=[0, 0], psi_mat=psi_mat, number_of_factors=F, beta=1 - ) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars, model=m - ) - vars_in_expr = [] - vars_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if id(v) in [id(u) for u in list(identify_variables(expr=con.expr))]: - if id(v) not in list(id(u) for u in vars_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - vars_in_expr.append(v) + # Call the PyROS solver + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u], + uncertainty_set=interval, + local_solver=local_subsolver, + global_solver=global_subsolver, + objective_focus=ObjectiveType.worst_case, + solve_master_globally=True, + time_limit=0.001, + ) + # validate termination condition self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", + results.pyros_termination_condition, + pyrosTerminationCondition.time_out, + msg="Returned termination condition is not return time_out.", ) - def test_point_in_set(self): - m = ConcreteModel() - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - - F = 1 - psi_mat = np.zeros(shape=(len(m.uncertain_params), F)) - for i in range(len(psi_mat)): - random_row_entries = list(np.random.uniform(low=0, high=0.2, size=F)) - for j in range(len(psi_mat[i])): - psi_mat[i][j] = random_row_entries[j] - _set = FactorModelSet( - origin=[0, 0], psi_mat=psi_mat, number_of_factors=F, beta=1 - ) - self.assertTrue( - _set.point_in_set([0, 0]), msg="Point is not in the FactorModelSet." - ) + # verify subsolver options are unchanged + subsolvers = [local_subsolver, global_subsolver] + for slvr, desc in zip(subsolvers, ["Local", "Global"]): + self.assertEqual( + len(list(slvr.options.keys())), + 0, + msg=f"{desc} subsolver options were changed by PyROS", + ) + self.assertIs( + getattr(slvr.options, "MaxTime", None), + None, + msg=( + f"{desc} subsolver (BARON) MaxTime setting was added " + "by PyROS, but not reverted" + ), + ) - def test_add_bounds_on_uncertain_parameters(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_pyros_backup_solvers(self): m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0) + m.name = "s381" - F = 1 - psi_mat = np.zeros(shape=(len(list(m.util.uncertain_param_vars.values())), F)) - for i in range(len(psi_mat)): - random_row_entries = list(np.random.uniform(low=0, high=0.2, size=F)) - for j in range(len(psi_mat[i])): - psi_mat[i][j] = random_row_entries[j] - _set = FactorModelSet( - origin=[0, 0], psi_mat=psi_mat, number_of_factors=F, beta=1 - ) - config = Block() - config.uncertainty_set = _set + class BadSolver: + def __init__(self, max_num_calls): + self.max_num_calls = max_num_calls + self.num_calls = 0 - FactorModelSet.add_bounds_on_uncertain_parameters(model=m, config=config) + def available(self, exception_flag=True): + return True - self.assertNotEqual( - m.util.uncertain_param_vars[0].lb, - None, - "Bounds not added correctly for FactorModelSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[0].ub, - None, - "Bounds not added correctly for FactorModelSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for FactorModelSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for FactorModelSet", - ) + def solve(self, *args, **kwargs): + if self.num_calls < self.max_num_calls: + self.num_calls += 1 + return SolverFactory("baron").solve(*args, **kwargs) + res = SolverResults() + res.solver.termination_condition = TerminationCondition.maxIterations + res.solver.status = SolverStatus.warning + return res + m.x1 = Var(within=Reals, bounds=(0, None), initialize=0.1) + m.x2 = Var(within=Reals, bounds=(0, None), initialize=0.1) + m.x3 = Var(within=Reals, bounds=(0, None), initialize=0.1) -class testIntersectionSetClass(unittest.TestCase): - """ - Unit tests for the IntersectionSet class. - Required input is set objects to intersect, - and set_as_constraint requires - an NLP solver to confirm the intersection is not empty. - """ + # === State Vars = [x13] + # === Decision Vars === + m.decision_vars = [m.x1, m.x2, m.x3] - def test_normal_construction_and_update(self): - """ - Test IntersectionSet constructor and setter - work normally when arguments are appropriate. - """ - bset = BoxSet(bounds=[[-1, 1], [-1, 1], [-1, 1]]) - aset = AxisAlignedEllipsoidalSet([0, 0, 0], [1, 1, 1]) + # === Uncertain Params === + m.set_params = Set(initialize=list(range(4))) + m.p = Param(m.set_params, initialize=2, mutable=True) + m.uncertain_params = [m.p] - iset = IntersectionSet(box_set=bset, axis_aligned_set=aset) - self.assertIn( - bset, - iset.all_sets, - msg=( - "IntersectionSet 'all_sets' attribute does not" - "contain expected BoxSet" - ), + m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) + m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) + + box_set = BoxSet(bounds=[(1.8, 2.2)]) + pyros = SolverFactory("pyros") + results = pyros.solve( + model=m, + first_stage_variables=m.decision_vars, + second_stage_variables=[], + uncertain_params=[m.p[1]], + uncertainty_set=box_set, + # note: allow 4 calls to work normally + # to permit successful solution of uncertainty + # bounding problems + local_solver=BadSolver(4), + global_solver=BadSolver(4), + backup_local_solvers=[SolverFactory("baron")], + backup_global_solvers=[SolverFactory("baron")], + options={"objective_focus": ObjectiveType.nominal}, + solve_master_globally=True, ) - self.assertIn( - aset, - iset.all_sets, - msg=( - "IntersectionSet 'all_sets' attribute does not" - "contain expected AxisAlignedEllipsoidalSet" - ), + self.assertTrue( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) - def test_error_on_intersecting_wrong_dims(self): + @unittest.skipUnless( + SolverFactory('baron').license_is_valid(), + "Global NLP solver is not available and licensed.", + ) + def test_separation_terminate_time_limit(self): """ - Test ValueError raised if IntersectionSet sets - are not of same dimension. + Test PyROS time limit status returned in event + separation problem times out. """ - bset = BoxSet(bounds=[[-1, 1], [-1, 1]]) - aset = AxisAlignedEllipsoidalSet([0, 0], [2, 2]) - wrong_aset = AxisAlignedEllipsoidalSet([0, 0, 0], [1, 1, 1]) - - exc_str = r".*of dimension 2, but attempting to add set of dimension 3" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - IntersectionSet(box_set=bset, axis_set=aset, wrong_set=wrong_aset) + m = build_leyffer_two_cons() - # construct a valid intersection set - iset = IntersectionSet(box_set=bset, axis_set=aset) - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - iset.all_sets.append(wrong_aset) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - def test_type_error_on_invalid_arg(self): - """ - Test TypeError raised if an argument not of type - UncertaintySet is passed to the IntersectionSet - constructor or appended to 'all_sets'. - """ - bset = BoxSet(bounds=[[-1, 1], [-1, 1]]) - aset = AxisAlignedEllipsoidalSet([0, 0], [2, 2]) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - exc_str = ( - r"Entry '1' of the argument `all_sets` is not An `UncertaintySet` " - r"object.*\(provided type 'int'\)" + # Define subsolvers utilized in the algorithm + local_subsolver = TimeDelaySolver( + calls_to_sleep=0, sub_solver=SolverFactory("baron"), max_time=1 ) + global_subsolver = SolverFactory("baron") - # assert error on construction - with self.assertRaisesRegex(TypeError, exc_str): - IntersectionSet(box_set=bset, axis_set=aset, invalid_arg=1) - - # construct a valid intersection set - iset = IntersectionSet(box_set=bset, axis_set=aset) + # Call the PyROS solver + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u], + uncertainty_set=interval, + local_solver=local_subsolver, + global_solver=global_subsolver, + objective_focus=ObjectiveType.worst_case, + solve_master_globally=True, + time_limit=1, + ) - # assert error on update - with self.assertRaisesRegex(TypeError, exc_str): - iset.all_sets.append(1) + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.time_out, + msg="Returned termination condition is not return time_out.", + ) - def test_error_on_intersection_dim_change(self): + @unittest.skipUnless( + ipopt_available + and SolverFactory('gams').license_is_valid() + and SolverFactory('baron').license_is_valid() + and SolverFactory("scip").license_is_valid(), + "IPOPT not available or one of GAMS/BARON/SCIP not licensed", + ) + def test_pyros_subsolver_time_limit_adjustment(self): """ - IntersectionSet dimension is considered immutable. - Test ValueError raised when attempting to set the - constituent sets to a different dimension. + Check that PyROS does not ultimately alter state of + subordinate solver options due to time limit adjustments. """ - bset = BoxSet(bounds=[[-1, 1], [-1, 1]]) - aset = AxisAlignedEllipsoidalSet([0, 0], [2, 2]) + m = build_leyffer_two_cons() - # construct the set - iset = IntersectionSet(box_set=bset, axis_set=aset) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - exc_str = r"Attempting to set.*dimension 2 to a sequence.* of dimension 1" + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - # attempt to set to 1-dimensional sets - iset.all_sets = [BoxSet([[1, 1]]), AxisAlignedEllipsoidalSet([0], [1])] + # subordinate solvers to test. + # for testing, we pass each as the 'local' solver, + # and the BARON solver without custom options + # as the 'global' solver + baron_no_options = SolverFactory("baron") + local_subsolvers = [ + SolverFactory("gams:conopt"), + SolverFactory("gams:conopt"), + SolverFactory("ipopt"), + SolverFactory("ipopt", options={"max_cpu_time": 300}), + SolverFactory("scip"), + SolverFactory("scip", options={"limits/time": 300}), + baron_no_options, + SolverFactory("baron", options={"MaxTime": 300}), + ] + local_subsolvers[0].options["add_options"] = ["option reslim=100;"] + + # Call the PyROS solver + for idx, opt in enumerate(local_subsolvers): + original_solver_options = opt.options.copy() + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u], + uncertainty_set=interval, + local_solver=opt, + global_solver=baron_no_options, + objective_focus=ObjectiveType.worst_case, + solve_master_globally=True, + time_limit=100, + ) + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg=( + "Returned termination condition with local " + f"subsolver {idx + 1} of 2 is not robust_optimal." + ), + ) + self.assertEqual( + opt.options, + original_solver_options, + msg=( + f"Options for subordinate solver {opt} were changed " + "by PyROS, and the changes wee not properly reverted." + ), + ) - def test_error_on_too_few_sets(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_terminate_with_application_error(self): """ - Check ValueError raised if too few sets are passed - to the intersection set. + Check that PyROS correctly raises ApplicationError + in event of abnormal IPOPT termination. """ - exc_str = r"Attempting.*minimum required length 2.*iterable of length 1" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - IntersectionSet(bset=BoxSet([[1, 2]])) - - # construct a valid intersection set - iset = IntersectionSet( - box_set=BoxSet([[1, 2]]), axis_set=AxisAlignedEllipsoidalSet([0], [1]) - ) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - # attempt to set to 1-dimensional sets - iset.all_sets = [BoxSet([[1, 1]])] - - def test_intersection_uncertainty_set_list_behavior(self): - """ - Test the 'all_sets' attribute of the IntersectionSet - class behaves like a regular Python list. - """ - iset = IntersectionSet( - bset=BoxSet([[0, 2]]), aset=AxisAlignedEllipsoidalSet([0], [1]) - ) - - # an UncertaintySetList of length 2. - # should behave like a list of length 2 - all_sets = iset.all_sets - - # test append - all_sets.append(BoxSet([[1, 2]])) - del all_sets[2:] - - # test extend - all_sets.extend([BoxSet([[1, 2]]), EllipsoidalSet([0], [[1]], 2)]) - del all_sets[2:] - - # index in range. Allow slicing as well - # none of these should result in exception - all_sets[0] - all_sets[1] - all_sets[100:] - all_sets[0:2:20] - all_sets[0:2:1] - all_sets[-20:-1:2] - - # index out of range - self.assertRaises(IndexError, lambda: all_sets[2]) - self.assertRaises(IndexError, lambda: all_sets[-3]) - - # assert min length ValueError if attempting to clear - # list to length less than 2 - with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): - all_sets[:] = all_sets[0] - with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): - del all_sets[1] - with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): - del all_sets[1:] - with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): - del all_sets[:] - with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): - all_sets.clear() - with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): - all_sets[0:] = [] - - # assignment out of range - with self.assertRaisesRegex(IndexError, r"assignment index out of range"): - all_sets[-3] = BoxSet([[1, 1.5]]) - with self.assertRaisesRegex(IndexError, r"assignment index out of range"): - all_sets[2] = BoxSet([[1, 1.5]]) - - # assigning to slices should work fine - all_sets[3:] = [BoxSet([[1, 1.5]]), BoxSet([[1, 3]])] - - @unittest.skipUnless(ipopt_available, "IPOPT is not available.") - def test_uncertainty_set_with_correct_params(self): - ''' - Case in which the UncertaintySet is constructed using the uncertain_param objects from the model to - which the uncertainty set constraint is being added. - ''' m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - bounds = [(-1, 1), (-1, 1)] - Q1 = BoxSet(bounds=bounds) - Q2 = AxisAlignedEllipsoidalSet(center=[0, 0], half_lengths=[2, 1]) - Q = IntersectionSet(Q1=Q1, Q2=Q2) - - config = ConfigBlock() + m.p = Param(mutable=True, initialize=1.5) + m.x1 = Var(initialize=-1) + m.obj = Objective(expr=log(m.x1) * m.p) + m.con = Constraint(expr=m.x1 * m.p >= -2) + solver = SolverFactory("ipopt") - config.declare("global_solver", ConfigValue(default=solver)) + solver.options["halt_on_ampl_error"] = "yes" + baron = SolverFactory("baron") - m.uncertainty_set_contr = Q.set_as_constraint( - uncertain_params=m.uncertain_param_vars, config=config - ) - uncertain_params_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if v in ComponentSet(identify_variables(expr=con.expr)): - if id(v) not in list(id(u) for u in uncertain_params_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - uncertain_params_in_expr.append(v) + box_set = BoxSet(bounds=[(1, 2)]) + pyros_solver = SolverFactory("pyros") + with self.assertRaisesRegex( + ApplicationError, r"Solver \(ipopt\) did not exit normally" + ): + pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[], + uncertain_params=[m.p], + uncertainty_set=box_set, + local_solver=solver, + global_solver=baron, + objective_focus=ObjectiveType.nominal, + time_limit=1000, + ) + # check solver settings are unchanged self.assertEqual( - [id(u) for u in uncertain_params_in_expr], - [id(u) for u in m.uncertain_param_vars.values()], - msg="Uncertain param Var objects used to construct uncertainty set constraint must" - " be the same uncertain param Var objects in the original model.", - ) - - @unittest.skipUnless(ipopt_available, "IPOPT is not available.") - def test_uncertainty_set_with_incorrect_params(self): - ''' - Case in which the set is constructed using uncertain_param objects which are Params instead of - Vars. Leads to a constraint this is not potentially variable. - ''' - m = ConcreteModel() - # At this stage, the separation problem has uncertain_params which are now Var objects - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Param( - range(len(m.uncertain_params)), initialize=0, mutable=True + len(list(solver.options.keys())), + 1, + msg=(f"Local subsolver {solver} options were changed by PyROS"), ) - bounds = [(-1, 1), (-1, 1)] - - Q1 = BoxSet(bounds=bounds) - Q2 = AxisAlignedEllipsoidalSet(center=[0, 0], half_lengths=[2, 1]) - Q = IntersectionSet(Q1=Q1, Q2=Q2) - - solver = SolverFactory("ipopt") - config = ConfigBlock() - config.declare("global_solver", ConfigValue(default=solver)) - - m.uncertainty_set_contr = Q.set_as_constraint( - uncertain_params=m.uncertain_param_vars, config=config + self.assertEqual( + solver.options["halt_on_ampl_error"], + "yes", + msg=( + f"Local subsolver {solver} option " + "'halt_on_ampl_error' was changed by PyROS" + ), ) - vars_in_expr = [] - for con in m.uncertainty_set_contr.values(): - for v in m.uncertain_param_vars.values(): - if id(v) in [id(u) for u in list(identify_variables(expr=con.expr))]: - if id(v) not in list(id(u) for u in vars_in_expr): - # Not using ID here leads to it thinking both are in the list already when they aren't - vars_in_expr.append(v) - self.assertEqual( - len(vars_in_expr), + len(list(baron.options.keys())), 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", + msg=(f"Global subsolver {baron} options were changed by PyROS"), ) - def test_point_in_set(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_master_subsolver_error(self): + """ + Test PyROS on a two-stage problem with a subsolver error + termination in the initial master problem. + """ m = ConcreteModel() - m.p1 = Var(initialize=0) - m.p2 = Var(initialize=0) - m.uncertain_params = [m.p1, m.p2] - m.uncertain_param_vars = Var(range(len(m.uncertain_params)), initialize=0) - - bounds = [(-1, 1), (-1, 1)] - Q1 = BoxSet(bounds=bounds) - Q2 = BoxSet(bounds=[(-2, 1), (-1, 2)]) - Q = IntersectionSet(Q1=Q1, Q2=Q2) - self.assertTrue( - Q.point_in_set([0, 0]), msg="Point is not in the IntersectionSet." - ) - @unittest.skipUnless(baron_available, "Global NLP solver is not available.") - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0.5) - - bounds = [(-1, 1), (-1, 1)] - Q1 = BoxSet(bounds=bounds) - Q2 = AxisAlignedEllipsoidalSet(center=[0, 0], half_lengths=[5, 5]) - Q = IntersectionSet(Q1=Q1, Q2=Q2) - config = Block() - config.uncertainty_set = Q - config.global_solver = SolverFactory("baron") - - IntersectionSet.add_bounds_on_uncertain_parameters(m, config) - - self.assertNotEqual( - m.util.uncertain_param_vars[0].lb, - None, - "Bounds not added correctly for IntersectionSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[0].ub, - None, - "Bounds not added correctly for IntersectionSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for IntersectionSet", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for IntersectionSet", - ) - - -# === master_problem_methods.py -class testInitialConstructMaster(unittest.TestCase): - def test_initial_construct_master(self): - model_data = MasterProblemData() - model_data.timing = None - model_data.working_model = ConcreteModel() - master_data = initial_construct_master(model_data) - self.assertTrue( - hasattr(master_data, "master_model"), - msg="Initial construction of master problem " - "did not create a master problem ConcreteModel object.", - ) - - -class testAddScenarioToMaster(unittest.TestCase): - def test_add_scenario_to_master(self): - working_model = ConcreteModel() - working_model.p = Param([1, 2], initialize=0, mutable=True) - working_model.x = Var() - model_data = MasterProblemData() - model_data.working_model = working_model - model_data.timing = None - master_data = initial_construct_master(model_data) - master_data.master_model.scenarios[0, 0].transfer_attributes_from( - working_model.clone() - ) - master_data.master_model.scenarios[0, 0].util = Block() - master_data.master_model.scenarios[0, 0].util.first_stage_variables = [ - master_data.master_model.scenarios[0, 0].x - ] - master_data.master_model.scenarios[0, 0].util.uncertain_params = [ - master_data.master_model.scenarios[0, 0].p[1], - master_data.master_model.scenarios[0, 0].p[2], - ] - add_scenario_to_master(master_data, violations=[1, 1]) + m.q = Param(initialize=1, mutable=True) - self.assertEqual( - len(master_data.master_model.scenarios), - 2, - msg="Scenario not added to master correctly. Expected 2 scenarios.", - ) + m.x1 = Var(initialize=1, bounds=(0, 1)) + # source of subsolver error: can't converge to log(0) + # in separation problem (make x2 second-stage var) + m.x2 = Var(initialize=2, bounds=(0, m.q)) -global_solver = "baron" + m.obj = Objective(expr=log(m.x1) + m.x2) + box_set = BoxSet(bounds=[(0, 1)]) -class testSolveMaster(unittest.TestCase): - @unittest.skipUnless(baron_available, "Global NLP solver is not available.") - def test_solve_master(self): - working_model = m = ConcreteModel() - m.x = Var(initialize=0.5, bounds=(0, 10)) - m.y = Var(initialize=1.0, bounds=(0, 5)) - m.z = Var(initialize=0, bounds=(None, None)) - m.p = Param(initialize=1, mutable=True) - m.obj = Objective(expr=m.x) - m.con = Constraint(expr=m.x + m.y + m.z <= 3) - model_data = MasterProblemData() - model_data.working_model = working_model - model_data.timing = None - model_data.iteration = 0 - master_data = initial_construct_master(model_data) - master_data.master_model.scenarios[0, 0].transfer_attributes_from( - working_model.clone() - ) - master_data.master_model.scenarios[0, 0].util = Block() - master_data.master_model.scenarios[0, 0].util.first_stage_variables = [ - master_data.master_model.scenarios[0, 0].x - ] - master_data.master_model.scenarios[0, 0].util.decision_rule_vars = [] - master_data.master_model.scenarios[0, 0].util.second_stage_variables = [] - master_data.master_model.scenarios[0, 0].util.uncertain_params = [ - master_data.master_model.scenarios[0, 0].p - ] - master_data.master_model.scenarios[0, 0].first_stage_objective = 0 - master_data.master_model.scenarios[0, 0].second_stage_objective = Expression( - expr=master_data.master_model.scenarios[0, 0].x - ) - master_data.master_model.scenarios[0, 0].util.dr_var_to_exponent_map = ( - ComponentMap() - ) - master_data.iteration = 0 - master_data.timing = TimingData() - - box_set = BoxSet(bounds=[(0, 2)]) - solver = SolverFactory(global_solver) - config = ConfigBlock() - config.declare("backup_global_solvers", ConfigValue(default=[])) - config.declare("backup_local_solvers", ConfigValue(default=[])) - config.declare("solve_master_globally", ConfigValue(default=True)) - config.declare("global_solver", ConfigValue(default=solver)) - config.declare("tee", ConfigValue(default=False)) - config.declare("decision_rule_order", ConfigValue(default=1)) - config.declare("objective_focus", ConfigValue(default=ObjectiveType.worst_case)) - config.declare( - "second_stage_variables", - ConfigValue( - default=master_data.master_model.scenarios[ - 0, 0 - ].util.second_stage_variables - ), + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") + + res = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.q], + uncertainty_set=box_set, + local_solver=local_solver, + global_solver=global_solver, + decision_rule_order=1, + tee=True, ) - config.declare("subproblem_file_directory", ConfigValue(default=None)) - config.declare("time_limit", ConfigValue(default=None)) - config.declare( - "progress_logger", ConfigValue(default=logging.getLogger(__name__)) + self.assertEqual( + res.pyros_termination_condition, + pyrosTerminationCondition.subsolver_error, + msg=( + f"Returned termination condition for separation error" + "test is not {pyrosTerminationCondition.subsolver_error}.", + ), ) - with time_code(master_data.timing, "main", is_main_timer=True): - master_soln = solve_master(master_data, config) - self.assertEqual( - master_soln.termination_condition, - TerminationCondition.optimal, - msg=( - "Could not solve simple master problem with solve_master " - "function." - ), - ) - - -# === regression test for the solver -class coefficientMatchingTests(unittest.TestCase): - def test_coefficient_matching_correct_num_constraints_added(self): - # Write the deterministic Pyomo model + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_separation_subsolver_error(self): + """ + Test PyROS on a two-stage problem with a subsolver error + termination in separation. + """ m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.u = Param(initialize=1.125, mutable=True) - m.con = Constraint(expr=m.u ** (0.5) * m.x1 - m.u * m.x2 <= 2) - m.eq_con = Constraint( - expr=m.u**2 * (m.x2 - 1) - + m.u * (m.x1**3 + 0.5) - - 5 * m.u * m.x1 * m.x2 - + m.u * (m.x1 + 2) - == 0 - ) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + m.q = Param(initialize=1, mutable=True) - config = Block() - config.uncertainty_set = Block() - config.uncertainty_set.parameter_bounds = [(0.25, 2)] + m.x1 = Var(initialize=1, bounds=(0, 1)) - m.util = Block() - m.util.first_stage_variables = [m.x1, m.x2] - m.util.second_stage_variables = [] - m.util.uncertain_params = [m.u] + # source of subsolver error: can't converge to log(0) + # in separation problem (make x2 second-stage var) + m.x2 = Var(initialize=2, bounds=(0, log(m.q))) - config.decision_rule_order = 0 + m.obj = Objective(expr=m.x1 + m.x2) - m.util.h_x_q_constraints = ComponentSet() + box_set = BoxSet(bounds=[(0, 1)]) - coeff_matching_success, robust_infeasible = coefficient_matching( - m, m.eq_con, [m.u], config - ) + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - self.assertEqual( - coeff_matching_success, True, msg="Coefficient matching was unsuccessful." - ) - self.assertEqual( - robust_infeasible, - False, - msg="Coefficient matching detected a robust infeasible constraint (1 == 0).", + res = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.q], + uncertainty_set=box_set, + local_solver=local_solver, + global_solver=global_solver, + decision_rule_order=1, + tee=True, ) self.assertEqual( - len(m.coefficient_matching_constraints), - 2, - msg="Coefficient matching produced incorrect number of h(x,q)=0 constraints.", + res.pyros_termination_condition, + pyrosTerminationCondition.subsolver_error, + msg=( + "Returned termination condition for separation error" + f"test is not {pyrosTerminationCondition.subsolver_error}." + ), ) - config.decision_rule_order = 1 - model_data = Block() - model_data.working_model = m + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + @unittest.skipUnless(baron_license_is_valid, "BARON is not available and licensed.") + def test_discrete_separation_subsolver_error(self): + """ + Test PyROS for two-stage problem with discrete type set, + subsolver error status. + """ - m.util.first_stage_variables = [m.x1] - m.util.second_stage_variables = [m.x2] + class BadSeparationSolver: + def __init__(self, solver): + self.solver = solver - add_decision_rule_variables(model_data=model_data, config=config) - add_decision_rule_constraints(model_data=model_data, config=config) + def available(self, exception_flag=False): + return self.solver.available(exception_flag=exception_flag) - coeff_matching_success, robust_infeasible = coefficient_matching( - m, m.eq_con, [m.u], config - ) - self.assertEqual( - coeff_matching_success, - False, - msg="Coefficient matching should have been " - "unsuccessful for higher order polynomial expressions.", - ) - self.assertEqual( - robust_infeasible, - False, - msg="Coefficient matching is not successful, " - "but should not be proven robust infeasible.", - ) + def solve(self, model, *args, **kwargs): + is_separation = hasattr(model, "uncertainty") + if is_separation: + res = SolverResults() + res.solver.termination_condition = TerminationCondition.unknown + else: + res = self.solver.solve(model, *args, **kwargs) + return res - def test_coefficient_matching_robust_infeasible_proof(self): - # Write the deterministic Pyomo model m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con = Constraint(expr=m.u ** (0.5) * m.x1 - m.u * m.x2 <= 2) - m.eq_con = Constraint( - expr=m.u * (m.x1**3 + 0.5) - - 5 * m.u * m.x1 * m.x2 - + m.u * (m.x1 + 2) - + m.u**2 - == 0 - ) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - - config = Block() - config.uncertainty_set = Block() - config.uncertainty_set.parameter_bounds = [(0.25, 2)] - m.util = Block() - m.util.first_stage_variables = [m.x1, m.x2] - m.util.second_stage_variables = [] - m.util.uncertain_params = [m.u] + m.q = Param(initialize=1, mutable=True) + m.x1 = Var(initialize=1, bounds=(0, 1)) + m.x2 = Var(initialize=2, bounds=(0, m.q)) + m.obj = Objective(expr=m.x1 + m.x2, sense=maximize) - config.decision_rule_order = 0 + discrete_set = DiscreteScenarioSet(scenarios=[(1,), (0,)]) - m.util.h_x_q_constraints = ComponentSet() + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - coeff_matching_success, robust_infeasible = coefficient_matching( - m, m.eq_con, [m.u], config - ) + with LoggingIntercept(level=logging.WARNING) as LOG: + res = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.q], + uncertainty_set=discrete_set, + local_solver=BadSeparationSolver(local_solver), + global_solver=BadSeparationSolver(global_solver), + decision_rule_order=1, + tee=True, + ) + self.assertRegex(LOG.getvalue(), "Could not.*separation.*iteration 0.*") self.assertEqual( - coeff_matching_success, - False, - msg="Coefficient matching should have been unsuccessful.", + res.pyros_termination_condition, pyrosTerminationCondition.subsolver_error ) - self.assertEqual( - robust_infeasible, - True, - msg="Coefficient matching should be proven robust infeasible.", - ) - - -# === regression test for the solver -@unittest.skipUnless(baron_available, "Global NLP solver is not available.") -class RegressionTest(unittest.TestCase): - def regression_test_constant_drs(self): - model = m = ConcreteModel() - m.name = "s381" - - m.x1 = Var(within=Reals, bounds=(0, None), initialize=0.1) - m.x2 = Var(within=Reals, bounds=(0, None), initialize=0.1) - m.x3 = Var(within=Reals, bounds=(0, None), initialize=0.1) - - # === State Vars = [x13] - # === Decision Vars === - m.decision_vars = [m.x1, m.x2, m.x3] - - # === Uncertain Params === - m.set_params = Set(initialize=list(range(4))) - m.p = Param(m.set_params, initialize=2, mutable=True) - m.uncertain_params = [m.p] + self.assertEqual(res.iterations, 1) - m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) - m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_discrete_separation_invalid_value_error(self): + """ + Test PyROS properly handles InvalidValueError. + """ + m = ConcreteModel() - box_set = BoxSet(bounds=[(1.8, 2.2)]) - solver = SolverFactory("baron") - pyros = SolverFactory("pyros") - results = pyros.solve( - model=m, - first_stage_variables=m.decision_vars, - second_stage_variables=[], - uncertain_params=[m.p[1]], - uncertainty_set=box_set, - local_solver=solver, - global_solver=solver, - options={"objective_focus": ObjectiveType.nominal}, - ) - self.assertTrue( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, - ) + m.q = Param(initialize=1, mutable=True) + m.x1 = Var(initialize=1, bounds=(0, 1)) - def regression_test_affine_drs(self): - model = m = ConcreteModel() - m.name = "s381" + # upper bound induces invalid value error: separation + # max(x2 - log(m.q)) will force subsolver to q = 0 + m.x2 = Var(initialize=2, bounds=(None, log(m.q))) - m.x1 = Var(within=Reals, bounds=(0, None), initialize=0.1) - m.x2 = Var(within=Reals, bounds=(0, None), initialize=0.1) - m.x3 = Var(within=Reals, bounds=(0, None), initialize=0.1) + m.obj = Objective(expr=m.x1 + m.x2, sense=maximize) - # === State Vars = [x13] - # === Decision Vars === - m.decision_vars = [m.x1, m.x2, m.x3] + discrete_set = DiscreteScenarioSet(scenarios=[(1,), (0,)]) - # === Uncertain Params === - m.set_params = Set(initialize=list(range(4))) - m.p = Param(m.set_params, initialize=2, mutable=True) - m.uncertain_params = [m.p] + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) - m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) + with LoggingIntercept(level=logging.ERROR) as LOG: + with self.assertRaises(InvalidValueError): + pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.q], + uncertainty_set=discrete_set, + local_solver=local_solver, + global_solver=global_solver, + decision_rule_order=1, + tee=True, + ) - box_set = BoxSet(bounds=[(1.8, 2.2)]) - solver = SolverFactory("baron") - pyros = SolverFactory("pyros") - results = pyros.solve( - model=m, - first_stage_variables=m.decision_vars, - second_stage_variables=[], - uncertain_params=[m.p[1]], - uncertainty_set=box_set, - local_solver=solver, - global_solver=solver, - options={ - "objective_focus": ObjectiveType.nominal, - "decision_rule_order": 1, - }, - ) - self.assertTrue( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, + err_str = LOG.getvalue() + self.assertRegex( + err_str, "Optimizer.*exception.*separation problem.*iteration 0" ) - def regression_test_quad_drs(self): - model = m = ConcreteModel() - m.name = "s381" + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + def test_pyros_nl_and_ampl_writer_tol(self): + """ + Test PyROS subsolver call routine behavior + with respect to the NL and AMPL writer tolerances is as + expected. + """ + m = ConcreteModel() + m.q = Param(initialize=1, mutable=True) + m.x1 = Var(initialize=1, bounds=(0, 1)) + m.x2 = Var(initialize=2, bounds=(0, m.q)) + m.obj = Objective(expr=m.x1 + m.x2) + + # fixed just inside the PyROS-specified NL writer tolerance. + m.x1.fix(m.x1.upper + 9.9e-5) - m.x1 = Var(within=Reals, bounds=(0, None), initialize=0.1) - m.x2 = Var(within=Reals, bounds=(0, None), initialize=0.1) - m.x3 = Var(within=Reals, bounds=(0, None), initialize=0.1) + current_nl_writer_tol = pyomo_nl_writer.TOL, pyomo_ampl_repn.TOL + ipopt_solver = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") - # === State Vars = [x13] - # === Decision Vars === - m.decision_vars = [m.x1, m.x2, m.x3] + pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.q], + uncertainty_set=BoxSet([[0, 1]]), + local_solver=ipopt_solver, + global_solver=ipopt_solver, + decision_rule_order=0, + solve_master_globally=False, + bypass_global_separation=True, + ) - # === Uncertain Params === - m.set_params = Set(initialize=list(range(4))) - m.p = Param(m.set_params, initialize=2, mutable=True) - m.uncertain_params = [m.p] + self.assertEqual( + (pyomo_nl_writer.TOL, pyomo_ampl_repn.TOL), + current_nl_writer_tol, + msg="Pyomo writer tolerances not restored as expected.", + ) - m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) - m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) + # fixed just outside the PyROS-specified writer tolerances. + # this should be exceptional. + m.x1.fix(m.x1.upper + 1.01e-4) - box_set = BoxSet(bounds=[(1.8, 2.2)]) - solver = SolverFactory("baron") - pyros = SolverFactory("pyros") - results = pyros.solve( - model=m, - first_stage_variables=m.decision_vars, - second_stage_variables=[], - uncertain_params=[m.p[1]], - uncertainty_set=box_set, - local_solver=solver, - global_solver=solver, - options={ - "objective_focus": ObjectiveType.nominal, - "decision_rule_order": 2, - }, + err_msg = ( + "model contains a trivially infeasible variable.*x1" + ".*fixed.*outside bounds" ) - self.assertTrue( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, + with self.assertRaisesRegex(InfeasibleConstraintException, err_msg): + pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.q], + uncertainty_set=BoxSet([[0, 1]]), + local_solver=ipopt_solver, + global_solver=ipopt_solver, + decision_rule_order=0, + solve_master_globally=False, + bypass_global_separation=True, + ) + + self.assertEqual( + (pyomo_nl_writer.TOL, pyomo_ampl_repn.TOL), + current_nl_writer_tol, + msg=( + "Pyomo writer tolerances not restored as expected " + "after exceptional test." + ), ) @unittest.skipUnless( baron_license_is_valid, "Global NLP solver is not available and licensed." ) - def test_minimize_dr_norm(self): + def test_pyros_math_domain_error(self): + """ + Test PyROS on a two-stage problem, discrete + set type with a math domain error evaluating + second-stage inequality constraint expressions in separation. + """ m = ConcreteModel() - m.p1 = Param(initialize=0, mutable=True) - m.p2 = Param(initialize=0, mutable=True) - m.z1 = Var(initialize=0, bounds=(0, 1)) - m.z2 = Var(initialize=0, bounds=(0, 1)) - - m.working_model = ConcreteModel() - m.working_model.util = Block() - - m.working_model.util.second_stage_variables = [m.z1, m.z2] - m.working_model.util.uncertain_params = [m.p1, m.p2] - m.working_model.util.first_stage_variables = [] - m.working_model.util.state_vars = [] - - m.working_model.util.first_stage_variables = [] - config = Bunch() - config.decision_rule_order = 1 - config.objective_focus = ObjectiveType.nominal - config.global_solver = SolverFactory('baron') - config.uncertain_params = m.working_model.util.uncertain_params - config.tee = False - config.solve_master_globally = True - config.time_limit = None - config.progress_logger = logging.getLogger(__name__) - - add_decision_rule_variables(model_data=m, config=config) - add_decision_rule_constraints(model_data=m, config=config) - - # === Make master_type model - master = ConcreteModel() - master.scenarios = Block(NonNegativeIntegers, NonNegativeIntegers) - master.scenarios[0, 0].transfer_attributes_from(m.working_model.clone()) - master.scenarios[0, 0].first_stage_objective = 0 - master.scenarios[0, 0].second_stage_objective = Expression( - expr=(master.scenarios[0, 0].util.second_stage_variables[0] - 1) ** 2 - + (master.scenarios[0, 0].util.second_stage_variables[1] - 1) ** 2 - ) - master.obj = Objective(expr=master.scenarios[0, 0].second_stage_objective) - master_data = MasterProblemData() - master_data.master_model = master - master_data.master_model.const_efficiency_applied = False - master_data.master_model.linear_efficiency_applied = False - master_data.iteration = 0 - - master_data.timing = TimingData() - with time_code(master_data.timing, "main", is_main_timer=True): - results, success = minimize_dr_vars(model_data=master_data, config=config) - self.assertEqual( - results.solver.termination_condition, - TerminationCondition.optimal, - msg="Minimize dr norm did not solve to optimality.", - ) - self.assertTrue( - success, msg=f"DR polishing success {success}, expected True." + m.q = Param(initialize=1, mutable=True) + m.x1 = Var(initialize=1, bounds=(0, 1)) + m.x2 = Var(initialize=2, bounds=(-m.q, log(m.q))) + m.obj = Objective(expr=m.x1 + m.x2) + + box_set = BoxSet(bounds=[[0, 1]]) + + local_solver = SolverFactory("baron") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") + + with self.assertRaisesRegex( + expected_exception=ArithmeticError, + expected_regex=( + "Evaluation of second-stage inequality constraint.*math domain error.*" + ), + msg="ValueError arising from math domain error not raised", + ): + # should raise math domain error: + # (1) lower bounding constraint on x2 solved first + # in separation, q = 0 in worst case + # (2) now tries to evaluate log(q), but q = 0 + pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.q], + uncertainty_set=box_set, + local_solver=local_solver, + global_solver=global_solver, + decision_rule_order=1, + tee=True, ) @unittest.skipUnless( baron_license_is_valid, "Global NLP solver is not available and licensed." ) - def test_identifying_violating_param_realization(self): + def test_pyros_no_perf_cons(self): + """ + Ensure PyROS properly accommodates models with no + second-stage inequality constraints + (such as effectively deterministic models). + """ m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) + m.x = Var(bounds=(0, 1)) + m.q = Param(mutable=True, initialize=1) - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) + m.obj = Objective(expr=m.x * m.q) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + pyros_solver = SolverFactory("pyros") + res = pyros_solver.solve( + model=m, + first_stage_variables=[m.x], + second_stage_variables=[], + uncertain_params=[m.q], + uncertainty_set=BoxSet(bounds=[[0, 1]]), + local_solver=SolverFactory("ipopt"), + global_solver=SolverFactory("ipopt"), + solve_master_globally=True, + ) + self.assertEqual( + res.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, + msg=( + f"Returned termination condition for separation error" + "test is not {pyrosTerminationCondition.subsolver_error}.", + ), + ) - # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_nominal_focus_robust_feasible(self): + """ + Test problem under nominal objective focus terminates + successfully. + """ + m = build_leyffer_two_cons() + + # singleton set, guaranteed robust feasibility + discrete_scenarios = DiscreteScenarioSet(scenarios=[[1.125]]) # Instantiate the PyROS solver pyros_solver = SolverFactory("pyros") @@ -4136,47 +1341,31 @@ def test_identifying_violating_param_realization(self): first_stage_variables=[m.x1, m.x2], second_stage_variables=[], uncertain_params=[m.u], - uncertainty_set=interval, + uncertainty_set=discrete_scenarios, local_solver=local_subsolver, global_solver=global_subsolver, + solve_master_globally=False, + bypass_local_separation=True, options={ - "objective_focus": ObjectiveType.worst_case, + "objective_focus": ObjectiveType.nominal, "solve_master_globally": True, }, ) - + # check for robust feasible termination self.assertEqual( results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg="Did not identify robust optimal solution to problem instance.", - ) - self.assertGreater( - results.iterations, - 0, - msg="Robust infeasible model terminated in 0 iterations (nominal case).", + pyrosTerminationCondition.robust_feasible, + msg="Returned termination condition is not return robust_optimal.", ) @unittest.skipUnless( baron_license_is_valid, "Global NLP solver is not available and licensed." ) - @unittest.skipUnless( - baron_version < (23, 1, 5) or baron_version >= (23, 6, 23), - "Test known to fail for BARON 23.1.5 and versions preceding 23.6.23", - ) - def test_terminate_with_max_iter(self): - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + def test_discrete_separation(self): + m = build_leyffer_two_cons() # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) + discrete_scenarios = DiscreteScenarioSet(scenarios=[[0.25], [2.0], [1.125]]) # Instantiate the PyROS solver pyros_solver = SolverFactory("pyros") @@ -4188,49 +1377,29 @@ def test_terminate_with_max_iter(self): # Call the PyROS solver results = pyros_solver.solve( model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], uncertain_params=[m.u], - uncertainty_set=interval, + uncertainty_set=discrete_scenarios, local_solver=local_subsolver, global_solver=global_subsolver, options={ "objective_focus": ObjectiveType.worst_case, "solve_master_globally": True, - "max_iter": 1, - "decision_rule_order": 2, }, ) self.assertEqual( results.pyros_termination_condition, - pyrosTerminationCondition.max_iter, - msg="Returned termination condition is not return max_iter.", - ) - - self.assertEqual( - results.iterations, - 1, - msg=( - f"Number of iterations in results object is {results.iterations}, " - f"but expected value 1." - ), + pyrosTerminationCondition.robust_optimal, + msg="Returned termination condition is not return robust_optimal.", ) @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." + scip_available and scip_license_is_valid, "SCIP is not available and licensed." ) - def test_terminate_with_time_limit(self): - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + def test_higher_order_decision_rules(self): + m = build_leyffer_two_cons() # Define the uncertainty set interval = BoxSet(bounds=[(0.25, 2)]) @@ -4239,78 +1408,51 @@ def test_terminate_with_time_limit(self): pyros_solver = SolverFactory("pyros") # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + local_subsolver = SolverFactory("scip") + global_subsolver = SolverFactory("scip") # Call the PyROS solver results = pyros_solver.solve( model=m, - first_stage_variables=[m.x1, m.x2], - second_stage_variables=[], + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], uncertain_params=[m.u], uncertainty_set=interval, local_solver=local_subsolver, global_solver=global_subsolver, - objective_focus=ObjectiveType.worst_case, - solve_master_globally=True, - time_limit=0.001, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + "decision_rule_order": 2, + }, ) - # validate termination condition self.assertEqual( results.pyros_termination_condition, - pyrosTerminationCondition.time_out, - msg="Returned termination condition is not return time_out.", + pyrosTerminationCondition.robust_optimal, + msg="Returned termination condition is not return robust_optimal.", ) - # verify subsolver options are unchanged - subsolvers = [local_subsolver, global_subsolver] - for slvr, desc in zip(subsolvers, ["Local", "Global"]): - self.assertEqual( - len(list(slvr.options.keys())), - 0, - msg=f"{desc} subsolver options were changed by PyROS", - ) - self.assertIs( - getattr(slvr.options, "MaxTime", None), - None, - msg=( - f"{desc} subsolver (BARON) MaxTime setting was added " - "by PyROS, but not reverted" - ), - ) - - @unittest.skipUnless( - SolverFactory('baron').license_is_valid(), - "Global NLP solver is not available and licensed.", - ) - def test_separation_terminate_time_limit(self): - """ - Test PyROS time limit status returned in event - separation problem times out. - """ - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + @unittest.skipUnless(scip_available, "Global NLP solver is not available.") + def test_coefficient_matching_solve(self): + # Write the deterministic Pyomo model + m = build_leyffer() + m.eq_con = Constraint( + expr=m.u**2 * (m.x2 - 1) + + m.u * (m.x1**3 + 0.5) + - 5 * m.u * m.x1 * m.x2 + + m.u * (m.x1 + 2) + == 0 + ) - # Define the uncertainty set interval = BoxSet(bounds=[(0.25, 2)]) # Instantiate the PyROS solver pyros_solver = SolverFactory("pyros") # Define subsolvers utilized in the algorithm - local_subsolver = TimeDelaySolver( - calls_to_sleep=0, sub_solver=SolverFactory("baron"), max_time=1 - ) - global_subsolver = SolverFactory("baron") + local_subsolver = SolverFactory('scip') + global_subsolver = SolverFactory("scip") # Call the PyROS solver results = pyros_solver.solve( @@ -4321,481 +1463,441 @@ def test_separation_terminate_time_limit(self): uncertainty_set=interval, local_solver=local_subsolver, global_solver=global_subsolver, - objective_focus=ObjectiveType.worst_case, - solve_master_globally=True, - time_limit=1, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, ) self.assertEqual( results.pyros_termination_condition, - pyrosTerminationCondition.time_out, - msg="Returned termination condition is not return time_out.", + pyrosTerminationCondition.robust_optimal, + msg=( + "Non-optimal termination condition from robust" + "feasible coefficient matching problem." + ), + ) + self.assertAlmostEqual( + results.final_objective_value, + 6.0394, + 2, + msg="Incorrect objective function value.", ) - @unittest.skipUnless( - SolverFactory('gams').license_is_valid() - and SolverFactory('baron').license_is_valid(), - "Global NLP solver is not available and licensed.", - ) - def test_gams_successful_time_limit(self): + def build_mitsos_4_3(self): """ - Test PyROS time limit status returned in event - separation problem times out. + Create instance of Problem 4_3 from Mitsos (2011)'s + Test Set of semi-infinite programs. """ + # construct the deterministic model m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) + m.u = Param(initialize=0.5, mutable=True) + m.x1 = Var(bounds=[-1000, 1000]) + m.x2 = Var(bounds=[-1000, 1000]) + m.x3 = Var(bounds=[-1000, 1000]) + m.con = Constraint(expr=exp(m.u - 1) - m.x1 - m.x2 * m.u - m.x3 * m.u**2 <= 0) + m.eq_con = Constraint( + expr=( + m.u**2 * (m.x2 - 1) + + m.u * (m.x1**3 + 0.5) + - 5 * m.u * m.x1 * m.x2 + + m.u * (m.x1 + 2) + == 0 + ) + ) + m.obj = Objective(expr=m.x1 + m.x2 / 2 + m.x3 / 3) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + return m - # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) + @unittest.skipUnless( + baron_license_is_valid and scip_available and scip_license_is_valid, + "Global solvers BARON and SCIP not both available and licensed", + ) + @unittest.skipIf( + (24, 1, 5) <= baron_version and baron_version <= (24, 5, 8), + f"Test expected to fail for BARON version {baron_version}", + ) + def test_coeff_matching_solver_insensitive(self): + """ + Check that result for instance with constraint subject to + coefficient matching is insensitive to subsolver settings. Based + on Mitsos (2011) semi-infinite programming instance 4_3. + """ + m = self.build_mitsos_4_3() - # Instantiate the PyROS solver + # instantiate BARON subsolver and PyROS solver + baron = SolverFactory("baron") + scip = SolverFactory("scip") pyros_solver = SolverFactory("pyros") - # Define subsolvers utilized in the algorithm - # two GAMS solvers, one of which has reslim set - # (overridden when invoked in PyROS) - local_subsolvers = [ - SolverFactory("gams:conopt"), - SolverFactory("gams:conopt"), - SolverFactory("ipopt"), - ] - local_subsolvers[0].options["add_options"] = ["option reslim=100;"] - global_subsolver = SolverFactory("baron") - global_subsolver.options["MaxTime"] = 300 - - # Call the PyROS solver - for idx, opt in enumerate(local_subsolvers): - results = pyros_solver.solve( + # solve with PyROS + solver_names = {"baron": baron, "scip": scip} + for name, solver in solver_names.items(): + res = pyros_solver.solve( model=m, - first_stage_variables=[m.x1, m.x2], - second_stage_variables=[], + first_stage_variables=[], + second_stage_variables=[m.x1, m.x2, m.x3], uncertain_params=[m.u], - uncertainty_set=interval, - local_solver=opt, - global_solver=global_subsolver, + uncertainty_set=BoxSet(bounds=[[0, 1]]), + local_solver=solver, + global_solver=solver, objective_focus=ObjectiveType.worst_case, solve_master_globally=True, - time_limit=100, + bypass_local_separation=True, + robust_feasibility_tolerance=1e-4, ) - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, + first=res.iterations, + second=2, msg=( - f"Returned termination condition with local " - "subsolver {idx + 1} of 2 is not robust_optimal." + "Iterations for Watson 43 instance solved with " + f"subsolver {name} not as expected" ), ) - - # check first local subsolver settings - # remain unchanged after PyROS exit - self.assertEqual( - len(list(local_subsolvers[0].options["add_options"])), - 1, - msg=( - f"Local subsolver {local_subsolvers[0]} options 'add_options'" - "were changed by PyROS" - ), - ) - self.assertEqual( - local_subsolvers[0].options["add_options"][0], - "option reslim=100;", - msg=( - f"Local subsolver {local_subsolvers[0]} setting " - "'add_options' was modified " - "by PyROS, but changes were not properly undone" - ), - ) - - # check global subsolver settings unchanged - self.assertEqual( - len(list(global_subsolver.options.keys())), - 1, - msg=(f"Global subsolver {global_subsolver} options were changed by PyROS"), - ) - self.assertEqual( - global_subsolver.options["MaxTime"], - 300, - msg=( - f"Global subsolver {global_subsolver} setting " - "'MaxTime' was modified " - "by PyROS, but changes were not properly undone" - ), - ) - - # check other local subsolvers remain unchanged - for slvr, key in zip(local_subsolvers[1:], ["add_options", "max_cpu_time"]): - # no custom options were added to the `options` - # attribute of the optimizer, so any attribute - # of `options` should be `None` - self.assertIs( - getattr(slvr.options, key, None), - None, - msg=( - f"Local subsolver {slvr} setting '{key}' was added " - "by PyROS, but not reverted" + np.testing.assert_allclose( + actual=res.final_objective_value, + # this value can be hand-calculated by analyzing the + # initial master problem + desired=0.9781633, + rtol=0, + atol=5e-3, + err_msg=( + "Final objective for Watson 43 instance solved with " + f"subsolver {name} not as expected" ), ) @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." + scip_available and scip_license_is_valid, "SCIP is not available and licensed." ) - def test_terminate_with_application_error(self): + def test_coefficient_matching_partitioning_insensitive(self): """ - Check that PyROS correctly raises ApplicationError - in event of abnormal IPOPT termination. + Check that result for instance with constraint subject to + coefficient matching is insensitive to DOF partitioning. Model + is based on Mitsos (2011) semi-infinite programming instance + 4_3. """ - m = ConcreteModel() - m.p = Param(mutable=True, initialize=1.5) - m.x1 = Var(initialize=-1) - m.obj = Objective(expr=log(m.x1) * m.p) - m.con = Constraint(expr=m.x1 * m.p >= -2) - - solver = SolverFactory("ipopt") - solver.options["halt_on_ampl_error"] = "yes" - baron = SolverFactory("baron") + m = self.build_mitsos_4_3() - box_set = BoxSet(bounds=[(1, 2)]) + global_solver = SolverFactory("scip") pyros_solver = SolverFactory("pyros") - with self.assertRaisesRegex( - ApplicationError, r"Solver \(ipopt\) did not exit normally" - ): - pyros_solver.solve( + + # solve with PyROS + partitionings = [ + {"fsv": [m.x1, m.x2, m.x3], "ssv": []}, + {"fsv": [], "ssv": [m.x1, m.x2, m.x3]}, + ] + for partitioning in partitionings: + res = pyros_solver.solve( model=m, - first_stage_variables=[m.x1], - second_stage_variables=[], - uncertain_params=[m.p], - uncertainty_set=box_set, - local_solver=solver, - global_solver=baron, - objective_focus=ObjectiveType.nominal, - time_limit=1000, + first_stage_variables=partitioning["fsv"], + second_stage_variables=partitioning["ssv"], + uncertain_params=[m.u], + uncertainty_set=BoxSet(bounds=[[0, 1]]), + local_solver=global_solver, + global_solver=global_solver, + objective_focus=ObjectiveType.worst_case, + solve_master_globally=True, + bypass_local_separation=True, + robust_feasibility_tolerance=1e-4, + ) + self.assertEqual( + first=res.iterations, + second=2, + msg=( + "Iterations for Watson 43 instance solved with " + f"first-stage vars {[fsv.name for fsv in partitioning['fsv']]} " + f"second-stage vars {[ssv.name for ssv in partitioning['ssv']]} " + "not as expected" + ), + ) + np.testing.assert_allclose( + actual=res.final_objective_value, + desired=0.9781633, + rtol=0, + atol=5e-3, + err_msg=( + "Final objective for Watson 43 instance solved with " + f"first-stage vars {[fsv.name for fsv in partitioning['fsv']]} " + f"second-stage vars {[ssv.name for ssv in partitioning['ssv']]} " + "not as expected" + ), ) - # check solver settings are unchanged - self.assertEqual( - len(list(solver.options.keys())), - 1, - msg=(f"Local subsolver {solver} options were changed by PyROS"), + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_coefficient_matching_robust_infeasible_proof_in_pyros(self): + # Write the deterministic Pyomo model + m = build_leyffer() + m.eq_con = Constraint( + expr=m.u * (m.x1**3 + 0.5) + - 5 * m.u * m.x1 * m.x2 + + m.u * (m.x1 + 2) + + m.u**2 + == 0 ) - self.assertEqual( - solver.options["halt_on_ampl_error"], - "yes", - msg=( - f"Local subsolver {solver} option " - "'halt_on_ampl_error' was changed by PyROS" - ), + + interval = BoxSet(bounds=[(0.25, 2)]) + + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") + + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory("baron") + global_subsolver = SolverFactory("baron") + + # Call the PyROS solver + + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u], + uncertainty_set=interval, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, ) + self.assertEqual( - len(list(baron.options.keys())), - 0, - msg=(f"Global subsolver {baron} options were changed by PyROS"), + results.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, + msg="Robust infeasible problem not identified via coefficient matching.", + ) + self.assertEqual( + results.iterations, 0, msg="Number of PyROS iterations not as expected." ) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_master_subsolver_error(self): + @unittest.skipUnless(ipopt_available, "IPOPT not available") + def test_coefficient_matching_robust_infeasible_param_only_con(self): """ - Test PyROS on a two-stage problem with a subsolver error - termination in the initial master problem. + Test robust infeasibility reported due to equality + constraint depending only on uncertain params. """ - m = ConcreteModel() - - m.q = Param(initialize=1, mutable=True) - - m.x1 = Var(initialize=1, bounds=(0, 1)) - - # source of subsolver error: can't converge to log(0) - # in separation problem (make x2 second-stage var) - m.x2 = Var(initialize=2, bounds=(0, m.q)) + m = build_leyffer() + m.robust_infeasible_eq_con = Constraint(expr=m.u == 1) - m.obj = Objective(expr=log(m.x1) + m.x2) - - box_set = BoxSet(bounds=[(0, 1)]) + box_set = BoxSet(bounds=[(0.25, 2)]) - local_solver = SolverFactory("ipopt") - global_solver = SolverFactory("baron") + ipopt = SolverFactory("ipopt") pyros_solver = SolverFactory("pyros") - res = pyros_solver.solve( + results = pyros_solver.solve( model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - uncertain_params=[m.q], + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u], uncertainty_set=box_set, - local_solver=local_solver, - global_solver=global_solver, - decision_rule_order=1, - tee=True, + local_solver=ipopt, + global_solver=ipopt, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, ) + self.assertEqual( - res.pyros_termination_condition, - pyrosTerminationCondition.subsolver_error, - msg=( - f"Returned termination condition for separation error" - "test is not {pyrosTerminationCondition.subsolver_error}.", - ), + results.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, + msg="Robust infeasible problem not identified via coefficient matching.", + ) + self.assertEqual( + results.iterations, 0, msg="Number of PyROS iterations not as expected." ) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_separation_subsolver_error(self): + @unittest.skipUnless(ipopt_available, "IPOPT not available.") + def test_coefficient_matching_nonlinear_expr(self): """ - Test PyROS on a two-stage problem with a subsolver error - termination in separation. + Test behavior of PyROS solver for model with + equality constraint that cannot be reformulated via + coefficient matching due to nonlinearity. """ - m = ConcreteModel() - - m.q = Param(initialize=1, mutable=True) - - m.x1 = Var(initialize=1, bounds=(0, 1)) + m = build_leyffer() + m.eq_con = Constraint(expr=m.u**2 * (m.x2 - 1) == 0) - # source of subsolver error: can't converge to log(0) - # in separation problem (make x2 second-stage var) - m.x2 = Var(initialize=2, bounds=(0, log(m.q))) + interval = BoxSet(bounds=[(0.25, 2)]) - m.obj = Objective(expr=m.x1 + m.x2) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - box_set = BoxSet(bounds=[(0, 1)]) - d_set = DiscreteScenarioSet(scenarios=[(1,), (0,)]) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory("ipopt") + global_subsolver = SolverFactory("ipopt") - local_solver = SolverFactory("ipopt") - global_solver = SolverFactory("baron") - pyros_solver = SolverFactory("pyros") + # Call the PyROS solver + with LoggingIntercept(module="pyomo.contrib.pyros", level=logging.DEBUG) as LOG: + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.u], + uncertainty_set=interval, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": False, + "bypass_global_separation": True, + "decision_rule_order": 1, + }, + ) - res = pyros_solver.solve( - model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - uncertain_params=[m.q], - uncertainty_set=box_set, - local_solver=local_solver, - global_solver=global_solver, - decision_rule_order=1, - tee=True, + pyros_log = LOG.getvalue() + self.assertRegex( + pyros_log, r".*Equality constraint '.*eq_con.*'.*cannot be written.*" ) + self.assertEqual( - res.pyros_termination_condition, - pyrosTerminationCondition.subsolver_error, - msg=( - "Returned termination condition for separation error" - f"test is not {pyrosTerminationCondition.subsolver_error}." - ), + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) - # FIXME: This test is expected to fail now, as writing out invalid - # models generates an exception in the problem writer (and is never - # actually sent to the solver) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - @unittest.expectedFailure - def test_discrete_separation_subsolver_error(self): - """ - Test PyROS for two-stage problem with discrete type set, - subsolver error status. - """ + @unittest.skipUnless(ipopt_available, "IPOPT not available.") + def test_coefficient_matching_certain_param(self): m = ConcreteModel() + m.q1 = Param(mutable=True, initialize=1) + m.q2 = Param(mutable=True, initialize=1) + m.x1 = Var(bounds=[0, 1]) + m.x2 = Var(bounds=[0, 1]) + m.eq_con = Constraint(expr=m.q1 * m.x1 - m.x2 + m.q2 == 0) + m.obj = Objective(expr=m.x1 + m.x2) - m.q = Param(initialize=1, mutable=True) - m.x1 = Var(initialize=1, bounds=(0, 1)) - - # upper bound induces subsolver error: separation - # max(x2 - log(m.q)) will force subsolver to q = 0 - m.x2 = Var(initialize=2, bounds=(None, log(m.q))) - - m.obj = Objective(expr=m.x1 + m.x2, sense=maximize) - - discrete_set = DiscreteScenarioSet(scenarios=[(1,), (0,)]) - - local_solver = SolverFactory("ipopt") - global_solver = SolverFactory("baron") pyros_solver = SolverFactory("pyros") + ipopt = SolverFactory("ipopt") + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.q1, m.q2], + # makes q2 a certain param + # so the equality constraint should be coefficient matched + # with respect to q1 only + uncertainty_set=BoxSet([[1, 2], [1, 1]]), + local_solver=ipopt, + global_solver=ipopt, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, + ) - res = pyros_solver.solve( + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + ) + self.assertEqual(results.iterations, 1) + self.assertAlmostEqual(first=results.final_objective_value, second=1, places=2) + self.assertEqual(m.x1.value, 0) + self.assertEqual(m.x2.value, 1) + + results2 = pyros_solver.solve( model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - uncertain_params=[m.q], - uncertainty_set=discrete_set, - local_solver=local_solver, - global_solver=global_solver, - decision_rule_order=1, - tee=True, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.q1, m.q2], + # now both parameters are truly uncertain; + # problem should be robust infeasible + uncertainty_set=BoxSet([[1, 2], [1, 2]]), + local_solver=ipopt, + global_solver=ipopt, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, ) self.assertEqual( - res.pyros_termination_condition, - pyrosTerminationCondition.subsolver_error, - msg=( - "Returned termination condition for separation error" - f"test is not {pyrosTerminationCondition.subsolver_error}." - ), + results2.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, ) + # robust infeasibility detected in coefficient matching + self.assertEqual(results2.iterations, 0) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_pyros_math_domain_error(self): - """ - Test PyROS on a two-stage problem, discrete - set type with a math domain error evaluating - performance constraint expressions in separation. - """ + @unittest.skipUnless(ipopt_available, "IPOPT not available.") + def test_coefficient_matching_single_certain_param(self): m = ConcreteModel() m.q = Param(initialize=1, mutable=True) - m.x1 = Var(initialize=1, bounds=(0, 1)) - m.x2 = Var(initialize=2, bounds=(-m.q, log(m.q))) + m.x1 = Var(bounds=[1, 2]) + m.x2 = Var(bounds=[1, 2]) + # unless the uncertain parameter q is fixed to a single value, + # this constraint is subject to coefficient matching + m.con = Constraint(expr=m.q * m.x1 - m.x2 == 0) m.obj = Objective(expr=m.x1 + m.x2) - box_set = BoxSet(bounds=[[0, 1]]) - - local_solver = SolverFactory("baron") - global_solver = SolverFactory("baron") + ipopt = SolverFactory("ipopt") pyros_solver = SolverFactory("pyros") - with self.assertRaisesRegex( - expected_exception=ArithmeticError, - expected_regex=( - "Evaluation of performance constraint.*math domain error.*" - ), - msg="ValueError arising from math domain error not raised", - ): - # should raise math domain error: - # (1) lower bounding constraint on x2 solved first - # in separation, q = 0 in worst case - # (2) now tries to evaluate log(q), but q = 0 - pyros_solver.solve( - model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - uncertain_params=[m.q], - uncertainty_set=box_set, - local_solver=local_solver, - global_solver=global_solver, - decision_rule_order=1, - tee=True, - ) - - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_pyros_no_perf_cons(self): - """ - Ensure PyROS properly accommodates models with no - performance constraints (such as effectively deterministic - models). - """ - m = ConcreteModel() - m.x = Var(bounds=(0, 1)) - m.q = Param(mutable=True, initialize=1) - - m.obj = Objective(expr=m.x * m.q) - - pyros_solver = SolverFactory("pyros") res = pyros_solver.solve( model=m, - first_stage_variables=[m.x], + first_stage_variables=[m.x1, m.x2], second_stage_variables=[], - uncertain_params=[m.q], - uncertainty_set=BoxSet(bounds=[[0, 1]]), - local_solver=SolverFactory("ipopt"), - global_solver=SolverFactory("ipopt"), - solve_master_globally=True, + uncertain_params=m.q, + uncertainty_set=BoxSet([[1, 1]]), + local_solver=ipopt, + global_solver=ipopt, ) self.assertEqual( - res.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, - msg=( - f"Returned termination condition for separation error" - "test is not {pyrosTerminationCondition.subsolver_error}.", - ), + res.pyros_termination_condition, pyrosTerminationCondition.robust_feasible ) + self.assertEqual(res.iterations, 1) + self.assertAlmostEqual(res.final_objective_value, 2) + self.assertAlmostEqual(m.x1.value, 1) + self.assertAlmostEqual(m.x2.value, 1) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_nominal_focus_robust_feasible(self): - """ - Test problem under nominal objective focus terminates - successfully. - """ - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - - # singleton set, guaranteed robust feasibility - discrete_scenarios = DiscreteScenarioSet(scenarios=[[1.125]]) - - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") - - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + res2 = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=m.q, + uncertainty_set=BoxSet([[1, 2]]), + local_solver=ipopt, + global_solver=ipopt, + ) + self.assertEqual( + res2.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, + ) + self.assertEqual(res2.iterations, 1) - # Call the PyROS solver - results = pyros_solver.solve( + # when q constrained to 0, still robust infeasible, + # as the equality constraint fixes x2 to 0 (out of bounds) + res3 = pyros_solver.solve( model=m, first_stage_variables=[m.x1, m.x2], second_stage_variables=[], - uncertain_params=[m.u], - uncertainty_set=discrete_scenarios, - local_solver=local_subsolver, - global_solver=global_subsolver, - solve_master_globally=False, - bypass_local_separation=True, - options={ - "objective_focus": ObjectiveType.nominal, - "solve_master_globally": True, - }, + uncertain_params=m.q, + uncertainty_set=BoxSet([[0, 0]]), + local_solver=ipopt, + global_solver=ipopt, + nominal_uncertain_param_vals=[0], ) - # check for robust feasible termination self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, - msg="Returned termination condition is not return robust_optimal.", + res3.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, ) + self.assertEqual(res3.iterations, 1) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_discrete_separation(self): - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - - # Define the uncertainty set - discrete_scenarios = DiscreteScenarioSet(scenarios=[[0.25], [2.0], [1.125]]) + @unittest.skipUnless(scip_available, "Global NLP solver is not available.") + def test_coefficient_matching_singleton_set(self): + m = build_leyffer() + # when uncertainty set is singleton, + # this constraint should not be coefficient matched; + # otherwise problem is reported robust infeasible + m.eq_con = Constraint( + expr=m.u * (m.x1**3 + 0.5) + - 5 * m.u * m.x1 * m.x2 + + m.u * (m.x1 + 2) + + m.u**2 + == 0 + ) # Instantiate the PyROS solver pyros_solver = SolverFactory("pyros") # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + local_subsolver = SolverFactory('scip') + global_subsolver = SolverFactory("scip") # Call the PyROS solver results = pyros_solver.solve( @@ -4803,7 +1905,7 @@ def test_discrete_separation(self): first_stage_variables=[m.x1, m.x2], second_stage_variables=[], uncertain_params=[m.u], - uncertainty_set=discrete_scenarios, + uncertainty_set=BoxSet(bounds=[(value(m.u), value(m.u))]), local_solver=local_subsolver, global_solver=global_subsolver, options={ @@ -4811,411 +1913,498 @@ def test_discrete_separation(self): "solve_master_globally": True, }, ) - self.assertEqual( results.pyros_termination_condition, pyrosTerminationCondition.robust_optimal, - msg="Returned termination condition is not return robust_optimal.", + ) + self.assertEqual(results.iterations, 1) + self.assertAlmostEqual( + first=results.final_objective_value, + second=2.4864, + places=2, + msg="Incorrect objective function value.", ) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - @unittest.skipUnless( - baron_version == (23, 1, 5), "Test runs >90 minutes with Baron 22.9.30" - ) - def test_higher_order_decision_rules(self): - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - - # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) - - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") - - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") - - # Call the PyROS solver - results = pyros_solver.solve( + results2 = pyros_solver.solve( model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], uncertain_params=[m.u], - uncertainty_set=interval, + uncertainty_set=BoxSet(bounds=[(value(m.u), 1e-3 + value(m.u))]), local_solver=local_subsolver, global_solver=global_subsolver, options={ "objective_focus": ObjectiveType.worst_case, "solve_master_globally": True, - "decision_rule_order": 2, }, ) - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg="Returned termination condition is not return robust_optimal.", + results2.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, ) + self.assertEqual(results2.iterations, 0) + self.assertEqual(results2.final_objective_value, None) - @unittest.skipUnless(scip_available, "Global NLP solver is not available.") - def test_coefficient_matching_solve(self): - # Write the deterministic Pyomo model - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.u = Param(initialize=1.125, mutable=True) + @unittest.skipUnless(ipopt_available, "IPOPT not available") + def test_pyros_certain_params_ipopt_degrees_of_freedom(self): + """ + Test PyROS with IPOPT as subsolver does not run into + subproblems not solved successfully due to too few + degrees of freedom. + """ + # choose a value of 2 or more + num_uncertain_params = 5 - m.con = Constraint(expr=m.u ** (0.5) * m.x1 - m.u * m.x2 <= 2) - m.eq_con = Constraint( - expr=m.u**2 * (m.x2 - 1) - + m.u * (m.x1**3 + 0.5) - - 5 * m.u * m.x1 * m.x2 - + m.u * (m.x1 + 2) - == 0 - ) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + m = ConcreteModel() + m.x = Var(bounds=[1, 2]) + m.q = Param(range(num_uncertain_params), initialize=1, mutable=True) + m.obj = Objective(expr=m.x + sum(m.q.values())) - interval = BoxSet(bounds=[(0.25, 2)]) + # only the first uncertain parameter is effectively uncertain + box_set = BoxSet([[1, 2]] + [[1, 1]] * (num_uncertain_params - 1)) - # Instantiate the PyROS solver pyros_solver = SolverFactory("pyros") - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('scip') - global_subsolver = SolverFactory("scip") + # IPOPT is sensitive to models with too few degrees of freedom + ipopt = SolverFactory('ipopt') - # Call the PyROS solver results = pyros_solver.solve( model=m, - first_stage_variables=[m.x1, m.x2], + first_stage_variables=[m.x], second_stage_variables=[], - uncertain_params=[m.u], - uncertainty_set=interval, - local_solver=local_subsolver, - global_solver=global_subsolver, + uncertain_params=m.q, + uncertainty_set=box_set, + local_solver=ipopt, + global_solver=ipopt, options={ "objective_focus": ObjectiveType.worst_case, - "solve_master_globally": True, + "bypass_local_separation": True, }, ) + # if treatment of uncertainty set constraints that + # depend only on singleton uncertain parameters is not + # appropriate, then subsolver error termination may occur self.assertEqual( results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg="Non-optimal termination condition from robust feasible coefficient matching problem.", + pyrosTerminationCondition.robust_feasible, ) + self.assertEqual(results.iterations, 2) self.assertAlmostEqual( - results.final_objective_value, - 6.0394, - 2, - msg="Incorrect objective function value.", + first=results.final_objective_value, + second=2 + num_uncertain_params, + places=2, ) + self.assertEqual(m.x.value, 1) - def create_mitsos_4_3(self): + @parameterized.expand([[True, 1], [True, 2], [False, 1], [False, 2]]) + def test_two_stage_set_nonstatic_dr_robust_opt(self, use_discrete_set, dr_order): """ - Create instance of Problem 4_3 from Mitsos (2011)'s - Test Set of semi-infinite programs. + Test problems that are sensitive to the DR order efficiency. + + If the efficiency is not switched off properly, then + PyROS may terminate prematurely with a(n inaccurate) + robust infeasibility status. """ - # construct the deterministic model m = ConcreteModel() - m.u = Param(initialize=0.5, mutable=True) - m.x1 = Var(bounds=[-1000, 1000]) - m.x2 = Var(bounds=[-1000, 1000]) - m.x3 = Var(bounds=[-1000, 1000]) - m.con = Constraint(expr=exp(m.u - 1) - m.x1 - m.x2 * m.u - m.x3 * m.u**2 <= 0) - m.eq_con = Constraint( - expr=( - m.u**2 * (m.x2 - 1) - + m.u * (m.x1**3 + 0.5) - - 5 * m.u * m.x1 * m.x2 - + m.u * (m.x1 + 2) - == 0 - ) + m.x = Var(bounds=[-2, 2], initialize=0) + m.z = Var(bounds=[-10, 10], initialize=0) + m.q = Param(initialize=2, mutable=True) + m.obj = Objective(expr=m.x + m.z, sense=maximize) + # when uncertainty set is discrete, the + # preprocessor should write out this constraint for + # each scenario as a first-stage constraint + # otherwise, coefficient matching constraint + # requires only the affine DR coefficient be nonzero + m.xz_con = Constraint(expr=m.z == m.q) + + uncertainty_set = ( + DiscreteScenarioSet([[2], [3]]) if use_discrete_set else BoxSet([[2, 3]]) ) - m.obj = Objective(expr=m.x1 + m.x2 / 2 + m.x3 / 3) + baron = SolverFactory("baron") + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=m.x, + second_stage_variables=m.z, + uncertain_params=m.q, + uncertainty_set=uncertainty_set, + local_solver=baron, + global_solver=baron, + solve_master_globally=True, + bypass_local_separation=True, + decision_rule_order=dr_order, + objective_focus="worst_case", + ) + self.assertEqual( + # DR efficiency should have been switched off due to + # DR-dependent equalities, so robust optimal + # if the DR efficiency was not switched off, then + # robust infeasibililty would have been prematurely reported + res.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + ) + self.assertEqual(res.iterations, 1) + # optimal solution evaluated under worst-case scenario + self.assertAlmostEqual(res.final_objective_value, 4, places=4) + self.assertAlmostEqual(m.x.value, 2, places=4) + self.assertAlmostEqual(m.z.value, 2, places=4) + + +@unittest.skipUnless(baron_available, "BARON not available") +class TestReformulateSecondStageEqualitiesDiscrete(unittest.TestCase): + """ + Test behavior of PyROS solver when the uncertainty set is + discrete and there are second-stage + equality constraints that are state-variable independent, + and therefore, subject to reformulation. + """ + def build_single_stage_model(self): + m = ConcreteModel() + m.x = Var(range(3), bounds=[-2, 2], initialize=0) + m.q = Param(range(3), initialize=0, mutable=True) + m.c = Param(range(3), initialize={0: 1, 1: 0, 2: 1}) + m.obj = Objective(expr=sum(m.x[i] * m.c[i] for i in m.x), sense=maximize) + # when uncertainty set is discrete, the + # preprocessor should write out this constraint for + # each scenario as a first-stage constraint + m.xq_con = Constraint(expr=sum(m.x[i] * m.q[i] for i in m.x) == 0) return m - @unittest.skipUnless( - baron_license_is_valid and scip_available and scip_license_is_valid, - "Global solvers BARON and SCIP not both available and licensed", - ) - def test_coeff_matching_solver_insensitive(self): - """ - Check that result for instance with constraint subject to - coefficient matching is insensitive to subsolver settings. Based - on Mitsos (2011) semi-infinite programming instance 4_3. - """ - m = self.create_mitsos_4_3() + def build_two_stage_model(self): + m = ConcreteModel() + m.x = Var(bounds=[None, None], initialize=0) + m.z = Var(bounds=[-2, 2], initialize=0) + m.q = Param(initialize=2, mutable=True) + m.obj = Objective(expr=m.x + m.z, sense=maximize) + # when uncertainty set is discrete, the + # preprocessor should write out this constraint for + # each scenario as a first-stage constraint + m.xz_con = Constraint(expr=m.x + m.q * m.z == 0) + return m - # instantiate BARON subsolver and PyROS solver + def test_single_stage_discrete_set_fullrank(self): + m = self.build_single_stage_model() + uncertainty_set = DiscreteScenarioSet( + # reformulating second-stage equality for these scenarios + # should result in first-stage equalities finally being + # (full-column-rank matrix) @ (x) == 0 + # so x=0 is sole robust feasible solution + scenarios=[ + [0] * len(m.q), + [1] * len(m.q), + list(range(1, len(m.q) + 1)), + [(idx + 1) ** 2 for idx in m.q], + ] + ) baron = SolverFactory("baron") - scip = SolverFactory("scip") - pyros_solver = SolverFactory("pyros") + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=m.x, + second_stage_variables=[], + uncertain_params=m.q, + uncertainty_set=uncertainty_set, + local_solver=baron, + global_solver=baron, + solve_master_globally=True, + bypass_local_separation=True, + objective_focus="worst_case", + ) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + self.assertEqual(res.iterations, 1) + self.assertAlmostEqual(res.final_objective_value, 0, places=4) + self.assertAlmostEqual(m.x[0].value, 0, places=4) + self.assertAlmostEqual(m.x[1].value, 0, places=4) + self.assertAlmostEqual(m.x[2].value, 0, places=4) - # solve with PyROS - solver_names = {"baron": baron, "scip": scip} - for name, solver in solver_names.items(): - res = pyros_solver.solve( - model=m, - first_stage_variables=[], - second_stage_variables=[m.x1, m.x2, m.x3], - uncertain_params=[m.u], - uncertainty_set=BoxSet(bounds=[[0, 1]]), - local_solver=solver, - global_solver=solver, - objective_focus=ObjectiveType.worst_case, - solve_master_globally=True, - bypass_local_separation=True, - robust_feasibility_tolerance=1e-4, - ) - self.assertEqual( - first=res.iterations, - second=2, - msg=( - "Iterations for Watson 43 instance solved with " - f"subsolver {name} not as expected" - ), - ) - np.testing.assert_allclose( - actual=res.final_objective_value, - desired=0.9781633, - rtol=0, - atol=5e-3, - err_msg=( - "Final objective for Watson 43 instance solved with " - f"subsolver {name} not as expected" - ), - ) + def test_single_stage_discrete_set_rank2(self): + m = self.build_single_stage_model() + uncertainty_set = DiscreteScenarioSet( + # reformulating second-stage equality for these scenarios + # should make the optimal solution unique + scenarios=[[0] * len(m.q), [1] * len(m.q), [(idx + 1) ** 2 for idx in m.q]] + ) + baron = SolverFactory("baron") + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=m.x, + second_stage_variables=[], + uncertain_params=m.q, + uncertainty_set=uncertainty_set, + local_solver=baron, + global_solver=baron, + solve_master_globally=True, + bypass_local_separation=True, + objective_focus="worst_case", + ) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + self.assertEqual(res.iterations, 1) + self.assertAlmostEqual(res.final_objective_value, 2, places=4) + # optimal solution is unique + self.assertAlmostEqual(m.x[0].value, 5 / 4, places=4) + self.assertAlmostEqual(m.x[1].value, -2, places=4) + self.assertAlmostEqual(m.x[2].value, 3 / 4, places=4) - @unittest.skipUnless(scip_available, "NLP solver is not available.") - def test_coefficient_matching_partitioning_insensitive(self): - """ - Check that result for instance with constraint subject to - coefficient matching is insensitive to DOF partitioning. Model - is based on Mitsos (2011) semi-infinite programming instance - 4_3. - """ - m = self.create_mitsos_4_3() + def test_single_stage_discrete_set_rank1(self): + m = self.build_single_stage_model() + uncertainty_set = DiscreteScenarioSet( + scenarios=[[0] * len(m.q), [2] * len(m.q), [3] * len(m.q)] + ) + baron = SolverFactory("baron") + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=m.x, + second_stage_variables=[], + uncertain_params=m.q, + uncertainty_set=uncertainty_set, + local_solver=baron, + global_solver=baron, + solve_master_globally=True, + bypass_local_separation=True, + objective_focus="worst_case", + ) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + self.assertEqual(res.iterations, 1) + self.assertAlmostEqual(res.final_objective_value, 2, places=4) + # subject to these scenarios, the optimal solution is non-unique, + # but should satisfy this check + self.assertAlmostEqual(m.x[1].value, -2, places=4) + + def test_two_stage_discrete_set_rank2_affine_dr(self): + m = self.build_two_stage_model() + uncertainty_set = DiscreteScenarioSet([[2], [3]]) + baron = SolverFactory("baron") + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=m.x, + second_stage_variables=m.z, + uncertain_params=m.q, + uncertainty_set=uncertainty_set, + local_solver=baron, + global_solver=baron, + solve_master_globally=True, + bypass_local_separation=True, + decision_rule_order=1, + objective_focus="worst_case", + ) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + self.assertEqual(res.iterations, 1) + self.assertAlmostEqual(res.final_objective_value, 2, places=4) + self.assertAlmostEqual(m.x.value, 4, places=4) + self.assertAlmostEqual(m.z.value, -2, places=4) + + def test_two_stage_discrete_set_fullrank_affine_dr(self): + m = self.build_two_stage_model() + uncertainty_set = DiscreteScenarioSet([[2], [3], [4]]) + baron = SolverFactory("baron") + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=m.x, + second_stage_variables=m.z, + uncertain_params=m.q, + uncertainty_set=uncertainty_set, + local_solver=baron, + global_solver=baron, + solve_master_globally=True, + bypass_local_separation=True, + decision_rule_order=1, + objective_focus="worst_case", + ) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + self.assertEqual(res.iterations, 1) + self.assertAlmostEqual(res.final_objective_value, 0, places=4) + # the second-stage equalities are a full rank linear system + # in x and the DR variables, with RHS 0, so all + # variables must be 0 + self.assertAlmostEqual(m.x.value, 0, places=4) + self.assertAlmostEqual(m.z.value, 0, places=4) - # instantiate BARON subsolver and PyROS solver - baron = SolverFactory("scip") - pyros_solver = SolverFactory("pyros") - # solve with PyROS - partitionings = [ - {"fsv": [m.x1, m.x2, m.x3], "ssv": []}, - {"fsv": [], "ssv": [m.x1, m.x2, m.x3]}, - ] - for partitioning in partitionings: - res = pyros_solver.solve( - model=m, - first_stage_variables=partitioning["fsv"], - second_stage_variables=partitioning["ssv"], - uncertain_params=[m.u], - uncertainty_set=BoxSet(bounds=[[0, 1]]), - local_solver=baron, - global_solver=baron, - objective_focus=ObjectiveType.worst_case, - solve_master_globally=True, - bypass_local_separation=True, - robust_feasibility_tolerance=1e-4, - ) - self.assertEqual( - first=res.iterations, - second=2, - msg=( - "Iterations for Watson 43 instance solved with " - f"first-stage vars {[fsv.name for fsv in partitioning['fsv']]} " - f"second-stage vars {[ssv.name for ssv in partitioning['ssv']]} " - "not as expected" - ), - ) - np.testing.assert_allclose( - actual=res.final_objective_value, - desired=0.9781633, - rtol=0, - atol=5e-3, - err_msg=( - "Final objective for Watson 43 instance solved with " - f"first-stage vars {[fsv.name for fsv in partitioning['fsv']]} " - f"second-stage vars {[ssv.name for ssv in partitioning['ssv']]} " - "not as expected" - ), +@unittest.skipUnless(ipopt_available, "IPOPT not available.") +class TestPyROSVarsAsUncertainParams(unittest.TestCase): + """ + Test PyROS solver treatment of Var/VarData + objects passed as uncertain parameters. + """ + + def build_model_objects(self): + mdl1 = build_leyffer_two_cons_two_params() + + # clone: use a Var to represent the uncertain parameter. + # to ensure Var is out of scope of all subproblems + # as viewed by the subsolvers, + # let's make the bounds exclude the nominal value; + # PyROS should ignore these bounds as well + mdl2 = mdl1.clone() + mdl2.uvar = Var( + [1, 2], initialize={1: mdl2.u1.value, 2: mdl2.u2.value}, bounds=(-1, 0) + ) + + # want to test replacement of named expressions + # in preprocessing as well, + # so we add a simple placeholder expression + mdl2.uvar2_expr = Expression(expr=mdl2.uvar[2]) + + for comp in [mdl2.con1, mdl2.con2, mdl2.obj]: + comp.set_value( + replace_expressions( + expr=comp.expr, + substitution_map={ + id(mdl2.u1): mdl2.uvar[1], + id(mdl2.u2): mdl2.uvar2_expr, + }, + ) ) + box_set = BoxSet([[0.25, 2], [0.5, 1.5]]) + + return mdl1, mdl2, box_set - def test_coefficient_matching_raises_error_4_3(self): + def test_pyros_unfixed_vars_as_uncertain_params(self): """ - Check that result for instance with constraint subject to - coefficient matching results in exception certifying robustness - cannot be certified where expected. Model - is based on Mitsos (2011) semi-infinite programming instance - 4_3. + Test PyROS raises exception if unfixed Vars are + passed to the argument `uncertain_params`. """ - m = self.create_mitsos_4_3() + _, mdl2, box_set = self.build_model_objects() + mdl2.uvar.unfix() - # instantiate BARON subsolver and PyROS solver - baron = SolverFactory("baron") + ipopt_solver = SolverFactory("ipopt") pyros_solver = SolverFactory("pyros") - # solve with PyROS - dr_orders = [1, 2] - for dr_order in dr_orders: - regex_assert_mgr = self.assertRaisesRegex( - ValueError, - expected_regex=( - "Coefficient matching unsuccessful. See the solver logs." - ), + err_str_1 = r".*VarData object with name 'uvar\[1\]' is not fixed" + with self.assertRaisesRegex(ValueError, err_str_1): + pyros_solver.solve( + model=mdl2, + first_stage_variables=[mdl2.x1, mdl2.x2], + second_stage_variables=[], + uncertain_params=mdl2.uvar, + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, + ) + with self.assertRaisesRegex(ValueError, err_str_1): + pyros_solver.solve( + model=mdl2, + first_stage_variables=[mdl2.x1, mdl2.x2], + second_stage_variables=[], + uncertain_params=[mdl2.uvar[1], mdl2.uvar[2]], + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, ) - logging_intercept_mgr = LoggingIntercept(level=logging.ERROR) - - with regex_assert_mgr, logging_intercept_mgr as LOG: - pyros_solver.solve( - model=m, - first_stage_variables=[], - second_stage_variables=[m.x1, m.x2, m.x3], - uncertain_params=[m.u], - uncertainty_set=BoxSet(bounds=[[0, 1]]), - local_solver=baron, - global_solver=baron, - objective_focus=ObjectiveType.worst_case, - decision_rule_order=dr_order, - solve_master_globally=True, - bypass_local_separation=True, - robust_feasibility_tolerance=1e-4, - ) - detailed_error_msg = LOG.getvalue() - self.assertRegex( - detailed_error_msg[:-1], - ( - r"Equality constraint.*cannot be guaranteed to " - r"be robustly feasible.*" - r"Consider editing this constraint.*" - ), + mdl2.uvar[1].fix() + err_str_2 = r".*VarData object with name 'uvar\[2\]' is not fixed" + with self.assertRaisesRegex(ValueError, err_str_2): + pyros_solver.solve( + model=mdl2, + first_stage_variables=[mdl2.x1, mdl2.x2], + second_stage_variables=[], + uncertain_params=mdl2.uvar, + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, + ) + with self.assertRaisesRegex(ValueError, err_str_2): + pyros_solver.solve( + model=mdl2, + first_stage_variables=[mdl2.x1, mdl2.x2], + second_stage_variables=[], + uncertain_params=[mdl2.uvar[1], mdl2.uvar[2]], + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, ) - def test_coefficient_matching_robust_infeasible_proof_in_pyros(self): - # Write the deterministic Pyomo model - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.u = Param(initialize=1.125, mutable=True) + def test_pyros_vars_as_uncertain_params_correct(self): + """ + Test PyROS solver result is invariant to the type used + in argument `uncertain_params`. + """ + mdl1, mdl2, box_set = self.build_model_objects() - m.con = Constraint(expr=m.u ** (0.5) * m.x1 - m.u * m.x2 <= 2) - m.eq_con = Constraint( - expr=m.u * (m.x1**3 + 0.5) - - 5 * m.u * m.x1 * m.x2 - + m.u * (m.x1 + 2) - + m.u**2 - == 0 - ) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + # explicitly fixed + mdl2.uvar.fix() - interval = BoxSet(bounds=[(0.25, 2)]) + # fixed by bounds that are literal constants + mdl3 = mdl2.clone() + mdl3.uvar.unfix() + mdl3.uvar[1].setlb(mdl3.uvar[1].value) + mdl3.uvar[1].setub(mdl3.uvar[1].value) + mdl3.uvar[2].setlb(mdl3.uvar[2].value) + mdl3.uvar[2].setub(mdl3.uvar[2].value) - # Instantiate the PyROS solver + ipopt_solver = SolverFactory("ipopt") pyros_solver = SolverFactory("pyros") - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") - - # Call the PyROS solver - - results = pyros_solver.solve( - model=m, - first_stage_variables=[m.x1, m.x2], + res1 = pyros_solver.solve( + model=mdl1, + first_stage_variables=[mdl1.x1, mdl1.x2], second_stage_variables=[], - uncertain_params=[m.u], - uncertainty_set=interval, - local_solver=local_subsolver, - global_solver=global_subsolver, - options={ - "objective_focus": ObjectiveType.worst_case, - "solve_master_globally": True, - }, + uncertain_params=[mdl1.u1, mdl1.u2], + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, ) - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_infeasible, - msg="Robust infeasible problem not identified via coefficient matching.", - ) - - def test_coefficient_matching_nonlinear_expr(self): - # Write the deterministic Pyomo model - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con = Constraint(expr=m.u ** (0.5) * m.x1 - m.u * m.x2 <= 2) - m.eq_con = Constraint( - expr=m.u**2 * (m.x2 - 1) - + m.u * (m.x1**3 + 0.5) - - 5 * m.u * m.x1 * m.x2 - + m.u * (m.x1 + 2) - == 0 + res1.pyros_termination_condition, pyrosTerminationCondition.robust_feasible ) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - - interval = BoxSet(bounds=[(0.25, 2)]) - - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") - - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") - # Call the PyROS solver - with self.assertRaises( - ValueError, - msg="ValueError should be raised for general " - "nonlinear expressions in h(x,z,q)=0 constraints.", - ): - results = pyros_solver.solve( - model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - uncertain_params=[m.u], - uncertainty_set=interval, - local_solver=local_subsolver, - global_solver=global_subsolver, - options={ - "objective_focus": ObjectiveType.worst_case, - "solve_master_globally": True, - "decision_rule_order": 1, - }, + for model, adverb in zip([mdl2, mdl3], ["explicitly", "by bounds"]): + res = pyros_solver.solve( + model=model, + first_stage_variables=[model.x1, model.x2], + second_stage_variables=[], + uncertain_params=model.uvar, + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, + ) + self.assertEqual( + res.pyros_termination_condition, + res1.pyros_termination_condition, + msg=( + "PyROS termination condition " + "is sensitive to uncertain parameter component type " + f"when uncertain parameter is a Var fixed {adverb}." + ), + ) + self.assertEqual( + res1.final_objective_value, + res.final_objective_value, + msg=( + "PyROS termination condition " + "is sensitive to uncertain parameter component type " + f"when uncertain parameter is a Var fixed {adverb}." + ), + ) + self.assertEqual( + res1.iterations, + res.iterations, + msg=( + "PyROS iteration count " + "is sensitive to uncertain parameter component type " + f"when uncertain parameter is a Var fixed {adverb}." + ), ) @unittest.skipUnless(scip_available, "Global NLP solver is not available.") class testBypassingSeparation(unittest.TestCase): + @unittest.skipUnless(scip_available, "SCIP is not available.") + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") def test_bypass_global_separation(self): """Test bypassing of global separation solve calls.""" - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + m = build_leyffer_two_cons() # Define the uncertainty set interval = BoxSet(bounds=[(0.25, 2)]) @@ -5353,18 +2542,7 @@ class testModelMultipleObjectives(unittest.TestCase): def test_multiple_objs(self): """Test bypassing of global separation solve calls.""" - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - - # add another objective + m = build_leyffer_two_cons() m.obj2 = Objective(expr=m.obj.expr / 2) # add block, with another objective @@ -5446,188 +2624,19 @@ def test_multiple_objs(self): ) -class testModelIdentifyObjectives(unittest.TestCase): - """ - This class contains tests for validating routines used to - determine the first-stage and second-stage portions of a - two-stage expression. - """ - - def test_identify_objectives(self): - """ - Test first and second-stage objective identification - for a simple two-stage model. - """ - # model - m = ConcreteModel() - - # parameters - m.p = Param(range(4), initialize=1, mutable=True) - m.q = Param(initialize=1) - - # variables - m.x = Var(range(4)) - m.z = Var() - m.y = Var(initialize=2) - - # objective - m.obj = Objective( - expr=( - (m.x[0] + m.y) - * ( - sum(m.x[idx] * m.p[idx] for idx in range(3)) - + m.q * m.z - + m.x[0] * m.q - ) - + sin(m.x[0] + m.q) - + cos(m.x[2] + m.z) - ) - ) - - # util block for specifying DOF and uncertainty - m.util = Block() - m.util.first_stage_variables = list(m.x.values()) - m.util.second_stage_variables = [m.z] - m.util.uncertain_params = [m.p[0], m.p[1]] - - identify_objective_functions(m, m.obj) - - fsv_set = ComponentSet(m.util.first_stage_variables) - uncertain_param_set = ComponentSet(m.util.uncertain_params) - - # determine vars and uncertain params participating in - # objective - fsv_in_obj = ComponentSet( - var for var in identify_variables(m.obj) if var in fsv_set - ) - ssv_in_obj = ComponentSet( - var for var in identify_variables(m.obj) if var not in fsv_set - ) - uncertain_params_in_obj = ComponentSet( - param - for param in identify_mutable_parameters(m.obj) - if param in uncertain_param_set - ) - - # determine vars and uncertain params participating in - # first-stage objective - fsv_in_first_stg_cost = ComponentSet( - var for var in identify_variables(m.first_stage_objective) if var in fsv_set - ) - ssv_in_first_stg_cost = ComponentSet( - var - for var in identify_variables(m.first_stage_objective) - if var not in fsv_set - ) - uncertain_params_in_first_stg_cost = ComponentSet( - param - for param in identify_mutable_parameters(m.first_stage_objective) - if param in uncertain_param_set - ) - - # determine vars and uncertain params participating in - # second-stage objective - fsv_in_second_stg_cost = ComponentSet( - var - for var in identify_variables(m.second_stage_objective) - if var in fsv_set - ) - ssv_in_second_stg_cost = ComponentSet( - var - for var in identify_variables(m.second_stage_objective) - if var not in fsv_set - ) - uncertain_params_in_second_stg_cost = ComponentSet( - param - for param in identify_mutable_parameters(m.second_stage_objective) - if param in uncertain_param_set - ) - - # now perform checks - self.assertTrue( - fsv_in_first_stg_cost | fsv_in_second_stg_cost == fsv_in_obj, - f"{{var.name for var in fsv_in_first_stg_cost | fsv_in_second_stg_cost}} " - f"is not {{var.name for var in fsv_in_obj}}", - ) - self.assertFalse( - ssv_in_first_stg_cost, - f"First-stage expression {str(m.first_stage_objective.expr)}" - f" consists of non first-stage variables " - f"{{var.name for var in fsv_in_second_stg_cost}}", - ) - self.assertTrue( - ssv_in_second_stg_cost == ssv_in_obj, - f"{[var.name for var in ssv_in_second_stg_cost]} is not" - f"{{var.name for var in ssv_in_obj}}", - ) - self.assertFalse( - uncertain_params_in_first_stg_cost, - f"First-stage expression {str(m.first_stage_objective.expr)}" - " consists of uncertain params" - f" {{p.name for p in uncertain_params_in_first_stg_cost}}", - ) - self.assertTrue( - uncertain_params_in_second_stg_cost == uncertain_params_in_obj, - f"{{p.name for p in uncertain_params_in_second_stg_cost}} is not " - f"{{p.name for p in uncertain_params_in_obj}}", - ) - - def test_identify_objectives_var_expr(self): - """ - Test first and second-stage objective identification - for an objective expression consisting only of a Var. - """ - # model - m = ConcreteModel() - - # parameters - m.p = Param(range(4), initialize=1, mutable=True) - m.q = Param(initialize=1) - - # variables - m.x = Var(range(4)) - - # objective - m.obj = Objective(expr=m.x[1]) - - # util block for specifying DOF and uncertainty - m.util = Block() - m.util.first_stage_variables = list(m.x.values()) - m.util.second_stage_variables = list() - m.util.uncertain_params = list() - - identify_objective_functions(m, m.obj) - fsv_in_second_stg_obj = list( - v.name for v in identify_variables(m.second_stage_objective) - ) - - # perform checks - self.assertTrue(list(identify_variables(m.first_stage_objective)) == [m.x[1]]) - self.assertFalse( - fsv_in_second_stg_obj, - "Second stage objective contains variable(s) " f"{fsv_in_second_stg_obj}", - ) - - -class testMasterFeasibilityUnitConsistency(unittest.TestCase): +class TestMasterFeasibilityUnitConsistency(unittest.TestCase): """ Test cases for models with unit-laden model components. """ @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - @unittest.skipUnless( - baron_version < (23, 1, 5), "Test known to fail beginning with Baron 23.1.5" + scip_available and scip_license_is_valid, "SCIP is not available and licensed." ) def test_two_stg_mod_with_axis_aligned_set(self): """ Test two-stage model with `AxisAlignedEllipsoidalSet` as the uncertainty set. """ - from pyomo.environ import units as u - - # define model m = ConcreteModel() m.x1 = Var(initialize=0, bounds=(0, None)) m.x2 = Var(initialize=0, bounds=(0, None), units=u.m) @@ -5648,8 +2657,8 @@ def test_two_stg_mod_with_axis_aligned_set(self): pyros_solver = SolverFactory("pyros") # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + local_subsolver = SolverFactory("scip") + global_subsolver = SolverFactory("scip") # Call the PyROS solver # note: second-stage variable and uncertain params have units @@ -5697,20 +2706,7 @@ def simple_nlp_model(self): Create simple NLP for the unit tests defined within this class """ - # define model - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u1 = Param(initialize=1.125, mutable=True) - m.u2 = Param(initialize=1, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u1 ** (0.5) - m.x2 * m.u1 <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u1 == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) - - return m + return build_leyffer_two_cons_two_params() @unittest.skipUnless( SolverFactory('appsi_ipopt').available(exception_flag=False), @@ -5811,25 +2807,14 @@ def test_pyros_gams_ipopt(self): ) @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." + scip_available and scip_license_is_valid, "SCIP is not available and licensed." ) def test_two_stg_mod_with_intersection_set(self): """ Test two-stage model with `AxisAlignedEllipsoidalSet` as the uncertainty set. """ - # define model - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u1 = Param(initialize=1.125, mutable=True) - m.u2 = Param(initialize=1, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u1 ** (0.5) - m.x2 * m.u1 <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u1 == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) + m = self.simple_nlp_model() # construct the IntersectionSet ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[1, 0]) @@ -5840,8 +2825,8 @@ def test_two_stg_mod_with_intersection_set(self): pyros_solver = SolverFactory("pyros") # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + local_subsolver = SolverFactory("scip") + global_subsolver = SolverFactory("scip") # Call the PyROS solver results = pyros_solver.solve( @@ -6004,8 +2989,8 @@ def test_log_iter_record_not_all_sep_solved(self): solver time limit was reached, or the user-provides subordinate optimizer(s) were unable to solve a separation subproblem to an acceptable level. - A '+' should be appended to the number of performance constraints - found to be violated. + A '+' should be appended to the number of second-stage + inequality constraints found to be violated. """ # for some fields, we choose floats with more than four # four decimal points to ensure rounding also matches @@ -6198,6 +3183,7 @@ def test_log_config(self): " keepfiles=False\n" " tee=False\n" " load_solution=True\n" + " symbolic_solver_labels=False\n" " objective_focus=\n" " nominal_uncertain_param_vals=[0.5]\n" " decision_rule_order=0\n" @@ -6229,8 +3215,13 @@ def test_log_intro(self): Test logging of PyROS solver introductory messages. """ pyros_solver = SolverFactory("pyros") - with LoggingIntercept(level=logging.INFO) as LOG: - pyros_solver._log_intro(logger=logger, level=logging.INFO) + with capture_output(capture_fd=True) as OUT: + with LoggingIntercept(level=logging.INFO) as LOG: + pyros_solver._log_intro(logger=logger, level=logging.INFO) + + # ensure git repo commit check error messages suppressed + err_msgs = OUT.getvalue() + self.assertEqual(err_msgs, "") intro_msgs = LOG.getvalue() @@ -6395,18 +3386,7 @@ def test_pyros_kwargs_with_overlap(self): keyword arguments passed explicitly and implicitly through `options`. """ - # define model - m = ConcreteModel() - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u1 = Param(initialize=1.125, mutable=True) - m.u2 = Param(initialize=1, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u1 ** (0.5) - m.x2 * m.u1 <= 2) - m.con2 = Constraint(expr=m.x1**2 - m.x2**2 * m.u1 == m.x3) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) + m = build_leyffer_two_cons_two_params() # Define the uncertainty set # we take the parameter `u2` to be 'fixed' @@ -6503,25 +3483,16 @@ def solve(self, model, **kwds): class TestPyROSSolverAdvancedValidation(unittest.TestCase): """ - Test PyROS solver returns expected exception messages - when arguments are invalid. + Test PyROS solver validation routines result in + expected normal or exceptional solver behavior + depending on the arguments. """ def build_simple_test_model(self): """ Build simple valid test model. """ - m = ConcreteModel(name="test_model") - - m.x1 = Var(initialize=0, bounds=(0, None)) - m.x2 = Var(initialize=0, bounds=(0, None)) - m.u = Param(initialize=1.125, mutable=True) - - m.con1 = Constraint(expr=m.x1 * m.u ** (0.5) - m.x2 * m.u <= 2) - - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - - return m + return build_leyffer() def test_pyros_invalid_model_type(self): """ @@ -6648,6 +3619,60 @@ def test_pyros_overlap_dof_vars(self): expected_regex="Ensure no Vars are included in both arguments.", ) + @parameterized.expand([["first_stage", True], ["second_stage", False]]) + def test_pyros_overlap_uncertain_params_vars(self, stage_name, is_first_stage): + """ + Test PyROS solver raises exception if there + is overlap between `uncertain_params` and either + `first_stage_variables` or `second_stage_variables`. + """ + # build model + mdl = self.build_simple_test_model() + + first_stage_vars = [mdl.x1, mdl.x2] if is_first_stage else [] + second_stage_vars = [mdl.x1, mdl.x2] if not is_first_stage else [] + + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() + + # perform checks + exc_str = ( + f"Arguments `{stage_name}_variables` and `uncertain_params` " + "contain at least one common Var object." + ) + with LoggingIntercept(level=logging.ERROR) as LOG: + mdl.x1.fix() # uncertain params should be fixed + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=first_stage_vars, + second_stage_variables=second_stage_vars, + uncertain_params=[mdl.x1], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, + ) + + # check logger output is as expected + log_msgs = LOG.getvalue().split("\n")[:-1] + self.assertEqual( + len(log_msgs), 3, "Error message does not contain expected number of lines." + ) + self.assertRegex( + text=log_msgs[0], + expected_regex=( + f"The following Vars were found in both `{stage_name}_variables`" + "and `uncertain_params`.*" + ), + ) + self.assertRegex(text=log_msgs[1], expected_regex=" 'x1'") + self.assertRegex( + text=log_msgs[2], + expected_regex="Ensure no Vars are included in both arguments.", + ) + def test_pyros_vars_not_in_model(self): """ Test PyROS appropriately raises exception if there are @@ -6665,47 +3690,33 @@ def test_pyros_vars_not_in_model(self): global_solver = SimpleTestSolver() pyros = SolverFactory("pyros") - mdl.bad_con = Constraint(expr=mdl2.x1 + mdl2.x2 >= 1) - - desc_dof_map = [ - ("first-stage", [mdl2.x1], [], 2), - ("second-stage", [], [mdl2.x2], 2), - ("state", [mdl.x1], [], 3), - ] + mdl.bad_con = Constraint(expr=mdl.x1 + mdl2.x2 >= 1) + mdl2.x3 = Var(initialize=1) # now perform checks - for vardesc, first_stage_vars, second_stage_vars, numlines in desc_dof_map: - with LoggingIntercept(level=logging.ERROR) as LOG: - exc_str = ( - "Found entries of " - f"{vardesc} variables not descended from.*model.*" + with LoggingIntercept(level=logging.ERROR) as LOG: + exc_str = "Found Vars.*active.*" "not descended from.*model.*" + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[mdl.x1, mdl.x2], + second_stage_variables=[mdl2.x3], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, ) - with self.assertRaisesRegex(ValueError, exc_str): - pyros.solve( - model=mdl, - first_stage_variables=first_stage_vars, - second_stage_variables=second_stage_vars, - uncertain_params=[mdl.u], - uncertainty_set=BoxSet([[1 / 4, 2]]), - local_solver=local_solver, - global_solver=global_solver, - ) - - log_msgs = LOG.getvalue().split("\n")[:-1] - - # check detailed log message is as expected - self.assertEqual( - len(log_msgs), - numlines, - "Error-level log message does not contain expected number of lines.", - ) - self.assertRegex( - text=log_msgs[0], - expected_regex=( - f"The following {vardesc} variables" - ".*not descended from.*model with name 'model1'" - ), - ) + + log_msgs = LOG.getvalue().split("\n") + invalid_vars_strs_list = log_msgs[1:-1] + self.assertEqual( + len(invalid_vars_strs_list), + 1, + msg="Number of lines referencing name of invalid Vars not as expected.", + ) + self.assertRegex( + text=invalid_vars_strs_list[0], expected_regex=f"{mdl2.x2.name!r}" + ) def test_pyros_non_continuous_vars(self): """ @@ -6715,6 +3726,7 @@ def test_pyros_non_continuous_vars(self): # build model; make one variable discrete mdl = self.build_simple_test_model() mdl.x2.domain = NonNegativeIntegers + mdl.name = "test_model" # prepare solvers pyros = SolverFactory("pyros") @@ -6884,6 +3896,348 @@ def test_pyros_invalid_bypass_separation(self): bypass_global_separation=True, ) + @unittest.skipUnless(ipopt_available, "IPOPT not available") + def test_pyros_fixed_var_scope(self): + """ + Test PyROS solver on an instance such that the outcome + is clearly affected by whether a fixed variable + is treated as a decision variable (as it should be) + rather than a constant. + """ + model = ConcreteModel() + model.q = Param(initialize=1, mutable=True) + model.x1 = Var(bounds=(0, 1), initialize=0) + model.x2 = Var(bounds=(model.q, 1)) + model.x2.fix(1) + model.obj = Objective(expr=model.x1 + model.x2) + + ipopt_solver = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") + res = pyros_solver.solve( + model=model, + first_stage_variables=[model.x1], + second_stage_variables=[], + uncertain_params=[model.q], + uncertainty_set=BoxSet([[1, 2]]), + local_solver=ipopt_solver, + global_solver=ipopt_solver, + ) + + # fixed variable is considered decision variable, + # so the bounds must be honored + # (or else this problem is trivially robust feasible) + # infeasibility as uncertain lower bound may exceed upper bound + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_infeasible + ) + self.assertEqual(res.iterations, 2) + + +@unittest.skipUnless(ipopt_available, "IPOPT not available.") +class TestResolveAndValidatePyROSInputs(unittest.TestCase): + def test_validate_pyros_inputs_config(self): + """ + Test PyROS solver input validation sets up the + final config (options) as expected. + """ + model = build_leyffer_two_cons() + box_set = BoxSet(bounds=[[0.25, 2]]) + + ipopt_solver = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") + config, _ = pyros_solver._resolve_and_validate_pyros_args( + model=model, + first_stage_variables=[model.x1, model.x2], + second_stage_variables=[], + uncertain_params=model.u, + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, + ) + self.assertEqual(config.first_stage_variables, [model.x1, model.x2]) + self.assertFalse(config.second_stage_variables) + self.assertEqual(config.uncertain_params, [model.u]) + self.assertIs(config.uncertainty_set, box_set) + self.assertIs(config.local_solver, ipopt_solver) + self.assertIs(config.global_solver, ipopt_solver) + + def test_validate_pyros_inputs_user_var_partitioning(self): + """ + Test PyROS solver input validation sets up the user + variable partitioning/scope as expected. + """ + model = build_leyffer_two_cons() + box_set = BoxSet([[0.25, 2]]) + # so we can check treatment of fixed variables + # note: x3 does not appear in the objective + model.x3.fix() + # so we can check treatment of variables not in the + # active objective or constraints + model.x4 = Var() + + ipopt_solver = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") + _, user_var_partitioning = pyros_solver._resolve_and_validate_pyros_args( + model=model, + first_stage_variables=[model.x1, model.x2], + second_stage_variables=[], + uncertain_params=model.u, + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, + ) + self.assertEqual( + user_var_partitioning.first_stage_variables, [model.x1, model.x2] + ) + self.assertFalse(user_var_partitioning.second_stage_variables) + self.assertEqual(user_var_partitioning.state_variables, [model.x3]) + + def test_validate_pyros_inputs_user_var_partitioning_obj_only(self): + """ + Test PyROS solver input validation sets up the user + variable partitioning/scope as expected. + """ + model = build_leyffer_two_cons() + # so we can check that variables in objective but not + # constraints are in scope + model.con1.deactivate() + model.con2.deactivate() + box_set = BoxSet([[0.25, 2]]) + + ipopt_solver = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") + _, user_var_partitioning = pyros_solver._resolve_and_validate_pyros_args( + model=model, + first_stage_variables=[model.x1, model.x2], + second_stage_variables=[], + uncertain_params=model.u, + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, + ) + self.assertEqual( + user_var_partitioning.first_stage_variables, [model.x1, model.x2] + ) + self.assertFalse(user_var_partitioning.second_stage_variables) + self.assertFalse(user_var_partitioning.state_variables) + + +# @SolverFactory.register("subsolver_error__solver") +class SubsolverErrorSolver(object): + """ + Solver that returns a bad termination condition + to purposefully create an SP subsolver error. + + Parameters + ---------- + sub_solver: SolverFactory + The wrapped solver object + all_fail: bool + Set to true to always return a subsolver error. + Otherwise, the solver checks `failed_flag` to see if it should behave normally or error. + The solver sets `failed_flag=True` after returning an error, and subsequent solves + should behave normally unless `failed_flag` is manually toggled off again. + + Attributes + ---------- + failed_flag + """ + + def __init__(self, sub_solver, all_fail): + self.sub_solver = sub_solver + self.all_fail = all_fail + + self.failed_flag = False + self.options = Bunch() + + def available(self, exception_flag=True): + return True + + def license_is_valid(self): + return True + + def __enter__(self): + return self + + def __exit__(self, et, ev, tb): + pass + + def solve(self, model, **kwargs): + """ + 'Solve' a model. + + Parameters + ---------- + model : ConcreteModel + Model of interest. + + Returns + ------- + results : SolverResults + Solver results. + """ + + # ensure only one active objective + active_objs = [ + obj for obj in model.component_data_objects(Objective, active=True) + ] + assert len(active_objs) == 1 + + # check if a separation problem is being solved + # this is done by checking if there is a separation objective + sp_check = hasattr(model, 'separation_obj_0') + if sp_check: + # check if the problem needs to fail + if not self.failed_flag or self.all_fail: + # set up results.solver + results = SolverResults() + + results.solver.termination_condition = TerminationCondition.error + results.solver.status = SolverStatus.error + + # record that a failure has been produced + self.failed_flag = True + + return results + + # invoke subsolver + results = self.sub_solver.solve(model, **kwargs) + + return results + + +@unittest.skipUnless(ipopt_available, "IPOPT is not available.") +@unittest.skipUnless( + baron_available and baron_license_is_valid, + "Global NLP solver is not available and licensed.", +) +class TestPyROSSubsolverErrorEfficiency(unittest.TestCase): + """ + Test PyROS subsolver error efficiency for continuous and discrete uncertainty sets. + """ + + @parameterized.expand( + [ + ("failed_but_recovered_local", 7, False), + ("failed_and_terminated_local", 10, False), + ("failed_and_terminated_global", 7, True), + ] + ) + def test_continuous_set_subsolver_error_recovery( + self, name, sec_con_UB, test_global_error + ): + m = build_leyffer_two_cons() + # the following constraint is unviolated/violated depending on the UB + # if the constraint is unviolated, no other violations are found, and + # PyROS should terminate with subsolver error. + # if the constraint is violated, PyROS can continue to the next iteration + # despite subsolver errors. + m.sec_con = Constraint(expr=m.u * m.x1 <= sec_con_UB) + m.sec_con.pprint() + + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) + + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") + + # Define subsolvers utilized in the algorithm + # the error solver will cause the first separation problem to fail + local_subsolver = SubsolverErrorSolver( + sub_solver=SolverFactory('ipopt'), all_fail=False + ) + if test_global_error: + global_subsolver = SubsolverErrorSolver( + sub_solver=SolverFactory('baron'), all_fail=False + ) + else: + global_subsolver = SolverFactory("baron") + + # Call the PyROS solver + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.u], + uncertainty_set=interval, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, + ) + + if 'recovered' in name: + # check successful termination + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", + ) + else: + # check unsuccessful termination + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.subsolver_error, + msg="Did not report subsolver error to problem instance.", + ) + + @parameterized.expand( + [("failed_but_recovered_local", 7), ("failed_and_terminated_local", 10)] + ) + def test_discrete_set_subsolver_error_recovery(self, name, sec_con_UB): + m = build_leyffer_two_cons() + # the following constraint is unviolated/violated depending on the UB + # if the constraint is unviolated, no other violations are found, and + # PyROS should terminate with subsolver error. + # if the constraint is violated, PyROS can continue to the next iteration + # despite subsolver errors. + m.sec_con = Constraint(expr=m.u * m.x1 <= sec_con_UB) + + # Define the uncertainty set + discrete_set = DiscreteScenarioSet(scenarios=[[0.25], [1.125], [2]]) + + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") + + # Define subsolvers utilized in the algorithm + # the error solver will cause the first separation problem to fail + local_subsolver = SubsolverErrorSolver( + sub_solver=SolverFactory('ipopt'), all_fail=False + ) + global_subsolver = SolverFactory("baron") + + # Call the PyROS solver + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.u], + uncertainty_set=discrete_set, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, + ) + + if 'recovered' in name: + # check successful termination + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", + ) + else: + # check unsuccessful termination + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.subsolver_error, + msg="Did not report subsolver error to problem instance.", + ) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/contrib/pyros/tests/test_master.py b/pyomo/contrib/pyros/tests/test_master.py new file mode 100644 index 00000000000..4ef4b17157d --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_master.py @@ -0,0 +1,721 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +""" +Test methods for construction and solution of master problem +objects. +""" + + +import logging +import time +import pyomo.common.unittest as unittest + +from pyomo.common.collections import Bunch +from pyomo.common.dependencies import numpy_available, scipy_available +from pyomo.core.base import ConcreteModel, Constraint, minimize, Objective, Param, Var +from pyomo.core.expr import exp +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.environ import SolverFactory +from pyomo.opt import TerminationCondition + +from pyomo.contrib.pyros.uncertainty_sets import BoxSet +from pyomo.contrib.pyros.master_problem_methods import ( + add_scenario_block_to_master_problem, + construct_initial_master_problem, + construct_master_feasibility_problem, + construct_dr_polishing_problem, + MasterProblemData, + higher_order_decision_rule_efficiency, +) +from pyomo.contrib.pyros.util import ( + ModelData, + preprocess_model_data, + get_all_first_stage_eq_cons, + ObjectiveType, + time_code, + TimingData, + VariablePartitioning, + pyrosTerminationCondition, +) + + +if not (numpy_available and scipy_available): + raise unittest.SkipTest("Packages numpy and scipy must both be available.") + +_baron = SolverFactory("baron") +baron_available = _baron.available() +baron_license_is_valid = _baron.license_is_valid() + + +logger = logging.getLogger(__name__) + + +def build_simple_model_data(objective_focus="worst_case", decision_rule_order=1): + """ + Test construction of master problem. + """ + m = ConcreteModel() + m.u = Param(initialize=0.5, mutable=True) + m.x1 = Var(bounds=[-1000, 1000], initialize=1) + m.x2 = Var(bounds=[-1000, 1000], initialize=1) + m.x3 = Var(bounds=[-1000, 1000], initialize=-3) + m.con = Constraint(expr=exp(m.u - 1) - m.x1 - m.x2 * m.u - m.x3 * m.u**2 <= 0) + m.eq_con = Constraint(expr=m.x2 - 1 == 0) + + m.obj = Objective(expr=m.x1 + m.x2 / 2 + m.x3 / 3) + + config = Bunch( + uncertain_params=[m.u], + objective_focus=ObjectiveType[objective_focus], + decision_rule_order=decision_rule_order, + progress_logger=logger, + nominal_uncertain_param_vals=[0.4], + separation_priority_order=dict(), + uncertainty_set=BoxSet([[1, 2]]), + ) + model_data = ModelData(original_model=m, timing=TimingData(), config=config) + user_var_partitioning = VariablePartitioning( + first_stage_variables=[m.x1], + second_stage_variables=[m.x2, m.x3], + state_variables=[], + ) + + preprocess_model_data(model_data, user_var_partitioning) + + return model_data + + +class TestConstructMasterProblem(unittest.TestCase): + """ + Tests for construction of the master problem and + scenario sub-blocks. + """ + + def test_initial_construct_master(self): + """ + Test initial construction of the master problem + from the preprocesed working model. + """ + model_data = build_simple_model_data() + master_model = construct_initial_master_problem(model_data) + + self.assertTrue(hasattr(master_model, "scenarios")) + self.assertIsNot(master_model.scenarios[0, 0], model_data.working_model) + self.assertTrue(master_model.epigraph_obj.active) + self.assertIs( + master_model.epigraph_obj.expr, + master_model.scenarios[0, 0].first_stage.epigraph_var, + ) + + # check all the variables (including first-stage ones) + # were cloned + nadj_var_zip = zip( + master_model.scenarios[0, 0].all_nonadjustable_variables, + model_data.working_model.all_nonadjustable_variables, + ) + for master_var, wm_var in nadj_var_zip: + self.assertIsNot( + master_var, + wm_var, + f"Variable with name {wm_var.name!r} not cloned as expected.", + ) + + # check parameter value is set to the nominal realization + self.assertEqual( + master_model.scenarios[0, 0].user_model.u.value, + model_data.config.nominal_uncertain_param_vals[0], + ) + + def test_add_scenario_block_to_master(self): + """ + Test method for adding scenario block to an already + constructed master problem, without cloning of the + first-stage variables. + """ + model_data = build_simple_model_data() + master_model = construct_initial_master_problem(model_data) + add_scenario_block_to_master_problem( + master_model=master_model, + scenario_idx=[0, 1], + param_realization=[0.6], + from_block=master_model.scenarios[0, 0], + clone_first_stage_components=False, + ) + + self.assertEqual(master_model.scenarios[0, 1].user_model.u.value, 0.6) + + nadj_var_zip = zip( + master_model.scenarios[0, 0].all_nonadjustable_variables, + master_model.scenarios[0, 1].all_nonadjustable_variables, + ) + for var_00, var_01 in nadj_var_zip: + self.assertIs( + var_00, + var_01, + msg=f"Variable {var_00.name} was cloned across scenario blocks.", + ) + + # the first-stage inequality and equality constraints + # should be cloned. we do this to avoid issues with the solver + # interfaces (such as issues with manipulating symbol maps) + nadj_ineq_con_zip = zip( + master_model.scenarios[0, 0].first_stage.inequality_cons.values(), + master_model.scenarios[0, 1].first_stage.inequality_cons.values(), + ) + for ineq_con_00, ineq_con_01 in nadj_ineq_con_zip: + self.assertIsNot( + ineq_con_00, + ineq_con_01, + msg=( + f"first-stage inequality con {ineq_con_00.name!r} was not " + "cloned across scenario blocks." + ), + ) + self.assertTrue( + ineq_con_00.active, + msg=( + "First-stage inequality constraint " + f"{ineq_con_00.name!r} should be active." + ), + ) + self.assertFalse( + ineq_con_01.active, + msg=( + "Duplicate first-stage inequality constraint " + f"{ineq_con_01.name!r} should be deactivated" + ), + ) + + nadj_eq_con_zip = zip( + get_all_first_stage_eq_cons(master_model.scenarios[0, 0]), + get_all_first_stage_eq_cons(master_model.scenarios[0, 1]), + ) + for eq_con_00, eq_con_01 in nadj_eq_con_zip: + self.assertIsNot( + eq_con_00, + eq_con_01, + msg=( + f"first-stage equality con {eq_con_00.name} was not cloned " + "across scenario blocks." + ), + ) + self.assertTrue( + eq_con_00.active, + msg=( + "First-stage equality constraint " + f"{eq_con_00.name!r} should be active." + ), + ) + self.assertFalse( + eq_con_01.active, + msg=( + "Duplicate first-stage equality constraint " + f"{eq_con_01.name!r} should be deactivated" + ), + ) + + +class TestNewConstructMasterFeasibilityProblem(unittest.TestCase): + """ + Test construction of the master feasibility problem. + """ + + def build_simple_master_data(self): + """ + Construct master data-like object for feasibility problem + tests. + """ + model_data = build_simple_model_data() + master_model = construct_initial_master_problem(model_data) + add_scenario_block_to_master_problem( + master_model=master_model, + scenario_idx=[1, 0], + param_realization=[1], + from_block=master_model.scenarios[0, 0], + clone_first_stage_components=False, + ) + master_data = Bunch( + master_model=master_model, iteration=1, config=model_data.config + ) + + return master_data + + def test_construct_master_feasibility_problem_var_map(self): + """ + Test construction of feasibility problem var map. + """ + master_data = self.build_simple_master_data() + slack_model = construct_master_feasibility_problem(master_data) + + self.assertTrue(master_data.feasibility_problem_varmap) + for mvar, feasvar in master_data.feasibility_problem_varmap: + self.assertIs( + mvar, + master_data.master_model.find_component(feasvar), + msg=f"{mvar.name!r} is not same as find_component({feasvar.name!r})", + ) + self.assertIs( + feasvar, + slack_model.find_component(mvar), + msg=f"{feasvar.name!r} is not same as find_component({mvar.name!r})", + ) + + def test_construct_master_feasibility_problem_slack_vars(self): + """ + Check master feasibility slack variables. + """ + master_data = self.build_simple_master_data() + slack_model = construct_master_feasibility_problem(master_data) + + slack_var_blk = slack_model._core_add_slack_variables + scenario_10_blk = slack_model.scenarios[1, 0] + + # test a few of the constraints + slack_user_model_x3_lb_con = scenario_10_blk.second_stage.inequality_cons[ + "var_x3_certain_lower_bound_con" + ] + slack_user_model_x3_lb_con_var = slack_var_blk.find_component( + "'_slack_minus_scenarios[1,0].second_stage.inequality_cons[" + "var_x3_certain_lower_bound_con]'" + ) + assertExpressionsEqual( + self, + slack_user_model_x3_lb_con.body <= slack_user_model_x3_lb_con.upper, + -scenario_10_blk.user_model.x3 - slack_user_model_x3_lb_con_var <= 1000.0, + ) + self.assertEqual(slack_user_model_x3_lb_con_var.value, 0) + + slack_user_model_x3_ub_con = scenario_10_blk.second_stage.inequality_cons[ + "var_x3_certain_upper_bound_con" + ] + slack_user_model_x3_ub_con_var = slack_var_blk.find_component( + "'_slack_minus_scenarios[1,0].second_stage.inequality_cons[" + "var_x3_certain_upper_bound_con]'" + ) + assertExpressionsEqual( + self, + slack_user_model_x3_ub_con.body <= slack_user_model_x3_ub_con.upper, + scenario_10_blk.user_model.x3 - slack_user_model_x3_ub_con_var <= 1000.0, + ) + self.assertEqual(slack_user_model_x3_lb_con_var.value, 0) + + # constraint 'con' is violated when u = 0.8; + # check slack initialization + slack_user_model_con_var = slack_var_blk.find_component( + "'_slack_minus_scenarios[1,0].second_stage.inequality_cons" + "[ineq_con_con_upper_bound_con]'" + ) + self.assertEqual( + slack_user_model_con_var.value, + -master_data.master_model.scenarios[1, 0].user_model.con.uslack(), + ) + + def test_construct_master_feasibility_problem_obj(self): + """ + Check master feasibility slack variables. + """ + master_data = self.build_simple_master_data() + slack_model = construct_master_feasibility_problem(master_data) + + self.assertFalse(slack_model.epigraph_obj.active) + self.assertTrue(slack_model._core_add_slack_variables._slack_objective.active) + + +class TestDRPolishingProblem(unittest.TestCase): + """ + Tests for the PyROS DR polishing problem. + """ + + def build_simple_master_data(self): + """ + Construct master data-like object for feasibility problem + tests. + """ + model_data = build_simple_model_data() + master_model = construct_initial_master_problem(model_data) + add_scenario_block_to_master_problem( + master_model=master_model, + scenario_idx=[1, 0], + param_realization=[0.1], + from_block=master_model.scenarios[0, 0], + clone_first_stage_components=False, + ) + master_data = Bunch( + master_model=master_model, iteration=1, config=model_data.config + ) + + return master_data + + def test_construct_dr_polishing_problem_nonadj_components(self): + """ + Test state of the nonadjustable components + of the DR polishing problem. + """ + master_data = self.build_simple_master_data() + polishing_model = construct_dr_polishing_problem(master_data) + eff_first_stage_vars = polishing_model.scenarios[ + 0, 0 + ].effective_var_partitioning.first_stage_variables + for effective_first_stage_var in eff_first_stage_vars: + self.assertTrue( + effective_first_stage_var.fixed, + msg=( + "Effective first-stage variable " + f"{effective_first_stage_var.name!r} " + "not fixed." + ), + ) + + nom_polishing_block = polishing_model.scenarios[0, 0] + self.assertTrue(nom_polishing_block.first_stage.epigraph_var.fixed) + self.assertFalse(nom_polishing_block.first_stage.decision_rule_vars[0][0].fixed) + self.assertFalse(nom_polishing_block.first_stage.decision_rule_vars[0][1].fixed) + + # ensure constraints in fixed vars were deactivated + self.assertFalse(nom_polishing_block.user_model.eq_con.active) + + # these have either unfixed DR or adjustable variables, + # so they should remain active + # self.assertTrue(nom_polishing_block.user_model.con.active) + self.assertTrue( + nom_polishing_block.second_stage.inequality_cons[ + "ineq_con_con_upper_bound_con" + ].active + ) + self.assertTrue(nom_polishing_block.second_stage.decision_rule_eqns[0].active) + + def test_construct_dr_polishing_problem_polishing_components(self): + """ + Test auxiliary Var/Constraint components of the DR polishing + problem. + """ + master_data = self.build_simple_master_data() + # DR order is 1, and x3 is second-stage. + # to test fixing efficiency, fix the affine DR variable + decision_rule_vars = master_data.master_model.scenarios[ + 0, 0 + ].first_stage.decision_rule_vars + decision_rule_vars[0][1].fix() + polishing_model = construct_dr_polishing_problem(master_data) + nom_polishing_block = polishing_model.scenarios[0, 0] + + self.assertFalse(decision_rule_vars[0][0].fixed) + self.assertTrue(polishing_model.polishing_vars[0][0].fixed) + self.assertFalse(polishing_model.polishing_abs_val_lb_con_0[0].active) + self.assertFalse(polishing_model.polishing_abs_val_ub_con_0[0].active) + + # polishing components for the affine DR term should be + # fixed/deactivated since the DR variable was fixed + self.assertTrue(decision_rule_vars[0][1].fixed) + self.assertTrue(polishing_model.polishing_vars[0][1].fixed) + self.assertFalse(polishing_model.polishing_abs_val_lb_con_0[1].active) + self.assertFalse(polishing_model.polishing_abs_val_ub_con_0[1].active) + + # check initialization of polishing vars + self.assertEqual( + polishing_model.polishing_vars[0][0].value, + abs(nom_polishing_block.first_stage.decision_rule_vars[0][0].value), + ) + self.assertEqual( + polishing_model.polishing_vars[0][1].value, + abs(nom_polishing_block.first_stage.decision_rule_vars[0][1].value), + ) + + assertExpressionsEqual( + self, + polishing_model.polishing_obj.expr, + polishing_model.polishing_vars[0][0] + polishing_model.polishing_vars[0][1], + ) + self.assertEqual(polishing_model.polishing_obj.sense, minimize) + + def test_construct_dr_polishing_problem_objectives(self): + """ + Test states of the Objective components of the DR + polishing model. + """ + master_data = self.build_simple_master_data() + polishing_model = construct_dr_polishing_problem(master_data) + self.assertFalse(polishing_model.epigraph_obj.active) + self.assertTrue(polishing_model.polishing_obj.active) + + def test_construct_dr_polishing_problem_params_zero(self): + """ + Check that DR polishing fixes/deactivates components + for DR expression terms where the product of uncertain + parameters is below tolerance. + """ + master_data = self.build_simple_master_data() + + # trigger fixing of the corresponding polishing vars + master_data.master_model.scenarios[0, 0].user_model.u.set_value(1e-10) + master_data.master_model.scenarios[1, 0].user_model.u.set_value(1e-11) + + polishing_model = construct_dr_polishing_problem(master_data) + + dr_vars = polishing_model.scenarios[0, 0].first_stage.decision_rule_vars + + # since static DR terms should not be polished + self.assertTrue(polishing_model.polishing_vars[0][0].fixed) + self.assertFalse(polishing_model.polishing_abs_val_lb_con_0[0].active) + self.assertFalse(polishing_model.polishing_abs_val_ub_con_0[0].active) + + # affine term should be fixed to 0, + # since the uncertain param values are small enough. + # polishing constraints are deactivated since we don't need them + self.assertTrue(dr_vars[0][1].fixed) + self.assertEqual(dr_vars[0][1].value, 0) + self.assertTrue(polishing_model.polishing_vars[0][1].fixed) + self.assertFalse(polishing_model.polishing_abs_val_lb_con_0[1].active) + self.assertFalse(polishing_model.polishing_abs_val_ub_con_0[1].active) + + +class TestHigherOrderDecisionRuleEfficiency(unittest.TestCase): + """ + Test efficiency for decision rules. + """ + + def test_higher_order_decision_rule_efficiency(self): + """ + Test higher-order decision rule efficiency. + """ + model_data = build_simple_model_data(decision_rule_order=2) + master_model = construct_initial_master_problem(model_data) + master_data = Bunch( + master_model=master_model, iteration=0, config=model_data.config + ) + decision_rule_vars = master_data.master_model.scenarios[ + 0, 0 + ].first_stage.decision_rule_vars[0] + + for iter_num in range(4): + master_data.iteration = iter_num + higher_order_decision_rule_efficiency(master_data) + self.assertFalse( + decision_rule_vars[0].fixed, + msg=( + f"DR Var {decision_rule_vars[1].name!r} should not " + f"be fixed by efficiency in iteration {iter_num}" + ), + ) + if iter_num == 0: + self.assertTrue( + decision_rule_vars[1].fixed, + msg=( + f"DR Var {decision_rule_vars[1].name!r} should " + f"be fixed by efficiency in iteration {iter_num}" + ), + ) + self.assertTrue( + decision_rule_vars[2].fixed, + msg=( + f"DR Var {decision_rule_vars[2].name!r} should " + f"be fixed by efficiency in iteration {iter_num}" + ), + ) + elif iter_num <= len(master_data.config.uncertain_params): + self.assertFalse( + decision_rule_vars[1].fixed, + msg=( + f"DR Var {decision_rule_vars[1].name!r} should not " + f"be fixed by efficiency in iteration {iter_num}" + ), + ) + self.assertTrue( + decision_rule_vars[2].fixed, + msg=( + f"DR Var {decision_rule_vars[2].name!r} should " + f"be fixed by efficiency in iteration {iter_num}" + ), + ) + else: + self.assertFalse( + decision_rule_vars[1].fixed, + msg=( + f"DR Var {decision_rule_vars[1].name!r} should not " + f"be fixed by efficiency in iteration {iter_num}" + ), + ) + self.assertFalse( + decision_rule_vars[2].fixed, + msg=( + f"DR Var {decision_rule_vars[2].name!r} should not " + f"be fixed by efficiency in iteration {iter_num}" + ), + ) + + +class TestSolveMaster(unittest.TestCase): + """ + Test method for solving master problem + """ + + @unittest.skipUnless(baron_available, "Global NLP solver is not available.") + def test_solve_master(self): + model_data = build_simple_model_data() + model_data.timing = TimingData() + baron = SolverFactory("baron") + model_data.config.update( + dict( + local_solver=baron, + global_solver=baron, + backup_local_solvers=[], + backup_global_solvers=[], + tee=False, + ) + ) + master_data = MasterProblemData(model_data) + with time_code(master_data.timing, "main", is_main_timer=True): + master_soln = master_data.solve_master() + self.assertEqual(len(master_soln.master_results_list), 1) + self.assertIsNone(master_soln.feasibility_problem_results) + self.assertIsNone(master_soln.pyros_termination_condition) + self.assertIs(master_soln.master_model, master_data.master_model) + self.assertEqual( + master_soln.master_results_list[0].solver.termination_condition, + TerminationCondition.optimal, + msg=( + "Could not solve simple master problem with solve_master " + "function." + ), + ) + + @unittest.skipUnless(baron_available, "Global NLP solver is not available") + def test_solve_master_timeout_on_master(self): + """ + Test method for solution of master problems times out + on feasibility problem. + """ + model_data = build_simple_model_data() + model_data.timing = TimingData() + baron = SolverFactory("baron") + model_data.config.update( + dict( + local_solver=baron, + global_solver=baron, + backup_local_solvers=[], + backup_global_solvers=[], + tee=False, + time_limit=1, + ) + ) + master_data = MasterProblemData(model_data) + with time_code(master_data.timing, "main", is_main_timer=True): + time.sleep(1) + master_soln = master_data.solve_master() + self.assertIsNone(master_soln.feasibility_problem_results) + self.assertEqual(master_soln.master_model, master_data.master_model) + self.assertEqual(len(master_soln.master_results_list), 1) + self.assertEqual( + master_soln.master_results_list[0].solver.termination_condition, + TerminationCondition.optimal, + msg=( + "Could not solve simple master problem with solve_master " + "function." + ), + ) + self.assertEqual( + master_soln.pyros_termination_condition, + pyrosTerminationCondition.time_out, + ) + + @unittest.skipUnless(baron_available, "Global NLP solver is not available") + def test_solve_master_timeout_on_master_feasibility(self): + """ + Test method for solution of master problems times out + on feasibility problem. + """ + model_data = build_simple_model_data() + model_data.timing = TimingData() + baron = SolverFactory("baron") + model_data.config.update( + dict( + local_solver=baron, + global_solver=baron, + backup_local_solvers=[], + backup_global_solvers=[], + tee=False, + time_limit=1, + ) + ) + master_data = MasterProblemData(model_data) + add_scenario_block_to_master_problem( + master_data.master_model, + scenario_idx=[1, 0], + param_realization=[0.6], + from_block=master_data.master_model.scenarios[0, 0], + clone_first_stage_components=False, + ) + master_data.iteration = 1 + with time_code(master_data.timing, "main", is_main_timer=True): + time.sleep(1) + master_soln = master_data.solve_master() + self.assertIsNotNone(master_soln.feasibility_problem_results) + self.assertFalse(master_soln.master_results_list) + self.assertIs(master_soln.master_model, master_data.master_model) + self.assertEqual( + master_soln.pyros_termination_condition, + pyrosTerminationCondition.time_out, + ) + + +class TestPolishDRVars(unittest.TestCase): + """ + Test DR polishing subroutine. + """ + + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_polish_dr_vars(self): + model_data = build_simple_model_data() + model_data.timing = TimingData() + baron = SolverFactory("baron") + model_data.config.update( + dict( + local_solver=baron, + global_solver=baron, + backup_local_solvers=[], + backup_global_solvers=[], + tee=False, + ) + ) + master_data = MasterProblemData(model_data) + add_scenario_block_to_master_problem( + master_data.master_model, + scenario_idx=[1, 0], + param_realization=[0.6], + from_block=master_data.master_model.scenarios[0, 0], + clone_first_stage_components=False, + ) + master_data.iteration = 1 + + master_data.timing = TimingData() + with time_code(master_data.timing, "main", is_main_timer=True): + master_soln = master_data.solve_master() + self.assertEqual( + master_soln.master_results_list[0].solver.termination_condition, + TerminationCondition.optimal, + ) + + results, success = master_data.solve_dr_polishing() + self.assertEqual( + results.solver.termination_condition, + TerminationCondition.optimal, + msg="Minimize dr norm did not solve to optimality.", + ) + self.assertTrue( + success, msg=f"DR polishing success {success}, expected True." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/pyros/tests/test_preprocessor.py b/pyomo/contrib/pyros/tests/test_preprocessor.py new file mode 100644 index 00000000000..8997e2a2707 --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_preprocessor.py @@ -0,0 +1,3051 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +""" +Tests for the PyROS preprocessor. +""" + + +import logging +import textwrap +import pyomo.common.unittest as unittest + +from pyomo.common.collections import Bunch, ComponentSet, ComponentMap +from pyomo.common.dependencies import numpy_available +from pyomo.common.dependencies import scipy as sp, scipy_available +from pyomo.common.dependencies import attempt_import +from pyomo.common.log import LoggingIntercept +from pyomo.core.base import ( + Any, + Var, + Constraint, + Expression, + Objective, + ConcreteModel, + Param, + RangeSet, + maximize, + Block, +) +from pyomo.core.base.set_types import NonNegativeReals, NonPositiveReals, Reals +from pyomo.core.expr import ( + LinearExpression, + log, + sin, + exp, + RangedExpression, + SumExpression, +) +from pyomo.core.expr.compare import assertExpressionsEqual + +from pyomo.contrib.pyros.uncertainty_sets import BoxSet, DiscreteScenarioSet +from pyomo.contrib.pyros.util import ( + ModelData, + ObjectiveType, + get_all_first_stage_eq_cons, + get_effective_var_partitioning, + get_var_certain_uncertain_bounds, + get_var_bound_pairs, + turn_nonadjustable_var_bounds_to_constraints, + turn_adjustable_var_bounds_to_constraints, + standardize_inequality_constraints, + standardize_equality_constraints, + standardize_active_objective, + declare_objective_expressions, + add_decision_rule_constraints, + add_decision_rule_variables, + reformulate_state_var_independent_eq_cons, + setup_working_model, + VariablePartitioning, + preprocess_model_data, + log_model_statistics, +) + +parameterized, param_available = attempt_import('parameterized') + +if not (numpy_available and scipy_available and param_available): + raise unittest.SkipTest( + 'PyROS preprocessor unit tests require parameterized, numpy, and scipy' + ) +parameterized = parameterized.parameterized + + +logger = logging.getLogger(__name__) + + +class TestEffectiveVarPartitioning(unittest.TestCase): + """ + Test method(s) for identification of nonadjustable variables + which are not necessarily in the user-provided sequence of + first-stage variables. + """ + + def build_simple_test_model_data(self): + """ + Build simple model for effective variable partitioning tests. + """ + m = ConcreteModel() + m.q = Param(mutable=True, initialize=1) + m.q2 = Param(mutable=True, initialize=2) + m.x1 = Var(bounds=(m.q2, m.q2)) + m.x2 = Var() + m.z = Var() + m.y = Var(range(1, 5)) + + m.c0 = Constraint(expr=m.q + m.x1 + m.z == 0) + m.c1 = Constraint(expr=(0, m.x1 - m.z * (m.q2 - 1), 0)) + m.c2 = Constraint(expr=m.x1**2 - m.z + m.y[1] == 0) + m.c2_dupl = Constraint(expr=m.x1**2 - m.z + m.y[1] == 0) + m.c3 = Constraint(expr=m.x1**3 + m.y[1] + 2 * m.y[2] == 0) + m.c4 = Constraint(expr=m.x2**2 + m.y[1] + m.y[2] + m.y[3] + m.y[4] == 0) + m.c5 = Constraint(expr=m.x2 + 2 * m.y[2] + m.y[3] + 2 * m.y[4] == 0) + + model_data = Bunch() + model_data.config = Bunch() + model_data.working_model = ConcreteModel() + model_data.working_model.user_model = mdl = m.clone() + model_data.working_model.uncertain_params = [mdl.q, mdl.q2] + model_data.working_model.effective_uncertain_params = [mdl.q] + + user_var_partitioning = model_data.working_model.user_var_partitioning = Bunch() + user_var_partitioning.first_stage_variables = [mdl.x1, mdl.x2] + user_var_partitioning.second_stage_variables = [mdl.z] + user_var_partitioning.state_variables = list(mdl.y.values()) + + return model_data + + def test_effective_partitioning_system(self): + """ + Test effective partitioning on an example system of + constraints. + """ + model_data = self.build_simple_test_model_data() + m = model_data.working_model.user_model + + config = model_data.config + config.decision_rule_order = 0 + config.progress_logger = logger + + expected_partitioning = { + "first_stage_variables": [m.x1, m.x2, m.z, m.y[1], m.y[2]], + "second_stage_variables": [], + "state_variables": [m.y[3], m.y[4]], + } + for dr_order in [0, 1, 2]: + config.decision_rule_order = dr_order + actual_partitioning = get_effective_var_partitioning(model_data=model_data) + for vartype, expected_vars in expected_partitioning.items(): + actual_vars = getattr(actual_partitioning, vartype) + self.assertEqual( + ComponentSet(expected_vars), + ComponentSet(actual_vars), + msg=( + f"Effective {vartype!r} are not as expected " + f"for decision rule order {config.decision_rule_order}. " + "\n" + f"Expected: {[var.name for var in expected_vars]}" + "\n" + f"Actual: {[var.name for var in actual_vars]}" + ), + ) + + # linear coefficient below tolerance; + # that should prevent pretriangularization + m.c2.set_value(m.x1**2 + m.z + 1e-10 * m.y[1] == 0) + m.c2_dupl.set_value(m.x1**2 + m.z + 1e-10 * m.y[1] == 0) + expected_partitioning = { + "first_stage_variables": [m.x1, m.x2, m.z], + "second_stage_variables": [], + "state_variables": list(m.y.values()), + } + for dr_order in [0, 1, 2]: + config.decision_rule_order = dr_order + actual_partitioning = get_effective_var_partitioning(model_data) + for vartype, expected_vars in expected_partitioning.items(): + actual_vars = getattr(actual_partitioning, vartype) + self.assertEqual( + ComponentSet(expected_vars), + ComponentSet(actual_vars), + msg=( + f"Effective {vartype!r} are not as expected " + f"for decision rule order {config.decision_rule_order}. " + "\n" + f"Expected: {[var.name for var in expected_vars]}" + "\n" + f"Actual: {[var.name for var in actual_vars]}" + ), + ) + + # put linear coefs above tolerance again: + # original behavior expected + m.c2.set_value(1e-6 * m.y[1] + m.x1**2 + m.z + 1e-10 * m.y[1] == 0) + m.c2_dupl.set_value(1e-6 * m.y[1] + m.x1**2 + m.z + 1e-10 * m.y[1] == 0) + expected_partitioning = { + "first_stage_variables": [m.x1, m.x2, m.z, m.y[1], m.y[2]], + "second_stage_variables": [], + "state_variables": [m.y[3], m.y[4]], + } + for dr_order in [0, 1, 2]: + config.decision_rule_order = dr_order + actual_partitioning = get_effective_var_partitioning(model_data) + for vartype, expected_vars in expected_partitioning.items(): + actual_vars = getattr(actual_partitioning, vartype) + self.assertEqual( + ComponentSet(expected_vars), + ComponentSet(actual_vars), + msg=( + f"Effective {vartype!r} are not as expected " + f"for decision rule order {config.decision_rule_order}. " + "\n" + f"Expected: {[var.name for var in expected_vars]}" + "\n" + f"Actual: {[var.name for var in actual_vars]}" + ), + ) + + # introducing this simple nonlinearity prevents + # y[2] from being identified as pretriangular + expected_partitioning = { + "first_stage_variables": [m.x1, m.x2, m.z, m.y[1]], + "second_stage_variables": [], + "state_variables": [m.y[2], m.y[3], m.y[4]], + } + m.c3.set_value(m.x1**3 + m.y[1] + 2 * m.y[1] * m.y[2] == 0) + for dr_order in [0, 1, 2]: + config.decision_rule_order = dr_order + actual_partitioning = get_effective_var_partitioning(model_data) + for vartype, expected_vars in expected_partitioning.items(): + actual_vars = getattr(actual_partitioning, vartype) + self.assertEqual( + ComponentSet(expected_vars), + ComponentSet(actual_vars), + msg=( + f"Effective {vartype!r} are not as expected " + f"for decision rule order {config.decision_rule_order}. " + "\n" + f"Expected: {[var.name for var in expected_vars]}" + "\n" + f"Actual: {[var.name for var in actual_vars]}" + ), + ) + + # fixing y[2] should make y[2] nonadjustable regardless + m.y[2].fix(10) + expected_partitioning = { + "first_stage_variables": [m.x1, m.x2, m.z, m.y[1], m.y[2]], + "second_stage_variables": [], + "state_variables": [m.y[3], m.y[4]], + } + for dr_order in [0, 1, 2]: + config.decision_rule_order = dr_order + actual_partitioning = get_effective_var_partitioning(model_data) + for vartype, expected_vars in expected_partitioning.items(): + actual_vars = getattr(actual_partitioning, vartype) + self.assertEqual( + ComponentSet(expected_vars), + ComponentSet(actual_vars), + msg=( + f"Effective {vartype!r} are not as expected " + f"for decision rule order {config.decision_rule_order}. " + "\n" + f"Expected: {[var.name for var in expected_vars]}" + "\n" + f"Actual: {[var.name for var in actual_vars]}" + ), + ) + + def test_effective_partitioning_modified_linear_system(self): + """ + Test effective partitioning on modified system of equations. + """ + model_data = self.build_simple_test_model_data() + m = model_data.working_model.user_model + + # now the second-stage variable can't be determined uniquely; + # can't pretriangularize this unless z already known to be + # nonadjustable + m.c1.set_value((0, m.x1 + m.z**2, 0)) + + config = model_data.config + config.decision_rule_order = 0 + config.progress_logger = logger + + expected_partitioning_static_dr = { + "first_stage_variables": [m.x1, m.x2, m.z, m.y[1], m.y[2]], + "second_stage_variables": [], + "state_variables": [m.y[3], m.y[4]], + } + actual_partitioning_static_dr = get_effective_var_partitioning(model_data) + for vartype, expected_vars in expected_partitioning_static_dr.items(): + actual_vars = getattr(actual_partitioning_static_dr, vartype) + self.assertEqual( + ComponentSet(expected_vars), + ComponentSet(actual_vars), + msg=( + f"Effective {vartype!r} are not as expected " + f"for decision rule order {config.decision_rule_order}. " + "\n" + f"Expected: {[var.name for var in expected_vars]}" + "\n" + f"Actual: {[var.name for var in actual_vars]}" + ), + ) + + config.decision_rule_order = 1 + expected_partitioning_nonstatic_dr = { + "first_stage_variables": [m.x1, m.x2], + "second_stage_variables": [m.z], + "state_variables": list(m.y.values()), + } + for dr_order in [1, 2]: + actual_partitioning_nonstatic_dr = get_effective_var_partitioning( + model_data + ) + for vartype, expected_vars in expected_partitioning_nonstatic_dr.items(): + actual_vars = getattr(actual_partitioning_nonstatic_dr, vartype) + self.assertEqual( + ComponentSet(expected_vars), + ComponentSet(actual_vars), + msg=( + f"Effective {vartype!r} are not as expected " + f"for decision rule order {config.decision_rule_order}. " + "\n" + f"Expected: {[var.name for var in expected_vars]}" + "\n" + f"Actual: {[var.name for var in actual_vars]}" + ), + ) + + +class TestSetupModelData(unittest.TestCase): + """ + Test method for setting up the working model works as expected. + """ + + def build_test_model_data(self): + """ + Build model data object for the preprocessor. + """ + model_data = Bunch() + model_data.config = Bunch() + model_data.original_model = m = ConcreteModel() + + # PARAMS: one uncertain, one certain + m.p = Param(initialize=2, mutable=True) + m.q = Param(initialize=4.5, mutable=True) + + # first-stage variables + m.x1 = Var(bounds=(0, m.q), initialize=1) + m.x2 = Var(domain=NonNegativeReals, bounds=[m.p, m.p], initialize=m.p) + + # second-stage variables + m.z1 = Var(domain=RangeSet(2, 4, 0), bounds=[-m.p, m.q], initialize=2) + m.z2 = Var(bounds=(-2 * m.q**2, None), initialize=1) + m.z3 = Var(bounds=(-m.q, 0), initialize=0) + m.z4 = Var(initialize=5) + m.z5 = Var(domain=NonNegativeReals, bounds=(m.q, m.q)) + + # state variables + m.y1 = Var(domain=NonNegativeReals, initialize=0) + m.y2 = Var(initialize=10) + # note: y3 out-of-scope, as it will not appear in the active + # Objective and Constraint objects + m.y3 = Var(domain=RangeSet(0, 1, 0), bounds=(0.2, 0.5)) + + # Var to represent an uncertain Param; + # bounds will be ignored + m.q2var = Var(bounds=(0, None), initialize=3.2) + + # fix some variables + m.z4.fix() + m.y2.fix() + + # NAMED EXPRESSIONS: mainly to test + # Var -> Param substitution for uncertain params + m.nexpr = Expression(expr=log(m.y2) + m.q2var) + + # EQUALITY CONSTRAINTS + m.eq1 = Constraint(expr=m.q * (m.z3 + m.x2) == 0) + m.eq2 = Constraint(expr=m.x1 - m.z1 == 0) + m.eq3 = Constraint(expr=m.x1**2 + m.x2 + m.p * m.z2 == m.p) + m.eq4 = Constraint(expr=m.z3 + m.y1 == m.q) + + # INEQUALITY CONSTRAINTS + m.ineq1 = Constraint(expr=(-m.p, m.x1 + m.z1, exp(m.q))) + m.ineq2 = Constraint(expr=(0, m.x1 + m.x2, 10)) + m.ineq3 = Constraint(expr=(2 * m.q, 2 * (m.z3 + m.y1), 2 * m.q)) + m.ineq4 = Constraint(expr=-m.q <= m.y2**2 + m.nexpr) + + # out of scope: deactivated + m.ineq5 = Constraint(expr=m.y3 <= m.q) + m.ineq5.deactivate() + + # OBJECTIVE + # contains a rich combination of first-stage and second-stage terms + m.obj = Objective( + expr=( + m.p**2 + + 2 * m.p * m.q + + log(m.x1) + + 2 * m.p * m.x1 + + m.q**2 * m.x1 + + m.p**3 * (m.z1 + m.z2 + m.y1) + + m.z4 + + m.z5 + ) + ) + + # inactive objective + m.inactive_obj = Objective(expr=1 + m.q2var + m.x1) + m.inactive_obj.deactivate() + + # set up the var partitioning + user_var_partitioning = VariablePartitioning( + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[m.z1, m.z2, m.z3, m.z4, m.z5], + # note: y3 out of scope, so excluded + state_variables=[m.y1, m.y2], + ) + + return model_data, user_var_partitioning + + def test_setup_working_model(self): + """ + Test method for setting up the working model is as expected. + """ + model_data, user_var_partitioning = self.build_test_model_data() + om = model_data.original_model + config = model_data.config + config.uncertain_params = [om.q, om.q2var] + config.progress_logger = logger + config.nominal_uncertain_param_vals = [om.q.value, om.q2var.value] + + setup_working_model(model_data, user_var_partitioning) + working_model = model_data.working_model + + # active constraints + m = model_data.working_model.user_model + self.assertEqual( + ComponentSet(working_model.original_active_equality_cons), + ComponentSet([m.eq1, m.eq2, m.eq3, m.eq4]), + ) + self.assertEqual( + ComponentSet(working_model.original_active_inequality_cons), + ComponentSet([m.ineq1, m.ineq2, m.ineq3, m.ineq4]), + ) + + # active objective + self.assertTrue(m.obj.active) + self.assertFalse(m.inactive_obj.active) + + # user var partitioning + up = working_model.user_var_partitioning + self.assertEqual( + ComponentSet(up.first_stage_variables), ComponentSet([m.x1, m.x2]) + ) + self.assertEqual( + ComponentSet(up.second_stage_variables), + ComponentSet([m.z1, m.z2, m.z3, m.z4, m.z5]), + ) + self.assertEqual(ComponentSet(up.state_variables), ComponentSet([m.y1, m.y2])) + + # uncertain params + self.assertEqual( + ComponentSet(working_model.orig_uncertain_params), + ComponentSet([m.q, m.q2var]), + ) + + self.assertEqual(list(working_model.temp_uncertain_params.index_set()), [1]) + temp_uncertain_param = working_model.temp_uncertain_params[1] + self.assertEqual( + ComponentSet(working_model.uncertain_params), + ComponentSet([m.q, temp_uncertain_param]), + ) + + # ensure original model unchanged + self.assertFalse( + hasattr(om, "util"), msg="Original model still has temporary util block" + ) + + # constraint partitioning initialization + self.assertFalse(working_model.first_stage.inequality_cons) + self.assertFalse(working_model.first_stage.dr_dependent_equality_cons) + self.assertFalse(working_model.first_stage.dr_independent_equality_cons) + self.assertFalse(working_model.second_stage.inequality_cons) + self.assertFalse(working_model.second_stage.equality_cons) + + # ensure uncertain Param substitutions carried out properly + ublk = model_data.working_model.user_model + self.assertExpressionsEqual( + ublk.nexpr.expr, log(ublk.y2) + temp_uncertain_param + ) + self.assertExpressionsEqual( + ublk.inactive_obj.expr, LinearExpression([1, temp_uncertain_param, m.x1]) + ) + self.assertExpressionsEqual(ublk.ineq4.expr, -ublk.q <= ublk.y2**2 + ublk.nexpr) + + # other component expressions should remain as declared + self.assertExpressionsEqual(ublk.eq1.expr, ublk.q * (ublk.z3 + ublk.x2) == 0) + self.assertExpressionsEqual(ublk.eq2.expr, ublk.x1 - ublk.z1 == 0) + self.assertExpressionsEqual( + ublk.eq3.expr, ublk.x1**2 + ublk.x2 + ublk.p * ublk.z2 == ublk.p + ) + self.assertExpressionsEqual(ublk.eq4.expr, ublk.z3 + ublk.y1 == ublk.q) + self.assertExpressionsEqual( + ublk.ineq1.expr, + RangedExpression((-ublk.p, ublk.x1 + ublk.z1, exp(ublk.q)), False), + ) + self.assertExpressionsEqual( + ublk.ineq2.expr, RangedExpression((0, ublk.x1 + ublk.x2, 10), False) + ) + self.assertExpressionsEqual( + ublk.ineq3.expr, + RangedExpression((2 * ublk.q, 2 * (ublk.z3 + ublk.y1), 2 * ublk.q), False), + ) + self.assertExpressionsEqual(ublk.ineq5.expr, ublk.y3 <= ublk.q) + self.assertExpressionsEqual( + ublk.obj.expr, + ( + ublk.p**2 + + 2 * ublk.p * ublk.q + + log(ublk.x1) + + 2 * ublk.p * ublk.x1 + + ublk.q**2 * ublk.x1 + + ublk.p**3 * (ublk.z1 + ublk.z2 + ublk.y1) + + ublk.z4 + + ublk.z5 + ), + ) + + +class TestResolveVarBounds(unittest.TestCase): + """ + Tests for resolution of variable bounds. + """ + + def test_resolve_var_bounds(self): + """ + Test resolve variable bounds. + """ + m = ConcreteModel() + m.q1 = Param(initialize=1, mutable=True) + m.q2 = Param(initialize=1, mutable=True) + m.p1 = Param(initialize=5, mutable=True) + m.p2 = Param(initialize=0, mutable=True) + m.z1 = Var(bounds=(0, 1)) + m.z2 = Var(bounds=(1, 1)) + m.z3 = Var(domain=NonNegativeReals, bounds=(2, 4)) + m.z4 = Var(domain=NonNegativeReals, bounds=(m.q1, 0)) + m.z5 = Var(domain=RangeSet(2, 4, 0), bounds=(4, 6)) + m.z6 = Var(domain=NonNegativeReals, bounds=(m.q1, m.q1)) + m.z7 = Var(domain=NonNegativeReals, bounds=(m.q1, 1 * m.q1)) + m.z8 = Var(domain=RangeSet(0, 5, 0), bounds=[m.q1, m.q2]) + m.z9 = Var(domain=RangeSet(0, 5, 0), bounds=[m.q1, m.p1]) + m.z10 = Var(domain=RangeSet(0, 5, 0), bounds=[m.q1, m.p2]) + + # useful for checking domains later + original_var_domains = ComponentMap( + ( + (var, var.domain) + for var in (m.z1, m.z2, m.z3, m.z4, m.z5, m.z6, m.z7, m.z8, m.z9, m.z10) + ) + ) + + expected_bounds = ( + (m.z1, (0, None, 1), (None, None, None)), + (m.z2, (None, 1, None), (None, None, None)), + (m.z3, (2, None, 4), (None, None, None)), + (m.z4, (None, 0, None), (m.q1, None, None)), + (m.z5, (None, 4, None), (None, None, None)), + (m.z6, (0, None, None), (None, m.q1, None)), + # the 1 * q expression is simplified to just q + # when variable bounds are specified + (m.z7, (0, None, None), (None, m.q1, None)), + (m.z8, (0, None, 5), (m.q1, None, m.q2)), + (m.z9, (0, None, m.p1), (m.q1, None, None)), + (m.z10, (0, None, m.p2), (m.q1, None, None)), + ) + for var, exp_cert_bounds, exp_uncert_bounds in expected_bounds: + actual_cert_bounds, actual_uncert_bounds = get_var_certain_uncertain_bounds( + var, [m.q1, m.q2] + ) + for btype, exp_bound in zip(("lower", "eq", "upper"), exp_cert_bounds): + actual_bound = getattr(actual_cert_bounds, btype) + self.assertIs( + exp_bound, + actual_bound, + msg=( + f"Resolved certain {btype} bound for variable " + f"{var.name!r} is not as expected. " + "\n Expected certain bounds: " + f"lower={str(exp_cert_bounds[0])}, " + f"eq={str(exp_cert_bounds[1])}, " + f"upper={str(exp_cert_bounds[2])} " + "\n Actual certain bounds: " + f"lower={str(actual_cert_bounds.lower)}, " + f"eq={str(actual_cert_bounds.eq)}, " + f"upper={str(actual_cert_bounds.upper)} " + ), + ) + + for btype, exp_bound in zip(("lower", "eq", "upper"), exp_uncert_bounds): + actual_bound = getattr(actual_uncert_bounds, btype) + self.assertIs( + exp_bound, + actual_bound, + msg=( + f"Resolved uncertain {btype} bound for variable " + f"{var.name!r} is not as expected. " + "\n Expected uncertain bounds: " + f"lower={str(exp_uncert_bounds[0])}, " + f"eq={str(exp_uncert_bounds[1])}, " + f"upper={str(exp_uncert_bounds[2])} " + "\n Actual uncertain bounds: " + f"lower={str(actual_uncert_bounds.lower)}, " + f"eq={str(actual_uncert_bounds.eq)}, " + f"upper={str(actual_uncert_bounds.upper)} " + ), + ) + + # the bounds resolution method should leave domains unaltered + for var, orig_domain in original_var_domains.items(): + self.assertIs( + var.domain, + orig_domain, + msg=( + f"Domain for var {var.name!r} appears to have been changed " + f"from {orig_domain} to {var.domain} " + "by the bounds resolution method " + f"{get_var_certain_uncertain_bounds.__name__!r}." + ), + ) + + +class TestTurnVarBoundsToConstraints(unittest.TestCase): + """ + Tests for reformulating variable bounds to explicit + inequality/equality constraints. + """ + + def build_simple_test_model_data(self): + """ + Build simple model data object for turning bounds + to constraints. + """ + model_data = Bunch() + model_data.config = Bunch() + + model_data.working_model = ConcreteModel() + model_data.working_model.user_model = m = ConcreteModel() + + m.q1 = Param(initialize=1, mutable=True) + m.q2 = Param(initialize=1, mutable=True) + m.p1 = Param(initialize=5, mutable=True) + m.p2 = Param(initialize=0, mutable=True) + + m.z1 = Var(bounds=(None, None)) + m.z2 = Var(bounds=(1, 1)) + m.z3 = Var(domain=NonNegativeReals, bounds=(2, m.p1)) + m.z4 = Var(domain=NonNegativeReals, bounds=(m.q1, 0)) + m.z5 = Var(domain=RangeSet(2, 4, 0), bounds=(4, m.q2)) + m.z6 = Var(domain=NonNegativeReals, bounds=(m.q1, m.q1)) + m.z7 = Var(domain=NonPositiveReals, bounds=(m.q1, 1 * m.q1)) + m.z8 = Var(domain=RangeSet(0, 5, 0), bounds=[m.q1, m.q2]) + m.z9 = Var(domain=RangeSet(0, 5, 0), bounds=[m.q1, m.p1]) + m.z10 = Var(domain=RangeSet(0, 5, 0), bounds=[m.q1, m.p2]) + + model_data.working_model.uncertain_params = [m.q1, m.q2, m.p1] + model_data.working_model.effective_uncertain_params = [m.q1, m.q2] + + model_data.working_model.second_stage = Block() + model_data.working_model.second_stage.inequality_cons = Constraint(Any) + model_data.working_model.second_stage.equality_cons = Constraint(Any) + model_data.separation_priority_order = dict() + + return model_data + + def test_turn_nonadjustable_bounds_to_constraints(self): + """ + Test subroutine for reformulating bounds on nonadjustable + variables to constraints. + + This subroutine should reformulate only the uncertain + declared bounds for the nonadjustable variables. + All other variable bounds should be left unchanged. + All variable domains should remain unchanged. + """ + model_data = self.build_simple_test_model_data() + + working_model = model_data.working_model + m = model_data.working_model.user_model + + # mock effective partitioning for testing + ep = model_data.working_model.effective_var_partitioning = Bunch() + ep.first_stage_variables = [m.z1, m.z2, m.z3, m.z4, m.z5, m.z6, m.z7, m.z8] + ep.second_stage_variables = [m.z9] + ep.state_variables = [m.z10] + effective_first_stage_var_set = ComponentSet(ep.first_stage_variables) + + original_var_domains_and_bounds = ComponentMap( + (var, (var.domain, get_var_bound_pairs(var)[1])) + for var in model_data.working_model.user_model.component_data_objects(Var) + ) + + # expected final bounds and bound constraint types + expected_final_nonadj_var_bounds = ComponentMap( + ( + (m.z1, (get_var_bound_pairs(m.z1)[1], [])), + (m.z2, (get_var_bound_pairs(m.z2)[1], [])), + (m.z3, (get_var_bound_pairs(m.z3)[1], [])), + (m.z4, ((None, 0), ["lower"])), + (m.z5, ((4, None), ["upper"])), + (m.z6, ((None, None), ["eq"])), + (m.z7, ((None, None), ["eq"])), + (m.z8, ((None, None), ["lower", "upper"])), + ) + ) + + turn_nonadjustable_var_bounds_to_constraints(model_data) + + for var, (orig_domain, orig_bounds) in original_var_domains_and_bounds.items(): + # all var domains should remain unchanged + self.assertIs( + var.domain, + orig_domain, + msg=( + f"Domain of variable {var.name!r} was changed from " + f"{orig_domain} to {var.domain} by " + f"{turn_nonadjustable_var_bounds_to_constraints.__name__!r}. " + ), + ) + _, (final_lb, final_ub) = get_var_bound_pairs(var) + + if var not in effective_first_stage_var_set: + # these are the adjustable variables. + # bounds should not have been changed + self.assertIs( + orig_bounds[0], + final_lb, + msg=( + f"Lower bound for adjustable variable {var.name!r} appears to " + f"have been changed from {orig_bounds[0]} to {final_lb}." + ), + ) + self.assertIs( + orig_bounds[1], + final_ub, + msg=( + f"Upper bound for adjustable variable {var.name!r} appears to " + f"have been changed from {orig_bounds[1]} to {final_ub}." + ), + ) + else: + # these are the nonadjustable variables. + # only the uncertain bounds should have been + # changed, and accompanying constraints added + + expected_bounds, con_bound_types = expected_final_nonadj_var_bounds[var] + expected_lb, expected_ub = expected_bounds + + self.assertIs( + expected_lb, + final_lb, + msg=( + f"Lower bound for nonadjustable variable {var.name!r} " + f"should be {expected_lb}, but was " + f"found to be {final_lb}." + ), + ) + self.assertIs( + expected_ub, + final_ub, + msg=( + f"Upper bound for nonadjustable variable {var.name!r} " + f"should be {expected_ub}, but was " + f"found to be {final_ub}." + ), + ) + + second_stage = working_model.second_stage + + # verify bound constraint expressions + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z4_uncertain_lower_bound_con"].expr, + -m.z4 <= -m.q1, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z5_uncertain_upper_bound_con"].expr, + m.z5 <= m.q2, + ) + assertExpressionsEqual( + self, + second_stage.equality_cons["var_z6_uncertain_eq_bound_con"].expr, + m.z6 == m.q1, + ) + assertExpressionsEqual( + self, + second_stage.equality_cons["var_z7_uncertain_eq_bound_con"].expr, + m.z7 == m.q1, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z8_uncertain_lower_bound_con"].expr, + -m.z8 <= -m.q1, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z8_uncertain_upper_bound_con"].expr, + m.z8 <= m.q2, + ) + + # check constraint partitioning + self.assertEqual( + len(working_model.second_stage.inequality_cons), + 4, + msg="Number of second-stage inequalities not as expected.", + ) + self.assertEqual( + len(working_model.second_stage.equality_cons), + 2, + msg="Number of second-stage equalities not as expected.", + ) + + # check separation priorities + for con_name in second_stage.inequality_cons: + self.assertEqual( + model_data.separation_priority_order[con_name], + 0, + msg=( + f"Separation priority for entry {con_name!r} of second-stage " + "inequalities not as expected." + ), + ) + + def test_turn_adjustable_bounds_to_constraints(self): + """ + Test subroutine for reformulating domains and bounds + on adjustable variables to constraints. + + This subroutine should reformulate the domain and + declared bounds for every adjustable + (i.e. effective second-stage and effective state) + variable. + The domains and bounds for all other variables + should be left unchanged. + """ + model_data = self.build_simple_test_model_data() + + m = model_data.working_model.user_model + + # simple mock partitioning for the test + ep = model_data.working_model.effective_var_partitioning = Bunch() + ep.first_stage_variables = [m.z9, m.z10] + ep.second_stage_variables = [m.z1, m.z2, m.z3, m.z4, m.z5, m.z6] + ep.state_variables = [m.z7, m.z8] + effective_first_stage_var_set = ComponentSet(ep.first_stage_variables) + + original_var_domains_and_bounds = ComponentMap( + (var, (var.domain, get_var_bound_pairs(var)[1])) + for var in model_data.working_model.user_model.component_data_objects(Var) + ) + + turn_adjustable_var_bounds_to_constraints(model_data) + + for var, (orig_domain, orig_bounds) in original_var_domains_and_bounds.items(): + _, (final_lb, final_ub) = get_var_bound_pairs(var) + if var not in effective_first_stage_var_set: + # these are the adjustable variables. + # domains should have been removed, + # i.e. changed to reals. + # bounds should also have been removed + self.assertIs( + var.domain, + Reals, + msg=( + f"Domain of adjustable variable {var.name!r} " + "should now be Reals, but was instead found to be " + f"{var.domain}" + ), + ) + self.assertIsNone( + final_lb, + msg=( + f"Declared lower bound for adjustable variable {var.name!r} " + "should now be None, as all adjustable variable bounds " + "should have been removed, but was instead found to be" + f"{final_lb}." + ), + ) + self.assertIsNone( + final_ub, + msg=( + f"Declared upper bound for adjustable variable {var.name!r} " + "should now be None, as all adjustable variable bounds " + "should have been removed, but was instead found to be" + f"{final_ub}." + ), + ) + else: + # these are the nonadjustable variables. + # domains and bounds should be left unchanged + self.assertIs( + var.domain, + orig_domain, + msg=( + f"Domain of adjustable variable {var.name!r} " + "should now be Reals, but was instead found to be " + f"{var.domain}" + ), + ) + self.assertIs( + orig_bounds[0], + final_lb, + msg=( + f"Lower bound for nonadjustable variable {var.name!r} " + "appears to " + f"have been changed from {orig_bounds[0]} to {final_lb}." + ), + ) + self.assertIs( + orig_bounds[1], + final_ub, + msg=( + f"Upper bound for nonadjustable variable {var.name!r} " + "appears to " + f"have been changed from {orig_bounds[1]} to {final_ub}." + ), + ) + + second_stage = model_data.working_model.second_stage + + self.assertEqual(len(second_stage.inequality_cons), 10) + self.assertEqual(len(second_stage.equality_cons), 5) + + # verify bound constraint expressions + assertExpressionsEqual( + self, + second_stage.equality_cons["var_z2_certain_eq_bound_con"].expr, + m.z2 == 1, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z3_certain_lower_bound_con"].expr, + -m.z3 <= -2, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z3_certain_upper_bound_con"].expr, + m.z3 <= m.p1, + ) + assertExpressionsEqual( + self, + second_stage.equality_cons["var_z4_certain_eq_bound_con"].expr, + m.z4 == 0, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z4_uncertain_lower_bound_con"].expr, + -m.z4 <= -m.q1, + ) + assertExpressionsEqual( + self, + second_stage.equality_cons["var_z5_certain_eq_bound_con"].expr, + m.z5 == 4, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z5_uncertain_upper_bound_con"].expr, + m.z5 <= m.q2, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z6_certain_lower_bound_con"].expr, + -m.z6 <= 0, + ) + assertExpressionsEqual( + self, + second_stage.equality_cons["var_z6_uncertain_eq_bound_con"].expr, + m.z6 == m.q1, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z7_certain_upper_bound_con"].expr, + m.z7 <= 0, + ) + assertExpressionsEqual( + self, + second_stage.equality_cons["var_z7_uncertain_eq_bound_con"].expr, + m.z7 == m.q1, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z8_certain_lower_bound_con"].expr, + -m.z8 <= 0, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z8_certain_upper_bound_con"].expr, + m.z8 <= 5, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z8_uncertain_lower_bound_con"].expr, + -m.z8 <= -m.q1, + ) + assertExpressionsEqual( + self, + second_stage.inequality_cons["var_z8_uncertain_upper_bound_con"].expr, + m.z8 <= m.q2, + ) + + # check separation priorities + for con_name in second_stage.inequality_cons: + self.assertEqual( + model_data.separation_priority_order[con_name], + 0, + msg=( + f"Separation priority for entry {con_name!r} of second-stage " + "inequalities not as expected." + ), + ) + + +class TestStandardizeInequalityConstraints(unittest.TestCase): + """ + Test standardization of inequality constraints. + """ + + def build_simple_test_model_data(self): + """ + Build model data object for testing constraint standardization + routines. + """ + model_data = Bunch() + model_data.config = Bunch() + model_data.working_model = ConcreteModel() + model_data.working_model.user_model = m = Block() + + m.x1 = Var() + m.x2 = Var() + m.z1 = Var() + m.z2 = Var() + m.y1 = Var() + + m.p = Param(initialize=2, mutable=True) + m.q = Param(mutable=True, initialize=1) + m.q_cert = Param(mutable=True, initialize=1) + + m.c1 = Constraint(expr=m.x1 <= 1) + m.c2 = Constraint(expr=(1, m.x1, 2 * m.q_cert)) + m.c3 = Constraint(expr=m.q <= m.x1) + m.c3_up = Constraint(expr=m.x1 - 2 * m.q <= 0) + m.c4 = Constraint(expr=(log(m.p), m.x2, m.q)) + m.c5 = Constraint(expr=(m.q, m.x2, 2 * m.q)) + m.c6 = Constraint(expr=m.z1 <= 1) + m.c7 = Constraint(expr=(0, m.z2, 1)) + m.c8 = Constraint(expr=(m.p**0.5, m.y1, m.p)) + m.c9 = Constraint(expr=m.y1 - m.q <= 0) + m.c10 = Constraint(expr=m.y1 <= m.q**2) + m.c11 = Constraint(expr=m.z2 <= m.q) + m.c12 = Constraint(expr=(m.q**2, m.x1, sin(m.p) * m.q_cert)) + + m.c11.deactivate() + + model_data.working_model.uncertain_params = [m.q, m.q_cert] + model_data.working_model.effective_uncertain_params = [m.q] + + model_data.working_model.first_stage = Block() + model_data.working_model.first_stage.inequality_cons = Constraint(Any) + model_data.working_model.second_stage = Block() + model_data.working_model.second_stage.inequality_cons = Constraint(Any) + + model_data.working_model.original_active_inequality_cons = [ + m.c1, + m.c2, + m.c3, + m.c3_up, + m.c4, + m.c5, + m.c6, + m.c7, + m.c8, + m.c9, + m.c10, + m.c12, + ] + + ep = model_data.working_model.effective_var_partitioning = Bunch() + ep.first_stage_variables = [m.x1, m.x2] + ep.second_stage_variables = [m.z1, m.z2] + ep.state_variables = [m.y1] + + model_data.separation_priority_order = dict() + + return model_data + + def test_standardize_inequality_constraints(self): + """ + Test inequality constraint standardization routine. + """ + model_data = self.build_simple_test_model_data() + working_model = model_data.working_model + m = working_model.user_model + + model_data.config.separation_priority_order = dict(c3=1, c5=2) + standardize_inequality_constraints(model_data) + + fs_ineq_cons = working_model.first_stage.inequality_cons + ss_ineq_cons = working_model.second_stage.inequality_cons + + self.assertEqual(len(fs_ineq_cons), 4) + self.assertEqual(len(ss_ineq_cons), 13) + + self.assertFalse(m.c1.active) + new_c1_con = fs_ineq_cons["ineq_con_c1"] + self.assertTrue(new_c1_con.active) + assertExpressionsEqual(self, new_c1_con.expr, m.x1 <= 1) + + # 1 <= m.x1 <= 2; first-stage constraint. no modification + self.assertFalse(m.c2.active) + new_c2_con = fs_ineq_cons["ineq_con_c2"] + self.assertTrue(new_c2_con.active) + assertExpressionsEqual( + self, new_c2_con.expr, RangedExpression((1, m.x1, 2 * m.q_cert), False) + ) + + # m.q <= m.x1; single second-stage inequality. modify in place + self.assertFalse(m.c3.active) + new_c3_con = ss_ineq_cons["ineq_con_c3_lower_bound_con"] + self.assertTrue(new_c3_con.active) + assertExpressionsEqual(self, new_c3_con.expr, -m.x1 <= -m.q) + self.assertEqual(model_data.separation_priority_order[new_c3_con.index()], 1) + + # m.x1 - 2 * m.q <= 0; + # single second-stage inequality. modify in place + # test case where uncertain param is in body, + # rather than bound, and rest of expression is first-stage + self.assertFalse(m.c3_up.active) + new_c3_up_con = ss_ineq_cons["ineq_con_c3_up_upper_bound_con"] + self.assertTrue(new_c3_up_con.active) + assertExpressionsEqual(self, new_c3_up_con.expr, m.x1 - 2 * m.q <= 0.0) + + # log(m.p) <= m.x2 <= m.q + # lower bound is first-stage, upper bound second-stage + self.assertFalse(m.c4.active) + new_c4_lower_bound_con = fs_ineq_cons["ineq_con_c4_lower_bound_con"] + new_c4_upper_bound_con = ss_ineq_cons["ineq_con_c4_upper_bound_con"] + self.assertTrue(new_c4_lower_bound_con.active) + self.assertTrue(new_c4_upper_bound_con.active) + assertExpressionsEqual(self, new_c4_lower_bound_con.expr, log(m.p) <= m.x2) + assertExpressionsEqual(self, new_c4_upper_bound_con.expr, m.x2 <= m.q) + + # m.q <= m.x2 <= 2 * m.q + # two second-stage constraints, one for each bound + self.assertFalse(m.c5.active) + new_c5_lower_bound_con = ss_ineq_cons["ineq_con_c5_lower_bound_con"] + new_c5_upper_bound_con = ss_ineq_cons["ineq_con_c5_upper_bound_con"] + self.assertTrue(new_c5_lower_bound_con.active) + self.assertTrue(new_c5_lower_bound_con.active) + assertExpressionsEqual(self, new_c5_lower_bound_con.expr, -m.x2 <= -m.q) + assertExpressionsEqual(self, new_c5_upper_bound_con.expr, m.x2 <= 2 * m.q) + self.assertEqual( + model_data.separation_priority_order[new_c5_lower_bound_con.index()], 2 + ) + self.assertEqual( + model_data.separation_priority_order[new_c5_upper_bound_con.index()], 2 + ) + + # single second-stage inequality + self.assertFalse(m.c6.active) + new_c6_upper_bound_con = ss_ineq_cons["ineq_con_c6_upper_bound_con"] + self.assertTrue(new_c6_upper_bound_con.active) + assertExpressionsEqual(self, new_c6_upper_bound_con.expr, m.z1 <= 1.0) + + # two new second-stage inequalities + self.assertFalse(m.c7.active) + new_c7_lower_bound_con = ss_ineq_cons["ineq_con_c7_lower_bound_con"] + new_c7_upper_bound_con = ss_ineq_cons["ineq_con_c7_upper_bound_con"] + self.assertTrue(new_c7_lower_bound_con.active) + self.assertTrue(new_c7_upper_bound_con.active) + assertExpressionsEqual(self, new_c7_lower_bound_con.expr, -m.z2 <= 0.0) + assertExpressionsEqual(self, new_c7_upper_bound_con.expr, m.z2 <= 1.0) + + # m.p ** 0.5 <= m.y1 <= m.p + # two second-stage inequalities + self.assertFalse(m.c8.active) + new_c8_lower_bound_con = ss_ineq_cons["ineq_con_c8_lower_bound_con"] + new_c8_upper_bound_con = ss_ineq_cons["ineq_con_c8_upper_bound_con"] + self.assertTrue(new_c8_lower_bound_con.active) + self.assertTrue(new_c8_upper_bound_con.active) + assertExpressionsEqual(self, new_c8_lower_bound_con.expr, -m.y1 <= -m.p**0.5) + assertExpressionsEqual(self, new_c8_upper_bound_con.expr, m.y1 <= m.p) + + # m.y1 - m.q <= 0 + # one second-stage inequality + self.assertFalse(m.c9.active) + new_c9_upper_bound_con = ss_ineq_cons["ineq_con_c9_upper_bound_con"] + self.assertTrue(new_c9_upper_bound_con.active) + assertExpressionsEqual(self, new_c9_upper_bound_con.expr, m.y1 - m.q <= 0.0) + + # m.y1 <= m.q ** 2 + # single second-stage inequality + self.assertFalse(m.c10.active) + new_c10_upper_bound_con = ss_ineq_cons["ineq_con_c10_upper_bound_con"] + self.assertTrue(new_c10_upper_bound_con.active) + assertExpressionsEqual(self, new_c10_upper_bound_con.expr, m.y1 <= m.q**2) + + # originally deactivated; + # no modification + self.assertFalse(m.c11.active) + assertExpressionsEqual(self, m.c11.expr, m.z2 <= m.q) + + # lower bound second-stage; upper bound first-stage + self.assertFalse(m.c12.active) + new_c12_lower_bound_con = ss_ineq_cons["ineq_con_c12_lower_bound_con"] + new_c12_upper_bound_con = fs_ineq_cons["ineq_con_c12_upper_bound_con"] + self.assertTrue(new_c12_lower_bound_con.active) + self.assertTrue(new_c12_upper_bound_con.active) + assertExpressionsEqual(self, new_c12_lower_bound_con.expr, -m.x1 <= -m.q**2) + assertExpressionsEqual( + self, new_c12_upper_bound_con.expr, m.x1 <= sin(m.p) * m.q_cert + ) + + # check separation priorities + for con_name in ss_ineq_cons: + if "c3" not in con_name and "c5" not in con_name: + self.assertEqual( + model_data.separation_priority_order[con_name], + 0, + msg=( + f"Separation priority for entry {con_name!r} of second-stage " + "inequalities not as expected." + ), + ) + + def test_standardize_inequality_error(self): + """ + Test exception raised by inequality constraint standardization + method if equality-type expression detected. + """ + model_data = self.build_simple_test_model_data() + model_data.config.separation_priority_order = dict() + working_model = model_data.working_model + m = working_model.user_model + + # change to equality constraint to trigger the exception + m.c6.set_value(m.z1 == 1) + + exc_str = r"Found an equality bound.*1.0.*for the constraint.*c6'" + with self.assertRaisesRegex(ValueError, exc_str): + standardize_inequality_constraints(model_data) + + +class TestStandardizeEqualityConstraints(unittest.TestCase): + """ + Test standardization of equality constraints. + """ + + def build_simple_test_model_data(self): + """ + Build model data object for testing constraint standardization + routines. + """ + model_data = Bunch() + model_data.config = Bunch() + model_data.working_model = ConcreteModel() + model_data.working_model.user_model = m = Block() + + m.x1 = Var() + m.x2 = Var() + m.z1 = Var() + m.z2 = Var() + m.y1 = Var() + + m.p = Param(initialize=2, mutable=True) + m.q = Param(mutable=True, initialize=1) + m.q_cert = Param(mutable=True, initialize=1) + + # first-stage equalities + m.eq1 = Constraint(expr=m.x1 + log(m.p) == m.q_cert + 1) + m.eq2 = Constraint(expr=(1, m.x2, 1)) + + # second-stage equalities + m.eq3 = Constraint(expr=m.x2 * m.q == 1) + m.eq4 = Constraint(expr=m.x2 - m.z1**2 == 0) + m.eq5 = Constraint(expr=m.q == m.y1) + m.eq6 = Constraint(expr=(m.q, m.y1, m.q)) + m.eq7 = Constraint(expr=m.z2 == 0) + + # make eq7 out of scope + m.eq7.deactivate() + + model_data.working_model.uncertain_params = [m.q, m.q_cert] + model_data.working_model.effective_uncertain_params = [m.q] + + model_data.working_model.first_stage = Block() + model_data.working_model.first_stage.dr_dependent_equality_cons = Constraint( + Any + ) + model_data.working_model.first_stage.dr_independent_equality_cons = Constraint( + Any + ) + model_data.working_model.second_stage = Block() + model_data.working_model.second_stage.equality_cons = Constraint(Any) + + model_data.working_model.original_active_equality_cons = [ + m.eq1, + m.eq2, + m.eq3, + m.eq4, + m.eq5, + m.eq6, + ] + + ep = model_data.working_model.effective_var_partitioning = Bunch() + ep.second_stage_variables = [m.x1, m.x2] + ep.second_stage_variables = [m.z1, m.z2] + ep.state_variables = [m.y1] + + return model_data + + def test_standardize_equality_constraints(self): + """ + Test inequality constraint standardization routine. + """ + model_data = self.build_simple_test_model_data() + working_model = model_data.working_model + m = working_model.user_model + + standardize_equality_constraints(model_data) + + first_stage_eq_cons = working_model.first_stage.dr_independent_equality_cons + second_stage_eq_cons = working_model.second_stage.equality_cons + + self.assertEqual(len(first_stage_eq_cons), 2) + self.assertEqual(len(second_stage_eq_cons), 4) + + self.assertFalse(m.eq1.active) + new_eq1_con = first_stage_eq_cons["eq_con_eq1"] + self.assertTrue(new_eq1_con.active) + assertExpressionsEqual(self, new_eq1_con.expr, m.x1 + log(m.p) == m.q_cert + 1) + + self.assertFalse(m.eq2.active) + new_eq2_con = first_stage_eq_cons["eq_con_eq2"] + self.assertTrue(new_eq2_con.active) + assertExpressionsEqual( + self, new_eq2_con.expr, RangedExpression((1, m.x2, 1), False) + ) + + self.assertFalse(m.eq3.active) + new_eq3_con = second_stage_eq_cons["eq_con_eq3"] + self.assertTrue(new_eq3_con.active) + assertExpressionsEqual(self, new_eq3_con.expr, m.x2 * m.q == 1) + + self.assertFalse(m.eq4.active) + new_eq4_con = second_stage_eq_cons["eq_con_eq4"] + self.assertTrue(new_eq4_con) + assertExpressionsEqual(self, new_eq4_con.expr, m.x2 - m.z1**2 == 0) + + self.assertFalse(m.eq5.active) + new_eq5_con = second_stage_eq_cons["eq_con_eq5"] + self.assertTrue(new_eq5_con) + assertExpressionsEqual(self, new_eq5_con.expr, m.q == m.y1) + + self.assertFalse(m.eq6.active) + new_eq6_con = second_stage_eq_cons["eq_con_eq6"] + self.assertTrue(new_eq6_con.active) + assertExpressionsEqual( + self, new_eq6_con.expr, RangedExpression((m.q, m.y1, m.q), False) + ) + + # excluded from the list of active constraints; + # state should remain unchanged + self.assertFalse(m.eq7.active) + assertExpressionsEqual(self, m.eq7.expr, m.z2 == 0) + + +class TestStandardizeActiveObjective(unittest.TestCase): + """ + Test methods for standardization of the active objective. + """ + + def build_simple_test_model_data(self): + """ + Build simple model for testing active objective + standardization. + """ + model_data = Bunch() + model_data.config = Bunch() + model_data.working_model = ConcreteModel() + model_data.working_model.user_model = m = Block() + + m.x = Var(initialize=1) + m.z = Var(initialize=2) + m.y = Var() + + m.p = Param(initialize=1, mutable=True) + m.q = Param(initialize=1, mutable=True) + m.q_cert = Param(initialize=1, mutable=True) + + m.obj1 = Objective( + expr=( + 10 + + m.p + + m.q + + m.p * m.q_cert * m.x + + m.z * m.p + + m.y**2 * m.q + + m.y + + log(m.x) + ) + ) + m.obj2 = Objective(expr=m.p + m.x * m.z + m.z**2) + + model_data.working_model.uncertain_params = [m.q, m.q_cert] + model_data.working_model.effective_uncertain_params = [m.q] + + up = model_data.working_model.user_var_partitioning = Bunch() + up.first_stage_variables = [m.x] + up.second_stage_variables = [m.z] + up.state_variables = [m.y] + + ep = model_data.working_model.effective_var_partitioning = Bunch() + ep.first_stage_variables = [m.x, m.z] + ep.second_stage_variables = [] + ep.state_variables = [m.y] + + model_data.working_model.first_stage = Block() + model_data.working_model.first_stage.inequality_cons = Constraint(Any) + model_data.working_model.second_stage = Block() + model_data.working_model.second_stage.inequality_cons = Constraint(Any) + + model_data.separation_priority_order = dict() + + return model_data + + def test_declare_objective_expressions(self): + """ + Test method for identification/declaration + of per-stage objective summands. + """ + model_data = self.build_simple_test_model_data() + working_model = model_data.working_model + m = model_data.working_model.user_model + + declare_objective_expressions(working_model, m.obj1) + assertExpressionsEqual( + self, + working_model.first_stage_objective.expr, + 10 + m.p + m.p * m.q_cert * m.x + log(m.x), + ) + assertExpressionsEqual( + self, + working_model.second_stage_objective.expr, + m.q + m.z * m.p + m.y**2 * m.q + m.y, + ) + assertExpressionsEqual(self, working_model.full_objective.expr, m.obj1.expr) + + def test_declare_objective_expressions_maximization_obj(self): + """ + Test per-stage objective summand expressions are constructed + as expected when the objective is of a maximization sense. + """ + model_data = self.build_simple_test_model_data() + working_model = model_data.working_model + m = model_data.working_model.user_model + m.obj1.sense = maximize + + declare_objective_expressions(working_model, m.obj1) + assertExpressionsEqual( + self, + working_model.first_stage_objective.expr, + -10 - m.p - m.p * m.q_cert * m.x - log(m.x), + ) + assertExpressionsEqual( + self, + working_model.second_stage_objective.expr, + -m.q - m.z * m.p - m.y**2 * m.q - m.y, + ) + assertExpressionsEqual(self, working_model.full_objective.expr, -m.obj1.expr) + + def test_standardize_active_obj_worst_case_focus(self): + """ + Test preprocessing step for standardization + of the active model objective. + """ + model_data = self.build_simple_test_model_data() + working_model = model_data.working_model + m = model_data.working_model.user_model + model_data.config.objective_focus = ObjectiveType.worst_case + + m.obj1.activate() + m.obj2.deactivate() + + standardize_active_objective(model_data) + + self.assertFalse( + m.obj1.active, + msg=( + f"Objective {m.obj1.name!r} should have been deactivated by " + f"{standardize_active_objective}." + ), + ) + assertExpressionsEqual( + self, + working_model.second_stage.inequality_cons["epigraph_con"].expr, + m.obj1.expr - working_model.first_stage.epigraph_var <= 0, + ) + self.assertEqual(model_data.separation_priority_order["epigraph_con"], 0) + + def test_standardize_active_obj_nominal_focus(self): + """ + Test standardization of active objective under nominal + objective focus. + """ + model_data = self.build_simple_test_model_data() + working_model = model_data.working_model + m = model_data.working_model.user_model + model_data.config.objective_focus = ObjectiveType.nominal + + m.obj1.activate() + m.obj2.deactivate() + + standardize_active_objective(model_data) + + self.assertFalse( + m.obj1.active, + msg=( + f"Objective {m.obj1.name!r} should have been deactivated by " + f"{standardize_active_objective}." + ), + ) + assertExpressionsEqual( + self, + working_model.first_stage.inequality_cons["epigraph_con"].expr, + m.obj1.expr - working_model.first_stage.epigraph_var <= 0, + ) + self.assertNotIn("epigraph_con", model_data.separation_priority_order) + + def test_standardize_active_obj_unsupported_focus(self): + """ + Test standardization of active objective under + an objective focus currently not supported + """ + model_data = self.build_simple_test_model_data() + m = model_data.working_model.user_model + model_data.config.objective_focus = "bad_focus" + + m.obj1.activate() + m.obj2.deactivate() + + exc_str = r"Classification.*not implemented for objective focus 'bad_focus'" + with self.assertRaisesRegex(ValueError, exc_str): + standardize_active_objective(model_data) + + def test_standardize_active_obj_nonadjustable_max(self): + """ + Test standardize active objective for case in which + the objective is independent of the nonadjustable variables + and of a maximization sense. + """ + model_data = self.build_simple_test_model_data() + working_model = model_data.working_model + m = working_model.user_model + model_data.config.objective_focus = ObjectiveType.worst_case + + # assume all variables nonadjustable + ep = model_data.working_model.effective_var_partitioning + ep.first_stage_variables = [m.x, m.z] + ep.second_stage_variables = [] + ep.state_variables = [m.y] + + m.obj1.deactivate() + m.obj2.activate() + m.obj2.sense = maximize + + standardize_active_objective(model_data) + + self.assertFalse( + m.obj2.active, + msg=( + f"Objective {m.obj2.name!r} should have been deactivated by " + f"{standardize_active_objective}." + ), + ) + + assertExpressionsEqual( + self, + working_model.first_stage.inequality_cons["epigraph_con"].expr, + -m.obj2.expr - working_model.first_stage.epigraph_var <= 0, + ) + self.assertNotIn("epigraph_con", model_data.separation_priority_order) + + +class TestAddDecisionRuleVars(unittest.TestCase): + """ + Test method for adding decision rule variables to working model. + There should be one indexed decision rule variable for every + effective second-stage variable. + The number of decision rule variables per effective second-stage + variable should depend on: + + - the number of uncertain parameters in the model + - the decision rule order specified by the user. + """ + + def build_simple_test_model_data(self): + """ + Make simple model data object for DR variable + declaration testing. + """ + model_data = Bunch() + model_data.config = Bunch() + model_data.working_model = ConcreteModel() + model_data.working_model.user_model = m = Block() + + # uncertain parameters + m.q = Param(range(4), initialize=0, mutable=True) + + # second-stage variables + m.x = Var() + m.z1 = Var([0, 1], initialize=0) + m.z2 = Var() + m.y = Var() + + model_data.working_model.uncertain_params = list(m.q.values()) + model_data.working_model.effective_uncertain_params = [m.q[0], m.q[1], m.q[2]] + + up = model_data.working_model.user_var_partitioning = Bunch() + up.first_stage_variables = [m.x] + up.second_stage_variables = [m.z1, m.z2] + up.state_variables = [m.y] + + ep = model_data.working_model.effective_var_partitioning = Bunch() + ep.first_stage_variables = [m.x, m.z1] + ep.second_stage_variables = [m.z2] + ep.state_variables = [m.y] + + model_data.working_model.first_stage = Block() + + return model_data + + def test_correct_num_dr_vars_static(self): + """ + Test DR variable setup routines declare the correct + number of DR coefficient variables, static DR case. + """ + model_data = self.build_simple_test_model_data() + model_data.config.decision_rule_order = 0 + + add_decision_rule_variables(model_data) + + for indexed_dr_var in model_data.working_model.first_stage.decision_rule_vars: + self.assertEqual( + len(indexed_dr_var), + 1, + msg=( + "Number of decision rule coefficient variables " + f"in indexed Var object {indexed_dr_var.name!r}" + "does not match correct value." + ), + ) + + effective_second_stage_vars = ( + model_data.working_model.effective_var_partitioning.second_stage_variables + ) + self.assertEqual( + len(ComponentSet(model_data.working_model.first_stage.decision_rule_vars)), + len(effective_second_stage_vars), + msg=( + "Number of unique indexed DR variable components should equal " + "number of second-stage variables." + ), + ) + + # check mapping is as expected + ess_dr_var_zip = zip( + effective_second_stage_vars, + model_data.working_model.first_stage.decision_rule_vars, + ) + for ess_var, indexed_dr_var in ess_dr_var_zip: + mapped_dr_var = model_data.working_model.eff_ss_var_to_dr_var_map[ess_var] + self.assertIs( + mapped_dr_var, + indexed_dr_var, + msg=( + f"Second-stage var {ess_var.name!r} " + f"is mapped to DR var {mapped_dr_var.name!r}, " + f"but expected mapping to DR var {indexed_dr_var.name!r}." + ), + ) + + def test_correct_num_dr_vars_affine(self): + """ + Test DR variable setup routines declare the correct + number of DR coefficient variables, affine DR case. + """ + model_data = self.build_simple_test_model_data() + model_data.config.decision_rule_order = 1 + + add_decision_rule_variables(model_data) + + for indexed_dr_var in model_data.working_model.first_stage.decision_rule_vars: + self.assertEqual( + len(indexed_dr_var), + 1 + len(model_data.working_model.effective_uncertain_params), + msg=( + "Number of decision rule coefficient variables " + f"in indexed Var object {indexed_dr_var.name!r}" + "does not match correct value." + ), + ) + + effective_second_stage_vars = ( + model_data.working_model.effective_var_partitioning.second_stage_variables + ) + self.assertEqual( + len(ComponentSet(model_data.working_model.first_stage.decision_rule_vars)), + len(effective_second_stage_vars), + msg=( + "Number of unique indexed DR variable components should equal " + "number of second-stage variables." + ), + ) + + # check mapping is as expected + ess_dr_var_zip = zip( + effective_second_stage_vars, + model_data.working_model.first_stage.decision_rule_vars, + ) + for ess_var, indexed_dr_var in ess_dr_var_zip: + mapped_dr_var = model_data.working_model.eff_ss_var_to_dr_var_map[ess_var] + self.assertIs( + mapped_dr_var, + indexed_dr_var, + msg=( + f"Second-stage var {ess_var.name!r} " + f"is mapped to DR var {mapped_dr_var.name!r}, " + f"but expected mapping to DR var {indexed_dr_var.name!r}." + ), + ) + + def test_correct_num_dr_vars_quadratic(self): + """ + Test DR variable setup routines declare the correct + number of DR coefficient variables, quadratic DR case. + """ + model_data = self.build_simple_test_model_data() + model_data.config.decision_rule_order = 2 + + add_decision_rule_variables(model_data) + + num_params = len(model_data.working_model.effective_uncertain_params) + + for indexed_dr_var in model_data.working_model.first_stage.decision_rule_vars: + self.assertEqual( + len(indexed_dr_var), + 1 + num_params # static term # affine terms + # quadratic terms + + sp.special.comb(num_params, 2, repetition=True, exact=True), + msg=( + "Number of decision rule coefficient variables " + f"in indexed Var object {indexed_dr_var.name!r}" + "does not match correct value." + ), + ) + + effective_second_stage_vars = ( + model_data.working_model.effective_var_partitioning.second_stage_variables + ) + self.assertEqual( + len(ComponentSet(model_data.working_model.first_stage.decision_rule_vars)), + len(effective_second_stage_vars), + msg=( + "Number of unique indexed DR variable components should equal " + "number of second-stage variables." + ), + ) + + # check mapping is as expected + ess_dr_var_zip = zip( + effective_second_stage_vars, + model_data.working_model.first_stage.decision_rule_vars, + ) + for ess_var, indexed_dr_var in ess_dr_var_zip: + mapped_dr_var = model_data.working_model.eff_ss_var_to_dr_var_map[ess_var] + self.assertIs( + mapped_dr_var, + indexed_dr_var, + msg=( + f"Second-stage var {ess_var.name!r} " + f"is mapped to DR var {mapped_dr_var.name!r}, " + f"but expected mapping to DR var {indexed_dr_var.name!r}." + ), + ) + + +class TestAddDecisionRuleConstraints(unittest.TestCase): + """ + Test method for adding decision rule equality constraints + to the working model. There should be as many decision + rule equality constraints as there are effective second-stage + variables, and each constraint should relate an effective + second-stage variable to the uncertain parameters and corresponding + decision rule variables. + """ + + def build_simple_test_model_data(self): + """ + Make simple test model for DR variable + declaration testing. + """ + model_data = Bunch() + model_data.config = Bunch() + model_data.working_model = ConcreteModel() + model_data.working_model.user_model = m = Block() + + # uncertain parameters + m.q = Param(range(4), initialize=0, mutable=True) + + # second-stage variables + m.x = Var() + m.z1 = Var([0, 1], initialize=0) + m.z2 = Var() + m.y = Var() + + model_data.working_model.uncertain_params = list(m.q.values()) + model_data.working_model.effective_uncertain_params = [m.q[0], m.q[1], m.q[2]] + + up = model_data.working_model.user_var_partitioning = Bunch() + up.first_stage_variables = [m.x] + up.second_stage_variables = [m.z1, m.z2] + up.state_variables = [m.y] + + ep = model_data.working_model.effective_var_partitioning = Bunch() + ep.first_stage_variables = [m.x, m.z1] + ep.second_stage_variables = [m.z2] + ep.state_variables = [m.y] + + model_data.working_model.first_stage = Block() + model_data.working_model.second_stage = Block() + + return model_data + + def test_num_dr_eqns_added_correct(self): + """ + Check that number of DR equality constraints added + by constraint declaration routines matches the number + of second-stage variables in the model. + """ + model_data = self.build_simple_test_model_data() + model_data.config.decision_rule_order = 2 + + add_decision_rule_variables(model_data) + add_decision_rule_constraints(model_data) + + effective_second_stage_vars = ( + model_data.working_model.effective_var_partitioning.second_stage_variables + ) + self.assertEqual( + len(model_data.working_model.second_stage.decision_rule_eqns), + len(effective_second_stage_vars), + msg=( + "Number of decision rule equations should match number of " + "effective second-stage variables." + ), + ) + + # check second-stage var to DR equation mapping is as expected + ess_dr_var_zip = zip( + effective_second_stage_vars, + model_data.working_model.second_stage.decision_rule_eqns.values(), + ) + for ess_var, dr_eqn in ess_dr_var_zip: + mapped_dr_eqn = model_data.working_model.eff_ss_var_to_dr_eqn_map[ess_var] + self.assertIs( + mapped_dr_eqn, + dr_eqn, + msg=( + f"Second-stage var {ess_var.name!r} " + f"is mapped to DR equation {mapped_dr_eqn.name!r}, " + f"but expected mapping to DR equation {dr_eqn.name!r}." + ), + ) + self.assertTrue(mapped_dr_eqn.active) + + def test_dr_eqns_form_correct(self): + """ + Check that form of decision rule equality constraints + is as expected. + + Decision rule equations should be of the standard form: + (sum of DR monomial terms) - (second-stage variable) == 0 + where each monomial term should be of form: + (product of uncertain parameters) * (decision rule variable) + + This test checks that the equality constraints are of this + standard form. + """ + model_data = self.build_simple_test_model_data() + working_model = model_data.working_model + m = model_data.working_model.user_model + + # set up simple config-like object + model_data.config.decision_rule_order = 2 + + # add DR variables and constraints + add_decision_rule_variables(model_data) + add_decision_rule_constraints(model_data) + + dr_zip = zip( + model_data.working_model.effective_var_partitioning.second_stage_variables, + model_data.working_model.first_stage.decision_rule_vars, + model_data.working_model.second_stage.decision_rule_eqns.values(), + ) + for ss_var, indexed_dr_var, dr_eq in dr_zip: + expected_dr_eq_expression = ( + indexed_dr_var[0] + + indexed_dr_var[1] * m.q[0] + + indexed_dr_var[2] * m.q[1] + + indexed_dr_var[3] * m.q[2] + + indexed_dr_var[4] * m.q[0] * m.q[0] + + indexed_dr_var[5] * m.q[0] * m.q[1] + + indexed_dr_var[6] * m.q[0] * m.q[2] + + indexed_dr_var[7] * m.q[1] * m.q[1] + + indexed_dr_var[8] * m.q[1] * m.q[2] + + indexed_dr_var[9] * m.q[2] * m.q[2] + - ss_var + == 0 + ) + assertExpressionsEqual(self, dr_eq.expr, expected_dr_eq_expression) + + expected_dr_var_to_exponent_map = ComponentMap( + ( + (indexed_dr_var[0], 0), + (indexed_dr_var[1], 1), + (indexed_dr_var[2], 1), + (indexed_dr_var[3], 1), + (indexed_dr_var[4], 2), + (indexed_dr_var[5], 2), + (indexed_dr_var[6], 2), + (indexed_dr_var[7], 2), + (indexed_dr_var[8], 2), + (indexed_dr_var[9], 2), + ) + ) + self.assertEqual( + working_model.dr_var_to_exponent_map, + expected_dr_var_to_exponent_map, + msg="DR variable to exponent map not as expected.", + ) + + +class TestReformulateStateVarIndependentEqCons(unittest.TestCase): + """ + Unit tests for routine that reformulates + state variable-independent second-stage equality constraints. + """ + + def setup_test_model_data(self, uncertainty_set=None): + """ + Set up simple test model for testing the reformulation + routine. + """ + model_data = Bunch() + model_data.config = Bunch(uncertainty_set=uncertainty_set or BoxSet([[0, 1]])) + model_data.working_model = working_model = ConcreteModel() + model_data.working_model.user_model = m = Block() + + m.x1 = Var(initialize=0, bounds=(0, None)) + m.x2 = Var(initialize=0, bounds=(0, None)) + m.u = Param(initialize=1.125, mutable=True) + m.u_cert = Param(initialize=1, mutable=True) + m.con = Constraint(expr=m.u ** (0.5) * m.x1 - m.u * m.x2 <= 2) + m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + m.eq_con = Constraint( + expr=( + m.u**2 * (m.x2 - 1) + + m.u * (m.x1**3 + 0.5) + - 5 * m.u * m.u_cert * m.x1 * m.x2 + == -m.u * (m.x1 + 2) + ) + ) + + # mathematically redundant, but makes the tests more rigorous + # as we want to check that loops in the coefficient + # matching routine are exited appropriately + m.eq_con_2 = Constraint(expr=m.u * (m.x2 - 1) == 0) + + working_model.uncertain_params = [m.u, m.u_cert] + working_model.effective_uncertain_params = [m.u] + + working_model.first_stage = Block() + working_model.first_stage.dr_dependent_equality_cons = Constraint(Any) + working_model.first_stage.dr_independent_equality_cons = Constraint(Any) + working_model.second_stage = Block() + working_model.second_stage.equality_cons = Constraint(Any) + working_model.second_stage.inequality_cons = Constraint(Any) + + working_model.second_stage.equality_cons["eq_con"] = m.eq_con.expr + working_model.second_stage.equality_cons["eq_con_2"] = m.eq_con_2.expr + working_model.second_stage.inequality_cons["con"] = m.con.expr + + # deactivate constraints on user model, as these are not + # what the reformulation routine actually processes + m.eq_con.deactivate() + m.eq_con_2.deactivate() + m.con.deactivate() + + working_model.all_variables = [m.x1, m.x2] + ep = working_model.effective_var_partitioning = Bunch() + ep.first_stage_variables = [m.x1] + ep.second_stage_variables = [m.x2] + ep.state_variables = [] + + return model_data + + def test_coefficient_matching_correct_constraints_added(self): + """ + Test coefficient matching adds correct number of constraints + in event of successful use. + """ + model_data = self.setup_test_model_data() + m = model_data.working_model.user_model + + # all vars first-stage + ep = model_data.working_model.effective_var_partitioning + ep.first_stage_variables = [m.x1, m.x2] + ep.second_stage_variables = [] + + model_data.config.decision_rule_order = 1 + model_data.config.progress_logger = logger + + model_data.working_model.first_stage.decision_rule_vars = [] + model_data.working_model.second_stage.decision_rule_eqns = [] + model_data.working_model.all_nonadjustable_variables = list( + ep.first_stage_variables + ) + + robust_infeasible = reformulate_state_var_independent_eq_cons(model_data) + + self.assertFalse( + robust_infeasible, + msg=( + "Coefficient matching unexpectedly detected" + "a robust infeasible constraint" + ), + ) + + fs_blk = model_data.working_model.first_stage + dr_dependent_fs_eq_cons = fs_blk.dr_dependent_equality_cons + dr_independent_fs_eq_cons = fs_blk.dr_independent_equality_cons + self.assertFalse(dr_dependent_fs_eq_cons) + self.assertEqual( + len(dr_independent_fs_eq_cons), + 3, + msg="Number of coefficient matching constraints not as expected.", + ) + self.assertEqual(len(model_data.working_model.second_stage.equality_cons), 0) + # we originally declared an inequality constraint on the model + self.assertEqual(len(model_data.working_model.second_stage.inequality_cons), 1) + + assertExpressionsEqual( + self, + dr_independent_fs_eq_cons["coeff_matching_eq_con_coeff_1"].expr, + m.x1**3 + 0.5 + 5 * m.x1 * m.x2 * (-1) + (-1) * (m.x1 + 2) * (-1) == 0, + ) + assertExpressionsEqual( + self, + dr_independent_fs_eq_cons["coeff_matching_eq_con_coeff_2"].expr, + m.x2 - 1 == 0, + ) + assertExpressionsEqual( + self, + dr_independent_fs_eq_cons["coeff_matching_eq_con_2_coeff_1"].expr, + m.x2 - 1 == 0, + ) + + def test_reformulate_nonlinear_state_var_independent_eq_con(self): + """ + Test routine appropriately performs coefficient matching + of polynomial-like constraints, + and recasting of nonlinear constraints to opposing equalities. + """ + model_data = self.setup_test_model_data() + model_data.separation_priority_order = dict() + + model_data.config.decision_rule_order = 1 + model_data.config.progress_logger = logging.getLogger( + self.test_reformulate_nonlinear_state_var_independent_eq_con.__name__ + ) + model_data.config.progress_logger.setLevel(logging.DEBUG) + + add_decision_rule_variables(model_data) + add_decision_rule_constraints(model_data) + + ep = model_data.working_model.effective_var_partitioning + model_data.working_model.all_nonadjustable_variables = list( + ep.first_stage_variables + + list(model_data.working_model.first_stage.decision_rule_var_0.values()) + ) + + wm = model_data.working_model + m = model_data.working_model.user_model + + # we want only one of the constraints to be 'nonlinear' + # change eq_con_2 to give a valid matching constraint + wm.second_stage.equality_cons["eq_con_2"].set_value(m.u * (m.x1 - 1) == 0) + + with LoggingIntercept(level=logging.DEBUG) as LOG: + robust_infeasible = reformulate_state_var_independent_eq_cons(model_data) + + err_msg = LOG.getvalue() + self.assertRegex( + text=err_msg, + expected_regex=(r".*Equality constraint '.*eq_con.*'.*cannot be written.*"), + ) + + self.assertFalse( + robust_infeasible, + msg=( + "Coefficient matching unexpectedly detected" + "a robust infeasible constraint" + ), + ) + + fs_blk = model_data.working_model.first_stage + dr_independent_fs_eq_cons = fs_blk.dr_independent_equality_cons + # check constraint partitioning updated as expected + self.assertFalse(wm.second_stage.equality_cons) + self.assertEqual(len(wm.second_stage.inequality_cons), 3) + self.assertEqual(len(wm.first_stage.dr_independent_equality_cons), 1) + self.assertFalse(wm.first_stage.dr_dependent_equality_cons) + + second_stage_ineq_cons = wm.second_stage.inequality_cons + self.assertTrue(second_stage_ineq_cons["reform_lower_bound_from_eq_con"].active) + self.assertTrue(second_stage_ineq_cons["reform_upper_bound_from_eq_con"].active) + self.assertTrue( + dr_independent_fs_eq_cons["coeff_matching_eq_con_2_coeff_1"].active + ) + + # expressions for the new opposing inequalities + # and coefficient matching constraint + assertExpressionsEqual( + self, + second_stage_ineq_cons["reform_lower_bound_from_eq_con"].expr, + -( + m.u**2 * (m.x2 - 1) + + m.u * (m.x1**3 + 0.5) + - ((5 * m.u * m.u_cert * m.x1) * m.x2) + - (-m.u) * (m.x1 + 2) + ) + <= 0.0, + ) + assertExpressionsEqual( + self, + second_stage_ineq_cons["reform_upper_bound_from_eq_con"].expr, + ( + m.u**2 * (m.x2 - 1) + + m.u * (m.x1**3 + 0.5) + - ((5 * m.u * m.u_cert * m.x1) * m.x2) + - (-m.u) * (m.x1 + 2) + <= 0.0 + ), + ) + assertExpressionsEqual( + self, + dr_independent_fs_eq_cons["coeff_matching_eq_con_2_coeff_1"].expr, + m.x1 - 1 == 0, + ) + + # separation priorities were also updated + self.assertEqual( + model_data.separation_priority_order["reform_lower_bound_from_eq_con"], 0 + ) + self.assertEqual( + model_data.separation_priority_order["reform_upper_bound_from_eq_con"], 0 + ) + + def test_reformulate_equality_cons_discrete_set(self): + """ + Test routine for reformulating state-variable-independent + second-stage equality constraints under scenario-based + uncertainty works as expected. + """ + model_data = self.setup_test_model_data( + uncertainty_set=DiscreteScenarioSet([[0], [0.7]]) + ) + model_data.separation_priority_order = dict() + + model_data.config.decision_rule_order = 1 + model_data.config.progress_logger = logging.getLogger( + self.test_reformulate_nonlinear_state_var_independent_eq_con.__name__ + ) + model_data.config.progress_logger.setLevel(logging.DEBUG) + + add_decision_rule_variables(model_data) + add_decision_rule_constraints(model_data) + + ep = model_data.working_model.effective_var_partitioning + model_data.working_model.all_nonadjustable_variables = list( + ep.first_stage_variables + + list(model_data.working_model.first_stage.decision_rule_var_0.values()) + ) + + wm = model_data.working_model + m = model_data.working_model.user_model + wm.second_stage.equality_cons["eq_con_2"].set_value(m.u * (m.x1 - 1) == 0) + + robust_infeasible = reformulate_state_var_independent_eq_cons(model_data) + + # check constraint partitioning updated as expected + dr_dependent_equality_cons = wm.first_stage.dr_dependent_equality_cons + dr_independent_equality_cons = wm.first_stage.dr_independent_equality_cons + self.assertFalse(robust_infeasible) + self.assertFalse(wm.second_stage.equality_cons) + self.assertEqual(len(wm.second_stage.inequality_cons), 1) + self.assertEqual(len(wm.first_stage.dr_dependent_equality_cons), 2) + self.assertEqual(len(wm.first_stage.dr_independent_equality_cons), 2) + + self.assertTrue(dr_dependent_equality_cons["scenario_0_eq_con"].active) + self.assertTrue(dr_dependent_equality_cons["scenario_1_eq_con"].active) + self.assertTrue(dr_independent_equality_cons["scenario_0_eq_con_2"].active) + self.assertTrue(dr_independent_equality_cons["scenario_1_eq_con_2"].active) + + # expressions for the new opposing inequalities + # and coefficient matching constraint + dr_vars = list(wm.first_stage.decision_rule_vars[0].values()) + assertExpressionsEqual( + self, + wm.first_stage.dr_dependent_equality_cons["scenario_0_eq_con"].expr, + ( + 0 * SumExpression([dr_vars[0] + 0 * dr_vars[1], -1]) + + 0 * (m.x1**3 + 0.5) + - ((0 * m.u_cert * m.x1) * (dr_vars[0] + 0 * dr_vars[1])) + == (0 * (m.x1 + 2)) + ), + ) + assertExpressionsEqual( + self, + wm.first_stage.dr_dependent_equality_cons["scenario_1_eq_con"].expr, + ( + (0.7**2) * SumExpression([dr_vars[0] + 0.7 * dr_vars[1], -1]) + + 0.7 * (m.x1**3 + 0.5) + - ((5 * 0.7 * m.u_cert * m.x1) * (dr_vars[0] + 0.7 * dr_vars[1])) + == (-0.7 * (m.x1 + 2)) + ), + ) + assertExpressionsEqual( + self, + wm.first_stage.dr_independent_equality_cons["scenario_0_eq_con_2"].expr, + 0 * (m.x1 - 1) == 0, + ) + assertExpressionsEqual( + self, + wm.first_stage.dr_independent_equality_cons["scenario_1_eq_con_2"].expr, + 0.7 * (m.x1 - 1) == 0, + ) + + def test_coefficient_matching_robust_infeasible_proof(self): + """ + Test coefficient matching detects robust infeasibility + as expected. + """ + # Write the deterministic Pyomo model + model_data = self.setup_test_model_data() + m = model_data.working_model.user_model + model_data.working_model.first_stage.decision_rule_vars = [] + model_data.working_model.second_stage.equality_cons["eq_con"].set_value( + expr=m.u * (m.x1**3 + 0.5) + - 5 * m.u * m.x1 * m.x2 + + m.u * (m.x1 + 2) + + m.u**2 + == 0 + ) + ep = model_data.working_model.effective_var_partitioning + ep.first_stage_variables = [m.x1, m.x2] + ep.second_stage_variables = [] + + model_data.config.decision_rule_order = 1 + model_data.config.progress_logger = logger + + model_data.working_model.all_nonadjustable_variables = list( + ep.first_stage_variables + ) + + with LoggingIntercept(level=logging.INFO) as LOG: + robust_infeasible = reformulate_state_var_independent_eq_cons(model_data) + + self.assertTrue( + robust_infeasible, + msg="Coefficient matching should be proven robust infeasible.", + ) + robust_infeasible_msg = LOG.getvalue() + self.assertRegex( + text=robust_infeasible_msg, + expected_regex=( + r"PyROS has determined that the model is robust infeasible\. " + r"One reason for this.*equality constraint '.*eq_con.*'.*" + ), + ) + + +class TestPreprocessModelData(unittest.TestCase): + """ + Test the PyROS preprocessor. + """ + + def build_test_model_data(self): + """ + Build model data object for the preprocessor. + """ + m = ConcreteModel() + + # PARAMS: p uncertain, q certain + m.p = Param(initialize=2, mutable=True) + m.q = Param(initialize=4.5, mutable=True) + m.q_cert = Param(initialize=1, mutable=True) + + # first-stage variables + m.x1 = Var(bounds=(0, m.q), initialize=1) + m.x2 = Var(domain=NonNegativeReals, bounds=[m.p, m.p], initialize=m.p) + + # second-stage variables + m.z1 = Var(domain=RangeSet(2, 4, 0), bounds=[-m.p, m.q], initialize=2) + m.z2 = Var(bounds=(-2 * m.q**2, None), initialize=1) + m.z3 = Var(bounds=(-m.q, 0), initialize=0) + m.z4 = Var(initialize=5) + # the bounds produce an equality constraint + # that then leads to coefficient matching. + # problem is robust infeasible if DR static, else + # matching constraints are added + m.z5 = Var(domain=NonNegativeReals, bounds=(m.q, m.q)) + + # state variables + m.y1 = Var(domain=NonNegativeReals, initialize=0) + m.y2 = Var(initialize=10) + # note: y3 out-of-scope, as it will not appear in the active + # Objective and Constraint objects + m.y3 = Var(domain=RangeSet(0, 1, 0), bounds=(0.2, 0.5)) + + # fix some variables + m.z4.fix() + m.y2.fix() + + # Var representing uncertain parameter + m.q2var = Var(initialize=3.2) + + # named Expression in terms of uncertain parameter + # represented by a Var + m.q2expr = Expression(expr=m.q2var * 10) + + # EQUALITY CONSTRAINTS + # this will be reformulated by coefficient matching + m.eq1 = Constraint(expr=m.q * (m.z3 + m.x2 * m.q_cert) == 0) + # ranged constraints with identical bounds are considered equalities + # this makes z1 nonadjustable + m.eq2 = Constraint(expr=m.x1 - m.z1 == 0) + # if q_cert is not effectively uncertain, then pretriangular: + # makes z2 nonadjustable, so first-stage + m.eq3 = Constraint(expr=m.x1**2 + m.x2 * m.q_cert + m.p * m.z2 == m.p) + # second-stage equality + m.eq4 = Constraint(expr=m.z3 + m.y1 + 5 * m.q2var == m.q) + + # INEQUALITY CONSTRAINTS + # since x1, z1 nonadjustable, LB is first-stage, + # but UB second-stage due to uncertain param q + m.ineq1 = Constraint(expr=(-m.p, m.x1 + m.z1, exp(m.q))) + # two first-stage inequalities + m.ineq2 = Constraint(expr=(0, m.x1 + m.x2, 10 * m.q_cert)) + # though the bounds are structurally equal, they are not + # identical objects, so this constitutes + # two second-stage inequalities + # note: these inequalities redundant, + # as collectively these constraints + # are mathematically identical to eq4 + m.ineq3 = Constraint(expr=(2 * m.q, 2 * (m.z3 + m.y1), 2 * m.q)) + # second-stage inequality. trivially satisfied/infeasible, + # since y2 is fixed + m.ineq4 = Constraint(expr=-m.q <= m.y2**2 + log(m.y2)) + + # out of scope: deactivated + m.ineq5 = Constraint(expr=m.y3 <= m.q) + m.ineq5.deactivate() + + # ineq constraint in which the only uncertain parameter + # is represented by a Var. will be second-stage due + # to the presence of the uncertain parameter + m.ineq6 = Constraint(expr=-m.q2var <= m.x1) + + # OBJECTIVE + # contains a rich combination of first-stage and second-stage terms + m.obj = Objective( + expr=( + m.p**2 + + 2 * m.p * m.q + + log(m.x1) * m.q_cert + + 2 * m.p * m.x1 + + m.q**2 * m.x1 + + m.p**3 * (m.z1 + m.z2 + m.y1) + + m.z4 + + m.z5 + + m.q2expr + ) + ) + + model_data = ModelData( + original_model=m, + timing=None, + config=Bunch( + uncertainty_set=BoxSet([[4, 5], [3, 4], [1, 1]]), + uncertain_params=[m.q, m.q2var, m.q_cert], + nominal_uncertain_param_vals=[m.q.value, m.q2var.value, m.q_cert.value], + ), + ) + + # set up the var partitioning + user_var_partitioning = VariablePartitioning( + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[m.z1, m.z2, m.z3, m.z4, m.z5], + # note: y3 out of scope, so excluded + state_variables=[m.y1, m.y2], + ) + + return model_data, user_var_partitioning + + def test_preprocessor_effective_var_partitioning_static_dr(self): + """ + Test preprocessor repartitions the variables + as expected. + """ + # setup + model_data, user_var_partitioning = self.build_test_model_data() + config = model_data.config + config.update( + dict( + objective_focus=ObjectiveType.worst_case, + decision_rule_order=0, + progress_logger=logger, + separation_priority_order=dict(), + ) + ) + preprocess_model_data(model_data, user_var_partitioning) + ep = model_data.working_model.effective_var_partitioning + ublk = model_data.working_model.user_model + self.assertEqual( + ComponentSet(ep.first_stage_variables), + ComponentSet( + [ + # all second-stage variables are nonadjustable + # due to the DR + ublk.x1, + ublk.x2, + ublk.z1, + ublk.z2, + ublk.z3, + ublk.z4, + ublk.z5, + ublk.y2, + ] + ), + ) + self.assertEqual(ep.second_stage_variables, []) + self.assertEqual(ep.state_variables, [ublk.y1]) + + working_model = model_data.working_model + self.assertEqual( + ComponentSet(working_model.all_nonadjustable_variables), + ComponentSet( + [ublk.x1, ublk.x2, ublk.z1, ublk.z2, ublk.z3, ublk.z4, ublk.z5, ublk.y2] + + [working_model.first_stage.epigraph_var] + ), + ) + self.assertEqual( + ComponentSet(working_model.all_variables), + ComponentSet( + [ + ublk.x1, + ublk.x2, + ublk.z1, + ublk.z2, + ublk.z3, + ublk.z4, + ublk.z5, + ublk.y1, + ublk.y2, + ] + + [working_model.first_stage.epigraph_var] + ), + ) + + @parameterized.expand([["affine", 1], ["quadratic", 2]]) + def test_preprocessor_effective_var_partitioning_nonstatic_dr(self, name, dr_order): + """ + Test preprocessor repartitions the variables + as expected. + """ + model_data, user_var_partitioning = self.build_test_model_data() + config = model_data.config + config.update( + dict( + objective_focus=ObjectiveType.worst_case, + decision_rule_order=dr_order, + progress_logger=logger, + separation_priority_order=dict(), + ) + ) + preprocess_model_data(model_data, user_var_partitioning) + ep = model_data.working_model.effective_var_partitioning + ublk = model_data.working_model.user_model + self.assertEqual( + ComponentSet(ep.first_stage_variables), + ComponentSet([ublk.x1, ublk.x2, ublk.z1, ublk.z2, ublk.z4, ublk.y2]), + ) + self.assertEqual( + ComponentSet(ep.second_stage_variables), ComponentSet([ublk.z3, ublk.z5]) + ) + self.assertEqual(ComponentSet(ep.state_variables), ComponentSet([ublk.y1])) + working_model = model_data.working_model + self.assertEqual( + ComponentSet(working_model.all_nonadjustable_variables), + ComponentSet( + [ublk.x1, ublk.x2, ublk.z1, ublk.z2, ublk.z4, ublk.y2] + + [working_model.first_stage.epigraph_var] + + list(working_model.first_stage.decision_rule_var_0.values()) + + list(working_model.first_stage.decision_rule_var_1.values()) + ), + ) + self.assertEqual( + ComponentSet(working_model.all_variables), + ComponentSet( + [ + ublk.x1, + ublk.x2, + ublk.z1, + ublk.z2, + ublk.z3, + ublk.z4, + ublk.z5, + ublk.y1, + ublk.y2, + ] + + [working_model.first_stage.epigraph_var] + + list(working_model.first_stage.decision_rule_var_0.values()) + + list(working_model.first_stage.decision_rule_var_1.values()) + ), + ) + + @parameterized.expand( + [ + ["affine_nominal", 1, "nominal"], + ["affine_worst_case", 1, "worst_case"], + # eq1 doesn't get reformulated in coefficient matching + # as the polynomial degree is too high + ["quadratic_nominal", 2, "nominal"], + ["quadratic_worst_case", 2, "worst_case"], + ] + ) + def test_preprocessor_constraint_partitioning_nonstatic_dr( + self, name, dr_order, obj_focus + ): + """ + Test preprocessor partitions constraints as expected + for nonstatic DR. + """ + model_data, user_var_partitioning = self.build_test_model_data() + model_data.config.update( + dict( + objective_focus=ObjectiveType[obj_focus], + decision_rule_order=dr_order, + progress_logger=logger, + separation_priority_order=dict(ineq3=2), + ) + ) + preprocess_model_data(model_data, user_var_partitioning) + + working_model = model_data.working_model + ublk = working_model.user_model + + # list of expected coefficient matching constraint names + # equality bound constraint for z5 and/or eq1 are subject + # to reformulation + if dr_order == 1: + coeff_matching_con_names = [ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_0", + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_1", + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_2", + 'coeff_matching_eq_con_eq1_coeff_1', + 'coeff_matching_eq_con_eq1_coeff_2', + 'coeff_matching_eq_con_eq1_coeff_3', + ] + else: + coeff_matching_con_names = [ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_0", + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_1", + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_2", + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_3", + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_4", + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_5", + ] + + self.assertEqual( + list(working_model.first_stage.inequality_cons), + ( + ["ineq_con_ineq1_lower_bound_con", "ineq_con_ineq2"] + + (["epigraph_con"] if obj_focus == "nominal" else []) + ), + ) + self.assertEqual( + list(working_model.first_stage.dr_independent_equality_cons), + ["eq_con_eq2", "eq_con_eq3"], + ) + self.assertEqual( + list(working_model.first_stage.dr_dependent_equality_cons), + coeff_matching_con_names, + ) + self.assertEqual( + list(working_model.second_stage.inequality_cons), + ( + [ + "var_x1_uncertain_upper_bound_con", + "var_z1_uncertain_upper_bound_con", + "var_z2_uncertain_lower_bound_con", + "var_z3_certain_upper_bound_con", + "var_z3_uncertain_lower_bound_con", + "var_z5_certain_lower_bound_con", + "var_y1_certain_lower_bound_con", + "ineq_con_ineq1_upper_bound_con", + "ineq_con_ineq3_lower_bound_con", + "ineq_con_ineq3_upper_bound_con", + "ineq_con_ineq4_lower_bound_con", + "ineq_con_ineq6_lower_bound_con", + ] + + (["epigraph_con"] if obj_focus == "worst_case" else []) + + ( + # for quadratic DR, + # eq1 gets reformulated to two inequality constraints + # since it is state variable independent and + # too nonlinear for coefficient matching + [ + "reform_lower_bound_from_eq_con_eq1", + "reform_upper_bound_from_eq_con_eq1", + ] + if dr_order == 2 + else [] + ) + ), + ) + self.assertEqual( + list(working_model.second_stage.equality_cons), + # eq1 doesn't get reformulated in coefficient matching + # when DR order is 2 as the polynomial degree is too high + ["eq_con_eq4"], + ) + + # verify the constraints are active + for fs_eq_con in get_all_first_stage_eq_cons(working_model): + self.assertTrue(fs_eq_con.active, msg=f"{fs_eq_con.name} inactive") + for fs_ineq_con in working_model.first_stage.inequality_cons.values(): + self.assertTrue(fs_ineq_con.active, msg=f"{fs_ineq_con.name} inactive") + for perf_eq_con in working_model.second_stage.equality_cons.values(): + self.assertTrue(perf_eq_con.active, msg=f"{perf_eq_con.name} inactive") + for perf_ineq_con in working_model.second_stage.inequality_cons.values(): + self.assertTrue(perf_ineq_con.active, msg=f"{perf_ineq_con.name} inactive") + + # verify the constraint expressions + m = ublk + fs = working_model.first_stage + ss = working_model.second_stage + assertExpressionsEqual(self, m.x1.lower, 0) + assertExpressionsEqual( + self, + ss.inequality_cons["var_x1_uncertain_upper_bound_con"].expr, + m.x1 <= m.q, + ) + + assertExpressionsEqual( + self, + ss.inequality_cons["var_z1_uncertain_upper_bound_con"].expr, + m.z1 <= m.q, + ) + assertExpressionsEqual( + self, + ss.inequality_cons["var_z2_uncertain_lower_bound_con"].expr, + -m.z2 <= -(-2 * m.q**2), + ) + assertExpressionsEqual( + self, + ss.inequality_cons["var_z3_uncertain_lower_bound_con"].expr, + -m.z3 <= -(-m.q), + ) + assertExpressionsEqual( + self, ss.inequality_cons["var_z3_certain_upper_bound_con"].expr, m.z3 <= 0 + ) + assertExpressionsEqual( + self, ss.inequality_cons["var_z5_certain_lower_bound_con"].expr, -m.z5 <= 0 + ) + assertExpressionsEqual( + self, ss.inequality_cons["var_y1_certain_lower_bound_con"].expr, -m.y1 <= 0 + ) + assertExpressionsEqual( + self, + fs.inequality_cons["ineq_con_ineq1_lower_bound_con"].expr, + -m.p <= m.x1 + m.z1, + ) + assertExpressionsEqual( + self, + ss.inequality_cons["ineq_con_ineq1_upper_bound_con"].expr, + m.x1 + m.z1 <= exp(m.q), + ) + assertExpressionsEqual( + self, + fs.inequality_cons["ineq_con_ineq2"].expr, + RangedExpression((0, m.x1 + m.x2, 10 * m.q_cert), False), + ) + assertExpressionsEqual( + self, + ss.inequality_cons["ineq_con_ineq3_lower_bound_con"].expr, + -(2 * (m.z3 + m.y1)) <= -(2 * m.q), + ) + assertExpressionsEqual( + self, + ss.inequality_cons["ineq_con_ineq3_upper_bound_con"].expr, + 2 * (m.z3 + m.y1) <= 2 * m.q, + ) + assertExpressionsEqual( + self, + ss.inequality_cons["ineq_con_ineq4_lower_bound_con"].expr, + -(m.y2**2 + log(m.y2)) <= -(-m.q), + ) + self.assertFalse(m.ineq5.active) + assertExpressionsEqual( + self, + ss.inequality_cons["ineq_con_ineq6_lower_bound_con"].expr, + -m.x1 <= -(-1 * working_model.temp_uncertain_params[1]), + ) + + assertExpressionsEqual( + self, fs.dr_independent_equality_cons["eq_con_eq2"].expr, m.x1 - m.z1 == 0 + ) + assertExpressionsEqual( + self, + fs.dr_independent_equality_cons["eq_con_eq3"].expr, + m.x1**2 + m.x2 * m.q_cert + m.p * m.z2 == m.p, + ) + if dr_order < 2: + # due to coefficient matching, this should have been deleted + self.assertNotIn("eq_con_eq1", ss.equality_cons) + + # user model block should have no active constraints + self.assertFalse(list(m.component_data_objects(Constraint, active=True))) + + # check separation priorities + for con_name, order in model_data.separation_priority_order.items(): + expected_order = 2 if "ineq3" in con_name else 0 + self.assertEqual( + order, + expected_order, + msg=( + "Separation priority order for second-stage inequality " + f"{con_name!r} not as expected." + ), + ) + + @parameterized.expand( + [["static", 0, True], ["affine", 1, False], ["quadratic", 2, False]] + ) + def test_preprocessor_coefficient_matching( + self, name, dr_order, expected_robust_infeas + ): + """ + Check preprocessor robust infeasibility return status. + """ + model_data, user_var_partitioning = self.build_test_model_data() + config = model_data.config + config.update( + dict( + objective_focus=ObjectiveType.worst_case, + decision_rule_order=dr_order, + progress_logger=logger, + separation_priority_order=dict(), + ) + ) + + # for static DR, problem should be robust infeasible + # due to the coefficient matching constraints derived + # from bounds on z5 + robust_infeasible = preprocess_model_data(model_data, user_var_partitioning) + self.assertIsInstance(robust_infeasible, bool) + self.assertEqual(robust_infeasible, expected_robust_infeas) + + # check the coefficient matching constraint expressions + working_model = model_data.working_model + m = model_data.working_model.user_model + fs = working_model.first_stage + dr_dependent_fs_eqs = working_model.first_stage.dr_dependent_equality_cons + ss_ineqs = working_model.second_stage.inequality_cons + if config.decision_rule_order == 1: + # check the constraint expressions of eq1 and z5 bound + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_0" + ].expr, + fs.decision_rule_vars[1][0] == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_1" + ].expr, + fs.decision_rule_vars[1][1] - 1 == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_2" + ].expr, + fs.decision_rule_vars[1][2] == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs["coeff_matching_eq_con_eq1_coeff_1"].expr, + # note: the certain parameter was eliminated by + # the expression parser + fs.decision_rule_vars[0][0] + m.x2 == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs["coeff_matching_eq_con_eq1_coeff_2"].expr, + fs.decision_rule_vars[0][1] == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs["coeff_matching_eq_con_eq1_coeff_3"].expr, + fs.decision_rule_vars[0][2] == 0, + ) + if config.decision_rule_order == 2: + # eq1 should be deactivated and refomulated to 2 inequalities + assertExpressionsEqual( + self, + ss_ineqs["reform_lower_bound_from_eq_con_eq1"].expr, + -(m.q * (m.z3 + m.x2 * m.q_cert)) <= 0.0, + ) + assertExpressionsEqual( + self, + ss_ineqs["reform_upper_bound_from_eq_con_eq1"].expr, + m.q * (m.z3 + m.x2 * m.q_cert) <= 0.0, + ) + + # check coefficient matching constraint expressions + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_0" + ].expr, + fs.decision_rule_vars[1][0] == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_1" + ].expr, + fs.decision_rule_vars[1][1] - 1 == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_2" + ].expr, + fs.decision_rule_vars[1][2] == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_3" + ].expr, + fs.decision_rule_vars[1][3] == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_4" + ].expr, + fs.decision_rule_vars[1][4] == 0, + ) + assertExpressionsEqual( + self, + dr_dependent_fs_eqs[ + "coeff_matching_var_z5_uncertain_eq_bound_con_coeff_5" + ].expr, + fs.decision_rule_vars[1][5] == 0, + ) + + @parameterized.expand([["static", 0], ["affine", 1], ["quadratic", 2]]) + def test_preprocessor_objective_standardization(self, name, dr_order): + """ + Test preprocessor standardizes the active objective as + expected. + """ + model_data, user_var_partitioning = self.build_test_model_data() + config = model_data.config + config.update( + dict( + objective_focus=ObjectiveType.worst_case, + decision_rule_order=dr_order, + progress_logger=logger, + separation_priority_order=dict(), + ) + ) + preprocess_model_data(model_data, user_var_partitioning) + + ublk = model_data.working_model.user_model + working_model = model_data.working_model + + assertExpressionsEqual( + self, + working_model.second_stage.inequality_cons["epigraph_con"].expr, + ublk.obj.expr - working_model.first_stage.epigraph_var <= 0, + ) + assertExpressionsEqual(self, working_model.full_objective.expr, ublk.obj.expr) + + # recall: objective summands are classified according + # to dependence on uncertain parameters and variables + # the *user* considers adjustable, + # so the summands should be independent of the DR order + # (which itself affects the effective var partitioning) + assertExpressionsEqual( + self, + working_model.first_stage_objective.expr, + ublk.p**2 + log(ublk.x1) * ublk.q_cert + 2 * ublk.p * ublk.x1, + ) + assertExpressionsEqual( + self, + working_model.second_stage_objective.expr, + ( + 2 * ublk.p * ublk.q + + ublk.q**2 * ublk.x1 + + ublk.p**3 * (ublk.z1 + ublk.z2 + ublk.y1) + + ublk.z4 + + ublk.z5 + + ublk.q2expr + ), + ) + + @parameterized.expand([["nominal"], ["worst_case"]]) + def test_preprocessor_log_model_statistics_affine_dr(self, obj_focus): + """ + Test statistics of the preprocessed working model are + logged as expected. + """ + model_data, user_var_partitioning = self.build_test_model_data() + config = model_data.config + config.update( + dict( + objective_focus=ObjectiveType[obj_focus], + decision_rule_order=1, + progress_logger=logger, + separation_priority_order=dict(), + ) + ) + preprocess_model_data(model_data, user_var_partitioning) + + # expected model stats worked out by hand + expected_log_str = textwrap.dedent( + f""" + Model Statistics: + Number of variables : 16 + Epigraph variable : 1 + First-stage variables : 2 + Second-stage variables : 5 (2 adj.) + State variables : 2 (1 adj.) + Decision rule variables : 6 + Number of uncertain parameters : 3 (2 eff.) + Number of constraints : 26 + Equality constraints : 11 + Coefficient matching constraints : 6 + Other first-stage equations : 2 + Second-stage equations : 1 + Decision rule equations : 2 + Inequality constraints : 15 + First-stage inequalities : {3 if obj_focus == 'nominal' else 2} + Second-stage inequalities : {12 if obj_focus == 'nominal' else 13} + """ + ) + + with LoggingIntercept(level=logging.INFO) as LOG: + log_model_statistics(model_data) + log_str = LOG.getvalue() + + log_lines = log_str.splitlines()[1:] + expected_log_lines = expected_log_str.splitlines()[1:] + + self.assertEqual(len(log_lines), len(expected_log_lines)) + for line, expected_line in zip(log_lines, expected_log_lines): + self.assertEqual(line, expected_line) + + @parameterized.expand([["nominal"], ["worst_case"]]) + def test_preprocessor_log_model_statistics_quadratic_dr(self, obj_focus): + """ + Test statistics of the preprocessed working model are + logged as expected. + """ + model_data, user_var_partitioning = self.build_test_model_data() + config = model_data.config + config.update( + dict( + objective_focus=ObjectiveType[obj_focus], + decision_rule_order=2, + progress_logger=logger, + separation_priority_order=dict(), + ) + ) + preprocess_model_data(model_data, user_var_partitioning) + + # expected model stats worked out by hand + expected_log_str = textwrap.dedent( + f""" + Model Statistics: + Number of variables : 22 + Epigraph variable : 1 + First-stage variables : 2 + Second-stage variables : 5 (2 adj.) + State variables : 2 (1 adj.) + Decision rule variables : 12 + Number of uncertain parameters : 3 (2 eff.) + Number of constraints : 28 + Equality constraints : 11 + Coefficient matching constraints : 6 + Other first-stage equations : 2 + Second-stage equations : 1 + Decision rule equations : 2 + Inequality constraints : 17 + First-stage inequalities : {3 if obj_focus == 'nominal' else 2} + Second-stage inequalities : {14 if obj_focus == 'nominal' else 15} + """ + ) + + with LoggingIntercept(level=logging.INFO) as LOG: + log_model_statistics(model_data) + log_str = LOG.getvalue() + + log_lines = log_str.splitlines()[1:] + expected_log_lines = expected_log_str.splitlines()[1:] + + self.assertEqual(len(log_lines), len(expected_log_lines)) + for line, expected_line in zip(log_lines, expected_log_lines): + self.assertEqual(line, expected_line) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/pyros/tests/test_separation.py b/pyomo/contrib/pyros/tests/test_separation.py new file mode 100644 index 00000000000..14fcb6b9478 --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_separation.py @@ -0,0 +1,663 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +""" +Test separation problem construction methods. +""" + + +import logging +import pyomo.common.unittest as unittest +from pyomo.common.log import LoggingIntercept + +from pyomo.common.collections import Bunch +from pyomo.common.dependencies import numpy as np, numpy_available, scipy_available +from pyomo.core.base import ConcreteModel, Constraint, Objective, Param, Var +from pyomo.core.expr import exp, RangedExpression, value +from pyomo.core.expr.compare import assertExpressionsEqual + +from pyomo.contrib.pyros.master_problem_methods import ( + MasterProblemData, + add_scenario_block_to_master_problem, +) +from pyomo.contrib.pyros.separation_problem_methods import ( + construct_separation_problem, + group_ss_ineq_constraints_by_priority, + SeparationProblemData, + initialize_separation, +) +from pyomo.contrib.pyros.uncertainty_sets import ( + BoxSet, + FactorModelSet, + DiscreteScenarioSet, +) +from pyomo.contrib.pyros.util import ( + ModelData, + preprocess_model_data, + ObjectiveType, + VariablePartitioning, +) + + +if not (numpy_available and scipy_available): + raise unittest.SkipTest("Packages numpy and scipy must both be available.") + + +logger = logging.getLogger(__name__) + + +def build_simple_model_data(objective_focus="worst_case", uncertainty_set=None): + """ + Build simple model data object for master problem construction. + """ + m = ConcreteModel() + m.u = Param(initialize=0.5, mutable=True) + m.u2 = Param(initialize=0, mutable=True) + m.x1 = Var(bounds=[-1000, 1000]) + m.x2 = Var(bounds=[-1000, 1000]) + m.x3 = Var(bounds=[-1000, 1000]) + m.con = Constraint(expr=exp(m.u - 1) - m.x1 - m.x2 * m.u - m.x3 * m.u**2 <= 0) + + # this makes x2 nonadjustable + m.eq_con = Constraint(expr=m.x2 - 1 == 0) + + m.obj = Objective(expr=m.x1 + m.x2 / 2 + m.x3 / 3 + m.u + m.u2) + + if uncertainty_set is None: + uncertainty_set = BoxSet([[0, 1], [0, 0]]) + + config = Bunch( + uncertain_params=[m.u, m.u2], + objective_focus=ObjectiveType[objective_focus], + decision_rule_order=1, + progress_logger=logger, + nominal_uncertain_param_vals=[0.5, 0], + uncertainty_set=uncertainty_set, + separation_priority_order=dict(con=2), + ) + model_data = ModelData(original_model=m, timing=None, config=config) + user_var_partitioning = VariablePartitioning( + first_stage_variables=[m.x1], + second_stage_variables=[m.x2, m.x3], + state_variables=[], + ) + + preprocess_model_data(model_data, user_var_partitioning) + + return model_data + + +class TestConstructSeparationProblem(unittest.TestCase): + """ + Test method for construction of separation problem. + """ + + def test_construct_separation_problem_nonadj_components(self): + """ + Check first-stage variables and constraints of the + separation problem are fixed and deactivated, + respectively. + """ + model_data = build_simple_model_data(objective_focus="worst_case") + separation_model = construct_separation_problem(model_data) + + # check nonadjustable components fixed/deactivated + self.assertTrue(separation_model.user_model.x1.fixed) + self.assertTrue(separation_model.first_stage.epigraph_var.fixed) + for indexed_var in separation_model.first_stage.decision_rule_vars: + for dr_var in indexed_var.values(): + self.assertTrue(dr_var.fixed, msg=f"DR var {dr_var.name!r} not fixed") + + # first-stage equality constraints should be inactive + self.assertFalse(separation_model.user_model.eq_con.active) + for coeff_con in separation_model.first_stage.coefficient_matching_cons: + self.assertFalse( + coeff_con.active, + msg=f"Coefficient matching constraint {coeff_con.name!r} active.", + ) + + def test_construct_separation_problem_ss_ineq_cons(self): + """ + Check second-stage inequality constraints are deactivated + and replaced with objectives, as appropriate. + """ + model_data = build_simple_model_data(objective_focus="worst_case") + separation_model = construct_separation_problem(model_data) + + # check expression of second-stage ineq cons correct + # check these individually + # (i.e. uncertain params have been replaced) + m = separation_model.user_model + u1_var = separation_model.uncertainty.uncertain_param_var_list[0] + u2_var = separation_model.uncertainty.uncertain_param_var_list[1] + assertExpressionsEqual( + self, + separation_model.second_stage.inequality_cons["epigraph_con"].expr, + ( + m.x1 + + m.x2 / 2 + + m.x3 / 3 + + u1_var + + u2_var + - separation_model.first_stage.epigraph_var + <= 0 + ), + ) + + self.assertFalse( + separation_model.second_stage.inequality_cons["epigraph_con"].active + ) + self.assertFalse( + m.con.active, + separation_model.second_stage.inequality_cons[ + "ineq_con_con_upper_bound_con" + ].active, + ) + self.assertFalse( + m.con.active, + separation_model.second_stage.inequality_cons[ + "var_x3_certain_lower_bound_con" + ].active, + ) + self.assertFalse( + m.con.active, + separation_model.second_stage.inequality_cons[ + "var_x3_certain_upper_bound_con" + ].active, + ) + + # check second-stage ineq con expressions match obj expressions + # (loop through the con to obj map) + self.assertEqual( + len(separation_model.second_stage_ineq_con_to_obj_map), + len(separation_model.second_stage.inequality_cons), + ) + for ineq_con, obj in separation_model.second_stage_ineq_con_to_obj_map.items(): + assertExpressionsEqual(self, ineq_con.body - ineq_con.upper, obj.expr) + + def test_construct_separation_problem_ss_eq_and_dr_cons(self): + """ + Check second-stage and DR equations are appropriately handled + by the separation problems. + """ + # check DR equation is active + model_data = build_simple_model_data(objective_focus="worst_case") + separation_model = construct_separation_problem(model_data) + + self.assertTrue(separation_model.second_stage.decision_rule_eqns[0].active) + + u1_var = separation_model.uncertainty.uncertain_param_var_list[0] + assertExpressionsEqual( + self, + separation_model.second_stage.decision_rule_eqns[0].expr, + ( + separation_model.first_stage.decision_rule_vars[0][0] + # only one effective uncertain parameter, + # so only one affine DR term + + u1_var * separation_model.first_stage.decision_rule_vars[0][1] + - separation_model.user_model.x3 + == 0 + ), + ) + + def test_construct_separation_problem_uncertainty_components(self): + """ + Test separation problem handles uncertain parameter variable + components as expected. + """ + model_data = build_simple_model_data(objective_focus="worst_case") + separation_model = construct_separation_problem(model_data) + uncertainty_blk = separation_model.uncertainty + boxcon1, boxcon2 = uncertainty_blk.uncertainty_cons_list + paramvar1, paramvar2 = uncertainty_blk.uncertain_param_var_list + + self.assertEqual(uncertainty_blk.auxiliary_var_list, []) + self.assertEqual(len(uncertainty_blk.uncertainty_cons_list), 2) + assertExpressionsEqual( + self, + boxcon1.expr, + RangedExpression((np.int_(0), paramvar1, np.int_(1)), False), + ) + assertExpressionsEqual( + self, + boxcon2.expr, + RangedExpression((np.int_(0), paramvar2, np.int_(0)), False), + ) + self.assertTrue(boxcon1.active) + # constraints in fixed uncertain params should be deactivated + self.assertFalse(boxcon2.active) + + # u, bounds [0, 1] + self.assertFalse(paramvar1.fixed) + # bounds [0, 0]; separation constructor should fix the Var + self.assertTrue(paramvar2.fixed) + + self.assertEqual(paramvar1.bounds, (0, 1)) + self.assertEqual(paramvar2.bounds, (0, 0)) + + def test_construct_separation_problem_uncertain_factor_param_components(self): + """ + Test separation problem uncertainty components for uncertainty + set requiring auxiliary variables. + """ + model_data = build_simple_model_data( + objective_focus="worst_case", + uncertainty_set=FactorModelSet( + origin=[1, 0], beta=1, number_of_factors=2, psi_mat=[[1, 2.5], [0, 1]] + ), + ) + separation_model = construct_separation_problem(model_data) + uncertainty_blk = separation_model.uncertainty + *matrix_product_cons, aux_sum_con = uncertainty_blk.uncertainty_cons_list + paramvar1, paramvar2 = uncertainty_blk.uncertain_param_var_list + auxvar1, auxvar2 = uncertainty_blk.auxiliary_var_list + + self.assertEqual(len(matrix_product_cons), 2) + self.assertTrue(matrix_product_cons[0].active) + self.assertTrue(matrix_product_cons[1].active) + self.assertTrue(aux_sum_con.active) + assertExpressionsEqual( + self, aux_sum_con.expr, RangedExpression((-2, auxvar1 + auxvar2, 2), False) + ) + assertExpressionsEqual( + self, matrix_product_cons[0].expr, auxvar1 + 2.5 * auxvar2 + 1 == paramvar1 + ) + assertExpressionsEqual( + self, matrix_product_cons[1].expr, 0.0 * auxvar1 + auxvar2 == paramvar2 + ) + + # none of the vars should be fixed + self.assertFalse(paramvar1.fixed) + self.assertFalse(paramvar2.fixed) + self.assertFalse(auxvar1.fixed) + self.assertFalse(auxvar2.fixed) + + # factor set auxiliary variables + self.assertEqual(auxvar1.bounds, (-1, 1)) + self.assertEqual(auxvar2.bounds, (-1, 1)) + + # factor set bounds are tighter + self.assertEqual(paramvar1.bounds, (-2.5, 4.5)) + self.assertEqual(paramvar2.bounds, (-1.0, 1.0)) + + # check decision rule equation + assertExpressionsEqual( + self, + separation_model.second_stage.decision_rule_eqns[0].expr, + ( + separation_model.first_stage.decision_rule_vars[0][0] + + paramvar1 * separation_model.first_stage.decision_rule_vars[0][1] + + paramvar2 * separation_model.first_stage.decision_rule_vars[0][2] + - separation_model.user_model.x3 + == 0 + ), + ) + + +class TestGroupSecondStageIneqConsByPriority(unittest.TestCase): + def test_group_ss_ineq_constraints_by_priority(self): + model_data = build_simple_model_data() + separation_model = construct_separation_problem(model_data) + + # build mock separation data-like object + # since we are testing only the grouping method + separation_data = Bunch( + separation_model=separation_model, + separation_priority_order=model_data.separation_priority_order, + ) + + priority_groups = group_ss_ineq_constraints_by_priority(separation_data) + + self.assertEqual(list(priority_groups.keys()), [2, 0]) + ss_ineq_cons = separation_model.second_stage.inequality_cons + self.assertEqual( + priority_groups[2], [ss_ineq_cons["ineq_con_con_upper_bound_con"]] + ) + self.assertEqual( + priority_groups[0], + [ + ss_ineq_cons["var_x3_certain_lower_bound_con"], + ss_ineq_cons["var_x3_certain_upper_bound_con"], + ss_ineq_cons["epigraph_con"], + ], + ) + + +class TestInitializeSeparation(unittest.TestCase): + """ + Tests for separation subproblem initialization. + """ + + def test_initialize_separation(self): + model_data = build_simple_model_data( + objective_focus="worst_case", + uncertainty_set=FactorModelSet( + # note: origin is chosen to be different + # from the nominal value so that + # initialization of auxiliary uncertain + # parameters is meaningfully tested + origin=[1, 0.5], + psi_mat=[[1, 1], [1, 0]], + beta=0.5, + number_of_factors=2, + ), + ) + + master_data = MasterProblemData(model_data) + nom_scenario_blk = master_data.master_model.scenarios[0, 0] + nom_scenario_blk.user_model.x1.set_value(10) + nom_scenario_blk.user_model.x2.set_value(1) + nom_scenario_blk.user_model.x3.set_value(5) + nom_scenario_blk.first_stage.decision_rule_vars[0][0].set_value(5) + nom_scenario_blk.first_stage.epigraph_var.set_value( + value(nom_scenario_blk.full_objective) + ) + + # set up new scenario block + new_master_param_realization = [1.5, 1] + add_scenario_block_to_master_problem( + master_model=master_data.master_model, + scenario_idx=(1, 0), + param_realization=new_master_param_realization, + from_block=nom_scenario_blk, + clone_first_stage_components=False, + ) + new_scenario_blk = master_data.master_model.scenarios[1, 0] + new_scenario_blk.first_stage.epigraph_var.set_value( + # objective for new block is higher, + # so update the epigraph variable + value(new_scenario_blk.full_objective) + ) + # different value for the adjustable variable + # so we also adjust the DR variables + # to ensure the DR equations are satisfied + new_scenario_blk.user_model.x3.set_value(6) + new_scenario_blk.first_stage.decision_rule_vars[0][0].set_value(4.5) + new_scenario_blk.first_stage.decision_rule_vars[0][1].set_value(1) + + separation_data = SeparationProblemData(model_data) + + ss_ineq_con_to_maximize = ( + separation_data.separation_model.second_stage.inequality_cons[ + "epigraph_con" + ] + ) + separation_data.points_added_to_master[1, 0] = new_master_param_realization + separation_data.auxiliary_values_for_master_points[(1, 0)] = ( + model_data.config.uncertainty_set.compute_auxiliary_uncertain_param_vals( + point=new_master_param_realization, solver=None + ) + ) + + with LoggingIntercept(module=__name__, level=logging.DEBUG) as LOG: + initialize_separation( + ss_ineq_con_to_maximize=ss_ineq_con_to_maximize, + separation_data=separation_data, + master_data=master_data, + ) + + # the constraint violation being maximized for + # has a higher value in the newer master block + init_from_scenario_blk = new_scenario_blk + + # all active constraints of the separation problem + # should be satisfied post initialization, so expect + # no logging messages stating otherwise + log_output = LOG.getvalue() + self.assertFalse(log_output, "DEBUG-level log output should be empty.") + + sep_usr_blk = separation_data.separation_model.user_model + sep_model = separation_data.separation_model + + # first-stage variables added by PyROS + self.assertEqual( + value(sep_model.first_stage.epigraph_var), + value(init_from_scenario_blk.first_stage.epigraph_var), + ) + self.assertEqual( + value(sep_model.first_stage.decision_rule_vars[0][0]), + value(init_from_scenario_blk.first_stage.decision_rule_vars[0][0]), + ) + self.assertEqual( + value(sep_model.first_stage.decision_rule_vars[0][1]), + value(init_from_scenario_blk.first_stage.decision_rule_vars[0][1]), + ) + self.assertEqual( + value(sep_model.first_stage.decision_rule_vars[0][2]), + value(init_from_scenario_blk.first_stage.decision_rule_vars[0][2]), + ) + + # variables of original user model + self.assertEqual( + value(sep_usr_blk.x1), value(init_from_scenario_blk.user_model.x1) + ) + self.assertEqual( + value(sep_usr_blk.x2), value(init_from_scenario_blk.user_model.x2) + ) + self.assertEqual( + value(sep_usr_blk.x3), value(init_from_scenario_blk.user_model.x3) + ) + + # main uncertain parameter variables + self.assertEqual( + value(sep_model.uncertainty.uncertain_param_indexed_var[0]), + value(init_from_scenario_blk.uncertain_params[0]), + ) + self.assertEqual( + value(sep_model.uncertainty.uncertain_param_indexed_var[1]), + value(init_from_scenario_blk.uncertain_params[1]), + ) + + # auxiliary uncertain parameter variables + expected_aux_var_vals = separation_data.auxiliary_values_for_master_points[ + (1, 0) + ] + self.assertEqual( + value(sep_model.uncertainty.auxiliary_var_list[0]), expected_aux_var_vals[0] + ) + self.assertEqual( + value(sep_model.uncertainty.auxiliary_var_list[1]), expected_aux_var_vals[1] + ) + + def test_initialize_separation_infeasibility_logging(self): + """ + Test initialization of a separation problem for which + one of the active separation model constraints is + not satisfied. + """ + model_data = build_simple_model_data( + objective_focus="worst_case", + uncertainty_set=FactorModelSet( + # note: origin is chosen to be different + # from the nominal value so that + # initialization of auxiliary uncertain + # parameters is meaningfully tested + origin=[1, 0.5], + psi_mat=[[1, 1], [1, 0]], + beta=0.5, + number_of_factors=2, + ), + ) + + master_data = MasterProblemData(model_data) + nom_scenario_blk = master_data.master_model.scenarios[0, 0] + nom_scenario_blk.user_model.x1.set_value(10) + nom_scenario_blk.user_model.x2.set_value(1) + + # this results in a violation of the DR equality constraint + nom_scenario_blk.user_model.x3.set_value(5 + 1.5e-5) + nom_scenario_blk.first_stage.decision_rule_vars[0][0].set_value(5) + + nom_scenario_blk.first_stage.epigraph_var.set_value( + value(nom_scenario_blk.full_objective) + ) + + separation_data = SeparationProblemData(model_data) + ss_ineq_con_to_maximize = ( + separation_data.separation_model.second_stage.inequality_cons[ + "epigraph_con" + ] + ) + with LoggingIntercept(module=__name__, level=logging.DEBUG) as LOG: + initialize_separation( + ss_ineq_con_to_maximize=ss_ineq_con_to_maximize, + separation_data=separation_data, + master_data=master_data, + ) + log_output = LOG.getvalue() + log_output_lines = log_output.split("\n")[:-1] + self.assertEqual( + len(log_output_lines), + 1, + "Expected DEBUG-level output for separation problem initialization " + "test to have exactly 1 line.", + ) + self.assertRegex( + log_output, + r"Initial point for separation of .*violates the model constraint " + r"'second_stage.decision_rule_eqns\[0\].*' by more than.*", + ) + + def test_initialize_separation_trivially_infeas_uncertainty(self): + """ + Test error raised by separation problem initialization routine + if the routine finds violated constraints depending + only on fixed uncertain parameters. + """ + model_data = build_simple_model_data( + objective_focus="worst_case", + # notice: the bounds mean the first uncertain + # parameter will be fixed + uncertainty_set=BoxSet(bounds=[[0.5, 0.5], [0, 1]]), + ) + + master_data = MasterProblemData(model_data) + nom_scenario_blk = master_data.master_model.scenarios[0, 0] + nom_scenario_blk.user_model.x1.set_value(10) + nom_scenario_blk.user_model.x2.set_value(1) + nom_scenario_blk.user_model.x3.set_value(5) + nom_scenario_blk.first_stage.decision_rule_vars[0][0].set_value(5) + nom_scenario_blk.first_stage.epigraph_var.set_value( + value(nom_scenario_blk.full_objective) + ) + + separation_data = SeparationProblemData(model_data) + # notice: the artificial point is such that the box constraint + # in the effectively certain parameter will be violated; + # this should trigger an exception + separation_data.points_added_to_master[(0, 0)] = (1.5, 0) + ss_ineq_con_to_maximize = ( + separation_data.separation_model.second_stage.inequality_cons[ + "epigraph_con" + ] + ) + exc_str = "Trivial infeasibility detected in the.*BoxSet.*constraints" + with self.assertRaisesRegex(ValueError, exc_str): + initialize_separation( + ss_ineq_con_to_maximize=ss_ineq_con_to_maximize, + separation_data=separation_data, + master_data=master_data, + ) + + def test_initialize_separation_discrete_uncertainty(self): + """ + Test initialization of a separation problem + with a discrete uncertainty set. + """ + model_data = build_simple_model_data( + objective_focus="worst_case", + uncertainty_set=DiscreteScenarioSet(scenarios=[[0.5, 0], [1, 0.5]]), + ) + + master_data = MasterProblemData(model_data) + nom_scenario_blk = master_data.master_model.scenarios[0, 0] + nom_scenario_blk.user_model.x1.set_value(10) + nom_scenario_blk.user_model.x2.set_value(1) + + # this results in a violation of the DR equality constraint, + # which is not logged, since the uncertainty set is discrete + nom_scenario_blk.user_model.x3.set_value(5 + 1.1e-5) + nom_scenario_blk.first_stage.decision_rule_vars[0][0].set_value(5) + + nom_scenario_blk.first_stage.epigraph_var.set_value( + value(nom_scenario_blk.full_objective) + ) + + separation_data = SeparationProblemData(model_data) + ss_ineq_con_to_maximize = ( + separation_data.separation_model.second_stage.inequality_cons[ + "epigraph_con" + ] + ) + + sep_usr_blk = separation_data.separation_model.user_model + sep_model = separation_data.separation_model + + # fix uncertain parameters to off-nominal value; + # these should not be modified by the initialization + off_nominal_scenario = model_data.config.uncertainty_set.scenarios[1] + sep_model.uncertainty.uncertain_param_var_list[0].set_value( + off_nominal_scenario[0] + ) + sep_model.uncertainty.uncertain_param_var_list[1].set_value( + off_nominal_scenario[1] + ) + sep_model.uncertainty.uncertain_param_var_list[0].fix() + sep_model.uncertainty.uncertain_param_var_list[1].fix() + + with LoggingIntercept(module=__name__, level=logging.DEBUG) as LOG: + initialize_separation( + ss_ineq_con_to_maximize=ss_ineq_con_to_maximize, + separation_data=separation_data, + master_data=master_data, + ) + log_output = LOG.getvalue() + self.assertFalse(log_output, "DEBUG-level log output should be empty.") + + # first-stage variables added by PyROS + self.assertEqual( + value(sep_model.first_stage.epigraph_var), + value(nom_scenario_blk.first_stage.epigraph_var), + ) + self.assertEqual( + value(sep_model.first_stage.decision_rule_vars[0][0]), + value(nom_scenario_blk.first_stage.decision_rule_vars[0][0]), + ) + self.assertEqual( + value(sep_model.first_stage.decision_rule_vars[0][1]), + value(nom_scenario_blk.first_stage.decision_rule_vars[0][1]), + ) + self.assertEqual( + value(sep_model.first_stage.decision_rule_vars[0][2]), + value(nom_scenario_blk.first_stage.decision_rule_vars[0][2]), + ) + + # variables of original user model + self.assertEqual(value(sep_usr_blk.x1), value(nom_scenario_blk.user_model.x1)) + self.assertEqual(value(sep_usr_blk.x2), value(nom_scenario_blk.user_model.x2)) + self.assertEqual(value(sep_usr_blk.x3), value(nom_scenario_blk.user_model.x3)) + + # uncertain parameter variable state should not have been + # modified by the initialization + self.assertEqual( + value(sep_model.uncertainty.uncertain_param_indexed_var[0]), + off_nominal_scenario[0], + ) + self.assertEqual( + value(sep_model.uncertainty.uncertain_param_indexed_var[1]), + off_nominal_scenario[1], + ) + self.assertTrue(sep_model.uncertainty.uncertain_param_var_list[0].fixed) + self.assertTrue(sep_model.uncertainty.uncertain_param_var_list[1].fixed) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py new file mode 100644 index 00000000000..0e5482eb8e0 --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -0,0 +1,2646 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +""" +Tests for the PyROS UncertaintySet class and subclasses. +""" + +import itertools as it +import pyomo.common.unittest as unittest + +from pyomo.common.collections import Bunch +from pyomo.common.dependencies import ( + attempt_import, + numpy as np, + numpy_available, + scipy as sp, + scipy_available, +) +from pyomo.core.base import ConcreteModel, Param, Var +from pyomo.core.expr import RangedExpression +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.environ import SolverFactory + +from pyomo.contrib.pyros.uncertainty_sets import ( + AxisAlignedEllipsoidalSet, + BoxSet, + BudgetSet, + CardinalitySet, + DiscreteScenarioSet, + EllipsoidalSet, + FactorModelSet, + IntersectionSet, + PolyhedralSet, + UncertaintySet, + UncertaintyQuantification, + Geometry, + _setup_standard_uncertainty_set_constraint_block, +) + +import logging + +logger = logging.getLogger(__name__) + +parameterized, param_available = attempt_import('parameterized') + +if not (numpy_available and scipy_available and param_available): + raise unittest.SkipTest( + 'PyROS preprocessor unit tests require parameterized, numpy, and scipy' + ) +parameterized = parameterized.parameterized + +# === Config args for testing +global_solver = 'baron' +global_solver_args = dict() + +_baron = SolverFactory('baron') +baron_available = _baron.available(exception_flag=False) +if baron_available: + baron_license_is_valid = _baron.license_is_valid() + baron_version = _baron.version() +else: + baron_license_is_valid = False + baron_version = (0, 0, 0) + + +class TestBoxSet(unittest.TestCase): + """ + Tests for the BoxSet. + """ + + def test_normal_construction_and_update(self): + """ + Test BoxSet constructor and setter work normally + when bounds are appropriate. + """ + bounds = [[1, 2], [3, 4]] + bset = BoxSet(bounds=bounds) + np.testing.assert_allclose( + bounds, bset.bounds, err_msg="BoxSet bounds not as expected" + ) + + # check bounds update + new_bounds = [[3, 4], [5, 6]] + bset.bounds = new_bounds + np.testing.assert_allclose( + new_bounds, bset.bounds, err_msg="BoxSet bounds not as expected" + ) + + def test_error_on_box_set_dim_change(self): + """ + BoxSet dimension is considered immutable. + Test ValueError raised when attempting to alter the + box set dimension (i.e. number of rows of `bounds`). + """ + bounds = [[1, 2], [3, 4]] + bset = BoxSet(bounds=bounds) # 2-dimensional set + + exc_str = r"Attempting to set.*dimension 2 to a value of dimension 3" + with self.assertRaisesRegex(ValueError, exc_str): + bset.bounds = [[1, 2], [3, 4], [5, 6]] + + def test_error_on_lb_exceeds_ub(self): + """ + Test exception raised when an LB exceeds a UB. + """ + bad_bounds = [[1, 2], [4, 3]] + + exc_str = r"Lower bound 4 exceeds upper bound 3" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BoxSet(bad_bounds) + + # construct a valid box set + bset = BoxSet([[1, 2], [3, 4]]) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + bset.bounds = bad_bounds + + def test_error_on_ragged_bounds_array(self): + """ + Test ValueError raised on attempting to set BoxSet bounds + to a ragged array. + + This test also validates `uncertainty_sets.is_ragged` for all + pre-defined array-like attributes of all set-types, as the + `is_ragged` method is used throughout. + """ + # example ragged arrays + ragged_arrays = ( + [[1, 2], 3], # list and int in same sequence + [[1, 2], [3, [4, 5]]], # 2nd row ragged (list and int) + [[1, 2], [3]], # variable row lengths + ) + + # construct valid box set + bset = BoxSet(bounds=[[1, 2], [3, 4]]) + + # exception message should match this regex + exc_str = r"Argument `bounds` should not be a ragged array-like.*" + for ragged_arr in ragged_arrays: + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BoxSet(bounds=ragged_arr) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + bset.bounds = ragged_arr + + def test_error_on_invalid_bounds_shape(self): + """ + Test ValueError raised when attempting to set + Box set bounds to array of incorrect shape + (should be a 2-D array with 2 columns). + """ + # 3d array + three_d_arr = [[[1, 2], [3, 4], [5, 6]]] + exc_str = ( + r"Argument `bounds` must be a 2-dimensional.*" + r"\(detected 3 dimensions.*\)" + ) + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BoxSet(three_d_arr) + + # construct valid box set + bset = BoxSet([[1, 2], [3, 4], [5, 6]]) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + bset.bounds = three_d_arr + + def test_error_on_wrong_number_columns(self): + """ + BoxSet bounds should be a 2D array-like with 2 columns. + ValueError raised if number columns wrong + """ + three_col_arr = [[1, 2, 3], [4, 5, 6]] + exc_str = ( + r"Attribute 'bounds' should be of shape \(\.{3},2\), " + r"but detected shape \(\.{3},3\)" + ) + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BoxSet(three_col_arr) + + # construct a valid box set + bset = BoxSet([[1, 2], [3, 4]]) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + bset.bounds = three_col_arr + + def test_error_on_empty_last_dimension(self): + """ + Check ValueError raised when last dimension of BoxSet bounds is + empty. + """ + empty_2d_arr = [[], [], []] + exc_str = ( + r"Last dimension of argument `bounds` must be non-empty " + r"\(detected shape \(3, 0\)\)" + ) + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BoxSet(bounds=empty_2d_arr) + + # create a valid box set + bset = BoxSet([[1, 2], [3, 4]]) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + bset.bounds = empty_2d_arr + + def test_error_on_non_numeric_bounds(self): + """ + Test that ValueError is raised if box set bounds + are set to array-like with entries of a non-numeric + type (such as int, float). + """ + # invalid bounds (contains an entry type str) + new_bounds = [[1, "test"], [3, 2]] + + exc_str = ( + r"Entry 'test' of the argument `bounds` " + r"is not a valid numeric type \(provided type 'str'\)" + ) + + # assert error on construction + with self.assertRaisesRegex(TypeError, exc_str): + BoxSet(new_bounds) + + # construct a valid box set + bset = BoxSet(bounds=[[1, 2], [3, 4]]) + + # assert error on update + with self.assertRaisesRegex(TypeError, exc_str): + bset.bounds = new_bounds + + def test_error_on_bounds_with_nan_or_inf(self): + """ + Box set bounds set to array-like with inf or nan. + """ + # construct a valid box set + bset = BoxSet(bounds=[[1, 2], [3, 4]]) + + for val_str in ["inf", "nan"]: + bad_bounds = [[1, float(val_str)], [2, 3]] + exc_str = ( + fr"Entry '{val_str}' of the argument `bounds` " + fr"is not a finite numeric value" + ) + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BoxSet(bad_bounds) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + bset.bounds = bad_bounds + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + box_set = BoxSet(bounds=[[1, 2], [3, 4]]) + uq = box_set.set_as_constraint(uncertain_params=None, block=m) + + self.assertEqual(uq.auxiliary_vars, []) + self.assertIs(uq.block, m) + con1, con2 = uq.uncertainty_cons + var1, var2 = uq.uncertain_param_vars + + assertExpressionsEqual( + self, con1.expr, RangedExpression((np.int_(1), var1, np.int_(2)), False) + ) + assertExpressionsEqual( + self, con2.expr, RangedExpression((np.int_(3), var2, np.int_(4)), False) + ) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain param vars + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + box_set = BoxSet(bounds=[[1, 2], [3, 4]]) + with self.assertRaisesRegex(ValueError, ".*dimension"): + box_set.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1], initialize=0, mutable=True) + box_set = BoxSet(bounds=[[1, 2], [3, 4]]) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + box_set.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + box_set.set_as_constraint(uncertain_params=m.p1, block=m) + + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_compute_parameter_bounds(self): + """ + Test parameter bounds computations give expected results. + """ + box_set = BoxSet([[1, 2], [3, 4]]) + computed_bounds = box_set._compute_parameter_bounds(SolverFactory("baron")) + np.testing.assert_allclose(computed_bounds, [[1, 2], [3, 4]]) + np.testing.assert_allclose(computed_bounds, box_set.parameter_bounds) + + def test_point_in_set(self): + """ + Test point in set check works as expected. + """ + box_set = BoxSet(bounds=[[1, 2], [3, 4]]) + + in_set_points = [(1, 3), (1, 4), (2, 3), (2, 4), (1.5, 3.5)] + out_of_set_points = [(0, 0), (0, 3), (0, 4), (1, 2), (3, 4)] + for point in in_set_points: + self.assertTrue( + box_set.point_in_set(point), + msg=f"Point {point} should not be in uncertainty set {box_set}.", + ) + for point in out_of_set_points: + self.assertFalse( + box_set.point_in_set(point), + msg=f"Point {point} should not be in uncertainty set {box_set}.", + ) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + box_set.point_in_set([1, 2, 3]) + + def test_add_bounds_on_uncertain_parameters(self): + m = ConcreteModel() + m.uncertain_param_vars = Var([0, 1], initialize=0) + box_set = BoxSet(bounds=[(1, 2), (3, 4)]) + + box_set._add_bounds_on_uncertain_parameters( + global_solver=None, uncertain_param_vars=m.uncertain_param_vars + ) + self.assertEqual(m.uncertain_param_vars[0].bounds, (1, 2)) + self.assertEqual(m.uncertain_param_vars[1].bounds, (3, 4)) + + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + box_set = BoxSet(bounds=[(1, 2), (1, 1), (3, 4)]) + self.assertEqual( + box_set._is_coordinate_fixed(config=Bunch()), [False, True, False] + ) + + # test the tolerance + box_set.bounds = [(1, 2), (2, 2 + 5e-5), (3, 4)] + self.assertEqual( + box_set._is_coordinate_fixed(config=Bunch()), [False, True, False] + ) + box_set.bounds = [(1, 2), (2, 2 + 1e-4), (3, 4)] + self.assertEqual( + box_set._is_coordinate_fixed(config=Bunch()), [False, False, False] + ) + + +class TestBudgetSet(unittest.TestCase): + """ + Tests for the BudgetSet. + """ + + def test_normal_budget_construction_and_update(self): + """ + Test BudgetSet constructor and attribute setters work + appropriately. + """ + budget_mat = [[1, 0, 1], [0, 1, 0]] + budget_rhs_vec = [1, 3] + + # check attributes are as expected + buset = BudgetSet(budget_mat, budget_rhs_vec) + + np.testing.assert_allclose(budget_mat, buset.budget_membership_mat) + np.testing.assert_allclose(budget_rhs_vec, buset.budget_rhs_vec) + np.testing.assert_allclose( + [[1, 0, 1], [0, 1, 0], [-1, 0, 0], [0, -1, 0], [0, 0, -1]], + buset.coefficients_mat, + ) + np.testing.assert_allclose([1, 3, 0, 0, 0], buset.rhs_vec) + np.testing.assert_allclose(np.zeros(3), buset.origin) + + # update the set + buset.budget_membership_mat = [[1, 1, 0], [0, 0, 1]] + buset.budget_rhs_vec = [3, 4] + + # check updates work + np.testing.assert_allclose([[1, 1, 0], [0, 0, 1]], buset.budget_membership_mat) + np.testing.assert_allclose([3, 4], buset.budget_rhs_vec) + np.testing.assert_allclose( + [[1, 1, 0], [0, 0, 1], [-1, 0, 0], [0, -1, 0], [0, 0, -1]], + buset.coefficients_mat, + ) + np.testing.assert_allclose([3, 4, 0, 0, 0], buset.rhs_vec) + + # update origin + buset.origin = [1, 0, -1.5] + np.testing.assert_allclose([1, 0, -1.5], buset.origin) + + def test_error_on_budget_set_dim_change(self): + """ + BudgetSet dimension is considered immutable. + Test ValueError raised when attempting to alter the + budget set dimension. + """ + budget_mat = [[1, 0, 1], [0, 1, 0]] + budget_rhs_vec = [1, 3] + bu_set = BudgetSet(budget_mat, budget_rhs_vec) + + # error on budget incidence matrix update + exc_str = ( + r".*must have 3 columns to match set dimension \(provided.*1 columns\)" + ) + with self.assertRaisesRegex(ValueError, exc_str): + bu_set.budget_membership_mat = [[1], [1]] + + # error on origin update + exc_str = ( + r".*must have 3 entries to match set dimension \(provided.*4 entries\)" + ) + with self.assertRaisesRegex(ValueError, exc_str): + bu_set.origin = [1, 2, 1, 0] + + def test_error_on_budget_member_mat_row_change(self): + """ + Number of rows of budget membership mat is immutable. + Hence, size of budget_rhs_vec is also immutable. + """ + budget_mat = [[1, 0, 1], [0, 1, 0]] + budget_rhs_vec = [1, 3] + bu_set = BudgetSet(budget_mat, budget_rhs_vec) + + exc_str = ( + r".*must have 2 rows to match shape of attribute 'budget_rhs_vec' " + r"\(provided.*1 rows\)" + ) + with self.assertRaisesRegex(ValueError, exc_str): + bu_set.budget_membership_mat = [[1, 0, 1]] + + exc_str = ( + r".*must have 2 entries to match shape of attribute " + r"'budget_membership_mat' \(provided.*1 entries\)" + ) + with self.assertRaisesRegex(ValueError, exc_str): + bu_set.budget_rhs_vec = [1] + + def test_error_on_neg_budget_rhs_vec_entry(self): + """ + Test ValueError raised if budget RHS vec has entry + with negative value entry. + """ + budget_mat = [[1, 0, 1], [1, 1, 0]] + neg_val_rhs_vec = [1, -1] + + exc_str = r"Entry -1 of.*'budget_rhs_vec' is negative*" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BudgetSet(budget_mat, neg_val_rhs_vec) + + # construct a valid budget set + buset = BudgetSet(budget_mat, [1, 1]) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + buset.budget_rhs_vec = neg_val_rhs_vec + + def test_error_on_non_bool_budget_mat_entry(self): + """ + Test ValueError raised if budget membership mat has + entry which is not a 0-1 value. + """ + invalid_budget_mat = [[1, 0, 1], [1, 1, 0.1]] + budget_rhs_vec = [1, 1] + + exc_str = r"Attempting.*entries.*not 0-1 values \(example: 0.1\).*" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BudgetSet(invalid_budget_mat, budget_rhs_vec) + + # construct a valid budget set + buset = BudgetSet([[1, 0, 1], [1, 1, 0]], budget_rhs_vec) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + buset.budget_membership_mat = invalid_budget_mat + + def test_error_on_budget_mat_all_zero_rows(self): + """ + Test ValueError raised if budget membership mat + has a row with all zeros. + """ + invalid_row_mat = [[0, 0, 0], [1, 1, 1], [0, 0, 0]] + budget_rhs_vec = [1, 1, 2] + + exc_str = r".*all entries zero in rows at indexes: 0, 2.*" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BudgetSet(invalid_row_mat, budget_rhs_vec) + + # construct a valid budget set + buset = BudgetSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], budget_rhs_vec) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + buset.budget_membership_mat = invalid_row_mat + + def test_error_on_budget_mat_all_zero_columns(self): + """ + Test ValueError raised if budget membership mat + has a column with all zeros. + """ + invalid_col_mat = [[0, 0, 1], [0, 0, 1], [0, 0, 1]] + budget_rhs_vec = [1, 1, 2] + + exc_str = r".*all entries zero in columns at indexes: 0, 1.*" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + BudgetSet(invalid_col_mat, budget_rhs_vec) + + # construct a valid budget set + buset = BudgetSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], budget_rhs_vec) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + buset.budget_membership_mat = invalid_col_mat + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_compute_parameter_bounds(self): + """ + Test parameter bounds computations give expected results. + """ + solver = SolverFactory("baron") + + buset1 = BudgetSet([[1, 1], [0, 1]], rhs_vec=[2, 3], origin=None) + np.testing.assert_allclose( + buset1.parameter_bounds, buset1._compute_parameter_bounds(solver) + ) + + # this also checks that the list entries are tuples + self.assertEqual(buset1.parameter_bounds, [(0, 2), (0, 2)]) + + buset2 = BudgetSet([[1, 0], [1, 1]], rhs_vec=[3, 2], origin=[1, 2]) + self.assertEqual( + buset2.parameter_bounds, buset2._compute_parameter_bounds(solver) + ) + np.testing.assert_allclose( + buset2.parameter_bounds, buset2._compute_parameter_bounds(solver) + ) + self.assertEqual(buset2.parameter_bounds, [(1, 3), (2, 4)]) + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + m.v2 = Var(initialize=0) + buset = BudgetSet([[1, 0], [1, 1]], rhs_vec=[3, 2], origin=[1, 3]) + + uq = buset.set_as_constraint(uncertain_params=[m.v1, m.v2], block=m) + self.assertEqual(uq.auxiliary_vars, []) + self.assertIs(uq.block, m) + self.assertEqual(len(uq.uncertain_param_vars), 2) + self.assertIs(uq.uncertain_param_vars[0], m.v1) + self.assertIs(uq.uncertain_param_vars[1], m.v2) + self.assertEqual(len(uq.uncertainty_cons), 4) + + assertExpressionsEqual( + self, uq.uncertainty_cons[0].expr, m.v1 + np.float64(0) * m.v2 <= np.int_(4) + ) + assertExpressionsEqual( + self, uq.uncertainty_cons[1].expr, m.v1 + m.v2 <= np.int_(6) + ) + assertExpressionsEqual( + self, + uq.uncertainty_cons[2].expr, + -np.float64(1.0) * m.v1 - np.float64(0) * m.v2 <= np.int_(-1), + ) + assertExpressionsEqual( + self, + uq.uncertainty_cons[3].expr, + -np.float64(0) * m.v1 + np.float64(-1.0) * m.v2 <= np.int_(-3), + ) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain parameters + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + buset = BudgetSet([[1, 0], [1, 1]], rhs_vec=[3, 2], origin=[1, 3]) + with self.assertRaisesRegex(ValueError, ".*dimension"): + buset.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1], initialize=0, mutable=True) + buset = BudgetSet([[1, 0], [1, 1]], rhs_vec=[3, 2], origin=[1, 3]) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + buset.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + buset.set_as_constraint(uncertain_params=m.p1, block=m) + + def test_point_in_set(self): + """ + Test point in set checks work as expected. + """ + buset = BudgetSet([[1, 0], [1, 1]], rhs_vec=[3, 2], origin=[1, 3]) + self.assertTrue(buset.point_in_set([1, 3])) + self.assertTrue(buset.point_in_set([3, 3])) + self.assertTrue(buset.point_in_set([2, 4])) + self.assertFalse(buset.point_in_set([0, 0])) + self.assertFalse(buset.point_in_set([0, 3])) + self.assertFalse(buset.point_in_set([4, 2])) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + buset.point_in_set([1, 2, 3, 4]) + + def test_add_bounds_on_uncertain_parameters(self): + """ + Test method for adding bounds on uncertain params + works as expected. + """ + m = ConcreteModel() + m.v = Var([0, 1], initialize=0.5) + buset = BudgetSet([[1, 0], [1, 1]], rhs_vec=[3, 2], origin=[1, 3]) + buset._add_bounds_on_uncertain_parameters( + global_solver=None, uncertain_param_vars=m.v + ) + self.assertEqual(m.v[0].bounds, (1, 3)) + self.assertEqual(m.v[1].bounds, (3, 5)) + + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + buset = BudgetSet( + origin=np.zeros(3), budget_membership_mat=np.eye(3), rhs_vec=[1, 0, 2] + ) + self.assertEqual( + buset._is_coordinate_fixed(config=Bunch()), [False, True, False] + ) + + +class TestFactorModelSet(unittest.TestCase): + """ + Tests for the FactorModelSet. + """ + + def test_normal_factor_model_construction_and_update(self): + """ + Test FactorModelSet constructor and setter work normally + when attribute values are appropriate. + """ + # valid inputs + fset = FactorModelSet( + origin=[0, 0, 1], + number_of_factors=2, + psi_mat=[[1, 2], [0, 1], [1, 0]], + beta=0.1, + ) + + # check attributes are as expected + np.testing.assert_allclose(fset.origin, [0, 0, 1]) + np.testing.assert_allclose(fset.psi_mat, [[1, 2], [0, 1], [1, 0]]) + np.testing.assert_allclose(fset.number_of_factors, 2) + np.testing.assert_allclose(fset.beta, 0.1) + self.assertEqual(fset.dim, 3) + + # update the set + fset.origin = [1, 1, 0] + fset.psi_mat = [[1, 0], [0, 1], [1, 1]] + fset.beta = 0.5 + + # check updates work + np.testing.assert_allclose(fset.origin, [1, 1, 0]) + np.testing.assert_allclose(fset.psi_mat, [[1, 0], [0, 1], [1, 1]]) + np.testing.assert_allclose(fset.beta, 0.5) + + def test_error_on_factor_model_set_dim_change(self): + """ + Test ValueError raised when attempting to change FactorModelSet + dimension (by changing number of entries in origin + or number of rows of psi_mat). + """ + origin = [0, 0, 0] + number_of_factors = 2 + psi_mat = [[1, 0], [0, 1], [1, 1]] + beta = 0.5 + + # construct factor model set + fset = FactorModelSet(origin, number_of_factors, psi_mat, beta) + + # assert error on psi mat update + exc_str = ( + r"should be of shape \(3, 2\) to match.*dimensions " + r"\(provided shape \(2, 2\)\)" + ) + with self.assertRaisesRegex(ValueError, exc_str): + fset.psi_mat = [[1, 0], [1, 2]] + + # assert error on origin update + exc_str = r"Attempting.*factor model set of dimension 3 to value of dimension 2" + with self.assertRaisesRegex(ValueError, exc_str): + fset.origin = [1, 3] + + def test_error_on_invalid_number_of_factors(self): + """ + Test ValueError raised if number of factors + is negative int, or AttributeError + if attempting to update (should be immutable). + """ + exc_str = r".*'number_of_factors' must be a positive int \(provided value -1\)" + with self.assertRaisesRegex(ValueError, exc_str): + FactorModelSet( + origin=[0], number_of_factors=-1, psi_mat=[[1, 2], [1, 1]], beta=0.1 + ) + + fset = FactorModelSet( + origin=[0, 1], number_of_factors=2, psi_mat=[[1, 2], [1, 1]], beta=0.1 + ) + + exc_str = r".*'number_of_factors' is immutable" + with self.assertRaisesRegex(AttributeError, exc_str): + fset.number_of_factors = 3 + + def test_error_on_invalid_beta(self): + """ + Test ValueError raised if beta is invalid (exceeds 1 or + is negative) + """ + origin = [0, 0, 0] + number_of_factors = 2 + psi_mat = [[1, 0], [0, 1], [1, 1]] + neg_beta = -0.5 + big_beta = 1.5 + + # assert error on construction + neg_exc_str = ( + r".*must be a real number between 0 and 1.*\(provided value -0.5\)" + ) + big_exc_str = r".*must be a real number between 0 and 1.*\(provided value 1.5\)" + with self.assertRaisesRegex(ValueError, neg_exc_str): + FactorModelSet(origin, number_of_factors, psi_mat, neg_beta) + with self.assertRaisesRegex(ValueError, big_exc_str): + FactorModelSet(origin, number_of_factors, psi_mat, big_beta) + + # create a valid factor model set + fset = FactorModelSet(origin, number_of_factors, psi_mat, 1) + + # assert error on update + with self.assertRaisesRegex(ValueError, neg_exc_str): + fset.beta = neg_beta + with self.assertRaisesRegex(ValueError, big_exc_str): + fset.beta = big_beta + + def test_error_on_rank_deficient_psi_mat(self): + """ + Test exception raised if factor loading matrix `psi_mat` + is rank-deficient. + """ + with self.assertRaisesRegex(ValueError, r"full column rank.*\(2, 3\)"): + # more columns than rows + FactorModelSet( + origin=[0, 0], + number_of_factors=3, + psi_mat=[[1, -1, 1], [1, 0.1, 1]], + beta=1 / 6, + ) + with self.assertRaisesRegex(ValueError, r"full column rank.*\(2, 2\)"): + # linearly dependent columns + FactorModelSet( + origin=[0, 0], + number_of_factors=2, + psi_mat=[[1, -1], [1, -1]], + beta=1 / 6, + ) + + @parameterized.expand( + [ + # map beta to expected parameter bounds + ["beta0", 0, [(-2.0, 2.0), (0.1, 1.9), (-5.0, 9.0), (-4.0, 10.0)]], + ["beta1ov6", 1 / 6, [(-2.5, 2.5), (-0.4, 2.4), (-8.0, 12.0), (-7.0, 13.0)]], + [ + "beta1ov3", + 1 / 3, + [(-3.0, 3.0), (-0.9, 2.9), (-11.0, 15.0), (-10.0, 16.0)], + ], + [ + "beta1ov2", + 1 / 2, + [(-3.0, 3.0), (-0.95, 2.95), (-11.5, 15.5), (-10.5, 16.5)], + ], + [ + "beta2ov3", + 2 / 3, + [(-3.0, 3.0), (-1.0, 3.0), (-12.0, 16.0), (-11.0, 17.0)], + ], + [ + "beta7ov9", + 7 / 9, + [ + (-3.0, 3.0), + (-31 / 30, 91 / 30), + (-37 / 3, 49 / 3), + (-34 / 3, 52 / 3), + ], + ], + ["beta1", 1, [(-3.0, 3.0), (-1.1, 3.1), (-13.0, 17.0), (-12.0, 18.0)]], + ] + ) + @unittest.skipUnless(baron_available, "BARON is not available") + def test_compute_parameter_bounds(self, name, beta, expected_param_bounds): + """ + Test parameter bounds computations give expected results. + """ + solver = SolverFactory("baron") + + fset = FactorModelSet( + origin=[0, 1, 2, 3], + number_of_factors=3, + psi_mat=[[1, -1, 1], [1, 0.1, 1], [-1, -6, -8], [1, 6, 8]], + beta=beta, + ) + + param_bounds = fset.parameter_bounds + # won't be exactly equal, + np.testing.assert_allclose(param_bounds, expected_param_bounds, atol=1e-13) + + # check parameter bounds matches LP results + # exactly for each case + solver_param_bounds = fset._compute_parameter_bounds(solver) + np.testing.assert_allclose( + solver_param_bounds, + param_bounds, + err_msg=( + "Parameter bounds not consistent with LP values for " + "FactorModelSet with parameterization:\n" + f"F={fset.number_of_factors},\n" + f"beta={fset.beta},\n" + f"psi_mat={fset.psi_mat},\n" + f"origin={fset.origin}." + ), + # account for solver tolerances and numerical errors + atol=1e-4, + ) + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + fset = FactorModelSet( + origin=[0, 1, 2, 3], + number_of_factors=3, + psi_mat=[[1, -1, 1], [1, 0.1, 1], [-1, -6, -8], [1, 6, 8]], + beta=1 / 6, + ) + uq = fset.set_as_constraint(uncertain_params=None) + + self.assertEqual(len(uq.auxiliary_vars), 3) + self.assertEqual(uq.auxiliary_vars[0].bounds, (-1, 1)) + self.assertEqual(uq.auxiliary_vars[1].bounds, (-1, 1)) + self.assertEqual(uq.auxiliary_vars[2].bounds, (-1, 1)) + + *factor_model_matrix_cons, betaf_abs_val_con = uq.uncertainty_cons + + self.assertEqual(len(factor_model_matrix_cons), 4) + assertExpressionsEqual( + self, + factor_model_matrix_cons[0].expr, + ( + uq.auxiliary_vars[0] + + (-1.0) * uq.auxiliary_vars[1] + + uq.auxiliary_vars[2] + == uq.uncertain_param_vars[0] + ), + ) + assertExpressionsEqual( + self, + factor_model_matrix_cons[1].expr, + ( + uq.auxiliary_vars[0] + + 0.1 * uq.auxiliary_vars[1] + + uq.auxiliary_vars[2] + + 1 + == uq.uncertain_param_vars[1] + ), + ) + assertExpressionsEqual( + self, + factor_model_matrix_cons[2].expr, + ( + (-1.0) * uq.auxiliary_vars[0] + + (-6.0) * uq.auxiliary_vars[1] + + (-8.0) * uq.auxiliary_vars[2] + + 2 + == uq.uncertain_param_vars[2] + ), + ) + assertExpressionsEqual( + self, + factor_model_matrix_cons[3].expr, + ( + (1.0) * uq.auxiliary_vars[0] + + (6.0) * uq.auxiliary_vars[1] + + (8.0) * uq.auxiliary_vars[2] + + 3 + == uq.uncertain_param_vars[3] + ), + ) + + betaf_abs_val_con = uq.uncertainty_cons[-1] + assertExpressionsEqual( + self, + betaf_abs_val_con.expr, + RangedExpression((-0.5, sum(uq.auxiliary_vars), 0.5), False), + ) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain parameters + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + box_set = BoxSet(bounds=[[1, 2], [3, 4]]) + with self.assertRaisesRegex(ValueError, ".*dimension"): + box_set.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1], initialize=0, mutable=True) + box_set = BoxSet(bounds=[[1, 2], [3, 4]]) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + box_set.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + box_set.set_as_constraint(uncertain_params=m.p1, block=m) + + def test_point_in_set(self): + """ + Test point in set check works if psi matrix is skinny. + """ + fset = FactorModelSet( + origin=[0, 0, 0, 0], + number_of_factors=3, + psi_mat=[[1, -1, 1], [1, 0.1, 1], [2, 0.3, 1], [4, 5, 1]], + beta=1 / 6, + ) + + self.assertTrue(fset.point_in_set(fset.origin)) + + for aux_space_pt in it.permutations([1, 0.5, -1]): + fset_pt_from_crit = fset.origin + fset.psi_mat @ aux_space_pt + self.assertTrue( + fset.point_in_set(fset_pt_from_crit), + msg=( + f"Point {fset_pt_from_crit} generated from critical point " + f"{aux_space_pt} of the auxiliary variable space " + "is not in the set." + ), + ) + + fset_pt_from_neg_crit = fset.origin - fset.psi_mat @ aux_space_pt + self.assertTrue( + fset.point_in_set(fset_pt_from_neg_crit), + msg=( + f"Point {fset_pt_from_neg_crit} generated from critical point " + f"{aux_space_pt} of the auxiliary variable space " + "is not in the set." + ), + ) + + # some points transformed from hypercube vertices. + # since F - k = 2 < 1 = k, no such point should be in the set + self.assertFalse(fset.point_in_set(fset.origin + fset.psi_mat @ [1, 1, 1])) + self.assertFalse(fset.point_in_set(fset.origin + fset.psi_mat @ [1, 1, -1])) + self.assertFalse(fset.point_in_set(fset.origin + fset.psi_mat @ [1, -1, -1])) + self.assertFalse(fset.point_in_set(fset.origin + fset.psi_mat @ [-1, -1, -1])) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + fset.point_in_set([1, 2, 3]) + + def test_add_bounds_on_uncertain_parameters(self): + m = ConcreteModel() + m.uncertain_param_vars = Var(range(4), initialize=0) + fset = FactorModelSet( + origin=[0, 1, 2, 3], + number_of_factors=3, + psi_mat=[[1, -1, 1], [1, 0.1, 1], [-1, -6, -8], [1, 6, 8]], + beta=1, + ) + + fset._add_bounds_on_uncertain_parameters( + global_solver=None, uncertain_param_vars=m.uncertain_param_vars + ) + self.assertEqual(m.uncertain_param_vars[0].bounds, (-3.0, 3.0)) + self.assertEqual(m.uncertain_param_vars[1].bounds, (-1.1, 3.1)) + self.assertEqual(m.uncertain_param_vars[2].bounds, (-13.0, 17.0)) + self.assertEqual(m.uncertain_param_vars[3].bounds, (-12.0, 18.0)) + + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + fset = FactorModelSet( + origin=np.zeros(3), + number_of_factors=2, + psi_mat=[[1, 0], [1, -1], [1, 1]], + beta=1, + ) + self.assertEqual( + fset._is_coordinate_fixed(config=Bunch()), [False, False, False] + ) + fset.beta = 0 + self.assertEqual( + fset._is_coordinate_fixed(config=Bunch()), [False, False, True] + ) + + +class TestIntersectionSet(unittest.TestCase): + """ + Tests for the IntersectionSet. + """ + + def test_normal_construction_and_update(self): + """ + Test IntersectionSet constructor and setter + work normally when arguments are appropriate. + """ + bset = BoxSet(bounds=[[-1, 1], [-1, 1], [-1, 1]]) + aset = AxisAlignedEllipsoidalSet([0, 0, 0], [1, 1, 1]) + + iset = IntersectionSet(box_set=bset, axis_aligned_set=aset) + self.assertIn( + bset, + iset.all_sets, + msg=( + "IntersectionSet 'all_sets' attribute does not" + "contain expected BoxSet" + ), + ) + self.assertIn( + aset, + iset.all_sets, + msg=( + "IntersectionSet 'all_sets' attribute does not" + "contain expected AxisAlignedEllipsoidalSet" + ), + ) + + def test_error_on_intersecting_wrong_dims(self): + """ + Test ValueError raised if IntersectionSet sets + are not of same dimension. + """ + bset = BoxSet(bounds=[[-1, 1], [-1, 1]]) + aset = AxisAlignedEllipsoidalSet([0, 0], [2, 2]) + wrong_aset = AxisAlignedEllipsoidalSet([0, 0, 0], [1, 1, 1]) + + exc_str = r".*of dimension 2, but attempting to add set of dimension 3" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + IntersectionSet(box_set=bset, axis_set=aset, wrong_set=wrong_aset) + + # construct a valid intersection set + iset = IntersectionSet(box_set=bset, axis_set=aset) + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + iset.all_sets.append(wrong_aset) + + def test_type_error_on_invalid_arg(self): + """ + Test TypeError raised if an argument not of type + UncertaintySet is passed to the IntersectionSet + constructor or appended to 'all_sets'. + """ + bset = BoxSet(bounds=[[-1, 1], [-1, 1]]) + aset = AxisAlignedEllipsoidalSet([0, 0], [2, 2]) + + exc_str = ( + r"Entry '1' of the argument `all_sets` is not An `UncertaintySet` " + r"object.*\(provided type 'int'\)" + ) + + # assert error on construction + with self.assertRaisesRegex(TypeError, exc_str): + IntersectionSet(box_set=bset, axis_set=aset, invalid_arg=1) + + # construct a valid intersection set + iset = IntersectionSet(box_set=bset, axis_set=aset) + + # assert error on update + with self.assertRaisesRegex(TypeError, exc_str): + iset.all_sets.append(1) + + def test_error_on_intersection_dim_change(self): + """ + IntersectionSet dimension is considered immutable. + Test ValueError raised when attempting to set the + constituent sets to a different dimension. + """ + bset = BoxSet(bounds=[[-1, 1], [-1, 1]]) + aset = AxisAlignedEllipsoidalSet([0, 0], [2, 2]) + + # construct the set + iset = IntersectionSet(box_set=bset, axis_set=aset) + + exc_str = r"Attempting to set.*dimension 2 to a sequence.* of dimension 1" + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + # attempt to set to 1-dimensional sets + iset.all_sets = [BoxSet([[1, 1]]), AxisAlignedEllipsoidalSet([0], [1])] + + def test_error_on_too_few_sets(self): + """ + Check ValueError raised if too few sets are passed + to the intersection set. + """ + exc_str = r"Attempting.*minimum required length 2.*iterable of length 1" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + IntersectionSet(bset=BoxSet([[1, 2]])) + + # construct a valid intersection set + iset = IntersectionSet( + box_set=BoxSet([[1, 2]]), axis_set=AxisAlignedEllipsoidalSet([0], [1]) + ) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + # attempt to set to 1-dimensional sets + iset.all_sets = [BoxSet([[1, 1]])] + + def test_intersection_uncertainty_set_list_behavior(self): + """ + Test the 'all_sets' attribute of the IntersectionSet + class behaves like a regular Python list. + """ + iset = IntersectionSet( + bset=BoxSet([[0, 2]]), aset=AxisAlignedEllipsoidalSet([0], [1]) + ) + + # an UncertaintySetList of length 2. + # should behave like a list of length 2 + all_sets = iset.all_sets + + # test append + all_sets.append(BoxSet([[1, 2]])) + del all_sets[2:] + + # test extend + all_sets.extend([BoxSet([[1, 2]]), EllipsoidalSet([0], [[1]], 2)]) + del all_sets[2:] + + # index in range. Allow slicing as well + # none of these should result in exception + all_sets[0] + all_sets[1] + all_sets[100:] + all_sets[0:2:20] + all_sets[0:2:1] + all_sets[-20:-1:2] + + # index out of range + self.assertRaises(IndexError, lambda: all_sets[2]) + self.assertRaises(IndexError, lambda: all_sets[-3]) + + # assert min length ValueError if attempting to clear + # list to length less than 2 + with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): + all_sets[:] = all_sets[0] + with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): + del all_sets[1] + with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): + del all_sets[1:] + with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): + del all_sets[:] + with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): + all_sets.clear() + with self.assertRaisesRegex(ValueError, r"Length.* must be at least 2"): + all_sets[0:] = [] + + # assignment out of range + with self.assertRaisesRegex(IndexError, r"assignment index out of range"): + all_sets[-3] = BoxSet([[1, 1.5]]) + with self.assertRaisesRegex(IndexError, r"assignment index out of range"): + all_sets[2] = BoxSet([[1, 1.5]]) + + # assigning to slices should work fine + all_sets[3:] = [BoxSet([[1, 1.5]]), BoxSet([[1, 3]])] + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + m.v2 = Var(initialize=0) + + i_set = IntersectionSet( + set1=BoxSet([(-0.5, 0.5), (-0.5, 0.5)]), + set2=FactorModelSet( + origin=[0, 0], number_of_factors=2, beta=0.75, psi_mat=[[1, 1], [1, 2]] + ), + set3=CardinalitySet([-0.5, -0.5], [2, 2], 2), + # ellipsoid. this is enclosed in all the other sets + set4=AxisAlignedEllipsoidalSet([0, 0], [0.25, 0.25]), + ) + + uq = i_set.set_as_constraint(uncertain_params=[m.v1, m.v2], block=m) + + self.assertIs(uq.block, m) + self.assertEqual(uq.uncertain_param_vars, [m.v1, m.v2]) + self.assertEqual(len(uq.auxiliary_vars), 4) + self.assertEqual(len(uq.uncertainty_cons), 9) + + # box set constraints + assertExpressionsEqual( + self, + uq.uncertainty_cons[0].expr, + RangedExpression((np.float64(-0.5), m.v1, np.float64(0.5)), False), + ) + assertExpressionsEqual( + self, + uq.uncertainty_cons[1].expr, + RangedExpression((np.float64(-0.5), m.v2, np.float64(0.5)), False), + ) + + # factor model constraints + aux_vars = uq.auxiliary_vars + assertExpressionsEqual( + self, uq.uncertainty_cons[2].expr, aux_vars[0] + aux_vars[1] == m.v1 + ) + assertExpressionsEqual( + self, uq.uncertainty_cons[3].expr, aux_vars[0] + 2 * aux_vars[1] == m.v2 + ) + assertExpressionsEqual( + self, + uq.uncertainty_cons[4].expr, + RangedExpression((-1.5, aux_vars[0] + aux_vars[1], 1.5), False), + ) + self.assertEqual(aux_vars[0].bounds, (-1, 1)) + self.assertEqual(aux_vars[1].bounds, (-1, 1)) + + # cardinality set constraints + assertExpressionsEqual( + self, uq.uncertainty_cons[5].expr, -0.5 + 2 * aux_vars[2] == m.v1 + ) + assertExpressionsEqual( + self, uq.uncertainty_cons[6].expr, -0.5 + 2 * aux_vars[3] == m.v2 + ) + assertExpressionsEqual( + self, uq.uncertainty_cons[7].expr, sum(aux_vars[2:4]) <= 2 + ) + self.assertEqual(aux_vars[2].bounds, (0, 1)) + self.assertEqual(uq.auxiliary_vars[3].bounds, (0, 1)) + + # axis-aligned ellipsoid constraint + assertExpressionsEqual( + self, + uq.uncertainty_cons[8].expr, + m.v1**2 / np.float64(0.0625) + m.v2**2 / np.float64(0.0625) <= 1, + ) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain parameters + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + i_set = IntersectionSet( + set1=BoxSet(bounds=[[1, 2], [3, 4]]), + set2=AxisAlignedEllipsoidalSet([0, 1], [5, 5]), + ) + with self.assertRaisesRegex(ValueError, ".*dimension"): + i_set.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1], initialize=0, mutable=True) + i_set = IntersectionSet( + set1=BoxSet(bounds=[[1, 2], [3, 4]]), + set2=AxisAlignedEllipsoidalSet([0, 1], [5, 5]), + ) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + i_set.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + i_set.set_as_constraint(uncertain_params=m.p1, block=m) + + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_compute_parameter_bounds(self): + """ + Test parameter bounds computations give expected results. + """ + i_set = IntersectionSet( + set1=BoxSet([(-0.5, 0.5), (-0.5, 0.5)]), + set2=FactorModelSet( + origin=[0, 0], number_of_factors=2, beta=0.75, psi_mat=[[1, 1], [1, 2]] + ), + # another origin-centered square + set3=CardinalitySet([-0.5, -0.5], [2, 2], 2), + # ellipsoid. this is enclosed in all the other sets + set4=AxisAlignedEllipsoidalSet([0, 0], [0.25, 0.25]), + ) + + # ellipsoid is enclosed by everyone else, so + # that determines the bounds + computed_bounds = i_set._compute_parameter_bounds(SolverFactory("baron")) + np.testing.assert_allclose(computed_bounds, [[-0.25, 0.25], [-0.25, 0.25]]) + + # returns empty list + self.assertFalse(i_set.parameter_bounds) + + def test_point_in_set(self): + """ + Test point in set check for intersection set. + """ + i_set = IntersectionSet( + set1=BoxSet([(-0.5, 0.5), (-0.5, 0.5)]), + # this is just an origin-centered square + set2=FactorModelSet( + origin=[0, 0], number_of_factors=2, beta=0.75, psi_mat=[[1, 1], [1, 2]] + ), + set3=CardinalitySet([-0.5, -0.5], [2, 2], 2), + # ellipsoid. this is enclosed in all the other sets + set4=AxisAlignedEllipsoidalSet([0, 0], [0.25, 0.25]), + ) + + # ellipsoid points + self.assertTrue(i_set.point_in_set([0, 0])) + self.assertTrue(i_set.point_in_set([0, 0.25])) + self.assertTrue(i_set.point_in_set([0, -0.25])) + self.assertTrue(i_set.point_in_set([0.25, 0])) + self.assertTrue(i_set.point_in_set([-0.25, 0])) + + # box vertex + self.assertFalse(i_set.point_in_set([0.5, 0.5])) + # cardinality set origin and vertex of the box + # are outside the ellipse + self.assertFalse(i_set.point_in_set([-0.5, -0.5])) + + @unittest.skipUnless(baron_available, "Global NLP solver is not available.") + def test_add_bounds_on_uncertain_parameters(self): + m = ConcreteModel() + m.uncertain_param_vars = Var([0, 1], initialize=0) + iset = IntersectionSet( + set1=BoxSet([(-0.5, 0.5), (-0.5, 0.5)]), + set2=FactorModelSet( + origin=[0, 0], number_of_factors=2, beta=0.75, psi_mat=[[1, 1], [1, 2]] + ), + set3=CardinalitySet([-0.5, -0.5], [2, 2], 2), + # ellipsoid. this is enclosed in all the other sets + set4=AxisAlignedEllipsoidalSet([0, 0], [0.25, 0.25]), + ) + + iset._add_bounds_on_uncertain_parameters( + global_solver=SolverFactory("baron"), + uncertain_param_vars=m.uncertain_param_vars, + ) + + # account for imprecision + np.testing.assert_allclose(m.uncertain_param_vars[0].bounds, (-0.25, 0.25)) + np.testing.assert_allclose(m.uncertain_param_vars[1].bounds, (-0.25, 0.25)) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + iset.point_in_set([1, 2, 3]) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + iset = IntersectionSet( + set1=BoxSet(bounds=[[0, 1], [0, 1]]), set2=BoxSet(bounds=[[1, 2], [0, 1]]) + ) + baron = SolverFactory("baron") + self.assertEqual( + iset._is_coordinate_fixed(config=Bunch(global_solver=baron)), [True, False] + ) + + +class TestCardinalitySet(unittest.TestCase): + """ + Tests for the CardinalitySet. + """ + + def test_normal_cardinality_construction_and_update(self): + """ + Test CardinalitySet constructor and setter work normally + when bounds are appropriate. + """ + # valid inputs + cset = CardinalitySet(origin=[0, 0], positive_deviation=[1, 3], gamma=2) + + # check attributes are as expected + np.testing.assert_allclose(cset.origin, [0, 0]) + np.testing.assert_allclose(cset.positive_deviation, [1, 3]) + np.testing.assert_allclose(cset.gamma, 2) + self.assertEqual(cset.dim, 2) + + # update the set + cset.origin = [1, 2] + cset.positive_deviation = [3, 0] + cset.gamma = 0.5 + + # check updates work + np.testing.assert_allclose(cset.origin, [1, 2]) + np.testing.assert_allclose(cset.positive_deviation, [3, 0]) + np.testing.assert_allclose(cset.gamma, 0.5) + + def test_error_on_neg_positive_deviation(self): + """ + Cardinality set positive deviation attribute should + contain nonnegative numerical entries. + + Check ValueError raised if any negative entries provided. + """ + origin = [0, 0] + positive_deviation = [1, -2] # invalid + gamma = 2 + + exc_str = r"Entry -2 of attribute 'positive_deviation' is negative value" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + cset = CardinalitySet(origin, positive_deviation, gamma) + + # construct a valid cardinality set + cset = CardinalitySet(origin, [1, 1], gamma) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + cset.positive_deviation = positive_deviation + + def test_error_on_invalid_gamma(self): + """ + Cardinality set gamma attribute should be a float-like + between 0 and the set dimension. + + Check ValueError raised if gamma attribute is set + to an invalid value. + """ + origin = [0, 0] + positive_deviation = [1, 1] + gamma = 3 # should be invalid + + exc_str = ( + r".*attribute 'gamma' must be a real number " + r"between 0 and dimension 2 \(provided value 3\)" + ) + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + CardinalitySet(origin, positive_deviation, gamma) + + # construct a valid cardinality set + cset = CardinalitySet(origin, positive_deviation, gamma=2) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + cset.gamma = gamma + + def test_error_on_cardinality_set_dim_change(self): + """ + Dimension is considered immutable. + Test ValueError raised when attempting to alter the + set dimension (i.e. number of entries of `origin`). + """ + # construct a valid cardinality set + cset = CardinalitySet(origin=[0, 0], positive_deviation=[1, 1], gamma=2) + + exc_str = r"Attempting to set.*dimension 2 to value of dimension 3" + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + cset.origin = [0, 0, 0] + with self.assertRaisesRegex(ValueError, exc_str): + cset.positive_deviation = [1, 1, 1] + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + cset = CardinalitySet([-0.5, 1, 2], [2.5, 3, 0], 1.5) + uq = cset.set_as_constraint(uncertain_params=None, block=m) + + self.assertEqual(len(uq.uncertainty_cons), 4) + self.assertEqual(len(uq.auxiliary_vars), 3) + self.assertEqual(len(uq.uncertain_param_vars), 3) + self.assertIs(uq.block, m) + + *hadamard_cons, gamma_con = uq.uncertainty_cons + var1, var2, var3 = uq.uncertain_param_vars + auxvar1, auxvar2, auxvar3 = uq.auxiliary_vars + + assertExpressionsEqual( + self, hadamard_cons[0].expr, -0.5 + 2.5 * auxvar1 == var1 + ) + assertExpressionsEqual(self, hadamard_cons[1].expr, 1.0 + 3.0 * auxvar2 == var2) + assertExpressionsEqual(self, hadamard_cons[2].expr, 2.0 + 0.0 * auxvar3 == var3) + assertExpressionsEqual(self, gamma_con.expr, auxvar1 + auxvar2 + auxvar3 <= 1.5) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain parameters + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + cset = CardinalitySet([-0.5, 1, 2], [2.5, 3, 0], 1.5) + with self.assertRaisesRegex(ValueError, ".*dimension"): + cset.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1, 2], initialize=0, mutable=True) + cset = CardinalitySet([-0.5, 1, 2], [2.5, 3, 0], 1.5) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + cset.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + cset.set_as_constraint(uncertain_params=m.p1, block=m) + + def test_point_in_set(self): + cset = CardinalitySet( + origin=[-0.5, 1, 2], positive_deviation=[2.5, 3, 0], gamma=1.5 + ) + + self.assertTrue(cset.point_in_set(cset.origin)) + + # first param full deviation + self.assertTrue(cset.point_in_set([-0.5, 4, 2])) + # second param full deviation + self.assertTrue(cset.point_in_set([2, 1, 2])) + # one and a half deviations (max) + self.assertTrue(cset.point_in_set([2, 2.5, 2])) + + # over one and a half deviations; out of set + self.assertFalse(cset.point_in_set([2.05, 2.5, 2])) + self.assertFalse(cset.point_in_set([2, 2.55, 2])) + + # deviation in dimension that has been fixed + self.assertFalse(cset.point_in_set([-0.25, 4, 2.01])) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + cset.point_in_set([1, 2, 3, 4]) + + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_compute_parameter_bounds(self): + """ + Test parameter bounds computations give expected results. + """ + cset = CardinalitySet( + origin=[-0.5, 1, 2], positive_deviation=[2.5, 3, 0], gamma=1.5 + ) + computed_bounds = cset._compute_parameter_bounds(SolverFactory("baron")) + np.testing.assert_allclose(computed_bounds, [[-0.5, 2], [1, 4], [2, 2]]) + np.testing.assert_allclose(computed_bounds, cset.parameter_bounds) + + def test_add_bounds_on_uncertain_parameters(self): + m = ConcreteModel() + m.uncertain_param_vars = Var([0, 1, 2], initialize=0) + cset = CardinalitySet( + origin=[-0.5, 1, 2], positive_deviation=[2.5, 3, 0], gamma=1.5 + ) + + cset._add_bounds_on_uncertain_parameters( + global_solver=None, uncertain_param_vars=m.uncertain_param_vars + ) + self.assertEqual(m.uncertain_param_vars[0].bounds, (-0.5, 2)) + self.assertEqual(m.uncertain_param_vars[1].bounds, (1, 4)) + self.assertEqual(m.uncertain_param_vars[2].bounds, (2, 2)) + + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + cset = CardinalitySet(origin=np.zeros(3), positive_deviation=[1, 1, 0], gamma=1) + self.assertEqual( + cset._is_coordinate_fixed(config=Bunch()), [False, False, True] + ) + + +class TestDiscreteScenarioSet(unittest.TestCase): + """ + Tests for the DiscreteScenarioSet. + """ + + def test_normal_discrete_set_construction_and_update(self): + """ + Test DiscreteScenarioSet constructor and setter work normally + when scenarios are appropriate. + """ + scenarios = [[0, 0, 0], [1, 2, 3]] + + # normal construction should work + dset = DiscreteScenarioSet(scenarios) + + # check scenarios added appropriately + np.testing.assert_allclose(scenarios, dset.scenarios) + + # check scenarios updated appropriately + new_scenarios = [[0, 1, 2], [1, 2, 0], [3, 5, 4]] + dset.scenarios = new_scenarios + np.testing.assert_allclose(new_scenarios, dset.scenarios) + + def test_error_on_discrete_set_dim_change(self): + """ + Test ValueError raised when attempting to update + DiscreteScenarioSet dimension. + """ + scenarios = [[1, 2], [3, 4]] + dset = DiscreteScenarioSet(scenarios) # 2-dimensional set + + exc_str = ( + r".*must have 2 columns.* to match set dimension " + r"\(provided.*with 3 columns\)" + ) + with self.assertRaisesRegex(ValueError, exc_str): + dset.scenarios = [[1, 2, 3], [4, 5, 6]] + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + m.v1 = Var([0, 1], initialize=0) + dset = DiscreteScenarioSet([[1, 2], [3, 4]]) + uq = dset.set_as_constraint(block=m, uncertain_params=m.v1) + self.assertEqual(uq.uncertain_param_vars, [m.v1[0], m.v1[1]]) + self.assertEqual(uq.uncertainty_cons, []) + self.assertEqual(uq.auxiliary_vars, []) + self.assertIs(uq.block, m) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain parameters + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + dset = DiscreteScenarioSet([[1, 2], [3, 4]]) + with self.assertRaisesRegex(ValueError, ".*dimension"): + dset.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1], initialize=0, mutable=True) + dset = DiscreteScenarioSet([[1, 2], [3, 4]]) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + dset.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + dset.set_as_constraint(uncertain_params=m.p1, block=m) + + def test_point_in_set(self): + dset = DiscreteScenarioSet([(0, 0), (1.5, 0), (0, 1), (1, 1), (2, 0)]) + self.assertTrue(dset.point_in_set([0, 0])) + self.assertTrue(dset.point_in_set([1.5, 0])) + self.assertTrue(dset.point_in_set([0, 1.0])) + self.assertTrue(dset.point_in_set([1, 1.0])) + self.assertTrue(dset.point_in_set([2, 0])) + self.assertFalse(dset.point_in_set([2, 2])) + + # check precision: slight deviations from (0, 0) + self.assertTrue(dset.point_in_set([4.9e-9, 4.9e-9])) + self.assertFalse(dset.point_in_set([5.1e-9, 5.1e-9])) + self.assertFalse(dset.point_in_set([1e-7, 1e-7])) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + dset.point_in_set([1, 2, 3]) + + def test_add_bounds_on_uncertain_parameters(self): + m = ConcreteModel() + m.uncertain_param_vars = Var([0, 1], initialize=0) + dset = DiscreteScenarioSet([(0, 0), (1.5, 0), (0, 1), (1, 1), (2, 0)]) + + dset._add_bounds_on_uncertain_parameters( + global_solver=None, uncertain_param_vars=m.uncertain_param_vars + ) + self.assertEqual(m.uncertain_param_vars[0].bounds, (0, 2)) + self.assertEqual(m.uncertain_param_vars[1].bounds, (0, 1.0)) + + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + dset = DiscreteScenarioSet(scenarios=[[0, 0, 0], [0, 1, 2], [0, 3, 4]]) + self.assertEqual( + dset._is_coordinate_fixed(config=Bunch()), [True, False, False] + ) + + +class TestAxisAlignedEllipsoidalSet(unittest.TestCase): + """ + Tests for the AxisAlignedEllipsoidalSet. + """ + + def test_normal_construction_and_update(self): + """ + Test AxisAlignedEllipsoidalSet constructor and setter + work normally when bounds are appropriate. + """ + center = [0, 0] + half_lengths = [1, 3] + aset = AxisAlignedEllipsoidalSet(center, half_lengths) + np.testing.assert_allclose( + center, + aset.center, + err_msg="AxisAlignedEllipsoidalSet center not as expected", + ) + np.testing.assert_allclose( + half_lengths, + aset.half_lengths, + err_msg="AxisAlignedEllipsoidalSet half-lengths not as expected", + ) + + # check attributes update + new_center = [-1, -3] + new_half_lengths = [0, 1] + aset.center = new_center + aset.half_lengths = new_half_lengths + + np.testing.assert_allclose( + new_center, + aset.center, + err_msg="AxisAlignedEllipsoidalSet center update not as expected", + ) + np.testing.assert_allclose( + new_half_lengths, + aset.half_lengths, + err_msg=("AxisAlignedEllipsoidalSet half lengths update not as expected"), + ) + + def test_error_on_axis_aligned_dim_change(self): + """ + AxisAlignedEllipsoidalSet dimension is considered immutable. + Test ValueError raised when attempting to alter the + box set dimension (i.e. number of rows of `bounds`). + """ + center = [0, 0] + half_lengths = [1, 3] + aset = AxisAlignedEllipsoidalSet(center, half_lengths) + + exc_str = r"Attempting to set.*dimension 2 to value of dimension 3" + with self.assertRaisesRegex(ValueError, exc_str): + aset.center = [0, 0, 1] + + with self.assertRaisesRegex(ValueError, exc_str): + aset.half_lengths = [0, 0, 1] + + def test_error_on_negative_axis_aligned_half_lengths(self): + """ + Test ValueError if half lengths for AxisAlignedEllipsoidalSet + contains a negative value. + """ + center = [1, 1] + invalid_half_lengths = [1, -1] + exc_str = r"Entry -1 of.*'half_lengths' is negative.*" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + AxisAlignedEllipsoidalSet(center, invalid_half_lengths) + + # construct a valid axis-aligned ellipsoidal set + aset = AxisAlignedEllipsoidalSet(center, [1, 0]) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + aset.half_lengths = invalid_half_lengths + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + m.v = Var([0, 1, 2]) + aeset = AxisAlignedEllipsoidalSet(center=[0, 1.5, 1], half_lengths=[1.5, 2, 0]) + uq = aeset.set_as_constraint(uncertain_params=m.v, block=m) + + self.assertEqual(len(uq.uncertainty_cons), 2) + self.assertEqual(len(uq.uncertain_param_vars), 3) + self.assertEqual(uq.auxiliary_vars, []) + self.assertIs(uq.block, m) + + con1, con2 = uq.uncertainty_cons + + assertExpressionsEqual(self, con1.expr, m.v[2] == np.float64(1.0)) + assertExpressionsEqual( + self, + con2.expr, + m.v[0] ** 2 / np.float64(2.25) + + (m.v[1] - np.float64(1.5)) ** 2 / np.float64(4) + <= 1, + ) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain parameters + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + aeset = AxisAlignedEllipsoidalSet(center=[0, 1.5, 1], half_lengths=[1.5, 2, 0]) + with self.assertRaisesRegex(ValueError, ".*dimension"): + aeset.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1, 2], initialize=0, mutable=True) + aeset = AxisAlignedEllipsoidalSet(center=[0, 1.5, 1], half_lengths=[1.5, 2, 0]) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + aeset.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + aeset.set_as_constraint(uncertain_params=m.p1, block=m) + + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_compute_parameter_bounds(self): + """ + Test parameter bounds computations give expected results. + """ + aeset = AxisAlignedEllipsoidalSet(center=[0, 1.5, 1], half_lengths=[1.5, 2, 0]) + computed_bounds = aeset._compute_parameter_bounds(SolverFactory("baron")) + np.testing.assert_allclose(computed_bounds, [[-1.5, 1.5], [-0.5, 3.5], [1, 1]]) + np.testing.assert_allclose(computed_bounds, aeset.parameter_bounds) + + def test_point_in_set(self): + aeset = AxisAlignedEllipsoidalSet(center=[0, 0, 1], half_lengths=[1.5, 2, 0]) + + self.assertTrue(aeset.point_in_set([0, 0, 1])) + self.assertTrue(aeset.point_in_set([0, 2, 1])) + self.assertTrue(aeset.point_in_set([0, -2, 1])) + self.assertTrue(aeset.point_in_set([1.5, 0, 1])) + self.assertTrue(aeset.point_in_set([-1.5, 0, 1])) + self.assertFalse(aeset.point_in_set([0, 0, 1.05])) + self.assertFalse(aeset.point_in_set([1.505, 0, 1])) + self.assertFalse(aeset.point_in_set([0, 2.05, 1])) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + aeset.point_in_set([1, 2, 3, 4]) + + def test_add_bounds_on_uncertain_parameters(self): + m = ConcreteModel() + m.uncertain_param_vars = Var([0, 1, 2], initialize=0) + aeset = AxisAlignedEllipsoidalSet(center=[0, 1.5, 1], half_lengths=[1.5, 2, 0]) + aeset._add_bounds_on_uncertain_parameters( + global_solver=None, uncertain_param_vars=m.uncertain_param_vars + ) + self.assertEqual(m.uncertain_param_vars[0].bounds, (-1.5, 1.5)) + self.assertEqual(m.uncertain_param_vars[1].bounds, (-0.5, 3.5)) + self.assertEqual(m.uncertain_param_vars[2].bounds, (1, 1)) + + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + aeset = AxisAlignedEllipsoidalSet(center=np.zeros(3), half_lengths=[1, 2, 0]) + self.assertEqual( + aeset._is_coordinate_fixed(config=Bunch()), [False, False, True] + ) + + +class TestEllipsoidalSet(unittest.TestCase): + """ + Tests for the EllipsoidalSet. + """ + + def test_normal_construction_and_update(self): + """ + Test EllipsoidalSet constructor and setter + work normally when arguments are appropriate. + """ + center = [0, 0] + shape_matrix = [[1, 0], [0, 2]] + scale = 2 + eset = EllipsoidalSet(center, shape_matrix, scale) + np.testing.assert_allclose( + center, eset.center, err_msg="EllipsoidalSet center not as expected" + ) + np.testing.assert_allclose( + shape_matrix, + eset.shape_matrix, + err_msg="EllipsoidalSet shape matrix not as expected", + ) + np.testing.assert_allclose( + scale, eset.scale, err_msg="EllipsoidalSet scale not as expected" + ) + np.testing.assert_allclose( + # evaluate chisquare CDF for 2 degrees of freedom + # using simplified formula + 1 - np.exp(-scale / 2), + eset.gaussian_conf_lvl, + err_msg="EllipsoidalSet Gaussian confidence level not as expected", + ) + + # check attributes update + new_center = [-1, -3] + new_shape_matrix = [[2, 1], [1, 3]] + new_scale = 1 + + eset.center = new_center + eset.shape_matrix = new_shape_matrix + eset.scale = new_scale + + np.testing.assert_allclose( + new_center, + eset.center, + err_msg="EllipsoidalSet center update not as expected", + ) + np.testing.assert_allclose( + new_shape_matrix, + eset.shape_matrix, + err_msg="EllipsoidalSet shape matrix update not as expected", + ) + np.testing.assert_allclose( + new_scale, eset.scale, err_msg="EllipsoidalSet scale update not as expected" + ) + np.testing.assert_allclose( + # evaluate chisquare CDF for 2 degrees of freedom + # using simplified formula + 1 - np.exp(-new_scale / 2), + eset.gaussian_conf_lvl, + err_msg="EllipsoidalSet Gaussian confidence level update not as expected", + ) + + def test_normal_construction_and_update_gaussian_conf_lvl(self): + """ + Test EllipsoidalSet constructor and setter + work normally when arguments are appropriate. + """ + init_conf_lvl = 0.95 + eset = EllipsoidalSet( + center=[0, 0, 0], + shape_matrix=np.eye(3), + scale=None, + gaussian_conf_lvl=init_conf_lvl, + ) + + self.assertEqual(eset.gaussian_conf_lvl, init_conf_lvl) + np.testing.assert_allclose( + sp.stats.chi2.isf(q=1 - init_conf_lvl, df=eset.dim), + eset.scale, + err_msg="EllipsoidalSet scale not as expected", + ) + + new_conf_lvl = 0.99 + eset.gaussian_conf_lvl = new_conf_lvl + self.assertEqual(eset.gaussian_conf_lvl, new_conf_lvl) + np.testing.assert_allclose( + sp.stats.chi2.isf(q=1 - new_conf_lvl, df=eset.dim), + eset.scale, + err_msg="EllipsoidalSet scale not as expected", + ) + + def test_error_on_ellipsoidal_dim_change(self): + """ + EllipsoidalSet dimension is considered immutable. + Test ValueError raised when center size is not equal + to set dimension. + """ + shape_matrix = [[1, 0], [0, 1]] + scale = 2 + + eset = EllipsoidalSet([0, 0], shape_matrix, scale) + + exc_str = r"Attempting to set.*dimension 2 to value of dimension 3" + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + eset.center = [0, 0, 0] + + def test_error_on_neg_scale(self): + """ + Test ValueError raised if scale attribute set to negative + value. + """ + center = [0, 0] + shape_matrix = [[1, 0], [0, 2]] + neg_scale = -1 + + exc_str = r".*must be a non-negative real \(provided.*-1\)" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + EllipsoidalSet(center, shape_matrix, neg_scale) + + # construct a valid EllipsoidalSet + eset = EllipsoidalSet(center, shape_matrix, scale=2) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + eset.scale = neg_scale + + def test_error_invalid_gaussian_conf_lvl(self): + """ + Test error when attempting to initialize with Gaussian + confidence level outside range. + """ + center = [0, 0] + shape_matrix = [[1, 0], [0, 2]] + invalid_conf_lvl = 1.001 + + exc_str = r"Ensure the confidence level is a value in \[0, 1\)." + + # error on construction + with self.assertRaisesRegex(ValueError, exc_str): + EllipsoidalSet( + center=center, + shape_matrix=shape_matrix, + scale=None, + gaussian_conf_lvl=invalid_conf_lvl, + ) + + # error on updating valid ellipsoid + eset = EllipsoidalSet(center, shape_matrix, scale=None, gaussian_conf_lvl=0.95) + with self.assertRaisesRegex(ValueError, exc_str): + eset.gaussian_conf_lvl = invalid_conf_lvl + + # negative confidence level + eset = EllipsoidalSet(center, shape_matrix, scale=None, gaussian_conf_lvl=0.95) + with self.assertRaisesRegex(ValueError, exc_str): + eset.gaussian_conf_lvl = -0.1 + + def test_error_scale_gaussian_conf_lvl_construction(self): + """ + Test exception raised if neither or both of + `scale` and `gaussian_conf_lvl` are None. + """ + exc_str = r"Exactly one of `scale` and `gaussian_conf_lvl` should be None" + with self.assertRaisesRegex(ValueError, exc_str): + EllipsoidalSet([0], [[1]], scale=None, gaussian_conf_lvl=None) + + with self.assertRaisesRegex(ValueError, exc_str): + EllipsoidalSet([0], [[1]], scale=1, gaussian_conf_lvl=0.95) + + def test_error_on_shape_matrix_with_wrong_size(self): + """ + Test error in event EllipsoidalSet shape matrix + is not in accordance with set dimension. + """ + center = [0, 0] + invalid_shape_matrix = [[1, 0]] + scale = 1 + + exc_str = r".*must be a square matrix of size 2.*\(provided.*shape \(1, 2\)\)" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + EllipsoidalSet(center, invalid_shape_matrix, scale) + + # construct a valid EllipsoidalSet + eset = EllipsoidalSet(center, [[1, 0], [0, 1]], scale) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + eset.shape_matrix = invalid_shape_matrix + + def test_error_on_invalid_shape_matrix(self): + """ + Test exceptional cases of invalid square shape matrix + arguments + """ + center = [0, 0] + scale = 3 + + # assert error on construction + with self.assertRaisesRegex( + ValueError, + r"Shape matrix must be symmetric", + msg="Asymmetric shape matrix test failed", + ): + EllipsoidalSet(center, [[1, 1], [0, 1]], scale) + with self.assertRaises( + np.linalg.LinAlgError, msg="Singular shape matrix test failed" + ): + EllipsoidalSet(center, [[0, 0], [0, 0]], scale) + with self.assertRaisesRegex( + ValueError, + r"Non positive-definite.*", + msg="Indefinite shape matrix test failed", + ): + EllipsoidalSet(center, [[1, 0], [0, -2]], scale) + + # construct a valid EllipsoidalSet + eset = EllipsoidalSet(center, [[1, 0], [0, 2]], scale) + + # assert error on update + with self.assertRaisesRegex( + ValueError, + r"Shape matrix must be symmetric", + msg="Asymmetric shape matrix test failed", + ): + eset.shape_matrix = [[1, 1], [0, 1]] + with self.assertRaises( + np.linalg.LinAlgError, msg="Singular shape matrix test failed" + ): + eset.shape_matrix = [[0, 0], [0, 0]] + with self.assertRaisesRegex( + ValueError, + r"Non positive-definite.*", + msg="Indefinite shape matrix test failed", + ): + eset.shape_matrix = [[1, 0], [0, -2]] + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + eset = EllipsoidalSet( + center=[1, 1.5], shape_matrix=[[1, 0.5], [0.5, 1]], scale=2.5 + ) + uq = eset.set_as_constraint(uncertain_params=None, block=m) + + self.assertEqual(uq.auxiliary_vars, []) + self.assertEqual(len(uq.uncertain_param_vars), 2) + self.assertEqual(len(uq.uncertainty_cons), 1) + self.assertIs(uq.block, m) + + var1, var2 = uq.uncertain_param_vars + + assertExpressionsEqual( + self, + uq.uncertainty_cons[0].expr, + ( + np.float64(4 / 3) * (var1 - np.float64(1.0)) * (var1 - np.float64(1.0)) + + np.float64(-2 / 3) + * (var1 - np.float64(1.0)) + * (var2 - np.float64(1.5)) + + np.float64(-2 / 3) + * (var2 - np.float64(1.5)) + * (var1 - np.float64(1.0)) + + np.float64(4 / 3) + * (var2 - np.float64(1.5)) + * (var2 - np.float64(1.5)) + <= 2.5 + ), + ) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain parameters + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + eset = EllipsoidalSet( + center=[1, 1.5], shape_matrix=[[1, 0.5], [0.5, 1]], scale=2.5 + ) + with self.assertRaisesRegex(ValueError, ".*dimension"): + eset.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1], initialize=0, mutable=True) + eset = EllipsoidalSet( + center=[1, 1.5], shape_matrix=[[1, 0.5], [0.5, 1]], scale=2.5 + ) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + eset.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + eset.set_as_constraint(uncertain_params=m.p1, block=m) + + def test_point_in_set(self): + eset = EllipsoidalSet( + center=[1, 1.5], shape_matrix=[[1, 0.5], [0.5, 1]], scale=2.5 + ) + sqrt_mat = np.linalg.cholesky(eset.shape_matrix) + sqrt_scale = eset.scale**0.5 + center = eset.center + self.assertTrue(eset.point_in_set(eset.center)) + + # some boundary points + self.assertTrue(eset.point_in_set(center + sqrt_mat @ [0, sqrt_scale])) + self.assertTrue(eset.point_in_set(center + sqrt_mat @ [sqrt_scale, 0])) + self.assertTrue(eset.point_in_set(center + sqrt_mat @ [0, -sqrt_scale])) + self.assertTrue(eset.point_in_set(center + sqrt_mat @ [-sqrt_scale, 0])) + + self.assertFalse(eset.point_in_set(center + sqrt_mat @ [0, sqrt_scale * 2])) + self.assertFalse(eset.point_in_set(center + sqrt_mat @ [sqrt_scale * 2, 0])) + self.assertFalse(eset.point_in_set(center + sqrt_mat @ [0, -sqrt_scale * 2])) + self.assertFalse(eset.point_in_set(center + sqrt_mat @ [-sqrt_scale * 2, 0])) + + # test singleton + eset.scale = 0 + self.assertTrue(eset.point_in_set(eset.center)) + self.assertTrue(eset.point_in_set(eset.center + [5e-9, 0])) + self.assertFalse(eset.point_in_set(eset.center + [1e-4, 1e-4])) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + eset.point_in_set([1, 2, 3, 4]) + + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_compute_parameter_bounds(self): + """ + Test parameter bounds computations give expected results. + """ + baron = SolverFactory("baron") + eset = EllipsoidalSet( + center=[1, 1.5], shape_matrix=[[1, 0.5], [0.5, 1]], scale=0.25 + ) + computed_bounds = eset._compute_parameter_bounds(baron) + np.testing.assert_allclose(computed_bounds, [[0.5, 1.5], [1.0, 2.0]]) + np.testing.assert_allclose(computed_bounds, eset.parameter_bounds) + + eset2 = EllipsoidalSet( + center=[1, 1.5], shape_matrix=[[1, 0.5], [0.5, 1]], scale=2.25 + ) + computed_bounds_2 = eset2._compute_parameter_bounds(baron) + + # add absolute tolerance to account from + # matrix inversion and roundoff errors + np.testing.assert_allclose(computed_bounds_2, [[-0.5, 2.5], [0, 3]], atol=1e-8) + np.testing.assert_allclose(computed_bounds_2, eset2.parameter_bounds, atol=1e-8) + + def test_add_bounds_on_uncertain_parameters(self): + m = ConcreteModel() + m.uncertain_param_vars = Var([0, 1], initialize=0) + eset = EllipsoidalSet( + center=[1, 1.5], shape_matrix=[[1, 0.5], [0.5, 1]], scale=0.25 + ) + eset._add_bounds_on_uncertain_parameters( + global_solver=None, uncertain_param_vars=m.uncertain_param_vars + ) + self.assertEqual(m.uncertain_param_vars[0].bounds, (0.5, 1.5)) + self.assertEqual(m.uncertain_param_vars[1].bounds, (1, 2)) + + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + eset = EllipsoidalSet( + center=np.zeros(3), shape_matrix=np.diag([1, 2, 3]), scale=1 + ) + self.assertEqual( + eset._is_coordinate_fixed(config=Bunch()), [False, False, False] + ) + eset.scale = 0 + self.assertEqual(eset._is_coordinate_fixed(config=Bunch()), [True, True, True]) + + +class TestPolyhedralSet(unittest.TestCase): + """ + Tests for the PolyhedralSet. + """ + + def test_normal_construction_and_update(self): + """ + Test PolyhedralSet constructor and attribute setters work + appropriately. + """ + lhs_coefficients_mat = [[1, 2, 3], [4, 5, 6]] + rhs_vec = [1, 3] + + pset = PolyhedralSet(lhs_coefficients_mat, rhs_vec) + + # check attributes are as expected + np.testing.assert_allclose(lhs_coefficients_mat, pset.coefficients_mat) + np.testing.assert_allclose(rhs_vec, pset.rhs_vec) + + # update the set + pset.coefficients_mat = [[1, 0, 1], [1, 1, 1.5]] + pset.rhs_vec = [3, 4] + + # check updates work + np.testing.assert_allclose([[1, 0, 1], [1, 1, 1.5]], pset.coefficients_mat) + np.testing.assert_allclose([3, 4], pset.rhs_vec) + + def test_error_on_polyhedral_set_dim_change(self): + """ + PolyhedralSet dimension (number columns of 'coefficients_mat') + is considered immutable. + Test ValueError raised if attempt made to change dimension. + """ + # construct valid set + pset = PolyhedralSet([[1, 2, 3], [4, 5, 6]], [1, 3]) + + exc_str = ( + r".*must have 3 columns to match set dimension \(provided.*2 columns\)" + ) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + pset.coefficients_mat = [[1, 2], [3, 4]] + + def test_error_on_inconsistent_rows(self): + """ + Number of rows of budget membership mat is immutable. + Similarly, size of rhs_vec is immutable. + Check ValueError raised in event of attempted change. + """ + coeffs_mat_exc_str = ( + r".*must have 2 rows to match shape of attribute 'rhs_vec' " + r"\(provided.*3 rows\)" + ) + rhs_vec_exc_str = ( + r".*must have 2 entries to match shape of attribute " + r"'coefficients_mat' \(provided.*3 entries\)" + ) + # assert error on construction + with self.assertRaisesRegex(ValueError, rhs_vec_exc_str): + PolyhedralSet([[1, 2], [3, 4]], rhs_vec=[1, 3, 3]) + + # construct a valid polyhedral set + # (2 x 2 coefficients, 2-vector for RHS) + pset = PolyhedralSet([[1, 2], [3, 4]], rhs_vec=[1, 3]) + + # assert error on update + with self.assertRaisesRegex(ValueError, coeffs_mat_exc_str): + # 3 x 2 matrix row mismatch + pset.coefficients_mat = [[1, 2], [3, 4], [5, 6]] + with self.assertRaisesRegex(ValueError, rhs_vec_exc_str): + # 3-vector mismatches 2 rows + pset.rhs_vec = [1, 3, 2] + + def test_error_on_empty_set(self): + """ + Check ValueError raised if nonemptiness check performed + at construction returns a negative result. + """ + exc_str = r"PolyhedralSet.*is empty.*" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + PolyhedralSet([[1], [-1]], rhs_vec=[1, -3]) + + def test_error_on_polyhedral_mat_all_zero_columns(self): + """ + Test ValueError raised if budget membership mat + has a column with all zeros. + """ + invalid_col_mat = [[0, 0, 1], [0, 0, 1], [0, 0, 1]] + rhs_vec = [1, 1, 2] + + exc_str = r".*all entries zero in columns at indexes: 0, 1.*" + + # assert error on construction + with self.assertRaisesRegex(ValueError, exc_str): + PolyhedralSet(invalid_col_mat, rhs_vec) + + # construct a valid budget set + pset = PolyhedralSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], rhs_vec) + + # assert error on update + with self.assertRaisesRegex(ValueError, exc_str): + pset.coefficients_mat = invalid_col_mat + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + pset = PolyhedralSet( + lhs_coefficients_mat=[[1, 0], [-1, 1], [-1, -1]], rhs_vec=[2, -1, -1] + ) + uq = pset.set_as_constraint(uncertain_params=None, block=m) + + self.assertEqual(uq.auxiliary_vars, []) + self.assertEqual(len(uq.uncertain_param_vars), 2) + self.assertEqual(len(uq.uncertainty_cons), 3) + self.assertIs(uq.block, m) + + var1, var2 = uq.uncertain_param_vars + + assertExpressionsEqual( + self, uq.uncertainty_cons[0].expr, var1 + np.int_(0) * var2 <= np.int_(2) + ) + assertExpressionsEqual( + self, + uq.uncertainty_cons[1].expr, + np.int_(-1) * var1 + np.int_(1) * var2 <= np.int_(-1), + ) + assertExpressionsEqual( + self, + uq.uncertainty_cons[2].expr, + np.int_(-1) * var1 + np.int_(-1) * var2 <= np.int_(-1), + ) + + def test_set_as_constraint_dim_mismatch(self): + """ + Check exception raised if number of uncertain parameters + does not match the dimension. + """ + m = ConcreteModel() + m.v1 = Var(initialize=0) + pset = PolyhedralSet( + lhs_coefficients_mat=[[1, 0], [-1, 1], [-1, -1]], rhs_vec=[2, -1, -1] + ) + with self.assertRaisesRegex(ValueError, ".*dimension"): + pset.set_as_constraint(uncertain_params=[m.v1], block=m) + + def test_set_as_constraint_type_mismatch(self): + """ + Check exception raised if uncertain parameter variables + are of invalid type. + """ + m = ConcreteModel() + m.p1 = Param([0, 1], initialize=0, mutable=True) + pset = PolyhedralSet( + lhs_coefficients_mat=[[1, 0], [-1, 1], [-1, -1]], rhs_vec=[2, -1, -1] + ) + with self.assertRaisesRegex(TypeError, ".*valid component type"): + pset.set_as_constraint(uncertain_params=[m.p1[0], m.p1[1]], block=m) + + with self.assertRaisesRegex(TypeError, ".*valid component type"): + pset.set_as_constraint(uncertain_params=m.p1, block=m) + + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_compute_parameter_bounds(self): + """ + Test parameter bounds computations give expected results. + """ + pset = PolyhedralSet( + lhs_coefficients_mat=[[1, 0], [-1, 1], [-1, -1]], rhs_vec=[2, -1, -1] + ) + self.assertEqual(pset.parameter_bounds, []) + computed_bounds = pset._compute_parameter_bounds(SolverFactory("baron")) + self.assertEqual(computed_bounds, [(1, 2), (-1, 1)]) + + def test_point_in_set(self): + """ + Test point in set checks work as expected. + """ + pset = PolyhedralSet( + lhs_coefficients_mat=[[1, 0], [-1, 1], [-1, -1]], rhs_vec=[2, -1, -1] + ) + self.assertTrue(pset.point_in_set([1, 0])) + self.assertTrue(pset.point_in_set([2, 1])) + self.assertTrue(pset.point_in_set([2, -1])) + self.assertFalse(pset.point_in_set([1, 1])) + self.assertFalse(pset.point_in_set([-1, 0])) + self.assertFalse(pset.point_in_set([0, 0])) + + # check what happens if dimensions are off + with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): + pset.point_in_set([1, 2, 3, 4]) + + @unittest.skipUnless(baron_available, "Global NLP solver is not available.") + def test_add_bounds_on_uncertain_parameters(self): + m = ConcreteModel() + m.uncertain_param_vars = Var([0, 1], initialize=0) + pset = PolyhedralSet( + lhs_coefficients_mat=[[1, 0], [-1, 1], [-1, -1]], rhs_vec=[2, -1, -1] + ) + pset._add_bounds_on_uncertain_parameters( + global_solver=SolverFactory("baron"), + uncertain_param_vars=m.uncertain_param_vars, + ) + self.assertEqual(m.uncertain_param_vars[0].bounds, (1, 2)) + self.assertEqual(m.uncertain_param_vars[1].bounds, (-1, 1)) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + pset = PolyhedralSet( + lhs_coefficients_mat=[[1, 0], [-1, 0], [0, 1], [0, -1]], + rhs_vec=[1, 1, 1, -1], + ) + baron = SolverFactory("baron") + self.assertEqual( + pset._is_coordinate_fixed(config=Bunch(global_solver=baron)), [False, True] + ) + + +class CustomUncertaintySet(UncertaintySet): + """ + Test simple custom uncertainty set subclass. + """ + + def __init__(self, dim): + self._dim = dim + + @property + def geometry(self): + self.geometry = Geometry.LINEAR + + @property + def dim(self): + return self._dim + + def set_as_constraint(self, uncertain_params=None, block=None): + blk, param_var_list, conlist, aux_vars = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, + uncertain_param_vars=uncertain_params, + dim=self.dim, + num_auxiliary_vars=None, + ) + ) + conlist.add(sum(param_var_list) <= 0) + for var in param_var_list: + conlist.add(-1 <= var) + + return UncertaintyQuantification( + block=blk, + uncertainty_cons=list(conlist.values()), + uncertain_param_vars=param_var_list, + auxiliary_vars=aux_vars, + ) + + def point_in_set(self, point): + point_arr = np.array(point) + return point_arr.sum() <= 0 and np.all(-1 <= point_arr) + + @property + def parameter_bounds(self): + return [(-1, 1)] * self.dim + + +class TestCustomUncertaintySet(unittest.TestCase): + """ + Test for a custom uncertainty set subclass. + """ + + def test_set_as_constraint(self): + """ + Test method for setting up constraints works correctly. + """ + m = ConcreteModel() + custom_set = CustomUncertaintySet(dim=2) + uq = custom_set.set_as_constraint(uncertain_params=None, block=m) + + con1, con2, con3 = uq.uncertainty_cons + var1, var2 = uq.uncertain_param_vars + self.assertEqual(uq.auxiliary_vars, []) + self.assertIs(uq.block, m) + self.assertEqual(len(uq.uncertainty_cons), 3) + self.assertEqual(len(uq.uncertain_param_vars), 2) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_compute_parameter_bounds(self): + """ + Test parameter bounds computations give expected results. + """ + baron = SolverFactory("baron") + custom_set = CustomUncertaintySet(dim=2) + self.assertEqual(custom_set.parameter_bounds, [(-1, 1)] * 2) + self.assertEqual(custom_set._compute_parameter_bounds(baron), [(-1, 1)] * 2) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_is_coordinate_fixed(self): + """ + Test method for checking whether there are coordinates + constrained to a single value. + """ + custom_set = CustomUncertaintySet(dim=3) + custom_set._PARAMETER_BOUNDS_EXACT = True + self.assertEqual( + custom_set._is_coordinate_fixed(config=Bunch()), [False, False, False] + ) + + custom_set._PARAMETER_BOUNDS_EXACT = False + baron = SolverFactory("baron") + self.assertEqual( + custom_set._is_coordinate_fixed(config=Bunch(global_solver=baron)), + [False, False, False], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 028a9f38da1..bf0b2a4ce86 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,68 +10,193 @@ # ___________________________________________________________________________ """ -Abstract and pre-defined classes for representing uncertainty sets (or -uncertain parameter spaces) of two-stage nonlinear robust optimization -models. +This module defines the :class:`~UncertaintySet` abstract base class, +used for representing the uncertainty set of a robust +optimization problem to be solved with PyROS, +and a suite of pre-implemented concrete subclasses, +based on uncertainty sets often used in the robust optimization +literature. +""" -Along with a ``ConcreteModel`` object representing a deterministic model -formulation, an uncertainty set object may be passed to the PyROS solver -to obtain a solution to the model's two-stage robust optimization -counterpart. +import abc +import math +import functools +from numbers import Integral +from collections import namedtuple +from collections.abc import Iterable, MutableSequence +from enum import Enum -Classes -------- -``UncertaintySet`` - Abstract base class for a generic uncertainty set. All other set - types defined in this module are subclasses. A user may implement - their own uncertainty set type as a custom-written subclass. +from pyomo.common.dependencies import numpy as np, scipy as sp +from pyomo.common.modeling import unique_component_name +from pyomo.core.base import ( + Block, + ConstraintList, + ConcreteModel, + maximize, + minimize, + Var, + VarData, +) +from pyomo.core.expr import mutable_expression, native_numeric_types, value +from pyomo.core.util import quicksum, dot_product +from pyomo.opt.results import check_optimal_termination +from pyomo.contrib.pyros.util import ( + copy_docstring, + PARAM_IS_CERTAIN_REL_TOL, + PARAM_IS_CERTAIN_ABS_TOL, + POINT_IN_UNCERTAINTY_SET_TOL, + standardize_component_data, +) -``EllipsoidalSet`` - A hyperellipsoid. -``AxisAlignedEllipsoidalSet`` - An axis-aligned hyperellipsoid. +valid_num_types = tuple(native_numeric_types) -``PolyhedralSet`` - A bounded convex polyhedron/polytope. -``BoxSet`` - A hyperrectangle. +def standardize_uncertain_param_vars(obj, dim): + """ + Standardize an object castable to a list of VarData objects + representing uncertain model parameters, + and check that the length of the resulting list is equal + to the specified dimension. -``BudgetSet`` - A budget set. + Parameters + ---------- + obj : Var, VarData, or iterable of Var/VarData + Object to standardize. + dim : int + Specified dimension. -``CardinalitySet`` - A cardinality set (or gamma set). + Returns + ------- + var_data_list : list of VarData + Standard variable list. + """ + var_data_list = standardize_component_data( + obj=obj, + valid_ctype=Var, + valid_cdatatype=VarData, + ctype_validator=None, + cdatatype_validator=None, + allow_repeats=False, + from_iterable=obj, + ) + if len(var_data_list) != dim: + raise ValueError( + f"Passed {len(var_data_list)} VarData objects representing " + "the uncertain parameters, but the uncertainty set is of " + f"dimension {dim}." + ) -``DiscreteScenarioSet`` - A discrete set of finitely many points. + return var_data_list -``FactorModelSet`` - A factor model set (or net-alpha model set). -``IntersectionSet`` - An intersection of two or more sets, each represented by an - ``UncertaintySet`` object. -""" +def _setup_standard_uncertainty_set_constraint_block( + block, uncertain_param_vars, dim, num_auxiliary_vars=None +): + """ + Set up block to prepare for declaration of uncertainty + set constraints. -import abc -import math -import functools -from numbers import Integral -from collections.abc import Iterable, MutableSequence -from enum import Enum + Parameters + ---------- + block : BlockData or None + Block to be prepared. If `None`, a new concrete block + is instantiated. + uncertain_param_vars : list of VarData or None + Variables representing the main uncertain parameters. + If `None`, then a new IndexedVar object consisting of + `dim` members is declared on `block`. + dim : int + Dimension of the uncertainty set of interest. + num_auxiliary_vars : int + Number of variables representing auxiliary uncertain + parameters to be declared. + + Returns + ------- + block : BlockData + Prepared block. + param_var_data_list : list of VarData + Variable data objects representing the main uncertain + parameters. + con_list : ConstraintList + Empty ConstraintList, to which the uncertainty set constraints + should be added later. + auxiliary_var_list : list of VarData + Variable data objects representing the auxiliary uncertain + parameters. + """ + if block is None: + block = Block(concrete=True) + + if uncertain_param_vars is None: + uncertain_param_indexed_var = Var(range(dim)) + block.add_component( + unique_component_name(block, "uncertain_param_indexed_var"), + uncertain_param_indexed_var, + ) + param_var_data_list = list(uncertain_param_indexed_var.values()) + else: + # resolve arguments + param_var_data_list = standardize_uncertain_param_vars( + uncertain_param_vars, dim=dim + ) + con_list = ConstraintList() + block.add_component( + unique_component_name(block, "uncertainty_set_conlist"), con_list + ) + + auxiliary_var_list = [] + if num_auxiliary_vars is not None: + auxiliary_param_var = Var(range(num_auxiliary_vars)) + block.add_component( + unique_component_name(block, "auxiliary_param_var"), auxiliary_param_var + ) + auxiliary_var_list = list(auxiliary_param_var.values()) -from pyomo.common.dependencies import numpy as np, scipy as sp -from pyomo.core.base import ConcreteModel, Objective, maximize, minimize, Block -from pyomo.core.base.constraint import ConstraintList -from pyomo.core.base.var import Var, IndexedVar -from pyomo.core.expr.numvalue import value, native_numeric_types -from pyomo.opt.results import check_optimal_termination -from pyomo.contrib.pyros.util import add_bounds_for_uncertain_parameters + return block, param_var_data_list, con_list, auxiliary_var_list -valid_num_types = tuple(native_numeric_types) +UncertaintyQuantification = namedtuple( + "UncertaintyQuantification", + ("block", "uncertainty_cons", "uncertain_param_vars", "auxiliary_vars"), +) +UncertaintyQuantification.__doc__ = """ + A collection of modeling components + generated or addressed by the `set_as_constraint` method of + an uncertainty set object. + + The UncertaintyQuantification class was generated using + the Python :py:func:`~collections.namedtuple` factory function, + so the standard :py:func:`~collections.namedtuple` + attributes and methods + (e.g., :py:meth:`~collections.somenamedtuple._asdict`) + are available. + + Parameters + ---------- + block : BlockData + Block on which the uncertainty set constraints + were added. + uncertainty_cons : list of ConstraintData + The added uncertainty set constraints. + uncertain_param_vars : list of VarData + Variables representing the (main) uncertain parameters. + auxiliary_vars : list of VarData + Variables representing the auxiliary uncertain parameters. +""" +UncertaintyQuantification.block.__doc__ = ( + "Block on which the uncertainty set constraints were added." +) +UncertaintyQuantification.uncertainty_cons.__doc__ = ( + "The added uncertainty set constraints." +) +UncertaintyQuantification.uncertain_param_vars.__doc__ = ( + "Variables representing the (main) uncertain parameters." +) +UncertaintyQuantification.auxiliary_vars.__doc__ = ( + "Variables representing the auxiliary uncertain parameters." +) def validate_arg_type( @@ -157,11 +282,24 @@ def validate_arg_type( def is_ragged(arr, arr_types=None): """ - Determine whether an array-like (such as a list or Numpy ndarray) - is ragged. + Return True if the array-like `arr` is ragged, False otherwise. NOTE: if Numpy ndarrays are considered to be arr types, then zero-dimensional arrays are not considered to be as such. + + Parameters + ---------- + arr : array_like + Array to check. + arr_types : None or iterable of type + Types of entries of `arr` to be considered subarrays. + If `None` is specified, then this is set to + ``(list, numpy.ndarray, tuple)``. + + Returns + ------- + bool + True if ragged, False otherwise. """ arr_types = (list, np.ndarray, tuple) if arr_types is None else arr_types @@ -192,7 +330,23 @@ def is_ragged(arr, arr_types=None): def validate_dimensions(arr_name, arr, dim, display_value=False): """ Validate dimension of an array-like object. - Raise Exception if validation fails. + + Parameters + ---------- + arr_name : str + Name of the array to validate. + arr : array_like + Array to validate. + dim : int + Required dimension of the array. + display_value : bool, optional + True to include the array string representation + in exception messages, False otherwise. + + Raises + ------ + ValueError + If `arr` is ragged or not of the required dimension `dim`. """ if is_ragged(arr): raise ValueError( @@ -217,7 +371,13 @@ def validate_dimensions(arr_name, arr, dim, display_value=False): def validate_array( - arr, arr_name, dim, valid_types, valid_type_desc=None, required_shape=None + arr, + arr_name, + dim, + valid_types, + valid_type_desc=None, + required_shape=None, + required_shape_qual="", ): """ Validate shape and entry types of an array-like object. @@ -244,6 +404,15 @@ def validate_array( corresponding to the position of the entry or `None` (meaning no requirement for the length in the corresponding dimension). + required_shape_qual : str, optional + Clause/phrase expressing reason `arr` should be of shape + `required_shape`, e.g. "to match the set dimension". + + Raises + ------ + ValueError + If the Numpy array to which `arr` is cast is not of shape + `required_shape`. """ np_arr = np.array(arr, dtype=object) validate_dimensions(arr_name, np_arr, dim, display_value=False) @@ -267,9 +436,15 @@ def generate_shape_str(shape, required_shape): if size is not None and size != np_arr.shape[idx]: req_shape_str = generate_shape_str(required_shape, required_shape) actual_shape_str = generate_shape_str(np_arr.shape, required_shape) + required_shape_qual = ( + # add a preceding space, if needed + f" {required_shape_qual}" + if required_shape_qual + else "" + ) raise ValueError( f"Attribute '{arr_name}' should be of shape " - f"{req_shape_str}, but detected shape " + f"{req_shape_str}{required_shape_qual}, but detected shape " f"{actual_shape_str}" ) @@ -283,11 +458,6 @@ def generate_shape_str(shape, required_shape): ) -def column(matrix, i): - # Get column i of a given multi-dimensional list - return [row[i] for row in matrix] - - class Geometry(Enum): """ Geometry classifications for PyROS uncertainty set objects. @@ -310,6 +480,10 @@ class UncertaintySet(object, metaclass=abc.ABCMeta): components of a Pyomo modeling object. """ + # True if parameter_bounds attribute returns + # exact bounding box, False otherwise + _PARAMETER_BOUNDS_EXACT = False + @property @abc.abstractmethod def dim(self): @@ -337,33 +511,26 @@ def parameter_bounds(self): """ raise NotImplementedError - def bounding_model(self, config=None): + def _create_bounding_model(self): """ Make uncertain parameter value bounding problems (optimize value of each uncertain parameter subject to constraints on the uncertain parameters). - Parameters - ---------- - config : None or ConfigDict, optional - If a ConfigDict is provided, then it contains - arguments passed to the PyROS solver. - Returns ------- model : ConcreteModel - Bounding problem, with all Objectives deactivated. + Bounding model, with an indexed mimimization sense + Objective with name 'param_var_objectives' consisting + of `N` entries, all of which have been deactivated. """ model = ConcreteModel() - model.util = Block() # construct param vars, initialize to nominal point model.param_vars = Var(range(self.dim)) # add constraints - model.cons = self.set_as_constraint( - uncertain_params=model.param_vars, model=model, config=config - ) + self.set_as_constraint(uncertain_params=model.param_vars, block=model) @model.Objective(range(self.dim)) def param_var_objectives(self, idx): @@ -400,32 +567,19 @@ def is_bounded(self, config): This method is invoked during the validation step of a PyROS solver call. """ - bounding_model = self.bounding_model(config=config) - solver = config.global_solver - # initialize uncertain parameter variables - for param, param_var in zip( - config.uncertain_params, bounding_model.param_vars.values() - ): - param_var.set_value(param.value, skip_validation=True) - - for idx, obj in bounding_model.param_var_objectives.items(): - # activate objective for corresponding dimension - obj.activate() - - # solve for lower bound, then upper bound - for sense in (minimize, maximize): - obj.sense = sense - res = solver.solve(bounding_model, load_solutions=False, tee=False) - - if not check_optimal_termination(res): - return False + param_bounds_arr = np.array( + self._compute_parameter_bounds(solver=config.global_solver) + ) - # ensure sense is minimize when done, deactivate - obj.sense = minimize - obj.deactivate() + all_bounds_finite = np.all(np.isfinite(param_bounds_arr)) + if not all_bounds_finite: + config.progress_logger.info( + "Computed coordinate value bounds are not all finite. " + f"Got bounds: {param_bounds_arr}" + ) - return True + return all_bounds_finite def is_nonempty(self, config): """ @@ -441,21 +595,28 @@ def is_valid(self, config): return self.is_nonempty(config=config) and self.is_bounded(config=config) @abc.abstractmethod - def set_as_constraint(self, **kwargs): + def set_as_constraint(self, uncertain_params=None, block=None): """ - Construct a (sequence of) mathematical constraint(s) - (represented by Pyomo `Constraint` objects) on the uncertain - parameters to represent the uncertainty set for use in a - two-stage robust optimization problem or subproblem (such as a - PyROS separation subproblem). + Construct a block of Pyomo constraint(s) defining the + uncertainty set on variables representing the uncertain + parameters, for use in a two-stage robust optimization + problem or subproblem (such as a PyROS separation subproblem). Parameters ---------- - **kwargs : dict - Keyword arguments containing, at the very least, a sequence - of `Param` or `Var` objects representing the uncertain - parameters of interest, and any additional information - needed to generate the constraints. + uncertain_params : None, Var, or list of Var, optional + Variable objects representing the (main) uncertain + parameters. If `None` is passed, then + new variable objects are constructed. + block : BlockData or None, optional + Block on which to declare the constraints and any + new variable objects. If `None` is passed, then a new + block is constructed. + + Returns + ------- + UncertaintyQuantification + A collection of the components added or addressed. """ pass @@ -480,55 +641,196 @@ def point_in_set(self, point): determine whether a user-specified nominal parameter realization lies in the uncertainty set. """ - - # === Ensure point is of correct dimensionality as the uncertain parameters - if len(point) != self.dim: - raise AttributeError( - "Point must have same dimensions as uncertain parameters." - ) + validate_array( + arr=point, + arr_name="point", + dim=1, + valid_types=valid_num_types, + valid_type_desc="numeric type", + required_shape=[self.dim], + required_shape_qual="to match the set dimension", + ) m = ConcreteModel() - the_params = [] - for i in range(self.dim): - m.add_component("x_%s" % i, Var(initialize=point[i])) - the_params.append(getattr(m, "x_%s" % i)) + uncertainty_quantification = self.set_as_constraint(block=m) + for var, val in zip(uncertainty_quantification.uncertain_param_vars, point): + var.set_value(val) + + # since constraint expressions are relational, + # `value()` returns True if constraint satisfied, False else + # NOTE: this check may be inaccurate if there are auxiliary + # variables and they have not been initialized to + # feasible values + is_in_set = all( + value(con.expr) for con in uncertainty_quantification.uncertainty_cons + ) - # === Generate constraint for set - set_constraint = self.set_as_constraint(uncertain_params=the_params) + return is_in_set - # === value() returns True if the constraint is satisfied, False else. - is_in_set = all(value(con.expr) for con in set_constraint.values()) + def _compute_parameter_bounds(self, solver, index=None): + """ + Compute lower and upper coordinate value bounds + for every dimension of `self` by solving a bounding model. - return is_in_set + Parameters + ---------- + solver : Pyomo solver type + Optimizer to invoke on the bounding problems. + index : list of int, optional + Positional indices of the coordinates for which + to compute bounds. If None is passed, + then the argument is set to ``list(range(self.dim))``, + so that the bounds for all coordinates are computed. - @staticmethod - def add_bounds_on_uncertain_parameters(**kwargs): + Returns + ------- + param_bounds : list of tuple of float + Each entry of the list is a 2-tuple + containing the lower and upper bound for + the corresponding dimension. + + Raises + ------ + ValueError + If solver failed to compute a bound for a + coordinate. + """ + if index is None: + index = list(range(self.dim)) + + bounding_model = self._create_bounding_model() + objs_to_optimize = ( + (idx, obj) + for idx, obj in bounding_model.param_var_objectives.items() + if idx in index + ) + param_bounds = [] + for idx, obj in objs_to_optimize: + # activate objective for corresponding dimension + obj.activate() + bounds = [] + + # solve for lower bound, then upper bound + # solve should be successful + for sense in (minimize, maximize): + obj.sense = sense + res = solver.solve(bounding_model, load_solutions=False) + if check_optimal_termination(res): + bounding_model.solutions.load_from(res) + else: + raise ValueError( + "Could not compute " + f"{'lower' if sense == minimize else 'upper'} " + f"bound in dimension {idx + 1} of {self.dim}. " + f"Solver status summary:\n {res.solver}." + ) + bounds.append(value(obj)) + + # add parameter bounds for current dimension + param_bounds.append(tuple(bounds)) + + # ensure sense is minimize when done, deactivate + obj.sense = minimize + obj.deactivate() + + return param_bounds + + def _add_bounds_on_uncertain_parameters( + self, uncertain_param_vars, global_solver=None + ): """ - Specify the numerical bounds for the uncertain parameters - restricted by the set. Each uncertain parameter is represented - by a Pyomo `Var` object in a model passed to this method, - and the numerical bounds are specified by setting the - `.lb()` and `.ub()` attributes of the `Var` object. + Specify declared bounds for Vars representing the uncertain + parameters constrained to an uncertainty set. Parameters ---------- - kwargs : dict - Keyword arguments consisting of a Pyomo `ConfigDict` and a - Pyomo `ConcreteModel` object, representing a PyROS solver - configuration and the optimization model of interest. + global_solver : None or Pyomo solver, optional + Optimizer capable of solving bounding problems to + global optimality. If the coordinate bounds for the + set can be retrieved through `self.parameter_bounds`, + then None can be passed. + uncertain_param_vars : Var, VarData, or list of Var/VarData + Variables representing the uncertain parameter objects. Notes ----- This method is invoked in advance of a PyROS separation subproblem. """ - config = kwargs.pop('config') - model = kwargs.pop('model') - _set = config.uncertainty_set - parameter_bounds = _set.parameter_bounds - for i, p in enumerate(model.util.uncertain_param_vars.values()): - p.setlb(parameter_bounds[i][0]) - p.setub(parameter_bounds[i][1]) + uncertain_param_vars = standardize_uncertain_param_vars( + uncertain_param_vars, self.dim + ) + + parameter_bounds = self.parameter_bounds + if not parameter_bounds: + parameter_bounds = self._compute_parameter_bounds(global_solver) + + for (lb, ub), param_var in zip(parameter_bounds, uncertain_param_vars): + param_var.setlb(lb) + param_var.setub(ub) + + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + """ + Compute auxiliary uncertain parameter values for a given point. + The point need not be in the uncertainty set. + + Parameters + ---------- + point : (N,) array-like + Point of interest. + solver : Pyomo solver, optional + If needed, a Pyomo solver with which to compute the + auxiliary values. + + Returns + ------- + aux_space_pt : numpy.ndarray + Computed auxiliary uncertain parameter values. + """ + raise NotImplementedError( + f"Auxiliary parameter computation not supported for {type(self).__name__}." + ) + + def _is_coordinate_fixed(self, config, index=None): + """ + Test whether each Cartesian coordinate of interest + of the uncertainty set is constrained to a single value. + + Parameters + ---------- + config : ConfigDict + PyROS solver options. Should at least contain attribute + `global_solver`. + index : iterable of int, optional + Positional indices of the coordinates to check. + If `None` is passed, then `index` is set to + ``list(range(self.dim))``, so that all coordinates + are checked. + + Returns + ------- + list of bool + Same length as ``index``. + An entry of the list is True if the corresponding + coordinate is constrained to a single value, + False otherwise. + """ + + def _values_close(a, b): + return math.isclose( + a, b, rel_tol=PARAM_IS_CERTAIN_ABS_TOL, abs_tol=PARAM_IS_CERTAIN_REL_TOL + ) + + param_bounds = self.parameter_bounds + if not (param_bounds and self._PARAMETER_BOUNDS_EXACT): + # we need the exact bounding box + param_bounds = self._compute_parameter_bounds( + solver=config.global_solver, index=index + ) + else: + index = list(range(len(param_bounds))) if index is None else index + param_bounds = [param_bounds[idx] for idx in index] + return [_values_close(lb, ub) for lb, ub in param_bounds] class UncertaintySetList(MutableSequence): @@ -724,7 +1026,7 @@ def dim(self): class BoxSet(UncertaintySet): """ - A hyper-rectangle (a.k.a. "box"). + A hyper-rectangle (i.e., "box"). Parameters ---------- @@ -758,6 +1060,8 @@ class BoxSet(UncertaintySet): [0, 1]]) """ + _PARAMETER_BOUNDS_EXACT = True + def __init__(self, bounds): """Initialize self (see class docstring).""" self.bounds = bounds @@ -835,40 +1139,32 @@ def parameter_bounds(self): """ return [tuple(bound) for bound in self.bounds] - def set_as_constraint(self, uncertain_params, **kwargs): - """ - Construct a list of box constraints on a given sequence - of uncertain parameter objects. - - Parameters - ---------- - uncertain_params : list of Param or list of Var - Uncertain parameter objects upon which the constraints - are imposed. - **kwargs : dict, optional - Additional arguments. These arguments are currently - ignored. - - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - """ - conlist = ConstraintList() - conlist.construct() - - set_i = list(range(len(uncertain_params))) + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, uncertain_params=None, block=None): + block, param_var_list, uncertainty_conlist, aux_var_list = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, + uncertain_param_vars=uncertain_params, + dim=self.dim, + num_auxiliary_vars=None, + ) + ) - for i in set_i: - conlist.add(uncertain_params[i] >= self.bounds[i][0]) - conlist.add(uncertain_params[i] <= self.bounds[i][1]) + vardata_bound_zip = zip(param_var_list, self.bounds) + for idx, (param_var, (lb, ub)) in enumerate(vardata_bound_zip): + uncertainty_conlist.add((lb, param_var, ub)) - return conlist + return UncertaintyQuantification( + block=block, + uncertain_param_vars=param_var_list, + uncertainty_cons=list(uncertainty_conlist.values()), + auxiliary_vars=aux_var_list, + ) class CardinalitySet(UncertaintySet): """ - A cardinality-constrained (a.k.a. "gamma") set. + A cardinality-constrained (i.e., "gamma") set. Parameters ---------- @@ -900,6 +1196,8 @@ class CardinalitySet(UncertaintySet): 1 """ + _PARAMETER_BOUNDS_EXACT = True + def __init__(self, origin, positive_deviation, gamma): """Initialize self (see class docstring).""" self.origin = origin @@ -1046,49 +1344,58 @@ def parameter_bounds(self): ] return parameter_bounds - def set_as_constraint(self, uncertain_params, **kwargs): - """ - Construct a list of cardinality set constraints on - a sequence of uncertain parameter objects. + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, uncertain_params=None, block=None): + # resolve arguments + block, param_var_data_list, conlist, aux_var_list = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, + uncertain_param_vars=uncertain_params, + dim=self.dim, + num_auxiliary_vars=self.dim, + ) + ) - Parameters - ---------- - uncertain_params : list of Param or list of Var - Uncertain parameter objects upon which the constraints - are imposed. - **kwargs : dict - Additional arguments. This dictionary should consist - of a `model` entry, which maps to a `ConcreteModel` - object representing the model of interest (parent model - of the uncertain parameter objects). + cardinality_zip = zip( + self.origin, self.positive_deviation, aux_var_list, param_var_data_list + ) + for orig_val, pos_dev, auxvar, param_var in cardinality_zip: + conlist.add(orig_val + pos_dev * auxvar == param_var) - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - """ - # === Ensure dimensions - if len(uncertain_params) != len(self.origin): - raise AttributeError( - "Dimensions of origin and uncertain_param lists must be equal." - ) + conlist.add(quicksum(aux_var_list) <= self.gamma) - model = kwargs['model'] - set_i = list(range(len(uncertain_params))) - model.util.cassi = Var(set_i, initialize=0, bounds=(0, 1)) - - # Make n equality constraints - conlist = ConstraintList() - conlist.construct() - for i in set_i: - conlist.add( - self.origin[i] + self.positive_deviation[i] * model.util.cassi[i] - == uncertain_params[i] - ) + for aux_var in aux_var_list: + aux_var.setlb(0) + aux_var.setub(1) - conlist.add(sum(model.util.cassi[i] for i in set_i) <= self.gamma) + return UncertaintyQuantification( + block=block, + uncertain_param_vars=param_var_data_list, + uncertainty_cons=list(conlist.values()), + auxiliary_vars=aux_var_list, + ) + + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + validate_array( + arr=point, + arr_name="point", + dim=1, + valid_types=valid_num_types, + valid_type_desc="numeric type", + required_shape=[self.dim], + required_shape_qual="to match the set dimension", + ) + point_arr = np.array(point) - return conlist + is_dev_nonzero = self.positive_deviation != 0 + aux_space_pt = np.empty(self.dim) + aux_space_pt[is_dev_nonzero] = ( + point_arr[is_dev_nonzero] - self.origin[is_dev_nonzero] + ) / self.positive_deviation[is_dev_nonzero] + aux_space_pt[self.positive_deviation == 0] = 0 + + return aux_space_pt def point_in_set(self, point): """ @@ -1104,17 +1411,13 @@ def point_in_set(self, point): : bool True if the point lies in the set, False otherwise. """ - cassis = [] - for i in range(self.dim): - if self.positive_deviation[i] > 0: - cassis.append((point[i] - self.origin[i]) / self.positive_deviation[i]) - - if sum(cassi for cassi in cassis) <= self.gamma and all( - cassi >= 0 and cassi <= 1 for cassi in cassis - ): - return True - else: - return False + aux_space_pt = self.compute_auxiliary_uncertain_param_vals(point) + return ( + np.all(point == self.origin + self.positive_deviation * aux_space_pt) + and aux_space_pt.sum() <= self.gamma + and np.all(0 <= aux_space_pt) + and np.all(aux_space_pt <= 1) + ) class PolyhedralSet(UncertaintySet): @@ -1177,7 +1480,7 @@ def _validate(self): c=np.zeros(self.coefficients_mat.shape[1]), A_ub=self.coefficients_mat, b_ub=self.rhs_vec, - method="simplex", + method="highs", bounds=(None, None), ) @@ -1185,7 +1488,7 @@ def _validate(self): if res.status == 1 or res.status == 4: raise ValueError( "Could not verify nonemptiness of the " - "polyhedral set (`scipy.optimize.linprog(method=simplex)` " + "polyhedral set (`scipy.optimize.linprog(method='highs')` " f" status {res.status}) " ) elif res.status == 2: @@ -1318,68 +1621,24 @@ def parameter_bounds(self): """ return [] - def set_as_constraint(self, uncertain_params, **kwargs): - """ - Construct a list of polyhedral constraints on a given sequence - of uncertain parameter objects. - - Parameters - ---------- - uncertain_params : list of Param or list of Var - Uncertain parameter objects upon which the constraints - are imposed. - **kwargs : dict, optional - Additional arguments. These arguments are currently - ignored. - - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - """ - - # === Ensure valid dimensions of lhs and rhs w.r.t uncertain_params - if np.asarray(self.coefficients_mat).shape[1] != len(uncertain_params): - raise AttributeError( - "Columns of coefficients_mat matrix " - "must equal length of uncertain parameters list." + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, uncertain_params=None, block=None): + block, param_var_data_list, conlist, aux_var_list = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, uncertain_param_vars=uncertain_params, dim=self.dim ) + ) - set_i = list(range(len(self.coefficients_mat))) - - conlist = ConstraintList() - conlist.construct() - - for i in set_i: - constraint = 0 - for j in range(len(uncertain_params)): - constraint += float(self.coefficients_mat[i][j]) * uncertain_params[j] - conlist.add(constraint <= float(self.rhs_vec[i])) - - return conlist - - @staticmethod - def add_bounds_on_uncertain_parameters(model, config): - """ - Specify the numerical bounds for each of a sequence of uncertain - parameters, represented by Pyomo `Var` objects, in a modeling - object. The numerical bounds are specified through the `.lb()` - and `.ub()` attributes of the `Var` objects. + for row, rhs_val in zip(self.coefficients_mat, self.rhs_vec): + lhs_expr = dot_product(row, param_var_data_list, index=range(row.size)) + conlist.add(lhs_expr <= rhs_val) - Parameters - ---------- - model : ConcreteModel - Model of interest (parent model of the uncertain parameter - objects for which to specify bounds). - config : ConfigDict - PyROS solver config. - - Notes - ----- - This method is invoked in advance of a PyROS separation - subproblem. - """ - add_bounds_for_uncertain_parameters(model=model, config=config) + return UncertaintyQuantification( + block=block, + uncertain_param_vars=param_var_data_list, + uncertainty_cons=list(conlist.values()), + auxiliary_vars=aux_var_list, + ) class BudgetSet(UncertaintySet): @@ -1433,6 +1692,8 @@ class BudgetSet(UncertaintySet): array([2, 2, 2]) """ + _PARAMETER_BOUNDS_EXACT = True + def __init__(self, budget_membership_mat, rhs_vec, origin=None): """Initialize self (see class docstring).""" self.budget_membership_mat = budget_membership_mat @@ -1661,64 +1922,14 @@ def parameter_bounds(self): return bounds - def set_as_constraint(self, uncertain_params, **kwargs): - """ - Construct a list of the constraints defining the budget - set on a given sequence of uncertain parameter objects. - - Parameters - ---------- - uncertain_params : list of Param or list of Var - Uncertain parameter objects upon which the constraints - are imposed. - **kwargs : dict, optional - Additional arguments. These arguments are currently - ignored. - - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - """ - # === Ensure matrix cols == len uncertain params - if self.dim != len(uncertain_params): - raise ValueError( - f"Argument 'uncertain_params' must contain {self.dim}" - "Param objects to match BudgetSet dimension" - f"(provided {len(uncertain_params)} objects)" - ) - - return PolyhedralSet.set_as_constraint(self, uncertain_params) - - @staticmethod - def add_bounds_on_uncertain_parameters(model, config): - """ - Specify the numerical bounds for each of a sequence of uncertain - parameters, represented by Pyomo `Var` objects, in a modeling - object. The numerical bounds are specified through the `.lb()` - and `.ub()` attributes of the `Var` objects. - - Parameters - ---------- - model : ConcreteModel - Model of interest (parent model of the uncertain parameter - objects for which to specify bounds). - config : ConfigDict - PyROS solver config. - - Notes - ----- - This method is invoked in advance of a PyROS separation - subproblem. - """ - # In this case, we use the UncertaintySet class method - # because we have numerical parameter_bounds - UncertaintySet.add_bounds_on_uncertain_parameters(model=model, config=config) + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, **kwargs): + return PolyhedralSet.set_as_constraint(self, **kwargs) class FactorModelSet(UncertaintySet): """ - A factor model (a.k.a. "net-alpha" model) set. + A factor model (i.e., "net-alpha" model) set. Parameters ---------- @@ -1726,14 +1937,17 @@ class FactorModelSet(UncertaintySet): Uncertain parameter values around which deviations are restrained. number_of_factors : int - Natural number representing the dimensionality of the + Natural number representing the dimension of the space to which the set projects. psi_mat : (N, F) array_like - Matrix designating each uncertain parameter's contribution to - each factor. Each row is associated with a separate uncertain + Matrix, of full column rank, designating each uncertain + parameter's contribution to each factor. + Each row is associated with a separate uncertain parameter. Each column is associated with a separate factor. Number of columns `F` of `psi_mat` should be equal to `number_of_factors`. + Since `psi_mat` is expected to be full column rank, + we require `F <= N`. beta : numeric type Real value between 0 and 1 specifying the fraction of the independent factors that can simultaneously attain @@ -1748,7 +1962,7 @@ class FactorModelSet(UncertaintySet): >>> fset = FactorModelSet( ... origin=np.zeros(4), ... number_of_factors=2, - ... psi_mat=np.full(shape=(4, 2), fill_value=0.1), + ... psi_mat=[[0, 0.1], [0, 0.1], [0.1, 0], [0.1, 0]], ... beta=0.5, ... ) >>> fset.origin @@ -1756,14 +1970,16 @@ class FactorModelSet(UncertaintySet): >>> fset.number_of_factors 2 >>> fset.psi_mat - array([[0.1, 0.1], - [0.1, 0.1], - [0.1, 0.1], - [0.1, 0.1]]) + array([[0. , 0.1], + [0. , 0.1], + [0.1, 0. ], + [0.1, 0. ]]) >>> fset.beta 0.5 """ + _PARAMETER_BOUNDS_EXACT = True + def __init__(self, origin, number_of_factors, psi_mat, beta): """Initialize self (see class docstring).""" self.origin = origin @@ -1811,13 +2027,15 @@ def origin(self, val): @property def number_of_factors(self): """ - int : Natural number representing the dimensionality `F` + int : Natural number representing the dimension `F` of the space to which the set projects. - This attribute is immutable, and may only be set at - object construction. Typically, the number of factors - is significantly less than the set dimension, but no - restriction to that end is imposed here. + This attribute is immutable, may only be set at + object construction, and must be equal to the number of + columns of the factor loading matrix ``self.psi_mat``. + Therefore, since we also require that ``self.psi_mat`` + be full column rank, `number_of_factors` + must not exceed the set dimension. """ return self._number_of_factors @@ -1838,10 +2056,12 @@ def number_of_factors(self, val): @property def psi_mat(self): """ - (N, F) numpy.ndarray : Matrix designating each - uncertain parameter's contribution to each factor. Each row is - associated with a separate uncertain parameter. Each column with - a separate factor. + (N, F) numpy.ndarray : Factor loading matrix, i.e., a full + column rank matrix for which each entry indicates how strongly + the factor corresponding to the entry's column is related + to the uncertain parameter corresponding to the entry's row. + Since `psi_mat` is expected to be full column rank, + we require `F <= N`. """ return self._psi_mat @@ -1868,13 +2088,15 @@ def psi_mat(self, val): f"(provided shape {psi_mat_arr.shape})" ) - # check values acceptable - for column in psi_mat_arr.T: - if np.allclose(column, 0): - raise ValueError( - "Each column of attribute 'psi_mat' should have at least " - "one nonzero entry" - ) + psi_mat_rank = np.linalg.matrix_rank(psi_mat_arr) + is_full_column_rank = psi_mat_rank == self.number_of_factors + if not is_full_column_rank: + raise ValueError( + "Attribute 'psi_mat' should be full column rank. " + f"(Got a matrix of shape {psi_mat_arr.shape} and rank {psi_mat_rank}.) " + "Ensure `psi_mat` does not have more columns than rows, " + "and the columns of `psi_mat` are linearly independent." + ) self._psi_mat = psi_mat_arr @@ -1889,7 +2111,7 @@ def beta(self): that as many factors will be above 0 as there will be below 0 (i.e., "zero-net-alpha" model). If ``beta = 1``, then the set is numerically equivalent to a `BoxSet` with bounds - ``[origin - psi @ np.ones(F), origin + psi @ np.ones(F)].T``. + ``[self.origin - psi @ np.ones(F), self.origin + psi @ np.ones(F)].T``. """ return self._beta @@ -1974,57 +2196,60 @@ def parameter_bounds(self): return parameter_bounds - def set_as_constraint(self, uncertain_params, **kwargs): - """ - Construct a list of factor model constraints on a given sequence - of uncertain parameter objects. - - Parameters - ---------- - uncertain_params : list of Param or list of Var - Uncertain parameter objects upon which the constraints - are imposed. - **kwargs : dict - Additional arguments. This dictionary should consist - of a `model` entry, which maps to a `ConcreteModel` - object representing the model of interest (parent model - of the uncertain parameter objects). - - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - """ - model = kwargs['model'] - - # === Ensure dimensions - if len(uncertain_params) != len(self.origin): - raise AttributeError( - "Dimensions of origin and uncertain_param lists must be equal." + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, uncertain_params=None, block=None): + block, param_var_data_list, uncertainty_conlist, aux_var_list = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, + uncertain_param_vars=uncertain_params, + dim=self.dim, + num_auxiliary_vars=self.number_of_factors, ) + ) - # Make F-dim cassi variable - n = list(range(self.number_of_factors)) - model.util.cassi = Var(n, initialize=0, bounds=(-1, 1)) + factor_zip = zip(self.origin, self.psi_mat, param_var_data_list) + for orig_val, psi_row, param_var in factor_zip: + psi_dot_product = dot_product( + psi_row, aux_var_list, index=range(self.number_of_factors) + ) + uncertainty_conlist.add(orig_val + psi_dot_product == param_var) - conlist = ConstraintList() - conlist.construct() + # absolute value constraints on sum of auxiliary vars + beta_F = self.beta * self.number_of_factors + uncertainty_conlist.add((-beta_F, quicksum(aux_var_list), beta_F)) - disturbances = [ - sum(self.psi_mat[i][j] * model.util.cassi[j] for j in n) - for i in range(len(uncertain_params)) - ] + for var in aux_var_list: + var.setlb(-1) + var.setub(1) - # Make n equality constraints - for i in range(len(uncertain_params)): - conlist.add(self.origin[i] + disturbances[i] == uncertain_params[i]) - conlist.add( - sum(model.util.cassi[i] for i in n) <= +self.beta * self.number_of_factors + return UncertaintyQuantification( + block=block, + uncertain_param_vars=param_var_data_list, + uncertainty_cons=list(uncertainty_conlist.values()), + auxiliary_vars=aux_var_list, ) - conlist.add( - sum(model.util.cassi[i] for i in n) >= -self.beta * self.number_of_factors + + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + validate_array( + arr=point, + arr_name="point", + dim=1, + valid_types=valid_num_types, + valid_type_desc="numeric type", + required_shape=[self.dim], + required_shape_qual="to match the set dimension", ) - return conlist + point_arr = np.array(point) + + # protect against cases where + # `psi_mat` was recently modified entrywise + # to a matrix that is not full column rank + self.psi_mat = self.psi_mat + + # since `psi_mat` is full column rank, + # the pseudoinverse uniquely determines the auxiliary values + return np.linalg.pinv(self.psi_mat) @ (point_arr - self.origin) def point_in_set(self, point): """ @@ -2040,18 +2265,13 @@ def point_in_set(self, point): : bool True if the point lies in the set, False otherwise. """ - inv_psi = np.linalg.pinv(self.psi_mat) - diff = np.asarray(list(point[i] - self.origin[i] for i in range(len(point)))) - cassis = np.dot(inv_psi, np.transpose(diff)) - - if abs( - sum(cassi for cassi in cassis) - ) <= self.beta * self.number_of_factors and all( - cassi >= -1 and cassi <= 1 for cassi in cassis - ): - return True - else: - return False + aux_space_pt = self.compute_auxiliary_uncertain_param_vals(point) + tol = POINT_IN_UNCERTAINTY_SET_TOL + return abs( + aux_space_pt.sum() + ) <= self.beta * self.number_of_factors + tol and np.all( + np.abs(aux_space_pt) <= 1 + tol + ) class AxisAlignedEllipsoidalSet(UncertaintySet): @@ -2081,6 +2301,8 @@ class AxisAlignedEllipsoidalSet(UncertaintySet): """ + _PARAMETER_BOUNDS_EXACT = True + def __init__(self, center, half_lengths): """Initialize self (see class docstring).""" self.center = center @@ -2198,63 +2420,37 @@ def parameter_bounds(self): ] return parameter_bounds - def set_as_constraint(self, uncertain_params, model=None, config=None): - """ - Construct a list of ellipsoidal constraints on a given sequence - of uncertain parameter objects. - - Parameters - ---------- - uncertain_params : {IndexedParam, IndexedVar, list of Param/Var} - Uncertain parameter objects upon which the constraints - are imposed. Indexed parameters are accepted, and - are unpacked for constraint generation. - **kwargs : dict, optional - Additional arguments. These arguments are currently - ignored. - - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - """ - all_params = list() - - # expand all uncertain parameters to a list. - # this accounts for the cases in which `uncertain_params` - # consists of indexed model components, - # or is itself a single indexed component - if not isinstance(uncertain_params, (tuple, list)): - uncertain_params = [uncertain_params] - - all_params = [] - for uparam in uncertain_params: - all_params.extend(uparam.values()) - - if len(all_params) != len(self.center): - raise AttributeError( - f"Center of ellipsoid is of dimension {len(self.center)}," - f" but vector of uncertain parameters is of dimension" - f" {len(all_params)}" + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, uncertain_params=None, block=None): + block, param_var_data_list, uncertainty_conlist, aux_var_list = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, + uncertain_param_vars=uncertain_params, + dim=self.dim, + num_auxiliary_vars=None, ) - - zip_all = zip(all_params, self.center, self.half_lengths) - diffs_squared = list() + ) # now construct the constraints - conlist = ConstraintList() - conlist.construct() + diffs_squared = list() + zip_all = zip(param_var_data_list, self.center, self.half_lengths) for param, ctr, half_len in zip_all: if half_len > 0: diffs_squared.append((param - ctr) ** 2 / (half_len) ** 2) else: # equality constraints for parameters corresponding to # half-lengths of zero - conlist.add(param == ctr) + uncertainty_conlist.add(param == ctr) - conlist.add(sum(diffs_squared) <= 1) + if diffs_squared: + uncertainty_conlist.add(quicksum(diffs_squared) <= 1) - return conlist + return UncertaintyQuantification( + block=block, + uncertain_param_vars=param_var_data_list, + uncertainty_cons=list(uncertainty_conlist.values()), + auxiliary_vars=aux_var_list, + ) class EllipsoidalSet(UncertaintySet): @@ -2266,31 +2462,37 @@ class EllipsoidalSet(UncertaintySet): center : (N,) array-like Center of the ellipsoid. shape_matrix : (N, N) array-like - A positive definite matrix characterizing the shape - and orientation of the ellipsoid. + A symmetric positive definite matrix characterizing + the shape and orientation of the ellipsoid. scale : numeric type, optional Square of the factor by which to scale the semi-axes of the ellipsoid (i.e. the eigenvectors of the shape matrix). The default is `1`. + gaussian_conf_lvl : numeric type, optional + (Fractional) confidence level of the multivariate + normal distribution with mean `center` and covariance + matrix `shape_matrix`. + Exactly one of `scale` and `gaussian_conf_lvl` should be + None; otherwise, an exception is raised. Examples -------- - 3D origin-centered unit hypersphere: + A 3D origin-centered unit ball: >>> from pyomo.contrib.pyros import EllipsoidalSet >>> import numpy as np - >>> hypersphere = EllipsoidalSet( + >>> ball = EllipsoidalSet( ... center=[0, 0, 0], ... shape_matrix=np.eye(3), ... scale=1, ... ) - >>> hypersphere.center + >>> ball.center array([0, 0, 0]) - >>> hypersphere.shape_matrix + >>> ball.shape_matrix array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]) - >>> hypersphere.scale + >>> ball.scale 1 A 2D ellipsoid with custom rotation and scaling: @@ -2308,13 +2510,44 @@ class EllipsoidalSet(UncertaintySet): >>> rotated_ellipsoid.scale 0.5 + A 4D 95% confidence ellipsoid: + + >>> conf_ellipsoid = EllipsoidalSet( + ... center=np.zeros(4), + ... shape_matrix=np.diag(range(1, 5)), + ... scale=None, + ... gaussian_conf_lvl=0.95, + ... ) + >>> conf_ellipsoid.center + array([0, 0, 0, 0]) + >>> conf_ellipsoid.shape_matrix + array([[1, 0, 0, 0]], + [0, 2, 0, 0]], + [0, 0, 3, 0]], + [0, 0, 0. 4]]) + >>> conf_ellipsoid.scale + ...9.4877... + >>> conf_ellipsoid.gaussian_conf_lvl + 0.95 + """ - def __init__(self, center, shape_matrix, scale=1): + _PARAMETER_BOUNDS_EXACT = True + + def __init__(self, center, shape_matrix, scale=1, gaussian_conf_lvl=None): """Initialize self (see class docstring).""" self.center = center self.shape_matrix = shape_matrix - self.scale = scale + + if scale is not None and gaussian_conf_lvl is None: + self.scale = scale + elif scale is None and gaussian_conf_lvl is not None: + self.gaussian_conf_lvl = gaussian_conf_lvl + else: + raise ValueError( + "Exactly one of `scale` and `gaussian_conf_lvl` should be " + f"None (got {scale=}, {gaussian_conf_lvl=})" + ) @property def type(self): @@ -2348,7 +2581,7 @@ def center(self, val): if val_arr.size != self.dim: raise ValueError( "Attempting to set attribute 'center' of " - f"AxisAlignedEllipsoidalSet of dimension {self.dim} " + f"{type(self).__name__} of dimension {self.dim} " f"to value of dimension {val_arr.size}" ) @@ -2427,7 +2660,7 @@ def shape_matrix(self, val): if hasattr(self, "_center"): if not all(size == self.dim for size in shape_mat_arr.shape): raise ValueError( - f"EllipsoidalSet attribute 'shape_matrix' " + f"{type(self).__name__} attribute 'shape_matrix' " f"must be a square matrix of size " f"{self.dim} to match set dimension " f"(provided matrix with shape {shape_mat_arr.shape})" @@ -2450,12 +2683,40 @@ def scale(self, val): validate_arg_type("scale", val, valid_num_types, "a valid numeric type", False) if val < 0: raise ValueError( - "EllipsoidalSet attribute " + f"{type(self).__name__} attribute " f"'scale' must be a non-negative real " f"(provided value {val})" ) self._scale = val + self._gaussian_conf_lvl = sp.stats.chi2.cdf(x=val, df=self.dim) + + @property + def gaussian_conf_lvl(self): + """ + numeric type : (Fractional) confidence level of the + multivariate Gaussian distribution with mean ``self.origin`` + and covariance ``self.shape_matrix`` for ellipsoidal region + with square magnification factor ``self.scale``. + """ + return self._gaussian_conf_lvl + + @gaussian_conf_lvl.setter + def gaussian_conf_lvl(self, val): + validate_arg_type( + "gaussian_conf_lvl", val, valid_num_types, "a valid numeric type", False + ) + + scale_val = sp.stats.chi2.isf(q=1 - val, df=self.dim) + if np.isnan(scale_val) or np.isinf(scale_val): + raise ValueError( + f"Squared scaling factor calculation for confidence level {val} " + f"and set dimension {self.dim} returned {scale_val}. " + "Ensure the confidence level is a value in [0, 1)." + ) + + self._gaussian_conf_lvl = val + self._scale = scale_val @property def dim(self): @@ -2496,54 +2757,54 @@ def parameter_bounds(self): ] return parameter_bounds - def set_as_constraint(self, uncertain_params, **kwargs): - """ - Construct a list of ellipsoidal constraints on a given sequence - of uncertain parameter objects. - - Parameters - ---------- - uncertain_params : {IndexedParam, IndexedVar, list of Param/Var} - Uncertain parameter objects upon which the constraints - are imposed. Indexed parameters are accepted, and - are unpacked for constraint generation. - **kwargs : dict, optional - Additional arguments. These arguments are currently - ignored. - - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - """ - inv_covar = np.linalg.inv(self.shape_matrix) + @copy_docstring(UncertaintySet.point_in_set) + def point_in_set(self, point): + validate_array( + arr=point, + arr_name="point", + dim=1, + valid_types=valid_num_types, + valid_type_desc="numeric type", + required_shape=[self.dim], + required_shape_qual="to match the set dimension", + ) + off_center = point - self.center + normalized_pt_radius = np.sqrt( + off_center @ np.linalg.inv(self.shape_matrix) @ off_center + ) + normalized_boundary_radius = np.sqrt(self.scale) + return ( + normalized_pt_radius + <= normalized_boundary_radius + POINT_IN_UNCERTAINTY_SET_TOL + ) - if len(uncertain_params) != len(self.center): - raise AttributeError( - "Center of ellipsoid must be same dimensions as vector of uncertain parameters." + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, uncertain_params=None, block=None): + block, param_var_data_list, uncertainty_conlist, aux_var_list = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, + uncertain_param_vars=uncertain_params, + dim=self.dim, + num_auxiliary_vars=None, ) + ) - # Calculate row vector of differences - diff = [] - # === Assume VarList uncertain_param_vars - for idx, i in enumerate(uncertain_params): - if uncertain_params[idx].is_indexed(): - for index in uncertain_params[idx]: - diff.append(uncertain_params[idx][index] - self.center[idx]) - else: - diff.append(uncertain_params[idx] - self.center[idx]) - - # Calculate inner product of difference vector and covar matrix - product1 = [ - sum([x * y for x, y in zip(diff, column(inv_covar, i))]) - for i in range(len(inv_covar)) - ] - constraint = sum([x * y for x, y in zip(product1, diff)]) + inv_shape_mat = np.linalg.inv(self.shape_matrix) + with mutable_expression() as expr: + for (idx1, idx2), mat_entry in np.ndenumerate(inv_shape_mat): + expr += ( + mat_entry + * (param_var_data_list[idx1] - self.center[idx1]) + * (param_var_data_list[idx2] - self.center[idx2]) + ) + uncertainty_conlist.add(expr <= self.scale) - conlist = ConstraintList() - conlist.construct() - conlist.add(constraint <= self.scale) - return conlist + return UncertaintyQuantification( + block=block, + uncertain_param_vars=param_var_data_list, + uncertainty_cons=list(uncertainty_conlist.values()), + auxiliary_vars=aux_var_list, + ) class DiscreteScenarioSet(UncertaintySet): @@ -2569,6 +2830,8 @@ class DiscreteScenarioSet(UncertaintySet): """ + _PARAMETER_BOUNDS_EXACT = True + def __init__(self, scenarios): """Initialize self (see class docstring).""" # Standardize to list of tuples @@ -2659,41 +2922,27 @@ def is_bounded(self, config): """ return True - def set_as_constraint(self, uncertain_params, **kwargs): - """ - Construct a list of constraints on a given sequence - of uncertain parameter objects. - - Parameters - ---------- - uncertain_params : list of Param or list of Var - Uncertain parameter objects upon which the constraints - are imposed. - **kwargs : dict, optional - Additional arguments. These arguments are currently - ignored. - - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - """ + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, uncertain_params=None, block=None): # === Ensure point is of correct dimensionality as the uncertain parameters - dim = len(uncertain_params) - if any(len(d) != dim for d in self.scenarios): - raise AttributeError( - "All scenarios must have same dimensions as uncertain parameters." + block, param_var_data_list, uncertainty_conlist, aux_var_list = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, + uncertain_param_vars=uncertain_params, + dim=self.dim, + num_auxiliary_vars=None, ) + ) - conlist = ConstraintList() - conlist.construct() - - for n in list(range(len(self.scenarios))): - for i in list(range(len(uncertain_params))): - conlist.add(uncertain_params[i] == self.scenarios[n][i]) + # no constraints declared for the discrete set; + # instead, the param vars are fixed during separation - conlist.deactivate() - return conlist + return UncertaintyQuantification( + block=block, + uncertainty_cons=list(uncertainty_conlist.values()), + uncertain_param_vars=param_var_data_list, + auxiliary_vars=aux_var_list, + ) def point_in_set(self, point): """ @@ -2710,14 +2959,20 @@ def point_in_set(self, point): : bool True if the point lies in the set, False otherwise. """ - # Round all double precision to a tolerance - num_decimals = 8 - rounded_scenarios = list( - list(round(num, num_decimals) for num in d) for d in self.scenarios + validate_array( + arr=point, + arr_name="point", + dim=1, + valid_types=valid_num_types, + valid_type_desc="numeric type", + required_shape=[self.dim], + required_shape_qual="to match the set dimension", ) - rounded_point = list(round(num, num_decimals) for num in point) - - return any(rounded_point == rounded_d for rounded_d in rounded_scenarios) + # Round all double precision to a tolerance + num_decimals = round(-np.log10(POINT_IN_UNCERTAINTY_SET_TOL)) + rounded_scenarios = np.round(self.scenarios, decimals=num_decimals) + rounded_point = np.round(point, decimals=num_decimals) + return np.any(np.all(rounded_point == rounded_scenarios, axis=1)) class IntersectionSet(UncertaintySet): @@ -2841,61 +3096,13 @@ def point_in_set(self, point): else: return False - def is_empty_intersection(self, uncertain_params, nlp_solver): - """ - Determine if intersection is empty. - - Arguments - --------- - uncertain_params : list of Param or list of Var - List of uncertain parameter objects. - nlp_solver : Pyomo SolverFactory object - NLP solver. - - Returns - ------- - is_empty_intersection : bool - True if the intersection is certified to be empty, - and False otherwise. - """ - - # === Non-emptiness check for the set intersection - is_empty_intersection = True - if any(a_set.type == "discrete" for a_set in self.all_sets): - disc_sets = (a_set for a_set in self.all_sets if a_set.type == "discrete") - disc_set = min( - disc_sets, key=lambda x: len(x.scenarios) - ) # minimum set of scenarios - # === Ensure there is at least one scenario from this discrete set which is a member of all other sets - for scenario in disc_set.scenarios: - if all(a_set.point_in_set(point=scenario) for a_set in self.all_sets): - is_empty_intersection = False - break - else: - # === Compile constraints and solve NLP - m = ConcreteModel() - m.obj = Objective(expr=0) # dummy objective required if using baron - m.param_vars = Var(uncertain_params.index_set()) - for a_set in self.all_sets: - m.add_component( - a_set.type + "_constraints", - a_set.set_as_constraint(uncertain_params=m.param_vars), - ) - try: - res = nlp_solver.solve(m) - except: - raise ValueError( - "Solver terminated with an error while checking set intersection non-emptiness." - ) - if check_optimal_termination(res): - is_empty_intersection = False - return is_empty_intersection - # === Define pairwise intersection function @staticmethod def intersect(Q1, Q2): """ - Obtain the intersection of two uncertainty sets. + Obtain the intersection of two uncertainty sets, + accounting for the case where either of the two sets + is discrete. Parameters ---------- @@ -2904,113 +3111,52 @@ def intersect(Q1, Q2): Returns ------- - : DiscreteScenarioSet or IntersectionSet + DiscreteScenarioSet or IntersectionSet Intersection of the sets. A `DiscreteScenarioSet` is returned if both operand sets are `DiscreteScenarioSet` instances; otherwise, an `IntersectionSet` is returned. """ - constraints = ConstraintList() - constraints.construct() - - for set in (Q1, Q2): - other = Q1 if set is Q2 else Q2 - if set.type == "discrete": - intersected_scenarios = [] - for point in set.scenarios: - if other.point_in_set(point=point): - intersected_scenarios.append(point) - return DiscreteScenarioSet(scenarios=intersected_scenarios) + for set1, set2 in zip((Q1, Q2), (Q2, Q1)): + if isinstance(set1, DiscreteScenarioSet): + return DiscreteScenarioSet( + scenarios=[pt for pt in set1.scenarios if set1.point_in_set(pt)] + ) # === This case is if both sets are continuous return IntersectionSet(set1=Q1, set2=Q2) - return - - def set_as_constraint(self, uncertain_params, **kwargs): - """ - Construct a list of constraints on a given sequence - of uncertain parameter objects. In advance of constructing - the constraints, a check is performed to determine whether - the set is empty. - - Parameters - ---------- - uncertain_params : list of Param or list of Var - Uncertain parameter objects upon which the constraints - are imposed. - **kwargs : dict - Additional arguments. Must contain a `config` entry, - which maps to a `ConfigDict` containing an entry - entitled `global_solver`. The `global_solver` - key maps to an NLP solver, purportedly with global - optimization capabilities. - - Returns - ------- - conlist : ConstraintList - The constraints on the uncertain parameters. - - Raises - ------ - AttributeError - If the intersection set is found to be empty. - """ - try: - nlp_solver = kwargs["config"].global_solver - except: - raise AttributeError( - "set_as_constraint for SetIntersection requires access to an NLP solver via" - "the PyROS Solver config." + @copy_docstring(UncertaintySet.set_as_constraint) + def set_as_constraint(self, uncertain_params=None, block=None): + block, param_var_data_list, uncertainty_conlist, aux_var_list = ( + _setup_standard_uncertainty_set_constraint_block( + block=block, + uncertain_param_vars=uncertain_params, + dim=self.dim, + num_auxiliary_vars=None, ) - is_empty_intersection = self.is_empty_intersection( - uncertain_params=uncertain_params, nlp_solver=nlp_solver ) - def _intersect(Q1, Q2): - return self.intersect(Q1, Q2) - - if not is_empty_intersection: - Qint = functools.reduce(_intersect, self.all_sets) - - if Qint.type == "discrete": - return Qint.set_as_constraint(uncertain_params=uncertain_params) - else: - conlist = ConstraintList() - conlist.construct() - for set in Qint.all_sets: - for con in list( - set.set_as_constraint( - uncertain_params=uncertain_params - ).values() - ): - conlist.add(con.expr) - return conlist - else: - raise AttributeError( - "Set intersection is empty, cannot proceed with PyROS robust optimization." + intersection_set = functools.reduce(self.intersect, self.all_sets) + if isinstance(intersection_set, DiscreteScenarioSet): + return intersection_set.set_as_constraint( + uncertain_params=uncertain_params, block=block ) - @staticmethod - def add_bounds_on_uncertain_parameters(model, config): - """ - Specify the numerical bounds for each of a sequence of uncertain - parameters, represented by Pyomo `Var` objects, in a modeling - object. The numerical bounds are specified through the `.lb()` - and `.ub()` attributes of the `Var` objects. - - Parameters - ---------- - model : ConcreteModel - Model of interest (parent model of the uncertain parameter - objects for which to specify bounds). - config : ConfigDict - PyROS solver config. - - Notes - ----- - This method is invoked in advance of a PyROS separation - subproblem. - """ - - add_bounds_for_uncertain_parameters(model=model, config=config) - return + all_cons, all_aux_vars = [], [] + for idx, unc_set in enumerate(intersection_set.all_sets): + sub_block = Block() + block.add_component( + unique_component_name(block, f"sub_block_{idx}"), sub_block + ) + set_quantification = unc_set.set_as_constraint( + block=sub_block, uncertain_params=param_var_data_list + ) + all_cons.extend(set_quantification.uncertainty_cons) + all_aux_vars.extend(set_quantification.auxiliary_vars) + + return UncertaintyQuantification( + block=block, + uncertain_param_vars=param_var_data_list, + uncertainty_cons=all_cons, + auxiliary_vars=all_aux_vars, + ) diff --git a/pyomo/contrib/pyros/util.py b/pyomo/contrib/pyros/util.py index a3ab3464aa8..257d2529bf5 100644 --- a/pyomo/contrib/pyros/util.py +++ b/pyomo/contrib/pyros/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,47 +13,53 @@ Utility functions for the PyROS solver ''' -import copy +from collections import namedtuple +from collections.abc import Iterable +from contextlib import contextmanager from enum import Enum, auto -from pyomo.common.collections import ComponentSet, ComponentMap +import functools +import itertools as it +import logging +import math +import timeit + +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.dependencies import scipy as sp +from pyomo.common.errors import ApplicationError, InvalidValueError +from pyomo.common.log import Preformatted from pyomo.common.modeling import unique_component_name +from pyomo.common.timing import HierarchicalTimer, TicTocTimer from pyomo.core.base import ( + Any, + Block, + Component, + ConcreteModel, Constraint, - Var, - ConstraintList, - Objective, - minimize, Expression, - ConcreteModel, + Objective, maximize, - Block, + minimize, Param, + ParamData, + Reals, + Var, + VarData, + value, ) -from pyomo.core.util import prod -from pyomo.core.base.var import IndexedVar -from pyomo.core.base.set_types import Reals -from pyomo.opt import TerminationCondition as tc -from pyomo.core.expr import value -from pyomo.core.expr.numeric_expr import NPV_MaxExpression, NPV_MinExpression -from pyomo.repn.standard_repn import generate_standard_repn +from pyomo.core.expr.numeric_expr import SumExpression +from pyomo.core.expr.numvalue import native_types from pyomo.core.expr.visitor import ( identify_variables, identify_mutable_parameters, replace_expressions, ) -from pyomo.common.dependencies import scipy as sp -from pyomo.core.expr.numvalue import native_types +from pyomo.core.util import prod +from pyomo.opt import SolverFactory +import pyomo.repn.ampl as pyomo_ampl_repn +from pyomo.repn.parameterized_quadratic import ParameterizedQuadraticRepnVisitor +import pyomo.repn.plugins.nl_writer as pyomo_nl_writer +from pyomo.repn.util import OrderedVarRecorder from pyomo.util.vars_from_expressions import get_vars_from_components -from pyomo.core.expr.numeric_expr import SumExpression -from pyomo.environ import SolverFactory - -import itertools as it -import timeit -from contextlib import contextmanager -import logging -import math -from pyomo.common.timing import HierarchicalTimer -from pyomo.common.log import Preformatted # Tolerances used in the code @@ -62,8 +68,13 @@ COEFF_MATCH_REL_TOL = 1e-6 COEFF_MATCH_ABS_TOL = 0 ABS_CON_CHECK_FEAS_TOL = 1e-5 +PRETRIANGULAR_VAR_COEFF_TOL = 1e-6 +POINT_IN_UNCERTAINTY_SET_TOL = 1e-8 +DR_POLISHING_PARAM_PRODUCT_ZERO_TOL = 1e-10 + TIC_TOC_SOLVE_TIME_ATTR = "pyros_tic_toc_time" DEFAULT_LOGGER_NAME = "pyomo.contrib.pyros" +DEFAULT_SEPARATION_PRIORITY = 0 class TimingData: @@ -199,19 +210,22 @@ def get_main_elapsed_time(self): @contextmanager def time_code(timing_data_obj, code_block_name, is_main_timer=False): - """ - Starts timer at entry, stores elapsed time at exit. + """Starts timer at entry, stores elapsed time at exit. Parameters ---------- timing_data_obj : TimingData Timing data object. + code_block_name : str Name of code block being timed. - If `is_main_timer=True`, the start time is stored in the timing_data_obj, - allowing calculation of total elapsed time 'on the fly' (e.g. to enforce - a time limit) using `get_main_elapsed_time(timing_data_obj)`. + is_main_timer : bool + If ``is_main_timer=True``, the start time is stored in the + timing_data_obj, allowing calculation of total elapsed time 'on + the fly' (e.g. to enforce a time limit) using + ``get_main_elapsed_time(timing_data_obj)``. + """ # initialize tic toc timer timing_data_obj.start_timer(code_block_name) @@ -230,15 +244,15 @@ def get_main_elapsed_time(timing_data_obj): def adjust_solver_time_settings(timing_data_obj, solver, config): """ - Adjust solver max time setting based on current PyROS elapsed - time. + Adjust maximum time allowed for subordinate solver, based + on total PyROS solver elapsed time up to this point. Parameters ---------- timing_data_obj : Bunch PyROS timekeeper. solver : solver type - Solver for which to adjust the max time setting. + Subordinate solver for which to adjust the max time setting. config : ConfigDict PyROS solver config. @@ -260,26 +274,37 @@ def adjust_solver_time_settings(timing_data_obj, solver, config): ---- (1) Adjustment only supported for GAMS, BARON, and IPOPT interfaces. This routine can be generalized to other solvers - after a generic interface to the time limit setting + after a generic Pyomo interface to the time limit setting is introduced. - (2) For IPOPT, and probably also BARON, the CPU time limit - rather than the wallclock time limit, is adjusted, as - no interface to wallclock limit available. - For this reason, extra 30s is added to time remaining - for subsolver time limit. - (The extra 30s is large enough to ensure solver - elapsed time is not beneath elapsed time - user time limit, - but not so large as to overshoot the user-specified time limit - by an inordinate margin.) + (2) For IPOPT and BARON, the CPU time limit, + rather than the wallclock time limit, may be adjusted, + as there may be no means by which to specify the wall time + limit explicitly. + (3) For GAMS, we adjust the time limit through the GAMS Reslim + option. However, this may be overridden by any user + specifications included in a GAMS optfile, which may be + difficult to track down. + (4) To ensure the time limit is specified to a strictly + positive value, the time limit is adjusted to a value of + at least 1 second. """ + # in case there is no time remaining: we set time limit + # to a minimum of 1s, as some solvers require a strictly + # positive time limit + time_limit_buffer = 1 + if config.time_limit is not None: time_remaining = config.time_limit - get_main_elapsed_time(timing_data_obj) if isinstance(solver, type(SolverFactory("gams", solver_io="shell"))): original_max_time_setting = solver.options["add_options"] custom_setting_present = "add_options" in solver.options - # adjust GAMS solver time - reslim_str = f"option reslim={max(30, 30 + time_remaining)};" + # note: our time limit will be overridden by any + # time limits specified by the user through a + # GAMS optfile, but tracking down the optfile + # and/or the GAMS subsolver specific option + # is more difficult + reslim_str = "option reslim=" f"{max(time_limit_buffer, time_remaining)};" if isinstance(solver.options["add_options"], list): solver.options["add_options"].append(reslim_str) else: @@ -289,7 +314,16 @@ def adjust_solver_time_settings(timing_data_obj, solver, config): if isinstance(solver, SolverFactory.get_class("baron")): options_key = "MaxTime" elif isinstance(solver, SolverFactory.get_class("ipopt")): - options_key = "max_cpu_time" + options_key = ( + # IPOPT 3.14.0+ added support for specifying + # wall time limit explicitly; this is preferred + # over CPU time limit + "max_wall_time" + if solver.version() >= (3, 14, 0, 0) + else "max_cpu_time" + ) + elif isinstance(solver, SolverFactory.get_class("scip")): + options_key = "limits/time" else: options_key = None @@ -297,8 +331,19 @@ def adjust_solver_time_settings(timing_data_obj, solver, config): custom_setting_present = options_key in solver.options original_max_time_setting = solver.options[options_key] - # ensure positive value assigned to avoid application error - solver.options[options_key] = max(30, 30 + time_remaining) + # account for elapsed time remaining and + # original time limit setting. + # if no original time limit is set, then we assume + # there is no time limit, rather than tracking + # down the solver-specific default + orig_max_time = ( + float("inf") + if original_max_time_setting is None + else original_max_time_setting + ) + solver.options[options_key] = min( + max(time_limit_buffer, time_remaining), orig_max_time + ) else: custom_setting_present = False original_max_time_setting = None @@ -344,7 +389,16 @@ def revert_solver_max_time_adjustment( elif isinstance(solver, SolverFactory.get_class("baron")): options_key = "MaxTime" elif isinstance(solver, SolverFactory.get_class("ipopt")): - options_key = "max_cpu_time" + options_key = ( + # IPOPT 3.14.0+ added support for specifying + # wall time limit explicitly; this is preferred + # over CPU time limit + "max_wall_time" + if solver.version() >= (3, 14, 0, 0) + else "max_cpu_time" + ) + elif isinstance(solver, SolverFactory.get_class("scip")): + options_key = "limits/time" else: options_key = None @@ -359,12 +413,7 @@ def revert_solver_max_time_adjustment( if isinstance(solver, type(SolverFactory("gams", solver_io="shell"))): solver.options[options_key].pop() else: - # remove the max time specification introduced. - # All lines are needed here to completely remove the option - # from access through getattr and dictionary reference. delattr(solver.options, options_key) - if options_key in solver.options.keys(): - del solver.options[options_key] class PreformattedLogger(logging.Logger): @@ -502,287 +551,112 @@ class ObjectiveType(Enum): nominal = auto() -def recast_to_min_obj(model, obj): +def standardize_component_data( + obj, + valid_ctype, + valid_cdatatype, + ctype_validator=None, + cdatatype_validator=None, + allow_repeats=False, + from_iterable=None, +): """ - Recast model objective to a minimization objective, as necessary. + Cast an object to a list of Pyomo ComponentData objects. Parameters ---------- - model : ConcreteModel - Model of interest. - obj : ScalarObjective - Objective of interest. - """ - if obj.sense is not minimize: - if isinstance(obj.expr, SumExpression): - # ensure additive terms in objective - # are split in accordance with user declaration - obj.expr = sum(-term for term in obj.expr.args) - else: - obj.expr = -obj.expr - obj.sense = minimize - - -def turn_bounds_to_constraints(variable, model, config=None): - ''' - Turn the variable in question's "bounds" into direct inequality constraints on the model. - :param variable: the variable with bounds to be turned to None and made into constraints. - :param model: the model in which the variable resides - :param config: solver config - :return: the list of inequality constraints that are the bounds - ''' - lb, ub = variable.lower, variable.upper - if variable.domain is not Reals: - variable.domain = Reals - - if isinstance(lb, NPV_MaxExpression): - lb_args = lb.args - else: - lb_args = (lb,) - - if isinstance(ub, NPV_MinExpression): - ub_args = ub.args - else: - ub_args = (ub,) - - count = 0 - for arg in lb_args: - if arg is not None: - name = unique_component_name( - model, variable.name + f"_lower_bound_con_{count}" - ) - model.add_component(name, Constraint(expr=arg - variable <= 0)) - count += 1 - variable.setlb(None) - - count = 0 - for arg in ub_args: - if arg is not None: - name = unique_component_name( - model, variable.name + f"_upper_bound_con_{count}" - ) - model.add_component(name, Constraint(expr=variable - arg <= 0)) - count += 1 - variable.setub(None) - - -def get_time_from_solver(results): - """ - Obtain solver time from a Pyomo `SolverResults` object. + obj : Component, ComponentData, or iterable + Object from which component data objects + are cast. + valid_ctype : type or tuple of type + Valid Component type(s). + valid_cdatatype : type or tuple of type + Valid ComponentData type(s). + ctype_validator : None or callable, optional + Validator for component objects derived from `obj`. + cdatatype_validator : None or callable, optional + Validator for component data objects derived from `obj`. + allow_repeats : bool, optional + True to allow for nonunique component data objects + derived from `obj`, False otherwise. + from_iterable : str, optional + Description of the object to include in error messages. + Meant to be used if the object is an iterable from which + to derive component data objects. Returns ------- - : float - Solver time. May be CPU time or elapsed time, - depending on the solver. If no time attribute - is found, then `float("nan")` is returned. - - NOTE - ---- - This method attempts to access solver time through the - attributes of `results.solver` in the following order - of precedence: - - 1) Attribute with name ``pyros.util.TIC_TOC_SOLVE_TIME_ATTR``. - This attribute is an estimate of the elapsed solve time - obtained using the Pyomo `TicTocTimer` at the point the - solver from which the results object is derived was invoked. - Preferred over other time attributes, as other attributes - may be in CPUs, and for purposes of evaluating overhead - time, we require wall s. - 2) `'user_time'` if the results object was returned by a GAMS - solver, `'time'` otherwise. - """ - solver_name = getattr(results.solver, "name", None) - - # is this sufficient to confirm GAMS solver used? - from_gams = solver_name is not None and str(solver_name).startswith("GAMS ") - time_attr_name = "user_time" if from_gams else "time" - for attr_name in [TIC_TOC_SOLVE_TIME_ATTR, time_attr_name]: - solve_time = getattr(results.solver, attr_name, None) - if solve_time is not None: - break + list of ComponentData + The ComponentData objects derived from `obj`. + Note: If `obj` is a valid ComponentData type, + then ``[obj]`` is returned. - return float("nan") if solve_time is None else solve_time - - -def add_bounds_for_uncertain_parameters(model, config): - ''' - This function solves a set of optimization problems to determine bounds on the uncertain parameters - given the uncertainty set description. These bounds will be added as additional constraints to the uncertainty_set_constr - constraint. Should only be called once set_as_constraint() has been called on the separation_model object. - :param separation_model: the model on which to add the bounds - :param config: solver config - :return: - ''' - # === Determine bounds on all uncertain params - uncertain_param_bounds = [] - bounding_model = ConcreteModel() - bounding_model.util = Block() - bounding_model.util.uncertain_param_vars = IndexedVar( - model.util.uncertain_param_vars.index_set() - ) - for tup in model.util.uncertain_param_vars.items(): - bounding_model.util.uncertain_param_vars[tup[0]].set_value( - tup[1].value, skip_validation=True + Raises + ------ + TypeError + If `obj` is not an iterable and not an instance of + `valid_ctype` or `valid_cdatatype`. + ValueError + If ``allow_repeats=False`` and there are duplicates + among the component data objects derived from `obj`. + """ + if isinstance(obj, valid_ctype): + if ctype_validator is not None: + ctype_validator(obj) + ans = list(obj.values()) + if cdatatype_validator is not None: + for entry in ans: + cdatatype_validator(entry) + return ans + elif isinstance(obj, valid_cdatatype): + if cdatatype_validator is not None: + cdatatype_validator(obj) + return [obj] + elif isinstance(obj, Component): + # deal with this case separately from general + # iterables to prevent iteration over an invalid + # component type + raise TypeError( + f"Input object {obj!r} " + "is not of valid component type " + f"{valid_ctype.__name__} or component data type " + f"(got type {type(obj).__name__})." ) - - bounding_model.add_component( - "uncertainty_set_constraint", - config.uncertainty_set.set_as_constraint( - uncertain_params=bounding_model.util.uncertain_param_vars, - model=bounding_model, - config=config, - ), - ) - - for idx, param in enumerate( - list(bounding_model.util.uncertain_param_vars.values()) - ): - bounding_model.add_component( - "lb_obj_" + str(idx), Objective(expr=param, sense=minimize) + elif isinstance(obj, Iterable) and not isinstance(obj, str): + ans = [] + for item in obj: + ans.extend( + standardize_component_data( + item, + valid_ctype=valid_ctype, + valid_cdatatype=valid_cdatatype, + ctype_validator=ctype_validator, + cdatatype_validator=cdatatype_validator, + allow_repeats=allow_repeats, + from_iterable=obj, + ) + ) + else: + from_iterable_qual = ( + f" (entry of iterable {from_iterable})" if from_iterable is not None else "" ) - bounding_model.add_component( - "ub_obj_" + str(idx), Objective(expr=param, sense=maximize) + raise TypeError( + f"Input object {obj!r}{from_iterable_qual} " + "is not of valid component type " + f"{valid_ctype.__name__} or component data type " + f"{valid_cdatatype.__name__} (got type {type(obj).__name__})." ) - for o in bounding_model.component_data_objects(Objective): - o.deactivate() - - for i in range(len(bounding_model.util.uncertain_param_vars)): - bounds = [] - for limit in ("lb", "ub"): - getattr(bounding_model, limit + "_obj_" + str(i)).activate() - res = config.global_solver.solve(bounding_model, tee=False) - bounds.append(bounding_model.util.uncertain_param_vars[i].value) - getattr(bounding_model, limit + "_obj_" + str(i)).deactivate() - uncertain_param_bounds.append(bounds) - - # === Add bounds as constraints to uncertainty_set_constraint ConstraintList - for idx, bound in enumerate(uncertain_param_bounds): - model.util.uncertain_param_vars[idx].setlb(bound[0]) - model.util.uncertain_param_vars[idx].setub(bound[1]) - - return - - -def transform_to_standard_form(model): - """ - Recast all model inequality constraints of the form `a <= g(v)` (`<= b`) - to the 'standard' form `a - g(v) <= 0` (and `g(v) - b <= 0`), - in which `v` denotes all model variables and `a` and `b` are - contingent on model parameters. - - Parameters - ---------- - model : ConcreteModel - The model to search for constraints. This will descend into all - active Blocks and sub-Blocks as well. - - Note - ---- - If `a` and `b` are identical and the constraint is not classified as an - equality (i.e. the `equality` attribute of the constraint object - is `False`), then the constraint is recast to the equality `g(v) == a`. - """ - # Note: because we will be adding / modifying the number of - # constraints, we want to resolve the generator to a list before - # starting. - cons = list( - model.component_data_objects(Constraint, descend_into=True, active=True) - ) - for con in cons: - if not con.equality: - has_lb = con.lower is not None - has_ub = con.upper is not None - - if has_lb and has_ub: - if con.lower is con.upper: - # recast as equality Constraint - con.set_value(con.lower == con.body) - else: - # range inequality; split into two Constraints. - uniq_name = unique_component_name(model, con.name + '_lb') - model.add_component( - uniq_name, Constraint(expr=con.lower - con.body <= 0) - ) - con.set_value(con.body - con.upper <= 0) - elif has_lb: - # not in standard form; recast. - con.set_value(con.lower - con.body <= 0) - elif has_ub: - # move upper bound to body. - con.set_value(con.body - con.upper <= 0) - else: - # unbounded constraint: deactivate - con.deactivate() - - -def get_vars_from_component(block, ctype): - """Determine all variables used in active components within a block. - - Parameters - ---------- - block: Block - The block to search for components. This is a recursive - generator and will descend into any active sub-Blocks as well. - ctype: class - The component type (typically either :py:class:`Constraint` or - :py:class:`Objective` to search for). - - """ - - return get_vars_from_components(block, ctype, active=True, descend_into=True) - - -def replace_uncertain_bounds_with_constraints(model, uncertain_params): - """ - For variables of which the bounds are dependent on the parameters - in the list `uncertain_params`, remove the bounds and add - explicit variable bound inequality constraints. - - :param model: Model in which to make the bounds/constraint replacements - :type model: class:`pyomo.core.base.PyomoModel.ConcreteModel` - :param uncertain_params: List of uncertain model parameters - :type uncertain_params: list - """ - uncertain_param_set = ComponentSet(uncertain_params) - - # component for explicit inequality constraints - uncertain_var_bound_constrs = ConstraintList() - model.add_component( - unique_component_name(model, 'uncertain_var_bound_cons'), - uncertain_var_bound_constrs, - ) - - # get all variables in active objective and constraint expression(s) - vars_in_cons = ComponentSet(get_vars_from_component(model, Constraint)) - vars_in_obj = ComponentSet(get_vars_from_component(model, Objective)) - - for v in vars_in_cons | vars_in_obj: - # get mutable parameters in variable bounds expressions - ub = v.upper - mutable_params_ub = ComponentSet(identify_mutable_parameters(ub)) - lb = v.lower - mutable_params_lb = ComponentSet(identify_mutable_parameters(lb)) + # check for duplicates if desired + if not allow_repeats and len(ans) != len(ComponentSet(ans)): + comp_name_list = [comp.name for comp in ans] + raise ValueError( + f"Standardized component list {comp_name_list} " + f"derived from input {obj} " + "contains duplicate entries." + ) - # add explicit inequality constraint(s), remove variable bound(s) - if mutable_params_ub & uncertain_param_set: - if type(ub) is NPV_MinExpression: - upper_bounds = ub.args - else: - upper_bounds = (ub,) - for u_bnd in upper_bounds: - uncertain_var_bound_constrs.add(v - u_bnd <= 0) - v.setub(None) - if mutable_params_lb & uncertain_param_set: - if type(ub) is NPV_MaxExpression: - lower_bounds = lb.args - else: - lower_bounds = (lb,) - for l_bnd in lower_bounds: - uncertain_var_bound_constrs.add(l_bnd - v <= 0) - v.setlb(None) + return ans def check_components_descended_from_model(model, components, components_name, config): @@ -821,44 +695,12 @@ def check_components_descended_from_model(model, components, components_name, co f"{comp_names_str}" ) raise ValueError( - f"Found entries of {components_name} " + f"Found {components_name} " "not descended from input model. " "Check logger output messages." ) -def get_state_vars(blk, first_stage_variables, second_stage_variables): - """ - Get state variables of a modeling block. - - The state variables with respect to `blk` are the unfixed - `_VarData` objects participating in the active objective - or constraints descended from `blk` which are not - first-stage variables or second-stage variables. - - Parameters - ---------- - blk : ScalarBlock - Block of interest. - first_stage_variables : Iterable of VarData - First-stage variables. - second_stage_variables : Iterable of VarData - Second-stage variables. - - Yields - ------ - _VarData - State variable. - """ - dof_var_set = ComponentSet(first_stage_variables) | ComponentSet( - second_stage_variables - ) - for var in get_vars_from_component(blk, (Objective, Constraint)): - is_state_var = not var.fixed and var not in dof_var_set - if is_state_var: - yield var - - def check_variables_continuous(model, vars, config): """ Check that all DOF and state variables of the model @@ -939,11 +781,16 @@ def validate_model(model, config): ) +VariablePartitioning = namedtuple( + "VariablePartitioning", + ("first_stage_variables", "second_stage_variables", "state_variables"), +) + + def validate_variable_partitioning(model, config): """ - Check that partitioning of the first-stage variables, - second-stage variables, and uncertain parameters - is valid. + Check that the partitioning of the in-scope variables of the + model is valid. Parameters ---------- @@ -954,7 +801,7 @@ def validate_variable_partitioning(model, config): Returns ------- - list of _VarData + list of VarData State variables of the model. Raises @@ -987,27 +834,74 @@ def validate_variable_partitioning(model, config): "contain at least one common Var object." ) - state_vars = list( - get_state_vars( - model, - first_stage_variables=config.first_stage_variables, - second_stage_variables=config.second_stage_variables, + # uncertain parameters can be VarData objects; + # ensure they are not considered decision variables here + active_model_vars = ComponentSet( + get_vars_from_components( + block=model, + active=True, + include_fixed=True, + descend_into=True, + ctype=(Objective, Constraint), ) + ) - ComponentSet(config.uncertain_params) + check_components_descended_from_model( + model=model, + components=active_model_vars, + components_name=( + "Vars participating in the " + "active model Objective/Constraint expressions " + ), + config=config, ) - var_type_list_map = { - "first-stage variables": config.first_stage_variables, - "second-stage variables": config.second_stage_variables, - "state variables": state_vars, - } - for desc, vars in var_type_list_map.items(): - check_components_descended_from_model( - model=model, components=vars, components_name=desc, config=config - ) + check_variables_continuous(model, active_model_vars, config) + + first_stage_vars = ComponentSet(config.first_stage_variables) & active_model_vars + second_stage_vars = ComponentSet(config.second_stage_variables) & active_model_vars + state_vars = active_model_vars - (first_stage_vars | second_stage_vars) + + return VariablePartitioning( + list(first_stage_vars), list(second_stage_vars), list(state_vars) + ) + - all_vars = config.first_stage_variables + config.second_stage_variables + state_vars - check_variables_continuous(model, all_vars, config) +def _get_uncertain_param_val(var_or_param_data): + """ + Get value of VarData/ParamData object + that is considered an uncertain parameter. + + For any unfixed VarData object, we assume that + the `lower` and `upper` attributes are identical, + so the value of `lower` is returned in lieu of + the level value. + + Parameters + ---------- + var_or_param_data : VarData or ParamData + Object to be evaluated. + + Returns + ------- + object + Value of the VarData/ParamData object. + The value is typically of a numeric type. + """ + if isinstance(var_or_param_data, ParamData): + expr_to_evaluate = var_or_param_data + elif isinstance(var_or_param_data, VarData): + if var_or_param_data.fixed: + expr_to_evaluate = var_or_param_data + else: + expr_to_evaluate = var_or_param_data.lower + else: + raise ValueError( + f"Uncertain parameter object {var_or_param_data!r}" + f"is of type {type(var_or_param_data).__name__!r}, " + "but should be of type " + f"{ParamData.__name__} or {VarData.__name__}." + ) - return state_vars + return value(expr_to_evaluate, exception=True) def validate_uncertainty_specification(model, config): @@ -1027,6 +921,9 @@ def validate_uncertainty_specification(model, config): ValueError If at least one of the following holds: + - there are entries of `config.uncertain_params` + that are also in `config.first_stage_variables` or + `config.second_stage_variables` - dimension of uncertainty set does not equal number of uncertain parameters - uncertainty set `is_valid()` method does not return @@ -1040,6 +937,26 @@ def validate_uncertainty_specification(model, config): config=config, ) + first_stg_vars = config.first_stage_variables + second_stg_vars = config.second_stage_variables + for stg_str, vars in zip(["first", "second"], [first_stg_vars, second_stg_vars]): + overlapping_uncertain_params = ComponentSet(vars) & ComponentSet( + config.uncertain_params + ) + if overlapping_uncertain_params: + overlapping_var_list = "\n ".join( + f"{var.name!r}" for var in overlapping_uncertain_params + ) + config.progress_logger.error( + f"The following Vars were found in both `{stg_str}_stage_variables`" + f"and `uncertain_params`:\n {overlapping_var_list}" + "\nEnsure no Vars are included in both arguments." + ) + raise ValueError( + f"Arguments `{stg_str}_stage_variables` and `uncertain_params` " + "contain at least one common Var object." + ) + if len(config.uncertain_params) != config.uncertainty_set.dim: raise ValueError( "Length of argument `uncertain_params` does not match dimension " @@ -1058,7 +975,14 @@ def validate_uncertainty_specification(model, config): # otherwise, check length matches uncertainty dimension if not config.nominal_uncertain_param_vals: config.nominal_uncertain_param_vals = [ - value(param, exception=True) for param in config.uncertain_params + # NOTE: this allows uncertain parameters that are of type + # VarData and implicitly fixed by identical bounds + # that are mutable expressions in ParamData-type + # uncertain parameters; + # the bounds expressions are evaluated to + # to get the nominal realization + _get_uncertain_param_val(param) + for param in config.uncertain_params ] elif len(config.nominal_uncertain_param_vals) != len(config.uncertain_params): raise ValueError( @@ -1117,408 +1041,1976 @@ def validate_pyros_inputs(model, config): Input deterministic model. config : ConfigDict PyROS solver options. + + Returns + ------- + user_var_partitioning : VariablePartitioning + Partitioning of the in-scope model variables into + first-stage, second-stage, and state variables, + according to user specification of the first-stage + and second-stage variables. """ validate_model(model, config) - state_vars = validate_variable_partitioning(model, config) + user_var_partitioning = validate_variable_partitioning(model, config) validate_uncertainty_specification(model, config) validate_separation_problem_options(model, config) - return state_vars - - -def substitute_ssv_in_dr_constraints(model, constraint): - ''' - Generate the standard_repn for the dr constraints. Generate new expression with replace_expression to ignore - the ssv component. - Then, replace_expression with substitution_map between ssv and the new expression. - Deactivate or del_component the original dr equation. - Then, return modified model and do coefficient matching as normal. - :param model: the working_model - :param constraint: an equality constraint from the working model identified to be of the form h(x,z,q) = 0. - :return: - ''' - dr_eqns = model.util.decision_rule_eqns - fsv = ComponentSet(model.util.first_stage_variables) - if not hasattr(model, "dr_substituted_constraints"): - model.dr_substituted_constraints = ConstraintList() - - substitution_map = {} - for eqn in dr_eqns: - repn = generate_standard_repn(eqn.body, compute_values=False) - new_expression = 0 - map_linear_coeff_to_var = [ - x - for x in zip(repn.linear_coefs, repn.linear_vars) - if x[1] in ComponentSet(fsv) - ] - map_quad_coeff_to_var = [ - x - for x in zip(repn.quadratic_coefs, repn.quadratic_vars) - if x[1] in ComponentSet(fsv) - ] - if repn.linear_coefs: - for coeff, var in map_linear_coeff_to_var: - new_expression += coeff * var - if repn.quadratic_coefs: - for coeff, var in map_quad_coeff_to_var: - new_expression += coeff * var[0] * var[1] # var here is a 2-tuple - - substitution_map[id(repn.linear_vars[-1])] = new_expression - - model.dr_substituted_constraints.add( - replace_expressions(expr=constraint.lower, substitution_map=substitution_map) - == replace_expressions(expr=constraint.body, substitution_map=substitution_map) - ) - - # === Delete the original constraint - model.del_component(constraint.name) - - return model.dr_substituted_constraints[ - max(model.dr_substituted_constraints.keys()) - ] + return user_var_partitioning -def is_certain_parameter(uncertain_param_index, config): - ''' - If an uncertain parameter's inferred LB and UB are within a relative tolerance, - then the parameter is considered certain. - :param uncertain_param_index: index of the parameter in the config.uncertain_params list - :param config: solver config - :return: True if param is effectively "certain," else return False - ''' - if config.uncertainty_set.parameter_bounds: - param_bounds = config.uncertainty_set.parameter_bounds[uncertain_param_index] - return math.isclose( - a=param_bounds[0], - b=param_bounds[1], - rel_tol=PARAM_IS_CERTAIN_REL_TOL, - abs_tol=PARAM_IS_CERTAIN_ABS_TOL, - ) - else: - return False # cannot be determined without bounds - - -def coefficient_matching(model, constraint, uncertain_params, config): - ''' - :param model: master problem model - :param constraint: the constraint from the master problem model - :param uncertain_params: the list of uncertain parameters - :param first_stage_variables: the list of effective first-stage variables (includes ssv if decision_rule_order = 0) - :return: True if the coefficient matching was successful, False if its proven robust_infeasible due to - constraints of the form 1 == 0 - ''' - # === Returned flags - successful_matching = True - robust_infeasible = False +class ModelData: + """ + Container for modeling objects from which the PyROS + subproblems are constructed. - # === Efficiency for q_LB = q_UB - actual_uncertain_params = [] - - for i in range(len(uncertain_params)): - if not is_certain_parameter(uncertain_param_index=i, config=config): - actual_uncertain_params.append(uncertain_params[i]) - - # === Add coefficient matching constraint list - if not hasattr(model, "coefficient_matching_constraints"): - model.coefficient_matching_constraints = ConstraintList() - if not hasattr(model, "swapped_constraints"): - model.swapped_constraints = ConstraintList() - - variables_in_constraint = ComponentSet(identify_variables(constraint.expr)) - params_in_constraint = ComponentSet(identify_mutable_parameters(constraint.expr)) - first_stage_variables = model.util.first_stage_variables - second_stage_variables = model.util.second_stage_variables - - # === Determine if we need to do DR expression/ssv substitution to - # make h(x,z,q) == 0 into h(x,d,q) == 0 (which is just h(x,q) == 0) - if all( - v in ComponentSet(first_stage_variables) for v in variables_in_constraint - ) and any(q in ComponentSet(actual_uncertain_params) for q in params_in_constraint): - # h(x, q) == 0 - pass - elif all( - v in ComponentSet(first_stage_variables + second_stage_variables) - for v in variables_in_constraint - ) and any(q in ComponentSet(actual_uncertain_params) for q in params_in_constraint): - constraint = substitute_ssv_in_dr_constraints( - model=model, constraint=constraint - ) + Parameters + ---------- + original_model : ConcreteModel + Original user-provided model. + timing : TimingData + Main timing data object. - variables_in_constraint = ComponentSet(identify_variables(constraint.expr)) - params_in_constraint = ComponentSet( - identify_mutable_parameters(constraint.expr) - ) - else: - pass - - if all( - v in ComponentSet(first_stage_variables) for v in variables_in_constraint - ) and any(q in ComponentSet(actual_uncertain_params) for q in params_in_constraint): - # Swap param objects for variable objects in this constraint - model.param_set = [] - for i in range(len(list(variables_in_constraint))): - # Initialize Params to non-zero value due to standard_repn bug - model.add_component("p_%s" % i, Param(initialize=1, mutable=True)) - model.param_set.append(getattr(model, "p_%s" % i)) - - model.variable_set = [] - for i in range(len(list(actual_uncertain_params))): - model.add_component("x_%s" % i, Var(initialize=1)) - model.variable_set.append(getattr(model, "x_%s" % i)) - - original_var_to_param_map = list( - zip(list(variables_in_constraint), model.param_set) - ) - original_param_to_vap_map = list( - zip(list(actual_uncertain_params), model.variable_set) - ) + Attributes + ---------- + original_model : ConcreteModel + Original user-provided model. + timing : TimingData + Main PyROS solver timing data object. + working_model : ConcreteModel + Preprocessed clone of `original_model` from which + the PyROS cutting set subproblems are to be + constructed. + separation_priority_order : dict + Mapping from constraint names to separation priority + values. + """ - var_to_param_substitution_map_forward = {} - # Separation problem initialized to nominal uncertain parameter values - for var, param in original_var_to_param_map: - var_to_param_substitution_map_forward[id(var)] = param + def __init__(self, original_model, config, timing): + self.original_model = original_model + self.timing = timing + self.config = config + self.separation_priority_order = dict() + # working model will be addressed by preprocessing + self.working_model = None - param_to_var_substitution_map_forward = {} - # Separation problem initialized to nominal uncertain parameter values - for param, var in original_param_to_vap_map: - param_to_var_substitution_map_forward[id(param)] = var + def preprocess(self, user_var_partitioning): + """ + Preprocess model data. - var_to_param_substitution_map_reverse = {} - # Separation problem initialized to nominal uncertain parameter values - for var, param in original_var_to_param_map: - var_to_param_substitution_map_reverse[id(param)] = var + See :meth:`~preprocess_model_data`. - param_to_var_substitution_map_reverse = {} - # Separation problem initialized to nominal uncertain parameter values - for param, var in original_param_to_vap_map: - param_to_var_substitution_map_reverse[id(var)] = param + Returns + ------- + bool + True if robust infeasibility detected, False otherwise. + """ + return preprocess_model_data(self, user_var_partitioning) - model.swapped_constraints.add( - replace_expressions( - expr=replace_expressions( - expr=constraint.lower, - substitution_map=param_to_var_substitution_map_forward, - ), - substitution_map=var_to_param_substitution_map_forward, - ) - == replace_expressions( - expr=replace_expressions( - expr=constraint.body, - substitution_map=param_to_var_substitution_map_forward, - ), - substitution_map=var_to_param_substitution_map_forward, - ) - ) - swapped = model.swapped_constraints[max(model.swapped_constraints.keys())] +def setup_quadratic_expression_visitor( + wrt, subexpression_cache=None, var_map=None, var_order=None, sorter=None +): + """Setup a parameterized quadratic expression walker.""" + visitor = ParameterizedQuadraticRepnVisitor( + subexpression_cache={} if subexpression_cache is None else subexpression_cache, + var_recorder=OrderedVarRecorder( + var_map={} if var_map is None else var_map, + var_order={} if var_order is None else var_order, + sorter=sorter, + ), + wrt=wrt, + ) + visitor.expand_nonlinear_products = True + return visitor - val = generate_standard_repn(swapped.body, compute_values=False) - if val.constant is not None: - if type(val.constant) not in native_types: - temp_expr = replace_expressions( - val.constant, substitution_map=var_to_param_substitution_map_reverse - ) - # We will use generate_standard_repn to generate a - # simplified expression (in particular, to remove any - # "0*..." terms) - temp_expr = generate_standard_repn(temp_expr).to_expression() - if temp_expr.__class__ not in native_types: - model.coefficient_matching_constraints.add(expr=temp_expr == 0) - elif math.isclose( - value(temp_expr), - 0, - rel_tol=COEFF_MATCH_REL_TOL, - abs_tol=COEFF_MATCH_ABS_TOL, - ): - pass - else: - successful_matching = False - robust_infeasible = True - elif math.isclose( - value(val.constant), - 0, - rel_tol=COEFF_MATCH_REL_TOL, - abs_tol=COEFF_MATCH_ABS_TOL, - ): - pass - else: - successful_matching = False - robust_infeasible = True - if val.linear_coefs is not None: - for coeff in val.linear_coefs: - if type(coeff) not in native_types: - temp_expr = replace_expressions( - coeff, substitution_map=var_to_param_substitution_map_reverse - ) - # We will use generate_standard_repn to generate a - # simplified expression (in particular, to remove any - # "0*..." terms) - temp_expr = generate_standard_repn(temp_expr).to_expression() - if temp_expr.__class__ not in native_types: - model.coefficient_matching_constraints.add(expr=temp_expr == 0) - elif math.isclose( - value(temp_expr), - 0, - rel_tol=COEFF_MATCH_REL_TOL, - abs_tol=COEFF_MATCH_ABS_TOL, - ): - pass - else: - successful_matching = False - robust_infeasible = True - elif math.isclose( - value(coeff), - 0, - rel_tol=COEFF_MATCH_REL_TOL, - abs_tol=COEFF_MATCH_ABS_TOL, - ): - pass - else: - successful_matching = False - robust_infeasible = True - if val.quadratic_coefs: - for coeff in val.quadratic_coefs: - if type(coeff) not in native_types: - temp_expr = replace_expressions( - coeff, substitution_map=var_to_param_substitution_map_reverse - ) - # We will use generate_standard_repn to generate a - # simplified expression (in particular, to remove any - # "0*..." terms) - temp_expr = generate_standard_repn(temp_expr).to_expression() - if temp_expr.__class__ not in native_types: - model.coefficient_matching_constraints.add(expr=temp_expr == 0) - elif math.isclose( - value(temp_expr), - 0, - rel_tol=COEFF_MATCH_REL_TOL, - abs_tol=COEFF_MATCH_ABS_TOL, - ): - pass - else: - successful_matching = False - robust_infeasible = True - elif math.isclose( - value(coeff), - 0, - rel_tol=COEFF_MATCH_REL_TOL, - abs_tol=COEFF_MATCH_ABS_TOL, - ): - pass - else: - successful_matching = False - robust_infeasible = True - if val.nonlinear_expr is not None: - successful_matching = False - robust_infeasible = False +class BoundType: + """ + Indicator for whether a bound on a variable/constraint + is a lower bound, "equality" bound, or upper bound. + """ - if successful_matching: - model.util.h_x_q_constraints.add(constraint) + LOWER = "lower" + EQ = "eq" + UPPER = "upper" - for i in range(len(list(variables_in_constraint))): - model.del_component("p_%s" % i) - for i in range(len(list(params_in_constraint))): - model.del_component("x_%s" % i) +def get_var_bound_pairs(var): + """ + Get the domain and declared lower/upper + bound pairs of a variable data object. - model.del_component("swapped_constraints") - model.del_component("swapped_constraints_index") + Parameters + ---------- + var : VarData + Variable data object of interest. - return successful_matching, robust_infeasible + Returns + ------- + domain_bounds : 2-tuple of None or numeric type + Domain (lower, upper) bound pair. + declared_bounds : 2-tuple of None, numeric type, or NumericExpression + Declared (lower, upper) bound pair. + Bounds of type `NumericExpression` + are either constant or mutable expressions. + """ + # temporarily set domain to Reals to cleanly retrieve + # the declared bound expressions + orig_var_domain = var.domain + var.domain = Reals + domain_bounds = orig_var_domain.bounds() + declared_bounds = var.lower, var.upper -def selective_clone(block, first_stage_vars): - """ - Clone everything in a base_model except for the first-stage variables - :param block: the block of the model to be clones - :param first_stage_vars: the variables which should not be cloned - :return: - """ - memo = {'__block_scope__': {id(block): True, id(None): False}} - for v in first_stage_vars: - memo[id(v)] = v - new_block = copy.deepcopy(block, memo) - new_block._parent = None + # ensure state of variable object is ultimately left unchanged + var.domain = orig_var_domain - return new_block + return domain_bounds, declared_bounds -def add_decision_rule_variables(model_data, config): +def determine_certain_and_uncertain_bound( + domain_bound, declared_bound, uncertain_params, bound_type +): """ - Add variables for polynomial decision rules to the working - model. + Determine the certain and uncertain lower or upper + bound for a variable object, based on the specified + domain and declared bound. Parameters ---------- - model_data : ROSolveResults - Model data. - config : config_dict - PyROS solver options. + domain_bound : numeric type, NumericExpression, or None + Domain bound. + declared_bound : numeric type, NumericExpression, or None + Declared bound. + uncertain_params : iterable of ParamData + Uncertain model parameters. + bound_type : {BoundType.LOWER, BoundType.UPPER} + Indication of whether the domain bound and declared bound + specify lower or upper bounds for the variable value. - Note - ---- - Decision rule variables are considered first-stage decision - variables which do not get copied at each iteration. - PyROS currently supports static (zeroth order), - affine (first-order), and quadratic DR. + Returns + ------- + certain_bound : numeric type, NumericExpression, or None + Bound that independent of the uncertain parameters. + uncertain_bound : numeric expression or None + Bound that is dependent on the uncertain parameters. """ - second_stage_variables = model_data.working_model.util.second_stage_variables - first_stage_variables = model_data.working_model.util.first_stage_variables - decision_rule_vars = [] + if bound_type not in {BoundType.LOWER, BoundType.UPPER}: + raise ValueError( + f"Argument {bound_type=!r} should be either " + f"'{BoundType.LOWER}' or '{BoundType.UPPER}'." + ) - # since DR expression is a general polynomial in the uncertain - # parameters, the exact number of DR variables per second-stage - # variable depends on DR order and uncertainty set dimension - degree = config.decision_rule_order - num_uncertain_params = len(model_data.working_model.util.uncertain_params) - num_dr_vars = sp.special.comb( - N=num_uncertain_params + degree, k=degree, exact=True, repetition=False - ) + if declared_bound is not None: + uncertain_params_in_declared_bound = ComponentSet( + uncertain_params + ) & ComponentSet(identify_mutable_parameters(declared_bound)) + else: + uncertain_params_in_declared_bound = False - for idx, ss_var in enumerate(second_stage_variables): - # declare DR coefficients for current second-stage variable - indexed_dr_var = Var( - range(num_dr_vars), initialize=0, bounds=(None, None), domain=Reals - ) - model_data.working_model.add_component( - f"decision_rule_var_{idx}", indexed_dr_var - ) + if not uncertain_params_in_declared_bound: + uncertain_bound = None - # index 0 entry of the IndexedVar is the static - # DR term. initialize to user-provided value of - # the corresponding second-stage variable. - # all other entries remain initialized to 0. - indexed_dr_var[0].set_value(value(ss_var, exception=False)) + if declared_bound is None: + certain_bound = domain_bound + elif domain_bound is None: + certain_bound = declared_bound + else: + if bound_type == BoundType.LOWER: + certain_bound = ( + declared_bound + if value(declared_bound) >= domain_bound + else domain_bound + ) + else: + certain_bound = ( + declared_bound + if value(declared_bound) <= domain_bound + else domain_bound + ) + else: + uncertain_bound = declared_bound + certain_bound = domain_bound + + return certain_bound, uncertain_bound - # update attributes - first_stage_variables.extend(indexed_dr_var.values()) - decision_rule_vars.append(indexed_dr_var) - model_data.working_model.util.decision_rule_vars = decision_rule_vars +BoundTriple = namedtuple( + "BoundTriple", (BoundType.LOWER, BoundType.EQ, BoundType.UPPER) +) -def add_decision_rule_constraints(model_data, config): +def rearrange_bound_pair_to_triple(lower_bound, upper_bound): """ - Add decision rule equality constraints to the working model. + Rearrange a lower/upper bound pair into a lower/equality/upper + bound triple, according to whether or not the lower and upper + bound are identical numerical values or expressions. Parameters ---------- - model_data : ROSolveResults - Model data. - config : ConfigDict - PyROS solver options. - """ - - second_stage_variables = model_data.working_model.util.second_stage_variables - uncertain_params = model_data.working_model.util.uncertain_params - decision_rule_eqns = [] - decision_rule_vars_list = model_data.working_model.util.decision_rule_vars - degree = config.decision_rule_order + lower_bound : numeric type, NumericExpression, or None + Lower bound. + upper_bound : numeric type, NumericExpression, or None + Upper bound. - # keeping track of degree of monomial in which each - # DR coefficient participates will be useful for later - dr_var_to_exponent_map = ComponentMap() + Returns + ------- + BoundTriple + Lower/equality/upper bound triple. The equality + bound is None if `lower_bound` and `upper_bound` + are not identical numeric type or ``NumericExpression`` + objects, or else it is set to `upper_bound`, + in which case, both the lower and upper bounds are + returned as None. + + Note + ---- + This method is meant to behave in a manner akin to that of + ConstraintData.equality, in which a ranged inequality + constraint may be considered an equality constraint if + the `lower` and `upper` attributes of the constraint + are identical and not None. + """ + if lower_bound is not None and lower_bound is upper_bound: + eq_bound = upper_bound + lower_bound = None + upper_bound = None + else: + eq_bound = None + + return BoundTriple(lower_bound, eq_bound, upper_bound) + + +def get_var_certain_uncertain_bounds(var, uncertain_params): + """ + Determine the certain and uncertain lower/equality/upper bound + triples for a variable data object, based on that variable's + domain and declared bounds. + + Parameters + ---------- + var : VarData + Variable data object of interest. + uncertain_params : iterable of ParamData + Uncertain model parameters. + + Returns + ------- + certain_bounds : BoundTriple + The certain lower/equality/upper bound triple. + uncertain_bounds : BoundTriple + The uncertain lower/equality/upper bound triple. + """ + (domain_lb, domain_ub), (declared_lb, declared_ub) = get_var_bound_pairs(var) + + certain_lb, uncertain_lb = determine_certain_and_uncertain_bound( + domain_bound=domain_lb, + declared_bound=declared_lb, + uncertain_params=uncertain_params, + bound_type=BoundType.LOWER, + ) + certain_ub, uncertain_ub = determine_certain_and_uncertain_bound( + domain_bound=domain_ub, + declared_bound=declared_ub, + uncertain_params=uncertain_params, + bound_type=BoundType.UPPER, + ) + + certain_bounds = rearrange_bound_pair_to_triple( + lower_bound=certain_lb, upper_bound=certain_ub + ) + uncertain_bounds = rearrange_bound_pair_to_triple( + lower_bound=uncertain_lb, upper_bound=uncertain_ub + ) + + return certain_bounds, uncertain_bounds + + +def get_effective_var_partitioning(model_data): + """ + Partition the in-scope variables of the input model + according to known nonadjustability to the uncertain parameters. + The result is referred to as the "effective" variable + partitioning. + + In addition to the first-stage variables, + some of the variables considered second-stage variables + or state variables according to the user-provided variable + partitioning may be nonadjustable. This method analyzes + the decision rule order, fixed variables, and, + through an iterative pretriangularization method, + the equality constraints, to identify nonadjustable variables. + + Parameters + ---------- + model_data : model data object + Main model data object. + + Returns + ------- + effective_partitioning : VariablePartitioning + Effective variable partitioning. + """ + config = model_data.config + working_model = model_data.working_model + user_var_partitioning = model_data.working_model.user_var_partitioning + + # truly nonadjustable variables + nonadjustable_var_set = ComponentSet() + + effective_uncertain_params_set = ComponentSet( + working_model.effective_uncertain_params + ) + if not effective_uncertain_params_set: + config.progress_logger.info( + "Model has no effective uncertain parameters. " + "All variables are considered effectively first-stage." + ) + return VariablePartitioning( + first_stage_variables=( + user_var_partitioning.first_stage_variables + + user_var_partitioning.second_stage_variables + + user_var_partitioning.state_variables + ), + second_stage_variables=[], + state_variables=[], + ) + + # the following variables are immediately known to be nonadjustable: + # - first-stage variables + # - (if decision rule order is 0) second-stage variables + # - all variables fixed to a constant (independent of the uncertain + # parameters) explicitly by user or implicitly by bounds + var_type_list_pairs = ( + ("first-stage", user_var_partitioning.first_stage_variables), + ("second-stage", user_var_partitioning.second_stage_variables), + ("state", user_var_partitioning.state_variables), + ) + for vartype, varlist in var_type_list_pairs: + for wvar in varlist: + certain_var_bounds, _ = get_var_certain_uncertain_bounds( + wvar, working_model.effective_uncertain_params + ) + + is_var_nonadjustable = ( + vartype == "first-stage" + or (config.decision_rule_order == 0 and vartype == "second-stage") + or wvar.fixed + or certain_var_bounds.eq is not None + ) + if is_var_nonadjustable: + nonadjustable_var_set.add(wvar) + config.progress_logger.debug( + f"The {vartype} variable {wvar.name!r} " + "is nonadjustable, for the following reason(s):" + ) + + if vartype == "first-stage": + config.progress_logger.debug(f" the variable has a {vartype} status") + + if config.decision_rule_order == 0 and vartype == "second-stage": + config.progress_logger.debug( + f" the variable is {vartype} and the decision rules are static " + ) + + if wvar.fixed: + config.progress_logger.debug(" the variable is fixed explicitly") + + if certain_var_bounds.eq is not None: + config.progress_logger.debug(" the variable is fixed by domain/bounds") + + # determine constraints that are potentially applicable for + # pretriangularization + certain_eq_cons = ComponentSet() + for wcon in working_model.component_data_objects(Constraint, active=True): + if not wcon.equality: + continue + uncertain_params_in_expr = ( + ComponentSet(identify_mutable_parameters(wcon.expr)) + & effective_uncertain_params_set + ) + if uncertain_params_in_expr: + continue + certain_eq_cons.add(wcon) + + pretriangular_con_var_map = ComponentMap() + for num_passes in it.count(1): + config.progress_logger.debug( + f"Performing pass number {num_passes} over the certain constraints." + ) + new_pretriangular_con_var_map = ComponentMap() + for ccon in certain_eq_cons: + vars_in_con = ComponentSet(identify_variables(ccon.body - ccon.upper)) + adj_vars_in_con = vars_in_con - nonadjustable_var_set + + # conditions for pretriangularization of constraint + # with no uncertain params: + # - only one nonadjustable variable in the constraint + # - the nonadjustable variable appears only linearly, + # and the linear coefficient exceeds our specified + # tolerance. + if len(adj_vars_in_con) == 1: + adj_var_in_con = next(iter(adj_vars_in_con)) + visitor = setup_quadratic_expression_visitor(wrt=[]) + ccon_expr_repn = visitor.walk_expression(expr=ccon.body - ccon.upper) + adj_var_appears_linearly = adj_var_in_con not in ComponentSet( + identify_variables(ccon_expr_repn.nonlinear) + ) and id(adj_var_in_con) in ComponentSet(ccon_expr_repn.linear) + if adj_var_appears_linearly: + adj_var_linear_coeff = ccon_expr_repn.linear[id(adj_var_in_con)] + if abs(adj_var_linear_coeff) > PRETRIANGULAR_VAR_COEFF_TOL: + new_pretriangular_con_var_map[ccon] = adj_var_in_con + config.progress_logger.debug( + f" The variable {adj_var_in_con.name!r} is " + "made nonadjustable by the pretriangular constraint " + f"{ccon.name!r}." + ) + + nonadjustable_var_set.update(new_pretriangular_con_var_map.values()) + pretriangular_con_var_map.update(new_pretriangular_con_var_map) + if not new_pretriangular_con_var_map: + config.progress_logger.debug( + "No new pretriangular constraint/variable pairs found. " + "Terminating pretriangularization loop." + ) + break + + for pcon in new_pretriangular_con_var_map: + certain_eq_cons.remove(pcon) + + pretriangular_vars = ComponentSet(pretriangular_con_var_map.values()) + config.progress_logger.debug( + f"Identified {len(pretriangular_con_var_map)} pretriangular " + f"constraints and {len(pretriangular_vars)} pretriangular variables " + f"in {num_passes} passes over the certain constraints." + ) + + effective_first_stage_vars = list(nonadjustable_var_set) + effective_second_stage_vars = [ + var + for var in user_var_partitioning.second_stage_variables + if var not in nonadjustable_var_set + ] + effective_state_vars = [ + var + for var in user_var_partitioning.state_variables + if var not in nonadjustable_var_set + ] + num_vars = len( + effective_first_stage_vars + effective_second_stage_vars + effective_state_vars + ) + + config.progress_logger.debug("Effective partitioning statistics:") + config.progress_logger.debug(f" Variables: {num_vars}") + config.progress_logger.debug( + f" Effective first-stage variables: {len(effective_first_stage_vars)}" + ) + config.progress_logger.debug( + f" Effective second-stage variables: {len(effective_second_stage_vars)}" + ) + config.progress_logger.debug( + f" Effective state variables: {len(effective_state_vars)}" + ) + + return VariablePartitioning( + first_stage_variables=effective_first_stage_vars, + second_stage_variables=effective_second_stage_vars, + state_variables=effective_state_vars, + ) + + +def add_effective_var_partitioning(model_data): + """ + Obtain a repartitioning of the in-scope variables of the + working model according to known adjustability to the + uncertain parameters, and add this repartitioning to the + working model. + + Parameters + ---------- + model_data : model data object + Main model data object. + """ + effective_partitioning = get_effective_var_partitioning(model_data) + model_data.working_model.effective_var_partitioning = VariablePartitioning( + **effective_partitioning._asdict() + ) + + +def create_bound_constraint_expr(expr, bound, bound_type, standardize=True): + """ + Create a relational expression establishing a bound + for a numeric expression of interest. + + If desired, the expression is such that `bound` appears on the + right-hand side of the relational (inequality/equality) + operator. + + Parameters + ---------- + expr : NumericValue + Expression for which a bound is to be imposed. + This can be a Pyomo expression, Var, or Param. + bound : native numeric type or NumericValue + Bound for `expr`. This should be a numeric constant, + Param, or constant/mutable Pyomo expression. + bound_type : BoundType + Indicator for whether `expr` is to be lower bounded, + equality bounded, or upper bounded, by `bound`. + standardize : bool, optional + True to ensure `expr` appears on the left-hand side of the + relational operator, False otherwise. + + Returns + ------- + RelationalExpression + Establishes a bound on `expr`. + """ + if bound_type == BoundType.LOWER: + return -expr <= -bound if standardize else bound <= expr + elif bound_type == BoundType.EQ: + return expr == bound + elif bound_type == BoundType.UPPER: + return expr <= bound + else: + raise ValueError(f"Bound type {bound_type!r} not supported.") + + +def remove_var_declared_bound(var, bound_type): + """ + Remove the specified declared bound(s) of a variable data object. + + Parameters + ---------- + var : VarData + Variable data object of interest. + bound_type : BoundType + Indicator for the declared bound(s) to remove. + Note: if BoundType.EQ is specified, then both the + lower and upper bounds are removed. + """ + if bound_type == BoundType.LOWER: + var.setlb(None) + elif bound_type == BoundType.EQ: + var.setlb(None) + var.setub(None) + elif bound_type == BoundType.UPPER: + var.setub(None) + else: + raise ValueError( + f"Bound type {bound_type!r} not supported. " + f"Bound type must be '{BoundType.LOWER}', " + f"'{BoundType.EQ}, or '{BoundType.UPPER}'." + ) + + +def remove_all_var_bounds(var): + """ + Remove all the domain and declared bounds for a specified + variable data object. + """ + var.setlb(None) + var.setub(None) + var.domain = Reals + + +def turn_nonadjustable_var_bounds_to_constraints(model_data): + """ + Reformulate uncertain bounds for the nonadjustable + (i.e. effective first-stage) variables of the working + model to constraints. + + Only uncertain declared bounds are reformulated to + constraints, as these are the only bounds we need to + reformulate to properly construct the subproblems. + Consequently, all constraints added to the working model + in this method are considered second-stage constraints. + + Parameters + ---------- + model_data : model data object + Main model data object. + """ + working_model = model_data.working_model + nonadjustable_vars = working_model.effective_var_partitioning.first_stage_variables + uncertain_params_set = ComponentSet(working_model.effective_uncertain_params) + for var in nonadjustable_vars: + _, declared_bounds = get_var_bound_pairs(var) + declared_bound_triple = rearrange_bound_pair_to_triple(*declared_bounds) + var_name = var.getname( + relative_to=working_model.user_model, fully_qualified=True + ) + for btype, bound in declared_bound_triple._asdict().items(): + is_bound_uncertain = bound is not None and ( + ComponentSet(identify_mutable_parameters(bound)) & uncertain_params_set + ) + if is_bound_uncertain: + new_con_expr = create_bound_constraint_expr(var, bound, btype) + new_con_name = f"var_{var_name}_uncertain_{btype}_bound_con" + remove_var_declared_bound(var, btype) + if btype == BoundType.EQ: + working_model.second_stage.equality_cons[new_con_name] = ( + new_con_expr + ) + else: + working_model.second_stage.inequality_cons[new_con_name] = ( + new_con_expr + ) + # can't specify custom priorities for variable bounds + model_data.separation_priority_order[new_con_name] = ( + DEFAULT_SEPARATION_PRIORITY + ) + + # for subsequent developments: return a mapping + # from each variable to the corresponding binding constraints? + # we will add this as needed when changes are made to + # the interface for separation priority ordering + + +def turn_adjustable_var_bounds_to_constraints(model_data): + """ + Reformulate domain and declared bounds for the + adjustable (i.e., effective second-stage and effective state) + variables of the working model to explicit constraints. + + The domain and declared bounds for every adjustable variable + are unconditionally reformulated to constraints, + as this is required for appropriate construction of the + subproblems later. + Since these constraints depend on adjustable variables, + they are taken to be (effective) second-stage constraints. + + Parameters + ---------- + model_data : model data object + Main model data object. + """ + working_model = model_data.working_model + + adjustable_vars = ( + working_model.effective_var_partitioning.second_stage_variables + + working_model.effective_var_partitioning.state_variables + ) + for var in adjustable_vars: + cert_bound_triple, uncert_bound_triple = get_var_certain_uncertain_bounds( + var, working_model.effective_uncertain_params + ) + var_name = var.getname( + relative_to=working_model.user_model, fully_qualified=True + ) + cert_uncert_bound_zip = ( + ("certain", cert_bound_triple), + ("uncertain", uncert_bound_triple), + ) + for certainty_desc, bound_triple in cert_uncert_bound_zip: + for btype, bound in bound_triple._asdict().items(): + if bound is not None: + new_con_name = f"var_{var_name}_{certainty_desc}_{btype}_bound_con" + new_con_expr = create_bound_constraint_expr(var, bound, btype) + if btype == BoundType.EQ: + working_model.second_stage.equality_cons[new_con_name] = ( + new_con_expr + ) + else: + working_model.second_stage.inequality_cons[new_con_name] = ( + new_con_expr + ) + # no custom separation priorities for Var + # bound constraints + model_data.separation_priority_order[new_con_name] = ( + DEFAULT_SEPARATION_PRIORITY + ) + + remove_all_var_bounds(var) + + # for subsequent developments: return a mapping + # from each variable to the corresponding binding constraints? + # we will add this as needed when changes are made to + # the interface for separation priority ordering + + +def _replace_vars_in_component_exprs(block, substitution_map, ctype): + """ + Substitute other objects for Vars in the expression attributes + of the component objects of a given type in a given block. + + For efficiency purposes, only components whose expressions + contain the Vars to remove via the substitution are acted upon. + + Named expressions in the components acted upon are descended + into, but not removed. + + Parameters + ---------- + block : BlockData + Block on which to perform the replacement. + substitution_map : ComponentMap + First entry of each tuple is a Var to remove, + second entry is an object to introduce in its place. + ctype : type or tuple of type + Type(s) of the components whose expressions are to be + modified. + """ + vars_to_be_replaced = ComponentSet([var for var, _ in substitution_map.items()]) + substitution_map = {id(var): dest for var, dest in substitution_map.items()} + for cdata in block.component_data_objects(ctype, active=None, descend_into=True): + # efficiency: act only on components containing + # the Vars to be substituted + if ComponentSet(identify_variables(cdata.expr)) & vars_to_be_replaced: + cdata.set_value( + replace_expressions( + expr=cdata.expr, + substitution_map=substitution_map, + descend_into_named_expressions=True, + remove_named_expressions=False, + ) + ) + + +def replace_vars_with_params(block, var_to_param_map): + """ + Substitute ParamData objects for VarData objects + in the Expression, Constraint, and Objective components + declared on a block and all its sub-blocks. + + Note that when performing the substitutions in the + Constraint and Objective components, + named Expressions are descended into, but not replaced. + + Parameters + ---------- + block : BlockData + Block on which to perform the substitution. + var_to_param_map : ComponentMap + Mapping from VarData objects to be replaced + to the ParamData objects to be introduced. + """ + _replace_vars_in_component_exprs( + block=block, + substitution_map=var_to_param_map, + ctype=(Expression, Constraint, Objective), + ) + + +def setup_working_model(model_data, user_var_partitioning): + """ + Set up (construct) the working model based on user inputs, + and add it to the model data object. + + Parameters + ---------- + model_data : model data object + Main model data object. + user_var_partitioning : VariablePartitioning + User-based partitioning of the in-scope + variables of the input model. + """ + config = model_data.config + original_model = model_data.original_model + + # add temporary block to help keep track of variables + # and uncertain parameters after cloning + temp_util_block_attr_name = unique_component_name(original_model, "util") + original_model.add_component(temp_util_block_attr_name, Block()) + orig_temp_util_block = getattr(original_model, temp_util_block_attr_name) + orig_temp_util_block.orig_uncertain_params = config.uncertain_params + orig_temp_util_block.user_var_partitioning = VariablePartitioning( + **user_var_partitioning._asdict() + ) + + # now set up working model + model_data.working_model = working_model = ConcreteModel() + + # stagewise blocks for containing stagewise constraints + working_model.first_stage = Block() + working_model.first_stage.dr_independent_equality_cons = Constraint(Any) + working_model.first_stage.dr_dependent_equality_cons = Constraint(Any) + working_model.first_stage.inequality_cons = Constraint(Any) + working_model.second_stage = Block() + working_model.second_stage.equality_cons = Constraint(Any) + working_model.second_stage.inequality_cons = Constraint(Any) + + # original user model will be a sub-block of working model, + # in order to avoid attribute name clashes later + working_model.user_model = original_model.clone() + + # facilitate later retrieval of the user var partitioning + working_temp_util_block = getattr( + working_model.user_model, temp_util_block_attr_name + ) + model_data.working_model.orig_uncertain_params = ( + working_temp_util_block.orig_uncertain_params.copy() + ) + working_model.user_var_partitioning = VariablePartitioning( + **working_temp_util_block.user_var_partitioning._asdict() + ) + + # we are done with the util blocks + delattr(original_model, temp_util_block_attr_name) + delattr(working_model.user_model, temp_util_block_attr_name) + + uncertain_param_var_idxs = [] + for idx, obj in enumerate(working_model.orig_uncertain_params): + if isinstance(obj, VarData): + obj.fix() + uncertain_param_var_idxs.append(idx) + temp_params = working_model.temp_uncertain_params = Param( + uncertain_param_var_idxs, + within=Reals, + initialize={ + idx: config.nominal_uncertain_param_vals[idx] + for idx in uncertain_param_var_idxs + }, + mutable=True, + ) + working_model.uncertain_params = [ + temp_params[idx] if idx in uncertain_param_var_idxs else orig_param + for idx, orig_param in enumerate(working_model.orig_uncertain_params) + ] + + # don't want to pass over the model components unless + # at least one Var is to be replaced + if uncertain_param_var_idxs: + uncertain_var_to_param_map = ComponentMap( + (working_model.orig_uncertain_params[idx], temp_param) + for idx, temp_param in temp_params.items() + ) + replace_vars_with_params( + working_model, var_to_param_map=uncertain_var_to_param_map + ) + for var, param in uncertain_var_to_param_map.items(): + config.progress_logger.debug( + "Uncertain parameter with name " + f"{var.name!r} (relative to the working model clone) " + f"is of type {VarData.__name__}. " + f"A newly declared {ParamData.__name__} object " + f"with name {param.name!r} " + f"has been substituted for the {VarData.__name__} object " + "in all named expressions, constraints, and objectives " + "of the working model clone. " + ) + + # keep track of the original active constraints + working_model.original_active_equality_cons = [] + working_model.original_active_inequality_cons = [] + for con in working_model.component_data_objects(Constraint, active=True): + if con.equality: + # note: ranged constraints with identical LHS and RHS + # objects are considered equality constraints + working_model.original_active_equality_cons.append(con) + else: + working_model.original_active_inequality_cons.append(con) + + +def standardize_inequality_constraints(model_data): + """ + Standardize the inequality constraints of the working model, + and classify them as first-stage inequalities or second-stage + inequalities. + + Parameters + ---------- + model_data : model data object + Main model data object, containing the working model. + """ + config = model_data.config + working_model = model_data.working_model + uncertain_params_set = ComponentSet(working_model.effective_uncertain_params) + adjustable_vars_set = ComponentSet( + working_model.effective_var_partitioning.second_stage_variables + + working_model.effective_var_partitioning.state_variables + ) + for con in working_model.original_active_inequality_cons: + uncertain_params_in_con_expr = ( + ComponentSet(identify_mutable_parameters(con.expr)) & uncertain_params_set + ) + adjustable_vars_in_con_body = ( + ComponentSet(identify_variables(con.body)) & adjustable_vars_set + ) + con_rel_name = con.getname( + relative_to=working_model.user_model, fully_qualified=True + ) + + if uncertain_params_in_con_expr | adjustable_vars_in_con_body: + con_bounds_triple = rearrange_bound_pair_to_triple( + lower_bound=con.lower, upper_bound=con.upper + ) + finite_bounds = { + btype: bd + for btype, bd in con_bounds_triple._asdict().items() + if bd is not None + } + for btype, bound in finite_bounds.items(): + if btype == BoundType.EQ: + # no equality bounds should be identified here. + # equality bound may be identified if: + # 1. bound rearrangement method has a bug + # 2. ConstraintData.equality is changed. + # such a change would affect this method + # only indirectly + raise ValueError( + f"Found an equality bound {bound} for the constraint " + f"for the constraint with name {con.name!r}. " + "Either the bound or the constraint has been misclassified." + "Report this case to the Pyomo/PyROS developers." + ) + + std_con_expr = create_bound_constraint_expr( + expr=con.body, bound=bound, bound_type=btype, standardize=True + ) + new_con_name = f"ineq_con_{con_rel_name}_{btype}_bound_con" + + uncertain_params_in_std_expr = uncertain_params_set & ComponentSet( + identify_mutable_parameters(std_con_expr) + ) + if adjustable_vars_in_con_body | uncertain_params_in_std_expr: + working_model.second_stage.inequality_cons[new_con_name] = ( + std_con_expr + ) + # account for user-specified priority specifications + model_data.separation_priority_order[new_con_name] = ( + config.separation_priority_order.get( + con_rel_name, DEFAULT_SEPARATION_PRIORITY + ) + ) + else: + # we do not want to modify the arrangement of + # lower bound for first-stage inequalities, so + # pass `standardize=False` + working_model.first_stage.inequality_cons[new_con_name] = ( + create_bound_constraint_expr( + expr=con.body, + bound=bound, + bound_type=btype, + standardize=False, + ) + ) + + # constraint has now been moved over to stagewise blocks + con.deactivate() + else: + # constraint depends on the nonadjustable variables only + working_model.first_stage.inequality_cons[f"ineq_con_{con_rel_name}"] = ( + con.expr + ) + con.deactivate() + + +def standardize_equality_constraints(model_data): + """ + Classify the original active equality constraints of the + working model as first-stage or second-stage constraints. + + Parameters + ---------- + model_data : model data object + Main model data object, containing the working model. + """ + working_model = model_data.working_model + uncertain_params_set = ComponentSet(working_model.effective_uncertain_params) + adjustable_vars_set = ComponentSet( + working_model.effective_var_partitioning.second_stage_variables + + working_model.effective_var_partitioning.state_variables + ) + for con in working_model.original_active_equality_cons: + uncertain_params_in_con_expr = ( + ComponentSet(identify_mutable_parameters(con.expr)) & uncertain_params_set + ) + adjustable_vars_in_con_body = ( + ComponentSet(identify_variables(con.body)) & adjustable_vars_set + ) + + # note: none of the equality constraint expressions are modified + con_rel_name = con.getname( + relative_to=working_model.user_model, fully_qualified=True + ) + if uncertain_params_in_con_expr | adjustable_vars_in_con_body: + working_model.second_stage.equality_cons[f"eq_con_{con_rel_name}"] = ( + con.expr + ) + else: + working_model.first_stage.dr_independent_equality_cons[ + f"eq_con_{con_rel_name}" + ] = con.expr + + # definitely don't want active duplicate + con.deactivate() + + +def get_summands(expr): + """ + Recursively gather the individual summands of a numeric expression. + + Parameters + ---------- + expr : native numeric type or NumericValue + Expression to be analyzed. + + Returns + ------- + summands : list of expression-like + The summands. + """ + if isinstance(expr, SumExpression): + # note: NPV_SumExpression and LinearExpression + # are subclasses of SumExpression, + # so those instances are decomposed here, as well. + summands = [] + for arg in expr.args: + summands.extend(get_summands(arg)) + else: + summands = [expr] + return summands + + +def declare_objective_expressions(working_model, objective, sense=minimize): + """ + Identify the per-stage summands of an objective of interest, + according to the user-based variable partitioning. + + Two Expressions are declared on the working model to contain + the per-stage summands: + + - ``first_stage_objective``: Sum of additive terms of `objective` + that are non-uncertain constants or depend only on the + user-defined first-stage variables. + - ``second_stage_objective``: Sum of all other additive terms of + `objective`. + + To facilitate retrieval of the original objective expression + (modified to account for the sense), an Expression called + ``full_objective`` is also declared on the working model. + + Parameters + ---------- + working_model : ConcreteModel + Working model, constructed during a PyROS solver run. + objective : ObjectiveData + Objective of which summands are to be identified. + sense : {common.enums.minimize, common.enums.maximize}, optional + Desired sense of the objective; default is minimize. + """ + if sense not in {minimize, maximize}: + raise ValueError( + f"Objective sense {sense} not supported. " + f"Ensure sense is {minimize} (minimize) or {maximize} (maximize)." + ) + + obj_expr = objective.expr + + obj_args = get_summands(obj_expr) + + # initialize first and second-stage cost expressions + first_stage_expr = 0 + second_stage_expr = 0 + + first_stage_var_set = ComponentSet( + working_model.user_var_partitioning.first_stage_variables + ) + uncertain_param_set = ComponentSet(working_model.effective_uncertain_params) + + obj_sense = objective.sense + for term in obj_args: + non_first_stage_vars_in_term = ComponentSet( + v for v in identify_variables(term) if v not in first_stage_var_set + ) + uncertain_params_in_term = ComponentSet( + param + for param in identify_mutable_parameters(term) + if param in uncertain_param_set + ) + + # account for objective sense + + # update all expressions + std_term = term if obj_sense == sense else -term + if non_first_stage_vars_in_term or uncertain_params_in_term: + second_stage_expr += std_term + else: + first_stage_expr += std_term + + working_model.first_stage_objective = Expression(expr=first_stage_expr) + working_model.second_stage_objective = Expression(expr=second_stage_expr) + + # useful for later + working_model.full_objective = Expression( + expr=obj_expr if sense == obj_sense else -obj_expr + ) + + +def standardize_active_objective(model_data): + """ + Standardize the active objective of the working model. + + This method involves declaration of: + + - named expressions for the full active objective + (in a minimization sense), the first-stage objective summand, + and the second-stage objective summand. + - an epigraph epigraph variable and constraint. + + The epigraph constraint is considered a first-stage + inequality provided that it is independent of the + adjustable (i.e., effective second-stage and effective state) + variables and the effective uncertain parameters. + + Parameters + ---------- + model_data : model data object + Main model data object. + """ + config = model_data.config + working_model = model_data.working_model + + active_obj = next( + working_model.component_data_objects(Objective, active=True, descend_into=True) + ) + model_data.active_obj_original_sense = active_obj.sense + + # per-stage summands will be useful for reporting later + declare_objective_expressions(working_model=working_model, objective=active_obj) + + # useful for later + working_model.first_stage.epigraph_var = Var( + initialize=value(active_obj, exception=False) + ) + + # we add the epigraph objective later, as needed, + # on a per subproblem basis; + # doing so is more efficient than adding the objective now + active_obj.deactivate() + + # add the epigraph constraint + adjustable_vars = ( + working_model.effective_var_partitioning.second_stage_variables + + working_model.effective_var_partitioning.state_variables + ) + uncertain_params_in_obj = ComponentSet( + identify_mutable_parameters(active_obj.expr) + ) & ComponentSet(working_model.effective_uncertain_params) + adjustable_vars_in_obj = ( + ComponentSet(identify_variables(active_obj.expr)) & adjustable_vars + ) + if uncertain_params_in_obj | adjustable_vars_in_obj: + if config.objective_focus == ObjectiveType.worst_case: + working_model.second_stage.inequality_cons["epigraph_con"] = ( + working_model.full_objective.expr + - working_model.first_stage.epigraph_var + <= 0 + ) + model_data.separation_priority_order["epigraph_con"] = ( + DEFAULT_SEPARATION_PRIORITY + ) + elif config.objective_focus == ObjectiveType.nominal: + working_model.first_stage.inequality_cons["epigraph_con"] = ( + working_model.full_objective.expr + - working_model.first_stage.epigraph_var + <= 0 + ) + else: + raise ValueError( + "Classification of the epigraph constraint with uncertain " + "and/or adjustable components not implemented " + f"for objective focus {config.objective_focus!r}." + ) + else: + working_model.first_stage.inequality_cons["epigraph_con"] = ( + working_model.full_objective.expr - working_model.first_stage.epigraph_var + <= 0 + ) + + +def get_all_nonadjustable_variables(working_model): + """ + Get all nonadjustable variables of the working model. + + The nonadjustable variables comprise the: + + - epigraph variable + - decision rule variables + - effective first-stage variables + """ + epigraph_var = working_model.first_stage.epigraph_var + decision_rule_vars = list( + generate_all_decision_rule_var_data_objects(working_model) + ) + effective_first_stage_vars = ( + working_model.effective_var_partitioning.first_stage_variables + ) + + return [epigraph_var] + decision_rule_vars + effective_first_stage_vars + + +def get_all_first_stage_eq_cons(working_model): + return list(working_model.first_stage.dr_dependent_equality_cons.values()) + list( + working_model.first_stage.dr_independent_equality_cons.values() + ) + + +def get_all_adjustable_variables(working_model): + """ + Get all variables considered adjustable. + """ + return ( + working_model.effective_var_partitioning.second_stage_variables + + working_model.effective_var_partitioning.state_variables + ) + + +def generate_all_decision_rule_var_data_objects(working_blk): + """ + Generate a sequence of all decision rule variable data + objects. + + Parameters + ---------- + working_blk : BlockData + Block with a structure similar to the working model + created during preprocessing. + + Yields + ------ + VarData + Decision rule variable. + """ + for indexed_var in working_blk.first_stage.decision_rule_vars: + yield from indexed_var.values() + + +def generate_all_decision_rule_eqns(working_blk): + """ + Generate sequence of all decision rule equations. + """ + yield from working_blk.second_stage.decision_rule_eqns.values() + + +def get_dr_expression(working_blk, second_stage_var): + """ + Get DR expression corresponding to given second-stage variable. + + Parameters + ---------- + working_blk : BlockData + Block with a structure similar to the working model + created during preprocessing. + + Returns + ------ + VarData, LinearExpression, or SumExpression + The corresponding DR expression. + """ + dr_con = working_blk.eff_ss_var_to_dr_eqn_map[second_stage_var] + return sum(dr_con.body.args[:-1]) + + +def get_dr_var_to_monomial_map(working_blk): + """ + Get mapping from all decision rule variables in the working + block to their corresponding DR equation monomials. + + Parameters + ---------- + working_blk : BlockData + Working model Block, containing the decision rule + components. + + Returns + ------- + ComponentMap + The desired mapping. + """ + dr_var_to_monomial_map = ComponentMap() + for ss_var in working_blk.effective_var_partitioning.second_stage_variables: + dr_expr = get_dr_expression(working_blk, ss_var) + for dr_monomial in dr_expr.args: + if dr_monomial.is_expression_type(): + # degree > 1 monomial expression of form + # (product of uncertain params) * dr variable + dr_var_in_term = dr_monomial.args[-1] + else: + # the static term (intercept) + dr_var_in_term = dr_monomial + + dr_var_to_monomial_map[dr_var_in_term] = dr_monomial + + return dr_var_to_monomial_map + + +def check_time_limit_reached(timing_data, config): + """ + Return true if the PyROS solver time limit is reached, + False otherwise. + + Returns + ------- + bool + True if time limit reached, False otherwise. + """ + return ( + config.time_limit is not None + and timing_data.get_main_elapsed_time() >= config.time_limit + ) + + +def _reformulate_eq_con_scenario_uncertainty( + model_data, + discrete_set, + ss_eq_con, + ss_eq_con_index, + ss_var_id_to_dr_expr_map, + all_dr_vars_set, +): + """ + Reformulate a second-stage equality constraint that + is independent of the state variables and subject + to scenario-based uncertainty. + + This reformulation merely involves adding to the set + of first-stage equalities the original constraint + subject to each (hard-coded) scenario in the uncertainty set. + + The original constraint is removed from the model. + + Parameters + ---------- + model_data : ModelData + Model data object, with mostly preprocessed working model. + discrete_set : UncertaintySet + Uncertainty set with scenario-based geometry. + ss_eq_con : ConstraintData + Second-stage equality constraint to be reformulated. + Expected to be a member of + ``model_data.working_model.second_stage.equality_cons``. + ss_eq_con_index : hashable + Index of the equality constraint in + ``model_data.working_model.second_stage.equality_cons``. + ss_var_id_to_dr_expr_map : dict + Mapping from object IDs of second-stage variables to + corresponding decision rule expressions. + all_dr_vars_set : ComponentSet + A set of all the decision rule variables declared on + ``model_data.working_model``. + """ + working_model = model_data.working_model + con_expr_after_dr_substitution = replace_expressions( + expr=ss_eq_con.expr, substitution_map=ss_var_id_to_dr_expr_map + ) + vars_in_coeff_expr = ComponentSet( + identify_variables(con_expr_after_dr_substitution) + ) + has_con_dr_vars = vars_in_coeff_expr & all_dr_vars_set + indexed_con_to_update = ( + working_model.first_stage.dr_dependent_equality_cons + if has_con_dr_vars + else working_model.first_stage.dr_independent_equality_cons + ) + + scenarios_enum = enumerate(discrete_set.scenarios) + for sc_idx, scenario in scenarios_enum: + indexed_con_to_update[f"scenario_{sc_idx}_{ss_eq_con_index}"] = ( + replace_expressions( + expr=con_expr_after_dr_substitution, + substitution_map={ + id(param): scenario_val + for param, scenario_val in zip( + working_model.uncertain_params, scenario + ) + }, + ) + ) + del working_model.second_stage.equality_cons[ss_eq_con_index] + + +def _reformulate_eq_con_continuous_uncertainty( + model_data, + config, + ss_eq_con, + ss_eq_con_index, + ss_var_id_to_dr_expr_map, + uncertain_param_id_to_temp_var_map, + originally_unfixed_vars, + all_dr_vars_set, +): + """ + Reformulate a second-stage equality constraint that + is independent of the state variables and subject + to non-scenario-based uncertainty. + + If, after substitution of the decision rule expressions, + the constraint expression is a polynomial (up to degree 2) + in the uncertain parameters, then coefficient matching + constraints are added. Note that in some (rare?) cases, + coefficient matching constraints are restrictive. + + Otherwise, the constraint is cast to two second-stage + inequality constraints, each of which is assigned a separation + priority equal to ``DEFAULT_SEPARATION_PRIORITY``. + + The original constraint is removed from the model. + + Parameters + ---------- + model_data : ModelData + Model data object, with mostly preprocessed working model. + config : ConfigDict + PyROS solver settings. + ss_eq_con : ConstraintData + Second-stage equality constraint to be reformulated. + Expected to be a member of + ``model_data.working_model.second_stage.equality_cons``. + ss_eq_con_index : hashable + Index of the equality constraint in + ``model_data.working_model.second_stage.equality_cons``. + ss_var_id_to_dr_expr_map : dict + Mapping from object IDs of second-stage variables to + corresponding decision rule expressions. + uncertain_param_id_to_temp_var_map : dict + Mapping from object IDs of effective uncertain parameters + to temporary placeholder variables. + originally_unfixed_vars : list/ComponentSet of VarData + Variables of the working model that were originally + unfixed. + all_dr_vars_set : ComponentSet + A set of all the decision rule variables declared on + ``model_data.working_model``. + + Returns + ------- + robust_infeasible : bool + True if robust infeasibility was detected through + coefficient matching, False otherwise. + """ + robust_infeasible = False + + working_model = model_data.working_model + con_expr_after_dr_substitution = replace_expressions( + expr=ss_eq_con.body - ss_eq_con.upper, substitution_map=ss_var_id_to_dr_expr_map + ) + + # substitute temporarily defined vars for uncertain params. + # note: this is performed after, rather than along with, + # the DR expression substitution, as the DR expressions + # contain uncertain params + con_expr_after_all_substitutions = replace_expressions( + expr=con_expr_after_dr_substitution, + substitution_map=uncertain_param_id_to_temp_var_map, + ) + + # analyze the expression with respect to the + # effective uncertain parameters only. thus, only the proxy + # variables for the uncertain parameters are unfixed + # during the analysis + visitor = setup_quadratic_expression_visitor(wrt=originally_unfixed_vars) + expr_repn = visitor.walk_expression(con_expr_after_all_substitutions) + + if expr_repn.nonlinear is not None: + config.progress_logger.debug( + f"Equality constraint {ss_eq_con.name!r} " + "is state-variable independent, but cannot be written " + "as a polynomial in the uncertain parameters with " + "the currently available expression analyzers " + "and selected decision rules " + f"(decision_rule_order={config.decision_rule_order}). " + "We are unable to write a coefficient matching reformulation " + "of this constraint." + "Recasting to two inequality constraints." + ) + + # keeping this constraint as an equality is not appropriate, + # as it effectively constrains the uncertain parameters + # in the separation problems, since the effective DOF + # variables and DR variables are fixed. + # hence, we reformulate to inequalities + for bound_type in [BoundType.LOWER, BoundType.UPPER]: + std_con_expr = create_bound_constraint_expr( + expr=ss_eq_con.body, bound=ss_eq_con.upper, bound_type=bound_type + ) + new_con_name = f"reform_{bound_type}_bound_from_{ss_eq_con_index}" + working_model.second_stage.inequality_cons[new_con_name] = std_con_expr + # no custom priorities specified + model_data.separation_priority_order[new_con_name] = ( + DEFAULT_SEPARATION_PRIORITY + ) + else: + polynomial_repn_coeffs = ( + [expr_repn.constant] + + list(expr_repn.linear.values()) + + ( + [] + if expr_repn.quadratic is None + else list(expr_repn.quadratic.values()) + ) + ) + for coeff_idx, coeff_expr in enumerate(polynomial_repn_coeffs): + # for robust satisfaction of the original equality + # constraint, all polynomial coefficients must be + # equal to zero. so for each coefficient, + # we either check for trivial robust + # feasibility/infeasibility, or add a constraint + # restricting the coefficient expression to value 0 + if isinstance(coeff_expr, tuple(native_types)): + # coefficient is a constant; + # check value to determine + # trivial feasibility/infeasibility + robust_infeasible = not math.isclose( + a=coeff_expr, + b=0, + rel_tol=COEFF_MATCH_REL_TOL, + abs_tol=COEFF_MATCH_ABS_TOL, + ) + if robust_infeasible: + config.progress_logger.info( + "PyROS has determined that the model is " + "robust infeasible. " + "One reason for this is that " + f"the equality constraint {ss_eq_con.name!r} " + "cannot be satisfied against all realizations " + "of uncertainty, " + "given the current partitioning into " + "first-stage, second-stage, and state variables. " + "Consider editing this constraint to reference some " + "(additional) second-stage and/or state variable(s)." + ) + + # robust infeasibility found; + # that is sufficient for termination of PyROS. + break + + else: + # coefficient is variable-dependent. + # add matching constraint + new_con_name = f"coeff_matching_{ss_eq_con_index}_coeff_{coeff_idx}" + vars_in_coeff_expr = ComponentSet(identify_variables(coeff_expr)) + has_expr_dr_vars = vars_in_coeff_expr & all_dr_vars_set + indexed_con_to_update = ( + working_model.first_stage.dr_dependent_equality_cons + if has_expr_dr_vars + else working_model.first_stage.dr_independent_equality_cons + ) + indexed_con_to_update[new_con_name] = coeff_expr == 0 + new_con = indexed_con_to_update[new_con_name] + working_model.first_stage.coefficient_matching_cons.append(new_con) + + dr_dependence_qual = ( + f"DR variable-{'in' if has_expr_dr_vars else ''}dependent" + ) + config.progress_logger.debug( + f"Derived from constraint {ss_eq_con.name!r} a " + f"{dr_dependence_qual} coefficient " + f"matching constraint named {new_con_name!r} " + "with expression: \n " + f"{new_con.expr}." + ) + + del working_model.second_stage.equality_cons[ss_eq_con_index] + + return robust_infeasible + + +def reformulate_state_var_independent_eq_cons(model_data): + """ + Reformulate second-stage equality constraints that are + independent of the state variables. + + The reformulation of every such constraint is as follows: + + - If the uncertainty set is discrete, then the constraint, + subject to each scenario in the set, is added to the + first-stage equality constraints. + - Otherwise: + + - If, after substitution of the decision rule expressions + for the effective second-stage variables, the constraint + expression is a polynomial (of degree up to 2) in the + uncertain parameters, then an equality requiring that + each coefficient be of value 0 is added to the first-stage + equality constraints. + In some cases, matching of the coefficients may lead to + immediate detection of robust infeasibility. + - Otherwise, the constraint is cast to two second-stage + inequalities, each of which is assigned a separation + priority of ``DEFAULT_SEPARATION_PRIORITY``. + + Parameters + ---------- + model_data : model data object + Main model data object. + + Returns + ------- + robust_infeasible : bool + True if model found to be robust infeasible, + False otherwise. + """ + config = model_data.config + working_model = model_data.working_model + ep = working_model.effective_var_partitioning + + effective_second_stage_var_set = ComponentSet(ep.second_stage_variables) + effective_state_var_set = ComponentSet(ep.state_variables) + all_vars_set = ComponentSet(working_model.all_variables) + all_dr_vars_set = ComponentSet( + generate_all_decision_rule_var_data_objects(working_model) + ) + originally_unfixed_vars = [var for var in all_vars_set if not var.fixed] + + # we will need this to substitute DR expressions for + # second-stage variables later + ss_var_id_to_dr_expr_map = { + id(ss_var): get_dr_expression(working_model, ss_var) + for ss_var in effective_second_stage_var_set + } + + # goal: examine constraint expressions in terms of the + # uncertain params. we will use standard repn to do this. + # standard repn analyzes expressions in terms of Var components, + # but the uncertain params are implemented as mutable Param objects + # so we temporarily define Var components to be briefly substituted + # for the uncertain parameters as the constraints are analyzed + uncertain_params_set = ComponentSet(working_model.effective_uncertain_params) + working_model.temp_param_vars = temp_param_vars = Var( + range(len(uncertain_params_set)), + initialize={ + idx: value(param) for idx, param in enumerate(uncertain_params_set) + }, + ) + uncertain_param_to_temp_var_map = ComponentMap( + (param, param_var) + for param, param_var in zip(uncertain_params_set, temp_param_vars.values()) + ) + uncertain_param_id_to_temp_var_map = { + id(param): var for param, var in uncertain_param_to_temp_var_map.items() + } + + robust_infeasible = False + + # copy the items iterable, + # as we will be modifying the constituents of the constraint + # in place + working_model.first_stage.coefficient_matching_cons = [] + for con_idx, con in list(working_model.second_stage.equality_cons.items()): + vars_in_con = ComponentSet(identify_variables(con.expr)) + mutable_params_in_con = ComponentSet(identify_mutable_parameters(con.expr)) + + second_stage_vars_in_con = vars_in_con & effective_second_stage_var_set + state_vars_in_con = vars_in_con & effective_state_var_set + uncertain_params_in_con = mutable_params_in_con & uncertain_params_set + + coefficient_matching_applicable = not state_vars_in_con and ( + uncertain_params_in_con or second_stage_vars_in_con + ) + if coefficient_matching_applicable: + if config.uncertainty_set.geometry.name == "DISCRETE_SCENARIOS": + _reformulate_eq_con_scenario_uncertainty( + model_data=model_data, + ss_eq_con=con, + ss_eq_con_index=con_idx, + discrete_set=config.uncertainty_set, + ss_var_id_to_dr_expr_map=ss_var_id_to_dr_expr_map, + all_dr_vars_set=all_dr_vars_set, + ) + else: + robust_infeasible = _reformulate_eq_con_continuous_uncertainty( + model_data=model_data, + config=config, + ss_eq_con=con, + ss_eq_con_index=con_idx, + ss_var_id_to_dr_expr_map=ss_var_id_to_dr_expr_map, + uncertain_param_id_to_temp_var_map=( + uncertain_param_id_to_temp_var_map + ), + originally_unfixed_vars=originally_unfixed_vars, + all_dr_vars_set=all_dr_vars_set, + ) + if robust_infeasible: + break + + # we no longer need these auxiliary components + working_model.del_component(temp_param_vars) + working_model.del_component(temp_param_vars.index_set()) + + return robust_infeasible + + +def get_effective_uncertain_dimensions(model_data): + """ + Determine the positional indices of the effective uncertain + parameters, i.e., the uncertain parameters + of a model that are not constrained to a single value + by the uncertainty set constraints. + + Parameters + ---------- + model_data : ModelData + PyROS model data object. + + Returns + ------- + list of int + Positional indices of interest. + """ + are_coordinates_fixed = model_data.config.uncertainty_set._is_coordinate_fixed( + config=model_data.config + ) + return [idx for idx, is_fixed in enumerate(are_coordinates_fixed) if not is_fixed] + + +def preprocess_model_data(model_data, user_var_partitioning): + """ + Preprocess user inputs to modeling objects from which + PyROS subproblems can be efficiently constructed. + + Parameters + ---------- + model_data : model data object + Main model data object. + user_var_partitioning : VariablePartitioning + User-based partitioning of the in-scope + variables of the input model. + + Returns + ------- + robust_infeasible : bool + True if RO problem was found to be robust infeasible, + False otherwise. + """ + config = model_data.config + setup_working_model(model_data, user_var_partitioning) + + config.progress_logger.debug( + "Establishing the effective(ly) uncertain parameters..." + ) + model_data.working_model.effective_uncertain_dimensions = ( + get_effective_uncertain_dimensions(model_data) + ) + model_data.working_model.effective_uncertain_params = [ + model_data.working_model.uncertain_params[idx] + for idx in model_data.working_model.effective_uncertain_dimensions + ] + + # extract as many truly nonadjustable variables as possible + # from the second-stage and state variables + config.progress_logger.debug("Repartitioning variables by nonadjustability...") + add_effective_var_partitioning(model_data) + + # different treatment for effective first-stage + # than for effective second-stage and state variables + config.progress_logger.debug("Turning some variable bounds to constraints...") + turn_nonadjustable_var_bounds_to_constraints(model_data) + turn_adjustable_var_bounds_to_constraints(model_data) + + config.progress_logger.debug("Standardizing the model constraints...") + standardize_inequality_constraints(model_data) + standardize_equality_constraints(model_data) + + # includes epigraph reformulation + config.progress_logger.debug("Standardizing the active objective...") + standardize_active_objective(model_data) + + # DR components are added only per effective second-stage variable + config.progress_logger.debug("Adding decision rule components...") + add_decision_rule_variables(model_data) + add_decision_rule_constraints(model_data) + + # the epigraph and DR variables are also first-stage + config.progress_logger.debug("Finalizing nonadjustable variables...") + model_data.working_model.all_nonadjustable_variables = ( + get_all_nonadjustable_variables(model_data.working_model) + ) + model_data.working_model.all_adjustable_variables = get_all_adjustable_variables( + model_data.working_model + ) + model_data.working_model.all_variables = ( + model_data.working_model.all_nonadjustable_variables + + model_data.working_model.all_adjustable_variables + ) + + config.progress_logger.debug( + "Reformulating state variable-independent second-stage equality constraints..." + ) + robust_infeasible = reformulate_state_var_independent_eq_cons(model_data) + + return robust_infeasible + + +def log_model_statistics(model_data): + """ + Log statistics for the preprocessed model. + + Parameters + ---------- + model_data : model data object + Main model data object. + """ + config = model_data.config + working_model = model_data.working_model + + ep = working_model.effective_var_partitioning + up = working_model.user_var_partitioning + + # variables. we log the user partitioning + num_vars = len(working_model.all_variables) + num_epigraph_vars = 1 + num_first_stage_vars = len(up.first_stage_variables) + num_second_stage_vars = len(up.second_stage_variables) + num_state_vars = len(up.state_variables) + num_eff_second_stage_vars = len(ep.second_stage_variables) + num_eff_state_vars = len(ep.state_variables) + num_dr_vars = len(list(generate_all_decision_rule_var_data_objects(working_model))) + + # uncertain parameters + num_uncertain_params = len(working_model.uncertain_params) + num_eff_uncertain_params = len(working_model.effective_uncertain_params) + + # constraints + num_cons = len(list(working_model.component_data_objects(Constraint, active=True))) + + # # equality constraints + num_eq_cons = ( + len(working_model.first_stage.dr_dependent_equality_cons) + + len(working_model.first_stage.dr_independent_equality_cons) + + len(working_model.second_stage.equality_cons) + + len(working_model.second_stage.decision_rule_eqns) + ) + num_first_stage_eq_cons = len(get_all_first_stage_eq_cons(working_model)) + num_coeff_matching_cons = len(working_model.first_stage.coefficient_matching_cons) + num_other_first_stage_eqns = num_first_stage_eq_cons - num_coeff_matching_cons + num_second_stage_eq_cons = len(working_model.second_stage.equality_cons) + num_dr_eq_cons = len(working_model.second_stage.decision_rule_eqns) + + # # inequality constraints + num_ineq_cons = len(working_model.first_stage.inequality_cons) + len( + working_model.second_stage.inequality_cons + ) + num_first_stage_ineq_cons = len(working_model.first_stage.inequality_cons) + num_second_stage_ineq_cons = len(working_model.second_stage.inequality_cons) + + info_log_func = config.progress_logger.info + + IterationLogRecord.log_header_rule(info_log_func) + info_log_func("Model Statistics:") + + info_log_func(f" Number of variables : {num_vars}") + info_log_func(f" Epigraph variable : {num_epigraph_vars}") + info_log_func(f" First-stage variables : {num_first_stage_vars}") + info_log_func( + f" Second-stage variables : {num_second_stage_vars} " + f"({num_eff_second_stage_vars} adj.)" + ) + info_log_func( + f" State variables : {num_state_vars} " f"({num_eff_state_vars} adj.)" + ) + info_log_func(f" Decision rule variables : {num_dr_vars}") + + info_log_func( + f" Number of uncertain parameters : {num_uncertain_params} " + f"({num_eff_uncertain_params} eff.)" + ) + + info_log_func(f" Number of constraints : {num_cons}") + info_log_func(f" Equality constraints : {num_eq_cons}") + info_log_func(f" Coefficient matching constraints : {num_coeff_matching_cons}") + info_log_func(f" Other first-stage equations : {num_other_first_stage_eqns}") + info_log_func(f" Second-stage equations : {num_second_stage_eq_cons}") + info_log_func(f" Decision rule equations : {num_dr_eq_cons}") + info_log_func(f" Inequality constraints : {num_ineq_cons}") + info_log_func(f" First-stage inequalities : {num_first_stage_ineq_cons}") + info_log_func(f" Second-stage inequalities : {num_second_stage_ineq_cons}") + + +def add_decision_rule_variables(model_data): + """ + Add variables parameterizing the (polynomial) + decision rules to the working model. + + Parameters + ---------- + model_data : model data object + Model data. + + Notes + ----- + 1. One set of decision rule variables is added for each + effective second-stage variable. + 2. As an efficiency, no decision rule variables + are added for the nonadjustable, user-defined second-stage + variables, since the decision rules for such variables + are necessarily nonstatic. + """ + config = model_data.config + effective_second_stage_vars = ( + model_data.working_model.effective_var_partitioning.second_stage_variables + ) + model_data.working_model.first_stage.decision_rule_vars = decision_rule_vars = [] + + # facilitate matching of effective second-stage vars to DR vars later + model_data.working_model.eff_ss_var_to_dr_var_map = eff_ss_var_to_dr_var_map = ( + ComponentMap() + ) + + # since DR expression is a general polynomial in the uncertain + # parameters, the exact number of DR variables + # per effective second-stage variable + # depends only on the DR order and uncertainty set dimension + degree = config.decision_rule_order + num_uncertain_params = len(model_data.working_model.effective_uncertain_params) + num_dr_vars = sp.special.comb( + N=num_uncertain_params + degree, k=degree, exact=True, repetition=False + ) + + for idx, eff_ss_var in enumerate(effective_second_stage_vars): + indexed_dr_var = Var( + range(num_dr_vars), initialize=0, bounds=(None, None), domain=Reals + ) + model_data.working_model.first_stage.add_component( + f"decision_rule_var_{idx}", indexed_dr_var + ) + + # index 0 entry of the IndexedVar is the static + # DR term. initialize to user-provided value of + # the corresponding second-stage variable. + # all other entries remain initialized to 0. + indexed_dr_var[0].set_value(value(eff_ss_var, exception=False)) + + # update attributes + decision_rule_vars.append(indexed_dr_var) + eff_ss_var_to_dr_var_map[eff_ss_var] = indexed_dr_var + + +def add_decision_rule_constraints(model_data): + """ + Add decision rule equality constraints to the working model. + + Parameters + ---------- + model_data : model data object + Main model data object. + """ + config = model_data.config + effective_second_stage_vars = ( + model_data.working_model.effective_var_partitioning.second_stage_variables + ) + indexed_dr_var_list = model_data.working_model.first_stage.decision_rule_vars + uncertain_params = model_data.working_model.effective_uncertain_params + degree = config.decision_rule_order + + model_data.working_model.second_stage.decision_rule_eqns = decision_rule_eqns = ( + Constraint(range(len(effective_second_stage_vars))) + ) + + # keeping track of degree of monomial + # (in terms of the uncertain parameters) + # in which each DR coefficient participates will be useful for + # later + model_data.working_model.dr_var_to_exponent_map = dr_var_to_exponent_map = ( + ComponentMap() + ) + + # facilitate retrieval of DR equation for a given + # effective second-stage variable later + model_data.working_model.eff_ss_var_to_dr_eqn_map = eff_ss_var_to_dr_eqn_map = ( + ComponentMap() + ) # set up uncertain parameter combinations for # construction of the monomials of the DR expressions @@ -1528,15 +3020,16 @@ def add_decision_rule_constraints(model_data, config): monomial_param_combos.extend(power_combos) # now construct DR equations and declare them on the working model - second_stage_dr_var_zip = zip(second_stage_variables, decision_rule_vars_list) - for idx, (ss_var, indexed_dr_var) in enumerate(second_stage_dr_var_zip): + second_stage_dr_var_zip = zip(effective_second_stage_vars, indexed_dr_var_list) + for idx, (eff_ss_var, indexed_dr_var) in enumerate(second_stage_dr_var_zip): # for each DR equation, the number of coefficients should match # the number of monomial terms exactly if len(monomial_param_combos) != len(indexed_dr_var.index_set()): raise ValueError( f"Mismatch between number of DR coefficient variables " f"and number of DR monomials for DR equation index {idx}, " - f"corresponding to second-stage variable {ss_var.name!r}. " + "corresponding to effective second-stage variable " + f"{eff_ss_var.name!r}. " f"({len(indexed_dr_var.index_set())}!= {len(monomial_param_combos)})" ) @@ -1550,18 +3043,11 @@ def add_decision_rule_constraints(model_data, config): dr_var_to_exponent_map[dr_var] = len(param_combo) # declare constraint on model - dr_eqn = Constraint(expr=dr_expression - ss_var == 0) - model_data.working_model.add_component(f"decision_rule_eqn_{idx}", dr_eqn) + decision_rule_eqns[idx] = dr_expression - eff_ss_var == 0 + eff_ss_var_to_dr_eqn_map[eff_ss_var] = decision_rule_eqns[idx] - # append to list of DR equality constraints - decision_rule_eqns.append(dr_eqn) - # finally, add attributes to util block - model_data.working_model.util.decision_rule_eqns = decision_rule_eqns - model_data.working_model.util.dr_var_to_exponent_map = dr_var_to_exponent_map - - -def enforce_dr_degree(blk, config, degree): +def enforce_dr_degree(working_blk, config, degree): """ Make decision rule polynomials of a given degree by fixing value of the appropriate subset of the decision @@ -1576,159 +3062,136 @@ def enforce_dr_degree(blk, config, degree): degree : int Degree of the DR polynomials that is to be enforced. """ - second_stage_vars = blk.util.second_stage_variables - indexed_dr_vars = blk.util.decision_rule_vars - dr_var_to_exponent_map = blk.util.dr_var_to_exponent_map - - for ss_var, indexed_dr_var in zip(second_stage_vars, indexed_dr_vars): + for indexed_dr_var in working_blk.first_stage.decision_rule_vars: for dr_var in indexed_dr_var.values(): - dr_var_degree = dr_var_to_exponent_map[dr_var] - + dr_var_degree = working_blk.dr_var_to_exponent_map[dr_var] if dr_var_degree > degree: dr_var.fix(0) else: dr_var.unfix() -def identify_objective_functions(model, objective): +def load_final_solution(model_data, master_soln, original_user_var_partitioning): """ - Identify the first and second-stage portions of an Objective - expression, subject to user-provided variable partitioning and - uncertain parameter choice. In doing so, the first and second-stage - objective expressions are added to the model as `Expression` - attributes. + Load variable values from the master problem to the + original model. Parameters ---------- - model : ConcreteModel - Model of interest. - objective : Objective - Objective to be resolved into first and second-stage parts. + master_soln : MasterResults + Master solution object, containing the master model. + original_user_var_partitioning : VariablePartitioning + User partitioning of the variables of the original + model. """ - expr_to_split = objective.expr - - has_args = hasattr(expr_to_split, "args") - is_sum = isinstance(expr_to_split, SumExpression) + config = model_data.config + if config.objective_focus == ObjectiveType.nominal: + soln_master_blk = master_soln.master_model.scenarios[0, 0] + elif config.objective_focus == ObjectiveType.worst_case: + soln_master_blk = max( + master_soln.master_model.scenarios.values(), + key=lambda blk: value(blk.full_objective), + ) - # determine additive terms of the objective expression - # additive terms are in accordance with user declaration - if has_args and is_sum: - obj_args = expr_to_split.args - else: - obj_args = [expr_to_split] + original_model_vars = ( + original_user_var_partitioning.first_stage_variables + + original_user_var_partitioning.second_stage_variables + + original_user_var_partitioning.state_variables + ) + master_soln_vars = ( + soln_master_blk.user_var_partitioning.first_stage_variables + + soln_master_blk.user_var_partitioning.second_stage_variables + + soln_master_blk.user_var_partitioning.state_variables + ) + for orig_var, master_blk_var in zip(original_model_vars, master_soln_vars): + orig_var.set_value(master_blk_var.value, skip_validation=True) - # initialize first and second-stage cost expressions - first_stage_cost_expr = 0 - second_stage_cost_expr = 0 - first_stage_var_set = ComponentSet(model.util.first_stage_variables) - uncertain_param_set = ComponentSet(model.util.uncertain_params) +def call_solver(model, solver, config, timing_obj, timer_name, err_msg): + """ + Solve a model with a given optimizer, keeping track of + wall time requirements. - for term in obj_args: - non_first_stage_vars_in_term = ComponentSet( - v for v in identify_variables(term) if v not in first_stage_var_set - ) - uncertain_params_in_term = ComponentSet( - param - for param in identify_mutable_parameters(term) - if param in uncertain_param_set - ) + Parameters + ---------- + model : ConcreteModel + Model of interest. + solver : Pyomo solver type + Subordinate optimizer. + config : ConfigDict + PyROS solver settings. + timing_obj : TimingData + PyROS solver timing data object. + timer_name : str + Name of sub timer under the hierarchical timer contained in + ``timing_obj`` to start/stop for keeping track of solve + time requirements. + err_msg : str + Message to log through ``config.progress_logger.exception()`` + in event an ApplicationError is raised while attempting to + solve the model. - if non_first_stage_vars_in_term or uncertain_params_in_term: - second_stage_cost_expr += term - else: - first_stage_cost_expr += term + Returns + ------- + SolverResults + Solve results. Note that ``results.solver`` contains + an additional attribute, named after + ``TIC_TOC_SOLVE_TIME_ATTR``, of which the value is set to the + recorded solver wall time. - model.first_stage_objective = Expression(expr=first_stage_cost_expr) - model.second_stage_objective = Expression(expr=second_stage_cost_expr) + Raises + ------ + ApplicationError + If ApplicationError is raised by the solver. + In this case, `err_msg` is logged through + ``config.progress_logger.exception()`` before + the exception is raised. + """ + tt_timer = TicTocTimer() + orig_setting, custom_setting_present = adjust_solver_time_settings( + timing_obj, solver, config + ) + timing_obj.start_timer(timer_name) + tt_timer.tic(msg=None) + + # tentative: reduce risk of InfeasibleConstraintException + # occurring due to discrepancies between Pyomo NL writer + # tolerance and (default) subordinate solver (e.g. IPOPT) + # feasibility tolerances. + # e.g., a Var fixed outside bounds beyond the Pyomo NL writer + # tolerance, but still within the default IPOPT feasibility + # tolerance + current_nl_writer_tol = pyomo_nl_writer.TOL, pyomo_ampl_repn.TOL + pyomo_nl_writer.TOL = 1e-4 + pyomo_ampl_repn.TOL = 1e-4 + + try: + results = solver.solve( + model, + tee=config.tee, + load_solutions=False, + symbolic_solver_labels=config.symbolic_solver_labels, + ) + except (ApplicationError, InvalidValueError): + # account for possible external subsolver errors + # (such as segmentation faults, function evaluation + # errors, etc.) + config.progress_logger.error(err_msg) + raise + else: + setattr( + results.solver, TIC_TOC_SOLVE_TIME_ATTR, tt_timer.toc(msg=None, delta=True) + ) + finally: + pyomo_nl_writer.TOL, pyomo_ampl_repn.TOL = current_nl_writer_tol -def load_final_solution(model_data, master_soln, config): - ''' - load the final solution into the original model object - :param model_data: model data container object - :param master_soln: results data container object returned to user - :return: - ''' - if config.objective_focus == ObjectiveType.nominal: - model = model_data.original_model - soln = master_soln.nominal_block - elif config.objective_focus == ObjectiveType.worst_case: - model = model_data.original_model - indices = range(len(master_soln.master_model.scenarios)) - k = max( - indices, - key=lambda i: value( - master_soln.master_model.scenarios[i, 0].first_stage_objective - + master_soln.master_model.scenarios[i, 0].second_stage_objective - ), + timing_obj.stop_timer(timer_name) + revert_solver_max_time_adjustment( + solver, orig_setting, custom_setting_present, config ) - soln = master_soln.master_model.scenarios[k, 0] - - src_vars = getattr(model, 'tmp_var_list') - local_vars = getattr(soln, 'tmp_var_list') - varMap = list(zip(src_vars, local_vars)) - - for src, local in varMap: - src.set_value(local.value, skip_validation=True) - - return - - -def process_termination_condition_master_problem(config, results): - ''' - :param config: pyros config - :param results: solver results object - :return: tuple (try_backups (True/False) - pyros_return_code (default NONE or robust_infeasible or subsolver_error)) - ''' - locally_acceptable = [tc.optimal, tc.locallyOptimal, tc.globallyOptimal] - globally_acceptable = [tc.optimal, tc.globallyOptimal] - robust_infeasible = [tc.infeasible] - try_backups = [ - tc.feasible, - tc.maxTimeLimit, - tc.maxIterations, - tc.maxEvaluations, - tc.minStepLength, - tc.minFunctionValue, - tc.other, - tc.solverFailure, - tc.internalSolverError, - tc.error, - tc.unbounded, - tc.infeasibleOrUnbounded, - tc.invalidProblem, - tc.intermediateNonInteger, - tc.noSolution, - tc.unknown, - ] - termination_condition = results.solver.termination_condition - if config.solve_master_globally == False: - if termination_condition in locally_acceptable: - return (False, None) - elif termination_condition in robust_infeasible: - return (False, pyrosTerminationCondition.robust_infeasible) - elif termination_condition in try_backups: - return (True, None) - else: - raise NotImplementedError( - "This solver return termination condition (%s) " - "is currently not supported by PyROS." % termination_condition - ) - else: - if termination_condition in globally_acceptable: - return (False, None) - elif termination_condition in robust_infeasible: - return (False, pyrosTerminationCondition.robust_infeasible) - elif termination_condition in try_backups: - return (True, None) - else: - raise NotImplementedError( - "This solver return termination condition (%s) " - "is currently not supported by PyROS." % termination_condition - ) + return results class IterationLogRecord: @@ -1753,7 +3216,7 @@ class IterationLogRecord: dr_polishing_success : bool or None, optional True if DR polishing solved successfully, False otherwise. num_violated_cons : int or None, optional - Number of performance constraints found to be violated + Number of second-stage constraints found to be violated during separation step. all_sep_problems_solved : int or None, optional True if all separation problems were solved successfully, @@ -1764,7 +3227,7 @@ class IterationLogRecord: True if separation problems were solved with the subordinate global optimizer(s), False otherwise. max_violation : int or None - Maximum scaled violation of any performance constraint + Maximum scaled violation of any second-stage constraint found during separation step. elapsed_time : float, optional Total time elapsed up to the current iteration, in seconds. @@ -1792,7 +3255,7 @@ class IterationLogRecord: dr_polishing_success : bool or None True if DR polishing was solved successfully, False otherwise. num_violated_cons : int or None - Number of performance constraints found to be violated + Number of second-stage constraints found to be violated during separation step. all_sep_problems_solved : int or None True if all separation problems were solved successfully, @@ -1803,7 +3266,7 @@ class IterationLogRecord: True if separation problems were solved with the subordinate global optimizer(s), False otherwise. max_violation : int or None - Maximum scaled violation of any performance constraint + Maximum scaled violation of any second-stage constraint found during separation step. elapsed_time : float Total time elapsed up to the current iteration, in seconds. @@ -1933,3 +3396,25 @@ def log_header(log_func, with_rules=True, **log_func_kwargs): def log_header_rule(log_func, fillchar="-", **log_func_kwargs): """Log header rule.""" log_func(fillchar * IterationLogRecord._LINE_LENGTH, **log_func_kwargs) + + +def copy_docstring(source_func): + """ + Create a decorator which copies docstring of a callable + `source_func` to a target callable passed to the decorator. + + Returns + ------- + decorator_doc : callable + Decorator of interest. + """ + + def decorator_doc(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + wrapper.__doc__ = source_func.__doc__ + return wrapper + + return decorator_doc diff --git a/pyomo/contrib/satsolver/__init__.py b/pyomo/contrib/satsolver/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/satsolver/__init__.py +++ b/pyomo/contrib/satsolver/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/satsolver/satsolver.py b/pyomo/contrib/satsolver/satsolver.py index b5004d6a611..07e5199f401 100644 --- a/pyomo/contrib/satsolver/satsolver.py +++ b/pyomo/contrib/satsolver/satsolver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/satsolver/tests/__init__.py b/pyomo/contrib/satsolver/tests/__init__.py new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/satsolver/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/satsolver/test_satsolver.py b/pyomo/contrib/satsolver/tests/test_satsolver.py similarity index 97% rename from pyomo/contrib/satsolver/test_satsolver.py rename to pyomo/contrib/satsolver/tests/test_satsolver.py index f19f172f7b2..13ec6ba4451 100644 --- a/pyomo/contrib/satsolver/test_satsolver.py +++ b/pyomo/contrib/satsolver/tests/test_satsolver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from os.path import abspath, dirname, join, normpath +from os.path import join import pyomo.common.unittest as unittest -from pyomo.common.fileutils import import_file +from pyomo.common.fileutils import import_file, PYOMO_ROOT_DIR from pyomo.contrib.satsolver.satsolver import satisfiable, z3_available from pyomo.core.base.set_types import PositiveIntegers, NonNegativeReals, Binary from pyomo.environ import ( @@ -33,8 +33,7 @@ ) from pyomo.gdp import Disjunct, Disjunction -currdir = dirname(abspath(__file__)) -exdir = normpath(join(currdir, '..', '..', '..', 'examples', 'gdp')) +exdir = join(PYOMO_ROOT_DIR, 'examples', 'gdp') @unittest.skipUnless(z3_available, "Z3 SAT solver is not available.") diff --git a/pyomo/contrib/sensitivity_toolbox/__init__.py b/pyomo/contrib/sensitivity_toolbox/__init__.py index a20cbc389d7..59a1b3f12ab 100644 --- a/pyomo/contrib/sensitivity_toolbox/__init__.py +++ b/pyomo/contrib/sensitivity_toolbox/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/examples/HIV_Transmission.py b/pyomo/contrib/sensitivity_toolbox/examples/HIV_Transmission.py index 8d43dea26b2..9a8da7bc954 100755 --- a/pyomo/contrib/sensitivity_toolbox/examples/HIV_Transmission.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/HIV_Transmission.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/examples/__init__.py b/pyomo/contrib/sensitivity_toolbox/examples/__init__.py index a408b878891..93a2d74b0d9 100644 --- a/pyomo/contrib/sensitivity_toolbox/examples/__init__.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/examples/feedbackController.py b/pyomo/contrib/sensitivity_toolbox/examples/feedbackController.py index d973bedf5ba..55daf1eb557 100644 --- a/pyomo/contrib/sensitivity_toolbox/examples/feedbackController.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/feedbackController.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/examples/parameter.py b/pyomo/contrib/sensitivity_toolbox/examples/parameter.py index 85d31d3303e..b8cccece637 100644 --- a/pyomo/contrib/sensitivity_toolbox/examples/parameter.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/parameter.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/examples/parameter_kaug.py b/pyomo/contrib/sensitivity_toolbox/examples/parameter_kaug.py index c5e61307046..18f81faecdc 100644 --- a/pyomo/contrib/sensitivity_toolbox/examples/parameter_kaug.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/parameter_kaug.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/examples/rangeInequality.py b/pyomo/contrib/sensitivity_toolbox/examples/rangeInequality.py index b06cc8390d2..3ac30308bcf 100644 --- a/pyomo/contrib/sensitivity_toolbox/examples/rangeInequality.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/rangeInequality.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/examples/rooney_biegler.py b/pyomo/contrib/sensitivity_toolbox/examples/rooney_biegler.py index 3efb20bd44b..ae4e9ff9824 100644 --- a/pyomo/contrib/sensitivity_toolbox/examples/rooney_biegler.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/rooney_biegler.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,8 +22,8 @@ # at the URL "https://github.com/IDAES/idaes-pse". ############################################################################## """ -Rooney Biegler model, based on Rooney, W. C. and Biegler, L. T. (2001). Design for -model parameter uncertainty using nonlinear confidence regions. AIChE Journal, +Rooney Biegler model, based on Rooney, W. C. and Biegler, L. T. (2001). Design for +model parameter uncertainty using nonlinear confidence regions. AIChE Journal, 47(8), 1794-1804. """ from pyomo.common.dependencies import pandas as pd diff --git a/pyomo/contrib/sensitivity_toolbox/k_aug.py b/pyomo/contrib/sensitivity_toolbox/k_aug.py index a7fc10569fe..6231514254d 100644 --- a/pyomo/contrib/sensitivity_toolbox/k_aug.py +++ b/pyomo/contrib/sensitivity_toolbox/k_aug.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # ______________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/sens.py b/pyomo/contrib/sensitivity_toolbox/sens.py index a3d69b2c7b1..c1cd82a15b7 100644 --- a/pyomo/contrib/sensitivity_toolbox/sens.py +++ b/pyomo/contrib/sensitivity_toolbox/sens.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -# ______________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License -# ______________________________________________________________________________ from pyomo.environ import ( Param, Var, @@ -34,8 +24,10 @@ from pyomo.common.sorting import sorted_robust from pyomo.core.expr import ExpressionReplacementVisitor +from pyomo.core.expr.numvalue import is_potentially_variable from pyomo.common.modeling import unique_component_name +from pyomo.common.dependencies import numpy as np, scipy from pyomo.common.deprecation import deprecated from pyomo.common.tempfiles import TempfileManager from pyomo.opt import SolverFactory, SolverStatus @@ -44,8 +36,6 @@ import os import io import shutil -from pyomo.common.dependencies import numpy as np, numpy_available -from pyomo.common.dependencies import scipy, scipy_available logger = logging.getLogger('pyomo.contrib.sensitivity_toolbox') @@ -240,22 +230,27 @@ def sensitivity_calculation( def get_dsdp(model, theta_names, theta, tee=False): - """This function calculates gradient vector of the variables - with respect to the parameters (theta_names). - - e.g) min f: p1*x1+ p2*(x2^2) + p1*p2 - s.t c1: x1 + x2 = p1 - c2: x2 + x3 = p2 - 0 <= x1, x2, x3 <= 10 - p1 = 10 - p2 = 5 + r"""This function calculates gradient vector of the variables with + respect to the parameters (theta_names). + + For example, given: + + .. math:: + + \min f:\ & p1*x1 + p2*(x2^2) + p1*p2 \\ + s.t.\ & c1: x1 + x2 = p1 \\ + & c2: x2 + x3 = p2 \\ + & 0 <= x1, x2, x3 <= 10 \\ + & p1 = 10 \\ + & p2 = 5 + the function returns dx/dp and dp/dp, and column orders. The following terms are used to define the output dimensions: - Ncon = number of constraints - Nvar = number of variables (Nx + Ntheta) - Nx = number of decision (primal) variables - Ntheta = number of uncertain parameters. + - Ncon = number of constraints + - Nvar = number of variables (Nx + Ntheta) + - Nx = number of decision (primal) variables + - Ntheta = number of uncertain parameters. Parameters ---------- @@ -277,6 +272,7 @@ def get_dsdp(model, theta_names, theta, tee=False): columns = len(col) col: list List of variable names + """ # Get parameters from names. In SensitivityInterface, we expect # these to be parameters on the original model. @@ -331,54 +327,66 @@ def get_dsdp(model, theta_names, theta, tee=False): def get_dfds_dcds(model, theta_names, tee=False, solver_options=None): - """This function calculates gradient vector of the objective function - and constraints with respect to the variables and parameters. - - e.g) min f: p1*x1+ p2*(x2^2) + p1*p2 - s.t c1: x1 + x2 = p1 - c2: x2 + x3 = p2 - 0 <= x1, x2, x3 <= 10 - p1 = 10 - p2 = 5 + r"""This function calculates gradient vector of the objective function + and constraints with respect to the variables and parameters. + + For example, given: + + .. math:: + + \min f:\ & p1*x1 + p2*(x2^2) + p1*p2 \\ + s.t.\ & c1: x1 + x2 = p1 \\ + & c2: x2 + x3 = p2 \\ + & 0 <= x1, x2, x3 <= 10 \\ + & p1 = 10 \\ + & p2 = 5 + - Variables = (x1, x2, x3, p1, p2) - Fix p1 and p2 with estimated values The following terms are used to define the output dimensions: - Ncon = number of constraints - Nvar = number of variables (Nx + Ntheta) - Nx = number of decision (primal) variables - Ntheta = number of uncertain parameters. + - Ncon = number of constraints + - Nvar = number of variables (Nx + Ntheta) + - Nx = number of decision (primal) variables + - Ntheta = number of uncertain parameters. Parameters ---------- - model: Pyomo ConcreteModel + model : Pyomo ConcreteModel model should include an objective function - theta_names: list of strings + + theta_names : list of strings List of Var names - tee: bool, optional + + tee : bool, optional Indicates that ef solver output should be teed - solver_options: dict, optional + + solver_options : dict, optional Provides options to the solver (also the name of an attribute) Returns ------- - gradient_f: numpy.ndarray + gradient_f : numpy.ndarray Length Nvar array. A gradient vector of the objective function with respect to the (decision variables, parameters) at the optimal solution - gradient_c: scipy.sparse.csr.csr_matrix + + gradient_c : scipy.sparse.csr.csr_matrix Ncon by Nvar size sparse matrix. A Jacobian matrix of the constraints with respect to the (decision variables, parameters) at the optimal solution. Each row contains [row number, column number, and value], column order follows variable order in col and index starts from 0. Note that it follows k_aug. If no constraint exists, return [] - col: list + + col : list Size Nvar list of variable names - row: list + + row : list Size Ncon+1 list of constraints and objective function names. The final element is the objective function name. - line_dic: dict + + line_dic : dict column numbers of the theta_names in the model. Index starts from 1 Raises @@ -684,25 +692,29 @@ def _replace_parameters_in_constraints(self, variableSubMap): ) last_idx = 0 for con in old_con_list: - if con.equality or con.lower is None or con.upper is None: - new_expr = param_replacer.walk_expression(con.expr) - block.constList.add(expr=new_expr) + new_expr = param_replacer.walk_expression(con.expr) + # TODO: We could only create new constraints for expressions + # where substitution actually happened, but that breaks some + # current tests: + # + # if new_expr is con.expr: + # # No params were substituted. We can ignore this constraint + # continue + if new_expr.nargs() == 3 and ( + is_potentially_variable(new_expr.arg(0)) + or is_potentially_variable(new_expr.arg(2)) + ): + # This is a potentially "invalid" range constraint: it + # may now have variables in the bounds. For safety, we + # will split it into two simple inequalities. + block.constList.add(expr=(new_expr.arg(0) <= new_expr.arg(1))) last_idx += 1 new_old_comp_map[block.constList[last_idx]] = con - else: - # Constraint must be a ranged inequality, break into - # separate constraints - new_body = param_replacer.walk_expression(con.body) - new_lower = param_replacer.walk_expression(con.lower) - new_upper = param_replacer.walk_expression(con.upper) - - # Add constraint for lower bound - block.constList.add(expr=(new_lower <= new_body)) + block.constList.add(expr=(new_expr.arg(1) <= new_expr.arg(2))) last_idx += 1 new_old_comp_map[block.constList[last_idx]] = con - - # Add constraint for upper bound - block.constList.add(expr=(new_body <= new_upper)) + else: + block.constList.add(expr=new_expr) last_idx += 1 new_old_comp_map[block.constList[last_idx]] = con con.deactivate() diff --git a/pyomo/contrib/sensitivity_toolbox/tests/__init__.py b/pyomo/contrib/sensitivity_toolbox/tests/__init__.py index 53f447ece43..66b3b3a8bef 100644 --- a/pyomo/contrib/sensitivity_toolbox/tests/__init__.py +++ b/pyomo/contrib/sensitivity_toolbox/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/tests/test_k_aug_interface.py b/pyomo/contrib/sensitivity_toolbox/tests/test_k_aug_interface.py index e941656a392..822a56bc914 100644 --- a/pyomo/contrib/sensitivity_toolbox/tests/test_k_aug_interface.py +++ b/pyomo/contrib/sensitivity_toolbox/tests/test_k_aug_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # ____________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,8 +20,6 @@ # This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________ -""" -""" import os import pyomo.common.unittest as unittest from io import StringIO diff --git a/pyomo/contrib/sensitivity_toolbox/tests/test_sens.py b/pyomo/contrib/sensitivity_toolbox/tests/test_sens.py index 69cf0303987..0c4a1c10683 100644 --- a/pyomo/contrib/sensitivity_toolbox/tests/test_sens.py +++ b/pyomo/contrib/sensitivity_toolbox/tests/test_sens.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # ____________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/sensitivity_toolbox/tests/test_sens_unit.py b/pyomo/contrib/sensitivity_toolbox/tests/test_sens_unit.py index 9f4bcb2b497..91f46be9e6d 100644 --- a/pyomo/contrib/sensitivity_toolbox/tests/test_sens_unit.py +++ b/pyomo/contrib/sensitivity_toolbox/tests/test_sens_unit.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ # ____________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/simplemodel/__init__.py b/pyomo/contrib/simplemodel/__init__.py deleted file mode 100644 index f2f4922223e..00000000000 --- a/pyomo/contrib/simplemodel/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.common.deprecation import deprecation_warning, in_testing_environment - -try: - deprecation_warning( - "The use of pyomo.contrib.simple model is deprecated. " - "This capability is now supported in the pyomo_simplemodel " - "package, which is included in the pyomo_community distribution.", - version='5.6.9', - ) - from pyomocontrib_simplemodel import * -except ImportError: - # Only raise the exception if nose/pytest/sphinx are NOT running - # (otherwise test discovery can result in exceptions) - if not in_testing_environment(): - raise RuntimeError("The pyomocontrib_simplemodel package is not installed.") diff --git a/pyomo/contrib/simplification/__init__.py b/pyomo/contrib/simplification/__init__.py new file mode 100644 index 00000000000..6aea4321146 --- /dev/null +++ b/pyomo/contrib/simplification/__init__.py @@ -0,0 +1,12 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from .simplify import Simplifier diff --git a/pyomo/contrib/simplification/build.py b/pyomo/contrib/simplification/build.py new file mode 100644 index 00000000000..303029d5c4d --- /dev/null +++ b/pyomo/contrib/simplification/build.py @@ -0,0 +1,209 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import glob +import logging +import os +import shutil +import sys +import subprocess + +from pyomo.common.download import FileDownloader +from pyomo.common.envvar import PYOMO_CONFIG_DIR +from pyomo.common.fileutils import find_library, this_file_dir +from pyomo.common.tempfiles import TempfileManager + +logger = logging.getLogger(__name__ if __name__ != '__main__' else 'pyomo') + + +def build_ginac_library(parallel=None, argv=None, env=None): + sys.stdout.write("\n**** Building GiNaC library ****\n") + + configure_cmd = [ + os.path.join('.', 'configure'), + '--prefix=' + PYOMO_CONFIG_DIR, + '--disable-static', + ] + make_cmd = ['make'] + if parallel: + make_cmd.append(f'-j{parallel}') + install_cmd = ['make', 'install'] + + env = dict(os.environ) + pcdir = os.path.join(PYOMO_CONFIG_DIR, 'lib', 'pkgconfig') + if 'PKG_CONFIG_PATH' in env: + pcdir += os.pathsep + env['PKG_CONFIG_PATH'] + env['PKG_CONFIG_PATH'] = pcdir + + with TempfileManager.new_context() as tempfile: + tmpdir = tempfile.mkdtemp() + + downloader = FileDownloader() + if argv: + downloader.parse_args(argv) + + url = 'https://www.ginac.de/CLN/cln-1.3.7.tar.bz2' + cln_dir = os.path.join(tmpdir, 'cln') + downloader.set_destination_filename(cln_dir) + logger.info( + "Fetching CLN from %s and installing it to %s" + % (url, downloader.destination()) + ) + downloader.get_tar_archive(url, dirOffset=1) + assert subprocess.run(configure_cmd, cwd=cln_dir, env=env).returncode == 0 + logger.info("\nBuilding CLN\n") + assert subprocess.run(make_cmd, cwd=cln_dir, env=env).returncode == 0 + assert subprocess.run(install_cmd, cwd=cln_dir, env=env).returncode == 0 + + url = 'https://www.ginac.de/ginac-1.8.8.tar.bz2' + ginac_dir = os.path.join(tmpdir, 'ginac') + downloader.set_destination_filename(ginac_dir) + logger.info( + "Fetching GiNaC from %s and installing it to %s" + % (url, downloader.destination()) + ) + downloader.get_tar_archive(url, dirOffset=1) + assert subprocess.run(configure_cmd, cwd=ginac_dir, env=env).returncode == 0 + logger.info("\nBuilding GiNaC\n") + assert subprocess.run(make_cmd, cwd=ginac_dir, env=env).returncode == 0 + assert subprocess.run(install_cmd, cwd=ginac_dir, env=env).returncode == 0 + print("Installed GiNaC to %s" % (ginac_dir,)) + + +def _find_include(libdir, incpaths): + rel_path = ('include',) + incpaths + while 1: + basedir = os.path.dirname(libdir) + if not basedir or basedir == libdir: + return None + if os.path.exists(os.path.join(basedir, *rel_path)): + return os.path.join(basedir, *(rel_path[: -len(incpaths)])) + libdir = basedir + + +def build_ginac_interface(parallel=None, args=None): + from distutils.dist import Distribution + from pybind11.setup_helpers import Pybind11Extension, build_ext + from pyomo.common.cmake_builder import handleReadonly + + sys.stdout.write("\n**** Building GiNaC interface ****\n") + + if args is None: + args = [] + sources = [ + os.path.join(this_file_dir(), 'ginac', 'src', fname) + for fname in ['ginac_interface.cpp'] + ] + + ginac_lib = find_library('ginac') + if not ginac_lib: + raise RuntimeError( + 'could not find the GiNaC library; please make sure either to install ' + 'the library and development headers system-wide, or include the ' + 'path to the library in the LD_LIBRARY_PATH environment variable' + ) + ginac_lib_dir = os.path.dirname(ginac_lib) + ginac_include_dir = _find_include(ginac_lib_dir, ('ginac', 'ginac.h')) + if not ginac_include_dir: + raise RuntimeError('could not find GiNaC include directory') + + cln_lib = find_library('cln') + if not cln_lib: + raise RuntimeError( + 'could not find the CLN library; please make sure either to install ' + 'the library and development headers system-wide, or include the ' + 'path to the library in the LD_LIBRARY_PATH environment variable' + ) + cln_lib_dir = os.path.dirname(cln_lib) + cln_include_dir = _find_include(cln_lib_dir, ('cln', 'cln.h')) + if not cln_include_dir: + raise RuntimeError('could not find CLN include directory') + + extra_args = ['-std=c++11'] + ext = Pybind11Extension( + 'ginac_interface', + sources=sources, + language='c++', + include_dirs=[cln_include_dir, ginac_include_dir], + library_dirs=[cln_lib_dir, ginac_lib_dir], + libraries=['cln', 'ginac'], + extra_compile_args=extra_args, + ) + + class ginacBuildExt(build_ext): + def run(self): + basedir = os.path.abspath(os.path.curdir) + with TempfileManager.new_context() as tempfile: + if self.inplace: + tmpdir = os.path.join(this_file_dir(), 'ginac') + else: + tmpdir = os.path.abspath(tempfile.mkdtemp()) + sys.stdout.write("Building in '%s'\n" % tmpdir) + os.chdir(tmpdir) + super(ginacBuildExt, self).run() + if not self.inplace: + library = glob.glob("build/*/ginac_interface.*")[0] + target = os.path.join( + PYOMO_CONFIG_DIR, + 'lib', + 'python%s.%s' % sys.version_info[:2], + 'site-packages', + '.', + ) + if not os.path.exists(target): + os.makedirs(target) + sys.stdout.write(f"Installing {library} in {target}\n") + shutil.copy(library, target) + + package_config = { + 'name': 'ginac_interface', + 'packages': [], + 'ext_modules': [ext], + 'cmdclass': {"build_ext": ginacBuildExt}, + } + + dist = Distribution(package_config) + dist.script_args = ['build_ext'] + args + dist.parse_command_line() + dist.run_command('build_ext') + + +class GiNaCInterfaceBuilder(object): + def __call__(self, parallel): + return build_ginac_interface(parallel) + + def skip(self): + return not find_library('ginac') + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument( + "-j", + dest='parallel', + type=int, + default=None, + help="Enable parallel build with PARALLEL cores", + ) + parser.add_argument( + "--build-deps", + dest='build_deps', + action='store_true', + default=False, + help="Download and build the CLN/GiNaC libraries", + ) + options, argv = parser.parse_known_args(sys.argv) + logging.getLogger('pyomo').setLevel(logging.INFO) + if options.build_deps: + build_ginac_library(options.parallel, []) + build_ginac_interface(options.parallel, argv[1:]) diff --git a/pyomo/contrib/simplification/ginac/__init__.py b/pyomo/contrib/simplification/ginac/__init__.py new file mode 100644 index 00000000000..cfd07048ab7 --- /dev/null +++ b/pyomo/contrib/simplification/ginac/__init__.py @@ -0,0 +1,52 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import attempt_import as _attempt_import + + +def _importer(): + import os + import sys + from ctypes import cdll + from pyomo.common.envvar import PYOMO_CONFIG_DIR + from pyomo.common.fileutils import find_library + + try: + pyomo_config_dir = os.path.join( + PYOMO_CONFIG_DIR, + 'lib', + 'python%s.%s' % sys.version_info[:2], + 'site-packages', + ) + sys.path.insert(0, pyomo_config_dir) + # GiNaC needs 2 libraries that are generally dynamically linked + # to the interface library. If we built those ourselves, then + # the libraries will be PYOMO_CONFIG_DIR/lib ... but that + # directory is very likely to NOT be on the library search path + # when the Python interpreter was started. We will manually + # look for those two libraries, and if we find them, load them + # into this process (so the interface can find them) + for lib in ('cln', 'ginac'): + fname = find_library(lib) + if fname is not None: + cdll.LoadLibrary(fname) + + import ginac_interface + except ImportError: + from . import ginac_interface + finally: + assert sys.path[0] == pyomo_config_dir + sys.path.pop(0) + + return ginac_interface + + +interface, interface_available = _attempt_import('ginac_interface', importer=_importer) diff --git a/pyomo/contrib/simplification/ginac/src/ginac_interface.cpp b/pyomo/contrib/simplification/ginac/src/ginac_interface.cpp new file mode 100644 index 00000000000..f7aa0acb937 --- /dev/null +++ b/pyomo/contrib/simplification/ginac/src/ginac_interface.cpp @@ -0,0 +1,332 @@ +// ___________________________________________________________________________ +// +// Pyomo: Python Optimization Modeling Objects +// Copyright (c) 2008-2025 +// National Technology and Engineering Solutions of Sandia, LLC +// Under the terms of Contract DE-NA0003525 with National Technology and +// Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +// rights in this software. +// This software is distributed under the 3-clause BSD License. +// ___________________________________________________________________________ + +#include "ginac_interface.hpp" + + +bool is_integer(double x) { + return std::floor(x) == x; +} + + +ex ginac_expr_from_pyomo_node( + py::handle expr, + std::unordered_map &leaf_map, + std::unordered_map &ginac_pyomo_map, + PyomoExprTypes &expr_types, + bool symbolic_solver_labels + ) { + ex res; + ExprType tmp_type = + expr_types.expr_type_map[py::type::of(expr)].cast(); + + switch (tmp_type) { + case py_float: { + double val = expr.cast(); + if (is_integer(val)) { + res = numeric((long) val); + } + else { + res = numeric(val); + } + break; + } + case var: { + long expr_id = expr_types.id(expr).cast(); + if (leaf_map.count(expr_id) == 0) { + std::string vname; + if (symbolic_solver_labels) { + vname = expr.attr("name").cast(); + } + else { + vname = "x" + std::to_string(expr_id); + } + py::object lb = expr.attr("lb"); + if (lb.is_none() || lb.cast() < 0) { + leaf_map[expr_id] = realsymbol(vname); + } + else { + leaf_map[expr_id] = possymbol(vname); + } + ginac_pyomo_map[leaf_map[expr_id]] = expr.cast(); + } + res = leaf_map[expr_id]; + break; + } + case param: { + long expr_id = expr_types.id(expr).cast(); + if (leaf_map.count(expr_id) == 0) { + std::string pname; + if (symbolic_solver_labels) { + pname = expr.attr("name").cast(); + } + else { + pname = "p" + std::to_string(expr_id); + } + leaf_map[expr_id] = realsymbol(pname); + ginac_pyomo_map[leaf_map[expr_id]] = expr.cast(); + } + res = leaf_map[expr_id]; + break; + } + case product: { + py::list pyomo_args = expr.attr("args"); + res = ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels) * ginac_expr_from_pyomo_node(pyomo_args[1], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + break; + } + case sum: { + py::list pyomo_args = expr.attr("args"); + for (py::handle arg : pyomo_args) { + res += ginac_expr_from_pyomo_node(arg, leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + } + break; + } + case negation: { + py::list pyomo_args = expr.attr("args"); + res = - ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + break; + } + case external_func: { + long expr_id = expr_types.id(expr).cast(); + if (leaf_map.count(expr_id) == 0) { + leaf_map[expr_id] = realsymbol("f" + std::to_string(expr_id)); + ginac_pyomo_map[leaf_map[expr_id]] = expr.cast(); + } + res = leaf_map[expr_id]; + break; + } + case ExprType::power: { + py::list pyomo_args = expr.attr("args"); + res = pow(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels), ginac_expr_from_pyomo_node(pyomo_args[1], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + break; + } + case division: { + py::list pyomo_args = expr.attr("args"); + res = ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels) / ginac_expr_from_pyomo_node(pyomo_args[1], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + break; + } + case unary_func: { + std::string function_name = expr.attr("getname")().cast(); + py::list pyomo_args = expr.attr("args"); + if (function_name == "exp") + res = exp(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "log") + res = log(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "sin") + res = sin(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "cos") + res = cos(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "tan") + res = tan(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "asin") + res = asin(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "acos") + res = acos(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "atan") + res = atan(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "sqrt") + res = sqrt(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else + throw py::value_error("Unrecognized expression type: " + function_name); + break; + } + case linear: { + py::list pyomo_args = expr.attr("args"); + for (py::handle arg : pyomo_args) { + res += ginac_expr_from_pyomo_node(arg, leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + } + break; + } + case named_expr: { + res = ginac_expr_from_pyomo_node(expr.attr("expr"), leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + break; + } + case numeric_constant: { + res = numeric(expr.attr("value").cast()); + break; + } + case pyomo_unit: { + res = numeric(1.0); + break; + } + case unary_abs: { + py::list pyomo_args = expr.attr("args"); + res = abs(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + break; + } + default: { + throw py::value_error("Unrecognized expression type: " + + expr_types.builtins.attr("str")(py::type::of(expr)) + .cast()); + break; + } + } + return res; +} + +ex pyomo_expr_to_ginac_expr( + py::handle expr, + std::unordered_map &leaf_map, + std::unordered_map &ginac_pyomo_map, + PyomoExprTypes &expr_types, + bool symbolic_solver_labels + ) { + ex res = ginac_expr_from_pyomo_node(expr, leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + return res; + } + +ex pyomo_to_ginac(py::handle expr, PyomoExprTypes &expr_types) { + std::unordered_map leaf_map; + std::unordered_map ginac_pyomo_map; + ex res = ginac_expr_from_pyomo_node(expr, leaf_map, ginac_pyomo_map, expr_types, true); + return res; +} + + +class GinacToPyomoVisitor +: public visitor, + public symbol::visitor, + public numeric::visitor, + public add::visitor, + public mul::visitor, + public GiNaC::power::visitor, + public function::visitor, + public basic::visitor +{ + public: + std::unordered_map *leaf_map; + std::unordered_map node_map; + PyomoExprTypes *expr_types; + + GinacToPyomoVisitor(std::unordered_map *_leaf_map, PyomoExprTypes *_expr_types) : leaf_map(_leaf_map), expr_types(_expr_types) {} + ~GinacToPyomoVisitor() = default; + + void visit(const symbol& e) { + node_map[e] = leaf_map->at(e); + } + + void visit(const numeric& e) { + double val = e.to_double(); + node_map[e] = expr_types->NumericConstant(py::cast(val)); + } + + void visit(const add& e) { + size_t n = e.nops(); + py::object pe = node_map[e.op(0)]; + for (unsigned long ndx=1; ndx < n; ++ndx) { + pe = pe.attr("__add__")(node_map[e.op(ndx)]); + } + node_map[e] = pe; + } + + void visit(const mul& e) { + size_t n = e.nops(); + py::object pe = node_map[e.op(0)]; + for (unsigned long ndx=1; ndx < n; ++ndx) { + pe = pe.attr("__mul__")(node_map[e.op(ndx)]); + } + node_map[e] = pe; + } + + void visit(const GiNaC::power& e) { + py::object arg1 = node_map[e.op(0)]; + py::object arg2 = node_map[e.op(1)]; + py::object pe = arg1.attr("__pow__")(arg2); + node_map[e] = pe; + } + + void visit(const function& e) { + py::object arg = node_map[e.op(0)]; + std::string func_type = e.get_name(); + py::object pe; + if (func_type == "exp") { + pe = expr_types->exp(arg); + } + else if (func_type == "log") { + pe = expr_types->log(arg); + } + else if (func_type == "sin") { + pe = expr_types->sin(arg); + } + else if (func_type == "cos") { + pe = expr_types->cos(arg); + } + else if (func_type == "tan") { + pe = expr_types->tan(arg); + } + else if (func_type == "asin") { + pe = expr_types->asin(arg); + } + else if (func_type == "acos") { + pe = expr_types->acos(arg); + } + else if (func_type == "atan") { + pe = expr_types->atan(arg); + } + else if (func_type == "sqrt") { + pe = expr_types->sqrt(arg); + } + else { + throw py::value_error("unrecognized unary function: " + func_type); + } + node_map[e] = pe; + } + + void visit(const basic& e) { + throw py::value_error("unrecognized ginac expression type"); + } +}; + + +ex GinacInterface::to_ginac(py::handle expr) { + return pyomo_expr_to_ginac_expr(expr, leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); +} + +py::object GinacInterface::from_ginac(ex &ge) { + GinacToPyomoVisitor v(&ginac_pyomo_map, &expr_types); + ge.traverse_postorder(v); + return v.node_map[ge]; +} + +PYBIND11_MODULE(ginac_interface, m) { + m.def("pyomo_to_ginac", &pyomo_to_ginac); + py::class_(m, "PyomoExprTypes", py::module_local()) + .def(py::init<>()); + py::class_(m, "ginac_expression") + .def("expand", [](ex &ge) { + return ge.expand(); + }) + .def("normal", &ex::normal) + .def("__str__", [](ex &ge) { + std::ostringstream stream; + stream << ge; + return stream.str(); + }); + py::class_(m, "GinacInterface") + .def(py::init()) + .def("to_ginac", &GinacInterface::to_ginac) + .def("from_ginac", &GinacInterface::from_ginac); + py::enum_(m, "ExprType", py::module_local()) + .value("py_float", ExprType::py_float) + .value("var", ExprType::var) + .value("param", ExprType::param) + .value("product", ExprType::product) + .value("sum", ExprType::sum) + .value("negation", ExprType::negation) + .value("external_func", ExprType::external_func) + .value("power", ExprType::power) + .value("division", ExprType::division) + .value("unary_func", ExprType::unary_func) + .value("linear", ExprType::linear) + .value("named_expr", ExprType::named_expr) + .value("numeric_constant", ExprType::numeric_constant) + .export_values(); +} diff --git a/pyomo/contrib/simplification/ginac/src/ginac_interface.hpp b/pyomo/contrib/simplification/ginac/src/ginac_interface.hpp new file mode 100644 index 00000000000..bc5b0d7b6fc --- /dev/null +++ b/pyomo/contrib/simplification/ginac/src/ginac_interface.hpp @@ -0,0 +1,190 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define PYBIND11_DETAILED_ERROR_MESSAGES + +namespace py = pybind11; +using namespace pybind11::literals; +using namespace GiNaC; + +enum ExprType { + py_float = 0, + var = 1, + param = 2, + product = 3, + sum = 4, + negation = 5, + external_func = 6, + power = 7, + division = 8, + unary_func = 9, + linear = 10, + named_expr = 11, + numeric_constant = 12, + pyomo_unit = 13, + unary_abs = 14 +}; + +class PyomoExprTypes { +public: + PyomoExprTypes() { + expr_type_map[int_] = py_float; + expr_type_map[float_] = py_float; + expr_type_map[np_int16] = py_float; + expr_type_map[np_int32] = py_float; + expr_type_map[np_int64] = py_float; + expr_type_map[np_longlong] = py_float; + expr_type_map[np_uint16] = py_float; + expr_type_map[np_uint32] = py_float; + expr_type_map[np_uint64] = py_float; + expr_type_map[np_ulonglong] = py_float; + expr_type_map[np_float16] = py_float; + expr_type_map[np_float32] = py_float; + expr_type_map[np_float64] = py_float; + expr_type_map[ScalarVar] = var; + expr_type_map[_GeneralVarData] = var; + expr_type_map[AutoLinkedBinaryVar] = var; + expr_type_map[ScalarParam] = param; + expr_type_map[_ParamData] = param; + expr_type_map[MonomialTermExpression] = product; + expr_type_map[ProductExpression] = product; + expr_type_map[NPV_ProductExpression] = product; + expr_type_map[SumExpression] = sum; + expr_type_map[NPV_SumExpression] = sum; + expr_type_map[NegationExpression] = negation; + expr_type_map[NPV_NegationExpression] = negation; + expr_type_map[ExternalFunctionExpression] = external_func; + expr_type_map[NPV_ExternalFunctionExpression] = external_func; + expr_type_map[PowExpression] = ExprType::power; + expr_type_map[NPV_PowExpression] = ExprType::power; + expr_type_map[DivisionExpression] = division; + expr_type_map[NPV_DivisionExpression] = division; + expr_type_map[UnaryFunctionExpression] = unary_func; + expr_type_map[NPV_UnaryFunctionExpression] = unary_func; + expr_type_map[LinearExpression] = linear; + expr_type_map[_GeneralExpressionData] = named_expr; + expr_type_map[ScalarExpression] = named_expr; + expr_type_map[Integral] = named_expr; + expr_type_map[ScalarIntegral] = named_expr; + expr_type_map[NumericConstant] = numeric_constant; + expr_type_map[_PyomoUnit] = pyomo_unit; + expr_type_map[AbsExpression] = unary_abs; + expr_type_map[NPV_AbsExpression] = unary_abs; + } + ~PyomoExprTypes() = default; + py::int_ ione = 1; + py::float_ fone = 1.0; + py::type int_ = py::type::of(ione); + py::type float_ = py::type::of(fone); + py::object np = py::module_::import("numpy"); + py::type np_int16 = np.attr("int16"); + py::type np_int32 = np.attr("int32"); + py::type np_int64 = np.attr("int64"); + py::type np_longlong = np.attr("longlong"); + py::type np_uint16 = np.attr("uint16"); + py::type np_uint32 = np.attr("uint32"); + py::type np_uint64 = np.attr("uint64"); + py::type np_ulonglong = np.attr("ulonglong"); + py::type np_float16 = np.attr("float16"); + py::type np_float32 = np.attr("float32"); + py::type np_float64 = np.attr("float64"); + py::object ScalarParam = + py::module_::import("pyomo.core.base.param").attr("ScalarParam"); + py::object _ParamData = + py::module_::import("pyomo.core.base.param").attr("_ParamData"); + py::object ScalarVar = + py::module_::import("pyomo.core.base.var").attr("ScalarVar"); + py::object _GeneralVarData = + py::module_::import("pyomo.core.base.var").attr("_GeneralVarData"); + py::object AutoLinkedBinaryVar = + py::module_::import("pyomo.gdp.disjunct").attr("AutoLinkedBinaryVar"); + py::object numeric_expr = py::module_::import("pyomo.core.expr.numeric_expr"); + py::object NegationExpression = numeric_expr.attr("NegationExpression"); + py::object NPV_NegationExpression = + numeric_expr.attr("NPV_NegationExpression"); + py::object ExternalFunctionExpression = + numeric_expr.attr("ExternalFunctionExpression"); + py::object NPV_ExternalFunctionExpression = + numeric_expr.attr("NPV_ExternalFunctionExpression"); + py::object PowExpression = numeric_expr.attr("PowExpression"); + py::object NPV_PowExpression = numeric_expr.attr("NPV_PowExpression"); + py::object ProductExpression = numeric_expr.attr("ProductExpression"); + py::object NPV_ProductExpression = numeric_expr.attr("NPV_ProductExpression"); + py::object MonomialTermExpression = + numeric_expr.attr("MonomialTermExpression"); + py::object DivisionExpression = numeric_expr.attr("DivisionExpression"); + py::object NPV_DivisionExpression = + numeric_expr.attr("NPV_DivisionExpression"); + py::object SumExpression = numeric_expr.attr("SumExpression"); + py::object NPV_SumExpression = numeric_expr.attr("NPV_SumExpression"); + py::object UnaryFunctionExpression = + numeric_expr.attr("UnaryFunctionExpression"); + py::object AbsExpression = numeric_expr.attr("AbsExpression"); + py::object NPV_AbsExpression = numeric_expr.attr("NPV_AbsExpression"); + py::object NPV_UnaryFunctionExpression = + numeric_expr.attr("NPV_UnaryFunctionExpression"); + py::object LinearExpression = numeric_expr.attr("LinearExpression"); + py::object NumericConstant = + py::module_::import("pyomo.core.expr.numvalue").attr("NumericConstant"); + py::object expr_module = py::module_::import("pyomo.core.base.expression"); + py::object _GeneralExpressionData = + expr_module.attr("_GeneralExpressionData"); + py::object ScalarExpression = expr_module.attr("ScalarExpression"); + py::object ScalarIntegral = + py::module_::import("pyomo.dae.integral").attr("ScalarIntegral"); + py::object Integral = + py::module_::import("pyomo.dae.integral").attr("Integral"); + py::object _PyomoUnit = + py::module_::import("pyomo.core.base.units_container").attr("_PyomoUnit"); + py::object exp = numeric_expr.attr("exp"); + py::object log = numeric_expr.attr("log"); + py::object sin = numeric_expr.attr("sin"); + py::object cos = numeric_expr.attr("cos"); + py::object tan = numeric_expr.attr("tan"); + py::object asin = numeric_expr.attr("asin"); + py::object acos = numeric_expr.attr("acos"); + py::object atan = numeric_expr.attr("atan"); + py::object sqrt = numeric_expr.attr("sqrt"); + py::object builtins = py::module_::import("builtins"); + py::object id = builtins.attr("id"); + py::object len = builtins.attr("len"); + py::dict expr_type_map; +}; + +ex pyomo_to_ginac(py::handle expr, PyomoExprTypes &expr_types); + + +class GinacInterface { + public: + std::unordered_map leaf_map; + std::unordered_map ginac_pyomo_map; + PyomoExprTypes expr_types; + bool symbolic_solver_labels = false; + + GinacInterface() = default; + GinacInterface(bool _symbolic_solver_labels) : symbolic_solver_labels(_symbolic_solver_labels) {} + ~GinacInterface() = default; + + ex to_ginac(py::handle expr); + py::object from_ginac(ex &ginac_expr); +}; diff --git a/pyomo/contrib/simplification/plugins.py b/pyomo/contrib/simplification/plugins.py new file mode 100644 index 00000000000..436af6e0e53 --- /dev/null +++ b/pyomo/contrib/simplification/plugins.py @@ -0,0 +1,17 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.extensions import ExtensionBuilderFactory +from .build import GiNaCInterfaceBuilder + + +def load(): + ExtensionBuilderFactory.register('ginac')(GiNaCInterfaceBuilder) diff --git a/pyomo/contrib/simplification/simplify.py b/pyomo/contrib/simplification/simplify.py new file mode 100644 index 00000000000..c59e25138dd --- /dev/null +++ b/pyomo/contrib/simplification/simplify.py @@ -0,0 +1,75 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging +import warnings + +from pyomo.common.enums import NamedIntEnum +from pyomo.core.expr.sympy_tools import sympy2pyomo_expression, sympyify_expression +from pyomo.core.expr.numeric_expr import NumericExpression +from pyomo.core.expr.numvalue import value, is_constant + +from pyomo.contrib.simplification.ginac import ( + interface as ginac_interface, + interface_available as ginac_available, +) + + +def simplify_with_sympy(expr: NumericExpression): + if is_constant(expr): + return value(expr) + object_map, sympy_expr = sympyify_expression(expr, keep_mutable_parameters=True) + new_expr = sympy2pyomo_expression(sympy_expr.simplify(), object_map) + if is_constant(new_expr): + new_expr = value(new_expr) + return new_expr + + +def simplify_with_ginac(expr: NumericExpression, ginac_interface): + if is_constant(expr): + return value(expr) + ginac_expr = ginac_interface.to_ginac(expr) + return ginac_interface.from_ginac(ginac_expr.normal()) + + +class Simplifier(object): + class Mode(NamedIntEnum): + auto = 0 + sympy = 1 + ginac = 2 + + def __init__( + self, suppress_no_ginac_warnings: bool = False, mode: Mode = Mode.auto + ) -> None: + if mode == Simplifier.Mode.auto: + if ginac_available: + mode = Simplifier.Mode.ginac + else: + if not suppress_no_ginac_warnings: + msg = ( + "GiNaC does not seem to be available. Using SymPy. " + + "Note that the GiNaC interface is significantly faster." + ) + logging.getLogger(__name__).warning(msg) + warnings.warn(msg) + mode = Simplifier.Mode.sympy + + if mode == Simplifier.Mode.ginac: + self.gi = ginac_interface.GinacInterface(False) + self.simplify = self._simplify_with_ginac + else: + self.simplify = self._simplify_with_sympy + + def _simplify_with_ginac(self, expr: NumericExpression): + return simplify_with_ginac(expr, self.gi) + + def _simplify_with_sympy(self, expr: NumericExpression): + return simplify_with_sympy(expr) diff --git a/pyomo/contrib/simplification/tests/__init__.py b/pyomo/contrib/simplification/tests/__init__.py new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/simplification/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/simplification/tests/test_simplification.py b/pyomo/contrib/simplification/tests/test_simplification.py new file mode 100644 index 00000000000..7963e5b18aa --- /dev/null +++ b/pyomo/contrib/simplification/tests/test_simplification.py @@ -0,0 +1,123 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common import unittest +from pyomo.common.fileutils import this_file_dir +from pyomo.contrib.simplification import Simplifier +from pyomo.contrib.simplification.simplify import ginac_available +from pyomo.core.expr.compare import assertExpressionsEqual, compare_expressions +from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd +from pyomo.core.expr.sympy_tools import sympy_available + +import pyomo.environ as pyo + + +class SimplificationMixin: + def compare_against_possible_results(self, got, expected_list): + success = False + for exp in expected_list: + if compare_expressions(got, exp): + success = True + break + self.assertTrue(success) + + def test_simplify(self): + m = pyo.ConcreteModel() + x = m.x = pyo.Var(bounds=(0, None)) + e = x * pyo.log(x) + der1 = reverse_sd(e)[x] + der2 = reverse_sd(der1)[x] + der2_simp = self.simp.simplify(der2) + expected = x**-1.0 + assertExpressionsEqual(self, expected, der2_simp) + + def test_mul(self): + m = pyo.ConcreteModel() + x = m.x = pyo.Var() + e = 2 * x + e2 = self.simp.simplify(e) + expected = 2.0 * x + assertExpressionsEqual(self, expected, e2) + + def test_sum(self): + m = pyo.ConcreteModel() + x = m.x = pyo.Var() + e = 2 + x + e2 = self.simp.simplify(e) + self.compare_against_possible_results(e2, [2.0 + x, x + 2.0]) + + def test_neg(self): + m = pyo.ConcreteModel() + x = m.x = pyo.Var() + e = -pyo.log(x) + e2 = self.simp.simplify(e) + self.compare_against_possible_results( + e2, [(-1.0) * pyo.log(x), pyo.log(x) * (-1.0), -pyo.log(x)] + ) + + def test_pow(self): + m = pyo.ConcreteModel() + x = m.x = pyo.Var() + e = x**2.0 + e2 = self.simp.simplify(e) + assertExpressionsEqual(self, e, e2) + + def test_div(self): + m = pyo.ConcreteModel() + x = m.x = pyo.Var() + y = m.y = pyo.Var() + e = x / y + y / x - x / y + e2 = self.simp.simplify(e) + self.compare_against_possible_results( + e2, [y / x, y * (1.0 / x), y * x**-1.0, x**-1.0 * y] + ) + + def test_unary(self): + m = pyo.ConcreteModel() + x = m.x = pyo.Var() + func_list = [pyo.log, pyo.sin, pyo.cos, pyo.tan, pyo.asin, pyo.acos, pyo.atan] + for func in func_list: + e = func(x) + e2 = self.simp.simplify(e) + assertExpressionsEqual(self, e, e2) + + def test_param(self): + m = pyo.ConcreteModel() + x = m.x = pyo.Var() + p = m.p = pyo.Param(mutable=True) + e1 = p * x**2 + p * x + p * x**2 + e2 = self.simp.simplify(e1) + self.compare_against_possible_results( + e2, + [ + p * x**2.0 * 2.0 + p * x, + p * x + p * x**2.0 * 2.0, + 2.0 * p * x**2.0 + p * x, + p * x + 2.0 * p * x**2.0, + x**2.0 * p * 2.0 + p * x, + p * x + x**2.0 * p * 2.0, + p * x * (1 + 2 * x), + ], + ) + + +@unittest.skipUnless(sympy_available, 'sympy is not available') +class TestSimplificationSympy(unittest.TestCase, SimplificationMixin): + def setUp(self): + self.simp = Simplifier(mode=Simplifier.Mode.sympy) + + +@unittest.pytest.mark.default +@unittest.pytest.mark.builders +@unittest.skipUnless(ginac_available, 'GiNaC is not available') +class TestSimplificationGiNaC(unittest.TestCase, SimplificationMixin): + def setUp(self): + self.simp = Simplifier(mode=Simplifier.Mode.ginac) diff --git a/pyomo/contrib/solver/__init__.py b/pyomo/contrib/solver/__init__.py index a4a626013c4..9dc5c4b7b03 100644 --- a/pyomo/contrib/solver/__init__.py +++ b/pyomo/contrib/solver/__init__.py @@ -1,10 +1,40 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ + +from pyomo.common.deprecation import moved_module + +for _module in ('base', 'config', 'factory', 'results', 'util', 'persistent'): + moved_module( + f'pyomo.contrib.solver.{_module}', + f'pyomo.contrib.solver.common.{_module}', + version='6.9.2', + ) + +moved_module( + 'pyomo.contrib.solver.solution', + 'pyomo.contrib.solver.common.solution_loader', + version='6.9.2', +) + +for _module in ('ipopt', 'gurobi_direct', 'sol_reader'): + moved_module( + f'pyomo.contrib.solver.{_module}', + f'pyomo.contrib.solver.solvers.{_module}', + version='6.9.2', + ) + +moved_module( + 'pyomo.contrib.solver.gurobi', + 'pyomo.contrib.solver.solvers.gurobi_persistent', + version='6.9.2', +) + +del _module, moved_module diff --git a/pyomo/contrib/solver/base.py b/pyomo/contrib/solver/base.py deleted file mode 100644 index f3d60bef03d..00000000000 --- a/pyomo/contrib/solver/base.py +++ /dev/null @@ -1,552 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -import abc -import enum -from typing import Sequence, Dict, Optional, Mapping, NoReturn, List, Tuple -import os - -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.param import _ParamData -from pyomo.core.base.block import _BlockData -from pyomo.core.base.objective import _GeneralObjectiveData -from pyomo.common.config import document_kwargs_from_configdict -from pyomo.common.errors import ApplicationError -from pyomo.common.deprecation import deprecation_warning -from pyomo.opt.results.results_ import SolverResults as LegacySolverResults -from pyomo.opt.results.solution import Solution as LegacySolution -from pyomo.core.kernel.objective import minimize -from pyomo.core.base import SymbolMap -from pyomo.core.base.label import NumericLabeler -from pyomo.core.staleflag import StaleFlagManager -from pyomo.contrib.solver.config import SolverConfig, PersistentSolverConfig -from pyomo.contrib.solver.util import get_objective -from pyomo.contrib.solver.results import ( - Results, - legacy_solver_status_map, - legacy_termination_condition_map, - legacy_solution_status_map, -) - - -class SolverBase(abc.ABC): - """ - This base class defines the methods required for all solvers: - - available: Determines whether the solver is able to be run, combining both whether it can be found on the system and if the license is valid. - - solve: The main method of every solver - - version: The version of the solver - - is_persistent: Set to false for all non-persistent solvers. - - Additionally, solvers should have a :attr:`config` attribute that - inherits from one of :class:`SolverConfig`, - :class:`BranchAndBoundConfig`, - :class:`PersistentSolverConfig`, or - :class:`PersistentBranchAndBoundConfig`. - """ - - CONFIG = SolverConfig() - - def __init__(self, **kwds) -> None: - # We allow the user and/or developer to name the solver something else, - # if they really desire. Otherwise it defaults to the class name (all lowercase) - if "name" in kwds: - self.name = kwds["name"] - kwds.pop('name') - else: - self.name = type(self).__name__.lower() - self.config = self.CONFIG(value=kwds) - - # - # Support "with" statements. Forgetting to call deactivate - # on Plugins is a common source of memory leaks - # - def __enter__(self): - return self - - def __exit__(self, t, v, traceback): - """Exit statement - enables `with` statements.""" - - class Availability(enum.IntEnum): - """ - Class to capture different statuses in which a solver can exist in - order to record its availability for use. - """ - - FullLicense = 2 - LimitedLicense = 1 - NotFound = 0 - BadVersion = -1 - BadLicense = -2 - NeedsCompiledExtension = -3 - - def __bool__(self): - return self._value_ > 0 - - def __format__(self, format_spec): - # We want general formatting of this Enum to return the - # formatted string value and not the int (which is the - # default implementation from IntEnum) - return format(self.name, format_spec) - - def __str__(self): - # Note: Python 3.11 changed the core enums so that the - # "mixin" type for standard enums overrides the behavior - # specified in __format__. We will override str() here to - # preserve the previous behavior - return self.name - - @document_kwargs_from_configdict(CONFIG) - @abc.abstractmethod - def solve(self, model: _BlockData, **kwargs) -> Results: - """ - Solve a Pyomo model. - - Parameters - ---------- - model: _BlockData - The Pyomo model to be solved - **kwargs - Additional keyword arguments (including solver_options - passthrough - options; delivered directly to the solver (with no validation)) - - Returns - ------- - results: :class:`Results` - A results object - """ - - @abc.abstractmethod - def available(self) -> bool: - """Test if the solver is available on this system. - - Nominally, this will return True if the solver interface is - valid and can be used to solve problems and False if it cannot. - - Note that for licensed solvers there are a number of "levels" of - available: depending on the license, the solver may be available - with limitations on problem size or runtime (e.g., 'demo' - vs. 'community' vs. 'full'). In these cases, the solver may - return a subclass of enum.IntEnum, with members that resolve to - True if the solver is available (possibly with limitations). - The Enum may also have multiple members that all resolve to - False indicating the reason why the interface is not available - (not found, bad license, unsupported version, etc). - - Returns - ------- - available: SolverBase.Availability - An enum that indicates "how available" the solver is. - Note that the enum can be cast to bool, which will - be True if the solver is runable at all and False - otherwise. - """ - - @abc.abstractmethod - def version(self) -> Tuple: - """ - Returns - ------- - version: tuple - A tuple representing the version - """ - - def is_persistent(self) -> bool: - """ - Returns - ------- - is_persistent: bool - True if the solver is a persistent solver. - """ - return False - - -class PersistentSolverBase(SolverBase): - """ - Base class upon which persistent solvers can be built. This inherits the - methods from the solver base class and adds those methods that are necessary - for persistent solvers. - - Example usage can be seen in the Gurobi interface. - """ - - @document_kwargs_from_configdict(PersistentSolverConfig()) - @abc.abstractmethod - def solve(self, model: _BlockData, **kwargs) -> Results: - super().solve(model, kwargs) - - def is_persistent(self): - """ - Returns - ------- - is_persistent: bool - True if the solver is a persistent solver. - """ - return True - - def _load_vars( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> NoReturn: - """ - Load the solution of the primal variables into the value attribute of the variables. - - Parameters - ---------- - vars_to_load: list - A list of the variables whose solution should be loaded. If vars_to_load is None, then the solution - to all primal variables will be loaded. - """ - for v, val in self._get_primals(vars_to_load=vars_to_load).items(): - v.set_value(val, skip_validation=True) - StaleFlagManager.mark_all_as_stale(delayed=True) - - @abc.abstractmethod - def _get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: - """ - Get mapping of variables to primals. - - Parameters - ---------- - vars_to_load : Optional[Sequence[_GeneralVarData]], optional - Which vars to be populated into the map. The default is None. - - Returns - ------- - Mapping[_GeneralVarData, float] - A map of variables to primals. - """ - raise NotImplementedError( - f'{type(self)} does not support the get_primals method' - ) - - def _get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: - """ - Declare sign convention in docstring here. - - Parameters - ---------- - cons_to_load: list - A list of the constraints whose duals should be loaded. If cons_to_load is None, then the duals for all - constraints will be loaded. - - Returns - ------- - duals: dict - Maps constraints to dual values - """ - raise NotImplementedError(f'{type(self)} does not support the get_duals method') - - def _get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: - """ - Parameters - ---------- - vars_to_load: list - A list of the variables whose reduced cost should be loaded. If vars_to_load is None, then all reduced costs - will be loaded. - - Returns - ------- - reduced_costs: ComponentMap - Maps variable to reduced cost - """ - raise NotImplementedError( - f'{type(self)} does not support the get_reduced_costs method' - ) - - @abc.abstractmethod - def set_instance(self, model): - """ - Set an instance of the model - """ - - @abc.abstractmethod - def set_objective(self, obj: _GeneralObjectiveData): - """ - Set current objective for the model - """ - - @abc.abstractmethod - def add_variables(self, variables: List[_GeneralVarData]): - """ - Add variables to the model - """ - - @abc.abstractmethod - def add_parameters(self, params: List[_ParamData]): - """ - Add parameters to the model - """ - - @abc.abstractmethod - def add_constraints(self, cons: List[_GeneralConstraintData]): - """ - Add constraints to the model - """ - - @abc.abstractmethod - def add_block(self, block: _BlockData): - """ - Add a block to the model - """ - - @abc.abstractmethod - def remove_variables(self, variables: List[_GeneralVarData]): - """ - Remove variables from the model - """ - - @abc.abstractmethod - def remove_parameters(self, params: List[_ParamData]): - """ - Remove parameters from the model - """ - - @abc.abstractmethod - def remove_constraints(self, cons: List[_GeneralConstraintData]): - """ - Remove constraints from the model - """ - - @abc.abstractmethod - def remove_block(self, block: _BlockData): - """ - Remove a block from the model - """ - - @abc.abstractmethod - def update_variables(self, variables: List[_GeneralVarData]): - """ - Update variables on the model - """ - - @abc.abstractmethod - def update_parameters(self): - """ - Update parameters on the model - """ - - -class LegacySolverWrapper: - """ - Class to map the new solver interface features into the legacy solver - interface. Necessary for backwards compatibility. - """ - - # - # Support "with" statements - # - def __enter__(self): - return self - - def __exit__(self, t, v, traceback): - """Exit statement - enables `with` statements.""" - - def _map_config( - self, - tee, - load_solutions, - symbolic_solver_labels, - timelimit, - # Report timing is no longer a valid option. We now always return a - # timer object that can be inspected. - report_timing, - raise_exception_on_nonoptimal_result, - solver_io, - suffixes, - logfile, - keepfiles, - solnfile, - options, - ): - """Map between legacy and new interface configuration options""" - self.config = self.config() - self.config.tee = tee - self.config.load_solutions = load_solutions - self.config.symbolic_solver_labels = symbolic_solver_labels - self.config.time_limit = timelimit - self.config.solver_options.set_value(options) - # This is a new flag in the interface. To preserve backwards compatibility, - # its default is set to "False" - self.config.raise_exception_on_nonoptimal_result = ( - raise_exception_on_nonoptimal_result - ) - if solver_io is not None: - raise NotImplementedError('Still working on this') - if suffixes is not None: - raise NotImplementedError('Still working on this') - if logfile is not None: - raise NotImplementedError('Still working on this') - if keepfiles or 'keepfiles' in self.config: - cwd = os.getcwd() - deprecation_warning( - "`keepfiles` has been deprecated in the new solver interface. " - "Use `working_dir` instead to designate a directory in which " - f"files should be generated and saved. Setting `working_dir` to `{cwd}`.", - version='6.7.1.dev0', - ) - self.config.working_dir = cwd - # I believe this currently does nothing; however, it is unclear what - # our desired behavior is for this. - if solnfile is not None: - if 'filename' in self.config: - filename = os.path.splitext(solnfile)[0] - self.config.filename = filename - - def _map_results(self, model, results): - """Map between legacy and new Results objects""" - legacy_results = LegacySolverResults() - legacy_soln = LegacySolution() - legacy_results.solver.status = legacy_solver_status_map[ - results.termination_condition - ] - legacy_results.solver.termination_condition = legacy_termination_condition_map[ - results.termination_condition - ] - legacy_soln.status = legacy_solution_status_map[results.solution_status] - legacy_results.solver.termination_message = str(results.termination_condition) - obj = get_objective(model) - if len(list(obj)) > 0: - legacy_results.problem.sense = obj.sense - - if obj.sense == minimize: - legacy_results.problem.lower_bound = results.objective_bound - legacy_results.problem.upper_bound = results.incumbent_objective - else: - legacy_results.problem.upper_bound = results.objective_bound - legacy_results.problem.lower_bound = results.incumbent_objective - if ( - results.incumbent_objective is not None - and results.objective_bound is not None - ): - legacy_soln.gap = abs(results.incumbent_objective - results.objective_bound) - else: - legacy_soln.gap = None - return legacy_results, legacy_soln - - def _solution_handler( - self, load_solutions, model, results, legacy_results, legacy_soln - ): - """Method to handle the preferred action for the solution""" - symbol_map = SymbolMap() - symbol_map.default_labeler = NumericLabeler('x') - model.solutions.add_symbol_map(symbol_map) - legacy_results._smap_id = id(symbol_map) - delete_legacy_soln = True - if load_solutions: - if hasattr(model, 'dual') and model.dual.import_enabled(): - for c, val in results.solution_loader.get_duals().items(): - model.dual[c] = val - if hasattr(model, 'rc') and model.rc.import_enabled(): - for v, val in results.solution_loader.get_reduced_costs().items(): - model.rc[v] = val - elif results.incumbent_objective is not None: - delete_legacy_soln = False - for v, val in results.solution_loader.get_primals().items(): - legacy_soln.variable[symbol_map.getSymbol(v)] = {'Value': val} - if hasattr(model, 'dual') and model.dual.import_enabled(): - for c, val in results.solution_loader.get_duals().items(): - legacy_soln.constraint[symbol_map.getSymbol(c)] = {'Dual': val} - if hasattr(model, 'rc') and model.rc.import_enabled(): - for v, val in results.solution_loader.get_reduced_costs().items(): - legacy_soln.variable['Rc'] = val - - legacy_results.solution.insert(legacy_soln) - # Timing info was not originally on the legacy results, but we want - # to make it accessible to folks who are utilizing the backwards - # compatible version. - legacy_results.timing_info = results.timing_info - if delete_legacy_soln: - legacy_results.solution.delete(0) - return legacy_results - - def solve( - self, - model: _BlockData, - tee: bool = False, - load_solutions: bool = True, - logfile: Optional[str] = None, - solnfile: Optional[str] = None, - timelimit: Optional[float] = None, - report_timing: bool = False, - solver_io: Optional[str] = None, - suffixes: Optional[Sequence] = None, - options: Optional[Dict] = None, - keepfiles: bool = False, - symbolic_solver_labels: bool = False, - raise_exception_on_nonoptimal_result: bool = False, - ): - """ - Solve method: maps new solve method style to backwards compatible version. - - Returns - ------- - legacy_results - Legacy results object - - """ - original_config = self.config - self._map_config( - tee, - load_solutions, - symbolic_solver_labels, - timelimit, - report_timing, - raise_exception_on_nonoptimal_result, - solver_io, - suffixes, - logfile, - keepfiles, - solnfile, - options, - ) - - results: Results = super().solve(model) - legacy_results, legacy_soln = self._map_results(model, results) - - legacy_results = self._solution_handler( - load_solutions, model, results, legacy_results, legacy_soln - ) - - self.config = original_config - - return legacy_results - - def available(self, exception_flag=True): - """ - Returns a bool determining whether the requested solver is available - on the system. - """ - ans = super().available() - if exception_flag and not ans: - raise ApplicationError(f'Solver {self.__class__} is not available ({ans}).') - return bool(ans) - - def license_is_valid(self) -> bool: - """Test if the solver license is valid on this system. - - Note that this method is included for compatibility with the - legacy SolverFactory interface. Unlicensed or open source - solvers will return True by definition. Licensed solvers will - return True if a valid license is found. - - Returns - ------- - available: bool - True if the solver license is valid. Otherwise, False. - - """ - return bool(self.available()) diff --git a/pyomo/contrib/solver/common/__init__.py b/pyomo/contrib/solver/common/__init__.py new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/solver/common/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py new file mode 100644 index 00000000000..60b2b33af81 --- /dev/null +++ b/pyomo/contrib/solver/common/base.py @@ -0,0 +1,695 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from typing import Sequence, Dict, Optional, Mapping, List, Tuple +import os + +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.var import VarData +from pyomo.core.base.param import ParamData +from pyomo.core.base.block import BlockData +from pyomo.core.base.objective import Objective, ObjectiveData +from pyomo.common.config import document_kwargs_from_configdict, ConfigValue +from pyomo.common.enums import IntEnum +from pyomo.common.errors import ApplicationError +from pyomo.common.deprecation import deprecation_warning +from pyomo.common.modeling import NOTSET +from pyomo.opt.results.results_ import SolverResults as LegacySolverResults +from pyomo.opt.results.solution import Solution as LegacySolution +from pyomo.core.kernel.objective import minimize +from pyomo.core.base import SymbolMap +from pyomo.core.base.label import NumericLabeler +from pyomo.core.staleflag import StaleFlagManager +from pyomo.scripting.solve_config import default_config_block +from pyomo.contrib.solver.common.config import SolverConfig, PersistentSolverConfig +from pyomo.contrib.solver.common.util import get_objective +from pyomo.contrib.solver.common.results import ( + Results, + legacy_solver_status_map, + legacy_termination_condition_map, + legacy_solution_status_map, +) + +import logging + +logger = logging.getLogger(__name__) + + +class Availability(IntEnum): + """ + Class to capture different statuses in which a solver can exist in + order to record its availability for use. + """ + + FullLicense = 2 + LimitedLicense = 1 + NotFound = 0 + BadVersion = -1 + BadLicense = -2 + NeedsCompiledExtension = -3 + + def __bool__(self): + return self._value_ > 0 + + def __format__(self, format_spec): + return format(self.name, format_spec) + + def __str__(self): + return self.name + + +class SolverBase: + """ + This base class defines the methods required for all solvers: + - available: Determines whether the solver is able to be run, + combining both whether it can be found on the system and if the license is valid. + - solve: The main method of every solver + - version: The version of the solver + - is_persistent: Set to false for all non-persistent solvers. + + Additionally, solvers should have a :attr:`CONFIG` attribute that + inherits from one of :class:`SolverConfig`, + :class:`BranchAndBoundConfig`, + :class:`PersistentSolverConfig`, or + :class:`PersistentBranchAndBoundConfig`. + """ + + CONFIG = SolverConfig() + + def __init__(self, **kwds) -> None: + if "name" in kwds: + self.name = kwds.pop("name") + elif not hasattr(self, "name"): + self.name = type(self).__name__.lower() + self.config = self.CONFIG(value=kwds) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + """Exit statement - enables `with` statements.""" + + @document_kwargs_from_configdict(CONFIG) + def solve(self, model: BlockData, **kwargs) -> Results: + """ + Solve a Pyomo model. + + Parameters + ---------- + model: BlockData + The Pyomo model to be solved + **kwargs + Additional keyword arguments (including solver_options - passthrough + options; delivered directly to the solver (with no validation)) + + Returns + ------- + results: :class:`Results` + A results object + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'solve'." + ) + + def available(self) -> Availability: + """Test if the solver is available on this system. + + Nominally, this will return `True` if the solver interface is + valid and can be used to solve problems and `False` if it cannot. + Note that for licensed solvers there are a number of "levels" of + available: depending on the license, the solver may be available + with limitations on problem size or runtime (e.g., 'demo' + vs. 'community' vs. 'full'). In these cases, the solver may + return a subclass of enum.IntEnum, with members that resolve to + True if the solver is available (possibly with limitations). + The Enum may also have multiple members that all resolve to + False indicating the reason why the interface is not available + (not found, bad license, unsupported version, etc). + + Returns + ------- + available: Availability + An enum that indicates "how available" the solver is. + Note that the enum can be cast to bool, which will + be True if the solver is runable at all and False + otherwise. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'available'." + ) + + def version(self) -> Tuple: + """ + Returns + ------- + version: tuple + A tuple representing the version + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'version'." + ) + + def is_persistent(self) -> bool: + """ + Returns + ------- + is_persistent: bool + True if the solver is a persistent solver. + """ + return False + + +class PersistentSolverBase(SolverBase): + """ + Base class upon which persistent solvers can be built. This inherits the + methods from the solver base class and adds those methods that are necessary + for persistent solvers. + + Example usage can be seen in the Gurobi interface. + """ + + CONFIG = PersistentSolverConfig() + + def __init__(self, **kwds) -> None: + super().__init__(**kwds) + self._active_config = self.config + + @document_kwargs_from_configdict(CONFIG) + def solve(self, model: BlockData, **kwargs) -> Results: + """ + Solve a Pyomo model. + + Parameters + ---------- + model: BlockData + The Pyomo model to be solved + **kwargs + Additional keyword arguments (including solver_options - passthrough + options; delivered directly to the solver (with no validation)) + + Returns + ------- + results: :class:`Results` + A results object + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'solve'." + ) + + def is_persistent(self) -> bool: + """ + Returns + ------- + is_persistent: bool + True if the solver is a persistent solver. + """ + return True + + def _load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None: + """ + Load the solution of the primal variables into the value attribute of the variables. + + Parameters + ---------- + vars_to_load: list + A list of the variables whose solution should be loaded. If vars_to_load + is None, then the solution to all primal variables will be loaded. + """ + for var, val in self._get_primals(vars_to_load=vars_to_load).items(): + var.set_value(val, skip_validation=True) + StaleFlagManager.mark_all_as_stale(delayed=True) + + def _get_primals( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + """ + Get mapping of variables to primals. + + Parameters + ---------- + vars_to_load : Optional[Sequence[VarData]], optional + Which vars to be populated into the map. The default is None. + + Returns + ------- + Mapping[VarData, float] + A map of variables to primals. + """ + raise NotImplementedError( + f"{type(self)} does not support the get_primals method" + ) + + def _get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: + """ + Declare sign convention in docstring here. + + Parameters + ---------- + cons_to_load: list + A list of the constraints whose duals should be loaded. If cons_to_load + is None, then the duals for all constraints will be loaded. + + Returns + ------- + duals: dict + Maps constraints to dual values + """ + raise NotImplementedError(f"{type(self)} does not support the get_duals method") + + def _get_reduced_costs( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + """ + Parameters + ---------- + vars_to_load: list + A list of the variables whose reduced cost should be loaded. If vars_to_load + is None, then all reduced costs will be loaded. + + Returns + ------- + reduced_costs: ComponentMap + Maps variable to reduced cost + """ + raise NotImplementedError( + f"{type(self)} does not support the get_reduced_costs method" + ) + + def set_instance(self, model: BlockData): + """ + Set an instance of the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'set_instance'." + ) + + def set_objective(self, obj: ObjectiveData): + """ + Set current objective for the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'set_objective'." + ) + + def add_variables(self, variables: List[VarData]): + """ + Add variables to the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'add_variables'." + ) + + def add_parameters(self, params: List[ParamData]): + """ + Add parameters to the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'add_parameters'." + ) + + def add_constraints(self, cons: List[ConstraintData]): + """ + Add constraints to the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'add_constraints'." + ) + + def add_block(self, block: BlockData): + """ + Add a block to the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'add_block'." + ) + + def remove_variables(self, variables: List[VarData]): + """ + Remove variables from the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'remove_variables'." + ) + + def remove_parameters(self, params: List[ParamData]): + """ + Remove parameters from the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'remove_parameters'." + ) + + def remove_constraints(self, cons: List[ConstraintData]): + """ + Remove constraints from the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'remove_constraints'." + ) + + def remove_block(self, block: BlockData): + """ + Remove a block from the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'remove_block'." + ) + + def update_variables(self, variables: List[VarData]): + """ + Update variables on the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'update_variables'." + ) + + def update_parameters(self): + """ + Update parameters on the model. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'update_parameters'." + ) + + +class LegacySolverWrapper: + """ + Class to map the new solver interface features into the legacy solver + interface. Necessary for backwards compatibility. + """ + + def __init__(self, **kwargs): + if "solver_io" in kwargs: + raise NotImplementedError("Still working on this") + # There is no reason for a user to be trying to mix both old + # and new options. That is silly. So we will yell at them. + _options = kwargs.pop("options", None) + if "solver_options" in kwargs: + if _options is not None: + raise ValueError( + "Both 'options' and 'solver_options' were requested. " + "Please use one or the other, not both." + ) + _options = kwargs.pop("solver_options") + if _options is not None: + kwargs["solver_options"] = _options + super().__init__(**kwargs) + # Make the legacy 'options' attribute an alias of the new + # config.solver_options + self.options = self.config.solver_options + + # + # Support "with" statements + # + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + """Exit statement - enables `with` statements.""" + + def __setattr__(self, attr, value): + # 'options' and 'config' are really singleton attributes. Map + # any assignment to set_value() + if attr in ("options", "config") and attr in self.__dict__: + getattr(self, attr).set_value(value) + else: + super().__setattr__(attr, value) + + def _map_config( + self, + tee=NOTSET, + load_solutions=NOTSET, + symbolic_solver_labels=NOTSET, + timelimit=NOTSET, + report_timing=NOTSET, + raise_exception_on_nonoptimal_result=NOTSET, + solver_io=NOTSET, + suffixes=NOTSET, + logfile=NOTSET, + keepfiles=NOTSET, + solnfile=NOTSET, + options=NOTSET, + solver_options=NOTSET, + writer_config=NOTSET, + ): + """Map between legacy and new interface configuration options""" + if "report_timing" not in self.config: + self.config.declare( + "report_timing", ConfigValue(domain=bool, default=False) + ) + if tee is not NOTSET: + self.config.tee = tee + if load_solutions is not NOTSET: + self.config.load_solutions = load_solutions + if symbolic_solver_labels is not NOTSET: + self.config.symbolic_solver_labels = symbolic_solver_labels + if timelimit is not NOTSET: + self.config.time_limit = timelimit + if report_timing is not NOTSET: + self.config.report_timing = report_timing + if (options is not NOTSET) and (solver_options is not NOTSET): + # There is no reason for a user to be trying to mix both old + # and new options. That is silly. So we will yell at them. + # Example that would raise an error: + # solver.solve(model, options={'foo' : 'bar'}, solver_options={'foo' : 'not_bar'}) + raise ValueError( + "Both 'options' and 'solver_options' were requested. " + "Please use one or the other, not both." + ) + if options is not NOTSET: + # This block is trying to mimic the existing logic in the legacy + # interface that allows users to pass initialized options to + # the solver object and override them in the solve call. + self.config.solver_options.set_value(options) + elif solver_options is not NOTSET: + self.config.solver_options.set_value(solver_options) + if writer_config is not NOTSET: + self.config.writer_config.set_value(writer_config) + # This is a new flag in the interface. To preserve backwards compatibility, + # its default is set to "False" + if raise_exception_on_nonoptimal_result is not NOTSET: + self.config.raise_exception_on_nonoptimal_result = ( + raise_exception_on_nonoptimal_result + ) + if solver_io is not NOTSET and solver_io is not None: + raise NotImplementedError("Still working on this") + if suffixes is not NOTSET and suffixes is not None: + raise NotImplementedError("Still working on this") + if logfile is not NOTSET and logfile is not None: + raise NotImplementedError("Still working on this") + if keepfiles or "keepfiles" in self.config: + cwd = os.getcwd() + deprecation_warning( + "`keepfiles` has been deprecated in the new solver interface. " + "Use `working_dir` instead to designate a directory in which files " + f"should be generated and saved. Setting `working_dir` to `{cwd}`.", + version="6.7.1", + ) + self.config.working_dir = cwd + # I believe this currently does nothing; however, it is unclear what + # our desired behavior is for this. + if solnfile is not NOTSET: + if "filename" in self.config: + filename = os.path.splitext(solnfile)[0] + self.config.filename = filename + + def _map_results(self, model, results): + """Map between legacy and new Results objects""" + legacy_results = LegacySolverResults() + legacy_soln = LegacySolution() + legacy_results.solver.status = legacy_solver_status_map[ + results.termination_condition + ] + legacy_results.solver.termination_condition = legacy_termination_condition_map[ + results.termination_condition + ] + legacy_soln.status = legacy_solution_status_map(results) + legacy_results.solver.termination_message = str(results.termination_condition) + legacy_results.problem.number_of_constraints = float("nan") + legacy_results.problem.number_of_variables = float("nan") + number_of_objectives = sum( + 1 + for _ in model.component_data_objects( + Objective, active=True, descend_into=True + ) + ) + legacy_results.problem.number_of_objectives = number_of_objectives + if number_of_objectives == 1: + obj = get_objective(model) + legacy_results.problem.sense = obj.sense + + if obj.sense == minimize: + legacy_results.problem.lower_bound = results.objective_bound + legacy_results.problem.upper_bound = results.incumbent_objective + else: + legacy_results.problem.upper_bound = results.objective_bound + legacy_results.problem.lower_bound = results.incumbent_objective + if ( + results.incumbent_objective is not None + and results.objective_bound is not None + ): + legacy_soln.gap = abs(results.incumbent_objective - results.objective_bound) + else: + legacy_soln.gap = None + return legacy_results, legacy_soln + + def _solution_handler( + self, load_solutions, model, results, legacy_results, legacy_soln + ): + """Method to handle the preferred action for the solution""" + symbol_map = SymbolMap() + symbol_map.default_labeler = NumericLabeler("x") + if not hasattr(model, "solutions"): + # This logic gets around Issue #2130 in which + # solutions is not an attribute on Blocks + from pyomo.core.base.PyomoModel import ModelSolutions + + setattr(model, "solutions", ModelSolutions(model)) + model.solutions.add_symbol_map(symbol_map) + legacy_results._smap_id = id(symbol_map) + delete_legacy_soln = True + if load_solutions: + if hasattr(model, "dual") and model.dual.import_enabled(): + for con, val in results.solution_loader.get_duals().items(): + model.dual[con] = val + if hasattr(model, "rc") and model.rc.import_enabled(): + for var, val in results.solution_loader.get_reduced_costs().items(): + model.rc[var] = val + elif results.incumbent_objective is not None: + delete_legacy_soln = False + for var, val in results.solution_loader.get_primals().items(): + legacy_soln.variable[symbol_map.getSymbol(var)] = {"Value": val} + if hasattr(model, "dual") and model.dual.import_enabled(): + for con, val in results.solution_loader.get_duals().items(): + legacy_soln.constraint[symbol_map.getSymbol(con)] = {"Dual": val} + if hasattr(model, "rc") and model.rc.import_enabled(): + for var, val in results.solution_loader.get_reduced_costs().items(): + legacy_soln.variable["Rc"] = val + + legacy_results.solution.insert(legacy_soln) + # Timing info was not originally on the legacy results, but we want + # to make it accessible to folks who are utilizing the backwards + # compatible version. + legacy_results.timing_info = results.timing_info + if delete_legacy_soln: + legacy_results.solution.delete(0) + return legacy_results + + def solve( + self, + model: BlockData, + tee: bool = False, + load_solutions: bool = True, + logfile: Optional[str] = None, + solnfile: Optional[str] = None, + timelimit: Optional[float] = None, + report_timing: bool = False, + solver_io: Optional[str] = None, + suffixes: Optional[Sequence] = None, + options: Optional[Dict] = None, + keepfiles: bool = False, + symbolic_solver_labels: bool = False, + # These are for forward-compatibility + raise_exception_on_nonoptimal_result: bool = False, + solver_options: Optional[Dict] = None, + writer_config: Optional[Dict] = None, + ): + """ + Solve method: maps new solve method style to backwards compatible version. + + Returns + ------- + legacy_results + Legacy results object + + """ + original_config = self.config + + map_args = ( + "tee", + "load_solutions", + "symbolic_solver_labels", + "timelimit", + "report_timing", + "raise_exception_on_nonoptimal_result", + "solver_io", + "suffixes", + "logfile", + "keepfiles", + "solnfile", + "options", + "solver_options", + "writer_config", + ) + loc = locals() + filtered_args = {k: loc[k] for k in map_args if loc.get(k, None) is not None} + self._map_config(**filtered_args) + + logger.debug(f"{self.__class__.__name__} calling: super().solve(model)") + results: Results = super().solve(model) + logger.debug(f"{self.__class__.__name__} complete: super().solve(model)") + legacy_results, legacy_soln = self._map_results(model, results) + legacy_results = self._solution_handler( + load_solutions, model, results, legacy_results, legacy_soln + ) + + if self.config.report_timing: + print(results.timing_info.timer) + + self.config = original_config + + return legacy_results + + def available(self, exception_flag=True): + """ + Returns a bool determining whether the requested solver is available + on the system. + """ + ans = super().available() + if exception_flag and not ans: + raise ApplicationError( + f'Solver "{self.name}" is not available. ' + f"The returned status is: {ans}." + ) + return bool(ans) + + def license_is_valid(self) -> bool: + """Test if the solver license is valid on this system. + + Note that this method is included for compatibility with the + legacy SolverFactory interface. Unlicensed or open source + solvers will return True by definition. Licensed solvers will + return True if a valid license is found. + + Returns + ------- + available: bool + True if the solver license is valid. Otherwise, False. + + """ + return bool(self.available()) + + def config_block(self, init=False): + """ + Preserves config backwards compatibility; allows new solver interfaces + to be used in the pyomo solve call + """ + return default_config_block(self, init)[0] + + def set_options(self, options): + """ + Method to manually set options; can be called outside of the solve method + """ + opts = {k: v for k, v in options.value().items() if v is not None} + if opts: + self._map_config(**opts) diff --git a/pyomo/contrib/solver/config.py b/pyomo/contrib/solver/common/config.py similarity index 86% rename from pyomo/contrib/solver/config.py rename to pyomo/contrib/solver/common/config.py index e60219a74b5..24e0bdc97cc 100644 --- a/pyomo/contrib/solver/config.py +++ b/pyomo/contrib/solver/common/config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,7 +21,6 @@ ConfigValue, NonNegativeFloat, NonNegativeInt, - ADVANCED_OPTION, Bool, Path, ) @@ -31,27 +30,49 @@ def TextIO_or_Logger(val): - ans = [] - if not isinstance(val, Sequence): + """ + + Validates and converts input into a list of valid output streams. + + Accepts: + - sys.stdout + - Instances of io.TextIOBase + - logging.Logger (wrapped as LogStream) + - Boolean values (`True` -> sys.stdout) + + Returns: + - A list of validated output streams. + + Raises: + - ValueError if an invalid type is provided. + """ + if isinstance(val, Sequence) and not isinstance(val, (str, bytes)): + val = list(val) + else: val = [val] + + ans = [] for v in val: if v.__class__ in native_logical_types: if v: ans.append(sys.stdout) - elif isinstance(v, io.TextIOBase): + elif isinstance(v, (sys.stdout.__class__, io.TextIOBase)): + # We are guarding against file-like classes that do not derive from + # TextIOBase but are assigned to stdout / stderr. + # We still want to accept those classes. ans.append(v) elif isinstance(v, logging.Logger): ans.append(LogStream(level=logging.INFO, logger=v)) else: raise ValueError( - "Expected bool, TextIOBase, or Logger, but received {v.__class__}" + f"Expected sys.stdout, io.TextIOBase, Logger, or bool, but received {v.__class__}" ) return ans class SolverConfig(ConfigDict): """ - Base config for all direct solver interfaces + Common configuration options for all solver interfaces """ def __init__( @@ -193,20 +214,7 @@ def __init__( class AutoUpdateConfig(ConfigDict): """ - This is necessary for persistent solvers. - - Attributes - ---------- - check_for_new_or_removed_constraints: bool - check_for_new_or_removed_vars: bool - check_for_new_or_removed_params: bool - check_for_new_objective: bool - update_constraints: bool - update_vars: bool - update_parameters: bool - update_named_expressions: bool - update_objective: bool - treat_fixed_vars_as_params: bool + Control which parts of the model are automatically checked and/or updated upon re-solve """ def __init__( @@ -334,24 +342,6 @@ def __init__( certain objectives are not being modified.""", ), ) - self.treat_fixed_vars_as_params: bool = self.declare( - 'treat_fixed_vars_as_params', - ConfigValue( - domain=bool, - default=True, - visibility=ADVANCED_OPTION, - description=""" - This is an advanced option that should only be used in special circumstances. - With the default setting of True, fixed variables will be treated like parameters. - This means that z == x*y will be linear if x or y is fixed and the constraint - can be written to an LP file. If the value of the fixed variable gets changed, we have - to completely reprocess all constraints using that variable. If - treat_fixed_vars_as_params is False, then constraints will be processed as if fixed - variables are not fixed, and the solver will be told the variable is fixed. This means - z == x*y could not be written to an LP file even if x and/or y is fixed. However, - updating the values of fixed variables is much faster this way.""", - ), - ) class PersistentSolverConfig(SolverConfig): @@ -380,7 +370,7 @@ def __init__( ) -class PersistentBranchAndBoundConfig(BranchAndBoundConfig): +class PersistentBranchAndBoundConfig(PersistentSolverConfig, BranchAndBoundConfig): """ Base config for all persistent MIP solver interfaces """ @@ -400,7 +390,3 @@ def __init__( implicit_domain=implicit_domain, visibility=visibility, ) - - self.auto_updates: AutoUpdateConfig = self.declare( - 'auto_updates', AutoUpdateConfig() - ) diff --git a/pyomo/contrib/solver/factory.py b/pyomo/contrib/solver/common/factory.py similarity index 59% rename from pyomo/contrib/solver/factory.py rename to pyomo/contrib/solver/common/factory.py index 91ce92a9dee..876f36429af 100644 --- a/pyomo/contrib/solver/factory.py +++ b/pyomo/contrib/solver/common/factory.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,23 +12,41 @@ from pyomo.opt.base.solvers import LegacySolverFactory from pyomo.common.factory import Factory -from pyomo.contrib.solver.base import LegacySolverWrapper +from pyomo.contrib.solver.common.base import LegacySolverWrapper + +import logging + +logger = logging.getLogger(__name__) class SolverFactoryClass(Factory): + """ + Registers new interfaces in the legacy SolverFactory + """ + def register(self, name, legacy_name=None, doc=None): + logger.debug( + f"(factory.SolverFactoryClass (Legacy)) register '{name}', legacy_name='{legacy_name}'" + ) if legacy_name is None: legacy_name = name def decorator(cls): + logger.debug( + f"(factory.SolverFactoryClass (Legacy)) decorator class: {name} -> {cls}" + ) self._cls[name] = cls self._doc[name] = doc class LegacySolver(LegacySolverWrapper, cls): pass - LegacySolverFactory.register(legacy_name, doc)(LegacySolver) + LegacySolverFactory.register(legacy_name, doc + " (new interface)")( + LegacySolver + ) + # Preserve the preferred name, as registered in the Factory + cls.name = name return cls return decorator diff --git a/pyomo/contrib/solver/persistent.py b/pyomo/contrib/solver/common/persistent.py similarity index 74% rename from pyomo/contrib/solver/persistent.py rename to pyomo/contrib/solver/common/persistent.py index 4b1a7c58dcd..6fad7c93f11 100644 --- a/pyomo/contrib/solver/persistent.py +++ b/pyomo/contrib/solver/common/persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,21 +10,37 @@ # __________________________________________________________________________ import abc +import datetime from typing import List -from pyomo.core.base.constraint import _GeneralConstraintData, Constraint -from pyomo.core.base.sos import _SOSConstraintData, SOSConstraint -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.param import _ParamData, Param -from pyomo.core.base.objective import _GeneralObjectiveData +from pyomo.core.base.constraint import ConstraintData, Constraint +from pyomo.core.base.sos import SOSConstraintData, SOSConstraint +from pyomo.core.base.var import VarData +from pyomo.core.base.param import ParamData, Param +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.staleflag import StaleFlagManager from pyomo.common.collections import ComponentMap from pyomo.common.timing import HierarchicalTimer -from pyomo.core.expr.numvalue import NumericConstant -from pyomo.contrib.solver.util import collect_vars_and_named_exprs, get_objective +from pyomo.contrib.solver.common.results import Results +from pyomo.contrib.solver.common.util import collect_vars_and_named_exprs, get_objective class PersistentSolverUtils(abc.ABC): - def __init__(self): + def __init__(self, treat_fixed_vars_as_params=True): + """ + Parameters + ---------- + treat_fixed_vars_as_params: bool + This is an advanced option that should only be used in special circumstances. + With the default setting of True, fixed variables will be treated like parameters. + This means that z == x*y will be linear if x or y is fixed and the constraint + can be written to an LP file. If the value of the fixed variable gets changed, we have + to completely reprocess all constraints using that variable. If + treat_fixed_vars_as_params is False, then constraints will be processed as if fixed + variables are not fixed, and the solver will be told the variable is fixed. This means + z == x*y could not be written to an LP file even if x and/or y is fixed. However, + updating the values of fixed variables is much faster this way. + """ self._model = None self._active_constraints = {} # maps constraint to (lower, body, upper) self._vars = {} # maps var id to (var, lb, ub, fixed, domain, value) @@ -43,26 +59,28 @@ def __init__(self): self._vars_referenced_by_con = {} self._vars_referenced_by_obj = [] self._expr_types = None + self._treat_fixed_vars_as_params = treat_fixed_vars_as_params + self._active_config = self.config def set_instance(self, model): saved_config = self.config + saved_active_config = self._active_config self.__init__() self.config = saved_config + self._active_config = saved_active_config self._model = model self.add_block(model) if self._objective is None: self.set_objective(None) @abc.abstractmethod - def _add_variables(self, variables: List[_GeneralVarData]): + def _add_variables(self, variables: List[VarData]): pass - def add_variables(self, variables: List[_GeneralVarData]): + def add_variables(self, variables: List[VarData]): for v in variables: if id(v) in self._referenced_variables: - raise ValueError( - 'variable {name} has already been added'.format(name=v.name) - ) + raise ValueError(f'Variable {v.name} has already been added') self._referenced_variables[id(v)] = [{}, {}, None] self._vars[id(v)] = ( v, @@ -75,19 +93,19 @@ def add_variables(self, variables: List[_GeneralVarData]): self._add_variables(variables) @abc.abstractmethod - def _add_parameters(self, params: List[_ParamData]): + def _add_parameters(self, params: List[ParamData]): pass - def add_parameters(self, params: List[_ParamData]): + def add_parameters(self, params: List[ParamData]): for p in params: self._params[id(p)] = p self._add_parameters(params) @abc.abstractmethod - def _add_constraints(self, cons: List[_GeneralConstraintData]): + def _add_constraints(self, cons: List[ConstraintData]): pass - def _check_for_new_vars(self, variables: List[_GeneralVarData]): + def _check_for_new_vars(self, variables: List[VarData]): new_vars = {} for v in variables: v_id = id(v) @@ -95,7 +113,7 @@ def _check_for_new_vars(self, variables: List[_GeneralVarData]): new_vars[v_id] = v self.add_variables(list(new_vars.values())) - def _check_to_remove_vars(self, variables: List[_GeneralVarData]): + def _check_to_remove_vars(self, variables: List[VarData]): vars_to_remove = {} for v in variables: v_id = id(v) @@ -104,15 +122,13 @@ def _check_to_remove_vars(self, variables: List[_GeneralVarData]): vars_to_remove[v_id] = v self.remove_variables(list(vars_to_remove.values())) - def add_constraints(self, cons: List[_GeneralConstraintData]): + def add_constraints(self, cons: List[ConstraintData]): all_fixed_vars = {} for con in cons: if con in self._named_expressions: - raise ValueError( - 'constraint {name} has already been added'.format(name=con.name) - ) - self._active_constraints[con] = (con.lower, con.body, con.upper) - tmp = collect_vars_and_named_exprs(con.body) + raise ValueError(f'Constraint {con.name} has already been added') + self._active_constraints[con] = con.expr + tmp = collect_vars_and_named_exprs(con.expr) named_exprs, variables, fixed_vars, external_functions = tmp self._check_for_new_vars(variables) self._named_expressions[con] = [(e, e.expr) for e in named_exprs] @@ -121,7 +137,7 @@ def add_constraints(self, cons: List[_GeneralConstraintData]): self._vars_referenced_by_con[con] = variables for v in variables: self._referenced_variables[id(v)][0][con] = None - if not self.config.auto_updates.treat_fixed_vars_as_params: + if not self._treat_fixed_vars_as_params: for v in fixed_vars: v.unfix() all_fixed_vars[id(v)] = v @@ -130,15 +146,13 @@ def add_constraints(self, cons: List[_GeneralConstraintData]): v.fix() @abc.abstractmethod - def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + def _add_sos_constraints(self, cons: List[SOSConstraintData]): pass - def add_sos_constraints(self, cons: List[_SOSConstraintData]): + def add_sos_constraints(self, cons: List[SOSConstraintData]): for con in cons: if con in self._vars_referenced_by_con: - raise ValueError( - 'constraint {name} has already been added'.format(name=con.name) - ) + raise ValueError(f'Constraint {con.name} has already been added') self._active_constraints[con] = tuple() variables = con.get_variables() self._check_for_new_vars(variables) @@ -149,10 +163,10 @@ def add_sos_constraints(self, cons: List[_SOSConstraintData]): self._add_sos_constraints(cons) @abc.abstractmethod - def _set_objective(self, obj: _GeneralObjectiveData): + def _set_objective(self, obj: ObjectiveData): pass - def set_objective(self, obj: _GeneralObjectiveData): + def set_objective(self, obj: ObjectiveData): if self._objective is not None: for v in self._vars_referenced_by_obj: self._referenced_variables[id(v)][2] = None @@ -171,7 +185,7 @@ def set_objective(self, obj: _GeneralObjectiveData): self._vars_referenced_by_obj = variables for v in variables: self._referenced_variables[id(v)][2] = obj - if not self.config.auto_updates.treat_fixed_vars_as_params: + if not self._treat_fixed_vars_as_params: for v in fixed_vars: v.unfix() self._set_objective(obj) @@ -209,17 +223,15 @@ def add_block(self, block): self.set_objective(obj) @abc.abstractmethod - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): pass - def remove_constraints(self, cons: List[_GeneralConstraintData]): + def remove_constraints(self, cons: List[ConstraintData]): self._remove_constraints(cons) for con in cons: if con not in self._named_expressions: raise ValueError( - 'cannot remove constraint {name} - it was not added'.format( - name=con.name - ) + f'Cannot remove constraint {con.name} - it was not added' ) for v in self._vars_referenced_by_con[con]: self._referenced_variables[id(v)][0].pop(con) @@ -230,17 +242,15 @@ def remove_constraints(self, cons: List[_GeneralConstraintData]): del self._vars_referenced_by_con[con] @abc.abstractmethod - def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): pass - def remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def remove_sos_constraints(self, cons: List[SOSConstraintData]): self._remove_sos_constraints(cons) for con in cons: if con not in self._vars_referenced_by_con: raise ValueError( - 'cannot remove constraint {name} - it was not added'.format( - name=con.name - ) + f'Cannot remove constraint {con.name} - it was not added' ) for v in self._vars_referenced_by_con[con]: self._referenced_variables[id(v)][1].pop(con) @@ -250,34 +260,30 @@ def remove_sos_constraints(self, cons: List[_SOSConstraintData]): del self._vars_referenced_by_con[con] @abc.abstractmethod - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): pass - def remove_variables(self, variables: List[_GeneralVarData]): + def remove_variables(self, variables: List[VarData]): self._remove_variables(variables) for v in variables: v_id = id(v) if v_id not in self._referenced_variables: raise ValueError( - 'cannot remove variable {name} - it has not been added'.format( - name=v.name - ) + f'Cannot remove variable {v.name} - it has not been added' ) cons_using, sos_using, obj_using = self._referenced_variables[v_id] if cons_using or sos_using or (obj_using is not None): raise ValueError( - 'cannot remove variable {name} - it is still being used by constraints or the objective'.format( - name=v.name - ) + f'Cannot remove variable {v.name} - it is still being used by constraints or the objective' ) del self._referenced_variables[v_id] del self._vars[v_id] @abc.abstractmethod - def _remove_parameters(self, params: List[_ParamData]): + def _remove_parameters(self, params: List[ParamData]): pass - def remove_parameters(self, params: List[_ParamData]): + def remove_parameters(self, params: List[ParamData]): self._remove_parameters(params) for p in params: del self._params[id(p)] @@ -309,10 +315,10 @@ def remove_block(self, block): ) @abc.abstractmethod - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): pass - def update_variables(self, variables: List[_GeneralVarData]): + def update_variables(self, variables: List[VarData]): for v in variables: self._vars[id(v)] = ( v, @@ -331,7 +337,7 @@ def update_parameters(self): def update(self, timer: HierarchicalTimer = None): if timer is None: timer = HierarchicalTimer() - config = self.config.auto_updates + config = self._active_config.auto_updates new_vars = [] old_vars = [] new_params = [] @@ -340,7 +346,6 @@ def update(self, timer: HierarchicalTimer = None): old_cons = [] old_sos = [] new_sos = [] - current_vars_dict = {} current_cons_dict = {} current_sos_dict = {} timer.start('vars') @@ -381,15 +386,15 @@ def update(self, timer: HierarchicalTimer = None): for c in current_sos_dict.keys(): if c not in self._vars_referenced_by_con: new_sos.append(c) - for c in self._vars_referenced_by_con.keys(): + for c in self._vars_referenced_by_con: if c not in current_cons_dict and c not in current_sos_dict: if (c.ctype is Constraint) or ( - c.ctype is None and isinstance(c, _GeneralConstraintData) + c.ctype is None and isinstance(c, ConstraintData) ): old_cons.append(c) else: assert (c.ctype is SOSConstraint) or ( - c.ctype is None and isinstance(c, _SOSConstraintData) + c.ctype is None and isinstance(c, SOSConstraintData) ) old_sos.append(c) self.remove_constraints(old_cons) @@ -413,44 +418,16 @@ def update(self, timer: HierarchicalTimer = None): self.add_sos_constraints(new_sos) new_cons_set = set(new_cons) new_sos_set = set(new_sos) - new_vars_set = set(id(v) for v in new_vars) cons_to_remove_and_add = {} need_to_set_objective = False if config.update_constraints: - cons_to_update = [] - sos_to_update = [] for c in current_cons_dict.keys(): - if c not in new_cons_set: - cons_to_update.append(c) + if c not in new_cons_set and c.expr is not self._active_constraints[c]: + cons_to_remove_and_add[c] = None + sos_to_update = [] for c in current_sos_dict.keys(): if c not in new_sos_set: sos_to_update.append(c) - for c in cons_to_update: - lower, body, upper = self._active_constraints[c] - new_lower, new_body, new_upper = c.lower, c.body, c.upper - if new_body is not body: - cons_to_remove_and_add[c] = None - continue - if new_lower is not lower: - if ( - type(new_lower) is NumericConstant - and type(lower) is NumericConstant - and new_lower.value == lower.value - ): - pass - else: - cons_to_remove_and_add[c] = None - continue - if new_upper is not upper: - if ( - type(new_upper) is NumericConstant - and type(upper) is NumericConstant - and new_upper.value == upper.value - ): - pass - else: - cons_to_remove_and_add[c] = None - continue self.remove_sos_constraints(sos_to_update) self.add_sos_constraints(sos_to_update) timer.stop('cons') @@ -464,7 +441,7 @@ def update(self, timer: HierarchicalTimer = None): _v, lb, ub, fixed, domain_interval, value = self._vars[id(v)] if (fixed != v.fixed) or (fixed and (value != v.value)): vars_to_update.append(v) - if self.config.auto_updates.treat_fixed_vars_as_params: + if self._treat_fixed_vars_as_params: for c in self._referenced_variables[id(v)][0]: cons_to_remove_and_add[c] = None if self._referenced_variables[id(v)][2] is not None: @@ -500,13 +477,13 @@ def update(self, timer: HierarchicalTimer = None): break timer.stop('named expressions') timer.start('objective') - if self.config.auto_updates.check_for_new_objective: + if self._active_config.auto_updates.check_for_new_objective: pyomo_obj = get_objective(self._model) if pyomo_obj is not self._objective: need_to_set_objective = True else: pyomo_obj = self._objective - if self.config.auto_updates.update_objective: + if self._active_config.auto_updates.update_objective: if pyomo_obj is not None and pyomo_obj.expr is not self._objective_expr: need_to_set_objective = True elif pyomo_obj is not None and pyomo_obj.sense is not self._objective_sense: @@ -521,3 +498,41 @@ def update(self, timer: HierarchicalTimer = None): timer.start('vars') self.remove_variables(old_vars) timer.stop('vars') + + +class PersistentSolverMixin: + """ + The `solve` method in Gurobi and Highs is exactly the same, so this Mixin + minimizes the duplicate code + """ + + def solve(self, model, **kwds) -> Results: + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + self._active_config = config = self.config(value=kwds, preserve_implicit=True) + StaleFlagManager.mark_all_as_stale() + + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + if config.timer is None: + config.timer = HierarchicalTimer() + timer = config.timer + + if model is not self._model: + timer.start('set_instance') + self.set_instance(model) + timer.stop('set_instance') + else: + timer.start('update') + self.update(timer=timer) + timer.stop('update') + + res = self._solve() + self._last_results_object = res + + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + res.timing_info.start_timestamp = start_timestamp + res.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() + res.timing_info.timer = timer + self._active_config = self.config + + return res diff --git a/pyomo/contrib/solver/results.py b/pyomo/contrib/solver/common/results.py similarity index 59% rename from pyomo/contrib/solver/results.py rename to pyomo/contrib/solver/common/results.py index 699137d2fc9..ab639b7cbf4 100644 --- a/pyomo/contrib/solver/results.py +++ b/pyomo/contrib/solver/common/results.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,6 +21,7 @@ In, NonNegativeFloat, ADVANCED_OPTION, + DEVELOPER_OPTION, ) from pyomo.opt.results.solution import SolutionStatus as LegacySolutionStatus from pyomo.opt.results.solver import ( @@ -33,148 +34,96 @@ class TerminationCondition(enum.Enum): """ An Enum that enumerates all possible exit statuses for a solver call. - Attributes - ---------- - convergenceCriteriaSatisfied: 0 - The solver exited because convergence criteria of the problem were - satisfied. - maxTimeLimit: 1 - The solver exited due to reaching a specified time limit. - iterationLimit: 2 - The solver exited due to reaching a specified iteration limit. - objectiveLimit: 3 - The solver exited due to reaching an objective limit. For example, - in Gurobi, the exit message "Optimal objective for model was proven to - be worse than the value specified in the Cutoff parameter" would map - to objectiveLimit. - minStepLength: 4 - The solver exited due to a minimum step length. - Minimum step length reached may mean that the problem is infeasible or - that the problem is feasible but the solver could not converge. - unbounded: 5 - The solver exited because the problem has been found to be unbounded. - provenInfeasible: 6 - The solver exited because the problem has been proven infeasible. - locallyInfeasible: 7 - The solver exited because no feasible solution was found to the - submitted problem, but it could not be proven that no such solution exists. - infeasibleOrUnbounded: 8 - Some solvers do not specify between infeasibility or unboundedness and - instead return that one or the other has occurred. For example, in - Gurobi, this may occur because there are some steps in presolve that - prevent Gurobi from distinguishing between infeasibility and unboundedness. - error: 9 - The solver exited with some error. The error message will also be - captured and returned. - interrupted: 10 - The solver was interrupted while running. - licensingProblems: 11 - The solver experienced issues with licensing. This could be that no - license was found, the license is of the wrong type for the problem (e.g., - problem is too big for type of license), or there was an issue contacting - a licensing server. - emptyModel: 12 - The model being solved did not have any variables - unknown: 42 - All other unrecognized exit statuses fall in this category. """ convergenceCriteriaSatisfied = 0 + "The solver exited because convergence criteria of the problem were satisfied." maxTimeLimit = 1 + """The solver exited due to reaching a specified time limit.""" iterationLimit = 2 + """The solver exited due to reaching a specified iteration limit.""" objectiveLimit = 3 + """The solver exited due to reaching an objective limit. For example, in + Gurobi, the exit message "Optimal objective for model was proven to + be worse than the value specified in the Cutoff parameter" would map + to objectiveLimit. + """ minStepLength = 4 + """The solver exited due to a minimum step length. Minimum step length + reached may mean that the problem is infeasible or that the problem + is feasible but the solver could not converge. + """ unbounded = 5 + "The solver exited because the problem has been found to be unbounded." provenInfeasible = 6 + "The solver exited because the problem has been proven infeasible." locallyInfeasible = 7 + """The solver exited because no feasible solution was found to the + submitted problem, but it could not be proven that no such solution + exists. + """ infeasibleOrUnbounded = 8 + """Some solvers do not specify between infeasibility or unboundedness + and instead return that one or the other has occurred. For example, + in Gurobi, this may occur because there are some steps in presolve + that prevent Gurobi from distinguishing between infeasibility and + unboundedness. + """ error = 9 + """The solver exited with some error. The error message will also be + captured and returned. + """ interrupted = 10 + "The solver was interrupted while running." licensingProblems = 11 + """The solver experienced issues with licensing. This could be that no + license was found, the license is of the wrong type for the problem + (e.g., problem is too big for type of license), or there was an + issue contacting a licensing server. + """ emptyModel = 12 + "The model being solved did not have any variables" unknown = 42 + "All other unrecognized exit statuses fall in this category." class SolutionStatus(enum.Enum): - """ - An enumeration for interpreting the result of a termination. This describes the designated - status by the solver to be loaded back into the model. - - Attributes - ---------- - noSolution: 0 - No (single) solution was found; possible that a population of solutions - was returned. - infeasible: 10 - Solution point does not satisfy some domains and/or constraints. - feasible: 20 - A solution for which all of the constraints in the model are satisfied. - optimal: 30 - A feasible solution where the objective function reaches its specified - sense (e.g., maximum, minimum) + """An enumeration for interpreting the result of a termination. This + describes the designated status by the solver to be loaded back into + the model. + """ noSolution = 0 + """No (single) solution was found; possible that a population of + solutions was returned. + """ infeasible = 10 + "Solution point does not satisfy some domains and/or constraints." feasible = 20 + "A solution for which all of the constraints in the model are satisfied." optimal = 30 + "A feasible solution satisfying the solver's optimality criteria." class Results(ConfigDict): - """ - Attributes - ---------- - solution_loader: SolutionLoaderBase - Object for loading the solution back into the model. - termination_condition: :class:`TerminationCondition` - The reason the solver exited. This is a member of the - TerminationCondition enum. - solution_status: :class:`SolutionStatus` - The result of the solve call. This is a member of the SolutionStatus - enum. - incumbent_objective: float - If a feasible solution was found, this is the objective value of - the best solution found. If no feasible solution was found, this is - None. - objective_bound: float - The best objective bound found. For minimization problems, this is - the lower bound. For maximization problems, this is the upper bound. - For solvers that do not provide an objective bound, this should be -inf - (minimization) or inf (maximization) - solver_name: str - The name of the solver in use. - solver_version: tuple - A tuple representing the version of the solver in use. - iteration_count: int - The total number of iterations. - timing_info: ConfigDict - A ConfigDict containing two pieces of information: - start_timestamp: UTC timestamp of when run was initiated - wall_time: elapsed wall clock time for entire process - timer: a HierarchicalTimer object containing timing data about the solve - extra_info: ConfigDict - A ConfigDict to store extra information such as solver messages. - solver_configuration: ConfigDict - A copy of the SolverConfig ConfigDict, for later inspection/reproducibility. - solver_log: str - (ADVANCED OPTION) Any solver log messages. - """ + """Base class for all solver results""" def __init__( self, @@ -195,7 +144,8 @@ def __init__( self.solution_loader = self.declare( 'solution_loader', ConfigValue( - description="Object for loading the solution back into the model." + description="Object for loading the solution back into the model.", + visibility=DEVELOPER_OPTION, ), ) self.termination_condition: TerminationCondition = self.declare( @@ -276,8 +226,8 @@ def __init__( self.extra_info: ConfigDict = self.declare( 'extra_info', ConfigDict(implicit=True) ) - self.solver_configuration: ConfigDict = self.declare( - 'solver_configuration', + self.solver_config: ConfigDict = self.declare( + 'solver_config', ConfigValue( description="A copy of the config object used in the solve call.", visibility=ADVANCED_OPTION, @@ -335,17 +285,43 @@ def display( } -legacy_solution_status_map = { - SolutionStatus.noSolution: LegacySolutionStatus.unknown, - SolutionStatus.noSolution: LegacySolutionStatus.stoppedByLimit, - SolutionStatus.noSolution: LegacySolutionStatus.error, - SolutionStatus.noSolution: LegacySolutionStatus.other, - SolutionStatus.noSolution: LegacySolutionStatus.unsure, - SolutionStatus.noSolution: LegacySolutionStatus.unbounded, - SolutionStatus.optimal: LegacySolutionStatus.locallyOptimal, - SolutionStatus.optimal: LegacySolutionStatus.globallyOptimal, - SolutionStatus.optimal: LegacySolutionStatus.optimal, - SolutionStatus.infeasible: LegacySolutionStatus.infeasible, - SolutionStatus.feasible: LegacySolutionStatus.feasible, - SolutionStatus.feasible: LegacySolutionStatus.bestSoFar, -} +# The logic for the new solution status to legacy solution status +# cannot be contained in a dictionary because it is a many -> one +# relationship. +def legacy_solution_status_map(results): + """ + Map the new TerminationCondition/SolutionStatus values into LegacySolutionStatus + objects. Because we condensed results objects, some of the previous statuses + are no longer clearly achievable. + """ + if results.termination_condition in set( + [ + TerminationCondition.maxTimeLimit, + TerminationCondition.iterationLimit, + TerminationCondition.objectiveLimit, + TerminationCondition.minStepLength, + ] + ): + return LegacySolutionStatus.stoppedByLimit + if results.termination_condition in set( + [TerminationCondition.provenInfeasible, TerminationCondition.locallyInfeasible] + ): + return LegacySolutionStatus.infeasible + if results.termination_condition in set( + [ + TerminationCondition.error, + TerminationCondition.licensingProblems, + TerminationCondition.interrupted, + ] + ): + return LegacySolutionStatus.error + if results.termination_condition == TerminationCondition.unbounded: + return LegacySolutionStatus.unbounded + if ( + results.termination_condition + == TerminationCondition.convergenceCriteriaSatisfied + ): + if results.solution_status == SolutionStatus.feasible: + return LegacySolutionStatus.feasible + return LegacySolutionStatus.optimal + return LegacySolutionStatus.unknown diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py new file mode 100644 index 00000000000..911d8bee50d --- /dev/null +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -0,0 +1,134 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from typing import Sequence, Dict, Optional, Mapping, NoReturn + +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.var import VarData +from pyomo.core.staleflag import StaleFlagManager + + +class SolutionLoaderBase: + """ + Base class for all future SolutionLoader classes. + + Intent of this class and its children is to load the solution back into the model. + """ + + def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: + """ + Load the solution of the primal variables into the value attribute of the variables. + + Parameters + ---------- + vars_to_load: list + The minimum set of variables whose solution should be loaded. If vars_to_load + is None, then the solution to all primal variables will be loaded. Even if + vars_to_load is specified, the values of other variables may also be + loaded depending on the interface. + """ + for var, val in self.get_primals(vars_to_load=vars_to_load).items(): + var.set_value(val, skip_validation=True) + StaleFlagManager.mark_all_as_stale(delayed=True) + + def get_primals( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + """ + Returns a ComponentMap mapping variable to var value. + + Parameters + ---------- + vars_to_load: list + A list of the variables whose solution value should be retrieved. If vars_to_load + is None, then the values for all variables will be retrieved. + + Returns + ------- + primals: ComponentMap + Maps variables to solution values + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'get_primals'." + ) + + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: + """ + Returns a dictionary mapping constraint to dual value. + + Parameters + ---------- + cons_to_load: list + A list of the constraints whose duals should be retrieved. If cons_to_load + is None, then the duals for all constraints will be retrieved. + + Returns + ------- + duals: dict + Maps constraints to dual values + """ + raise NotImplementedError(f'{type(self)} does not support the get_duals method') + + def get_reduced_costs( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + """ + Returns a ComponentMap mapping variable to reduced cost. + + Parameters + ---------- + vars_to_load: list + A list of the variables whose reduced cost should be retrieved. If vars_to_load + is None, then the reduced costs for all variables will be loaded. + + Returns + ------- + reduced_costs: ComponentMap + Maps variables to reduced costs + """ + raise NotImplementedError( + f'{type(self)} does not support the get_reduced_costs method' + ) + + +class PersistentSolutionLoader(SolutionLoaderBase): + """ + Loader for persistent solvers + """ + + def __init__(self, solver): + self._solver = solver + self._valid = True + + def _assert_solution_still_valid(self): + if not self._valid: + raise RuntimeError('The results in the solver are no longer valid.') + + def get_primals(self, vars_to_load=None): + self._assert_solution_still_valid() + return self._solver._get_primals(vars_to_load=vars_to_load) + + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: + self._assert_solution_still_valid() + return self._solver._get_duals(cons_to_load=cons_to_load) + + def get_reduced_costs( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + self._assert_solution_still_valid() + return self._solver._get_reduced_costs(vars_to_load=vars_to_load) + + def invalidate(self): + self._valid = False diff --git a/pyomo/contrib/solver/common/util.py b/pyomo/contrib/solver/common/util.py new file mode 100644 index 00000000000..084b8114c66 --- /dev/null +++ b/pyomo/contrib/solver/common/util.py @@ -0,0 +1,128 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.errors import PyomoException +from pyomo.core.expr.visitor import ExpressionValueVisitor, nonpyomo_leaf_types +import pyomo.core.expr as EXPR +from pyomo.core.base.objective import Objective + + +class NoFeasibleSolutionError(PyomoException): + def __init__(self): + super().__init__( + 'A feasible solution was not found, so no solution can be loaded. ' + 'Please set opt.config.load_solutions=False and check ' + 'results.solution_status and ' + 'results.incumbent_objective before loading a solution.' + ) + + +class NoOptimalSolutionError(PyomoException): + def __init__(self): + super().__init__( + 'Solver did not find the optimal solution. Set ' + 'opt.config.raise_exception_on_nonoptimal_result = False to bypass this error.' + ) + + +class NoSolutionError(PyomoException): + def __init__(self): + super().__init__( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + + +class NoDualsError(PyomoException): + def __init__(self): + super().__init__( + 'Solver does not currently have valid duals. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + + +class NoReducedCostsError(PyomoException): + def __init__(self): + super().__init__( + 'Solver does not currently have valid reduced costs. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + + +class IncompatibleModelError(PyomoException): + def __init__(self): + super().__init__( + 'Model is not compatible with the chosen solver. Please check ' + 'the model and solver.' + ) + + +def get_objective(block): + """ + Get current active objective on a block. If there is more than one active, + return an error. + """ + objective = None + for obj in block.component_data_objects( + Objective, descend_into=True, active=True, sort=True + ): + if objective is not None: + raise ValueError('Multiple active objectives found.') + objective = obj + return objective + + +class _VarAndNamedExprCollector(ExpressionValueVisitor): + def __init__(self): + self.named_expressions = {} + self.variables = {} + self.fixed_vars = {} + self._external_functions = {} + + def visit(self, node, values): + pass + + def visiting_potential_leaf(self, node): + if type(node) in nonpyomo_leaf_types: + return True, None + + if node.is_variable_type(): + self.variables[id(node)] = node + if node.is_fixed(): + self.fixed_vars[id(node)] = node + return True, None + + if node.is_named_expression_type(): + self.named_expressions[id(node)] = node + return False, None + + if isinstance(node, EXPR.ExternalFunctionExpression): + self._external_functions[id(node)] = node + return False, None + + if node.is_expression_type(): + return False, None + + return True, None + + +_visitor = _VarAndNamedExprCollector() + + +def collect_vars_and_named_exprs(expr): + _visitor.__init__() + _visitor.dfs_postorder_stack(expr) + return ( + list(_visitor.named_expressions.values()), + list(_visitor.variables.values()), + list(_visitor.fixed_vars.values()), + list(_visitor._external_functions.values()), + ) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index c7da41463a2..86c05f2bd70 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,15 +10,27 @@ # ___________________________________________________________________________ -from .factory import SolverFactory -from .ipopt import Ipopt -from .gurobi import Gurobi +from .common.factory import SolverFactory +from .solvers.ipopt import Ipopt +from .solvers.gurobi_persistent import GurobiPersistent +from .solvers.gurobi_direct import GurobiDirect +from .solvers.highs import Highs def load(): SolverFactory.register( - name='ipopt', legacy_name='ipopt_v2', doc='The IPOPT NLP solver (new interface)' + name='ipopt', legacy_name='ipopt_v2', doc='The IPOPT NLP solver' )(Ipopt) SolverFactory.register( - name='gurobi', legacy_name='gurobi_v2', doc='New interface to Gurobi' - )(Gurobi) + name='gurobi_persistent', + legacy_name='gurobi_persistent_v2', + doc='Persistent interface to Gurobi', + )(GurobiPersistent) + SolverFactory.register( + name='gurobi_direct', + legacy_name='gurobi_direct_v2', + doc='Direct (scipy-based) interface to Gurobi', + )(GurobiDirect) + SolverFactory.register( + name='highs', legacy_name='highs', doc='Persistent interface to HiGHS' + )(Highs) diff --git a/pyomo/contrib/solver/solution.py b/pyomo/contrib/solver/solution.py deleted file mode 100644 index 32e84d2abca..00000000000 --- a/pyomo/contrib/solver/solution.py +++ /dev/null @@ -1,241 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -import abc -from typing import Sequence, Dict, Optional, Mapping, NoReturn - -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.expr import value -from pyomo.common.collections import ComponentMap -from pyomo.common.errors import DeveloperError -from pyomo.core.staleflag import StaleFlagManager -from pyomo.contrib.solver.sol_reader import SolFileData -from pyomo.repn.plugins.nl_writer import NLWriterInfo -from pyomo.core.expr.visitor import replace_expressions - - -class SolutionLoaderBase(abc.ABC): - """ - Base class for all future SolutionLoader classes. - - Intent of this class and its children is to load the solution back into the model. - """ - - def load_vars( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> NoReturn: - """ - Load the solution of the primal variables into the value attribute of the variables. - - Parameters - ---------- - vars_to_load: list - The minimum set of variables whose solution should be loaded. If vars_to_load is None, then the solution - to all primal variables will be loaded. Even if vars_to_load is specified, the values of other - variables may also be loaded depending on the interface. - """ - for v, val in self.get_primals(vars_to_load=vars_to_load).items(): - v.set_value(val, skip_validation=True) - StaleFlagManager.mark_all_as_stale(delayed=True) - - @abc.abstractmethod - def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: - """ - Returns a ComponentMap mapping variable to var value. - - Parameters - ---------- - vars_to_load: list - A list of the variables whose solution value should be retrieved. If vars_to_load is None, - then the values for all variables will be retrieved. - - Returns - ------- - primals: ComponentMap - Maps variables to solution values - """ - - def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: - """ - Returns a dictionary mapping constraint to dual value. - - Parameters - ---------- - cons_to_load: list - A list of the constraints whose duals should be retrieved. If cons_to_load is None, then the duals for all - constraints will be retrieved. - - Returns - ------- - duals: dict - Maps constraints to dual values - """ - raise NotImplementedError(f'{type(self)} does not support the get_duals method') - - def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: - """ - Returns a ComponentMap mapping variable to reduced cost. - - Parameters - ---------- - vars_to_load: list - A list of the variables whose reduced cost should be retrieved. If vars_to_load is None, then the - reduced costs for all variables will be loaded. - - Returns - ------- - reduced_costs: ComponentMap - Maps variables to reduced costs - """ - raise NotImplementedError( - f'{type(self)} does not support the get_reduced_costs method' - ) - - -class PersistentSolutionLoader(SolutionLoaderBase): - def __init__(self, solver): - self._solver = solver - self._valid = True - - def _assert_solution_still_valid(self): - if not self._valid: - raise RuntimeError('The results in the solver are no longer valid.') - - def get_primals(self, vars_to_load=None): - self._assert_solution_still_valid() - return self._solver._get_primals(vars_to_load=vars_to_load) - - def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: - self._assert_solution_still_valid() - return self._solver._get_duals(cons_to_load=cons_to_load) - - def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: - self._assert_solution_still_valid() - return self._solver._get_reduced_costs(vars_to_load=vars_to_load) - - def invalidate(self): - self._valid = False - - -class SolSolutionLoader(SolutionLoaderBase): - def __init__(self, sol_data: SolFileData, nl_info: NLWriterInfo) -> None: - self._sol_data = sol_data - self._nl_info = nl_info - - def load_vars( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> NoReturn: - if self._nl_info is None: - raise RuntimeError( - 'Solution loader does not currently have a valid solution. Please ' - 'check results.TerminationCondition and/or results.SolutionStatus.' - ) - if self._sol_data is None: - assert len(self._nl_info.variables) == 0 - else: - if self._nl_info.scaling: - for v, val, scale in zip( - self._nl_info.variables, - self._sol_data.primals, - self._nl_info.scaling.variables, - ): - v.set_value(val / scale, skip_validation=True) - else: - for v, val in zip(self._nl_info.variables, self._sol_data.primals): - v.set_value(val, skip_validation=True) - - for v, v_expr in self._nl_info.eliminated_vars: - v.value = value(v_expr) - - StaleFlagManager.mark_all_as_stale(delayed=True) - - def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: - if self._nl_info is None: - raise RuntimeError( - 'Solution loader does not currently have a valid solution. Please ' - 'check results.TerminationCondition and/or results.SolutionStatus.' - ) - val_map = dict() - if self._sol_data is None: - assert len(self._nl_info.variables) == 0 - else: - if self._nl_info.scaling is None: - scale_list = [1] * len(self._nl_info.variables) - else: - scale_list = self._nl_info.scaling.variables - for v, val, scale in zip( - self._nl_info.variables, self._sol_data.primals, scale_list - ): - val_map[id(v)] = val / scale - - for v, v_expr in self._nl_info.eliminated_vars: - val = replace_expressions(v_expr, substitution_map=val_map) - v_id = id(v) - val_map[v_id] = val - - res = ComponentMap() - if vars_to_load is None: - vars_to_load = self._nl_info.variables + [ - v for v, _ in self._nl_info.eliminated_vars - ] - for v in vars_to_load: - res[v] = val_map[id(v)] - - return res - - def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: - if self._nl_info is None: - raise RuntimeError( - 'Solution loader does not currently have a valid solution. Please ' - 'check results.TerminationCondition and/or results.SolutionStatus.' - ) - if len(self._nl_info.eliminated_vars) > 0: - raise NotImplementedError( - 'For now, turn presolve off (opt.config.writer_config.linear_presolve=False) ' - 'to get dual variable values.' - ) - if self._sol_data is None: - raise DeveloperError( - "Solution data is empty. This should not " - "have happened. Report this error to the Pyomo Developers." - ) - res = dict() - if self._nl_info.scaling is None: - scale_list = [1] * len(self._nl_info.constraints) - obj_scale = 1 - else: - scale_list = self._nl_info.scaling.constraints - obj_scale = self._nl_info.scaling.objectives[0] - if cons_to_load is None: - cons_to_load = set(self._nl_info.constraints) - else: - cons_to_load = set(cons_to_load) - for c, val, scale in zip( - self._nl_info.constraints, self._sol_data.duals, scale_list - ): - if c in cons_to_load: - res[c] = val * scale / obj_scale - return res diff --git a/pyomo/contrib/solver/solvers/__init__.py b/pyomo/contrib/solver/solvers/__init__.py new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/solver/solvers/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py new file mode 100644 index 00000000000..fc549b83ab1 --- /dev/null +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -0,0 +1,492 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import datetime +import io +import math +import operator +import os + +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.config import ConfigValue +from pyomo.common.dependencies import attempt_import +from pyomo.common.enums import ObjectiveSense +from pyomo.common.errors import MouseTrap, ApplicationError +from pyomo.common.shutdown import python_is_shutting_down +from pyomo.common.tee import capture_output, TeeStream +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.staleflag import StaleFlagManager +from pyomo.repn.plugins.standard_form import LinearStandardFormCompiler + +from pyomo.contrib.solver.common.base import SolverBase, Availability +from pyomo.contrib.solver.common.config import BranchAndBoundConfig +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, + NoDualsError, + NoReducedCostsError, + NoSolutionError, + IncompatibleModelError, +) +from pyomo.contrib.solver.common.results import ( + Results, + SolutionStatus, + TerminationCondition, +) +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase + +import logging + +logger = logging.getLogger(__name__) + + +gurobipy, gurobipy_available = attempt_import("gurobipy") + +# NOTE: set this if you would like the gurobi solver to write a file at the end (handy for debugging) +DEBUG_LP_FILE = None + + +class GurobiConfigMixin: + """ + Mixin class for Gurobi-specific configurations + """ + + def __init__(self): + self.use_mipstart: bool = self.declare( + "use_mipstart", + ConfigValue( + default=False, + domain=bool, + description="If True, the current values of the integer variables " + "will be passed to Gurobi.", + ), + ) + + +class GurobiConfig(BranchAndBoundConfig, GurobiConfigMixin): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + BranchAndBoundConfig.__init__( + self, + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + GurobiConfigMixin.__init__(self) + + +class GurobiDirectSolutionLoader(SolutionLoaderBase): + def __init__(self, grb_model, grb_cons, grb_vars, pyo_cons, pyo_vars, pyo_obj): + self._grb_model = grb_model + self._grb_cons = grb_cons + self._grb_vars = grb_vars + self._pyo_cons = pyo_cons + self._pyo_vars = pyo_vars + self._pyo_obj = pyo_obj + GurobiDirect._num_instances += 1 + + def __del__(self): + if python_is_shutting_down(): + return + # Free the associated model + if self._grb_model is not None: + self._grb_cons = None + self._grb_vars = None + self._pyo_cons = None + self._pyo_vars = None + self._pyo_obj = None + # explicitly release the model + self._grb_model.dispose() + self._grb_model = None + # Release the gurobi license if this is the last reference to + # the environment (either through a results object or solver + # interface) + GurobiDirect._num_instances -= 1 + if GurobiDirect._num_instances == 0: + GurobiDirect.release_license() + + def load_vars(self, vars_to_load=None, solution_number=0): + assert solution_number == 0 + if self._grb_model.SolCount == 0: + raise NoSolutionError() + + iterator = zip(self._pyo_vars, self._grb_vars.x.tolist()) + if vars_to_load: + vars_to_load = ComponentSet(vars_to_load) + iterator = filter(lambda var_val: var_val[0] in vars_to_load, iterator) + for p_var, g_var in iterator: + p_var.set_value(g_var, skip_validation=True) + StaleFlagManager.mark_all_as_stale(delayed=True) + + def get_primals(self, vars_to_load=None, solution_number=0): + assert solution_number == 0 + if self._grb_model.SolCount == 0: + raise NoSolutionError() + + iterator = zip(self._pyo_vars, self._grb_vars.x.tolist()) + if vars_to_load: + vars_to_load = ComponentSet(vars_to_load) + iterator = filter(lambda var_val: var_val[0] in vars_to_load, iterator) + return ComponentMap(iterator) + + def get_duals(self, cons_to_load=None): + if self._grb_model.Status != gurobipy.GRB.OPTIMAL: + raise NoDualsError() + + def dedup(_iter): + last = None + for con_info_dual in _iter: + if not con_info_dual[1] and con_info_dual[0][0] is last: + continue + last = con_info_dual[0][0] + yield con_info_dual + + iterator = dedup(zip(self._pyo_cons, self._grb_cons.getAttr("Pi").tolist())) + if cons_to_load: + cons_to_load = set(cons_to_load) + iterator = filter( + lambda con_info_dual: con_info_dual[0][0] in cons_to_load, iterator + ) + return {con_info[0]: dual for con_info, dual in iterator} + + def get_reduced_costs(self, vars_to_load=None): + if self._grb_model.Status != gurobipy.GRB.OPTIMAL: + raise NoReducedCostsError() + + iterator = zip(self._pyo_vars, self._grb_vars.getAttr("Rc").tolist()) + if vars_to_load: + vars_to_load = ComponentSet(vars_to_load) + iterator = filter(lambda var_rc: var_rc[0] in vars_to_load, iterator) + return ComponentMap(iterator) + + +class GurobiSolverMixin: + """ + gurobi_direct and gurobi_persistent check availability and set versions + in the same way. This moves the logic to a central location to reduce + duplicate code. + """ + + def available(self): + if not gurobipy_available: # this triggers the deferred import + return Availability.NotFound + if self._available == Availability.BadVersion: + return Availability.BadVersion + return self._check_license() + + def _check_license(self): + avail = False + try: + # Gurobipy writes out license file information when creating + # the environment + with capture_output(capture_fd=True): + m = gurobipy.Model() + avail = True + except gurobipy.GurobiError: + avail = False + + if avail: + if self._available is None: + self._available = self._check_full_license(m) + return self._available + return Availability.BadLicense + + @classmethod + def _check_full_license(cls, model=None): + if model is None: + model = gurobipy.Model() + model.setParam("OutputFlag", 0) + try: + model.addVars(range(2001)) + model.optimize() + return Availability.FullLicense + except gurobipy.GurobiError: + return Availability.LimitedLicense + + def version(self): + version = ( + gurobipy.GRB.VERSION_MAJOR, + gurobipy.GRB.VERSION_MINOR, + gurobipy.GRB.VERSION_TECHNICAL, + ) + return version + + +class GurobiDirect(GurobiSolverMixin, SolverBase): + """ + Interface to Gurobi using gurobipy + """ + + CONFIG = GurobiConfig() + + _available = None + _num_instances = 0 + _tc_map = None + + def __init__(self, **kwds): + logger.debug(f"(GurobiDirect (Legacy)): {self.__class__} __init__: {kwds} ") + super().__init__(**kwds) + GurobiDirect._num_instances += 1 + + @staticmethod + def release_license(): + if gurobipy_available: + with capture_output(capture_fd=True): + gurobipy.disposeDefaultEnv() + + def __del__(self): + if not python_is_shutting_down(): + GurobiDirect._num_instances -= 1 + if GurobiDirect._num_instances == 0: + self.release_license() + + def solve(self, model, **kwds) -> Results: + logger.debug(f"{self.__class__.__name__}: {kwds}") + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + config = self.config(value=kwds, preserve_implicit=True) + if not self.available(): + c = self.__class__ + raise ApplicationError( + f"Solver {c.__module__}.{c.__qualname__} is not available " + f"({self.available()})." + ) + if config.timer is None: + config.timer = HierarchicalTimer() + timer = config.timer + + StaleFlagManager.mark_all_as_stale() + + timer.start("compile_model") + repn = LinearStandardFormCompiler().write( + model, mixed_form=True, set_sense=None + ) + timer.stop("compile_model") + + if len(repn.objectives) > 1: + raise IncompatibleModelError( + f"The {self.__class__.__name__} solver only supports models " + f"with zero or one objectives (received {len(repn.objectives)})." + ) + + timer.start("prepare_matrices") + inf = float("inf") + ninf = -inf + bounds = list(map(operator.attrgetter("bounds"), repn.columns)) + lb = [ninf if _b is None else _b for _b in map(operator.itemgetter(0), bounds)] + ub = [inf if _b is None else _b for _b in map(operator.itemgetter(1), bounds)] + CON = gurobipy.GRB.CONTINUOUS + BIN = gurobipy.GRB.BINARY + INT = gurobipy.GRB.INTEGER + vtype = [ + ( + CON + if v.is_continuous() + else BIN if v.is_binary() else INT if v.is_integer() else "?" + ) + for v in repn.columns + ] + sense_type = list("=<>") # Note: ordering matches 0, 1, -1 + sense = [sense_type[r[1]] for r in repn.rows] + timer.stop("prepare_matrices") + + ostreams = [io.StringIO()] + config.tee + res = Results() + + orig_cwd = os.getcwd() + try: + if config.working_dir: + os.chdir(config.working_dir) + with capture_output(TeeStream(*ostreams), capture_fd=False): + gurobi_model = gurobipy.Model() + + timer.start("transfer_model") + # only add linear objective coefficients with variables if no quadratic terms + _obj = ( + (repn.c.todense()[0] if repn.c.shape[0] else 0) + if not repn.is_quadratic + else None + ) + + x = gurobi_model.addMVar( + len(repn.columns), lb=lb, ub=ub, obj=_obj, vtype=vtype + ) + logger.debug(f"repn.A: {repn.A.toarray()} {type(repn.A.toarray())}") + logger.debug(f"repn.is_quadratic: {repn.is_quadratic}") + logger.debug(f"sense: {sense}") + logger.debug(f"rhs: {repn.rhs}") + if repn.is_quadratic: + # quadratic: add each matrix constraint individually + A = [ + gurobi_model.addMQConstr(q, a, sense[i], repn.rhs[i], x, x, x) + for i, (a, q) in enumerate(zip(repn.A, repn.Q_list)) + ] + + gurobi_model.setMObjective( + None if len(repn.Q_obj) < 1 else repn.Q_obj[0], + repn.c.todense()[0], + repn.c_offset[0], + x, + x, + x, + repn.objectives[0].sense, + ) + + else: + # linear: all constraints in single matrix + A = gurobi_model.addMConstr(repn.A, x, sense, repn.rhs) + + if repn.c.shape[0]: + gurobi_model.setAttr("ObjCon", repn.c_offset[0]) + gurobi_model.setAttr( + "ModelSense", int(repn.objectives[0].sense) + ) + + # Note: calling gurobi_model.update() here is not + # necessary (it will happen as part of optimize()): + # gurobi_model.update() + timer.stop("transfer_model") + + options = config.solver_options + + gurobi_model.setParam("LogToConsole", 1) + + if config.threads is not None: + gurobi_model.setParam("Threads", config.threads) + if config.time_limit is not None: + gurobi_model.setParam("TimeLimit", config.time_limit) + if config.rel_gap is not None: + gurobi_model.setParam("MIPGap", config.rel_gap) + if config.abs_gap is not None: + gurobi_model.setParam("MIPGapAbs", config.abs_gap) + + if config.use_mipstart: + raise MouseTrap("MIPSTART not yet supported") + + for key, option in options.items(): + gurobi_model.setParam(key, option) + + timer.start("optimize") + gurobi_model.optimize() + timer.stop("optimize") + + finally: + os.chdir(orig_cwd) + + if DEBUG_LP_FILE: + logger.info(f"writing: {DEBUG_LP_FILE}") + gurobi_model.write(DEBUG_LP_FILE) + + res = self._postsolve( + timer, + config, + GurobiDirectSolutionLoader( + gurobi_model, A, x, repn.rows, repn.columns, repn.objectives + ), + ) + + res.solver_config = config + res.solver_name = "Gurobi" + res.solver_version = self.version() + res.solver_log = ostreams[0].getvalue() + + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + res.timing_info.start_timestamp = start_timestamp + res.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() + res.timing_info.timer = timer + return res + + def _postsolve(self, timer: HierarchicalTimer, config, loader): + grb_model = loader._grb_model + status = grb_model.Status + + results = Results() + results.solution_loader = loader + results.timing_info.gurobi_time = grb_model.Runtime + + if grb_model.SolCount > 0: + if status == gurobipy.GRB.OPTIMAL: + results.solution_status = SolutionStatus.optimal + else: + results.solution_status = SolutionStatus.feasible + else: + results.solution_status = SolutionStatus.noSolution + + results.termination_condition = self._get_tc_map().get( + status, TerminationCondition.unknown + ) + + if ( + results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + and config.raise_exception_on_nonoptimal_result + ): + raise NoOptimalSolutionError() + + if loader._pyo_obj: + try: + if math.isfinite(grb_model.ObjVal): + results.incumbent_objective = grb_model.ObjVal + else: + results.incumbent_objective = None + except (gurobipy.GurobiError, AttributeError): + results.incumbent_objective = None + try: + results.objective_bound = grb_model.ObjBound + except (gurobipy.GurobiError, AttributeError): + if grb_model.ModelSense == ObjectiveSense.minimize: + results.objective_bound = -math.inf + else: + results.objective_bound = math.inf + else: + results.incumbent_objective = None + results.objective_bound = None + + results.iteration_count = grb_model.getAttr("IterCount") + + timer.start("load solution") + if config.load_solutions: + if grb_model.SolCount > 0: + results.solution_loader.load_vars() + else: + raise NoFeasibleSolutionError() + timer.stop("load solution") + + return results + + def _get_tc_map(self): + if GurobiDirect._tc_map is None: + grb = gurobipy.GRB + tc = TerminationCondition + GurobiDirect._tc_map = { + grb.LOADED: tc.unknown, # problem is loaded, but no solution + grb.OPTIMAL: tc.convergenceCriteriaSatisfied, + grb.INFEASIBLE: tc.provenInfeasible, + grb.INF_OR_UNBD: tc.infeasibleOrUnbounded, + grb.UNBOUNDED: tc.unbounded, + grb.CUTOFF: tc.objectiveLimit, + grb.ITERATION_LIMIT: tc.iterationLimit, + grb.NODE_LIMIT: tc.iterationLimit, + grb.TIME_LIMIT: tc.maxTimeLimit, + grb.SOLUTION_LIMIT: tc.unknown, + grb.INTERRUPTED: tc.interrupted, + grb.NUMERIC: tc.unknown, + grb.SUBOPTIMAL: tc.unknown, + grb.USER_OBJ_LIMIT: tc.objectiveLimit, + } + return GurobiDirect._tc_map diff --git a/pyomo/contrib/solver/gurobi.py b/pyomo/contrib/solver/solvers/gurobi_persistent.py similarity index 80% rename from pyomo/contrib/solver/gurobi.py rename to pyomo/contrib/solver/solvers/gurobi_persistent.py index d0ac0d80f45..09aa6d1e851 100644 --- a/pyomo/contrib/solver/gurobi.py +++ b/pyomo/contrib/solver/solvers/gurobi_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,35 +9,53 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from collections.abc import Iterable +import io import logging import math from typing import List, Optional +from collections.abc import Iterable + from pyomo.common.collections import ComponentSet, ComponentMap, OrderedSet from pyomo.common.dependencies import attempt_import -from pyomo.common.errors import PyomoException +from pyomo.common.errors import ApplicationError from pyomo.common.tee import capture_output, TeeStream from pyomo.common.timing import HierarchicalTimer from pyomo.common.shutdown import python_is_shutting_down -from pyomo.common.config import ConfigValue from pyomo.core.kernel.objective import minimize, maximize from pyomo.core.base import SymbolMap, NumericLabeler, TextLabeler -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.sos import _SOSConstraintData -from pyomo.core.base.param import _ParamData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.sos import SOSConstraintData +from pyomo.core.base.param import ParamData from pyomo.core.expr.numvalue import value, is_constant, is_fixed, native_numeric_types from pyomo.repn import generate_standard_repn from pyomo.core.expr.numeric_expr import NPV_MaxExpression, NPV_MinExpression -from pyomo.contrib.solver.base import PersistentSolverBase -from pyomo.contrib.solver.results import Results, TerminationCondition, SolutionStatus -from pyomo.contrib.solver.config import PersistentBranchAndBoundConfig -from pyomo.contrib.solver.persistent import PersistentSolverUtils -from pyomo.contrib.solver.solution import PersistentSolutionLoader +from pyomo.contrib.solver.common.base import PersistentSolverBase, Availability +from pyomo.contrib.solver.common.results import ( + Results, + TerminationCondition, + SolutionStatus, +) +from pyomo.contrib.solver.common.config import PersistentBranchAndBoundConfig +from pyomo.contrib.solver.solvers.gurobi_direct import ( + GurobiConfigMixin, + GurobiSolverMixin, +) +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, + NoDualsError, + NoReducedCostsError, + NoSolutionError, + IncompatibleModelError, +) +from pyomo.contrib.solver.common.persistent import ( + PersistentSolverUtils, + PersistentSolverMixin, +) +from pyomo.contrib.solver.common.solution_loader import PersistentSolutionLoader from pyomo.core.staleflag import StaleFlagManager -import sys -import datetime -import io + logger = logging.getLogger(__name__) @@ -46,10 +64,10 @@ def _import_gurobipy(): try: import gurobipy except ImportError: - Gurobi._available = Gurobi.Availability.NotFound + GurobiPersistent._available = Availability.NotFound raise if gurobipy.GRB.VERSION_MAJOR < 7: - Gurobi._available = Gurobi.Availability.BadVersion + GurobiPersistent._available = Availability.BadVersion raise ImportError('The APPSI Gurobi interface requires gurobipy>=7.0.0') return gurobipy @@ -57,11 +75,7 @@ def _import_gurobipy(): gurobipy, gurobipy_available = attempt_import('gurobipy', importer=_import_gurobipy) -class DegreeError(PyomoException): - pass - - -class GurobiConfig(PersistentBranchAndBoundConfig): +class GurobiConfig(PersistentBranchAndBoundConfig, GurobiConfigMixin): def __init__( self, description=None, @@ -70,21 +84,15 @@ def __init__( implicit_domain=None, visibility=0, ): - super(GurobiConfig, self).__init__( + PersistentBranchAndBoundConfig.__init__( + self, description=description, doc=doc, implicit=implicit, implicit_domain=implicit_domain, visibility=visibility, ) - self.use_mipstart: bool = self.declare( - 'use_mipstart', - ConfigValue( - default=False, - domain=bool, - description="If True, the values of the integer variables will be passed to Gurobi.", - ), - ) + GurobiConfigMixin.__init__(self) class GurobiSolutionLoader(PersistentSolutionLoader): @@ -101,7 +109,7 @@ def get_primals(self, vars_to_load=None, solution_number=0): ) -class _MutableLowerBound(object): +class _MutableLowerBound: def __init__(self, expr): self.var = None self.expr = expr @@ -110,7 +118,7 @@ def update(self): self.var.setAttr('lb', value(self.expr)) -class _MutableUpperBound(object): +class _MutableUpperBound: def __init__(self, expr): self.var = None self.expr = expr @@ -119,7 +127,7 @@ def update(self): self.var.setAttr('ub', value(self.expr)) -class _MutableLinearCoefficient(object): +class _MutableLinearCoefficient: def __init__(self): self.expr = None self.var = None @@ -130,7 +138,7 @@ def update(self): self.gurobi_model.chgCoeff(self.con, self.var, value(self.expr)) -class _MutableRangeConstant(object): +class _MutableRangeConstant: def __init__(self): self.lhs_expr = None self.rhs_expr = None @@ -146,7 +154,7 @@ def update(self): slack.ub = rhs_val - lhs_val -class _MutableConstant(object): +class _MutableConstant: def __init__(self): self.expr = None self.con = None @@ -155,7 +163,7 @@ def update(self): self.con.rhs = value(self.expr) -class _MutableQuadraticConstraint(object): +class _MutableQuadraticConstraint: def __init__( self, gurobi_model, gurobi_con, constant, linear_coefs, quadratic_coefs ): @@ -190,7 +198,7 @@ def get_updated_rhs(self): return value(self.constant.expr) -class _MutableObjective(object): +class _MutableObjective: def __init__(self, gurobi_model, constant, linear_coefs, quadratic_coefs): self.gurobi_model = gurobi_model self.constant = constant @@ -218,16 +226,21 @@ def get_updated_expression(self): return gurobi_expr -class _MutableQuadraticCoefficient(object): +class _MutableQuadraticCoefficient: def __init__(self): self.expr = None self.var1 = None self.var2 = None -class Gurobi(PersistentSolverUtils, PersistentSolverBase): +class GurobiPersistent( + GurobiSolverMixin, + PersistentSolverMixin, + PersistentSolverUtils, + PersistentSolverBase, +): """ - Interface to Gurobi + Interface to Gurobi persistent """ CONFIG = GurobiConfig() @@ -236,20 +249,23 @@ class Gurobi(PersistentSolverUtils, PersistentSolverBase): _num_instances = 0 def __init__(self, **kwds): - PersistentSolverUtils.__init__(self) + treat_fixed_vars_as_params = kwds.pop('treat_fixed_vars_as_params', True) PersistentSolverBase.__init__(self, **kwds) - Gurobi._num_instances += 1 + PersistentSolverUtils.__init__( + self, treat_fixed_vars_as_params=treat_fixed_vars_as_params + ) + GurobiPersistent._num_instances += 1 self._solver_model = None self._symbol_map = SymbolMap() self._labeler = None - self._pyomo_var_to_solver_var_map = dict() - self._pyomo_con_to_solver_con_map = dict() - self._solver_con_to_pyomo_con_map = dict() - self._pyomo_sos_to_solver_sos_map = dict() + self._pyomo_var_to_solver_var_map = {} + self._pyomo_con_to_solver_con_map = {} + self._solver_con_to_pyomo_con_map = {} + self._pyomo_sos_to_solver_sos_map = {} self._range_constraints = OrderedSet() - self._mutable_helpers = dict() - self._mutable_bounds = dict() - self._mutable_quadratic_helpers = dict() + self._mutable_helpers = {} + self._mutable_bounds = {} + self._mutable_quadratic_helpers = {} self._mutable_objective = None self._needs_updated = True self._callback = None @@ -257,46 +273,6 @@ def __init__(self, **kwds): self._constraints_added_since_update = OrderedSet() self._vars_added_since_update = ComponentSet() self._last_results_object: Optional[Results] = None - self._config: Optional[GurobiConfig] = None - - def available(self): - if not gurobipy_available: # this triggers the deferred import - return self.Availability.NotFound - elif self._available == self.Availability.BadVersion: - return self.Availability.BadVersion - else: - return self._check_license() - - def _check_license(self): - avail = False - try: - # Gurobipy writes out license file information when creating - # the environment - with capture_output(capture_fd=True): - m = gurobipy.Model() - if self._solver_model is None: - self._solver_model = m - avail = True - except gurobipy.GurobiError: - avail = False - - if avail: - if self._available is None: - self._available = Gurobi._check_full_license() - return self._available - else: - return self.Availability.BadLicense - - @classmethod - def _check_full_license(cls): - m = gurobipy.Model() - m.setParam('OutputFlag', 0) - try: - m.addVars(range(2001)) - m.optimize() - return cls.Availability.FullLicense - except gurobipy.GurobiError: - return cls.Availability.LimitedLicense def release_license(self): self._reinit() @@ -306,28 +282,20 @@ def release_license(self): def __del__(self): if not python_is_shutting_down(): - Gurobi._num_instances -= 1 - if Gurobi._num_instances == 0: + GurobiPersistent._num_instances -= 1 + if GurobiPersistent._num_instances == 0: self.release_license() - def version(self): - version = ( - gurobipy.GRB.VERSION_MAJOR, - gurobipy.GRB.VERSION_MINOR, - gurobipy.GRB.VERSION_TECHNICAL, - ) - return version - @property def symbol_map(self): return self._symbol_map def _solve(self): - config = self._config + config = self._active_config timer = config.timer ostreams = [io.StringIO()] + config.tee - with TeeStream(*ostreams) as t, capture_output(t.STDOUT, capture_fd=False): + with capture_output(TeeStream(*ostreams), capture_fd=False): options = config.solver_options self._solver_model.setParam('LogToConsole', 1) @@ -359,40 +327,12 @@ def _solve(self): self._needs_updated = False res = self._postsolve(timer) - res.solver_configuration = config + res.solver_config = config res.solver_name = 'Gurobi' res.solver_version = self.version() res.solver_log = ostreams[0].getvalue() return res - def solve(self, model, **kwds) -> Results: - start_timestamp = datetime.datetime.now(datetime.timezone.utc) - self._config = config = self.config(value=kwds, preserve_implicit=True) - StaleFlagManager.mark_all_as_stale() - # Note: solver availability check happens in set_instance(), - # which will be called (either by the user before this call, or - # below) before this method calls self._solve. - if self._last_results_object is not None: - self._last_results_object.solution_loader.invalidate() - if config.timer is None: - config.timer = HierarchicalTimer() - timer = config.timer - if model is not self._model: - timer.start('set_instance') - self.set_instance(model) - timer.stop('set_instance') - else: - timer.start('update') - self.update(timer=timer) - timer.stop('update') - res = self._solve() - self._last_results_object = res - end_timestamp = datetime.datetime.now(datetime.timezone.utc) - res.timing_info.start_timestamp = start_timestamp - res.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() - res.timing_info.timer = timer - return res - def _process_domain_and_bounds( self, var, var_id, mutable_lbs, mutable_ubs, ndx, gurobipy_var ): @@ -438,13 +378,13 @@ def _process_domain_and_bounds( return lb, ub, vtype - def _add_variables(self, variables: List[_GeneralVarData]): - var_names = list() - vtypes = list() - lbs = list() - ubs = list() - mutable_lbs = dict() - mutable_ubs = dict() + def _add_variables(self, variables: List[VarData]): + var_names = [] + vtypes = [] + lbs = [] + ubs = [] + mutable_lbs = {} + mutable_ubs = {} for ndx, var in enumerate(variables): varname = self._symbol_map.getSymbol(var, self._labeler) lb, ub, vtype = self._process_domain_and_bounds( @@ -469,22 +409,22 @@ def _add_variables(self, variables: List[_GeneralVarData]): self._vars_added_since_update.update(variables) self._needs_updated = True - def _add_parameters(self, params: List[_ParamData]): + def _add_parameters(self, params: List[ParamData]): pass def _reinit(self): saved_config = self.config - saved_tmp_config = self._config - self.__init__() + saved_tmp_config = self._active_config + self.__init__(treat_fixed_vars_as_params=self._treat_fixed_vars_as_params) self.config = saved_config - self._config = saved_tmp_config + self._active_config = saved_tmp_config def set_instance(self, model): if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() if not self.available(): c = self.__class__ - raise PyomoException( + raise ApplicationError( f'Solver {c.__module__}.{c.__qualname__} is not available ' f'({self.available()}).' ) @@ -506,18 +446,18 @@ def set_instance(self, model): self.set_objective(None) def _get_expr_from_pyomo_expr(self, expr): - mutable_linear_coefficients = list() - mutable_quadratic_coefficients = list() + mutable_linear_coefficients = [] + mutable_quadratic_coefficients = [] repn = generate_standard_repn(expr, quadratic=True, compute_values=False) degree = repn.polynomial_degree() if (degree is None) or (degree > 2): - raise DegreeError( - 'GurobiAuto does not support expressions of degree {0}.'.format(degree) + raise IncompatibleModelError( + f'GurobiAuto does not support expressions of degree {degree}.' ) if len(repn.linear_vars) > 0: - linear_coef_vals = list() + linear_coef_vals = [] for ndx, coef in enumerate(repn.linear_coefs): if not is_constant(coef): mutable_linear_coefficient = _MutableLinearCoefficient() @@ -555,7 +495,7 @@ def _get_expr_from_pyomo_expr(self, expr): mutable_quadratic_coefficients, ) - def _add_constraints(self, cons: List[_GeneralConstraintData]): + def _add_constraints(self, cons: List[ConstraintData]): for con in cons: conname = self._symbol_map.getSymbol(con, self._labeler) ( @@ -622,7 +562,7 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): else: raise ValueError( "Constraint does not have a lower " - "or an upper bound: {0} \n".format(con) + f"or an upper bound: {con} \n" ) for tmp in mutable_linear_coefficients: tmp.con = gurobipy_con @@ -658,7 +598,7 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): else: raise ValueError( "Constraint does not have a lower " - "or an upper bound: {0} \n".format(con) + f"or an upper bound: {con} \n" ) if ( len(mutable_linear_coefficients) > 0 @@ -677,7 +617,7 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): self._mutable_quadratic_helpers[con] = mutable_quadratic_constraint else: raise ValueError( - 'Unrecognized Gurobi expression type: ' + str(gurobi_expr.__class__) + f'Unrecognized Gurobi expression type: {str(gurobi_expr.__class__)}' ) self._pyomo_con_to_solver_con_map[con] = gurobipy_con @@ -685,7 +625,7 @@ def _add_constraints(self, cons: List[_GeneralConstraintData]): self._constraints_added_since_update.update(cons) self._needs_updated = True - def _add_sos_constraints(self, cons: List[_SOSConstraintData]): + def _add_sos_constraints(self, cons: List[SOSConstraintData]): for con in cons: conname = self._symbol_map.getSymbol(con, self._labeler) level = con.level @@ -695,7 +635,7 @@ def _add_sos_constraints(self, cons: List[_SOSConstraintData]): sos_type = gurobipy.GRB.SOS_TYPE2 else: raise ValueError( - "Solver does not support SOS level {0} constraints".format(level) + f"Solver does not support SOS level {level} constraints" ) gurobi_vars = [] @@ -711,7 +651,7 @@ def _add_sos_constraints(self, cons: List[_SOSConstraintData]): self._constraints_added_since_update.update(cons) self._needs_updated = True - def _remove_constraints(self, cons: List[_GeneralConstraintData]): + def _remove_constraints(self, cons: List[ConstraintData]): for con in cons: if con in self._constraints_added_since_update: self._update_gurobi_model() @@ -725,7 +665,7 @@ def _remove_constraints(self, cons: List[_GeneralConstraintData]): self._mutable_quadratic_helpers.pop(con, None) self._needs_updated = True - def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): for con in cons: if con in self._constraints_added_since_update: self._update_gurobi_model() @@ -735,7 +675,7 @@ def _remove_sos_constraints(self, cons: List[_SOSConstraintData]): del self._pyomo_sos_to_solver_sos_map[con] self._needs_updated = True - def _remove_variables(self, variables: List[_GeneralVarData]): + def _remove_variables(self, variables: List[VarData]): for var in variables: v_id = id(var) if var in self._vars_added_since_update: @@ -747,17 +687,15 @@ def _remove_variables(self, variables: List[_GeneralVarData]): self._mutable_bounds.pop(v_id, None) self._needs_updated = True - def _remove_parameters(self, params: List[_ParamData]): + def _remove_parameters(self, params: List[ParamData]): pass - def _update_variables(self, variables: List[_GeneralVarData]): + def _update_variables(self, variables: List[VarData]): for var in variables: var_id = id(var) if var_id not in self._pyomo_var_to_solver_var_map: raise ValueError( - 'The Var provided to update_var needs to be added first: {0}'.format( - var - ) + f'The Var provided to update_var needs to be added first: {var}' ) self._mutable_bounds.pop((var_id, 'lb'), None) self._mutable_bounds.pop((var_id, 'ub'), None) @@ -811,17 +749,15 @@ def _set_objective(self, obj): sense = gurobipy.GRB.MINIMIZE gurobi_expr = 0 repn_constant = 0 - mutable_linear_coefficients = list() - mutable_quadratic_coefficients = list() + mutable_linear_coefficients = [] + mutable_quadratic_coefficients = [] else: if obj.sense == minimize: sense = gurobipy.GRB.MINIMIZE elif obj.sense == maximize: sense = gurobipy.GRB.MAXIMIZE else: - raise ValueError( - 'Objective sense is not recognized: {0}'.format(obj.sense) - ) + raise ValueError(f'Objective sense is not recognized: {obj.sense}') ( gurobi_expr, @@ -849,7 +785,7 @@ def _set_objective(self, obj): self._needs_updated = True def _postsolve(self, timer: HierarchicalTimer): - config = self._config + config = self._active_config gprob = self._solver_model grb = gurobipy.GRB @@ -905,9 +841,7 @@ def _postsolve(self, timer: HierarchicalTimer): != TerminationCondition.convergenceCriteriaSatisfied and config.raise_exception_on_nonoptimal_result ): - raise RuntimeError( - 'Solver did not find the optimal solution. Set opt.config.raise_exception_on_nonoptimal_result = False to bypass this error.' - ) + raise NoOptimalSolutionError() results.incumbent_objective = None results.objective_bound = None @@ -936,12 +870,7 @@ def _postsolve(self, timer: HierarchicalTimer): if gprob.SolCount > 0: self._load_vars() else: - raise RuntimeError( - 'A feasible solution was not found, so no solution can be loaded.' - 'Please set opt.config.load_solutions=False and check ' - 'results.solution_status and ' - 'results.incumbent_objective before loading a solution.' - ) + raise NoFeasibleSolutionError() timer.stop('load solution') return results @@ -980,10 +909,7 @@ def _get_primals(self, vars_to_load=None, solution_number=0): self._update_gurobi_model() # this is needed to ensure that solutions cannot be loaded after the model has been changed if self._solver_model.SolCount == 0: - raise RuntimeError( - 'Solver does not currently have a valid solution. Please ' - 'check the termination condition.' - ) + raise NoSolutionError() var_map = self._pyomo_var_to_solver_var_map ref_vars = self._referenced_variables @@ -996,28 +922,23 @@ def _get_primals(self, vars_to_load=None, solution_number=0): return self._load_suboptimal_mip_solution( vars_to_load=vars_to_load, solution_number=solution_number ) - else: - gurobi_vars_to_load = [ - var_map[pyomo_var_id] for pyomo_var_id in vars_to_load - ] - vals = self._solver_model.getAttr("X", gurobi_vars_to_load) - - res = ComponentMap() - for var_id, val in zip(vars_to_load, vals): - using_cons, using_sos, using_obj = ref_vars[var_id] - if using_cons or using_sos or (using_obj is not None): - res[self._vars[var_id][0]] = val - return res + + gurobi_vars_to_load = [var_map[pyomo_var_id] for pyomo_var_id in vars_to_load] + vals = self._solver_model.getAttr("X", gurobi_vars_to_load) + + res = ComponentMap() + for var_id, val in zip(vars_to_load, vals): + using_cons, using_sos, using_obj = ref_vars[var_id] + if using_cons or using_sos or (using_obj is not None): + res[self._vars[var_id][0]] = val + return res def _get_reduced_costs(self, vars_to_load=None): if self._needs_updated: self._update_gurobi_model() if self._solver_model.Status != gurobipy.GRB.OPTIMAL: - raise RuntimeError( - 'Solver does not currently have valid reduced costs. Please ' - 'check the termination condition.' - ) + raise NoReducedCostsError() var_map = self._pyomo_var_to_solver_var_map ref_vars = self._referenced_variables @@ -1042,14 +963,11 @@ def _get_duals(self, cons_to_load=None): self._update_gurobi_model() if self._solver_model.Status != gurobipy.GRB.OPTIMAL: - raise RuntimeError( - 'Solver does not currently have valid duals. Please ' - 'check the termination condition.' - ) + raise NoDualsError() con_map = self._pyomo_con_to_solver_con_map reverse_con_map = self._solver_con_to_pyomo_con_map - dual = dict() + dual = {} if cons_to_load is None: linear_cons_to_load = self._solver_model.getConstrs() @@ -1083,7 +1001,7 @@ def _get_duals(self, cons_to_load=None): def update(self, timer: HierarchicalTimer = None): if self._needs_updated: self._update_gurobi_model() - super(Gurobi, self).update(timer=timer) + super().update(timer=timer) self._update_gurobi_model() def _update_gurobi_model(self): @@ -1125,7 +1043,7 @@ def set_linear_constraint_attr(self, con, attr, val): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be modified. attr: str @@ -1138,9 +1056,9 @@ def set_linear_constraint_attr(self, con, attr, val): """ if attr in {'Sense', 'RHS', 'ConstrName'}: raise ValueError( - 'Linear constraint attr {0} cannot be set with'.format(attr) - + ' the set_linear_constraint_attr method. Please use' - + ' the remove_constraint and add_constraint methods.' + f'Linear constraint attr {attr} cannot be set with' + ' the set_linear_constraint_attr method. Please use' + ' the remove_constraint and add_constraint methods.' ) self._pyomo_con_to_solver_con_map[con].setAttr(attr, val) self._needs_updated = True @@ -1151,7 +1069,7 @@ def set_var_attr(self, var, attr, val): Parameters ---------- - var: pyomo.core.base.var._GeneralVarData + var: pyomo.core.base.var.VarData The pyomo var for which the corresponding gurobi var attribute should be modified. attr: str @@ -1167,15 +1085,15 @@ def set_var_attr(self, var, attr, val): """ if attr in {'LB', 'UB', 'VType', 'VarName'}: raise ValueError( - 'Var attr {0} cannot be set with'.format(attr) - + ' the set_var_attr method. Please use' - + ' the update_var method.' + f'Var attr {attr} cannot be set with' + ' the set_var_attr method. Please use' + ' the update_var method.' ) if attr == 'Obj': raise ValueError( 'Var attr Obj cannot be set with' - + ' the set_var_attr method. Please use' - + ' the set_objective method.' + ' the set_var_attr method. Please use' + ' the set_objective method.' ) self._pyomo_var_to_solver_var_map[id(var)].setAttr(attr, val) self._needs_updated = True @@ -1186,7 +1104,7 @@ def get_var_attr(self, var, attr): Parameters ---------- - var: pyomo.core.base.var._GeneralVarData + var: pyomo.core.base.var.VarData The pyomo var for which the corresponding gurobi var attribute should be retrieved. attr: str @@ -1202,7 +1120,7 @@ def get_linear_constraint_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be retrieved. attr: str @@ -1218,7 +1136,7 @@ def get_sos_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.sos._SOSConstraintData + con: pyomo.core.base.sos.SOSConstraintData The pyomo SOS constraint for which the corresponding gurobi SOS constraint attribute should be retrieved. attr: str @@ -1234,7 +1152,7 @@ def get_quadratic_constraint_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be retrieved. attr: str @@ -1308,15 +1226,15 @@ def set_callback(self, func=None): as an MILP using extended cutting planes in callbacks. >>> from gurobipy import GRB # doctest:+SKIP - >>> import pyomo.environ as pe + >>> import pyomo.environ as pyo >>> from pyomo.core.expr.taylor_series import taylor_series_expansion >>> from pyomo.contrib import appsi >>> - >>> m = pe.ConcreteModel() - >>> m.x = pe.Var(bounds=(0, 4)) - >>> m.y = pe.Var(within=pe.Integers, bounds=(0, None)) - >>> m.obj = pe.Objective(expr=2*m.x + m.y) - >>> m.cons = pe.ConstraintList() # for the cutting planes + >>> m = pyo.ConcreteModel() + >>> m.x = pyo.Var(bounds=(0, 4)) + >>> m.y = pyo.Var(within=pyo.Integers, bounds=(0, None)) + >>> m.obj = pyo.Objective(expr=2*m.x + m.y) + >>> m.cons = pyo.ConstraintList() # for the cutting planes >>> >>> def _add_cut(xval): ... # a function to generate the cut @@ -1334,7 +1252,7 @@ def set_callback(self, func=None): >>> >>> def my_callback(cb_m, cb_opt, cb_where): ... if cb_where == GRB.Callback.MIPSOL: - ... cb_opt.cbGetSolution(vars=[m.x, m.y]) + ... cb_opt.cbGetSolution(variables=[m.x, m.y]) ... if m.y.value < (m.x.value - 2)**2 - 1e-6: ... cb_opt.cbLazy(_add_cut(m.x.value)) ... @@ -1355,7 +1273,7 @@ def cbCut(self, con): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The cut to add """ if not con.active: @@ -1375,14 +1293,10 @@ def cbCut(self, con): if con.has_ub(): raise ValueError('Range constraints are not supported in cbCut.') if not is_fixed(con.lower): - raise ValueError( - 'Lower bound of constraint {0} is not constant.'.format(con) - ) + raise ValueError(f'Lower bound of constraint {con} is not constant.') if con.has_ub(): if not is_fixed(con.upper): - raise ValueError( - 'Upper bound of constraint {0} is not constant.'.format(con) - ) + raise ValueError(f'Upper bound of constraint {con} is not constant.') if con.equality: self._solver_model.cbCut( @@ -1404,43 +1318,43 @@ def cbCut(self, con): ) else: raise ValueError( - 'Constraint does not have a lower or an upper bound {0} \n'.format(con) + f'Constraint does not have a lower or an upper bound {con} \n' ) def cbGet(self, what): return self._solver_model.cbGet(what) - def cbGetNodeRel(self, vars): + def cbGetNodeRel(self, variables): """ Parameters ---------- - vars: Var or iterable of Var + variables: Var or iterable of Var """ - if not isinstance(vars, Iterable): - vars = [vars] - gurobi_vars = [self._pyomo_var_to_solver_var_map[id(i)] for i in vars] + if not isinstance(variables, Iterable): + variables = [variables] + gurobi_vars = [self._pyomo_var_to_solver_var_map[id(i)] for i in variables] var_values = self._solver_model.cbGetNodeRel(gurobi_vars) - for i, v in enumerate(vars): + for i, v in enumerate(variables): v.set_value(var_values[i], skip_validation=True) - def cbGetSolution(self, vars): + def cbGetSolution(self, variables): """ Parameters ---------- - vars: iterable of vars + variables: iterable of vars """ - if not isinstance(vars, Iterable): - vars = [vars] - gurobi_vars = [self._pyomo_var_to_solver_var_map[id(i)] for i in vars] + if not isinstance(variables, Iterable): + variables = [variables] + gurobi_vars = [self._pyomo_var_to_solver_var_map[id(i)] for i in variables] var_values = self._solver_model.cbGetSolution(gurobi_vars) - for i, v in enumerate(vars): + for i, v in enumerate(variables): v.set_value(var_values[i], skip_validation=True) def cbLazy(self, con): """ Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The lazy constraint to add """ if not con.active: @@ -1460,14 +1374,10 @@ def cbLazy(self, con): if con.has_ub(): raise ValueError('Range constraints are not supported in cbLazy.') if not is_fixed(con.lower): - raise ValueError( - 'Lower bound of constraint {0} is not constant.'.format(con) - ) + raise ValueError(f'Lower bound of constraint {con} is not constant.') if con.has_ub(): if not is_fixed(con.upper): - raise ValueError( - 'Upper bound of constraint {0} is not constant.'.format(con) - ) + raise ValueError(f'Upper bound of constraint {con} is not constant.') if con.equality: self._solver_model.cbLazy( @@ -1489,13 +1399,13 @@ def cbLazy(self, con): ) else: raise ValueError( - 'Constraint does not have a lower or an upper bound {0} \n'.format(con) + f'Constraint does not have a lower or an upper bound {con} \n' ) - def cbSetSolution(self, vars, solution): - if not isinstance(vars, Iterable): - vars = [vars] - gurobi_vars = [self._pyomo_var_to_solver_var_map[id(i)] for i in vars] + def cbSetSolution(self, variables, solution): + if not isinstance(variables, Iterable): + variables = [variables] + gurobi_vars = [self._pyomo_var_to_solver_var_map[id(i)] for i in variables] self._solver_model.cbSetSolution(gurobi_vars, solution) def cbUseSolution(self): diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py new file mode 100644 index 00000000000..3facfc00d13 --- /dev/null +++ b/pyomo/contrib/solver/solvers/highs.py @@ -0,0 +1,681 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging +import io +from typing import List, Optional + +from pyomo.common.collections import ComponentMap +from pyomo.common.dependencies import attempt_import +from pyomo.common.errors import ApplicationError +from pyomo.common.tee import TeeStream, capture_output +from pyomo.core.kernel.objective import minimize, maximize +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.sos import SOSConstraintData +from pyomo.core.base.param import ParamData +from pyomo.core.expr.numvalue import value, is_constant +from pyomo.repn import generate_standard_repn +from pyomo.core.expr.numeric_expr import NPV_MaxExpression, NPV_MinExpression +from pyomo.common.dependencies import numpy as np +from pyomo.core.staleflag import StaleFlagManager + +from pyomo.contrib.solver.common.base import PersistentSolverBase, Availability +from pyomo.contrib.solver.common.results import ( + Results, + TerminationCondition, + SolutionStatus, +) +from pyomo.contrib.solver.common.config import PersistentBranchAndBoundConfig +from pyomo.contrib.solver.common.persistent import ( + PersistentSolverUtils, + PersistentSolverMixin, +) +from pyomo.contrib.solver.common.solution_loader import PersistentSolutionLoader +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, + NoDualsError, + NoReducedCostsError, + NoSolutionError, + IncompatibleModelError, +) + +logger = logging.getLogger(__name__) + +highspy, highspy_available = attempt_import('highspy') + + +class _MutableVarBounds: + def __init__(self, lower_expr, upper_expr, pyomo_var_id, var_map, highs): + self.pyomo_var_id = pyomo_var_id + self.lower_expr = lower_expr + self.upper_expr = upper_expr + self.var_map = var_map + self.highs = highs + + def update(self): + col_ndx = self.var_map[self.pyomo_var_id] + lb = value(self.lower_expr) + ub = value(self.upper_expr) + self.highs.changeColBounds(col_ndx, lb, ub) + + +class _MutableLinearCoefficient: + def __init__(self, pyomo_con, pyomo_var_id, con_map, var_map, expr, highs): + self.expr = expr + self.highs = highs + self.pyomo_var_id = pyomo_var_id + self.pyomo_con = pyomo_con + self.con_map = con_map + self.var_map = var_map + + def update(self): + row_ndx = self.con_map[self.pyomo_con] + col_ndx = self.var_map[self.pyomo_var_id] + self.highs.changeCoeff(row_ndx, col_ndx, value(self.expr)) + + +class _MutableObjectiveCoefficient: + def __init__(self, pyomo_var_id, var_map, expr, highs): + self.expr = expr + self.highs = highs + self.pyomo_var_id = pyomo_var_id + self.var_map = var_map + + def update(self): + col_ndx = self.var_map[self.pyomo_var_id] + self.highs.changeColCost(col_ndx, value(self.expr)) + + +class _MutableObjectiveOffset: + def __init__(self, expr, highs): + self.expr = expr + self.highs = highs + + def update(self): + self.highs.changeObjectiveOffset(value(self.expr)) + + +class _MutableConstraintBounds: + def __init__(self, lower_expr, upper_expr, pyomo_con, con_map, highs): + self.lower_expr = lower_expr + self.upper_expr = upper_expr + self.con = pyomo_con + self.con_map = con_map + self.highs = highs + + def update(self): + row_ndx = self.con_map[self.con] + lb = value(self.lower_expr) + ub = value(self.upper_expr) + self.highs.changeRowBounds(row_ndx, lb, ub) + + +class Highs(PersistentSolverMixin, PersistentSolverUtils, PersistentSolverBase): + """ + Interface to HiGHS + """ + + CONFIG = PersistentBranchAndBoundConfig() + + _available = None + + def __init__(self, **kwds): + treat_fixed_vars_as_params = kwds.pop('treat_fixed_vars_as_params', True) + PersistentSolverBase.__init__(self, **kwds) + PersistentSolverUtils.__init__( + self, treat_fixed_vars_as_params=treat_fixed_vars_as_params + ) + self._solver_model = None + self._pyomo_var_to_solver_var_map = {} + self._pyomo_con_to_solver_con_map = {} + self._solver_con_to_pyomo_con_map = {} + self._mutable_helpers = {} + self._mutable_bounds = {} + self._objective_helpers = [] + self._last_results_object: Optional[Results] = None + self._sol = None + + def available(self): + if highspy_available: + return Availability.FullLicense + return Availability.NotFound + + def version(self): + try: + version = ( + highspy.HIGHS_VERSION_MAJOR, + highspy.HIGHS_VERSION_MINOR, + highspy.HIGHS_VERSION_PATCH, + ) + except AttributeError: + # Older versions of Highs do not have the above attributes + # and the solver version can only be obtained by making + # an instance of the solver class. + tmp = highspy.Highs() + version = (tmp.versionMajor(), tmp.versionMinor(), tmp.versionPatch()) + + return version + + def _solve(self): + config = self._active_config + timer = config.timer + options = config.solver_options + ostreams = [io.StringIO()] + config.tee + + with capture_output(output=TeeStream(*ostreams), capture_fd=True): + self._solver_model.setOptionValue('log_to_console', True) + + if config.threads is not None: + self._solver_model.setOptionValue('threads', config.threads) + if config.time_limit is not None: + self._solver_model.setOptionValue('time_limit', config.time_limit) + if config.rel_gap is not None: + self._solver_model.setOptionValue('mip_rel_gap', config.rel_gap) + if config.abs_gap is not None: + self._solver_model.setOptionValue('mip_abs_gap', config.abs_gap) + + for key, option in options.items(): + self._solver_model.setOptionValue(key, option) + timer.start('optimize') + if self.version()[:2] >= (1, 8): + self._solver_model.HandleKeyboardInterrupt = True + self._solver_model.run() + timer.stop('optimize') + + return self._postsolve() + + def _process_domain_and_bounds(self, var_id): + _v, _lb, _ub, _fixed, _domain_interval, _value = self._vars[var_id] + lb, ub, step = _domain_interval + if lb is None: + lb = -highspy.kHighsInf + if ub is None: + ub = highspy.kHighsInf + if step == 0: + vtype = highspy.HighsVarType.kContinuous + elif step == 1: + vtype = highspy.HighsVarType.kInteger + else: + raise ValueError( + f'Unrecognized domain step: {step} (should be either 0 or 1)' + ) + if _fixed: + lb = _value + ub = _value + else: + if _lb is not None or _ub is not None: + if not is_constant(_lb) or not is_constant(_ub): + if _lb is None: + tmp_lb = -highspy.kHighsInf + else: + tmp_lb = _lb + if _ub is None: + tmp_ub = highspy.kHighsInf + else: + tmp_ub = _ub + mutable_bound = _MutableVarBounds( + lower_expr=NPV_MaxExpression((tmp_lb, lb)), + upper_expr=NPV_MinExpression((tmp_ub, ub)), + pyomo_var_id=var_id, + var_map=self._pyomo_var_to_solver_var_map, + highs=self._solver_model, + ) + self._mutable_bounds[var_id] = (_v, mutable_bound) + if _lb is not None: + lb = max(value(_lb), lb) + if _ub is not None: + ub = min(value(_ub), ub) + + return lb, ub, vtype + + def _add_variables(self, variables: List[VarData]): + self._sol = None + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + lbs = [] + ubs = [] + indices = [] + vtypes = [] + + current_num_vars = len(self._pyomo_var_to_solver_var_map) + for v in variables: + v_id = id(v) + lb, ub, vtype = self._process_domain_and_bounds(v_id) + lbs.append(lb) + ubs.append(ub) + vtypes.append(vtype) + indices.append(current_num_vars) + self._pyomo_var_to_solver_var_map[v_id] = current_num_vars + current_num_vars += 1 + + self._solver_model.addVars( + len(lbs), np.array(lbs, dtype=np.double), np.array(ubs, dtype=np.double) + ) + self._solver_model.changeColsIntegrality( + len(vtypes), np.array(indices), np.array(vtypes) + ) + + def _add_parameters(self, params: List[ParamData]): + pass + + def _reinit(self): + saved_config = self.config + saved_active_config = self._active_config + self.__init__(treat_fixed_vars_as_params=self._treat_fixed_vars_as_params) + self.config = saved_config + self._active_config = saved_active_config + + def set_instance(self, model): + config = self._active_config + ostreams = config.tee + + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + if not self.available(): + c = self.__class__ + raise ApplicationError( + f'Solver {c.__module__}.{c.__qualname__} is not available ' + f'({self.available()}).' + ) + + with TeeStream(*ostreams) as t, capture_output(t.STDOUT, capture_fd=True): + self._reinit() + self._model = model + + self._solver_model = highspy.Highs() + self.add_block(model) + if self._objective is None: + self.set_objective(None) + + def _add_constraints(self, cons: List[ConstraintData]): + self._sol = None + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + current_num_cons = len(self._pyomo_con_to_solver_con_map) + lbs = [] + ubs = [] + starts = [] + var_indices = [] + coef_values = [] + + for con in cons: + repn = generate_standard_repn( + con.body, quadratic=False, compute_values=False + ) + if repn.nonlinear_expr is not None: + raise IncompatibleModelError( + f'Highs interface does not support expressions of degree {repn.polynomial_degree()}' + ) + + starts.append(len(coef_values)) + for ndx, coef in enumerate(repn.linear_coefs): + v = repn.linear_vars[ndx] + v_id = id(v) + coef_val = value(coef) + if not is_constant(coef): + mutable_linear_coefficient = _MutableLinearCoefficient( + pyomo_con=con, + pyomo_var_id=v_id, + con_map=self._pyomo_con_to_solver_con_map, + var_map=self._pyomo_var_to_solver_var_map, + expr=coef, + highs=self._solver_model, + ) + if con not in self._mutable_helpers: + self._mutable_helpers[con] = [] + self._mutable_helpers[con].append(mutable_linear_coefficient) + if coef_val == 0: + continue + var_indices.append(self._pyomo_var_to_solver_var_map[v_id]) + coef_values.append(coef_val) + + if con.has_lb(): + lb = con.lower - repn.constant + else: + lb = -highspy.kHighsInf + if con.has_ub(): + ub = con.upper - repn.constant + else: + ub = highspy.kHighsInf + + if not is_constant(lb) or not is_constant(ub): + mutable_con_bounds = _MutableConstraintBounds( + lower_expr=lb, + upper_expr=ub, + pyomo_con=con, + con_map=self._pyomo_con_to_solver_con_map, + highs=self._solver_model, + ) + if con not in self._mutable_helpers: + self._mutable_helpers[con] = [mutable_con_bounds] + else: + self._mutable_helpers[con].append(mutable_con_bounds) + + lbs.append(value(lb)) + ubs.append(value(ub)) + self._pyomo_con_to_solver_con_map[con] = current_num_cons + self._solver_con_to_pyomo_con_map[current_num_cons] = con + current_num_cons += 1 + + self._solver_model.addRows( + len(lbs), + np.array(lbs, dtype=np.double), + np.array(ubs, dtype=np.double), + len(coef_values), + np.array(starts), + np.array(var_indices), + np.array(coef_values, dtype=np.double), + ) + + def _add_sos_constraints(self, cons: List[SOSConstraintData]): + if cons: + raise NotImplementedError( + 'Highs interface does not support SOS constraints' + ) + + def _remove_constraints(self, cons: List[ConstraintData]): + self._sol = None + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + indices_to_remove = [] + for con in cons: + con_ndx = self._pyomo_con_to_solver_con_map.pop(con) + del self._solver_con_to_pyomo_con_map[con_ndx] + indices_to_remove.append(con_ndx) + self._mutable_helpers.pop(con, None) + self._solver_model.deleteRows( + len(indices_to_remove), np.array(list(sorted(indices_to_remove))) + ) + con_ndx = 0 + new_con_map = {} + for c in self._pyomo_con_to_solver_con_map: + new_con_map[c] = con_ndx + con_ndx += 1 + self._pyomo_con_to_solver_con_map.clear() + self._pyomo_con_to_solver_con_map.update(new_con_map) + self._solver_con_to_pyomo_con_map.clear() + self._solver_con_to_pyomo_con_map.update( + {v: k for k, v in self._pyomo_con_to_solver_con_map.items()} + ) + + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + if cons: + raise NotImplementedError( + 'Highs interface does not support SOS constraints' + ) + + def _remove_variables(self, variables: List[VarData]): + self._sol = None + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + indices_to_remove = [] + for v in variables: + v_id = id(v) + v_ndx = self._pyomo_var_to_solver_var_map.pop(v_id) + indices_to_remove.append(v_ndx) + self._mutable_bounds.pop(v_id, None) + indices_to_remove.sort() + self._solver_model.deleteVars( + len(indices_to_remove), np.array(list(sorted(indices_to_remove))) + ) + v_ndx = 0 + new_var_map = {} + for v_id in self._pyomo_var_to_solver_var_map: + new_var_map[v_id] = v_ndx + v_ndx += 1 + self._pyomo_var_to_solver_var_map.clear() + self._pyomo_var_to_solver_var_map.update(new_var_map) + + def _remove_parameters(self, params: List[ParamData]): + pass + + def _update_variables(self, variables: List[VarData]): + self._sol = None + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + indices = [] + lbs = [] + ubs = [] + vtypes = [] + + for v in variables: + v_id = id(v) + self._mutable_bounds.pop(v_id, None) + v_ndx = self._pyomo_var_to_solver_var_map[v_id] + lb, ub, vtype = self._process_domain_and_bounds(v_id) + lbs.append(lb) + ubs.append(ub) + vtypes.append(vtype) + indices.append(v_ndx) + + self._solver_model.changeColsBounds( + len(indices), + np.array(indices), + np.array(lbs, dtype=np.double), + np.array(ubs, dtype=np.double), + ) + self._solver_model.changeColsIntegrality( + len(indices), np.array(indices), np.array(vtypes) + ) + + def update_parameters(self): + self._sol = None + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + for con, helpers in self._mutable_helpers.items(): + for helper in helpers: + helper.update() + for k, (v, helper) in self._mutable_bounds.items(): + helper.update() + for helper in self._objective_helpers: + helper.update() + + def _set_objective(self, obj): + self._sol = None + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + n = len(self._pyomo_var_to_solver_var_map) + indices = np.arange(n) + costs = np.zeros(n, dtype=np.double) + self._objective_helpers = [] + if obj is None: + sense = highspy.ObjSense.kMinimize + self._solver_model.changeObjectiveOffset(0) + else: + if obj.sense == minimize: + sense = highspy.ObjSense.kMinimize + elif obj.sense == maximize: + sense = highspy.ObjSense.kMaximize + else: + raise ValueError(f'Objective sense is not recognized: {obj.sense}') + + repn = generate_standard_repn( + obj.expr, quadratic=False, compute_values=False + ) + if repn.nonlinear_expr is not None: + raise IncompatibleModelError( + f'Highs interface does not support expressions of degree {repn.polynomial_degree()}' + ) + + for coef, v in zip(repn.linear_coefs, repn.linear_vars): + v_id = id(v) + v_ndx = self._pyomo_var_to_solver_var_map[v_id] + costs[v_ndx] = value(coef) + if not is_constant(coef): + mutable_objective_coef = _MutableObjectiveCoefficient( + pyomo_var_id=v_id, + var_map=self._pyomo_var_to_solver_var_map, + expr=coef, + highs=self._solver_model, + ) + self._objective_helpers.append(mutable_objective_coef) + + self._solver_model.changeObjectiveOffset(value(repn.constant)) + if not is_constant(repn.constant): + mutable_objective_offset = _MutableObjectiveOffset( + expr=repn.constant, highs=self._solver_model + ) + self._objective_helpers.append(mutable_objective_offset) + + self._solver_model.changeObjectiveSense(sense) + self._solver_model.changeColsCost(n, indices, costs) + + def _postsolve(self): + config = self._active_config + timer = config.timer + timer.start('load solution') + + highs = self._solver_model + status = highs.getModelStatus() + + results = Results() + results.solution_loader = PersistentSolutionLoader(self) + results.timing_info.highs_time = highs.getRunTime() + + self._sol = highs.getSolution() + has_feasible_solution = self._sol.value_valid + if status == highspy.HighsModelStatus.kOptimal: + results.solution_status = SolutionStatus.optimal + elif has_feasible_solution: + results.solution_status = SolutionStatus.feasible + else: + results.solution_status = SolutionStatus.noSolution + + if status == highspy.HighsModelStatus.kNotset: + results.termination_condition = TerminationCondition.unknown + elif status == highspy.HighsModelStatus.kLoadError: + results.termination_condition = TerminationCondition.error + elif status == highspy.HighsModelStatus.kModelError: + results.termination_condition = TerminationCondition.error + elif status == highspy.HighsModelStatus.kPresolveError: + results.termination_condition = TerminationCondition.error + elif status == highspy.HighsModelStatus.kSolveError: + results.termination_condition = TerminationCondition.error + elif status == highspy.HighsModelStatus.kPostsolveError: + results.termination_condition = TerminationCondition.error + elif status == highspy.HighsModelStatus.kModelEmpty: + results.termination_condition = TerminationCondition.unknown + elif status == highspy.HighsModelStatus.kOptimal: + results.termination_condition = ( + TerminationCondition.convergenceCriteriaSatisfied + ) + elif status == highspy.HighsModelStatus.kInfeasible: + results.termination_condition = TerminationCondition.provenInfeasible + elif status == highspy.HighsModelStatus.kUnboundedOrInfeasible: + results.termination_condition = TerminationCondition.infeasibleOrUnbounded + elif status == highspy.HighsModelStatus.kUnbounded: + results.termination_condition = TerminationCondition.unbounded + elif status == highspy.HighsModelStatus.kObjectiveBound: + results.termination_condition = TerminationCondition.objectiveLimit + elif status == highspy.HighsModelStatus.kObjectiveTarget: + results.termination_condition = TerminationCondition.objectiveLimit + elif status == highspy.HighsModelStatus.kTimeLimit: + results.termination_condition = TerminationCondition.maxTimeLimit + elif status == highspy.HighsModelStatus.kIterationLimit: + results.termination_condition = TerminationCondition.iterationLimit + elif status == highspy.HighsModelStatus.kUnknown: + results.termination_condition = TerminationCondition.unknown + else: + results.termination_condition = TerminationCondition.unknown + + if ( + results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + and config.raise_exception_on_nonoptimal_result + ): + raise NoOptimalSolutionError() + + results.incumbent_objective = None + results.objective_bound = None + info = highs.getInfo() + if self._objective is not None: + if has_feasible_solution: + results.incumbent_objective = info.objective_function_value + if info.mip_node_count == -1: + if has_feasible_solution: + results.objective_bound = info.objective_function_value + else: + results.objective_bound = None + else: + results.objective_bound = info.mip_dual_bound + + if config.load_solutions: + if has_feasible_solution: + self._load_vars() + else: + raise NoFeasibleSolutionError() + timer.stop('load solution') + + return results + + def _load_vars(self, vars_to_load=None): + for v, val in self._get_primals(vars_to_load=vars_to_load).items(): + v.set_value(val, skip_validation=True) + StaleFlagManager.mark_all_as_stale(delayed=True) + + def _get_primals(self, vars_to_load=None): + if self._sol is None or not self._sol.value_valid: + raise NoSolutionError() + + res = ComponentMap() + if vars_to_load is None: + var_ids_to_load = [] + for v, ref_info in self._referenced_variables.items(): + using_cons, using_sos, using_obj = ref_info + if using_cons or using_sos or (using_obj is not None): + var_ids_to_load.append(v) + else: + var_ids_to_load = [id(v) for v in vars_to_load] + + var_vals = self._sol.col_value + + for v_id in var_ids_to_load: + v = self._vars[v_id][0] + v_ndx = self._pyomo_var_to_solver_var_map[v_id] + res[v] = var_vals[v_ndx] + + return res + + def _get_reduced_costs(self, vars_to_load=None): + if self._sol is None or not self._sol.dual_valid: + raise NoReducedCostsError() + res = ComponentMap() + if vars_to_load is None: + var_ids_to_load = list(self._vars.keys()) + else: + var_ids_to_load = [id(v) for v in vars_to_load] + + var_vals = self._sol.col_dual + + for v_id in var_ids_to_load: + v = self._vars[v_id][0] + v_ndx = self._pyomo_var_to_solver_var_map[v_id] + res[v] = var_vals[v_ndx] + + return res + + def _get_duals(self, cons_to_load=None): + if self._sol is None or not self._sol.dual_valid: + raise NoDualsError() + + res = {} + if cons_to_load is None: + cons_to_load = list(self._pyomo_con_to_solver_con_map.keys()) + + duals = self._sol.row_dual + + for c in cons_to_load: + c_ndx = self._pyomo_con_to_solver_con_map[c] + res[c] = duals[c_ndx] + + return res diff --git a/pyomo/contrib/solver/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py similarity index 80% rename from pyomo/contrib/solver/ipopt.py rename to pyomo/contrib/solver/solvers/ipopt.py index 3ac1a5ac4a2..d4aa41258c1 100644 --- a/pyomo/contrib/solver/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import logging import os import subprocess import datetime @@ -17,35 +18,39 @@ from pyomo.common import Executable from pyomo.common.config import ConfigValue, document_kwargs_from_configdict, ConfigDict -from pyomo.common.errors import PyomoException, DeveloperError +from pyomo.common.errors import ( + ApplicationError, + DeveloperError, + InfeasibleConstraintException, +) from pyomo.common.tempfiles import TempfileManager from pyomo.common.timing import HierarchicalTimer -from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.var import VarData from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.nl_writer import NLWriter, NLWriterInfo -from pyomo.contrib.solver.base import SolverBase -from pyomo.contrib.solver.config import SolverConfig -from pyomo.contrib.solver.factory import SolverFactory -from pyomo.contrib.solver.results import Results, TerminationCondition, SolutionStatus -from pyomo.contrib.solver.sol_reader import parse_sol_file -from pyomo.contrib.solver.solution import SolSolutionLoader +from pyomo.contrib.solver.common.base import SolverBase, Availability +from pyomo.contrib.solver.common.config import SolverConfig +from pyomo.contrib.solver.common.results import ( + Results, + TerminationCondition, + SolutionStatus, +) +from pyomo.contrib.solver.solvers.sol_reader import parse_sol_file, SolSolutionLoader +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, + NoSolutionError, +) from pyomo.common.tee import TeeStream from pyomo.core.expr.visitor import replace_expressions from pyomo.core.expr.numvalue import value from pyomo.core.base.suffix import Suffix from pyomo.common.collections import ComponentMap - -import logging +from pyomo.solvers.amplfunc_merge import amplfunc_merge logger = logging.getLogger(__name__) -class IpoptSolverError(PyomoException): - """ - General exception to catch solver system errors - """ - - class IpoptConfig(SolverConfig): def __init__( self, @@ -72,23 +77,14 @@ def __init__( ), ) self.writer_config: ConfigDict = self.declare( - 'writer_config', - ConfigValue( - default=NLWriter.CONFIG(), - description="Configuration that controls options in the NL writer.", - ), + 'writer_config', NLWriter.CONFIG() ) class IpoptSolutionLoader(SolSolutionLoader): - def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + def _error_check(self): if self._nl_info is None: - raise RuntimeError( - 'Solution loader does not currently have a valid solution. Please ' - 'check results.TerminationCondition and/or results.SolutionStatus.' - ) + raise NoSolutionError() if len(self._nl_info.eliminated_vars) > 0: raise NotImplementedError( 'For now, turn presolve off (opt.config.writer_config.linear_presolve=False) ' @@ -99,6 +95,11 @@ def get_reduced_costs( "Solution data is empty. This should not " "have happened. Report this error to the Pyomo Developers." ) + + def get_reduced_costs( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + self._error_check() if self._nl_info.scaling is None: scale_list = [1] * len(self._nl_info.variables) obj_scale = 1 @@ -109,7 +110,7 @@ def get_reduced_costs( nl_info = self._nl_info zl_map = sol_data.var_suffixes['ipopt_zL_out'] zu_map = sol_data.var_suffixes['ipopt_zU_out'] - rc = dict() + rc = {} for ndx, v in enumerate(nl_info.variables): scale = scale_list[ndx] v_id = id(v) @@ -198,7 +199,6 @@ def get_reduced_costs( } -@SolverFactory.register('ipopt_v2', doc='The ipopt NLP solver (new interface)') class Ipopt(SolverBase): CONFIG = IpoptConfig() @@ -207,6 +207,7 @@ def __init__(self, **kwds): self._writer = NLWriter() self._available_cache = None self._version_cache = None + self._version_timeout = 2 def available(self, config=None): if config is None: @@ -214,9 +215,9 @@ def available(self, config=None): pth = config.executable.path() if self._available_cache is None or self._available_cache[0] != pth: if pth is None: - self._available_cache = (None, self.Availability.NotFound) + self._available_cache = (None, Availability.NotFound) else: - self._available_cache = (pth, self.Availability.FullLicense) + self._available_cache = (pth, Availability.FullLicense) return self._available_cache[1] def version(self, config=None): @@ -229,10 +230,11 @@ def version(self, config=None): else: results = subprocess.run( [str(pth), '--version'], - timeout=1, + timeout=self._version_timeout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, + check=False, ) version = results.stdout.splitlines()[0] version = version.split(' ')[1].strip() @@ -240,6 +242,21 @@ def version(self, config=None): self._version_cache = (pth, version) return self._version_cache[1] + def has_linear_solver(self, linear_solver): + import pyomo.core as AML + + m = AML.ConcreteModel() + m.x = AML.Var() + m.o = AML.Objective(expr=(m.x - 2) ** 2) + results = self.solve( + m, + tee=False, + raise_exception_on_nonoptimal_result=False, + load_solutions=False, + solver_options={'linear_solver': linear_solver}, + ) + return 'running with linear solver' in results.solver_log + def _write_options_file(self, filename: str, options: Mapping): # First we need to determine if we even need to create a file. # If options is empty, then we return False @@ -252,7 +269,7 @@ def _write_options_file(self, filename: str, options: Mapping): for k, val in options.items(): if k not in ipopt_command_line_options: opt_file_exists = True - with open(filename + '.opt', 'a+') as opt_file: + with open(filename + '.opt', 'a+', encoding='utf-8') as opt_file: opt_file.write(str(k) + ' ' + str(val) + '\n') return opt_file_exists @@ -277,6 +294,7 @@ def _create_command_line(self, basename: str, config: IpoptConfig, opt_file: boo @document_kwargs_from_configdict(CONFIG) def solve(self, model, **kwds): + "Solve a model using Ipopt" # Begin time tracking start_timestamp = datetime.datetime.now(datetime.timezone.utc) # Update configuration options, based on keywords passed to solve @@ -284,13 +302,14 @@ def solve(self, model, **kwds): # Check if solver is available avail = self.available(config) if not avail: - raise IpoptSolverError( + raise ApplicationError( f'Solver {self.__class__} is not available ({avail}).' ) if config.threads: logger.log( logging.WARNING, - msg=f"The `threads` option was specified, but this is not used by {self.__class__}.", + msg="The `threads` option was specified, " + f"but this is not used by {self.__class__}.", ) if config.timer is None: timer = HierarchicalTimer() @@ -309,26 +328,39 @@ def solve(self, model, **kwds): raise RuntimeError( f"NL file with the same name {basename + '.nl'} already exists!" ) - with open(basename + '.nl', 'w') as nl_file, open( - basename + '.row', 'w' - ) as row_file, open(basename + '.col', 'w') as col_file: + # Note: the ASL has an issue where string constants written + # to the NL file (e.g. arguments in external functions) MUST + # be terminated with '\n' regardless of platform. We will + # disable universal newlines in the NL file to prevent + # Python from mapping those '\n' to '\r\n' on Windows. + with open( + basename + '.nl', 'w', newline='\n', encoding='utf-8' + ) as nl_file, open( + basename + '.row', 'w', encoding='utf-8' + ) as row_file, open( + basename + '.col', 'w', encoding='utf-8' + ) as col_file: timer.start('write_nl_file') self._writer.config.set_value(config.writer_config) - nl_info = self._writer.write( - model, - nl_file, - row_file, - col_file, - symbolic_solver_labels=config.symbolic_solver_labels, - ) + try: + nl_info = self._writer.write( + model, + nl_file, + row_file, + col_file, + symbolic_solver_labels=config.symbolic_solver_labels, + ) + proven_infeasible = False + except InfeasibleConstraintException: + proven_infeasible = True timer.stop('write_nl_file') - if len(nl_info.variables) > 0: + if not proven_infeasible and len(nl_info.variables) > 0: # Get a copy of the environment to pass to the subprocess env = os.environ.copy() if nl_info.external_function_libraries: - if env.get('AMPLFUNC'): - nl_info.external_function_libraries.append(env.get('AMPLFUNC')) - env['AMPLFUNC'] = "\n".join(nl_info.external_function_libraries) + env['AMPLFUNC'] = amplfunc_merge( + env, *nl_info.external_function_libraries + ) # Write the opt_file, if there should be one; return a bool to say # whether or not we have one (so we can correctly build the command line) opt_file = self._write_options_file( @@ -357,15 +389,22 @@ def solve(self, model, **kwds): universal_newlines=True, stdout=t.STDOUT, stderr=t.STDERR, + check=False, ) timer.stop('subprocess') # This is the stuff we need to parse to get the iterations # and time - iters, ipopt_time_nofunc, ipopt_time_func, ipopt_total_time = ( + (iters, ipopt_time_nofunc, ipopt_time_func, ipopt_total_time) = ( self._parse_ipopt_output(ostreams[0]) ) - if len(nl_info.variables) == 0: + if proven_infeasible: + results = Results() + results.termination_condition = TerminationCondition.provenInfeasible + results.solution_loader = SolSolutionLoader(None, None) + results.iteration_count = 0 + results.timing_info.total_seconds = 0 + elif len(nl_info.variables) == 0: if len(nl_info.eliminated_vars) == 0: results = Results() results.termination_condition = TerminationCondition.emptyModel @@ -381,7 +420,7 @@ def solve(self, model, **kwds): results.timing_info.total_seconds = 0 else: if os.path.isfile(basename + '.sol'): - with open(basename + '.sol', 'r') as sol_file: + with open(basename + '.sol', 'r', encoding='utf-8') as sol_file: timer.start('parse_sol') results = self._parse_solution(sol_file, nl_info) timer.stop('parse_sol') @@ -406,23 +445,14 @@ def solve(self, model, **kwds): config.raise_exception_on_nonoptimal_result and results.solution_status != SolutionStatus.optimal ): - raise RuntimeError( - 'Solver did not find the optimal solution. Set ' - 'opt.config.raise_exception_on_nonoptimal_result = False to bypass this error.' - ) + raise NoOptimalSolutionError() results.solver_name = self.name results.solver_version = self.version(config) - if ( - config.load_solutions - and results.solution_status == SolutionStatus.noSolution - ): - raise RuntimeError( - 'A feasible solution was not found, so no solution can be loaded.' - 'Please set opt.config.load_solutions=False to bypass this error.' - ) if config.load_solutions: + if results.solution_status == SolutionStatus.noSolution: + raise NoFeasibleSolutionError() results.solution_loader.load_vars() if ( hasattr(model, 'dual') @@ -456,8 +486,8 @@ def solve(self, model, **kwds): ) ) - results.solver_configuration = config - if len(nl_info.variables) > 0: + results.solver_config = config + if not proven_infeasible and len(nl_info.variables) > 0: results.solver_log = ostreams[0].getvalue() # Capture/record end-time / wall-time diff --git a/pyomo/contrib/solver/sol_reader.py b/pyomo/contrib/solver/solvers/sol_reader.py similarity index 53% rename from pyomo/contrib/solver/sol_reader.py rename to pyomo/contrib/solver/solvers/sol_reader.py index 41d840f8d07..e580e2a72f9 100644 --- a/pyomo/contrib/solver/sol_reader.py +++ b/pyomo/contrib/solver/solvers/sol_reader.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,28 +10,153 @@ # ___________________________________________________________________________ -from typing import Tuple, Dict, Any, List +from typing import Tuple, Dict, Any, List, Sequence, Optional, Mapping, NoReturn import io +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.var import VarData +from pyomo.core.expr import value +from pyomo.common.collections import ComponentMap +from pyomo.core.staleflag import StaleFlagManager from pyomo.common.errors import DeveloperError, PyomoException from pyomo.repn.plugins.nl_writer import NLWriterInfo -from pyomo.contrib.solver.results import Results, SolutionStatus, TerminationCondition +from pyomo.core.expr.visitor import replace_expressions +from pyomo.contrib.solver.common.results import ( + Results, + SolutionStatus, + TerminationCondition, +) +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase class SolFileData: + """ + Defines the data types found within a .sol file + """ + def __init__(self) -> None: - self.primals: List[float] = list() - self.duals: List[float] = list() - self.var_suffixes: Dict[str, Dict[int, Any]] = dict() - self.con_suffixes: Dict[str, Dict[Any]] = dict() - self.obj_suffixes: Dict[str, Dict[int, Any]] = dict() - self.problem_suffixes: Dict[str, List[Any]] = dict() - self.other: List(str) = list() + self.primals: List[float] = [] + self.duals: List[float] = [] + self.var_suffixes: Dict[str, Dict[int, Any]] = {} + self.con_suffixes: Dict[str, Dict[Any]] = {} + self.obj_suffixes: Dict[str, Dict[int, Any]] = {} + self.problem_suffixes: Dict[str, List[Any]] = {} + self.other: List(str) = [] + + +class SolSolutionLoader(SolutionLoaderBase): + """ + Loader for solvers that create .sol files (e.g., ipopt) + """ + + def __init__(self, sol_data: SolFileData, nl_info: NLWriterInfo) -> None: + self._sol_data = sol_data + self._nl_info = nl_info + + def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: + if self._nl_info is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + if self._sol_data is None: + assert len(self._nl_info.variables) == 0 + else: + if self._nl_info.scaling: + for var, val, scale in zip( + self._nl_info.variables, + self._sol_data.primals, + self._nl_info.scaling.variables, + ): + var.set_value(val / scale, skip_validation=True) + else: + for var, val in zip(self._nl_info.variables, self._sol_data.primals): + var.set_value(val, skip_validation=True) + + for var, v_expr in self._nl_info.eliminated_vars: + var.value = value(v_expr) + + StaleFlagManager.mark_all_as_stale(delayed=True) + + def get_primals( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + if self._nl_info is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + val_map = {} + if self._sol_data is None: + assert len(self._nl_info.variables) == 0 + else: + if self._nl_info.scaling is None: + scale_list = [1] * len(self._nl_info.variables) + else: + scale_list = self._nl_info.scaling.variables + for var, val, scale in zip( + self._nl_info.variables, self._sol_data.primals, scale_list + ): + val_map[id(var)] = val / scale + + for var, v_expr in self._nl_info.eliminated_vars: + val = replace_expressions(v_expr, substitution_map=val_map) + v_id = id(var) + val_map[v_id] = val + + res = ComponentMap() + if vars_to_load is None: + vars_to_load = self._nl_info.variables + [ + var for var, _ in self._nl_info.eliminated_vars + ] + for var in vars_to_load: + res[var] = val_map[id(var)] + + return res + + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: + if self._nl_info is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + if len(self._nl_info.eliminated_vars) > 0: + raise NotImplementedError( + 'For now, turn presolve off (opt.config.writer_config.linear_presolve=False) ' + 'to get dual variable values.' + ) + if self._sol_data is None: + raise DeveloperError( + "Solution data is empty. This should not " + "have happened. Report this error to the Pyomo Developers." + ) + res = {} + if self._nl_info.scaling is None: + scale_list = [1] * len(self._nl_info.constraints) + obj_scale = 1 + else: + scale_list = self._nl_info.scaling.constraints + obj_scale = self._nl_info.scaling.objectives[0] + if cons_to_load is None: + cons_to_load = set(self._nl_info.constraints) + else: + cons_to_load = set(cons_to_load) + for con, val, scale in zip( + self._nl_info.constraints, self._sol_data.duals, scale_list + ): + if con in cons_to_load: + res[con] = val * scale / obj_scale + return res def parse_sol_file( sol_file: io.TextIOBase, nl_info: NLWriterInfo, result: Results ) -> Tuple[Results, SolFileData]: + """ + Parse a .sol file and populate to Pyomo objects + """ sol_data = SolFileData() # @@ -40,37 +165,38 @@ def parse_sol_file( # For backwards compatibility and general safety, we will parse all # lines until "Options" appears. Anything before "Options" we will # consider to be the solver message. + options_found = False message = [] + model_objects = [] for line in sol_file: if not line: break line = line.strip() if "Options" in line: + # Once "Options" appears, we must now read the content under it. + options_found = True + line = sol_file.readline() + number_of_options = int(line) + # We are adding in this DeveloperError to see if the alternative case + # is ever actually hit in the wild. In a previous iteration of the sol + # reader, there was logic to check for the number of options, but it + # was uncovered by tests and unclear if actually necessary. + if number_of_options > 4: + raise DeveloperError( + """ + The sol file reader has hit an unexpected error while parsing. The number of + options recorded is greater than 4. Please report this error to the Pyomo + developers. + """ + ) + for i in range(number_of_options + 4): + line = sol_file.readline() + model_objects.append(int(line)) break message.append(line) - message = '\n'.join(message) - # Once "Options" appears, we must now read the content under it. - model_objects = [] - if "Options" in line: - line = sol_file.readline() - number_of_options = int(line) - # We are adding in this DeveloperError to see if the alternative case - # is ever actually hit in the wild. In a previous iteration of the sol - # reader, there was logic to check for the number of options, but it - # was uncovered by tests and unclear if actually necessary. - if number_of_options > 4: - raise DeveloperError( - """ -The sol file reader has hit an unexpected error while parsing. The number of -options recorded is greater than 4. Please report this error to the Pyomo -developers. - """ - ) - for i in range(number_of_options + 4): - line = sol_file.readline() - model_objects.append(int(line)) - else: + if not options_found: raise PyomoException("ERROR READING `sol` FILE. No 'Options' line found.") + message = '\n'.join(message) # Identify the total number of variables and constraints number_of_cons = model_objects[number_of_options + 1] number_of_vars = model_objects[number_of_options + 3] @@ -145,15 +271,15 @@ def parse_sol_file( if line == "": continue line = line.split() - # Some sort of garbage we tag onto the solver message, assuming we are past the suffixes + # Extra solver message processing if line[0] != 'suffix': # We assume this is the start of a # section like kestrel_option, which # comes after all suffixes. - remaining = "" + remaining = '' line = sol_file.readline() while line: - remaining += line.strip() + "; " + remaining += line.strip() + '; ' line = sol_file.readline() result.extra_info.solver_message += remaining break @@ -172,7 +298,7 @@ def parse_sol_file( for line in range(number_of_string_lines): sol_data.other.append(sol_file.readline()) if data_type == 0: # Var - sol_data.var_suffixes[suffix_name] = dict() + sol_data.var_suffixes[suffix_name] = {} for cnt in range(number_of_entries): suf_line = sol_file.readline().split() var_ndx = int(suf_line[0]) @@ -180,7 +306,7 @@ def parse_sol_file( suf_line[1] ) elif data_type == 1: # Con - sol_data.con_suffixes[suffix_name] = dict() + sol_data.con_suffixes[suffix_name] = {} for cnt in range(number_of_entries): suf_line = sol_file.readline().split() con_ndx = int(suf_line[0]) @@ -188,7 +314,7 @@ def parse_sol_file( suf_line[1] ) elif data_type == 2: # Obj - sol_data.obj_suffixes[suffix_name] = dict() + sol_data.obj_suffixes[suffix_name] = {} for cnt in range(number_of_entries): suf_line = sol_file.readline().split() obj_ndx = int(suf_line[0]) @@ -196,7 +322,7 @@ def parse_sol_file( suf_line[1] ) elif data_type == 3: # Prob - sol_data.problem_suffixes[suffix_name] = list() + sol_data.problem_suffixes[suffix_name] = [] for cnt in range(number_of_entries): suf_line = sol_file.readline().split() sol_data.problem_suffixes[suffix_name].append( diff --git a/pyomo/contrib/solver/tests/__init__.py b/pyomo/contrib/solver/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/solver/tests/__init__.py +++ b/pyomo/contrib/solver/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/solver/tests/solvers/__init__.py b/pyomo/contrib/solver/tests/solvers/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/solver/tests/solvers/__init__.py +++ b/pyomo/contrib/solver/tests/solvers/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_direct_quadratic_template.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_direct_quadratic_template.py new file mode 100644 index 00000000000..a759ca233f7 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_direct_quadratic_template.py @@ -0,0 +1,185 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +import pyomo.common.unittest as unittest +import pyomo.contrib.solver.solvers.gurobi_direct +import pyomo.core.base.constraint +import pyomo.core.base.objective +import pyomo.environ +import logging + +LOGGER = logging.getLogger(__name__) + +opt = pyomo.contrib.solver.solvers.gurobi_direct.GurobiDirect() +if not opt.available(): + raise unittest.SkipTest + +SOLVER_NAME = "gurobi_direct_v2" + + +def get_solver(solver_name=SOLVER_NAME): + return pyomo.environ.SolverFactory(solver_name) + + +def create_model( + *, + templatize_constraints=True, + templatize_objectives=True, + solver_name=SOLVER_NAME, + debug_file=None, +): + + pyomo.core.base.constraint.TEMPLATIZE_CONSTRAINTS = templatize_constraints + pyomo.core.base.objective.TEMPLATIZE_OBJECTIVES = templatize_objectives + if debug_file: + pyomo.contrib.solver.solvers.gurobi_direct.DEBUG_LP_FILE = debug_file + + LOGGER.info(f"TEMPLATIZE_CONSTRAINTS = {templatize_constraints}") + LOGGER.info(f"TEMPLATIZE_OBJECTIVES = {templatize_objectives}") + LOGGER.info( + f"DEBUG_LP_FILE = {pyomo.contrib.solver.solvers.gurobi_direct.DEBUG_LP_FILE}" + ) + + return pyomo.environ.ConcreteModel(), get_solver(solver_name) + + +class TestGurobiQuadraticTemplate(unittest.TestCase): + """ + Set of end-to-end tests of quadratic template objectives and constraints using + gurobi_direct solver. + """ + + def test_verify_using_templatized_constraints_and_objectives(self): + create_model() + self.assertTrue(pyomo.core.base.constraint.TEMPLATIZE_CONSTRAINTS) + self.assertTrue(pyomo.core.base.objective.TEMPLATIZE_OBJECTIVES) + + def test_linear_constraint_and_objective(self): + """Make sure linear still works""" + model, opt = create_model() + rng = list(range(2)) + model.x = pyomo.environ.Var(rng, bounds=(1, 100)) + + @model.Objective([(0, 1)], sense=pyomo.environ.maximize) + def obj(model, i, j): + return model.x[i] + 2 * model.x[j] + + @model.Constraint([(0, 1)]) + def c1(model, i, j): + return model.x[i] + model.x[j] <= 5 + + opt.solve(model, tee=False) + self.assertAlmostEqual(model.x[0].value, 1.0, delta=0.1) + self.assertAlmostEqual(model.x[1].value, 4.0, delta=0.1) + + def test_quadratic_objective_decorator(self): + model, opt = create_model() + rng = list(range(2)) + model.x = pyomo.environ.Var(rng, bounds=(1, 100)) + + @model.Objective([(0, 1)], sense=pyomo.environ.maximize) + def obj(model, i, j): + return model.x[i] + model.x[j] ** 2 + + @model.Constraint([(0, 1)]) + def c1(model, i, j): + return model.x[i] + model.x[j] <= 5 + + opt.solve(model, tee=False) + self.assertAlmostEqual(model.x[0].value, 1.0, delta=0.1) + self.assertAlmostEqual(model.x[1].value, 4.0, delta=0.1) + + def test_quadratic_objective_nondecorator(self): + model, opt = create_model() + rng = list(range(2)) + model.x = pyomo.environ.Var(rng, bounds=(1, 100)) + + model.obj = pyomo.environ.Objective( + expr=model.x[0] + model.x[1] ** 2, sense=pyomo.environ.maximize + ) + + @model.Constraint([(0, 1)]) + def c1(model, i, j): + return model.x[i] + model.x[j] <= 5 + + opt.solve(model, tee=False) + self.assertAlmostEqual(model.x[0].value, 1.0, delta=0.1) + self.assertAlmostEqual(model.x[1].value, 4.0, delta=0.1) + + def test_quadratic_constraint_binomial_constraint_nondecorator(self): + model, opt = create_model() + rng = list(range(2)) + model.x = pyomo.environ.Var(rng, bounds=(0, 6)) + model.obj = pyomo.environ.Objective( + expr=model.x[0] + model.x[1], sense=pyomo.environ.maximize + ) + model.c1 = pyomo.environ.Constraint( + expr=model.x[1] <= (model.x[0] - 2) * (model.x[0] - 5) + ) + opt.solve(model, tee=False) + self.assertAlmostEqual(model.x[0].value, 6.0, delta=0.1) + self.assertAlmostEqual(model.x[1].value, 4.0, delta=0.1) + + def test_quadratic_constraint_binomial_expansion_decorator(self): + """ + FIX: This case of binomial expansion does not appear to work. + While raising a binomial 〖(ax+b)〗^2 appears to work correctly, + the general binomial expansion case of (ax+b)(cx+d) does not in the decorator. + """ + # self.skipTest("This case does not work") + model, opt = create_model() + rng = list(range(2)) + model.x = pyomo.environ.Var(rng, bounds=(0, 6)) + model.obj = pyomo.environ.Objective( + expr=model.x[0] + model.x[1], sense=pyomo.environ.maximize + ) + + @model.Constraint([tuple(rng)]) + def c1(model, i, j): + return model.x[j] <= (model.x[i] - 2) * (model.x[i] - 5) + + opt.solve(model, tee=False) + + self.assertAlmostEqual(model.x[0].value, 6.0, delta=0.1) + self.assertAlmostEqual(model.x[1].value, 4.0, delta=0.1) + + def test_quadratic_constraint_decorator_1(self): + """expanded quadratic""" + model, opt = create_model() + rng = list(range(2)) + model.x = pyomo.environ.Var(rng, bounds=(0, 6)) + model.obj = pyomo.environ.Objective( + expr=model.x[0] + model.x[1], sense=pyomo.environ.maximize + ) + + @model.Constraint([tuple(rng)]) + def c1(model, i, j): + return model.x[j] - model.x[i] ** 2 + 7 * model.x[i] <= 10 + + opt.solve(model, tee=False) + self.assertAlmostEqual(model.x[0].value, 6.0, delta=0.1) + self.assertAlmostEqual(model.x[1].value, 4.0, delta=0.1) + + def test_quadratic_constraint_decorator_2(self): + """binomial squared""" + model, opt = create_model() + rng = list(range(2)) + model.x = pyomo.environ.Var(rng, bounds=(0, 6)) + model.obj = pyomo.environ.Objective( + expr=model.x[0] + model.x[1], sense=pyomo.environ.maximize + ) + + @model.Constraint([tuple(rng)]) + def c1(model, i, j): + return model.x[j] <= (model.x[i] - 4.0) ** 2 + + opt.solve(model, tee=False) + self.assertAlmostEqual(model.x[0].value, 6.0, delta=0.1) + self.assertAlmostEqual(model.x[1].value, 4.0, delta=0.1) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py index 2f281e2abf0..8703ae9edff 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,13 +10,13 @@ # ___________________________________________________________________________ import pyomo.common.unittest as unittest -import pyomo.environ as pe -from pyomo.contrib.solver.gurobi import Gurobi -from pyomo.contrib.solver.results import SolutionStatus +import pyomo.environ as pyo +from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent +from pyomo.contrib.solver.common.results import SolutionStatus from pyomo.core.expr.taylor_series import taylor_series_expansion -opt = Gurobi() +opt = GurobiPersistent() if not opt.available(): raise unittest.SkipTest import gurobipy @@ -86,17 +86,17 @@ def create_pmedian_model(): (10, 6): 1.9115931008709737, } - model = pe.ConcreteModel() - model.N = pe.Param(initialize=10) - model.Locations = pe.RangeSet(1, model.N) - model.P = pe.Param(initialize=3) - model.M = pe.Param(initialize=6) - model.Customers = pe.RangeSet(1, model.M) - model.d = pe.Param( - model.Locations, model.Customers, initialize=d_dict, within=pe.Reals + model = pyo.ConcreteModel() + model.N = pyo.Param(initialize=10) + model.Locations = pyo.RangeSet(1, model.N) + model.P = pyo.Param(initialize=3) + model.M = pyo.Param(initialize=6) + model.Customers = pyo.RangeSet(1, model.M) + model.d = pyo.Param( + model.Locations, model.Customers, initialize=d_dict, within=pyo.Reals ) - model.x = pe.Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) - model.y = pe.Var(model.Locations, within=pe.Binary) + model.x = pyo.Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) + model.y = pyo.Var(model.Locations, within=pyo.Binary) def rule(model): return sum( @@ -105,39 +105,39 @@ def rule(model): for m in model.Customers ) - model.obj = pe.Objective(rule=rule) + model.obj = pyo.Objective(rule=rule) - def rule(model, m): + def rule1(model, m): return (sum(model.x[n, m] for n in model.Locations), 1.0) - model.single_x = pe.Constraint(model.Customers, rule=rule) + model.single_x = pyo.Constraint(model.Customers, rule=rule1) - def rule(model, n, m): + def rule2(model, n, m): return (None, model.x[n, m] - model.y[n], 0.0) - model.bound_y = pe.Constraint(model.Locations, model.Customers, rule=rule) + model.bound_y = pyo.Constraint(model.Locations, model.Customers, rule=rule2) - def rule(model): + def rule3(model): return (sum(model.y[n] for n in model.Locations) - model.P, 0.0) - model.num_facilities = pe.Constraint(rule=rule) + model.num_facilities = pyo.Constraint(rule=rule3) return model class TestGurobiPersistentSimpleLPUpdates(unittest.TestCase): def setUp(self): - self.m = pe.ConcreteModel() + self.m = pyo.ConcreteModel() m = self.m - m.x = pe.Var() - m.y = pe.Var() - m.p1 = pe.Param(mutable=True) - m.p2 = pe.Param(mutable=True) - m.p3 = pe.Param(mutable=True) - m.p4 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.x + m.y) - m.c1 = pe.Constraint(expr=m.y - m.p1 * m.x >= m.p2) - m.c2 = pe.Constraint(expr=m.y - m.p3 * m.x >= m.p4) + m.x = pyo.Var() + m.y = pyo.Var() + m.p1 = pyo.Param(mutable=True) + m.p2 = pyo.Param(mutable=True) + m.p3 = pyo.Param(mutable=True) + m.p4 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.x + m.y) + m.c1 = pyo.Constraint(expr=m.y - m.p1 * m.x >= m.p2) + m.c2 = pyo.Constraint(expr=m.y - m.p3 * m.x >= m.p4) def get_solution(self): try: @@ -164,7 +164,7 @@ def set_params(self, p1, p2, p3, p4): def test_lp(self): self.set_params(-1, -2, 0.1, -2) x, y = self.get_solution() - opt = Gurobi() + opt = GurobiPersistent() res = opt.solve(self.m) self.assertAlmostEqual(x + y, res.incumbent_objective) self.assertAlmostEqual(x + y, res.objective_bound) @@ -192,50 +192,65 @@ def test_nonconvex_qcp_objective_bound_1(self): # for nonconvex but continuous problems even if a feasible solution # is not found # - # This is a fragile test because it could fail if Gurobi's algorithms improve - # (e.g., a heuristic solution is found before an objective bound of -8 is reached - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-5, 5)) - m.y = pe.Var(bounds=(-5, 5)) - m.obj = pe.Objective(expr=-m.x**2 - m.y) - m.c1 = pe.Constraint(expr=m.y <= -2 * m.x + 1) - m.c2 = pe.Constraint(expr=m.y <= m.x - 2) - opt = Gurobi() + # This is a fragile test because it could fail if Gurobi's + # algorithms improve (e.g., a heuristic solution is found before + # an objective bound of -8 is reached + # + # Update: as of Gurobi 11, this test no longer tests the + # intended behavior (the solver has improved) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-5, 5)) + m.y = pyo.Var(bounds=(-5, 5)) + m.obj = pyo.Objective(expr=-m.x**2 - m.y) + m.c1 = pyo.Constraint(expr=m.y <= -2 * m.x + 1) + m.c2 = pyo.Constraint(expr=m.y <= m.x - 2) + opt = GurobiPersistent() opt.config.solver_options['nonconvex'] = 2 opt.config.solver_options['BestBdStop'] = -8 opt.config.load_solutions = False opt.config.raise_exception_on_nonoptimal_result = False res = opt.solve(m) - self.assertEqual(res.incumbent_objective, None) + if opt.version() < (11, 0): + self.assertEqual(res.incumbent_objective, None) + else: + self.assertEqual(res.incumbent_objective, -4) self.assertAlmostEqual(res.objective_bound, -8) def test_nonconvex_qcp_objective_bound_2(self): - # the goal of this test is to ensure we can objective_bound properly - # for nonconvex but continuous problems when the solver terminates with a nonzero gap + # the goal of this test is to ensure we can objective_bound + # properly for nonconvex but continuous problems when the solver + # terminates with a nonzero gap + # + # This is a fragile test because it could fail if Gurobi's + # algorithms change # - # This is a fragile test because it could fail if Gurobi's algorithms change - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-5, 5)) - m.y = pe.Var(bounds=(-5, 5)) - m.obj = pe.Objective(expr=-m.x**2 - m.y) - m.c1 = pe.Constraint(expr=m.y <= -2 * m.x + 1) - m.c2 = pe.Constraint(expr=m.y <= m.x - 2) - opt = Gurobi() + # Update: as of Gurobi 11, this test no longer tests the + # intended behavior (the solver has improved) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-5, 5)) + m.y = pyo.Var(bounds=(-5, 5)) + m.obj = pyo.Objective(expr=-m.x**2 - m.y) + m.c1 = pyo.Constraint(expr=m.y <= -2 * m.x + 1) + m.c2 = pyo.Constraint(expr=m.y <= m.x - 2) + opt = GurobiPersistent() opt.config.solver_options['nonconvex'] = 2 opt.config.solver_options['MIPGap'] = 0.5 res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, -4) - self.assertAlmostEqual(res.objective_bound, -6) + if opt.version() < (11, 0): + self.assertAlmostEqual(res.objective_bound, -6) + else: + self.assertAlmostEqual(res.objective_bound, -4) def test_range_constraints(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.xl = pe.Param(initialize=-1, mutable=True) - m.xu = pe.Param(initialize=1, mutable=True) - m.c = pe.Constraint(expr=pe.inequality(m.xl, m.x, m.xu)) - m.obj = pe.Objective(expr=m.x) - - opt = Gurobi() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.xl = pyo.Param(initialize=-1, mutable=True) + m.xu = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Constraint(expr=pyo.inequality(m.xl, m.x, m.xu)) + m.obj = pyo.Objective(expr=m.x) + + opt = GurobiPersistent() opt.set_instance(m) res = opt.solve(m) self.assertAlmostEqual(m.x.value, -1) @@ -245,7 +260,7 @@ def test_range_constraints(self): self.assertAlmostEqual(m.x.value, -3) del m.obj - m.obj = pe.Objective(expr=m.x, sense=pe.maximize) + m.obj = pyo.Objective(expr=m.x, sense=pyo.maximize) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 1) @@ -255,16 +270,16 @@ def test_range_constraints(self): self.assertAlmostEqual(m.x.value, 3) def test_quadratic_constraint_with_params(self): - m = pe.ConcreteModel() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=1, mutable=True) - m.c = pe.Param(initialize=1, mutable=True) - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.con = pe.Constraint(expr=m.y >= m.a * m.x**2 + m.b * m.x + m.c) - - opt = Gurobi() + m = pyo.ConcreteModel() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Param(initialize=1, mutable=True) + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.con = pyo.Constraint(expr=m.y >= m.a * m.x**2 + m.b * m.x + m.c) + + opt = GurobiPersistent() res = opt.solve(m) self.assertAlmostEqual(m.x.value, -m.b.value / (2 * m.a.value)) self.assertAlmostEqual( @@ -281,14 +296,14 @@ def test_quadratic_constraint_with_params(self): ) def test_quadratic_objective(self): - m = pe.ConcreteModel() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=1, mutable=True) - m.c = pe.Param(initialize=1, mutable=True) - m.x = pe.Var() - m.obj = pe.Objective(expr=m.a * m.x**2 + m.b * m.x + m.c) - - opt = Gurobi() + m = pyo.ConcreteModel() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Param(initialize=1, mutable=True) + m.x = pyo.Var() + m.obj = pyo.Objective(expr=m.a * m.x**2 + m.b * m.x + m.c) + + opt = GurobiPersistent() res = opt.solve(m) self.assertAlmostEqual(m.x.value, -m.b.value / (2 * m.a.value)) self.assertAlmostEqual( @@ -307,11 +322,11 @@ def test_quadratic_objective(self): ) def test_var_bounds(self): - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-1, 1)) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, 1)) + m.obj = pyo.Objective(expr=m.x) - opt = Gurobi() + opt = GurobiPersistent() res = opt.solve(m) self.assertAlmostEqual(m.x.value, -1) @@ -320,9 +335,9 @@ def test_var_bounds(self): self.assertAlmostEqual(m.x.value, -3) del m.obj - m.obj = pe.Objective(expr=m.x, sense=pe.maximize) + m.obj = pyo.Objective(expr=m.x, sense=pyo.maximize) - opt = Gurobi() + opt = GurobiPersistent() res = opt.solve(m) self.assertAlmostEqual(m.x.value, 1) @@ -331,17 +346,17 @@ def test_var_bounds(self): self.assertAlmostEqual(m.x.value, 3) def test_fixed_var(self): - m = pe.ConcreteModel() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=1, mutable=True) - m.c = pe.Param(initialize=1, mutable=True) - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.con = pe.Constraint(expr=m.y >= m.a * m.x**2 + m.b * m.x + m.c) + m = pyo.ConcreteModel() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Param(initialize=1, mutable=True) + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.con = pyo.Constraint(expr=m.y >= m.a * m.x**2 + m.b * m.x + m.c) m.x.fix(1) - opt = Gurobi() + opt = GurobiPersistent() res = opt.solve(m) self.assertAlmostEqual(m.x.value, 1) self.assertAlmostEqual(m.y.value, 3) @@ -359,42 +374,42 @@ def test_fixed_var(self): ) def test_linear_constraint_attr(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c = pe.Constraint(expr=m.x + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c = pyo.Constraint(expr=m.x + m.y == 1) - opt = Gurobi() + opt = GurobiPersistent() opt.set_instance(m) opt.set_linear_constraint_attr(m.c, 'Lazy', 1) self.assertEqual(opt.get_linear_constraint_attr(m.c, 'Lazy'), 1) def test_quadratic_constraint_attr(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c = pe.Constraint(expr=m.y >= m.x**2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c = pyo.Constraint(expr=m.y >= m.x**2) - opt = Gurobi() + opt = GurobiPersistent() opt.set_instance(m) self.assertEqual(opt.get_quadratic_constraint_attr(m.c, 'QCRHS'), 0) def test_var_attr(self): - m = pe.ConcreteModel() - m.x = pe.Var(within=pe.Binary) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.Binary) + m.obj = pyo.Objective(expr=m.x) - opt = Gurobi() + opt = GurobiPersistent() opt.set_instance(m) opt.set_var_attr(m.x, 'Start', 1) self.assertEqual(opt.get_var_attr(m.x, 'Start'), 1) def test_callback(self): - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(0, 4)) - m.y = pe.Var(within=pe.Integers, bounds=(0, None)) - m.obj = pe.Objective(expr=2 * m.x + m.y) - m.cons = pe.ConstraintList() + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 4)) + m.y = pyo.Var(within=pyo.Integers, bounds=(0, None)) + m.obj = pyo.Objective(expr=2 * m.x + m.y) + m.cons = pyo.ConstraintList() def _add_cut(xval): m.x.value = xval @@ -403,14 +418,14 @@ def _add_cut(xval): _add_cut(0) _add_cut(4) - opt = Gurobi() + opt = GurobiPersistent() opt.set_instance(m) opt.set_gurobi_param('PreCrush', 1) opt.set_gurobi_param('LazyConstraints', 1) def _my_callback(cb_m, cb_opt, cb_where): if cb_where == gurobipy.GRB.Callback.MIPSOL: - cb_opt.cbGetSolution(vars=[m.x, m.y]) + cb_opt.cbGetSolution(variables=[m.x, m.y]) if m.y.value < (m.x.value - 2) ** 2 - 1e-6: cb_opt.cbLazy(_add_cut(m.x.value)) @@ -422,12 +437,12 @@ def _my_callback(cb_m, cb_opt, cb_where): def test_nonconvex(self): if gurobipy.GRB.VERSION_MAJOR < 9: raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c = pe.Constraint(expr=m.y == (m.x - 1) ** 2 - 2) - opt = Gurobi() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c = pyo.Constraint(expr=m.y == (m.x - 1) ** 2 - 2) + opt = GurobiPersistent() opt.config.solver_options['nonconvex'] = 2 opt.solve(m) self.assertAlmostEqual(m.x.value, -0.3660254037844423, 2) @@ -436,13 +451,13 @@ def test_nonconvex(self): def test_nonconvex2(self): if gurobipy.GRB.VERSION_MAJOR < 9: raise unittest.SkipTest - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=0 <= -m.y + (m.x - 1) ** 2 - 2) - m.c2 = pe.Constraint(expr=0 >= -m.y + (m.x - 1) ** 2 - 2) - opt = Gurobi() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=0 <= -m.y + (m.x - 1) ** 2 - 2) + m.c2 = pyo.Constraint(expr=0 >= -m.y + (m.x - 1) ** 2 - 2) + opt = GurobiPersistent() opt.config.solver_options['nonconvex'] = 2 opt.solve(m) self.assertAlmostEqual(m.x.value, -0.3660254037844423, 2) @@ -450,22 +465,22 @@ def test_nonconvex2(self): def test_solution_number(self): m = create_pmedian_model() - opt = Gurobi() + opt = GurobiPersistent() opt.config.solver_options['PoolSolutions'] = 3 opt.config.solver_options['PoolSearchMode'] = 2 res = opt.solve(m) num_solutions = opt.get_model_attr('SolCount') self.assertEqual(num_solutions, 3) res.solution_loader.load_vars(solution_number=0) - self.assertAlmostEqual(pe.value(m.obj.expr), 6.431184939357673) + self.assertAlmostEqual(pyo.value(m.obj.expr), 6.431184939357673) res.solution_loader.load_vars(solution_number=1) - self.assertAlmostEqual(pe.value(m.obj.expr), 6.584793218502477) + self.assertAlmostEqual(pyo.value(m.obj.expr), 6.584793218502477) res.solution_loader.load_vars(solution_number=2) - self.assertAlmostEqual(pe.value(m.obj.expr), 6.592304628123309) + self.assertAlmostEqual(pyo.value(m.obj.expr), 6.592304628123309) def test_zero_time_limit(self): m = create_pmedian_model() - opt = Gurobi() + opt = GurobiPersistent() opt.config.time_limit = 0 opt.config.load_solutions = False opt.config.raise_exception_on_nonoptimal_result = False @@ -483,7 +498,7 @@ def test_zero_time_limit(self): class TestManualModel(unittest.TestCase): def setUp(self): - opt = Gurobi() + opt = GurobiPersistent() opt.config.auto_updates.check_for_new_or_removed_params = False opt.config.auto_updates.check_for_new_or_removed_vars = False opt.config.auto_updates.check_for_new_or_removed_constraints = False @@ -494,11 +509,11 @@ def setUp(self): self.opt = opt def test_basics(self): - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-10, 10)) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y >= 2 * m.x + 1) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y >= 2 * m.x + 1) opt = self.opt opt.set_instance(m) @@ -515,7 +530,7 @@ def test_basics(self): duals = res.solution_loader.get_duals() self.assertAlmostEqual(duals[m.c1], -0.4) - m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + m.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) opt.add_constraints([m.c2]) self.assertEqual(opt.get_model_attr('NumVars'), 2) self.assertEqual(opt.get_model_attr('NumConstrs'), 2) @@ -559,7 +574,7 @@ def test_basics(self): self.assertEqual(opt.get_var_attr(m.x, 'LB'), -5) self.assertEqual(opt.get_var_attr(m.x, 'UB'), 5) - m.c2 = pe.Constraint(expr=m.y >= m.x**2) + m.c2 = pyo.Constraint(expr=m.y >= m.x**2) opt.add_constraints([m.c2]) self.assertEqual(opt.get_model_attr('NumVars'), 2) self.assertEqual(opt.get_model_attr('NumConstrs'), 1) @@ -571,7 +586,7 @@ def test_basics(self): self.assertEqual(opt.get_model_attr('NumConstrs'), 1) self.assertEqual(opt.get_model_attr('NumQConstrs'), 0) - m.z = pe.Var() + m.z = pyo.Var() opt.add_variables([m.z]) self.assertEqual(opt.get_model_attr('NumVars'), 3) opt.remove_variables([m.z]) @@ -579,12 +594,12 @@ def test_basics(self): self.assertEqual(opt.get_model_attr('NumVars'), 2) def test_update1(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c1 = pe.Constraint(expr=m.z >= m.x**2 + m.y**2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c1 = pyo.Constraint(expr=m.z >= m.x**2 + m.y**2) opt = self.opt opt.set_instance(m) @@ -600,12 +615,12 @@ def test_update1(self): self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) def test_update2(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c2 = pe.Constraint(expr=m.x + m.y == 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c2 = pyo.Constraint(expr=m.x + m.y == 1) opt = self.opt opt.config.symbolic_solver_labels = True @@ -622,18 +637,18 @@ def test_update2(self): self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) def test_update3(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c1 = pe.Constraint(expr=m.z >= m.x**2 + m.y**2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c1 = pyo.Constraint(expr=m.z >= m.x**2 + m.y**2) opt = self.opt opt.set_instance(m) opt.update() self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) - m.c2 = pe.Constraint(expr=m.y >= m.x**2) + m.c2 = pyo.Constraint(expr=m.y >= m.x**2) opt.add_constraints([m.c2]) self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) opt.remove_constraints([m.c2]) @@ -641,18 +656,18 @@ def test_update3(self): self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) def test_update4(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c1 = pe.Constraint(expr=m.z >= m.x + m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c1 = pyo.Constraint(expr=m.z >= m.x + m.y) opt = self.opt opt.set_instance(m) opt.update() self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) - m.c2 = pe.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y >= m.x) opt.add_constraints([m.c2]) self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) opt.remove_constraints([m.c2]) @@ -660,12 +675,12 @@ def test_update4(self): self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) def test_update5(self): - m = pe.ConcreteModel() - m.a = pe.Set(initialize=[1, 2, 3], ordered=True) - m.x = pe.Var(m.a, within=pe.Binary) - m.y = pe.Var(within=pe.Binary) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.SOSConstraint(var=m.x, sos=1) + m = pyo.ConcreteModel() + m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) + m.x = pyo.Var(m.a, within=pyo.Binary) + m.y = pyo.Var(within=pyo.Binary) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.SOSConstraint(var=m.x, sos=1) opt = self.opt opt.set_instance(m) @@ -681,18 +696,18 @@ def test_update5(self): self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) def test_update6(self): - m = pe.ConcreteModel() - m.a = pe.Set(initialize=[1, 2, 3], ordered=True) - m.x = pe.Var(m.a, within=pe.Binary) - m.y = pe.Var(within=pe.Binary) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.SOSConstraint(var=m.x, sos=1) + m = pyo.ConcreteModel() + m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) + m.x = pyo.Var(m.a, within=pyo.Binary) + m.y = pyo.Var(within=pyo.Binary) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.SOSConstraint(var=m.x, sos=1) opt = self.opt opt.set_instance(m) opt.update() self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) - m.c2 = pe.SOSConstraint(var=m.x, sos=2) + m.c2 = pyo.SOSConstraint(var=m.x, sos=2) opt.add_sos_constraints([m.c2]) self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) opt.remove_sos_constraints([m.c2]) diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index dc6bcf24855..30eb74a2db1 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,19 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import os +import subprocess import pyomo.environ as pyo from pyomo.common.fileutils import ExecutableData from pyomo.common.config import ConfigDict -from pyomo.contrib.solver.ipopt import IpoptConfig -from pyomo.contrib.solver.factory import SolverFactory -from pyomo.common import unittest +from pyomo.common.errors import DeveloperError +import pyomo.contrib.solver.solvers.ipopt as ipopt +from pyomo.contrib.solver.common.util import NoSolutionError +from pyomo.contrib.solver.common.factory import SolverFactory +from pyomo.common import unittest, Executable +from pyomo.common.tempfiles import TempfileManager +from pyomo.repn.plugins.nl_writer import NLWriter """ @@ -28,6 +34,250 @@ """ +ipopt_available = ipopt.Ipopt().available() + + +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestIpoptSolverConfig(unittest.TestCase): + def test_default_instantiation(self): + config = ipopt.IpoptConfig() + # Should be inherited + self.assertIsNone(config._description) + self.assertEqual(config._visibility, 0) + self.assertFalse(config.tee) + self.assertTrue(config.load_solutions) + self.assertTrue(config.raise_exception_on_nonoptimal_result) + self.assertFalse(config.symbolic_solver_labels) + self.assertIsNone(config.timer) + self.assertIsNone(config.threads) + self.assertIsNone(config.time_limit) + # Unique to this object + self.assertIsInstance(config.executable, type(Executable('path'))) + self.assertIsInstance(config.writer_config, type(NLWriter.CONFIG())) + + def test_custom_instantiation(self): + config = ipopt.IpoptConfig(description="A description") + config.tee = True + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertIsNone(config.time_limit) + # Default should be `ipopt` + self.assertIsNotNone(str(config.executable)) + self.assertIn('ipopt', str(config.executable)) + # Set to a totally bogus path + config.executable = Executable('/bogus/path') + self.assertIsNone(config.executable.executable) + self.assertFalse(config.executable.available()) + + +class TestIpoptSolutionLoader(unittest.TestCase): + def test_get_reduced_costs_error(self): + loader = ipopt.IpoptSolutionLoader(None, None) + with self.assertRaises(NoSolutionError): + loader.get_reduced_costs() + + # Set _nl_info to something completely bogus but is not None + class NLInfo: + pass + + loader._nl_info = NLInfo() + loader._nl_info.eliminated_vars = [1, 2, 3] + # This test may need to be altered if we enable returning duals + # when presolve is on + with self.assertRaises(NotImplementedError): + loader.get_reduced_costs() + # Reset _nl_info so we can ensure we get an error + # when _sol_data is None + loader._nl_info.eliminated_vars = [] + with self.assertRaises(DeveloperError): + loader.get_reduced_costs() + + +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestIpoptInterface(unittest.TestCase): + def test_command_line_options(self): + result = subprocess.run( + ['ipopt', '-='], capture_output=True, text=True, check=True + ) + output = result.stdout + options = [] + for line in output.splitlines(): + option_name = line.split()[0] if line.strip() else '' + if option_name: + options.append(option_name) + self.assertEqual(sorted(ipopt.ipopt_command_line_options), sorted(options)) + + def test_class_member_list(self): + opt = ipopt.Ipopt() + expected_list = [ + 'CONFIG', + 'config', + 'available', + 'has_linear_solver', + 'is_persistent', + 'solve', + 'version', + 'name', + ] + method_list = [method for method in dir(opt) if method.startswith('_') is False] + self.assertEqual(sorted(expected_list), sorted(method_list)) + + def test_default_instantiation(self): + opt = ipopt.Ipopt() + self.assertFalse(opt.is_persistent()) + self.assertIsNotNone(opt.version()) + self.assertEqual(opt.name, 'ipopt') + self.assertEqual(opt.CONFIG, opt.config) + self.assertTrue(opt.available()) + + def test_context_manager(self): + with ipopt.Ipopt() as opt: + self.assertFalse(opt.is_persistent()) + self.assertIsNotNone(opt.version()) + self.assertEqual(opt.name, 'ipopt') + self.assertEqual(opt.CONFIG, opt.config) + self.assertTrue(opt.available()) + + def test_available_cache(self): + opt = ipopt.Ipopt() + opt.available() + self.assertTrue(opt._available_cache[1]) + self.assertIsNotNone(opt._available_cache[0]) + # Now we will try with a custom config that has a fake path + config = ipopt.IpoptConfig() + config.executable = Executable('/a/bogus/path') + opt.available(config=config) + self.assertFalse(opt._available_cache[1]) + self.assertIsNone(opt._available_cache[0]) + + def test_version_cache(self): + opt = ipopt.Ipopt() + opt.version() + self.assertIsNotNone(opt._version_cache[0]) + self.assertIsNotNone(opt._version_cache[1]) + # Now we will try with a custom config that has a fake path + config = ipopt.IpoptConfig() + config.executable = Executable('/a/bogus/path') + opt.version(config=config) + self.assertIsNone(opt._version_cache[0]) + self.assertIsNone(opt._version_cache[1]) + + def test_write_options_file(self): + # If we have no options, we should get false back + opt = ipopt.Ipopt() + result = opt._write_options_file('fakename', None) + self.assertFalse(result) + # Pass it some options that ARE on the command line + opt = ipopt.Ipopt(solver_options={'max_iter': 4}) + result = opt._write_options_file('myfile', opt.config.solver_options) + self.assertFalse(result) + self.assertFalse(os.path.isfile('myfile.opt')) + # Now we are going to actually pass it some options that are NOT on + # the command line + opt = ipopt.Ipopt(solver_options={'custom_option': 4}) + with TempfileManager.new_context() as temp: + dname = temp.mkdtemp() + if not os.path.exists(dname): + os.mkdir(dname) + filename = os.path.join(dname, 'myfile') + result = opt._write_options_file(filename, opt.config.solver_options) + self.assertTrue(result) + self.assertTrue(os.path.isfile(filename + '.opt')) + # Make sure all options are writing to the file + opt = ipopt.Ipopt(solver_options={'custom_option_1': 4, 'custom_option_2': 3}) + with TempfileManager.new_context() as temp: + dname = temp.mkdtemp() + if not os.path.exists(dname): + os.mkdir(dname) + filename = os.path.join(dname, 'myfile') + result = opt._write_options_file(filename, opt.config.solver_options) + self.assertTrue(result) + self.assertTrue(os.path.isfile(filename + '.opt')) + with open(filename + '.opt', 'r') as f: + data = f.readlines() + self.assertEqual(len(data), len(list(opt.config.solver_options.keys()))) + + def test_has_linear_solver(self): + opt = ipopt.Ipopt() + self.assertTrue( + any( + map( + opt.has_linear_solver, + [ + 'mumps', + 'ma27', + 'ma57', + 'ma77', + 'ma86', + 'ma97', + 'pardiso', + 'pardisomkl', + 'spral', + 'wsmp', + ], + ) + ) + ) + self.assertFalse(opt.has_linear_solver('bogus_linear_solver')) + + def test_create_command_line(self): + opt = ipopt.Ipopt() + # No custom options, no file created. Plain and simple. + result = opt._create_command_line('myfile', opt.config, False) + self.assertEqual(result, [str(opt.config.executable), 'myfile.nl', '-AMPL']) + # Custom command line options + opt = ipopt.Ipopt(solver_options={'max_iter': 4}) + result = opt._create_command_line('myfile', opt.config, False) + self.assertEqual( + result, [str(opt.config.executable), 'myfile.nl', '-AMPL', 'max_iter=4'] + ) + # Let's see if we correctly parse config.time_limit + opt = ipopt.Ipopt(solver_options={'max_iter': 4}, time_limit=10) + result = opt._create_command_line('myfile', opt.config, False) + self.assertEqual( + result, + [ + str(opt.config.executable), + 'myfile.nl', + '-AMPL', + 'max_iter=4', + 'max_cpu_time=10.0', + ], + ) + # Now let's do multiple command line options + opt = ipopt.Ipopt(solver_options={'max_iter': 4, 'max_cpu_time': 10}) + result = opt._create_command_line('myfile', opt.config, False) + self.assertEqual( + result, + [ + str(opt.config.executable), + 'myfile.nl', + '-AMPL', + 'max_cpu_time=10', + 'max_iter=4', + ], + ) + # Let's now include if we "have" an options file + result = opt._create_command_line('myfile', opt.config, True) + self.assertEqual( + result, + [ + str(opt.config.executable), + 'myfile.nl', + '-AMPL', + 'option_file_name=myfile.opt', + 'max_cpu_time=10', + 'max_iter=4', + ], + ) + # Finally, let's make sure it errors if someone tries to pass option_file_name + opt = ipopt.Ipopt( + solver_options={'max_iter': 4, 'option_file_name': 'myfile.opt'} + ) + with self.assertRaises(ValueError): + result = opt._create_command_line('myfile', opt.config, False) + + class TestIpopt(unittest.TestCase): def create_model(self): model = pyo.ConcreteModel() @@ -42,13 +292,13 @@ def rosenbrock(m): def test_ipopt_config(self): # Test default initialization - config = IpoptConfig() + config = ipopt.IpoptConfig() self.assertTrue(config.load_solutions) self.assertIsInstance(config.solver_options, ConfigDict) self.assertIsInstance(config.executable, ExecutableData) # Test custom initialization - solver = SolverFactory('ipopt_v2', executable='/path/to/exe') + solver = SolverFactory('ipopt', executable='/path/to/exe') self.assertFalse(solver.config.tee) self.assertTrue(solver.config.executable.startswith('/path')) diff --git a/pyomo/contrib/solver/tests/unit/test_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_sol_reader.py similarity index 94% rename from pyomo/contrib/solver/tests/unit/test_sol_reader.py rename to pyomo/contrib/solver/tests/solvers/test_sol_reader.py index d5602945e07..0db7934cd86 100644 --- a/pyomo/contrib/solver/tests/unit/test_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_sol_reader.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ from pyomo.common import unittest from pyomo.common.fileutils import this_file_dir from pyomo.common.tempfiles import TempfileManager -from pyomo.contrib.solver.sol_reader import parse_sol_file, SolFileData +from pyomo.contrib.solver.solvers.sol_reader import SolFileData currdir = this_file_dir() diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index cf5f6cf5c57..808c6675529 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,15 +13,28 @@ import math from typing import Type -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo import gdp from pyomo.common.dependencies import attempt_import import pyomo.common.unittest as unittest -from pyomo.contrib.solver.results import TerminationCondition, SolutionStatus, Results -from pyomo.contrib.solver.base import SolverBase -from pyomo.contrib.solver.ipopt import Ipopt -from pyomo.contrib.solver.gurobi import Gurobi +from pyomo.contrib.solver.common.results import ( + TerminationCondition, + SolutionStatus, + Results, +) +from pyomo.contrib.solver.common.util import ( + NoDualsError, + NoSolutionError, + NoReducedCostsError, +) +from pyomo.contrib.solver.common.base import SolverBase +from pyomo.contrib.solver.common.factory import SolverFactory +from pyomo.contrib.solver.solvers.ipopt import Ipopt +from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent +from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect +from pyomo.contrib.solver.solvers.highs import Highs from pyomo.core.expr.numeric_expr import LinearExpression +from pyomo.core.expr.compare import assertExpressionsEqual np, numpy_available = attempt_import('numpy') @@ -32,11 +45,20 @@ if not param_available: raise unittest.SkipTest('Parameterized is not available.') -all_solvers = [('gurobi', Gurobi), ('ipopt', Ipopt)] -mip_solvers = [('gurobi', Gurobi)] +all_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('gurobi_direct', GurobiDirect), + ('ipopt', Ipopt), + ('highs', Highs), +] +mip_solvers = [ + ('gurobi', GurobiPersistent), + ('gurobi_direct', GurobiDirect), + ('highs', Highs), +] nlp_solvers = [('ipopt', Ipopt)] -qcp_solvers = [('gurobi', Gurobi), ('ipopt', Ipopt)] -miqcqp_solvers = [('gurobi', Gurobi)] +qcp_solvers = [('gurobi', GurobiPersistent), ('ipopt', Ipopt)] +miqcqp_solvers = [('gurobi', GurobiPersistent)] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} @@ -55,6 +77,465 @@ def _load_tests(solver_list): return res +def test_all_solvers_list(): + """ + Make sure that new solver interfaces get + added to the lists of solvers at the top of the file + """ + for name, cls in SolverFactory._cls.items(): + assert (name, cls) in all_solvers + + +class TestDualSignConvention(unittest.TestCase): + @parameterized.expand(input=_load_tests(all_solvers)) + def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + # for now, we don't support getting duals if linear_presolve = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + raise unittest.SkipTest( + f'cannot yet get duals if NLWriter presolve is on' + ) + else: + opt.config.writer_config.linear_presolve = False + + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.y - m.x - 1 == 0) + m.c2 = pyo.Constraint(expr=m.y + m.x - 1 == 0) + m.obj = pyo.Objective(expr=m.x + m.y) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.y - m.x - 1) + assertExpressionsEqual(self, m.c2.body, m.y + m.x - 1) + self.assertAlmostEqual(duals[m.c1], 0) + self.assertAlmostEqual(duals[m.c2], 1) + + # multiply the constraints by -1 and make sure the sign of the dual flips + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=-m.y + m.x + 1 == 0) + m.c2 = pyo.Constraint(expr=-m.y - m.x + 1 == 0) + m.obj = pyo.Objective(expr=m.x + m.y) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, -m.y + m.x + 1) + assertExpressionsEqual(self, m.c2.body, -m.y - m.x + 1) + self.assertAlmostEqual(duals[m.c1], 0) + self.assertAlmostEqual(duals[m.c2], -1) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_inequality( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + # for now, we don't support getting duals if linear_presolve = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + raise unittest.SkipTest( + f'cannot yet get duals if NLWriter presolve is on' + ) + else: + opt.config.writer_config.linear_presolve = False + + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.x - m.y + 1 <= 0) + m.c2 = pyo.Constraint(expr=-m.x - m.y + 1 <= 0) + m.obj = pyo.Objective(expr=m.y) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.x - m.y + 1) + assertExpressionsEqual(self, m.c2.body, -m.x - m.y + 1) + self.assertAlmostEqual(m.c1.ub, 0) + self.assertIsNone(m.c1.lb) + self.assertAlmostEqual(m.c2.ub, 0) + self.assertIsNone(m.c2.lb) + self.assertAlmostEqual(duals[m.c1], -0.5) + self.assertAlmostEqual(duals[m.c2], -0.5) + + # multiply the constraints by -1 and make sure the sign of the dual flips + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=-m.x + m.y - 1 >= 0) + m.c2 = pyo.Constraint(expr=m.x + m.y - 1 >= 0) + m.obj = pyo.Objective(expr=m.y) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, -m.x + m.y - 1) + assertExpressionsEqual(self, m.c2.body, m.x + m.y - 1) + self.assertAlmostEqual(m.c1.lb, 0) + self.assertIsNone(m.c1.ub) + self.assertAlmostEqual(m.c2.lb, 0) + self.assertIsNone(m.c2.ub) + self.assertAlmostEqual(duals[m.c1], 0.5) + self.assertAlmostEqual(duals[m.c2], 0.5) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + # for now, we don't support getting duals if linear_presolve = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + raise unittest.SkipTest( + f'cannot yet get duals if NLWriter presolve is on' + ) + else: + opt.config.writer_config.linear_presolve = False + + # first check the lower bound + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, None)) + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.x - m.y + 1 <= 0) + m.obj = pyo.Objective(expr=m.y) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.x - m.y + 1) + self.assertAlmostEqual(m.c1.ub, 0) + self.assertIsNone(m.c1.lb) + self.assertAlmostEqual(duals[m.c1], -1) + rc = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], 1) + + # now check the upper bound + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(None, 0)) + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=-m.x - m.y + 1 <= 0) + m.obj = pyo.Objective(expr=m.y) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, -m.x - m.y + 1) + self.assertAlmostEqual(m.c1.ub, 0) + self.assertIsNone(m.c1.lb) + self.assertAlmostEqual(duals[m.c1], -1) + rc = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], -1) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + # for now, we don't support getting duals if linear_presolve = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + raise unittest.SkipTest( + f'cannot yet get duals if NLWriter presolve is on' + ) + else: + opt.config.writer_config.linear_presolve = False + + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=(-1, m.x + m.y, 1)) + m.c2 = pyo.Constraint(expr=m.y - m.x >= -1) + m.obj = pyo.Objective(expr=m.y) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, -1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.x + m.y) + assertExpressionsEqual(self, m.c2.body, m.y - m.x) + self.assertAlmostEqual(duals[m.c1], 0.5) + self.assertAlmostEqual(duals[m.c2], 0.5) + + # now test the other side of the range constraint + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=(-1, m.x + m.y, 1)) + m.c2 = pyo.Constraint(expr=m.y - m.x <= 1) + m.obj = pyo.Objective(expr=-m.y) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.x + m.y) + assertExpressionsEqual(self, m.c2.body, m.y - m.x) + self.assertAlmostEqual(duals[m.c1], -0.5) + self.assertAlmostEqual(duals[m.c2], -0.5) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_equality_max( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + # for now, we don't support getting duals if linear_presolve = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + raise unittest.SkipTest( + f'cannot yet get duals if NLWriter presolve is on' + ) + else: + opt.config.writer_config.linear_presolve = False + + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.y - m.x - 1 == 0) + m.c2 = pyo.Constraint(expr=m.y + m.x - 1 == 0) + m.obj = pyo.Objective(expr=-m.x - m.y, sense=pyo.maximize) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.y - m.x - 1) + assertExpressionsEqual(self, m.c2.body, m.y + m.x - 1) + self.assertAlmostEqual(duals[m.c1], 0) + self.assertAlmostEqual(duals[m.c2], -1) + + # multiply the constraints by -1 and make sure the sign of the dual flips + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=-m.y + m.x + 1 == 0) + m.c2 = pyo.Constraint(expr=-m.y - m.x + 1 == 0) + m.obj = pyo.Objective(expr=-m.x - m.y, sense=pyo.maximize) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, -m.y + m.x + 1) + assertExpressionsEqual(self, m.c2.body, -m.y - m.x + 1) + self.assertAlmostEqual(duals[m.c1], 0) + self.assertAlmostEqual(duals[m.c2], 1) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_inequality_max( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + # for now, we don't support getting duals if linear_presolve = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + raise unittest.SkipTest( + f'cannot yet get duals if NLWriter presolve is on' + ) + else: + opt.config.writer_config.linear_presolve = False + + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.x - m.y + 1 <= 0) + m.c2 = pyo.Constraint(expr=-m.x - m.y + 1 <= 0) + m.obj = pyo.Objective(expr=-m.y, sense=pyo.maximize) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.x - m.y + 1) + assertExpressionsEqual(self, m.c2.body, -m.x - m.y + 1) + self.assertAlmostEqual(m.c1.ub, 0) + self.assertIsNone(m.c1.lb) + self.assertAlmostEqual(m.c2.ub, 0) + self.assertIsNone(m.c2.lb) + self.assertAlmostEqual(duals[m.c1], 0.5) + self.assertAlmostEqual(duals[m.c2], 0.5) + + # multiply the constraints by -1 and make sure the sign of the dual flips + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=-m.x + m.y - 1 >= 0) + m.c2 = pyo.Constraint(expr=m.x + m.y - 1 >= 0) + m.obj = pyo.Objective(expr=-m.y, sense=pyo.maximize) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, -m.x + m.y - 1) + assertExpressionsEqual(self, m.c2.body, m.x + m.y - 1) + self.assertAlmostEqual(m.c1.lb, 0) + self.assertIsNone(m.c1.ub) + self.assertAlmostEqual(m.c2.lb, 0) + self.assertIsNone(m.c2.ub) + self.assertAlmostEqual(duals[m.c1], -0.5) + self.assertAlmostEqual(duals[m.c2], -0.5) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_bounds_max( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + # for now, we don't support getting duals if linear_presolve = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + raise unittest.SkipTest( + f'cannot yet get duals if NLWriter presolve is on' + ) + else: + opt.config.writer_config.linear_presolve = False + + # first check the lower bound + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, None)) + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.x - m.y + 1 <= 0) + m.obj = pyo.Objective(expr=-m.y, sense=pyo.maximize) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.x - m.y + 1) + self.assertAlmostEqual(m.c1.ub, 0) + self.assertIsNone(m.c1.lb) + self.assertAlmostEqual(duals[m.c1], 1) + rc = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], -1) + + # now check the upper bound + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(None, 0)) + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=-m.x - m.y + 1 <= 0) + m.obj = pyo.Objective(expr=-m.y, sense=pyo.maximize) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, -m.x - m.y + 1) + self.assertAlmostEqual(m.c1.ub, 0) + self.assertIsNone(m.c1.lb) + self.assertAlmostEqual(duals[m.c1], 1) + rc = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], 1) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_range_max( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + # for now, we don't support getting duals if linear_presolve = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + raise unittest.SkipTest( + f'cannot yet get duals if NLWriter presolve is on' + ) + else: + opt.config.writer_config.linear_presolve = False + + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=(-1, m.x + m.y, 1)) + m.c2 = pyo.Constraint(expr=m.y - m.x >= -1) + m.obj = pyo.Objective(expr=-m.y, sense=pyo.maximize) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, -1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.x + m.y) + assertExpressionsEqual(self, m.c2.body, m.y - m.x) + self.assertAlmostEqual(duals[m.c1], -0.5) + self.assertAlmostEqual(duals[m.c2], -0.5) + + # now test the other side of the range constraint + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=(-1, m.x + m.y, 1)) + m.c2 = pyo.Constraint(expr=m.y - m.x <= 1) + m.obj = pyo.Objective(expr=m.y, sense=pyo.maximize) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + # the sign convention is based on the (lower, body, upper) representation of the constraint, + # so we need to make sure the constraint body is what we expect + assertExpressionsEqual(self, m.c1.body, m.x + m.y) + assertExpressionsEqual(self, m.c2.body, m.y - m.x) + self.assertAlmostEqual(duals[m.c1], 0.5) + self.assertAlmostEqual(duals[m.c2], 0.5) + + @unittest.skipUnless(numpy_available, 'numpy is not available') class TestSolvers(unittest.TestCase): @parameterized.expand(input=all_solvers) @@ -74,17 +555,17 @@ def test_remove_variable_and_objective( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(2, None)) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(2, None)) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 2) del m.x del m.obj - m.x = pe.Var(bounds=(2, None)) - m.obj = pe.Objective(expr=m.x) + m.x = pyo.Var(bounds=(2, None)) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 2) @@ -101,13 +582,13 @@ def test_stale_vars( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= m.x) - m.c2 = pe.Constraint(expr=m.y >= -m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y >= -m.x) m.x.value = 1 m.y.value = 1 m.z.value = 1 @@ -149,16 +630,16 @@ def test_range_constraint( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.obj = pe.Objective(expr=m.x) - m.c = pe.Constraint(expr=(-1, m.x, 1)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.obj = pyo.Objective(expr=m.x) + m.c = pyo.Constraint(expr=(-1, m.x, 1)) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, -1) duals = res.solution_loader.get_duals() self.assertAlmostEqual(duals[m.c], 1) - m.obj.sense = pe.maximize + m.obj.sense = pyo.maximize res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 1) @@ -177,10 +658,10 @@ def test_reduced_costs( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-1, 1)) - m.y = pe.Var(bounds=(-2, 2)) - m.obj = pe.Objective(expr=3 * m.x + 4 * m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, 1)) + m.y = pyo.Var(bounds=(-2, 2)) + m.obj = pyo.Objective(expr=3 * m.x + 4 * m.y) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, -1) @@ -206,15 +687,15 @@ def test_reduced_costs2( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-1, 1)) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, 1)) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, -1) rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], 1) - m.obj.sense = pe.maximize + m.obj.sense = pyo.maximize res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 1) @@ -233,16 +714,16 @@ def test_param_changes( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) - m.c2 = pe.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) + m.c2 = pyo.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -280,16 +761,16 @@ def test_immutable_param( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(initialize=-1) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) - m.c2 = pe.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(initialize=-1) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) + m.c2 = pyo.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) params_to_test = [(1, 2, 1), (1, 2, 1), (1, 3, 1)] for a1, b1, b2 in params_to_test: @@ -323,16 +804,16 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo check_duals = False else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y == m.a1 * m.x + m.b1) - m.c2 = pe.Constraint(expr=m.y == m.a2 * m.x + m.b2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y == m.a1 * m.x + m.b1) + m.c2 = pyo.Constraint(expr=m.y == m.a2 * m.x + m.b2) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -367,22 +848,22 @@ def test_linear_expression( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) e = LinearExpression( constant=m.b1, linear_coefs=[-1, m.a1], linear_vars=[m.y, m.x] ) - m.c1 = pe.Constraint(expr=e == 0) + m.c1 = pyo.Constraint(expr=e == 0) e = LinearExpression( constant=m.b2, linear_coefs=[-1, m.a2], linear_vars=[m.y, m.x] ) - m.c2 = pe.Constraint(expr=e == 0) + m.c2 = pyo.Constraint(expr=e == 0) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -414,15 +895,15 @@ def test_no_objective( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.c1 = pe.Constraint(expr=m.y == m.a1 * m.x + m.b1) - m.c2 = pe.Constraint(expr=m.y == m.a2 * m.x + m.b2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.c1 = pyo.Constraint(expr=m.y == m.a1 * m.x + m.b1) + m.c2 = pyo.Constraint(expr=m.y == m.a2 * m.x + m.b2) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -453,18 +934,18 @@ def test_add_remove_cons( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() a1 = -1 a2 = 1 b1 = 1 b2 = 2 a3 = 1 b3 = 3 - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= a1 * m.x + b1) - m.c2 = pe.Constraint(expr=m.y >= a2 * m.x + b2) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= a1 * m.x + b1) + m.c2 = pyo.Constraint(expr=m.y >= a2 * m.x + b2) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) @@ -479,7 +960,7 @@ def test_add_remove_cons( self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) - m.c3 = pe.Constraint(expr=m.y >= a3 * m.x + b3) + m.c3 = pyo.Constraint(expr=m.y >= a3 * m.x + b3) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, (b3 - b1) / (a1 - a3)) @@ -514,12 +995,12 @@ def test_results_infeasible( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= m.x) - m.c2 = pe.Constraint(expr=m.y <= m.x - 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y <= m.x - 1) with self.assertRaises(Exception): res = opt.solve(m) opt.config.load_solutions = False @@ -546,15 +1027,15 @@ def test_results_infeasible( # even if it did not converge; raise_exception_on_nonoptimal_result # is set to False, so we are free to load infeasible solutions with self.assertRaisesRegex( - RuntimeError, '.*does not currently have a valid solution.*' + NoSolutionError, '.*does not currently have a valid solution.*' ): res.solution_loader.load_vars() with self.assertRaisesRegex( - RuntimeError, '.*does not currently have valid duals.*' + NoDualsError, '.*does not currently have valid duals.*' ): res.solution_loader.get_duals() with self.assertRaisesRegex( - RuntimeError, '.*does not currently have valid reduced costs.*' + NoReducedCostsError, '.*does not currently have valid reduced costs.*' ): res.solution_loader.get_reduced_costs() @@ -568,12 +1049,12 @@ def test_duals(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y - m.x >= 0) - m.c2 = pe.Constraint(expr=m.y + m.x - 2 >= 0) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y - m.x >= 0) + m.c2 = pyo.Constraint(expr=m.y + m.x - 2 >= 0) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 1) @@ -598,13 +1079,13 @@ def test_mutable_quadratic_coefficient( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=-1, mutable=True) - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c = pe.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=-1, mutable=True) + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c = pyo.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0.41024548525899274, 4) @@ -627,15 +1108,15 @@ def test_mutable_quadratic_objective( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a = pe.Param(initialize=1, mutable=True) - m.b = pe.Param(initialize=-1, mutable=True) - m.c = pe.Param(initialize=1, mutable=True) - m.d = pe.Param(initialize=1, mutable=True) - m.obj = pe.Objective(expr=m.x**2 + m.c * m.y**2 + m.d * m.x) - m.ccon = pe.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a = pyo.Param(initialize=1, mutable=True) + m.b = pyo.Param(initialize=-1, mutable=True) + m.c = pyo.Param(initialize=1, mutable=True) + m.d = pyo.Param(initialize=1, mutable=True) + m.obj = pyo.Objective(expr=m.x**2 + m.c * m.y**2 + m.d * m.x) + m.ccon = pyo.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0.2719178742733325, 4) @@ -654,9 +1135,7 @@ def test_fixed_vars( for treat_fixed_vars_as_params in [True, False]: opt: SolverBase = opt_class() if opt.is_persistent(): - opt.config.auto_updates.treat_fixed_vars_as_params = ( - treat_fixed_vars_as_params - ) + opt = opt_class(treat_fixed_vars_as_params=treat_fixed_vars_as_params) if not opt.available(): raise unittest.SkipTest(f'Solver {opt.name} not available.') if any(name.startswith(i) for i in nl_solvers_set): @@ -664,17 +1143,17 @@ def test_fixed_vars( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() m.x.fix(0) - m.y = pe.Var() + m.y = pyo.Var() a1 = 1 a2 = -1 b1 = 1 b2 = 2 - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= a1 * m.x + b1) - m.c2 = pe.Constraint(expr=m.y >= a2 * m.x + b2) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= a1 * m.x + b1) + m.c2 = pyo.Constraint(expr=m.y >= a2 * m.x + b2) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) @@ -701,7 +1180,7 @@ def test_fixed_vars_2( ): opt: SolverBase = opt_class() if opt.is_persistent(): - opt.config.auto_updates.treat_fixed_vars_as_params = True + opt = opt_class(treat_fixed_vars_as_params=True) if not opt.available(): raise unittest.SkipTest(f'Solver {opt.name} not available.') if any(name.startswith(i) for i in nl_solvers_set): @@ -709,17 +1188,17 @@ def test_fixed_vars_2( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() m.x.fix(0) - m.y = pe.Var() + m.y = pyo.Var() a1 = 1 a2 = -1 b1 = 1 b2 = 2 - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= a1 * m.x + b1) - m.c2 = pe.Constraint(expr=m.y >= a2 * m.x + b2) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= a1 * m.x + b1) + m.c2 = pyo.Constraint(expr=m.y >= a2 * m.x + b2) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) @@ -746,7 +1225,7 @@ def test_fixed_vars_3( ): opt: SolverBase = opt_class() if opt.is_persistent(): - opt.config.auto_updates.treat_fixed_vars_as_params = True + opt = opt_class(treat_fixed_vars_as_params=True) if not opt.available(): raise unittest.SkipTest(f'Solver {opt.name} not available.') if any(name.startswith(i) for i in nl_solvers_set): @@ -754,11 +1233,11 @@ def test_fixed_vars_3( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x + m.y) - m.c1 = pe.Constraint(expr=m.x == 2 / m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x + m.y) + m.c1 = pyo.Constraint(expr=m.x == 2 / m.y) m.y.fix(1) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 3) @@ -770,7 +1249,7 @@ def test_fixed_vars_4( ): opt: SolverBase = opt_class() if opt.is_persistent(): - opt.config.auto_updates.treat_fixed_vars_as_params = True + opt = opt_class(treat_fixed_vars_as_params=True) if not opt.available(): raise unittest.SkipTest(f'Solver {opt.name} not available.') if any(name.startswith(i) for i in nl_solvers_set): @@ -778,11 +1257,11 @@ def test_fixed_vars_4( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.x == 2 / m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.x == 2 / m.y) m.y.fix(1) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 2) @@ -803,18 +1282,18 @@ def test_mutable_param_with_range( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(initialize=0, mutable=True) - m.a2 = pe.Param(initialize=0, mutable=True) - m.b1 = pe.Param(initialize=0, mutable=True) - m.b2 = pe.Param(initialize=0, mutable=True) - m.c1 = pe.Param(initialize=0, mutable=True) - m.c2 = pe.Param(initialize=0, mutable=True) - m.obj = pe.Objective(expr=m.y) - m.con1 = pe.Constraint(expr=(m.b1, m.y - m.a1 * m.x, m.c1)) - m.con2 = pe.Constraint(expr=(m.b2, m.y - m.a2 * m.x, m.c2)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(initialize=0, mutable=True) + m.a2 = pyo.Param(initialize=0, mutable=True) + m.b1 = pyo.Param(initialize=0, mutable=True) + m.b2 = pyo.Param(initialize=0, mutable=True) + m.c1 = pyo.Param(initialize=0, mutable=True) + m.c2 = pyo.Param(initialize=0, mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.con1 = pyo.Constraint(expr=(m.b1, m.y - m.a1 * m.x, m.c1)) + m.con2 = pyo.Constraint(expr=(m.b2, m.y - m.a2 * m.x, m.c2)) np.random.seed(0) params_to_test = [ @@ -825,7 +1304,7 @@ def test_mutable_param_with_range( np.random.uniform(-5, 2.5), np.random.uniform(2.5, 10), np.random.uniform(2.5, 10), - pe.minimize, + pyo.minimize, ), ( np.random.uniform(0, 10), @@ -834,7 +1313,7 @@ def test_mutable_param_with_range( np.random.uniform(-5, 2.5), np.random.uniform(2.5, 10), np.random.uniform(2.5, 10), - pe.maximize, + pyo.maximize, ), ( np.random.uniform(0, 10), @@ -843,7 +1322,7 @@ def test_mutable_param_with_range( np.random.uniform(-5, 2.5), np.random.uniform(2.5, 10), np.random.uniform(2.5, 10), - pe.minimize, + pyo.minimize, ), ( np.random.uniform(0, 10), @@ -852,7 +1331,7 @@ def test_mutable_param_with_range( np.random.uniform(-5, 2.5), np.random.uniform(2.5, 10), np.random.uniform(2.5, 10), - pe.maximize, + pyo.maximize, ), ] for a1, a2, b1, b2, c1, c2, sense in params_to_test: @@ -865,7 +1344,7 @@ def test_mutable_param_with_range( m.obj.sense = sense res: Results = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) - if sense is pe.minimize: + if sense is pyo.minimize: self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2), 6) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1, 6) self.assertAlmostEqual(res.incumbent_objective, m.y.value, 6) @@ -900,9 +1379,9 @@ def test_add_and_remove_vars( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.y = pe.Var(bounds=(-1, None)) - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.y = pyo.Var(bounds=(-1, None)) + m.obj = pyo.Objective(expr=m.y) if opt.is_persistent(): opt.config.auto_updates.update_parameters = False opt.config.auto_updates.update_vars = False @@ -916,13 +1395,13 @@ def test_add_and_remove_vars( self.assertEqual(res.solution_status, SolutionStatus.optimal) res.solution_loader.load_vars() self.assertAlmostEqual(m.y.value, -1) - m.x = pe.Var() + m.x = pyo.Var() a1 = 1 a2 = -1 b1 = 2 b2 = 1 - m.c1 = pe.Constraint(expr=(0, m.y - a1 * m.x - b1, None)) - m.c2 = pe.Constraint(expr=(None, -m.y + a2 * m.x + b2, 0)) + m.c1 = pyo.Constraint(expr=(0, m.y - a1 * m.x - b1, None)) + m.c2 = pyo.Constraint(expr=(None, -m.y + a2 * m.x + b2, 0)) if opt.is_persistent(): opt.add_constraints([m.c1, m.c2]) res = opt.solve(m) @@ -951,11 +1430,11 @@ def test_exp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y >= pe.exp(m.x)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y >= pyo.exp(m.x)) res = opt.solve(m) self.assertAlmostEqual(m.x.value, -0.42630274815985264) self.assertAlmostEqual(m.y.value, 0.6529186341994245) @@ -970,11 +1449,11 @@ def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(initialize=1) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y <= pe.log(m.x)) + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y <= pyo.log(m.x)) res = opt.solve(m) self.assertAlmostEqual(m.x.value, 0.6529186341994245) self.assertAlmostEqual(m.y.value, -0.42630274815985264) @@ -991,18 +1470,18 @@ def test_with_numpy( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) a1 = 1 b1 = 3 a2 = -2 b2 = 1 - m.c1 = pe.Constraint( + m.c1 = pyo.Constraint( expr=(np.float64(0), m.y - np.int64(1) * m.x - np.float32(3), None) ) - m.c2 = pe.Constraint( + m.c2 = pyo.Constraint( expr=(None, -m.y + np.int32(-2) * m.x + np.float64(1), np.float16(0)) ) res = opt.solve(m) @@ -1022,12 +1501,12 @@ def test_bounds_with_params( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.y = pe.Var() - m.p = pe.Param(mutable=True) + m = pyo.ConcreteModel() + m.y = pyo.Var() + m.p = pyo.Param(mutable=True) m.y.setlb(m.p) m.p.value = 1 - m.obj = pe.Objective(expr=m.y) + m.obj = pyo.Objective(expr=m.y) res = opt.solve(m) self.assertAlmostEqual(m.y.value, 1) m.p.value = -1 @@ -1035,7 +1514,7 @@ def test_bounds_with_params( self.assertAlmostEqual(m.y.value, -1) m.y.setlb(None) m.y.setub(m.p) - m.obj.sense = pe.maximize + m.obj.sense = pyo.maximize m.p.value = 5 res = opt.solve(m) self.assertAlmostEqual(m.y.value, 5) @@ -1044,7 +1523,7 @@ def test_bounds_with_params( self.assertAlmostEqual(m.y.value, 4) m.y.setub(None) m.y.setlb(m.p) - m.obj.sense = pe.minimize + m.obj.sense = pyo.minimize m.p.value = 3 res = opt.solve(m) self.assertAlmostEqual(m.y.value, 3) @@ -1061,12 +1540,12 @@ def test_solution_loader( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(1, None)) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=(0, m.y - m.x, None)) - m.c2 = pe.Constraint(expr=(0, m.y - m.x + 1, None)) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(1, None)) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=(0, m.y - m.x, None)) + m.c2 = pyo.Constraint(expr=(0, m.y - m.x + 1, None)) opt.config.load_solutions = False res = opt.solve(m) self.assertIsNone(m.x.value) @@ -1124,10 +1603,10 @@ def test_time_limit( raise unittest.SkipTest N = 30 - m = pe.ConcreteModel() - m.jobs = pe.Set(initialize=list(range(N))) - m.tasks = pe.Set(initialize=list(range(N))) - m.x = pe.Var(m.jobs, m.tasks, bounds=(0, 1)) + m = pyo.ConcreteModel() + m.jobs = pyo.Set(initialize=list(range(N))) + m.tasks = pyo.Set(initialize=list(range(N))) + m.x = pyo.Var(m.jobs, m.tasks, bounds=(0, 1)) random.seed(0) coefs = list() @@ -1139,10 +1618,10 @@ def test_time_limit( obj_expr = LinearExpression( linear_coefs=coefs, linear_vars=lin_vars, constant=0 ) - m.obj = pe.Objective(expr=obj_expr, sense=pe.maximize) + m.obj = pyo.Objective(expr=obj_expr, sense=pyo.maximize) - m.c1 = pe.Constraint(m.jobs) - m.c2 = pe.Constraint(m.tasks) + m.c1 = pyo.Constraint(m.jobs) + m.c2 = pyo.Constraint(m.tasks) for j in m.jobs: expr = LinearExpression( linear_coefs=[1] * N, @@ -1181,22 +1660,22 @@ def test_objective_changes( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.c1 = pe.Constraint(expr=m.y >= m.x + 1) - m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c1 = pyo.Constraint(expr=m.y >= m.x + 1) + m.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) + m.obj = pyo.Objective(expr=m.y) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) del m.obj - m.obj = pe.Objective(expr=2 * m.y) + m.obj = pyo.Objective(expr=2 * m.y) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 2) m.obj.expr = 3 * m.y res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 3) - m.obj.sense = pe.maximize + m.obj.sense = pyo.maximize opt.config.raise_exception_on_nonoptimal_result = False opt.config.load_solutions = False res = opt.solve(m) @@ -1207,10 +1686,10 @@ def test_objective_changes( TerminationCondition.infeasibleOrUnbounded, }, ) - m.obj.sense = pe.minimize + m.obj.sense = pyo.minimize opt.config.load_solutions = True del m.obj - m.obj = pe.Objective(expr=m.x * m.y) + m.obj = pyo.Objective(expr=m.x * m.y) m.x.fix(2) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 6, 6) @@ -1226,9 +1705,9 @@ def test_objective_changes( m.y.unfix() m.x.setlb(None) m.x.setub(None) - m.e = pe.Expression(expr=2) + m.e = pyo.Expression(expr=2) del m.obj - m.obj = pe.Objective(expr=m.e * m.y) + m.obj = pyo.Objective(expr=m.e * m.y) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 2) m.e.expr = 3 @@ -1250,9 +1729,9 @@ def test_domain(self, name: str, opt_class: Type[SolverBase], use_presolve: bool opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(1, None), domain=pe.NonNegativeReals) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(1, None), domain=pyo.NonNegativeReals) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) m.x.setlb(-1) @@ -1262,10 +1741,10 @@ def test_domain(self, name: str, opt_class: Type[SolverBase], use_presolve: bool res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) m.x.setlb(-1) - m.x.domain = pe.Reals + m.x.domain = pyo.Reals res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, -1) - m.x.domain = pe.NonNegativeReals + m.x.domain = pyo.NonNegativeReals res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 0) @@ -1281,19 +1760,19 @@ def test_domain_with_integers( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-1, None), domain=pe.NonNegativeIntegers) - m.obj = pe.Objective(expr=m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, None), domain=pyo.NonNegativeIntegers) + m.obj = pyo.Objective(expr=m.x) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 0) m.x.setlb(0.5) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) m.x.setlb(-5.5) - m.x.domain = pe.Integers + m.x.domain = pyo.Integers res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, -5) - m.x.domain = pe.Binary + m.x.domain = pyo.Binary res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 0) m.x.setlb(0.5) @@ -1312,11 +1791,11 @@ def test_fixed_binaries( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(domain=pe.Binary) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c = pe.Constraint(expr=m.y >= m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(domain=pyo.Binary) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c = pyo.Constraint(expr=m.y >= m.x) m.x.fix(0) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 0) @@ -1324,9 +1803,10 @@ def test_fixed_binaries( res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) - opt: SolverBase = opt_class() if opt.is_persistent(): - opt.config.auto_updates.treat_fixed_vars_as_params = False + opt: SolverBase = opt_class(treat_fixed_vars_as_params=False) + else: + opt = opt_class() m.x.fix(0) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 0) @@ -1345,18 +1825,18 @@ def test_with_gdp(self, name: str, opt_class: Type[SolverBase], use_presolve: bo else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-10, 10)) - m.y = pe.Var(bounds=(-10, 10)) - m.obj = pe.Objective(expr=m.y) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var(bounds=(-10, 10)) + m.obj = pyo.Objective(expr=m.y) m.d1 = gdp.Disjunct() - m.d1.c1 = pe.Constraint(expr=m.y >= m.x + 2) - m.d1.c2 = pe.Constraint(expr=m.y >= -m.x + 2) + m.d1.c1 = pyo.Constraint(expr=m.y >= m.x + 2) + m.d1.c2 = pyo.Constraint(expr=m.y >= -m.x + 2) m.d2 = gdp.Disjunct() - m.d2.c1 = pe.Constraint(expr=m.y >= m.x + 1) - m.d2.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + m.d2.c1 = pyo.Constraint(expr=m.y >= m.x + 1) + m.d2.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) m.disjunction = gdp.Disjunction(expr=[m.d2, m.d1]) - pe.TransformationFactory("gdp.bigm").apply_to(m) + pyo.TransformationFactory("gdp.bigm").apply_to(m) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) @@ -1383,13 +1863,13 @@ def test_variables_elsewhere( else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.b = pe.Block() - m.b.obj = pe.Objective(expr=m.y) - m.b.c1 = pe.Constraint(expr=m.y >= m.x + 2) - m.b.c2 = pe.Constraint(expr=m.y >= -m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.b = pyo.Block() + m.b.obj = pyo.Objective(expr=m.y) + m.b.c1 = pyo.Constraint(expr=m.y >= m.x + 2) + m.b.c2 = pyo.Constraint(expr=m.y >= -m.x) res = opt.solve(m.b) self.assertEqual(res.solution_status, SolutionStatus.optimal) @@ -1417,16 +1897,16 @@ def test_variables_elsewhere2( else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= m.x) - m.c2 = pe.Constraint(expr=m.y >= -m.x) - m.c3 = pe.Constraint(expr=m.y >= m.z + 1) - m.c4 = pe.Constraint(expr=m.y >= -m.z + 1) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y >= -m.x) + m.c3 = pyo.Constraint(expr=m.y >= m.z + 1) + m.c4 = pyo.Constraint(expr=m.y >= -m.z + 1) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) @@ -1457,13 +1937,13 @@ def test_bug_1(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(3, 7)) - m.y = pe.Var(bounds=(-10, 10)) - m.p = pe.Param(mutable=True, initialize=0) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(3, 7)) + m.y = pyo.Var(bounds=(-10, 10)) + m.p = pyo.Param(mutable=True, initialize=0) - m.obj = pe.Objective(expr=m.y) - m.c = pe.Constraint(expr=m.y >= m.p * m.x) + m.obj = pyo.Objective(expr=m.y) + m.c = pyo.Constraint(expr=m.y >= m.p * m.x) res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) @@ -1482,6 +1962,8 @@ def test_bug_2(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) """ for fixed_var_option in [True, False]: opt: SolverBase = opt_class() + if opt.is_persistent(): + opt = opt_class(treat_fixed_vars_as_params=fixed_var_option) if not opt.available(): raise unittest.SkipTest(f'Solver {opt.name} not available.') if any(name.startswith(i) for i in nl_solvers_set): @@ -1489,14 +1971,12 @@ def test_bug_2(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False - if opt.is_persistent(): - opt.config.auto_updates.treat_fixed_vars_as_params = fixed_var_option - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-10, 10)) - m.y = pe.Var() - m.obj = pe.Objective(expr=3 * m.y - m.x) - m.c = pe.Constraint(expr=m.y >= m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=3 * m.y - m.x) + m.c = pyo.Constraint(expr=m.y >= m.x) m.x.fix(1) res = opt.solve(m) @@ -1508,6 +1988,71 @@ def test_bug_2(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, -18, 5) + @parameterized.expand(input=_load_tests(nl_solvers)) + def test_presolve_with_zero_coef( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if use_presolve: + opt.config.writer_config.linear_presolve = True + else: + opt.config.writer_config.linear_presolve = False + + """ + when c2 gets presolved out, c1 becomes + x - y + y = 0 which becomes + x - 0*y == 0 which is the zero we are testing for + """ + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2 + m.z**2) + m.c1 = pyo.Constraint(expr=m.x == m.y + m.z + 1.5) + m.c2 = pyo.Constraint(expr=m.z == -m.y) + + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 2.25) + self.assertAlmostEqual(m.x.value, 1.5) + self.assertAlmostEqual(m.y.value, 0) + self.assertAlmostEqual(m.z.value, 0) + + m.x.setlb(2) + res = opt.solve( + m, load_solutions=False, raise_exception_on_nonoptimal_result=False + ) + if use_presolve: + exp = TerminationCondition.provenInfeasible + else: + exp = TerminationCondition.locallyInfeasible + self.assertEqual(res.termination_condition, exp) + + m = pyo.ConcreteModel() + m.w = pyo.Var() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2 + m.z**2 + m.w**2) + m.c1 = pyo.Constraint(expr=m.x + m.w == m.y + m.z) + m.c2 = pyo.Constraint(expr=m.z == -m.y) + m.c3 = pyo.Constraint(expr=m.x == -m.w) + + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 0) + self.assertAlmostEqual(m.w.value, 0) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 0) + self.assertAlmostEqual(m.z.value, 0) + + del m.c1 + m.c1 = pyo.Constraint(expr=m.x + m.w == m.y + m.z + 1.5) + res = opt.solve( + m, load_solutions=False, raise_exception_on_nonoptimal_result=False + ) + self.assertEqual(res.termination_condition, exp) + @parameterized.expand(input=_load_tests(all_solvers)) def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() @@ -1521,13 +2066,13 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo else: opt.config.writer_config.linear_presolve = False - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=m.y >= (m.x - 1) + 1) - m.c2 = pe.Constraint(expr=m.y >= -(m.x - 1) + 1) - m.scaling_factor = pe.Suffix(direction=pe.Suffix.EXPORT) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= (m.x - 1) + 1) + m.c2 = pyo.Constraint(expr=m.y >= -(m.x - 1) + 1) + m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) m.scaling_factor[m.x] = 0.5 m.scaling_factor[m.y] = 2 m.scaling_factor[m.c1] = 0.5 @@ -1569,20 +2114,20 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo class TestLegacySolverInterface(unittest.TestCase): @parameterized.expand(input=all_solvers) def test_param_updates(self, name: str, opt_class: Type[SolverBase]): - opt = pe.SolverFactory(name + '_v2') + opt = pyo.SolverFactory(name + '_v2') if not opt.available(exception_flag=False): raise unittest.SkipTest(f'Solver {opt.name} not available.') - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.a1 = pe.Param(mutable=True) - m.a2 = pe.Param(mutable=True) - m.b1 = pe.Param(mutable=True) - m.b2 = pe.Param(mutable=True) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) - m.c2 = pe.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) - m.dual = pe.Suffix(direction=pe.Suffix.IMPORT) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.a1 = pyo.Param(mutable=True) + m.a2 = pyo.Param(mutable=True) + m.b1 = pyo.Param(mutable=True) + m.b2 = pyo.Param(mutable=True) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) + m.c2 = pyo.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -1591,7 +2136,7 @@ def test_param_updates(self, name: str, opt_class: Type[SolverBase]): m.b1.value = b1 m.b2.value = b2 res = opt.solve(m) - pe.assert_optimal_termination(res) + pyo.assert_optimal_termination(res) self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) @@ -1599,16 +2144,16 @@ def test_param_updates(self, name: str, opt_class: Type[SolverBase]): @parameterized.expand(input=all_solvers) def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): - opt = pe.SolverFactory(name + '_v2') + opt = pyo.SolverFactory(name + '_v2') if not opt.available(exception_flag=False): raise unittest.SkipTest(f'Solver {opt.name} not available.') - m = pe.ConcreteModel() - m.x = pe.Var() - m.obj = pe.Objective(expr=m.x) - m.c = pe.Constraint(expr=(-1, m.x, 1)) - m.dual = pe.Suffix(direction=pe.Suffix.IMPORT) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.obj = pyo.Objective(expr=m.x) + m.c = pyo.Constraint(expr=(-1, m.x, 1)) + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) res = opt.solve(m, load_solutions=False) - pe.assert_optimal_termination(res) + pyo.assert_optimal_termination(res) self.assertIsNone(m.x.value) self.assertNotIn(m.c, m.dual) m.solutions.load_from(res) diff --git a/pyomo/contrib/solver/tests/unit/__init__.py b/pyomo/contrib/solver/tests/unit/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/solver/tests/unit/__init__.py +++ b/pyomo/contrib/solver/tests/unit/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/solver/tests/unit/sol_files/__init__.py b/pyomo/contrib/solver/tests/unit/sol_files/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/solver/tests/unit/sol_files/__init__.py +++ b/pyomo/contrib/solver/tests/unit/sol_files/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/solver/tests/unit/test_base.py b/pyomo/contrib/solver/tests/unit/test_base.py index 74c495b86cc..a93ac645f0f 100644 --- a/pyomo/contrib/solver/tests/unit/test_base.py +++ b/pyomo/contrib/solver/tests/unit/test_base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,96 +13,51 @@ from pyomo.common import unittest from pyomo.common.config import ConfigDict -from pyomo.contrib.solver import base +from pyomo.contrib.solver.common import base -class TestSolverBase(unittest.TestCase): - def test_abstract_member_list(self): - expected_list = ['solve', 'available', 'version'] - member_list = list(base.SolverBase.__abstractmethods__) - self.assertEqual(sorted(expected_list), sorted(member_list)) +class _LegacyWrappedSolverBase(base.LegacySolverWrapper, base.SolverBase): + pass + +class TestSolverBase(unittest.TestCase): def test_class_method_list(self): - expected_list = [ - 'Availability', - 'CONFIG', - 'available', - 'is_persistent', - 'solve', - 'version', - ] + expected_list = ['CONFIG', 'available', 'is_persistent', 'solve', 'version'] method_list = [ method for method in dir(base.SolverBase) if method.startswith('_') is False ] self.assertEqual(sorted(expected_list), sorted(method_list)) - @unittest.mock.patch.multiple(base.SolverBase, __abstractmethods__=set()) def test_init(self): - self.instance = base.SolverBase() - self.assertFalse(self.instance.is_persistent()) - self.assertEqual(self.instance.version(), None) - self.assertEqual(self.instance.name, 'solverbase') - self.assertEqual(self.instance.CONFIG, self.instance.config) - self.assertEqual(self.instance.solve(None), None) - self.assertEqual(self.instance.available(), None) + instance = base.SolverBase() + self.assertFalse(instance.is_persistent()) + self.assertEqual(instance.name, 'solverbase') + self.assertEqual(instance.CONFIG, instance.config) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.version(), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.solve(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.available(), None) - @unittest.mock.patch.multiple(base.SolverBase, __abstractmethods__=set()) def test_context_manager(self): - with base.SolverBase() as self.instance: - self.assertFalse(self.instance.is_persistent()) - self.assertEqual(self.instance.version(), None) - self.assertEqual(self.instance.name, 'solverbase') - self.assertEqual(self.instance.CONFIG, self.instance.config) - self.assertEqual(self.instance.solve(None), None) - self.assertEqual(self.instance.available(), None) + with base.SolverBase() as instance: + self.assertFalse(instance.is_persistent()) + self.assertEqual(instance.name, 'solverbase') + self.assertEqual(instance.CONFIG, instance.config) - @unittest.mock.patch.multiple(base.SolverBase, __abstractmethods__=set()) def test_config_kwds(self): - self.instance = base.SolverBase(tee=True) - self.assertTrue(self.instance.config.tee) - - @unittest.mock.patch.multiple(base.SolverBase, __abstractmethods__=set()) - def test_solver_availability(self): - self.instance = base.SolverBase() - self.instance.Availability._value_ = 1 - self.assertTrue(self.instance.Availability.__bool__(self.instance.Availability)) - self.instance.Availability._value_ = -1 - self.assertFalse( - self.instance.Availability.__bool__(self.instance.Availability) - ) + instance = base.SolverBase(tee=True) + self.assertTrue(instance.config.tee) - @unittest.mock.patch.multiple(base.SolverBase, __abstractmethods__=set()) def test_custom_solver_name(self): - self.instance = base.SolverBase(name='my_unique_name') - self.assertEqual(self.instance.name, 'my_unique_name') + instance = base.SolverBase(name='my_unique_name') + self.assertEqual(instance.name, 'my_unique_name') class TestPersistentSolverBase(unittest.TestCase): - def test_abstract_member_list(self): - expected_list = [ - 'remove_parameters', - 'version', - 'update_variables', - 'remove_variables', - 'add_constraints', - '_get_primals', - 'set_instance', - 'set_objective', - 'update_parameters', - 'remove_block', - 'add_block', - 'available', - 'add_parameters', - 'remove_constraints', - 'add_variables', - 'solve', - ] - member_list = list(base.PersistentSolverBase.__abstractmethods__) - self.assertEqual(sorted(expected_list), sorted(member_list)) - def test_class_method_list(self): expected_list = [ - 'Availability', 'CONFIG', '_get_duals', '_get_primals', @@ -132,53 +87,54 @@ def test_class_method_list(self): ] self.assertEqual(sorted(expected_list), sorted(method_list)) - @unittest.mock.patch.multiple(base.PersistentSolverBase, __abstractmethods__=set()) def test_init(self): - self.instance = base.PersistentSolverBase() - self.assertTrue(self.instance.is_persistent()) - self.assertEqual(self.instance.set_instance(None), None) - self.assertEqual(self.instance.add_variables(None), None) - self.assertEqual(self.instance.add_parameters(None), None) - self.assertEqual(self.instance.add_constraints(None), None) - self.assertEqual(self.instance.add_block(None), None) - self.assertEqual(self.instance.remove_variables(None), None) - self.assertEqual(self.instance.remove_parameters(None), None) - self.assertEqual(self.instance.remove_constraints(None), None) - self.assertEqual(self.instance.remove_block(None), None) - self.assertEqual(self.instance.set_objective(None), None) - self.assertEqual(self.instance.update_variables(None), None) - self.assertEqual(self.instance.update_parameters(), None) - + instance = base.PersistentSolverBase() + self.assertTrue(instance.is_persistent()) with self.assertRaises(NotImplementedError): - self.instance._get_primals() - + self.assertEqual(instance.set_instance(None), None) with self.assertRaises(NotImplementedError): - self.instance._get_duals() - + self.assertEqual(instance.add_variables(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.add_parameters(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.add_constraints(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.add_block(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.remove_variables(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.remove_parameters(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.remove_constraints(None), None) with self.assertRaises(NotImplementedError): - self.instance._get_reduced_costs() + self.assertEqual(instance.remove_block(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.set_objective(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.update_variables(None), None) + with self.assertRaises(NotImplementedError): + self.assertEqual(instance.update_parameters(), None) + with self.assertRaises(NotImplementedError): + instance._get_primals() + with self.assertRaises(NotImplementedError): + instance._get_duals() + with self.assertRaises(NotImplementedError): + instance._get_reduced_costs() - @unittest.mock.patch.multiple(base.PersistentSolverBase, __abstractmethods__=set()) def test_context_manager(self): - with base.PersistentSolverBase() as self.instance: - self.assertTrue(self.instance.is_persistent()) - self.assertEqual(self.instance.set_instance(None), None) - self.assertEqual(self.instance.add_variables(None), None) - self.assertEqual(self.instance.add_parameters(None), None) - self.assertEqual(self.instance.add_constraints(None), None) - self.assertEqual(self.instance.add_block(None), None) - self.assertEqual(self.instance.remove_variables(None), None) - self.assertEqual(self.instance.remove_parameters(None), None) - self.assertEqual(self.instance.remove_constraints(None), None) - self.assertEqual(self.instance.remove_block(None), None) - self.assertEqual(self.instance.set_objective(None), None) - self.assertEqual(self.instance.update_variables(None), None) - self.assertEqual(self.instance.update_parameters(), None) + with base.PersistentSolverBase() as instance: + self.assertTrue(instance.is_persistent()) class TestLegacySolverWrapper(unittest.TestCase): def test_class_method_list(self): - expected_list = ['available', 'license_is_valid', 'solve'] + expected_list = [ + 'available', + 'config_block', + 'license_is_valid', + 'set_options', + 'solve', + ] method_list = [ method for method in dir(base.LegacySolverWrapper) @@ -187,9 +143,10 @@ def test_class_method_list(self): self.assertEqual(sorted(expected_list), sorted(method_list)) def test_context_manager(self): - with base.LegacySolverWrapper() as instance: - with self.assertRaises(AttributeError): - instance.available() + with _LegacyWrappedSolverBase() as instance: + self.assertIsInstance(instance, _LegacyWrappedSolverBase) + with self.assertRaises(NotImplementedError): + self.assertFalse(instance.available(False)) def test_map_config(self): # Create a fake/empty config structure that can be added to an empty @@ -199,7 +156,7 @@ def test_map_config(self): 'solver_options', ConfigDict(implicit=True, description="Options to pass to the solver."), ) - instance = base.LegacySolverWrapper() + instance = _LegacyWrappedSolverBase() instance.config = self.config instance._map_config( True, False, False, 20, True, False, None, None, None, False, None, None @@ -207,9 +164,7 @@ def test_map_config(self): self.assertTrue(instance.config.tee) self.assertFalse(instance.config.load_solutions) self.assertEqual(instance.config.time_limit, 20) - # Report timing shouldn't be created because it no longer exists - with self.assertRaises(AttributeError): - print(instance.config.report_timing) + self.assertEqual(instance.config.report_timing, True) # Keepfiles should not be created because we did not declare keepfiles on # the original config with self.assertRaises(AttributeError): @@ -268,6 +223,81 @@ def test_map_config(self): with self.assertRaises(AttributeError): print(instance.config.keepfiles) + def test_solver_options_behavior(self): + # options can work in multiple ways (set from instantiation, set + # after instantiation, set during solve). + # Test case 1: Set at instantiation + solver = _LegacyWrappedSolverBase(options={'max_iter': 6}) + self.assertEqual(solver.options, {'max_iter': 6}) + self.assertEqual(solver.config.solver_options, {'max_iter': 6}) + + # Test case 2: Set later + solver = _LegacyWrappedSolverBase() + solver.options = {'max_iter': 4, 'foo': 'bar'} + self.assertEqual(solver.options, {'max_iter': 4, 'foo': 'bar'}) + self.assertEqual(solver.config.solver_options, {'max_iter': 4, 'foo': 'bar'}) + + # Test case 3: pass some options to the mapping (aka, 'solve' command) + solver = _LegacyWrappedSolverBase() + solver._map_config(options={'max_iter': 4}) + self.assertEqual(solver.options, {'max_iter': 4}) + self.assertEqual(solver.config.solver_options, {'max_iter': 4}) + + # Test case 4: Set at instantiation and override during 'solve' call + solver = _LegacyWrappedSolverBase(options={'max_iter': 6}) + solver._map_config(options={'max_iter': 4}) + self.assertEqual(solver.options, {'max_iter': 4}) + self.assertEqual(solver.config.solver_options, {'max_iter': 4}) + + # solver_options are also supported + # Test case 1: set at instantiation + solver = _LegacyWrappedSolverBase(solver_options={'max_iter': 6}) + self.assertEqual(solver.options, {'max_iter': 6}) + self.assertEqual(solver.config.solver_options, {'max_iter': 6}) + + # Test case 2: pass some solver_options to the mapping (aka, 'solve' command) + solver = _LegacyWrappedSolverBase() + solver._map_config(solver_options={'max_iter': 4}) + self.assertEqual(solver.options, {'max_iter': 4}) + self.assertEqual(solver.config.solver_options, {'max_iter': 4}) + + # Test case 3: Set at instantiation and override during 'solve' call + solver = _LegacyWrappedSolverBase(solver_options={'max_iter': 6}) + solver._map_config(solver_options={'max_iter': 4}) + self.assertEqual(solver.options, {'max_iter': 4}) + self.assertEqual(solver.config.solver_options, {'max_iter': 4}) + + # users can mix... sort of + # Test case 1: Initialize with options, solve with solver_options + solver = _LegacyWrappedSolverBase(options={'max_iter': 6}) + solver._map_config(solver_options={'max_iter': 4}) + self.assertEqual(solver.options, {'max_iter': 4}) + self.assertEqual(solver.config.solver_options, {'max_iter': 4}) + + # users CANNOT initialize both values at the same time, because how + # do we know what to do with it then? + # Test case 1: Class instance + with self.assertRaises(ValueError): + solver = _LegacyWrappedSolverBase( + options={'max_iter': 6}, solver_options={'max_iter': 4} + ) + # Test case 2: Passing to `solve` + solver = _LegacyWrappedSolverBase() + with self.assertRaises(ValueError): + solver._map_config(solver_options={'max_iter': 4}, options={'max_iter': 6}) + + # Test that assignment to maps to set_value: + solver = _LegacyWrappedSolverBase() + config = ConfigDict(implicit=True) + config.declare( + 'solver_options', + ConfigDict(implicit=True, description="Options to pass to the solver."), + ) + solver.config = config + solver.config.solver_options.max_iter = 6 + self.assertEqual(solver.options, {'max_iter': 6}) + self.assertEqual(solver.config.solver_options, {'max_iter': 6}) + def test_map_results(self): # Unclear how to test this pass diff --git a/pyomo/contrib/solver/tests/unit/test_config.py b/pyomo/contrib/solver/tests/unit/test_config.py index 354cfd8a37a..9ae998edd52 100644 --- a/pyomo/contrib/solver/tests/unit/test_config.py +++ b/pyomo/contrib/solver/tests/unit/test_config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,14 +9,52 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import logging +import io + +import pyomo.environ as pyo from pyomo.common import unittest -from pyomo.contrib.solver.config import ( +from pyomo.common.log import LogStream +from pyomo.common.tee import capture_output +from pyomo.common.dependencies import attempt_import +from pyomo.contrib.solver.common.config import ( SolverConfig, BranchAndBoundConfig, AutoUpdateConfig, PersistentSolverConfig, + TextIO_or_Logger, ) +ipopt, ipopt_available = attempt_import('ipopt') + + +class TestTextIO_or_LoggerValidator(unittest.TestCase): + def test_booleans(self): + ans = TextIO_or_Logger(True) + self.assertTrue(isinstance(ans[0], io._io.TextIOWrapper)) + ans = TextIO_or_Logger(False) + self.assertEqual(ans, []) + + def test_logger(self): + logger = logging.getLogger('contrib.solver.config.test.1') + ans = TextIO_or_Logger(logger) + self.assertTrue(isinstance(ans[0], LogStream)) + + @unittest.skipIf(not ipopt_available, 'ipopt is not available') + def test_real_example(self): + + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2], initialize=1, bounds=(0, None)) + m.eq = pyo.Constraint(expr=m.x[1] * m.x[2] ** 1.5 == 3) + m.obj = pyo.Objective(expr=m.x[1] ** 2 + m.x[2] ** 2) + + solver = pyo.SolverFactory("ipopt_v2") + with capture_output() as OUT: + solver.solve(m, tee=True, timelimit=5) + + contents = OUT.getvalue() + self.assertIn('EXIT: Optimal Solution Found.', contents) + class TestSolverConfig(unittest.TestCase): def test_interface_default_instantiation(self): @@ -78,7 +116,6 @@ def test_interface_default_instantiation(self): self.assertTrue(config.update_named_expressions) self.assertTrue(config.update_objective) self.assertTrue(config.update_objective) - self.assertTrue(config.treat_fixed_vars_as_params) def test_interface_custom_instantiation(self): config = AutoUpdateConfig(description="A description") @@ -109,7 +146,6 @@ def test_interface_default_instantiation(self): self.assertTrue(config.auto_updates.update_named_expressions) self.assertTrue(config.auto_updates.update_objective) self.assertTrue(config.auto_updates.update_objective) - self.assertTrue(config.auto_updates.treat_fixed_vars_as_params) def test_interface_custom_instantiation(self): config = PersistentSolverConfig(description="A description") diff --git a/pyomo/contrib/solver/tests/unit/test_ipopt.py b/pyomo/contrib/solver/tests/unit/test_ipopt.py deleted file mode 100644 index cc459245506..00000000000 --- a/pyomo/contrib/solver/tests/unit/test_ipopt.py +++ /dev/null @@ -1,225 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -import os - -from pyomo.common import unittest, Executable -from pyomo.common.errors import DeveloperError -from pyomo.common.tempfiles import TempfileManager -from pyomo.repn.plugins.nl_writer import NLWriter -from pyomo.contrib.solver import ipopt - - -ipopt_available = ipopt.Ipopt().available() - - -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestIpoptSolverConfig(unittest.TestCase): - def test_default_instantiation(self): - config = ipopt.IpoptConfig() - # Should be inherited - self.assertIsNone(config._description) - self.assertEqual(config._visibility, 0) - self.assertFalse(config.tee) - self.assertTrue(config.load_solutions) - self.assertTrue(config.raise_exception_on_nonoptimal_result) - self.assertFalse(config.symbolic_solver_labels) - self.assertIsNone(config.timer) - self.assertIsNone(config.threads) - self.assertIsNone(config.time_limit) - # Unique to this object - self.assertIsInstance(config.executable, type(Executable('path'))) - self.assertIsInstance(config.writer_config, type(NLWriter.CONFIG())) - - def test_custom_instantiation(self): - config = ipopt.IpoptConfig(description="A description") - config.tee = True - self.assertTrue(config.tee) - self.assertEqual(config._description, "A description") - self.assertIsNone(config.time_limit) - # Default should be `ipopt` - self.assertIsNotNone(str(config.executable)) - self.assertIn('ipopt', str(config.executable)) - # Set to a totally bogus path - config.executable = Executable('/bogus/path') - self.assertIsNone(config.executable.executable) - self.assertFalse(config.executable.available()) - - -class TestIpoptSolutionLoader(unittest.TestCase): - def test_get_reduced_costs_error(self): - loader = ipopt.IpoptSolutionLoader(None, None) - with self.assertRaises(RuntimeError): - loader.get_reduced_costs() - - # Set _nl_info to something completely bogus but is not None - class NLInfo: - pass - - loader._nl_info = NLInfo() - loader._nl_info.eliminated_vars = [1, 2, 3] - with self.assertRaises(NotImplementedError): - loader.get_reduced_costs() - # Reset _nl_info so we can ensure we get an error - # when _sol_data is None - loader._nl_info.eliminated_vars = [] - with self.assertRaises(DeveloperError): - loader.get_reduced_costs() - - -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestIpoptInterface(unittest.TestCase): - def test_class_member_list(self): - opt = ipopt.Ipopt() - expected_list = [ - 'Availability', - 'CONFIG', - 'config', - 'available', - 'is_persistent', - 'solve', - 'version', - 'name', - ] - method_list = [method for method in dir(opt) if method.startswith('_') is False] - self.assertEqual(sorted(expected_list), sorted(method_list)) - - def test_default_instantiation(self): - opt = ipopt.Ipopt() - self.assertFalse(opt.is_persistent()) - self.assertIsNotNone(opt.version()) - self.assertEqual(opt.name, 'ipopt') - self.assertEqual(opt.CONFIG, opt.config) - self.assertTrue(opt.available()) - - def test_context_manager(self): - with ipopt.Ipopt() as opt: - self.assertFalse(opt.is_persistent()) - self.assertIsNotNone(opt.version()) - self.assertEqual(opt.name, 'ipopt') - self.assertEqual(opt.CONFIG, opt.config) - self.assertTrue(opt.available()) - - def test_available_cache(self): - opt = ipopt.Ipopt() - opt.available() - self.assertTrue(opt._available_cache[1]) - self.assertIsNotNone(opt._available_cache[0]) - # Now we will try with a custom config that has a fake path - config = ipopt.IpoptConfig() - config.executable = Executable('/a/bogus/path') - opt.available(config=config) - self.assertFalse(opt._available_cache[1]) - self.assertIsNone(opt._available_cache[0]) - - def test_version_cache(self): - opt = ipopt.Ipopt() - opt.version() - self.assertIsNotNone(opt._version_cache[0]) - self.assertIsNotNone(opt._version_cache[1]) - # Now we will try with a custom config that has a fake path - config = ipopt.IpoptConfig() - config.executable = Executable('/a/bogus/path') - opt.version(config=config) - self.assertIsNone(opt._version_cache[0]) - self.assertIsNone(opt._version_cache[1]) - - def test_write_options_file(self): - # If we have no options, we should get false back - opt = ipopt.Ipopt() - result = opt._write_options_file('fakename', None) - self.assertFalse(result) - # Pass it some options that ARE on the command line - opt = ipopt.Ipopt(solver_options={'max_iter': 4}) - result = opt._write_options_file('myfile', opt.config.solver_options) - self.assertFalse(result) - self.assertFalse(os.path.isfile('myfile.opt')) - # Now we are going to actually pass it some options that are NOT on - # the command line - opt = ipopt.Ipopt(solver_options={'custom_option': 4}) - with TempfileManager.new_context() as temp: - dname = temp.mkdtemp() - if not os.path.exists(dname): - os.mkdir(dname) - filename = os.path.join(dname, 'myfile') - result = opt._write_options_file(filename, opt.config.solver_options) - self.assertTrue(result) - self.assertTrue(os.path.isfile(filename + '.opt')) - # Make sure all options are writing to the file - opt = ipopt.Ipopt(solver_options={'custom_option_1': 4, 'custom_option_2': 3}) - with TempfileManager.new_context() as temp: - dname = temp.mkdtemp() - if not os.path.exists(dname): - os.mkdir(dname) - filename = os.path.join(dname, 'myfile') - result = opt._write_options_file(filename, opt.config.solver_options) - self.assertTrue(result) - self.assertTrue(os.path.isfile(filename + '.opt')) - with open(filename + '.opt', 'r') as f: - data = f.readlines() - self.assertEqual(len(data), len(list(opt.config.solver_options.keys()))) - - def test_create_command_line(self): - opt = ipopt.Ipopt() - # No custom options, no file created. Plain and simple. - result = opt._create_command_line('myfile', opt.config, False) - self.assertEqual(result, [str(opt.config.executable), 'myfile.nl', '-AMPL']) - # Custom command line options - opt = ipopt.Ipopt(solver_options={'max_iter': 4}) - result = opt._create_command_line('myfile', opt.config, False) - self.assertEqual( - result, [str(opt.config.executable), 'myfile.nl', '-AMPL', 'max_iter=4'] - ) - # Let's see if we correctly parse config.time_limit - opt = ipopt.Ipopt(solver_options={'max_iter': 4}, time_limit=10) - result = opt._create_command_line('myfile', opt.config, False) - self.assertEqual( - result, - [ - str(opt.config.executable), - 'myfile.nl', - '-AMPL', - 'max_iter=4', - 'max_cpu_time=10.0', - ], - ) - # Now let's do multiple command line options - opt = ipopt.Ipopt(solver_options={'max_iter': 4, 'max_cpu_time': 10}) - result = opt._create_command_line('myfile', opt.config, False) - self.assertEqual( - result, - [ - str(opt.config.executable), - 'myfile.nl', - '-AMPL', - 'max_cpu_time=10', - 'max_iter=4', - ], - ) - # Let's now include if we "have" an options file - result = opt._create_command_line('myfile', opt.config, True) - self.assertEqual( - result, - [ - str(opt.config.executable), - 'myfile.nl', - '-AMPL', - 'option_file_name=myfile.opt', - 'max_cpu_time=10', - 'max_iter=4', - ], - ) - # Finally, let's make sure it errors if someone tries to pass option_file_name - opt = ipopt.Ipopt( - solver_options={'max_iter': 4, 'option_file_name': 'myfile.opt'} - ) - with self.assertRaises(ValueError): - result = opt._create_command_line('myfile', opt.config, False) diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py index 74404aaba4c..a818f4ff4ad 100644 --- a/pyomo/contrib/solver/tests/unit/test_results.py +++ b/pyomo/contrib/solver/tests/unit/test_results.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,19 +12,18 @@ from io import StringIO from typing import Sequence, Dict, Optional, Mapping, MutableMapping - -from pyomo.common import unittest +import pyomo.environ as pyo from pyomo.common.config import ConfigDict -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.var import VarData from pyomo.common.collections import ComponentMap -from pyomo.contrib.solver import results -from pyomo.contrib.solver import solution -import pyomo.environ as pyo +from pyomo.contrib.solver.common import results +from pyomo.contrib.solver.common import solution_loader from pyomo.core.base.var import Var +from pyomo.common import unittest -class SolutionLoaderExample(solution.SolutionLoaderBase): +class SolutionLoaderExample(solution_loader.SolutionLoaderBase): """ This is an example instantiation of a SolutionLoader that is used for testing generated results. @@ -51,8 +50,8 @@ def __init__( self._reduced_costs = reduced_costs def get_primals( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if self._primals is None: raise RuntimeError( 'Solution loader does not currently have a valid solution. Please ' @@ -67,8 +66,8 @@ def get_primals( return primals def get_duals( - self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None - ) -> Dict[_GeneralConstraintData, float]: + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: if self._duals is None: raise RuntimeError( 'Solution loader does not currently have valid duals. Please ' @@ -84,8 +83,8 @@ def get_duals( return duals def get_reduced_costs( - self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None - ) -> Mapping[_GeneralVarData, float]: + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: if self._reduced_costs is None: raise RuntimeError( 'Solution loader does not currently have valid reduced costs. Please ' @@ -167,7 +166,7 @@ def test_member_list(self): 'termination_condition', 'timing_info', 'solver_log', - 'solver_configuration', + 'solver_config', } actual_declared = res._declared self.assertEqual(expected_declared, actual_declared) @@ -193,8 +192,7 @@ def test_display(self): res = results.Results() stream = StringIO() res.display(ostream=stream) - expected_print = """solution_loader: None -termination_condition: TerminationCondition.unknown + expected_print = """termination_condition: TerminationCondition.unknown solution_status: SolutionStatus.noSolution incumbent_objective: None objective_bound: None diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index a5ee8a9e391..0453f0e0cb2 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,15 +10,13 @@ # ___________________________________________________________________________ from pyomo.common import unittest -from pyomo.contrib.solver.solution import SolutionLoaderBase, PersistentSolutionLoader +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + PersistentSolutionLoader, +) class TestSolutionLoaderBase(unittest.TestCase): - def test_abstract_member_list(self): - expected_list = ['get_primals'] - member_list = list(SolutionLoaderBase.__abstractmethods__) - self.assertEqual(sorted(expected_list), sorted(member_list)) - def test_member_list(self): expected_list = ['load_vars', 'get_primals', 'get_duals', 'get_reduced_costs'] method_list = [ @@ -28,10 +26,10 @@ def test_member_list(self): ] self.assertEqual(sorted(expected_list), sorted(method_list)) - @unittest.mock.patch.multiple(SolutionLoaderBase, __abstractmethods__=set()) def test_solution_loader_base(self): self.instance = SolutionLoaderBase() - self.assertEqual(self.instance.get_primals(), None) + with self.assertRaises(NotImplementedError): + self.instance.get_primals() with self.assertRaises(NotImplementedError): self.instance.get_duals() with self.assertRaises(NotImplementedError): @@ -52,12 +50,6 @@ def test_member_list(self): class TestPersistentSolutionLoader(unittest.TestCase): - def test_abstract_member_list(self): - # We expect no abstract members at this point because it's a real-life - # instantiation of SolutionLoaderBase - member_list = list(PersistentSolutionLoader('ipopt').__abstractmethods__) - self.assertEqual(member_list, []) - def test_member_list(self): expected_list = [ 'load_vars', diff --git a/pyomo/contrib/solver/tests/unit/test_util.py b/pyomo/contrib/solver/tests/unit/test_util.py index f2e8ee707f4..5d6b3ed3808 100644 --- a/pyomo/contrib/solver/tests/unit/test_util.py +++ b/pyomo/contrib/solver/tests/unit/test_util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,18 +11,15 @@ from pyomo.common import unittest import pyomo.environ as pyo -from pyomo.contrib.solver.util import ( - collect_vars_and_named_exprs, - get_objective, - check_optimal_termination, - assert_optimal_termination, - SolverStatus, - LegacyTerminationCondition, +from pyomo.contrib.solver.common.util import collect_vars_and_named_exprs, get_objective +from pyomo.opt.results.solver import ( + SolverStatus as LegacySolverStatus, + TerminationCondition as LegacyTerminationCondition, ) -from pyomo.contrib.solver.results import Results, SolutionStatus, TerminationCondition +from pyomo.opt.results import SolverResults as LegacySolverResults +from pyomo.contrib.solver.common.results import Results, SolutionStatus from typing import Callable from pyomo.common.gsl import find_GSL -from pyomo.opt.results import SolverResults class TestGenericUtils(unittest.TestCase): @@ -86,57 +83,44 @@ def test_get_objective_raise(self): def test_check_optimal_termination_new_interface(self): results = Results() results.solution_status = SolutionStatus.optimal - results.termination_condition = ( - TerminationCondition.convergenceCriteriaSatisfied - ) - # Both items satisfied - self.assertTrue(check_optimal_termination(results)) - # Termination condition not satisfied - results.termination_condition = TerminationCondition.iterationLimit - self.assertFalse(check_optimal_termination(results)) - # Both not satisfied + # Optimal solution + self.assertTrue(pyo.check_optimal_termination(results)) + # Anything other than optimal results.solution_status = SolutionStatus.noSolution - self.assertFalse(check_optimal_termination(results)) + self.assertFalse(pyo.check_optimal_termination(results)) def test_check_optimal_termination_condition_legacy_interface(self): - results = SolverResults() - results.solver.status = SolverStatus.ok + results = LegacySolverResults() + results.solver.status = LegacySolverStatus.ok results.solver.termination_condition = LegacyTerminationCondition.optimal # Both items satisfied - self.assertTrue(check_optimal_termination(results)) + self.assertTrue(pyo.check_optimal_termination(results)) # Termination condition not satisfied results.solver.termination_condition = LegacyTerminationCondition.unknown - self.assertFalse(check_optimal_termination(results)) + self.assertFalse(pyo.check_optimal_termination(results)) # Both not satisfied - results.solver.termination_condition = SolverStatus.aborted - self.assertFalse(check_optimal_termination(results)) + results.solver.termination_condition = LegacySolverStatus.aborted + self.assertFalse(pyo.check_optimal_termination(results)) def test_assert_optimal_termination_new_interface(self): results = Results() results.solution_status = SolutionStatus.optimal - results.termination_condition = ( - TerminationCondition.convergenceCriteriaSatisfied - ) - assert_optimal_termination(results) - # Termination condition not satisfied - results.termination_condition = TerminationCondition.iterationLimit - with self.assertRaises(RuntimeError): - assert_optimal_termination(results) - # Both not satisfied + pyo.assert_optimal_termination(results) + # No solution results.solution_status = SolutionStatus.noSolution with self.assertRaises(RuntimeError): - assert_optimal_termination(results) + pyo.assert_optimal_termination(results) def test_assert_optimal_termination_legacy_interface(self): - results = SolverResults() - results.solver.status = SolverStatus.ok + results = LegacySolverResults() + results.solver.status = LegacySolverStatus.ok results.solver.termination_condition = LegacyTerminationCondition.optimal - assert_optimal_termination(results) + pyo.assert_optimal_termination(results) # Termination condition not satisfied results.solver.termination_condition = LegacyTerminationCondition.unknown with self.assertRaises(RuntimeError): - assert_optimal_termination(results) + pyo.assert_optimal_termination(results) # Both not satisfied - results.solver.termination_condition = SolverStatus.aborted + results.solver.termination_condition = LegacySolverStatus.aborted with self.assertRaises(RuntimeError): - assert_optimal_termination(results) + pyo.assert_optimal_termination(results) diff --git a/pyomo/contrib/solver/util.py b/pyomo/contrib/solver/util.py deleted file mode 100644 index c6bbfbd22ad..00000000000 --- a/pyomo/contrib/solver/util.py +++ /dev/null @@ -1,143 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.core.expr.visitor import ExpressionValueVisitor, nonpyomo_leaf_types -import pyomo.core.expr as EXPR -from pyomo.core.base.objective import Objective -from pyomo.opt.results.solver import ( - SolverStatus, - TerminationCondition as LegacyTerminationCondition, -) - - -from pyomo.contrib.solver.results import TerminationCondition, SolutionStatus - - -def get_objective(block): - """ - Get current active objective on a block. If there is more than one active, - return an error. - """ - obj = None - for o in block.component_data_objects( - Objective, descend_into=True, active=True, sort=True - ): - if obj is not None: - raise ValueError('Multiple active objectives found') - obj = o - return obj - - -def check_optimal_termination(results): - """ - This function returns True if the termination condition for the solver - is 'optimal'. - - Parameters - ---------- - results : Pyomo Results object returned from solver.solve - - Returns - ------- - `bool` - """ - if hasattr(results, 'solution_status'): - if results.solution_status == SolutionStatus.optimal and ( - results.termination_condition - == TerminationCondition.convergenceCriteriaSatisfied - ): - return True - else: - if results.solver.status == SolverStatus.ok and ( - results.solver.termination_condition == LegacyTerminationCondition.optimal - or results.solver.termination_condition - == LegacyTerminationCondition.locallyOptimal - or results.solver.termination_condition - == LegacyTerminationCondition.globallyOptimal - ): - return True - return False - - -def assert_optimal_termination(results): - """ - This function checks if the termination condition for the solver - is 'optimal', 'locallyOptimal', or 'globallyOptimal', and the status is 'ok' - and it raises a RuntimeError exception if this is not true. - - Parameters - ---------- - results : Pyomo Results object returned from solver.solve - """ - if not check_optimal_termination(results): - if hasattr(results, 'solution_status'): - msg = ( - 'Solver failed to return an optimal solution. ' - 'Solution status: {}, Termination condition: {}'.format( - results.solution_status, results.termination_condition - ) - ) - else: - msg = ( - 'Solver failed to return an optimal solution. ' - 'Solver status: {}, Termination condition: {}'.format( - results.solver.status, results.solver.termination_condition - ) - ) - raise RuntimeError(msg) - - -class _VarAndNamedExprCollector(ExpressionValueVisitor): - def __init__(self): - self.named_expressions = {} - self.variables = {} - self.fixed_vars = {} - self._external_functions = {} - - def visit(self, node, values): - pass - - def visiting_potential_leaf(self, node): - if type(node) in nonpyomo_leaf_types: - return True, None - - if node.is_variable_type(): - self.variables[id(node)] = node - if node.is_fixed(): - self.fixed_vars[id(node)] = node - return True, None - - if node.is_named_expression_type(): - self.named_expressions[id(node)] = node - return False, None - - if type(node) is EXPR.ExternalFunctionExpression: - self._external_functions[id(node)] = node - return False, None - - if node.is_expression_type(): - return False, None - - return True, None - - -_visitor = _VarAndNamedExprCollector() - - -def collect_vars_and_named_exprs(expr): - _visitor.__init__() - _visitor.dfs_postorder_stack(expr) - return ( - list(_visitor.named_expressions.values()), - list(_visitor.variables.values()), - list(_visitor.fixed_vars.values()), - list(_visitor._external_functions.values()), - ) diff --git a/pyomo/contrib/trustregion/TRF.py b/pyomo/contrib/trustregion/TRF.py index ea3a8c746a4..84d67f5e1ea 100644 --- a/pyomo/contrib/trustregion/TRF.py +++ b/pyomo/contrib/trustregion/TRF.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/__init__.py b/pyomo/contrib/trustregion/__init__.py index 38b30839be3..81ff07bd774 100644 --- a/pyomo/contrib/trustregion/__init__.py +++ b/pyomo/contrib/trustregion/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/examples/__init__.py b/pyomo/contrib/trustregion/examples/__init__.py index 38b30839be3..81ff07bd774 100644 --- a/pyomo/contrib/trustregion/examples/__init__.py +++ b/pyomo/contrib/trustregion/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/examples/example1.py b/pyomo/contrib/trustregion/examples/example1.py index 66df26d143f..058f59c90d4 100755 --- a/pyomo/contrib/trustregion/examples/example1.py +++ b/pyomo/contrib/trustregion/examples/example1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/examples/example2.py b/pyomo/contrib/trustregion/examples/example2.py index ad648855410..8036d49b68b 100644 --- a/pyomo/contrib/trustregion/examples/example2.py +++ b/pyomo/contrib/trustregion/examples/example2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/filter.py b/pyomo/contrib/trustregion/filter.py index 7e647a7f0c5..6e20559f645 100644 --- a/pyomo/contrib/trustregion/filter.py +++ b/pyomo/contrib/trustregion/filter.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/interface.py b/pyomo/contrib/trustregion/interface.py index b459e7cfa17..6f8d74fb997 100644 --- a/pyomo/contrib/trustregion/interface.py +++ b/pyomo/contrib/trustregion/interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -290,13 +290,16 @@ def getCurrentDecisionVariableValues(self): return decision_values def updateDecisionVariableBounds(self, radius): - """ - Update the TRSP_k decision variable bounds + """Update the TRSP_k decision variable bounds This corresponds to: + + .. math:: || E^{-1} (u - u_k) || <= trust_radius - We omit E^{-1} because we assume that the users have correctly scaled - their variables. + + We omit :math:`E^{-1}` because we assume that the users have + correctly scaled their variables. + """ for var in self.decision_variables: var.setlb( diff --git a/pyomo/contrib/trustregion/plugins.py b/pyomo/contrib/trustregion/plugins.py index d4ed22b9d2f..464859ab2d0 100644 --- a/pyomo/contrib/trustregion/plugins.py +++ b/pyomo/contrib/trustregion/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/tests/__init__.py b/pyomo/contrib/trustregion/tests/__init__.py index 38b30839be3..81ff07bd774 100644 --- a/pyomo/contrib/trustregion/tests/__init__.py +++ b/pyomo/contrib/trustregion/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/tests/test_TRF.py b/pyomo/contrib/trustregion/tests/test_TRF.py index e2b2b2b64ad..18cc841d717 100644 --- a/pyomo/contrib/trustregion/tests/test_TRF.py +++ b/pyomo/contrib/trustregion/tests/test_TRF.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/tests/test_examples.py b/pyomo/contrib/trustregion/tests/test_examples.py index 5451cca5961..0abd01fceba 100644 --- a/pyomo/contrib/trustregion/tests/test_examples.py +++ b/pyomo/contrib/trustregion/tests/test_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/tests/test_filter.py b/pyomo/contrib/trustregion/tests/test_filter.py index 18e833685f8..c3276027971 100644 --- a/pyomo/contrib/trustregion/tests/test_filter.py +++ b/pyomo/contrib/trustregion/tests/test_filter.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/tests/test_interface.py b/pyomo/contrib/trustregion/tests/test_interface.py index 148caceddd1..c1ea9468c6c 100644 --- a/pyomo/contrib/trustregion/tests/test_interface.py +++ b/pyomo/contrib/trustregion/tests/test_interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -33,7 +33,7 @@ cos, SolverFactory, ) -from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.var import VarData from pyomo.core.expr.numeric_expr import ExternalFunctionExpression from pyomo.core.expr.visitor import identify_variables from pyomo.contrib.trustregion.interface import TRFInterface @@ -158,7 +158,7 @@ def test_replaceExternalFunctionsWithVariables(self): self.assertIsInstance(k, ExternalFunctionExpression) self.assertIn(str(self.interface.model.x[0]), str(k)) self.assertIn(str(self.interface.model.x[1]), str(k)) - self.assertIsInstance(i, _GeneralVarData) + self.assertIsInstance(i, VarData) self.assertEqual(i, self.interface.data.ef_outputs[1]) for i, k in self.interface.data.basis_expressions.items(): self.assertEqual(k, 0) @@ -234,7 +234,7 @@ def test_updateSurrogateModel(self): for key, val in self.interface.data.grad_basis_model_output.items(): self.assertEqual(value(val), 0) for key, val in self.interface.data.truth_model_output.items(): - self.assertEqual(value(val), 0.8414709848078965) + self.assertAlmostEqual(value(val), 0.8414709848078965) # The truth gradients should equal the output of [cos(2-1), -cos(2-1)] truth_grads = [] for key, val in self.interface.data.grad_truth_model_output.items(): @@ -332,7 +332,7 @@ def test_calculateFeasibility(self): # Check after a solve is completed self.interface.data.basis_constraint.activate() objective, step_norm, feasibility = self.interface.solveModel() - self.assertEqual(feasibility, 0.09569982275514467) + self.assertAlmostEqual(feasibility, 0.09569982275514467) self.interface.data.basis_constraint.deactivate() @unittest.skipIf( @@ -361,7 +361,7 @@ def test_calculateStepSizeInfNorm(self): # Check after a solve is completed self.interface.data.basis_constraint.activate() objective, step_norm, feasibility = self.interface.solveModel() - self.assertEqual(step_norm, 3.393437471478297) + self.assertAlmostEqual(step_norm, 3.393437471478297) self.interface.data.basis_constraint.deactivate() @unittest.skipIf( diff --git a/pyomo/contrib/trustregion/tests/test_util.py b/pyomo/contrib/trustregion/tests/test_util.py index bdc91744e61..c204881516e 100644 --- a/pyomo/contrib/trustregion/tests/test_util.py +++ b/pyomo/contrib/trustregion/tests/test_util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/trustregion/util.py b/pyomo/contrib/trustregion/util.py index ff3f218fc27..3d8b8b9a2e8 100644 --- a/pyomo/contrib/trustregion/util.py +++ b/pyomo/contrib/trustregion/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/viewer/README.md b/pyomo/contrib/viewer/README.md index cfc50b54ce2..93d773e3829 100644 --- a/pyomo/contrib/viewer/README.md +++ b/pyomo/contrib/viewer/README.md @@ -42,6 +42,24 @@ ui = get_mainwindow(model=model) # Do model things, the viewer will stay in sync with the Pyomo model ``` +If you are working in Jupyter notebook, Jupyter qtconsole, or other Jupyter- +based IDEs, and your model is in the __main__ namespace (this is the usual case), +you can specify the model by its variable name as below. The advantage of this +is that if you replace the model with a new model having the same variable name, +the UI will automatically update without having to manually reset the model pointer. + +```python +%gui qt #Enables IPython's GUI event loop integration. +# Execute the above in its own cell and wait for it to finish before moving on. +from pyomo.contrib.viewer.ui import get_mainwindow +import pyomo.environ as pyo + +model = pyo.ConcreteModel() # could import an existing model here +ui = get_mainwindow(model_var_name_in_main="model") + +# Do model things, the viewer will stay in sync with the Pyomo model +``` + **Note:** the ```%gui qt``` cell must be executed in its own cell and execution must complete before running any other cells (you can't use "run all"). diff --git a/pyomo/contrib/viewer/__init__.py b/pyomo/contrib/viewer/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/viewer/__init__.py +++ b/pyomo/contrib/viewer/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/viewer/model_browser.py b/pyomo/contrib/viewer/model_browser.py index 5887a577ba0..17ae37c1443 100644 --- a/pyomo/contrib/viewer/model_browser.py +++ b/pyomo/contrib/viewer/model_browser.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -28,12 +28,10 @@ import os import logging -_log = logging.getLogger(__name__) - -import pyomo.contrib.viewer.qt as myqt +from pyomo.common.fileutils import this_file_dir +from pyomo.common.flags import building_documentation from pyomo.contrib.viewer.report import value_no_exception, get_residual - -from pyomo.core.base.param import _ParamData +from pyomo.core.base.param import ParamData from pyomo.environ import ( Block, BooleanVar, @@ -44,19 +42,34 @@ value, units, ) -from pyomo.common.fileutils import this_file_dir -mypath = this_file_dir() -try: - _ModelBrowserUI, _ModelBrowser = myqt.uic.loadUiType( - os.path.join(mypath, "model_browser.ui") - ) -except: - # This lets the file still be imported, but you won't be able to use it - class _ModelBrowserUI(object): - pass +import pyomo.contrib.viewer.qt as myqt - class _ModelBrowser(object): +_log = logging.getLogger(__name__) + + +# This lets the file be imported when the Qt UI is not available (or +# when building docs), but you won't be able to use it +class _ModelBrowserUI(object): + pass + + +class _ModelBrowser(object): + pass + + +# Note that the classes loaded here have signatures that are not +# parsable by Sphinx, so we won't attempt to import them if we are +# building the API documentation. +if not building_documentation(): + import sys + + mypath = this_file_dir() + try: + _ModelBrowserUI, _ModelBrowser = myqt.uic.loadUiType( + os.path.join(mypath, "model_browser.ui") + ) + except: pass @@ -243,7 +256,7 @@ def _get_expr_callback(self): return None def _get_value_callback(self): - if isinstance(self.data, _ParamData): + if isinstance(self.data, ParamData): v = value_no_exception(self.data, div0="divide_by_0") # Check the param value for numpy float and int, sometimes numpy # values can sneak in especially if you set parameters from data @@ -295,7 +308,7 @@ def _get_residual_callback(self): def _get_units_callback(self): if isinstance(self.data, (Var, Var._ComponentDataClass)): return str(units.get_units(self.data)) - if isinstance(self.data, (Param, _ParamData)): + if isinstance(self.data, (Param, ParamData)): return str(units.get_units(self.data)) return self._cache_units @@ -320,7 +333,7 @@ def _set_value_callback(self, val): o.value = val except: return - elif isinstance(self.data, _ParamData): + elif isinstance(self.data, ParamData): if not self.data.parent_component().mutable: return try: diff --git a/pyomo/contrib/viewer/model_select.py b/pyomo/contrib/viewer/model_select.py index e9c82740708..39199aa8896 100644 --- a/pyomo/contrib/viewer/model_select.py +++ b/pyomo/contrib/viewer/model_select.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -28,23 +28,35 @@ import logging import os -_log = logging.getLogger(__name__) +from pyomo.common.fileutils import this_file_dir +from pyomo.common.flags import building_documentation -import pyomo.environ as pyo import pyomo.contrib.viewer.qt as myqt -from pyomo.common.fileutils import this_file_dir +import pyomo.environ as pyo + +_log = logging.getLogger(__name__) + + +# This lets the file be imported when the Qt UI is not available (or +# when building docs), but you won't be able to use it +class _ModelSelectUI(object): + pass + + +class _ModelSelect(object): + pass -mypath = this_file_dir() -try: - _ModelSelectUI, _ModelSelect = myqt.uic.loadUiType( - os.path.join(mypath, "model_select.ui") - ) -except: - # This lets the file still be imported, but you won't be able to use it - class _ModelSelectUI(object): - pass - class _ModelSelect(object): +# Note that the classes loaded here have signatures that are not +# parsable by Sphinx, so we won't attempt to import them if we are +# building the API documentation. +if not building_documentation(): + mypath = this_file_dir() + try: + _ModelSelectUI, _ModelSelect = myqt.uic.loadUiType( + os.path.join(mypath, "model_select.ui") + ) + except: pass @@ -60,31 +72,33 @@ def select_model(self): items = self.tableWidget.selectedItems() if len(items) == 0: return - self.ui_data.model = self.models[items[0].row()] + self.ui_data.model_var_name_in_main = self.models[items[0].row()][1] + self.ui_data.model = self.models[items[0].row()][0] self.close() def update_models(self): import __main__ - s = __main__.__dict__ + s = dir(__main__) keys = [] for k in s: - if isinstance(s[k], pyo.Block): + if isinstance(getattr(__main__, k), pyo.Block): keys.append(k) self.tableWidget.clearContents() self.tableWidget.setRowCount(len(keys)) self.models = [] for row, k in enumerate(sorted(keys)): + model = getattr(__main__, k) item = myqt.QTableWidgetItem() item.setText(k) self.tableWidget.setItem(row, 0, item) item = myqt.QTableWidgetItem() try: - item.setText(s[k].name) + item.setText(model.name) except: item.setText("None") self.tableWidget.setItem(row, 1, item) item = myqt.QTableWidgetItem() - item.setText(str(type(s[k]))) + item.setText(str(type(model))) self.tableWidget.setItem(row, 2, item) - self.models.append(s[k]) + self.models.append((model, k)) diff --git a/pyomo/contrib/viewer/pyomo_viewer.py b/pyomo/contrib/viewer/pyomo_viewer.py index 6a24e12aa61..816335e4089 100644 --- a/pyomo/contrib/viewer/pyomo_viewer.py +++ b/pyomo/contrib/viewer/pyomo_viewer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -41,7 +41,7 @@ class QtApp( model except NameError: model=None - ui, model = get_mainwindow(model=model, ask_close=False) + ui = get_mainwindow(model=model, ask_close=False) ui.setWindowTitle('Pyomo Model Viewer -- {}')""" _kernel_cmd_hide_ui = """try: diff --git a/pyomo/contrib/viewer/qt.py b/pyomo/contrib/viewer/qt.py index 2715d275758..0456d0299dd 100644 --- a/pyomo/contrib/viewer/qt.py +++ b/pyomo/contrib/viewer/qt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,7 +21,7 @@ # ___________________________________________________________________________ """ -Try to import PySide6, which is the current official Qt 6 Python interface. Then, +Try to import PySide6, which is the current official Qt 6 Python interface. Then, try PyQt5 if that doesn't work. If no compatible Qt Python interface is found, use some dummy classes to allow some testing. """ @@ -30,6 +30,8 @@ import enum import importlib +from pyomo.common.flags import building_documentation + # Supported Qt wrappers in preferred order supported = ["PySide6", "PyQt5"] # Import errors encountered, delay logging for testing reasons @@ -127,3 +129,14 @@ class QItemDelegate(object): from PyQt5.QtWidgets import QAction from PyQt5.QtCore import pyqtSignal as Signal from PyQt5 import uic + + # Note that QAbstractTableModel and QAbstractItemModel have + # signatures that are not parsable by Sphinx, so we will hide them + # if we are building the API documentation. + if building_documentation(): + + class QAbstractItemModel(object): + pass + + class QAbstractTableModel(object): + pass diff --git a/pyomo/contrib/viewer/report.py b/pyomo/contrib/viewer/report.py index f83a53c608d..c52e043a116 100644 --- a/pyomo/contrib/viewer/report.py +++ b/pyomo/contrib/viewer/report.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -50,7 +50,7 @@ def get_residual(ui_data, c): values of the constraint body. This function uses the cached values and will not trigger recalculation. If variable values have changed, this may not yield accurate results. - c(_ConstraintData): a constraint or constraint data + c(ConstraintData): a constraint or constraint data Returns: (float) residual """ @@ -149,7 +149,7 @@ def degrees_of_freedom(blk): Return the degrees of freedom. Args: - blk (Block or _BlockData): Block to count degrees of freedom in + blk (Block or BlockData): Block to count degrees of freedom in Returns: (int): Number of degrees of freedom """ diff --git a/pyomo/contrib/viewer/residual_table.py b/pyomo/contrib/viewer/residual_table.py index 94e8902848f..51da9f8a861 100644 --- a/pyomo/contrib/viewer/residual_table.py +++ b/pyomo/contrib/viewer/residual_table.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -28,24 +28,36 @@ import os import logging -_log = logging.getLogger(__name__) +from pyomo.common.fileutils import this_file_dir +from pyomo.common.flags import building_documentation +from pyomo.contrib.viewer.report import value_no_exception, get_residual import pyomo.contrib.viewer.qt as myqt -from pyomo.contrib.viewer.report import value_no_exception, get_residual import pyomo.environ as pyo -from pyomo.common.fileutils import this_file_dir -mypath = this_file_dir() -try: - _ResidualTableUI, _ResidualTable = myqt.uic.loadUiType( - os.path.join(mypath, "residual_table.ui") - ) -except: +_log = logging.getLogger(__name__) + + +# This lets the file be imported when the Qt UI is not available (or +# when building docs), but you won't be able to use it +class _ResidualTableUI(object): + pass - class _ResidualTableUI(object): - pass - class _ResidualTable(object): +class _ResidualTable(object): + pass + + +# Note that the classes loaded here have signatures that are not +# parsable by Sphinx, so we won't attempt to import them if we are +# building the API documentation. +if not building_documentation(): + mypath = this_file_dir() + try: + _ResidualTableUI, _ResidualTable = myqt.uic.loadUiType( + os.path.join(mypath, "residual_table.ui") + ) + except: pass diff --git a/pyomo/contrib/viewer/tests/__init__.py b/pyomo/contrib/viewer/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/contrib/viewer/tests/__init__.py +++ b/pyomo/contrib/viewer/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/viewer/tests/test_data_model_item.py b/pyomo/contrib/viewer/tests/test_data_model_item.py index 781ca25508a..23563c57d0d 100644 --- a/pyomo/contrib/viewer/tests/test_data_model_item.py +++ b/pyomo/contrib/viewer/tests/test_data_model_item.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -46,15 +46,10 @@ from pyomo.contrib.viewer.model_browser import ComponentDataItem from pyomo.contrib.viewer.ui_data import UIData from pyomo.common.dependencies import DeferredImportError +from pyomo.core.base.units_container import pint_available -try: - x = pyo.units.m - units_available = True -except DeferredImportError: - units_available = False - -@unittest.skipIf(not units_available, "Pyomo units are not available") +@unittest.skipIf(not pint_available, "Pyomo units are not available") class TestDataModelItem(unittest.TestCase): def setUp(self): # Borrowed this test model from the trust region tests diff --git a/pyomo/contrib/viewer/tests/test_data_model_tree.py b/pyomo/contrib/viewer/tests/test_data_model_tree.py index d517c91b353..b9fd9b8867f 100644 --- a/pyomo/contrib/viewer/tests/test_data_model_tree.py +++ b/pyomo/contrib/viewer/tests/test_data_model_tree.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -42,12 +42,7 @@ from pyomo.contrib.viewer.model_browser import ComponentDataModel import pyomo.contrib.viewer.qt as myqt from pyomo.common.dependencies import DeferredImportError - -try: - _x = pyo.units.m - units_available = True -except DeferredImportError: - units_available = False +from pyomo.core.base.units_container import pint_available available = myqt.available @@ -63,7 +58,7 @@ def __init__(*args, **kwargs): pass -@unittest.skipIf(not available or not units_available, "PyQt or units not available") +@unittest.skipIf(not available or not pint_available, "PyQt or units not available") class TestDataModel(unittest.TestCase): def setUp(self): # Borrowed this test model from the trust region tests diff --git a/pyomo/contrib/viewer/tests/test_qt.py b/pyomo/contrib/viewer/tests/test_qt.py index e71921500f9..5002e0c1b62 100644 --- a/pyomo/contrib/viewer/tests/test_qt.py +++ b/pyomo/contrib/viewer/tests/test_qt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,6 +23,13 @@ """ UI Tests """ +# The pytest-qt plugin can generate exceptions / core dumps when it is +# run in a terminal (without an active X11 screen). Setting the +# QT_QPA_PLATFORM environment variable *before* initializing Qt can work +# around this error (see https://stackoverflow.com/a/74719383): +import os + +os.environ['QT_QPA_PLATFORM'] = 'offscreen' from pyomo.environ import ( ConcreteModel, @@ -44,6 +51,7 @@ import pyomo.contrib.viewer.qt as myqt import pyomo.contrib.viewer.pyomo_viewer as pv from pyomo.contrib.viewer.qt import available +from pyomo.core.base.units_container import pint_available if available: import contextvars @@ -57,6 +65,18 @@ def qtbot(): """Overwrite qtbot - remove test failure""" return + pytestmark = unittest.pytest.mark.skip("Qt components are not available.") + +if not pint_available: + pytestmark = unittest.pytest.mark.skip( + "contrib.viewer requires pint, which is not available." + ) + +if not pv.qtconsole_available: + pytestmark = unittest.pytest.mark.skip( + "contrib.viewer requires qtconsole, which is not available." + ) + def get_model(): # Borrowed this test model from the trust region tests @@ -100,10 +120,9 @@ def blackbox(a, b): return m -@unittest.skipIf(not available, "Qt packages are not available.") def test_get_mainwindow(qtbot): m = get_model() - mw, m = get_mainwindow(model=m, testing=True) + mw = get_mainwindow(model=m, testing=True) assert hasattr(mw, "menuBar") assert isinstance(mw.variables, ModelBrowser) assert isinstance(mw.constraints, ModelBrowser) @@ -111,24 +130,21 @@ def test_get_mainwindow(qtbot): assert isinstance(mw.parameters, ModelBrowser) -@unittest.skipIf(not available, "Qt packages are not available.") def test_close_mainwindow(qtbot): - mw, m = get_mainwindow(model=None, testing=True) + mw = get_mainwindow(model=None, testing=True) mw.exit_action() -@unittest.skipIf(not available, "Qt packages are not available.") def test_show_model_select_no_models(qtbot): - mw, m = get_mainwindow(model=None, testing=True) + mw = get_mainwindow(model=None, testing=True) ms = mw.show_model_select() ms.update_models() ms.select_model() -@unittest.skipIf(not available, "Qt packages are not available.") def test_model_information(qtbot): m = get_model() - mw, m = get_mainwindow(model=m, testing=True) + mw = get_mainwindow(model=m, testing=True) mw.model_information() assert isinstance(mw._dialog, QMessageBox) text = mw._dialog.text() @@ -146,18 +162,16 @@ def test_model_information(qtbot): assert isinstance(mw.parameters, ModelBrowser) -@unittest.skipIf(not available, "Qt packages are not available.") def test_tree_expand_collapse(qtbot): m = get_model() - mw, m = get_mainwindow(model=m, testing=True) + mw = get_mainwindow(model=m, testing=True) mw.variables.treeView.expandAll() mw.variables.treeView.collapseAll() -@unittest.skipIf(not available, "Qt packages are not available.") def test_residual_table(qtbot): m = get_model() - mw, m = get_mainwindow(model=m, testing=True) + mw = get_mainwindow(model=m, testing=True) mw.residuals_restart() mw.ui_data.calculate_expressions() mw.residuals.calculate() @@ -181,10 +195,9 @@ def test_residual_table(qtbot): assert dm.data(dm.index(0, 0)) == "c5" -@unittest.skipIf(not available, "Qt packages are not available.") def test_var_tree(qtbot): m = get_model() - mw, m = get_mainwindow(model=m, testing=True) + mw = get_mainwindow(model=m, testing=True) qtbot.addWidget(mw) mw.variables.treeView.expandAll() root_index = mw.variables.datmodel.index(0, 0) @@ -215,10 +228,9 @@ def test_var_tree(qtbot): mw.variables.treeView.closePersistentEditor(z1_val_index) -@unittest.skipIf(not available, "Qt packages are not available.") def test_bad_view(qtbot): m = get_model() - mw, m = get_mainwindow(model=m, testing=True) + mw = get_mainwindow(model=m, testing=True) err = None try: mw.badTree = mw._tree_restart( @@ -229,7 +241,6 @@ def test_bad_view(qtbot): assert err == "ValueError" -@unittest.skipIf(not available, "Qt packages are not available.") def test_qtconsole_app(qtbot): app = pv.QtApp() # empty list to prevent picking up args from pytest diff --git a/pyomo/contrib/viewer/tests/test_report.py b/pyomo/contrib/viewer/tests/test_report.py index 88044490a77..ec4a2081e06 100644 --- a/pyomo/contrib/viewer/tests/test_report.py +++ b/pyomo/contrib/viewer/tests/test_report.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/viewer/ui.py b/pyomo/contrib/viewer/ui.py index 374af8a26f0..a3eb06d1c29 100644 --- a/pyomo/contrib/viewer/ui.py +++ b/pyomo/contrib/viewer/ui.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -39,34 +39,47 @@ def get_ipython(): import pyomo.contrib.viewer.report as rpt import pyomo.environ as pyo import pyomo.contrib.viewer.qt as myqt + +from pyomo.common.fileutils import this_file_dir +from pyomo.common.flags import building_documentation from pyomo.contrib.viewer.model_browser import ModelBrowser from pyomo.contrib.viewer.residual_table import ResidualTable from pyomo.contrib.viewer.model_select import ModelSelect from pyomo.contrib.viewer.ui_data import UIData -from pyomo.common.fileutils import this_file_dir _log = logging.getLogger(__name__) -_mypath = this_file_dir() -try: - _MainWindowUI, _MainWindow = myqt.uic.loadUiType(os.path.join(_mypath, "main.ui")) -except: - _log.exception("Failed to load UI files.") - # This lets the file still be imported, but you won't be able to use it - # Allowing this to be imported will let some basic tests pass without PyQt - class _MainWindowUI(object): - pass +# This lets the file be imported when the Qt UI is not available (or +# when building docs), but you won't be able to use it. Allowing this +# will let some basic tests run (and pass) without PyQt +class _MainWindowUI(object): + pass - class _MainWindow(object): - pass +class _MainWindow(object): + pass + + +# Note that the classes loaded here have signatures that are not +# parsable by Sphinx, so we won't attempt to import them if we are +# building the API documentation. +if not building_documentation(): + _mypath = this_file_dir() + try: + _MainWindowUI, _MainWindow = myqt.uic.loadUiType( + os.path.join(_mypath, "main.ui") + ) + except: + _log.exception("Failed to load UI files.") for _err in myqt.import_errors: _log.error(_err) -def get_mainwindow(model=None, show=True, ask_close=True, testing=False): +def get_mainwindow( + model=None, show=True, ask_close=True, model_var_name_in_main=None, testing=False +): """ Create a UI MainWindow. @@ -79,16 +92,32 @@ def get_mainwindow(model=None, show=True, ask_close=True, testing=False): (ui, model): ui is the MainWindow widget, and model is the linked Pyomo model. If no model is provided a new ConcreteModel is created """ + model_name = model_var_name_in_main if model is None: - model = pyo.ConcreteModel(name="Default") - ui = MainWindow(model=model, ask_close=ask_close, testing=testing) + import __main__ + + if model_name in dir(__main__): + if isinstance(getattr(__main__, model_name), pyo.Block): + model = getattr(__main__, model_name) + else: + for s in dir(__main__): + if isinstance(getattr(__main__, s), pyo.Block): + model = getattr(__main__, s) + model_name = s + break + ui = MainWindow( + model=model, + model_var_name_in_main=model_name, + ask_close=ask_close, + testing=testing, + ) try: get_ipython().events.register("post_execute", ui.refresh_on_execute) except AttributeError: pass # not in ipy kernel, so is fine to not register callback if show: ui.show() - return ui, model + return ui class MainWindow(_MainWindow, _MainWindowUI): @@ -97,6 +126,7 @@ def __init__(self, *args, **kwargs): main = self.main = kwargs.pop("main", None) ask_close = self.ask_close = kwargs.pop("ask_close", True) self.testing = kwargs.pop("testing", False) + model_var_name_in_main = kwargs.pop("model_var_name_in_main", None) flags = kwargs.pop("flags", 0) self.ui_data = UIData(model=model) super().__init__(*args, **kwargs) @@ -128,6 +158,7 @@ def __init__(self, *args, **kwargs): self.actionCalculateExpressions.triggered.connect( self.ui_data.calculate_expressions ) + self.ui_data.model_var_name_in_main = model_var_name_in_main self.actionTile.triggered.connect(self.mdiArea.tileSubWindows) self.actionCascade.triggered.connect(self.mdiArea.cascadeSubWindows) self.actionTabs.triggered.connect(self.toggle_tabs) @@ -256,6 +287,18 @@ def refresh_on_execute(self): ipython kernel. The main purpose of this right now it to refresh the UI display so that it matches the current state of the model. """ + if self.ui_data.model_var_name_in_main is not None: + import __main__ + + try: + mname = self.ui_data.model_var_name_in_main + mid = id(getattr(__main__, mname)) + if id(self.ui_data.model) != mid: + self.ui_data.model = getattr(__main__, mname) + self.update_model + return + except AttributeError: + pass for w in self._refresh_list: try: w.refresh() diff --git a/pyomo/contrib/viewer/ui_data.py b/pyomo/contrib/viewer/ui_data.py index c716cfeedf6..eecda25f037 100644 --- a/pyomo/contrib/viewer/ui_data.py +++ b/pyomo/contrib/viewer/ui_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -39,16 +39,27 @@ class UIDataNoUi(object): UIData. The class is split this way for testing when PyQt is not available. """ - def __init__(self, model=None): + def __init__(self, model=None, model_var_name_in_main=None): """ This class holds the basic UI setup, but doesn't depend on Qt. It shouldn't really be used except for testing when Qt is not available. Args: model: The Pyomo model to view + model_var_name_in_main: if this is set, check that the model variable + which points to a model object in __main__ has the same id when + the UI is refreshed due to a command being executed in jupyter + notebook or QtConsole, if not the same id, then update the model + Since the model viewer is not necessarily pointed at a model in the + __main__ namespace only set this if you want the model to auto + update. Since the model selector dialog lets you choose models + from the __main__ namespace it sets this when you select a model. + This is useful if you run a script repeatedly that replaces a model + preventing you from looking at a previous version of the model. """ super().__init__() self._model = None + self.model_var_name_in_main = model_var_name_in_main self._begin_update = False self.value_cache = ComponentMap() self.value_cache_units = ComponentMap() diff --git a/pyomo/core/__init__.py b/pyomo/core/__init__.py index bce79faacc5..f057376bf51 100644 --- a/pyomo/core/__init__.py +++ b/pyomo/core/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -63,8 +63,6 @@ from pyomo.core.expr.calculus.derivatives import differentiate from pyomo.core.expr.taylor_series import taylor_series_expansion -import pyomo.core.kernel - from pyomo.common.collections import ComponentMap from pyomo.core.expr.symbol_map import SymbolMap from pyomo.core.expr import ( @@ -80,6 +78,7 @@ expr_errors, calculus, ) + from pyomo.core import expr, util, kernel from pyomo.core.expr.numvalue import ( @@ -101,7 +100,7 @@ BooleanValue, native_logical_values, ) -from pyomo.core.kernel.objective import minimize, maximize +from pyomo.core.base import minimize, maximize from pyomo.core.base.config import PyomoOptions from pyomo.core.base.expression import Expression @@ -121,7 +120,6 @@ # from pyomo.core.base.component import name, Component, ModelComponentFactory from pyomo.core.base.componentuid import ComponentUID -import pyomo.core.base.indexed_component from pyomo.core.base.action import BuildAction from pyomo.core.base.check import BuildCheck from pyomo.core.base.set import Set, SetOf, simple_set_rule, RangeSet diff --git a/pyomo/core/base/PyomoModel.py b/pyomo/core/base/PyomoModel.py index 22bbc5fa02b..a18e1d92fa1 100644 --- a/pyomo/core/base/PyomoModel.py +++ b/pyomo/core/base/PyomoModel.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -50,9 +50,12 @@ def global_option(function, name, value): Example use: - @global_option('config.foo.bar', 1) - def functor(): - ... + .. code:: + + @global_option('config.foo.bar', 1) + def functor(): + # ... + """ PyomoConfig._option[tuple(name.split('.'))] = value diff --git a/pyomo/core/base/__init__.py b/pyomo/core/base/__init__.py index 4bbd0c9dc44..d92999aa931 100644 --- a/pyomo/core/base/__init__.py +++ b/pyomo/core/base/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,6 +12,7 @@ # TODO: this import is for historical backwards compatibility and should # probably be removed from pyomo.common.collections import ComponentMap +from pyomo.common.enums import minimize, maximize from pyomo.core.expr.symbol_map import SymbolMap from pyomo.core.expr.numvalue import ( @@ -33,10 +34,11 @@ BooleanValue, native_logical_values, ) -from pyomo.core.kernel.objective import minimize, maximize -from pyomo.core.base.config import PyomoOptions -from pyomo.core.base.expression import Expression, _ExpressionData +from pyomo.core.base.component import name, Component, ModelComponentFactory +from pyomo.core.base.componentuid import ComponentUID +from pyomo.core.base.config import PyomoOptions +from pyomo.core.base.enums import SortComponents, TraversalStrategy from pyomo.core.base.label import ( CuidLabeler, CounterLabeler, @@ -47,56 +49,73 @@ NameLabeler, ShortNameLabeler, ) +from pyomo.core.base.misc import display +from pyomo.core.base.reference import Reference +from pyomo.core.base.symbol_map import symbol_map_from_instance +from pyomo.core.base.transformation import ( + Transformation, + TransformationFactory, + ReverseTransformationToken, +) + +from pyomo.core.base.PyomoModel import ( + global_option, + ModelSolution, + ModelSolutions, + Model, + ConcreteModel, + AbstractModel, +) # # Components # -from pyomo.core.base.component import name, Component, ModelComponentFactory -from pyomo.core.base.componentuid import ComponentUID from pyomo.core.base.action import BuildAction -from pyomo.core.base.check import BuildCheck -from pyomo.core.base.set import Set, SetOf, simple_set_rule, RangeSet -from pyomo.core.base.param import Param -from pyomo.core.base.var import Var, _VarData, _GeneralVarData, ScalarVar, VarList +from pyomo.core.base.block import ( + Block, + BlockData, + ScalarBlock, + active_components, + components, + active_components_data, + components_data, +) from pyomo.core.base.boolean_var import ( BooleanVar, - _BooleanVarData, - _GeneralBooleanVarData, + BooleanVarData, BooleanVarList, ScalarBooleanVar, ) +from pyomo.core.base.check import BuildCheck +from pyomo.core.base.connector import Connector, ConnectorData from pyomo.core.base.constraint import ( simple_constraint_rule, simple_constraintlist_rule, ConstraintList, Constraint, - _ConstraintData, + ConstraintData, ) +from pyomo.core.base.expression import Expression, NamedExpressionData, ExpressionData +from pyomo.core.base.external import ExternalFunction from pyomo.core.base.logical_constraint import ( LogicalConstraint, LogicalConstraintList, - _LogicalConstraintData, + LogicalConstraintData, ) from pyomo.core.base.objective import ( simple_objective_rule, simple_objectivelist_rule, Objective, ObjectiveList, - _ObjectiveData, -) -from pyomo.core.base.connector import Connector -from pyomo.core.base.sos import SOSConstraint -from pyomo.core.base.piecewise import Piecewise -from pyomo.core.base.suffix import ( - active_export_suffix_generator, - active_import_suffix_generator, - Suffix, + ObjectiveData, ) -from pyomo.core.base.external import ExternalFunction -from pyomo.core.base.symbol_map import symbol_map_from_instance -from pyomo.core.base.reference import Reference - +from pyomo.core.base.param import Param, ParamData +from pyomo.core.base.piecewise import Piecewise, PiecewiseData from pyomo.core.base.set import ( + Set, + SetData, + SetOf, + RangeSet, Reals, PositiveReals, NonPositiveReals, @@ -116,37 +135,59 @@ PercentFraction, RealInterval, IntegerInterval, + simple_set_rule, ) -from pyomo.core.base.misc import display -from pyomo.core.base.block import ( - Block, - ScalarBlock, - active_components, - components, - active_components_data, - components_data, -) -from pyomo.core.base.enums import SortComponents, TraversalStrategy -from pyomo.core.base.PyomoModel import ( - global_option, - ModelSolution, - ModelSolutions, - Model, - ConcreteModel, - AbstractModel, -) -from pyomo.core.base.transformation import ( - Transformation, - TransformationFactory, - ReverseTransformationToken, +from pyomo.core.base.sos import SOSConstraint, SOSConstraintData +from pyomo.core.base.suffix import ( + active_export_suffix_generator, + active_import_suffix_generator, + Suffix, ) +from pyomo.core.base.var import Var, VarData, ScalarVar, VarList from pyomo.core.base.instance2dat import instance2dat +# # These APIs are deprecated and should be removed in the near future +# from pyomo.core.base.set import set_options, RealSet, IntegerSet, BooleanSet -from pyomo.common.deprecation import relocated_module_attribute +# +# declare deprecation paths for removed modules and attributes +# +from pyomo.common.deprecation import relocated_module_attribute, moved_module + +moved_module( + "pyomo.core.base.plugin", + "pyomo._archive.plugin", + msg="The pyomo.core.base.plugin module is deprecated. " + "See pyomo.core.base.transformation for Transformation and " + "TransformationFactory, pyomo.core.base.component for " + "ModelComponentFactory and pyomo.scripting.interface for " + "IPyomoScript* interfaces.", + version='6.0', +) +moved_module( + "pyomo.core.base.rangeset", + "pyomo._archive.rangeset", + msg='The pyomo.core.base.rangeset module is deprecated. ' + 'Import RangeSet objects from pyomo.core.base.set or pyomo.core.', + version='5.7', +) +moved_module( + "pyomo.core.base.sets", + "pyomo._archive.sets", + msg='The pyomo.core.base.sets module is deprecated. ' + 'Import Set objects from pyomo.core.base.set or pyomo.core.', + version='5.7', +) +moved_module( + "pyomo.core.base.template_expr", + "pyomo._archive.template_expr", + msg='The pyomo.core.base.template_expr module is deprecated. ' + 'Import expression template objects from pyomo.core.expr.template_expr.', + version='5.7', +) relocated_module_attribute( 'SimpleBlock', 'pyomo.core.base.block.SimpleBlock', version='6.0' @@ -155,4 +196,25 @@ relocated_module_attribute( 'SimpleBooleanVar', 'pyomo.core.base.boolean_var.SimpleBooleanVar', version='6.0' ) -del relocated_module_attribute +# Historically, only a subset of "private" component data classes were imported here +relocated_module_attribute( + f'_GeneralVarData', f'pyomo.core.base.VarData', version='6.7.2' +) +relocated_module_attribute( + f'_GeneralBooleanVarData', f'pyomo.core.base.BooleanVarData', version='6.7.2' +) +relocated_module_attribute( + f'_ExpressionData', f'pyomo.core.base.NamedExpressionData', version='6.7.2' +) +for _cdata in ( + 'ConstraintData', + 'LogicalConstraintData', + 'VarData', + 'BooleanVarData', + 'ObjectiveData', +): + relocated_module_attribute( + f'_{_cdata}', f'pyomo.core.base.{_cdata}', version='6.7.2' + ) + +del _cdata, relocated_module_attribute, moved_module diff --git a/pyomo/core/base/action.py b/pyomo/core/base/action.py index d24d94fe05a..3a48f413ce8 100644 --- a/pyomo/core/base/action.py +++ b/pyomo/core/base/action.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/block.py b/pyomo/core/base/block.py index a0948c693d7..dd0bc790b68 100644 --- a/pyomo/core/base/block.py +++ b/pyomo/core/base/block.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from __future__ import annotations import copy import logging import sys @@ -21,6 +22,7 @@ from io import StringIO from itertools import filterfalse, chain from operator import itemgetter, attrgetter +from typing import Union, Any, Type from pyomo.common.autoslots import AutoSlots from pyomo.common.collections import Mapping @@ -44,6 +46,7 @@ from pyomo.core.base.indexed_component import ( ActiveIndexedComponent, UnindexedComponent_set, + IndexedComponent, ) from pyomo.opt.base import ProblemFormat, guess_format @@ -157,13 +160,13 @@ def __init__(self): self.seen_data = set() def unique(self, comp, items, are_values): - """Returns generator that filters duplicate _ComponentData objects from items + """Returns generator that filters duplicate ComponentData objects from items Parameters ---------- comp: ComponentBase The Component (indexed or scalar) that contains all - _ComponentData returned by the `items` generator. `comp` may + ComponentData returned by the `items` generator. `comp` may be an IndexedComponent generated by :py:func:`Reference` (and hence may not own the component datas in `items`) @@ -172,8 +175,8 @@ def unique(self, comp, items, are_values): `comp` Component. are_values: bool - If `True`, `items` yields _ComponentData objects, otherwise, - `items` yields `(index, _ComponentData)` tuples. + If `True`, `items` yields ComponentData objects, otherwise, + `items` yields `(index, ComponentData)` tuples. """ if comp.is_reference(): @@ -251,7 +254,7 @@ class _BlockConstruction(object): class PseudoMap(AutoSlots.Mixin): """ This class presents a "mock" dict interface to the internal - _BlockData data structures. We return this object to the + BlockData data structures. We return this object to the user to preserve the historical "{ctype : {name : obj}}" interface without actually regenerating that dict-of-dicts data structure. @@ -358,7 +361,7 @@ def __contains__(self, key): TODO """ # Return True is the underlying Block contains the component - # name. Note, if this Pseudomap soecifies a ctype or the + # name. Note, if this Pseudomap specifies a ctype or the # active flag, we need to check that the underlying # component matches those flags if key in self._block._decl: @@ -484,7 +487,7 @@ def iteritems(self): return self.items() -class _BlockData(ActiveComponentData): +class BlockData(ActiveComponentData): """ This class holds the fundamental block data. """ @@ -534,12 +537,12 @@ def __init__(self, component): # _ctypes: { ctype -> [1st idx, last idx, count] } # _decl: { name -> idx } # _decl_order: list( tuples( obj, next_type_idx ) ) - super(_BlockData, self).__setattr__('_ctypes', {}) - super(_BlockData, self).__setattr__('_decl', {}) - super(_BlockData, self).__setattr__('_decl_order', []) + super(BlockData, self).__setattr__('_ctypes', {}) + super(BlockData, self).__setattr__('_decl', {}) + super(BlockData, self).__setattr__('_decl_order', []) self._private_data = None - def __getattr__(self, val): + def __getattr__(self, val) -> Union[Component, IndexedComponent, Any]: if val in ModelComponentFactory: return _component_decorator(self, ModelComponentFactory.get_class(val)) # Since the base classes don't support getattr, we can just @@ -548,7 +551,7 @@ def __getattr__(self, val): "'%s' object has no attribute '%s'" % (self.__class__.__name__, val) ) - def __setattr__(self, name, val): + def __setattr__(self, name: str, val: Union[Component, IndexedComponent, Any]): """ Set an attribute of a block data object. """ @@ -571,7 +574,7 @@ def __setattr__(self, name, val): # Other Python objects are added with the standard __setattr__ # method. # - super(_BlockData, self).__setattr__(name, val) + super(BlockData, self).__setattr__(name, val) # # Case 2. The attribute exists and it is a component in the # list of declarations in this block. We will use the @@ -625,11 +628,11 @@ def __setattr__(self, name, val): # else: # - # NB: This is important: the _BlockData is either a scalar + # NB: This is important: the BlockData is either a scalar # Block (where _parent and _component are defined) or a # single block within an Indexed Block (where only # _component is defined). Regardless, the - # _BlockData.__init__() method declares these methods and + # BlockData.__init__() method declares these methods and # sets them either to None or a weakref. Thus, we will # never have a problem converting these objects from # weakrefs into Blocks and back (when pickling); the @@ -644,23 +647,23 @@ def __setattr__(self, name, val): # return True, this shouldn't be too inefficient. # if name == '_parent': - if val is not None and not isinstance(val(), _BlockData): + if val is not None and not isinstance(val(), BlockData): raise ValueError( "Cannot set the '_parent' attribute of Block '%s' " "to a non-Block object (with type=%s); Did you " "try to create a model component named '_parent'?" % (self.name, type(val)) ) - super(_BlockData, self).__setattr__(name, val) + super(BlockData, self).__setattr__(name, val) elif name == '_component': - if val is not None and not isinstance(val(), _BlockData): + if val is not None and not isinstance(val(), BlockData): raise ValueError( "Cannot set the '_component' attribute of Block '%s' " "to a non-Block object (with type=%s); Did you " "try to create a model component named '_component'?" % (self.name, type(val)) ) - super(_BlockData, self).__setattr__(name, val) + super(BlockData, self).__setattr__(name, val) # # At this point, we should only be seeing non-component data # the user is hanging on the blocks (uncommon) or the @@ -677,7 +680,7 @@ def __setattr__(self, name, val): delattr(self, name) self.add_component(name, val) else: - super(_BlockData, self).__setattr__(name, val) + super(BlockData, self).__setattr__(name, val) def __delattr__(self, name): """ @@ -700,7 +703,7 @@ def __delattr__(self, name): # Other Python objects are removed with the standard __detattr__ # method. # - super(_BlockData, self).__delattr__(name) + super(BlockData, self).__delattr__(name) def _compact_decl_storage(self): idxMap = {} @@ -772,11 +775,11 @@ def transfer_attributes_from(self, src): Parameters ---------- - src: _BlockData or dict + src: BlockData or dict The Block or mapping that contains the new attributes to assign to this block. """ - if isinstance(src, _BlockData): + if isinstance(src, BlockData): # There is a special case where assigning a parent block to # this block creates a circular hierarchy if src is self: @@ -785,7 +788,7 @@ def transfer_attributes_from(self, src): while p_block is not None: if p_block is src: raise ValueError( - "_BlockData.transfer_attributes_from(): Cannot set a " + "BlockData.transfer_attributes_from(): Cannot set a " "sub-block (%s) to a parent block (%s): creates a " "circular hierarchy" % (self, src) ) @@ -801,7 +804,7 @@ def transfer_attributes_from(self, src): del_src_comp = lambda x: None else: raise ValueError( - "_BlockData.transfer_attributes_from(): expected a " + "BlockData.transfer_attributes_from(): expected a " "Block or dict; received %s" % (type(src).__name__,) ) @@ -875,7 +878,7 @@ def collect_ctypes(self, active=None, descend_into=True): def model(self): # - # Special case: the "Model" is always the top-level _BlockData, + # Special case: the "Model" is always the top-level BlockData, # so if this is the top-level block, it must be the model # # Also note the interesting and intentional characteristic for @@ -918,11 +921,7 @@ def find_component(self, label_or_component): a matching component is not found, None is returned. """ - if type(label_or_component) is ComponentUID: - cuid = label_or_component - else: - cuid = ComponentUID(label_or_component) - return cuid.find_component_on(self) + return ComponentUID(label_or_component).find_component_on(self) @contextmanager def _declare_reserved_components(self): @@ -957,13 +956,8 @@ def add_component(self, name, val): % (name, type(val), self.name, type(getattr(self, name))) ) # - # Skip the add_component() logic if this is a - # component type that is suppressed. - # _component = self.parent_component() _type = val.ctype - if _type in _component._suppress_ctypes: - return # # Raise an exception if the component already has a parent. # @@ -1032,7 +1026,7 @@ def add_component(self, name, val): # is inappropriate here. The correct way to add the attribute # is to delegate the work to the next class up the MRO. # - super(_BlockData, self).__setattr__(name, val) + super(BlockData, self).__setattr__(name, val) # # Update the ctype linked lists # @@ -1045,35 +1039,16 @@ def add_component(self, name, val): else: self._ctypes[_type] = [_new_idx, _new_idx, 1] # - # Propagate properties to sub-blocks: - # suppressed ctypes - # - if _type is Block: - val._suppress_ctypes |= _component._suppress_ctypes - # # Error, for disabled support implicit rule names # if '_rule' in val.__dict__ and val._rule is None: - _found = False try: _test = val.local_name + '_rule' for i in (1, 2): frame = sys._getframe(i) - _found |= _test in frame.f_locals except: pass - if _found: - # JDS: Do not blindly reformat this message. The - # formatter inserts arbitrarily-long names(), which can - # cause the resulting logged message to be very poorly - # formatted due to long lines. - logger.warning( - """As of Pyomo 4.0, Pyomo components no longer support implicit rules. -You defined a component (%s) that appears -to rely on an implicit rule (%s). -Components must now specify their rules explicitly using 'rule=' keywords.""" - % (val.name, _test) - ) + # # Don't reconstruct if this component has already been constructed. # This allows a user to move a component from one block to @@ -1103,7 +1078,7 @@ def add_component(self, name, val): # This is tricky: If we are in the middle of # constructing an indexed block, the block component # already has _constructed=True. Now, if the - # _BlockData.__init__() defines any local variables + # BlockData.__init__() defines any local variables # (like pyomo.gdp.Disjunct's indicator_var), name(True) # will fail: this block data exists and has a parent(), # but it has not yet been added to the parent's _data @@ -1191,7 +1166,7 @@ def del_component(self, name_or_object): # Note: 'del self.__dict__[name]' is inappropriate here. The # correct way to add the attribute is to delegate the work to # the next class up the MRO. - super(_BlockData, self).__delattr__(name) + super(BlockData, self).__delattr__(name) def reclassify_component_type( self, name_or_object, new_ctype, preserve_declaration_order=True @@ -1268,8 +1243,83 @@ def reclassify_component_type( self._decl_order[idx] = (obj, tmp) def clone(self, memo=None): - """ - TODO + """Make a copy of this block (and all components contained in it). + + Pyomo models use :py:class:`Block` components to define a + hierarchical structure and provide model scoping. When modeling + :py:class:`~pyomo.core.base.component.Component` objects are + assigned to a block, they are automatically added to that block's + scope. + + :py:meth:`clone()` implements a specialization of + :py:func:`copy.deepcopy` that will deep copy the + :py:class:`BlockData` using that block's scope: that is, copy + the :py:class:`BlockData` and (recursively) all + :py:class:`Component` objects attached to it (including any + sub-blocks). Pyomo + :py:class:`~pyomo.core.base.component.Component` / + :py:class:`~pyomo.core.base.component.ComponentData` objects + that are referenced through objects on this block but are not in + this block scope (i.e., are not owned by this block or a + subblock of this block) are not duplicated. + + Parameters + ---------- + memo : dict + A user-defined memo dictionary. The dictionary will be + updated by :py:meth:`clone` and :py:func:`copy.deepcopy`. + See :py:meth:`object.__deepcopy__` for more information. + + Examples + -------- + Given the following model: + + >>> m = pyo.ConcreteModel() + >>> m.I = pyo.RangeSet(3) + >>> m.x = pyo.Var() + >>> m.b1 = pyo.Block() + >>> m.b1.J = pyo.RangeSet(3) + >>> m.b1.y = pyo.Var(domain=pyo.Reals) + >>> m.b1.z = pyo.Var(m.I) + >>> m.b1.c = pyo.Constraint(expr=m.x >= m.b1.y + sum(m.b1.z[:])) + >>> m.b1.b2 = pyo.Block() + >>> m.b1.b2.w = pyo.Var(m.b1.J) + >>> m.b1.d = pyo.Constraint(expr=m.b1.y + sum(m.b1.b2.w[:]) == 5) + + If we clone a block: + + >>> i = m.b1.clone() + + All local components are copied: + + >>> assert m.b1 is not i + >>> assert m.b1.J is not i.J + >>> assert m.b1.y is not i.y + >>> assert m.b1.z is not i.z + >>> assert m.b1.b2 is not i.b2 + >>> assert m.b1.b2.w is not i.b2.w + + References to local components (in this case, Sets) are copied + and updated: + + >>> assert m.b1.b2.w.index_set() is not i.b2.w.index_set() + + But references to out-of-scope Sets (either global or in a + different block scope) are preserved: + + >>> assert m.b1.y.index_set() is i.y.index_set() + >>> assert m.b1.z.index_set() is i.z.index_set() + >>> assert m.b1.y.domain is i.y.domain + + Expressions are also updated in a similar manner: the new + expression will reference the new (copied) components for any + components in scope, but references to out-of-scope components + will be preserved: + + >>> from pyomo.core.expr.compare import compare_expressions + >>> assert compare_expressions(i.c.expr, m.x >= i.y + sum(i.z[:])) + >>> assert compare_expressions(i.d.expr, i.y + sum(i.b2.w[:]) == 5) + """ # FYI: we used to remove all _parent() weakrefs before # deepcopying and then restore them on the original and cloned @@ -1396,7 +1446,7 @@ def _component_data_iteritems(self, ctype, active, sort, dedup): Generator that returns a nested 2-tuple of - ((component name, index value), _ComponentData) + ((component name, index value), ComponentData) for every component data in the block matching the specified ctype(s). @@ -1413,7 +1463,7 @@ def _component_data_iteritems(self, ctype, active, sort, dedup): Iterate over the components in a specified sorted order dedup: _DeduplicateInfo - Deduplicator to prevent returning the same _ComponentData twice + Deduplicator to prevent returning the same ComponentData twice """ for name, comp in PseudoMap(self, ctype, active, sort).items(): # NOTE: Suffix has a dict interface (something other derived @@ -1449,7 +1499,7 @@ def _component_data_iteritems(self, ctype, active, sort, dedup): yield from dedup.unique(comp, _items, False) def _component_data_itervalues(self, ctype, active, sort, dedup): - """Generator that returns the _ComponentData for every component data + """Generator that returns the ComponentData for every component data in the block. Parameters @@ -1464,7 +1514,7 @@ def _component_data_itervalues(self, ctype, active, sort, dedup): Iterate over the components in a specified sorted order dedup: _DeduplicateInfo - Deduplicator to prevent returning the same _ComponentData twice + Deduplicator to prevent returning the same ComponentData twice """ for comp in PseudoMap(self, ctype, active, sort).values(): # NOTE: Suffix has a dict interface (something other derived @@ -1570,7 +1620,7 @@ def component_data_iterindex( generator recursively descends into sub-blocks. The tuple is - ((component name, index value), _ComponentData) + ((component name, index value), ComponentData) """ dedup = _DeduplicateInfo() @@ -1963,14 +2013,15 @@ def _create_objects_for_deepcopy(self, memo, component_list): _new = self.__class__.__new__(self.__class__) _ans = memo.setdefault(id(self), _new) if _ans is _new: - component_list.append(self) + component_list.append((self, _new)) # Blocks (and block-like things) need to pre-populate all # Components / ComponentData objects to help prevent # deepcopy() from violating the Python recursion limit. # This step is recursive; however, we do not expect "super # deep" Pyomo block hierarchies, so should be okay. - for comp in self.component_map().values(): - comp._create_objects_for_deepcopy(memo, component_list) + for comp, _ in self._decl_order: + if comp is not None: + comp._create_objects_for_deepcopy(memo, component_list) return _ans def private_data(self, scope=None): @@ -1991,6 +2042,11 @@ def private_data(self, scope=None): return self._private_data[scope] +class _BlockData(metaclass=RenamedClass): + __renamed__new_class__ = BlockData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register( "A component that contains one or more model components." ) @@ -2004,9 +2060,20 @@ class Block(ActiveIndexedComponent): is deferred. """ - _ComponentDataClass = _BlockData + _ComponentDataClass = BlockData _private_data_initializers = defaultdict(lambda: dict) + @overload + def __new__( + cls: Type[Block], *args, **kwds + ) -> Union[ScalarBlock, IndexedBlock]: ... + + @overload + def __new__(cls: Type[ScalarBlock], *args, **kwds) -> ScalarBlock: ... + + @overload + def __new__(cls: Type[IndexedBlock], *args, **kwds) -> IndexedBlock: ... + def __new__(cls, *args, **kwds): if cls != Block: return super(Block, cls).__new__(cls) @@ -2023,7 +2090,6 @@ def __init__( def __init__(self, *args, **kwargs): """Constructor""" - self._suppress_ctypes = set() _rule = kwargs.pop('rule', None) _options = kwargs.pop('options', None) # As concrete applies to the Block at declaration time, we will @@ -2086,7 +2152,7 @@ def _getitem_when_not_present(self, idx): # components declared by the rule have the opportunity # to be initialized with data from # _BlockConstruction.data as they are transferred over. - if obj is not _block and isinstance(obj, _BlockData): + if obj is not _block and isinstance(obj, BlockData): _block.transfer_attributes_from(obj) finally: if data is not None and _block is not self: @@ -2207,7 +2273,7 @@ def display(self, filename=None, ostream=None, prefix=""): ostream = sys.stdout for key in sorted(self): - _BlockData.display(self[key], filename, ostream, prefix) + BlockData.display(self[key], filename, ostream, prefix) @staticmethod def register_private_data_initializer(initializer, scope=None): @@ -2227,9 +2293,9 @@ def register_private_data_initializer(initializer, scope=None): Block._private_data_initializers[scope] = initializer -class ScalarBlock(_BlockData, Block): +class ScalarBlock(BlockData, Block): def __init__(self, *args, **kwds): - _BlockData.__init__(self, component=self) + BlockData.__init__(self, component=self) Block.__init__(self, *args, **kwds) # Initialize the data dict so that (abstract) attribute # assignment will work. Note that we do not trigger @@ -2251,6 +2317,11 @@ class IndexedBlock(Block): def __init__(self, *args, **kwds): Block.__init__(self, *args, **kwds) + @overload + def __getitem__(self, index) -> BlockData: ... + + __getitem__ = IndexedComponent.__getitem__ # type: ignore + # # Deprecated functions. @@ -2306,101 +2377,120 @@ def components_data(block, ctype, sort=None, sort_by_keys=False, sort_by_names=F # Create a Block and record all the default attributes, methods, etc. # These will be assumed to be the set of illegal component names. # -_BlockData._Block_reserved_words = set(dir(Block())) - - -class _IndexedCustomBlockMeta(type): - """Metaclass for creating an indexed custom block.""" - - pass - - -class _ScalarCustomBlockMeta(type): - """Metaclass for creating a scalar custom block.""" +BlockData._Block_reserved_words = set(dir(Block())) - def __new__(meta, name, bases, dct): - def __init__(self, *args, **kwargs): - # bases[0] is the custom block data object - bases[0].__init__(self, component=self) - # bases[1] is the custom block object that - # is used for declaration - bases[1].__init__(self, *args, **kwargs) - dct["__init__"] = __init__ - return type.__new__(meta, name, bases, dct) +class ScalarCustomBlockMixin(object): + def __init__(self, *args, **kwargs): + # __bases__ for the ScalarCustomBlock is + # + # (ScalarCustomBlockMixin, {custom_data}, {custom_block}) + # + # Unfortunately, we cannot guarantee that this is being called + # from the ScalarCustomBlock (someone could have inherited from + # that class to make another scalar class). We will walk up the + # MRO to find the Scalar class (which should be the only class + # that has this Mixin as the first base class) + for cls in self.__class__.__mro__: + if cls.__bases__[0] is ScalarCustomBlockMixin: + _mixin, _data, _block = cls.__bases__ + _data.__init__(self, component=self) + _block.__init__(self, *args, **kwargs) + break class CustomBlock(Block): """The base class used by instances of custom block components""" - def __init__(self, *args, **kwds): + def __init__(self, *args, **kwargs): if self._default_ctype is not None: - kwds.setdefault('ctype', self._default_ctype) - Block.__init__(self, *args, **kwds) - - def __new__(cls, *args, **kwds): - if cls.__name__.startswith('_Indexed') or cls.__name__.startswith('_Scalar'): - # we are entering here the second time (recursive) - # therefore, we need to create what we have - return super(CustomBlock, cls).__new__(cls) + kwargs.setdefault('ctype', self._default_ctype) + Block.__init__(self, *args, **kwargs) + + def __new__(cls, *args, **kwargs): + if cls.__bases__[0] is not CustomBlock: + # we are creating a class other than the "generic" derived + # custom block class. We can assume that the routing of the + # generic block class to the specific Scalar or Indexed + # subclass has already occurred and we can pass control up + # to (toward) object.__new__() + return super().__new__(cls, *args, **kwargs) + # If the first base class is this CustomBlock class, then the + # user is attempting to create the "generic" block class. + # Depending on the arguments, we need to map this to either the + # Scalar or Indexed block subclass. if not args or (args[0] is UnindexedComponent_set and len(args) == 1): - n = _ScalarCustomBlockMeta( - "_Scalar%s" % (cls.__name__,), (cls._ComponentDataClass, cls), {} - ) - return n.__new__(n) + return super().__new__(cls._scalar_custom_block, *args, **kwargs) else: - n = _IndexedCustomBlockMeta("_Indexed%s" % (cls.__name__,), (cls,), {}) - return n.__new__(n) + return super().__new__(cls._indexed_custom_block, *args, **kwargs) def declare_custom_block(name, new_ctype=None): """Decorator to declare components for a custom block data class - >>> @declare_custom_block(name=FooBlock) - ... class FooBlockData(_BlockData): + >>> @declare_custom_block(name="FooBlock") + ... class FooBlockData(BlockData): ... # custom block data class ... pass """ - def proc_dec(cls): - # this is the decorator function that - # creates the block component class - - # Default (derived) Block attributes - clsbody = { - "__module__": cls.__module__, # magic to fix the module - # Default IndexedComponent data object is the decorated class: - "_ComponentDataClass": cls, - # By default this new block does not declare a new ctype - "_default_ctype": None, - } + def block_data_decorator(block_data): + # this is the decorator function that creates the block + # component classes - c = type( + # Declare the new Block component (derived from CustomBlock) + # corresponding to the BlockData that we are decorating + # + # Note the use of `type(CustomBlock)` to pick up the metaclass + # that was used to create the CustomBlock (in general, it should + # be `type`) + comp = type(CustomBlock)( name, # name of new class (CustomBlock,), # base classes - clsbody, # class body definitions (will populate __dict__) + # class body definitions (populate the new class' __dict__) + { + # ensure the created class is associated with the calling module + "__module__": block_data.__module__, + # Default IndexedComponent data object is the decorated class: + "_ComponentDataClass": block_data, + # By default this new block does not declare a new ctype + "_default_ctype": None, + }, ) if new_ctype is not None: if new_ctype is True: - c._default_ctype = c - elif type(new_ctype) is type: - c._default_ctype = new_ctype + comp._default_ctype = comp + elif isinstance(new_ctype, type): + comp._default_ctype = new_ctype else: raise ValueError( "Expected new_ctype to be either type " "or 'True'; received: %s" % (new_ctype,) ) - # Register the new Block type in the same module as the BlockData - setattr(sys.modules[cls.__module__], name, c) - # TODO: can we also register concrete Indexed* and Scalar* - # classes into the original BlockData module (instead of relying - # on metaclasses)? + # Declare Indexed and Scalar versions of the custom block. We + # will register them both with the calling module scope, and + # with the CustomBlock (so that CustomBlock.__new__ can route + # the object creation to the correct class) + comp._indexed_custom_block = type(comp)( + "Indexed" + name, + (comp,), + { # ensure the created class is associated with the calling module + "__module__": block_data.__module__ + }, + ) + comp._scalar_custom_block = type(comp)( + "Scalar" + name, + (ScalarCustomBlockMixin, block_data, comp), + { # ensure the created class is associated with the calling module + "__module__": block_data.__module__ + }, + ) - # are these necessary? - setattr(cls, '_orig_name', name) - setattr(cls, '_orig_module', cls.__module__) - return cls + # Register the new Block types in the same module as the BlockData + for _cls in (comp, comp._indexed_custom_block, comp._scalar_custom_block): + setattr(sys.modules[block_data.__module__], _cls.__name__, _cls) + return block_data - return proc_dec + return block_data_decorator diff --git a/pyomo/core/base/blockutil.py b/pyomo/core/base/blockutil.py index d91a5c85ac2..8308f8df96a 100644 --- a/pyomo/core/base/blockutil.py +++ b/pyomo/core/base/blockutil.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/boolean_var.py b/pyomo/core/base/boolean_var.py index 246dcea6214..915ff82c0ba 100644 --- a/pyomo/core/base/boolean_var.py +++ b/pyomo/core/base/boolean_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,6 +19,7 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.core.expr.boolean_value import BooleanValue from pyomo.core.expr import GetItemExpression +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import value from pyomo.core.base.component import ComponentData, ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index @@ -68,27 +69,54 @@ def __setstate__(self, state): self._boolvar = weakref_ref(state) -class _BooleanVarData(ComponentData, BooleanValue): - """ - This class defines the data for a single variable. - - Constructor Arguments: - component The BooleanVar object that owns this data. - Public Class Attributes: - fixed If True, then this variable is treated as a - fixed constant in the model. - stale A Boolean indicating whether the value of this variable is - legitimate. This value is true if the value should - be considered legitimate for purposes of reporting or - other interrogation. - value The numeric value of this variable. +def _associated_binary_mapper(encode, val): + if val is None: + return None + if encode: + if val.__class__ is not _DeprecatedImplicitAssociatedBinaryVariable: + return val() + else: + if val.__class__ is not _DeprecatedImplicitAssociatedBinaryVariable: + return weakref_ref(val) + return val + + +class BooleanVarData(ComponentData, BooleanValue): + """This class defines the data for a single Boolean variable. + + Parameters + ---------- + component: Component + The BooleanVar object that owns this data. + + Attributes + ---------- + fixed: bool + If True, then this variable is treated as a fixed constant in + the model. + """ - __slots__ = () + __slots__ = ('_value', 'fixed', '_stale', '_associated_binary') + __autoslot_mappers__ = { + '_associated_binary': _associated_binary_mapper, + '_stale': StaleFlagManager.stale_mapper, + } def __init__(self, component=None): + # + # These lines represent in-lining of the + # following constructors: + # - BooleanVarData + # - ComponentData + # - BooleanValue self._component = weakref_ref(component) if (component is not None) else None self._index = NOTSET + self._value = None + self.fixed = False + self._stale = 0 # True + + self._associated_binary = None def is_fixed(self): """Returns True if this variable is fixed, otherwise returns False.""" @@ -128,120 +156,14 @@ def set_value(self, val, skip_validation=False): def clear(self): self.value = None - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Compute the value of this variable.""" + exception = _type_check_exception_arg(self, exception) return self.value @property def value(self): - """Return the value for this variable.""" - raise NotImplementedError - - @property - def domain(self): - """Return the domain for this variable.""" - raise NotImplementedError - - @property - def fixed(self): - """Return the fixed indicator for this variable.""" - raise NotImplementedError - - @property - def stale(self): - """Return the stale indicator for this variable.""" - raise NotImplementedError - - def fix(self, value=NOTSET, skip_validation=False): - """Fix the value of this variable (treat as nonvariable) - - This sets the `fixed` indicator to True. If ``value`` is - provided, the value (and the ``skip_validation`` flag) are first - passed to :py:meth:`set_value()`. - - """ - self.fixed = True - if value is not NOTSET: - self.set_value(value, skip_validation) - - def unfix(self): - """Unfix this variable (treat as variable) - - This sets the `fixed` indicator to False. - - """ - self.fixed = False - - def free(self): - """Alias for :py:meth:`unfix`""" - return self.unfix() - - -def _associated_binary_mapper(encode, val): - if val is None: - return None - if encode: - if val.__class__ is not _DeprecatedImplicitAssociatedBinaryVariable: - return val() - else: - if val.__class__ is not _DeprecatedImplicitAssociatedBinaryVariable: - return weakref_ref(val) - return val - - -class _GeneralBooleanVarData(_BooleanVarData): - """ - This class defines the data for a single Boolean variable. - - Constructor Arguments: - component The BooleanVar object that owns this data. - - Public Class Attributes: - domain The domain of this variable. - fixed If True, then this variable is treated as a - fixed constant in the model. - stale A Boolean indicating whether the value of this variable is - legitimiate. This value is true if the value should - be considered legitimate for purposes of reporting or - other interrogation. - value The numeric value of this variable. - - The domain attribute is a property because it is - too widely accessed directly to enforce explicit getter/setter - methods and we need to deter directly modifying or accessing - these attributes in certain cases. - """ - - __slots__ = ('_value', 'fixed', '_stale', '_associated_binary') - __autoslot_mappers__ = { - '_associated_binary': _associated_binary_mapper, - '_stale': StaleFlagManager.stale_mapper, - } - - def __init__(self, component=None): - # - # These lines represent in-lining of the - # following constructors: - # - _BooleanVarData - # - ComponentData - # - BooleanValue - self._component = weakref_ref(component) if (component is not None) else None - self._index = NOTSET - self._value = None - self.fixed = False - self._stale = 0 # True - - self._associated_binary = None - - # - # Abstract Interface - # - - # value is an attribute - - @property - def value(self): - """Return (or set) the value for this variable.""" + """bool : the current value for this variable.""" return self._value @value.setter @@ -250,11 +172,17 @@ def value(self, val): @property def domain(self): - """Return the domain for this variable.""" + """BooleanSet : the domain for this variable.""" return BooleanSet @property def stale(self): + """ + bool : A Boolean indicating whether the value of this variable is + Consistent with the most recent solve. `True` indicates that + this variable's value was set prior to the most recent solve and + was not updated by the results returned by the solve. + """ return StaleFlagManager.is_stale(self._stale) @stale.setter @@ -265,14 +193,14 @@ def stale(self, val): self._stale = StaleFlagManager.get_flag(0) def get_associated_binary(self): - """Get the binary _VarData associated with this - _GeneralBooleanVarData""" + """Get the binary VarData associated with this + BooleanVarData""" return ( self._associated_binary() if self._associated_binary is not None else None ) def associate_binary_var(self, binary_var): - """Associate a binary _VarData to this _GeneralBooleanVarData""" + """Associate a binary VarData to this BooleanVarData""" if ( self._associated_binary is not None and type(self._associated_binary) @@ -294,6 +222,40 @@ def associate_binary_var(self, binary_var): if binary_var is not None: self._associated_binary = weakref_ref(binary_var) + def fix(self, value=NOTSET, skip_validation=False): + """Fix the value of this variable (treat as nonvariable) + + This sets the `fixed` indicator to True. If ``value`` is + provided, the value (and the ``skip_validation`` flag) are first + passed to :py:meth:`set_value()`. + + """ + self.fixed = True + if value is not NOTSET: + self.set_value(value, skip_validation) + + def unfix(self): + """Unfix this variable (treat as variable) + + This sets the `fixed` indicator to False. + + """ + self.fixed = False + + def free(self): + """Alias for :py:meth:`unfix`""" + return self.unfix() + + +class _BooleanVarData(metaclass=RenamedClass): + __renamed__new_class__ = BooleanVarData + __renamed__version__ = '6.7.2' + + +class _GeneralBooleanVarData(metaclass=RenamedClass): + __renamed__new_class__ = BooleanVarData + __renamed__version__ = '6.7.2' + @ModelComponentFactory.register("Logical decision variables.") class BooleanVar(IndexedComponent): @@ -309,7 +271,7 @@ class BooleanVar(IndexedComponent): to True. """ - _ComponentDataClass = _GeneralBooleanVarData + _ComponentDataClass = BooleanVarData def __new__(cls, *args, **kwds): if cls != BooleanVar: @@ -390,7 +352,7 @@ def construct(self, data=None): _set.construct() # - # Construct _BooleanVarData objects for all index values + # Construct BooleanVarData objects for all index values # if not self.is_indexed(): self._data[None] = self @@ -501,11 +463,11 @@ def _pprint(self): ) -class ScalarBooleanVar(_GeneralBooleanVarData, BooleanVar): +class ScalarBooleanVar(BooleanVarData, BooleanVar): """A single variable.""" def __init__(self, *args, **kwd): - _GeneralBooleanVarData.__init__(self, component=self) + BooleanVarData.__init__(self, component=self) BooleanVar.__init__(self, *args, **kwd) self._index = UnindexedComponent_index @@ -519,9 +481,9 @@ def __init__(self, *args, **kwd): @property def value(self): - """Return the value for this variable.""" + """bool : the current value of this variable.""" if self._constructed: - return _GeneralBooleanVarData.value.fget(self) + return BooleanVarData.value.fget(self) raise ValueError( "Accessing the value of variable '%s' " "before the Var has been constructed (there " @@ -530,9 +492,8 @@ def value(self): @value.setter def value(self, val): - """Set the value for this variable.""" if self._constructed: - return _GeneralBooleanVarData.value.fset(self, val) + return BooleanVarData.value.fset(self, val) raise ValueError( "Setting the value of variable '%s' " "before the Var has been constructed (there " @@ -541,7 +502,8 @@ def value(self, val): @property def domain(self): - return _GeneralBooleanVarData.domain.fget(self) + """BooleanSet : the domain for this variable.""" + return BooleanVarData.domain.fget(self) def fix(self, value=NOTSET, skip_validation=False): """ @@ -549,7 +511,7 @@ def fix(self, value=NOTSET, skip_validation=False): indicating the variable should be fixed at its current value. """ if self._constructed: - return _GeneralBooleanVarData.fix(self, value, skip_validation) + return BooleanVarData.fix(self, value, skip_validation) raise ValueError( "Fixing variable '%s' " "before the Var has been constructed (there " @@ -559,7 +521,7 @@ def fix(self, value=NOTSET, skip_validation=False): def unfix(self): """Sets the fixed indicator to False.""" if self._constructed: - return _GeneralBooleanVarData.unfix(self) + return BooleanVarData.unfix(self) raise ValueError( "Freeing variable '%s' " "before the Var has been constructed (there " @@ -603,6 +565,7 @@ def free(self): @property def domain(self): + """BooleanSet : the domain for this variable.""" return BooleanSet # Because Emma wants crazy things... (Where crazy things are the ability to diff --git a/pyomo/core/base/check.py b/pyomo/core/base/check.py index 485d1a73b6b..38cffc879ea 100644 --- a/pyomo/core/base/check.py +++ b/pyomo/core/base/check.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/component.py b/pyomo/core/base/component.py index 22c2bc4b804..ab832b15029 100644 --- a/pyomo/core/base/component.py +++ b/pyomo/core/base/component.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,6 +20,7 @@ from pyomo.common.autoslots import AutoSlots, fast_deepcopy from pyomo.common.collections import OrderedDict from pyomo.common.deprecation import ( + RenamedClass, deprecated, deprecation_warning, relocated_module_attribute, @@ -79,7 +80,7 @@ class CloneError(pyomo.common.errors.PyomoException): pass -class _ComponentBase(PyomoObject): +class ComponentBase(PyomoObject): """A base class for Component and ComponentData This class defines some fundamental methods and properties that are @@ -111,7 +112,7 @@ def __deepcopy__(self, memo): # Templates (and the corresponding _GetItemExpression object), # expressions can refer to container (non-Simple) components, so # we need to override __deepcopy__ for both Component and - # ComponentData. + # ComponentData (so we put it here on ComponentBase). # if '__block_scope__' in memo: _scope = memo['__block_scope__'] @@ -119,8 +120,34 @@ def __deepcopy__(self, memo): tmp = self.parent_block() # "Floating" components should be in scope by default (we # will handle 'global' components like GlobalSets in the - # components) - _in_scope = tmp is None + # components). This ensures that things like set operators + # on Abstract set objects are correctly cloned. For + # example, consider an abstract indexed model component + # whose domain is specified by a Set expression: + # + # def x_init(m,i): + # if i == 2: + # return Set.Skip + # else: + # return [] + # m.x = Set( [1,2], + # domain={1: m.A*m.B, 2: m.A*m.A}, + # initialize=x_init ) + # + # We do not want to automatically add all the Set operators + # to the model at declaration time, as m.x[2] is never + # actually created. Plus, doing so would require complex + # parsing of the initializers. BUT, we need to ensure that + # the operators are deepcopied, otherwise when the model is + # cloned before construction the operators will still refer + # to the sets on the original abstract model (in particular, + # the Set x will have an unknown dimen). + # + # The solution is to automatically clone all floating + # components, except for Models (i.e., top-level BlockData + # have no parent and technically "float") + _in_scope = tmp is None and self is not self.model() + # # Note: normally we would need to check that tmp does not # end up being None. However, since clone() inserts # id(None) into the __block_scope__ dictionary, we are safe @@ -150,28 +177,6 @@ def __deepcopy__(self, memo): memo[id(self)] = self return self # - # At this point we know we need to deepcopy this component (and - # everything under it). We can't do the "obvious", since this - # is a (partially) slot-ized class and the __dict__ structure is - # nonauthoritative: - # - # for key, val in self.__dict__.iteritems(): - # object.__setattr__(ans, key, deepcopy(val, memo)) - # - # Further, __slots__ is also nonauthoritative (this may be a - # singleton component -- in which case it also has a __dict__). - # Plus, this may be a derived class with several layers of - # slots. So, we will piggyback on the __getstate__/__setstate__ - # logic amd resort to partially "pickling" the object, - # deepcopying the state, and then restoring the copy into - # the new instance. - # - # [JDS 7/7/14] I worry about the efficiency of using both - # getstate/setstate *and* deepcopy, but we need deepcopy to - # update the _parent refs appropriately, and since this is a - # slot-ized class, we cannot overwrite the __deepcopy__ - # attribute to prevent infinite recursion. - # # deepcopy() is an inherently recursive operation. This can # cause problems for highly interconnected Pyomo models (for # example, a time linked model where each time block has a @@ -182,95 +187,34 @@ def __deepcopy__(self, memo): # components / component datas, and NOT to attributes on the # components/datas. So, if we can first go through and stub in # all the objects that we will need to populate, and then go - # through and deepcopy them, then we can unroll the vast + # through and deepcopy them, we can unroll the vast # majority of the recursion. # component_list = [] self._create_objects_for_deepcopy(memo, component_list) # + # Note that self is now the first element of component_list + # # Now that we have created (but not populated) all the - # components that we expect to need, we can go through and - # populate all the components. + # components that we expect to need in the memo, we can go + # through and populate all the components. # # The component_list is roughly in declaration order. This # means that it should be relatively safe to clone the contents # in the same order. # - # There is a particularly subtle bug with 'uncopyable' - # attributes: if the exception is thrown while copying a complex - # data structure, we can be in a state where objects have been - # created and assigned to the memo in the try block, but they - # haven't had their state set yet. When the exception moves us - # into the except block, we need to effectively "undo" those - # partially copied classes. The only way is to restore the memo - # to the state it was in before we started. We will make use of - # the knowledge that 1) memo entries are never reassigned during - # a deepcopy(), and 2) dict are ordered by insertion order in - # Python >= 3.7. As a result, we do not need to preserve the - # whole memo before calling __getstate__/__setstate__, and can - # get away with only remembering the number of items in the - # memo. - # - # Note that entering/leaving try-except contexts has a - # not-insignificant overhead. On the hope that the user wrote a - # sane (deepcopy-able) model, we will try to do everything in - # one try-except block. - # - try: - for i, comp in enumerate(component_list): - saved_memo = len(memo) - # Note: this implementation avoids deepcopying the - # temporary 'state' list, significantly speeding things - # up. - memo[id(comp)].__setstate__( - [fast_deepcopy(field, memo) for field in comp.__getstate__()] - ) - return memo[id(self)] - except: - pass - # - # We hit an error deepcopying a component. Attempt to reset - # things and try again, but in a more cautious manner (after - # all, if one component was not deepcopyable, it stands to - # reason that several others will not be either). - # - # We want to remove any new entries added to the memo during the - # failed try above. - # - for _ in range(len(memo) - saved_memo): - memo.popitem() - # - # Now we are going to continue on, but in a more cautious - # manner: we will clone entries field at a time so that we can - # get the most "complete" copy possible. - for comp in component_list[i:]: - state = comp.__getstate__() - # Note: if has_dict, then __auto_slots__.slots will be 1 - # shorter than the state (the last element is the __dict__). - # Zip will ignore it. - _deepcopy_field = comp._deepcopy_field - new_state = [ - _deepcopy_field(memo, slot, value) - for slot, value in zip(comp.__auto_slots__.slots, state) - ] - if comp.__auto_slots__.has_dict: - new_state.append( - { - slot: _deepcopy_field(memo, slot, value) - for slot, value in state[-1].items() - } - ) - memo[id(comp)].__setstate__(new_state) + for comp, new in component_list: + comp.__deepcopy_state__(memo, new) return memo[id(self)] def _create_objects_for_deepcopy(self, memo, component_list): _new = self.__class__.__new__(self.__class__) _ans = memo.setdefault(id(self), _new) if _ans is _new: - component_list.append(self) + component_list.append((self, _new)) return _ans - def _deepcopy_field(self, memo, slot_name, value): + def __deepcopy_field__(self, value, memo, slot_name): saved_memo = len(memo) try: return fast_deepcopy(value, memo) @@ -283,15 +227,11 @@ def _deepcopy_field(self, memo, slot_name, value): # warn the user if '__block_scope__' not in memo: logger.warning( - """ - Uncopyable field encountered when deep - copying outside the scope of Block.clone(). - There is a distinct possibility that the new - copy is not complete. To avoid this - situation, either use Block.clone() or set - 'paranoid' mode by adding '__paranoid__' == - True to the memo before calling - copy.deepcopy.""" + "Uncopyable field encountered when deep " + "copying Pyomo components outside the scope of " + "Block.clone(). There is a distinct possibility " + "that the new copy is not complete. To avoid " + "this situation, please use Block.clone()" ) if self.model() is self: what = 'Model' @@ -368,7 +308,7 @@ def pprint(self, ostream=None, verbose=False, prefix=""): @property def name(self): - """Get the fully qualifed component name.""" + """Get the fully qualified component name.""" return self.getname(fully_qualified=True) # Adding a setter here to help users adapt to the new @@ -474,23 +414,31 @@ def _pprint_base_impl( ostream.write(_data) -class Component(_ComponentBase): +class _ComponentBase(metaclass=RenamedClass): + __renamed__new_class__ = ComponentBase + __renamed__version__ = '6.7.2' + + +class Component(ComponentBase): """ This is the base class for all Pyomo modeling components. - Constructor arguments: - ctype The class type for the derived subclass - doc A text string describing this component - name A name for this component + Parameters + ---------- + ctype : type + The class type for the derived subclass - Public class attributes: - doc A text string describing this component + doc : str + A text string describing this component + + name : str + A name for this component + + Attributes + ---------- + doc : str + A text string describing this component - Private class attributes: - _constructed A boolean that is true if this component has been - constructed - _parent A weakref to the parent block that owns this component - _ctype The class type for the derived subclass """ __autoslot_mappers__ = {'_parent': AutoSlots.weakref_mapper} @@ -657,14 +605,14 @@ def getname(self, fully_qualified=False, name_buffer=None, relative_to=None): "use of this argument poses risks if the buffer contains " "names relative to different Blocks in the model hierarchy or " "a mixture of local and fully_qualified names.", - version='TODO', + version='6.4.1', ) name_buffer[id(self)] = ans return ans @property def name(self): - """Get the fully qualifed component name.""" + """Get the fully qualified component name.""" return self.getname(fully_qualified=True) # Allow setting a component's name if it is not owned by a parent @@ -779,7 +727,7 @@ def deactivate(self): self._active = False -class ComponentData(_ComponentBase): +class ComponentData(ComponentBase): """ This is the base class for the component data used in Pyomo modeling components. Subclasses of ComponentData are @@ -802,11 +750,11 @@ class ComponentData(_ComponentBase): __autoslot_mappers__ = {'_component': AutoSlots.weakref_mapper} # NOTE: This constructor is in-lined in the constructors for the following - # classes: _BooleanVarData, _ConnectorData, _ConstraintData, - # _GeneralExpressionData, _LogicalConstraintData, - # _GeneralLogicalConstraintData, _GeneralObjectiveData, - # _ParamData,_GeneralVarData, _GeneralBooleanVarData, _DisjunctionData, - # _ArcData, _PortData, _LinearConstraintData, and + # classes: BooleanVarData, ConnectorData, ConstraintData, + # ExpressionData, LogicalConstraintData, + # LogicalConstraintData, ObjectiveData, + # ParamData,VarData, BooleanVarData, DisjunctionData, + # ArcData, PortData, _LinearConstraintData, and # _LinearMatrixConstraintData. Changes made here need to be made in those # constructors as well! def __init__(self, component): @@ -876,21 +824,24 @@ def index(self): - for some unknown reason - this instance does not belong to the parent component's index set. """ + try: + if self._component()[self._index] is self: + return self._index + except: + pass + if self._index is NOTSET: + return self._index parent = self.parent_component() - if ( - parent is not None - and self._index is not NOTSET - and parent[self._index] is not self - ): - # This error message is a bit goofy, but we can't call self.name - # here--it's an infinite loop! - raise DeveloperError( - "The '_data' dictionary and '_index' attribute are out of " - "sync for indexed %s '%s': The %s entry in the '_data' " - "dictionary does not map back to this component data object." - % (parent.ctype.__name__, parent.name, self._index) - ) - return self._index + if parent is None: + return self._index + # This error message is a bit goofy, but we can't call self.name + # here--it's an infinite loop! + raise DeveloperError( + "The '_data' dictionary and '_index' attribute are out of " + "sync for indexed %s '%s': The %s entry in the '_data' " + "dictionary does not map back to this component data object." + % (parent.ctype.__name__, parent.name, self._index) + ) def __str__(self): """Return a string with the component name and index""" @@ -916,7 +867,7 @@ def getname(self, fully_qualified=False, name_buffer=None, relative_to=None): "use of this argument poses risks if the buffer contains " "names relative to different Blocks in the model hierarchy or " "a mixture of local and fully_qualified names.", - version='TODO', + version='6.4.1', ) if id(self) in name_buffer: # Return the name if it is in the buffer diff --git a/pyomo/core/base/component_namer.py b/pyomo/core/base/component_namer.py index c2fa01f6ad5..8168f01288c 100644 --- a/pyomo/core/base/component_namer.py +++ b/pyomo/core/base/component_namer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/component_order.py b/pyomo/core/base/component_order.py index 9244828cbe5..24f843810ca 100644 --- a/pyomo/core/base/component_order.py +++ b/pyomo/core/base/component_order.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/componentuid.py b/pyomo/core/base/componentuid.py index 2075aa197dc..6be935d8fee 100644 --- a/pyomo/core/base/componentuid.py +++ b/pyomo/core/base/componentuid.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -43,6 +43,12 @@ def _index_repr(x): return __index_repr(x, _pickle) +def _context_err(_type): + raise ValueError( + f"Context is not allowed when initializing a ComponentUID from {_type}." + ) + + class ComponentUID(object): """ A Component unique identifier @@ -78,15 +84,15 @@ def __init__(self, component, cuid_buffer=None, context=None): # the string representation. if isinstance(component, str): if context is not None: - raise ValueError( - "Context is not allowed when initializing a " - "ComponentUID object from a string type" - ) + _context_err(str) try: self._cids = tuple(self._parse_cuid_v2(component)) except (OSError, IOError): self._cids = tuple(self._parse_cuid_v1(component)) - + elif type(component) is ComponentUID: + if context is not None: + _context_err(ComponentUID) + self._cids = component._cids elif type(component) is IndexedComponent_slice: self._cids = tuple( self._generate_cuid_from_slice(component, context=context) diff --git a/pyomo/core/base/config.py b/pyomo/core/base/config.py index 14c00522673..ff2cbd7d225 100644 --- a/pyomo/core/base/config.py +++ b/pyomo/core/base/config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/connector.py b/pyomo/core/base/connector.py index 435a2c2fccb..26b87569e42 100644 --- a/pyomo/core/base/connector.py +++ b/pyomo/core/base/connector.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -28,7 +28,7 @@ logger = logging.getLogger('pyomo.core') -class _ConnectorData(ComponentData, NumericValue): +class ConnectorData(ComponentData, NumericValue): """Holds the actual connector information""" __slots__ = ('vars', 'aggregators') @@ -105,6 +105,11 @@ def _iter_vars(self): yield v +class _ConnectorData(metaclass=RenamedClass): + __renamed__new_class__ = ConnectorData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register( "A bundle of variables that can be manipulated together." ) @@ -126,12 +131,15 @@ class Connector(IndexedComponent): constraints that involve the original variables contained within the Connector. - Constructor - Arguments: - name The name of this connector - index The index set that defines the distinct connectors. - By default, this is None, indicating that there - is a single connector. + Parameters + ---------- + name : str + The name of this connector + + index + The index set that defines the distinct connectors. By default, + this is None, indicating that there is a single connector. + """ def __new__(cls, *args, **kwds): @@ -157,7 +165,7 @@ def __init__(self, *args, **kwd): # IndexedComponent # def _getitem_when_not_present(self, idx): - _conval = self._data[idx] = _ConnectorData(component=self) + _conval = self._data[idx] = ConnectorData(component=self) return _conval def construct(self, data=None): @@ -170,7 +178,7 @@ def construct(self, data=None): timer = ConstructionTimer(self) self._constructed = True # - # Construct _ConnectorData objects for all index values + # Construct ConnectorData objects for all index values # if self.is_indexed(): self._initialize_members(self._index_set) @@ -258,9 +266,9 @@ def _line_generator(k, v): ) -class ScalarConnector(Connector, _ConnectorData): +class ScalarConnector(Connector, ConnectorData): def __init__(self, *args, **kwd): - _ConnectorData.__init__(self, component=self) + ConnectorData.__init__(self, component=self) Connector.__init__(self, *args, **kwd) self._index = UnindexedComponent_index diff --git a/pyomo/core/base/constraint.py b/pyomo/core/base/constraint.py index 8cf3c48ad0a..63858f36d61 100644 --- a/pyomo/core/base/constraint.py +++ b/pyomo/core/base/constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from __future__ import annotations import sys import logging from weakref import ref as weakref_ref from pyomo.common.pyomo_typing import overload +from typing import Union, Type from pyomo.common.deprecation import RenamedClass -from pyomo.common.errors import DeveloperError +from pyomo.common.errors import DeveloperError, TemplateExpressionError from pyomo.common.formatting import tabular_writer from pyomo.common.log import is_debug_set from pyomo.common.modeling import NOTSET @@ -36,12 +38,15 @@ InequalityExpression, RangedExpression, ) +from pyomo.core.expr.expr_common import _type_check_exception_arg +from pyomo.core.expr.template_expr import templatize_constraint from pyomo.core.base.component import ActiveComponentData, ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index from pyomo.core.base.indexed_component import ( ActiveIndexedComponent, UnindexedComponent_set, rule_wrapper, + IndexedComponent, ) from pyomo.core.base.set import Set from pyomo.core.base.disable_methods import disable_methods @@ -52,15 +57,18 @@ ) -logger = logging.getLogger('pyomo.core') +logger = logging.getLogger("pyomo.core") -_inf = float('inf') +TEMPLATIZE_CONSTRAINTS = False + +_inf = float("inf") _nonfinite_values = {_inf, -_inf} _known_relational_expressions = { EqualityExpression, InequalityExpression, RangedExpression, } +_strict_relational_exprs = {True, (False, True), (True, False), (True, True)} _rule_returned_none_error = """Constraint '%s': rule returned None. Constraint rules must return either a valid expression, a 2- or 3-member @@ -79,11 +87,14 @@ def simple_constraint_rule(rule): Example use: - @simple_constraint_rule - def C_rule(model, i, j): - ... + .. code:: + + @simple_constraint_rule + def C_rule(model, i, j): + # ... + + model.c = Constraint(rule=simple_constraint_rule(...)) - model.c = Constraint(rule=simple_constraint_rule(...)) """ map_types = set([type(None)]) | native_logical_types result_map = {None: Constraint.Skip} @@ -105,11 +116,14 @@ def simple_constraintlist_rule(rule): Example use: - @simple_constraintlist_rule - def C_rule(model, i, j): - ... + .. code:: + + @simple_constraintlist_rule + def C_rule(model, i, j): + # ... + + model.c = ConstraintList(expr=simple_constraintlist_rule(...)) - model.c = ConstraintList(expr=simple_constraintlist_rule(...)) """ map_types = set([type(None)]) | native_logical_types result_map = {None: ConstraintList.End} @@ -122,332 +136,245 @@ def C_rule(model, i, j): return rule_wrapper(rule, result_map, map_types=map_types) -# -# This class is a pure interface -# +class ConstraintData(ActiveComponentData): + """This class defines the data for a single algebraic constraint. + Parameters + ---------- + expr : ExpressionBase + The Pyomo expression stored in this constraint. -class _ConstraintData(ActiveComponentData): - """ - This class defines the data for a single constraint. - - Constructor arguments: - component The Constraint object that owns this data. + component : Constraint + The Constraint object that owns this data. - Public class attributes: - active A boolean that is true if this constraint is - active in the model. - body The Pyomo expression for this constraint - lower The Pyomo expression for the lower bound - upper The Pyomo expression for the upper bound - equality A boolean that indicates whether this is an - equality constraint - strict_lower A boolean that indicates whether this - constraint uses a strict lower bound - strict_upper A boolean that indicates whether this - constraint uses a strict upper bound - - Private class attributes: - _component The objective component. - _active A boolean that indicates whether this data is active """ - __slots__ = () + __slots__ = ("_expr",) # Set to true when a constraint class stores its expression # in linear canonical form _linear_canonical_form = False - def __init__(self, component=None): + def __init__(self, expr=None, component=None): # # These lines represent in-lining of the # following constructors: - # - _ConstraintData, + # - ConstraintData # - ActiveComponentData # - ComponentData self._component = weakref_ref(component) if (component is not None) else None - self._index = NOTSET self._active = True - # - # Interface - # + self._expr = None + if expr is not None: + self.set_value(expr) + logger.debug(f"{self.__class__} __init__: expr={expr}") - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Compute the value of the body of this constraint.""" - return value(self.body, exception=exception) - - def has_lb(self): - """Returns :const:`False` when the lower bound is - :const:`None` or negative infinity""" - return self.lb is not None - - def has_ub(self): - """Returns :const:`False` when the upper bound is - :const:`None` or positive infinity""" - return self.ub is not None - - def lslack(self): - """ - Returns the value of f(x)-L for constraints of the form: - L <= f(x) (<= U) - (U >=) f(x) >= L - """ - lb = self.lb - if lb is None: - return _inf - else: - return value(self.body) - lb - - def uslack(self): - """ - Returns the value of U-f(x) for constraints of the form: - (L <=) f(x) <= U - U >= f(x) (>= L) - """ - ub = self.ub - if ub is None: - return _inf - else: - return ub - value(self.body) - - def slack(self): - """ - Returns the smaller of lslack and uslack values - """ - lb = self.lb - ub = self.ub - body = value(self.body) - if lb is None: - return ub - body - elif ub is None: - return body - lb - return min(ub - body, body - lb) - - # - # Abstract Interface - # - - @property - def body(self): - """Access the body of a constraint expression.""" - raise NotImplementedError - - @property - def lower(self): - """Access the lower bound of a constraint expression.""" - raise NotImplementedError - - @property - def upper(self): - """Access the upper bound of a constraint expression.""" - raise NotImplementedError - - @property - def lb(self): - """Access the value of the lower bound of a constraint expression.""" - raise NotImplementedError - - @property - def ub(self): - """Access the value of the upper bound of a constraint expression.""" - raise NotImplementedError - - @property - def equality(self): - """A boolean indicating whether this is an equality constraint.""" - raise NotImplementedError - - @property - def strict_lower(self): - """True if this constraint has a strict lower bound.""" - raise NotImplementedError - - @property - def strict_upper(self): - """True if this constraint has a strict upper bound.""" - raise NotImplementedError + exception = _type_check_exception_arg(self, exception) + body = self.to_bounded_expression()[1] + if body.__class__ not in native_numeric_types: + body = value(self.body, exception=exception) + return body - def set_value(self, expr): - """Set the expression on this constraint.""" - raise NotImplementedError + def to_bounded_expression(self, evaluate_bounds=False): + """Convert this constraint to a tuple of 3 expressions (lb, body, ub) - def get_value(self): - """Get the expression on this constraint.""" - raise NotImplementedError + This method "standardizes" the expression into a 3-tuple of + expressions: (`lower_bound`, `body`, `upper_bound`). Upon + conversion, `lower_bound` and `upper_bound` are guaranteed to be + `None`, numeric constants, or fixed (not necessarily constant) + expressions. + Note + ---- + As this method operates on the *current state* of the + expression, any required expression manipulations (and by + extension, the result) can change after fixing / unfixing + :py:class:`Var` objects. -class _GeneralConstraintData(_ConstraintData): - """ - This class defines the data for a single general constraint. + Parameters + ---------- + evaluate_bounds: bool - Constructor arguments: - component The Constraint object that owns this data. - expr The Pyomo expression stored in this constraint. + If True, then the lower and upper bounds will be evaluated + to a finite numeric constant or None. - Public class attributes: - active A boolean that is true if this constraint is - active in the model. - body The Pyomo expression for this constraint - lower The Pyomo expression for the lower bound - upper The Pyomo expression for the upper bound - equality A boolean that indicates whether this is an - equality constraint - strict_lower A boolean that indicates whether this - constraint uses a strict lower bound - strict_upper A boolean that indicates whether this - constraint uses a strict upper bound + Raises + ------ - Private class attributes: - _component The objective component. - _active A boolean that indicates whether this data is active - """ + ValueError: Raised if the expression cannot be mapped to this + form (i.e., :py:class:`RangedExpression` constraints with + variable lower or upper bounds. - __slots__ = ('_body', '_lower', '_upper', '_expr') + """ + expr = self._expr - def __init__(self, expr=None, component=None): - # - # These lines represent in-lining of the - # following constructors: - # - _ConstraintData, - # - ActiveComponentData - # - ComponentData - self._component = weakref_ref(component) if (component is not None) else None - self._active = True + if expr.__class__ is RangedExpression: + lb, body, ub = ans = expr.args + if ( + lb.__class__ not in native_types + and lb.is_potentially_variable() + and not lb.is_fixed() + ): + raise ValueError( + f"Constraint '{self.name}' is a Ranged Inequality with a " + "variable lower bound. Cannot normalize the " + "constraint or send it to a solver." + ) + if ( + ub.__class__ not in native_types + and ub.is_potentially_variable() + and not ub.is_fixed() + ): + raise ValueError( + f"Constraint '{self.name}' is a Ranged Inequality with a " + "variable upper bound. Cannot normalize the " + "constraint or send it to a solver." + ) + elif expr is None: + ans = None, None, None + else: + lhs, rhs = expr.args - self._body = None - self._lower = None - self._upper = None - self._expr = None - if expr is not None: - self.set_value(expr) + if rhs.__class__ in native_types or not rhs.is_potentially_variable(): + ans = rhs if expr.__class__ is EqualityExpression else None, lhs, rhs + elif lhs.__class__ in native_types or not lhs.is_potentially_variable(): + ans = lhs, rhs, lhs if expr.__class__ is EqualityExpression else None + else: + ans = 0 if expr.__class__ is EqualityExpression else None, lhs - rhs, 0 - # - # Abstract Interface - # + if evaluate_bounds: + lb, body, ub = ans + return self._evaluate_bound(lb, True), body, self._evaluate_bound(ub, False) - @property - def body(self): - """Access the body of a constraint expression.""" - if self._body is not None: - return self._body - # The incoming RangedInequality had a potentially variable - # bound. The "body" is fine, but the bounds may not be - # (although the responsibility for those checks lies with the - # lower/upper properties) - body = self._expr.arg(1) - if body.__class__ in native_types and body is not None: - return as_numeric(body) - return body + return ans - def _get_range_bound(self, range_arg): - # Equalities and simple inequalities can always be (directly) - # reformulated at construction time to force constant bounds. - # The only time we need to defer the determination of bounds is - # for ranged inequalities that contain non-constant bounds (so - # we *know* that the expr will have 3 args) - # - # It is possible that there is no expression at all (so catch that) - if self._expr is None: + def _evaluate_bound(self, bound, is_lb): + if bound is None: return None - bound = self._expr.arg(range_arg) - if not is_fixed(bound): + if bound.__class__ not in native_numeric_types: + bound = float(value(bound)) + # Note that "bound != bound" catches float('nan') + if bound in _nonfinite_values or bound != bound: + if bound == (-_inf if is_lb else _inf): + return None raise ValueError( - "Constraint '%s' is a Ranged Inequality with a " - "variable %s bound. Cannot normalize the " - "constraint or send it to a solver." - % (self.name, {0: 'lower', 2: 'upper'}[range_arg]) + f"Constraint '{self.name}' created with an invalid non-finite " + f"{'lower' if is_lb else 'upper'} bound ({bound})." ) return bound + @property + def body(self): + """The body (variable portion) of a constraint expression.""" + try: + ans = self.to_bounded_expression()[1] + except ValueError: + # It is possible that the expression is not currently valid + # (i.e., a ranged expression with a non-fixed bound). We + # will catch that exception here and - if this actually *is* + # a RangedExpression - return the body. + if self._expr.__class__ is RangedExpression: + _, ans, _ = self._expr.args + else: + raise + if ans.__class__ in native_types and ans is not None: + # Historically, constraint.lower was guaranteed to return a type + # derived from Pyomo NumericValue (or None). Replicate that. + # + # [JDS 6/2024: it would be nice to remove this behavior, + # although possibly unnecessary, as people should use + # to_bounded_expression() instead] + return as_numeric(ans) + return ans + @property def lower(self): - """Access the lower bound of a constraint expression.""" - bound = self._lower if self._body is not None else self._get_range_bound(0) - # Historically, constraint.lower was guaranteed to return a type - # derived from Pyomo NumericValue (or None). Replicate that - # functionality, although clients should in almost all cases - # move to using ConstraintData.lb instead of accessing - # lower/body/upper to avoid the unnecessary creation (and - # inevitable destruction) of the NumericConstant wrappers. - if bound is None: - return None - return as_numeric(bound) + """The lower bound of a constraint expression. + + This is the fixed lower bound of a Constraint as a Pyomo + expression. This may contain potentially variable terms + that are currently fixed. If there is no lower bound, this will + return `None`. + + """ + ans = self.to_bounded_expression()[0] + if ans.__class__ in native_types and ans is not None: + # Historically, constraint.lower was guaranteed to return a type + # derived from Pyomo NumericValue (or None). Replicate that + # functionality, although clients should in almost all cases + # move to using ConstraintData.lb instead of accessing + # lower/body/upper to avoid the unnecessary creation (and + # inevitable destruction) of the NumericConstant wrappers. + return as_numeric(ans) + return ans @property def upper(self): - """Access the upper bound of a constraint expression.""" - bound = self._upper if self._body is not None else self._get_range_bound(2) - # Historically, constraint.upper was guaranteed to return a type - # derived from Pyomo NumericValue (or None). Replicate that - # functionality, although clients should in almost all cases - # move to using ConstraintData.ub instead of accessing - # lower/body/upper to avoid the unnecessary creation (and - # inevitable destruction) of the NumericConstant wrappers. - if bound is None: - return None - return as_numeric(bound) + """Access the upper bound of a constraint expression. + + This is the fixed upper bound of a Constraint as a Pyomo + expression. This may contain potentially variable terms + that are currently fixed. If there is no upper bound, this will + return `None`. + + """ + ans = self.to_bounded_expression()[2] + if ans.__class__ in native_types and ans is not None: + # Historically, constraint.upper was guaranteed to return a type + # derived from Pyomo NumericValue (or None). Replicate that + # functionality, although clients should in almost all cases + # move to using ConstraintData.lb instead of accessing + # lower/body/upper to avoid the unnecessary creation (and + # inevitable destruction) of the NumericConstant wrappers. + return as_numeric(ans) + return ans @property def lb(self): - """Access the value of the lower bound of a constraint expression.""" - bound = self._lower if self._body is not None else self._get_range_bound(0) - if bound.__class__ not in native_numeric_types: - if bound is None: - return None - bound = float(value(bound)) - if bound in _nonfinite_values or bound != bound: - # Note that "bound != bound" catches float('nan') - if bound == -_inf: - return None - else: - raise ValueError( - "Constraint '%s' created with an invalid non-finite " - "lower bound (%s)." % (self.name, bound) - ) - return bound + """float : the value of the lower bound of a constraint expression.""" + return self._evaluate_bound(self.to_bounded_expression()[0], True) @property def ub(self): - """Access the value of the upper bound of a constraint expression.""" - bound = self._upper if self._body is not None else self._get_range_bound(2) - if bound.__class__ not in native_numeric_types: - if bound is None: - return None - bound = float(value(bound)) - if bound in _nonfinite_values or bound != bound: - # Note that "bound != bound" catches float('nan') - if bound == _inf: - return None - else: - raise ValueError( - "Constraint '%s' created with an invalid non-finite " - "upper bound (%s)." % (self.name, bound) - ) - return bound + """float : the value of the upper bound of a constraint expression.""" + return self._evaluate_bound(self.to_bounded_expression()[2], False) @property def equality(self): - """A boolean indicating whether this is an equality constraint.""" - if self._expr.__class__ is EqualityExpression: + """bool : True if this is an equality constraint.""" + expr = self.expr + if expr.__class__ is EqualityExpression: return True - elif self._expr.__class__ is RangedExpression: + elif expr.__class__ is RangedExpression: # TODO: this is a very restrictive form of structural equality. - lb = self._expr.arg(0) - if lb is not None and lb is self._expr.arg(2): + lb = expr.arg(0) + if lb is not None and lb is expr.arg(2): return True return False @property def strict_lower(self): - """True if this constraint has a strict lower bound.""" + """bool : True if this constraint has a strict lower bound.""" return False @property def strict_upper(self): - """True if this constraint has a strict upper bound.""" + """bool : True if this constraint has a strict upper bound.""" return False + def has_lb(self): + """Returns :const:`False` when the lower bound is + :const:`None` or negative infinity""" + return self.lb is not None + + def has_ub(self): + """Returns :const:`False` when the upper bound is + :const:`None` or positive infinity""" + return self.ub is not None + @property def expr(self): """Return the expression associated with this constraint.""" @@ -455,15 +382,22 @@ def expr(self): def get_value(self): """Get the expression on this constraint.""" - return self._expr + return self.expr def set_value(self, expr): """Set the expression on this constraint.""" # Clear any previously-cached normalized constraint - self._lower = self._upper = self._body = self._expr = None - + self._expr = None if expr.__class__ in _known_relational_expressions: + if getattr(expr, "strict", False) in _strict_relational_exprs: + raise ValueError( + "Constraint '%s' encountered a strict " + "inequality expression ('>' or '<'). All " + "constraints must be formulated using " + "using '<=', '>=', or '=='." % (self.name,) + ) self._expr = expr + elif expr.__class__ is tuple: # or expr_type is list: for arg in expr: if ( @@ -560,120 +494,94 @@ def set_value(self, expr): "\n (0, model.price[item], 50)" % (self.name, str(expr)) ) raise ValueError(msg) - # - # Normalize the incoming expressions, if we can - # - args = self._expr.args - if self._expr.__class__ is InequalityExpression: - if self._expr.strict: - raise ValueError( - "Constraint '%s' encountered a strict " - "inequality expression ('>' or '< '). All" - " constraints must be formulated using " - "using '<=', '>=', or '=='." % (self.name,) - ) - if ( - args[1] is None - or args[1].__class__ in native_numeric_types - or not args[1].is_potentially_variable() - ): - self._body = args[0] - self._upper = args[1] - elif ( - args[0] is None - or args[0].__class__ in native_numeric_types - or not args[0].is_potentially_variable() - ): - self._lower = args[0] - self._body = args[1] - else: - self._body = args[0] - args[1] - self._upper = 0 - elif self._expr.__class__ is EqualityExpression: - if args[0] is None or args[1] is None: - # Error check: ensure equality does not have infinite RHS - raise ValueError( - "Equality constraint '%s' defined with " - "non-finite term (%sHS == None)." - % (self.name, 'L' if args[0] is None else 'R') - ) - if ( - args[0].__class__ in native_numeric_types - or not args[0].is_potentially_variable() - ): - self._lower = self._upper = args[0] - self._body = args[1] - elif ( - args[1].__class__ in native_numeric_types - or not args[1].is_potentially_variable() - ): - self._lower = self._upper = args[1] - self._body = args[0] - else: - self._lower = self._upper = 0 - self._body = args[0] - args[1] - # The following logic is caught below when checking for - # invalid non-finite bounds: - # - # if self._lower.__class__ in native_numeric_types and \ - # not math.isfinite(self._lower): - # raise ValueError( - # "Equality constraint '%s' defined with " - # "non-finite term." % (self.name)) - elif self._expr.__class__ is RangedExpression: - if any(self._expr.strict): - raise ValueError( - "Constraint '%s' encountered a strict " - "inequality expression ('>' or '< '). All" - " constraints must be formulated using " - "using '<=', '>=', or '=='." % (self.name,) - ) - if all( - ( - arg is None - or arg.__class__ in native_numeric_types - or not arg.is_potentially_variable() - ) - for arg in (args[0], args[2]) - ): - self._lower, self._body, self._upper = args + + def lslack(self): + """ + Returns the value of f(x)-L for constraints of the form: + L <= f(x) (<= U) + (U >=) f(x) >= L + """ + lb = self.lb + if lb is None: + return _inf else: - # Defensive programming: we currently only support three - # relational expression types. This will only be hit if - # someone defines a fourth... - raise DeveloperError( - "Unrecognized relational expression type: %s" - % (self._expr.__class__.__name__,) - ) + return value(self.body) - lb + + def uslack(self): + """ + Returns the value of U-f(x) for constraints of the form: + (L <=) f(x) <= U + U >= f(x) (>= L) + """ + ub = self.ub + if ub is None: + return _inf + else: + return ub - value(self.body) + + def slack(self): + """ + Returns the smaller of lslack and uslack values + """ + lb = self.lb + ub = self.ub + body = value(self.body) + if lb is None: + return ub - body + elif ub is None: + return body - lb + return min(ub - body, body - lb) + + +class _ConstraintData(metaclass=RenamedClass): + __renamed__new_class__ = ConstraintData + __renamed__version__ = "6.7.2" - # We have historically forced the body to be a numeric expression. - # TODO: remove this requirement - if self._body.__class__ in native_types and self._body is not None: - self._body = as_numeric(self._body) - - # We have historically mapped incoming inf to None - if self._lower.__class__ in native_numeric_types: - bound = self._lower - if bound in _nonfinite_values or bound != bound: - # Note that "bound != bound" catches float('nan') - if bound == -_inf: - self._lower = None - else: - raise ValueError( - "Constraint '%s' created with an invalid non-finite " - "lower bound (%s)." % (self.name, self._lower) - ) - if self._upper.__class__ in native_numeric_types: - bound = self._upper - if bound in _nonfinite_values or bound != bound: - # Note that "bound != bound" catches float('nan') - if bound == _inf: - self._upper = None - else: - raise ValueError( - "Constraint '%s' created with an invalid non-finite " - "upper bound (%s)." % (self.name, self._upper) - ) + +class _GeneralConstraintData(metaclass=RenamedClass): + __renamed__new_class__ = ConstraintData + __renamed__version__ = "6.7.2" + + +class TemplateConstraintData(ConstraintData): + __slots__ = () + + def __init__(self, template_info, component, index): + # These lines represent in-lining of the + # following constructors: + # - ConstraintData, + # - ActiveComponentData + # - ComponentData + self._component = component + self._active = True + self._index = index + self._expr = template_info + logger.debug( + f"{self.__class__.__qualname__}:\n" + f" component={component},\n index={index},\n" + f" template_info={template_info} ({type(template_info)})" + ) + + @property + def expr(self): + # Note that it is faster to just generate the expression from + # scratch than it is to clone it and replace the IndexTemplate objects + self.set_value(self.parent_component().rule(self.parent_block(), self.index())) + return self.expr + + def template_expr(self): + return self._expr + + def set_value(self, expr): + self.__class__ = ConstraintData + return self.set_value(expr) + + def to_bounded_expression(self): + tmp, self._expr = self._expr, self._expr[0] + try: + return super().to_bounded_expression() + finally: + self._expr = tmp @ModelComponentFactory.register("General constraint expressions.") @@ -718,7 +626,7 @@ class Constraint(ActiveIndexedComponent): The class type for the derived subclass """ - _ComponentDataClass = _GeneralConstraintData + _ComponentDataClass = ConstraintData class Infeasible(object): pass @@ -728,6 +636,17 @@ class Infeasible(object): Violated = Infeasible Satisfied = Feasible + @overload + def __new__( + cls: Type[Constraint], *args, **kwds + ) -> Union[ScalarConstraint, IndexedConstraint]: ... + + @overload + def __new__(cls: Type[ScalarConstraint], *args, **kwds) -> ScalarConstraint: ... + + @overload + def __new__(cls: Type[IndexedConstraint], *args, **kwds) -> IndexedConstraint: ... + def __new__(cls, *args, **kwds): if cls != Constraint: return super(Constraint, cls).__new__(cls) @@ -740,14 +659,14 @@ def __new__(cls, *args, **kwds): def __init__(self, *indexes, expr=None, rule=None, name=None, doc=None): ... def __init__(self, *args, **kwargs): - _init = self._pop_from_kwargs('Constraint', kwargs, ('rule', 'expr'), None) + _init = self._pop_from_kwargs("Constraint", kwargs, ("rule", "expr"), None) # Special case: we accept 2- and 3-tuples as constraints if type(_init) is tuple: self.rule = Initializer(_init, treat_sequences_as_mappings=False) else: self.rule = Initializer(_init) - kwargs.setdefault('ctype', Constraint) + kwargs.setdefault("ctype", Constraint) ActiveIndexedComponent.__init__(self, *args, **kwargs) def construct(self, data=None): @@ -795,6 +714,18 @@ def construct(self, data=None): # indices to be created at a later time). pass else: + if TEMPLATIZE_CONSTRAINTS: + try: + template_info = templatize_constraint(self) + comp = weakref_ref(self) + self._data = { + idx: TemplateConstraintData(template_info, comp, idx) + for idx in self.index_set() + } + return + except TemplateExpressionError: + pass + # Bypass the index validation and create the member directly for index in self.index_set(): self._setitem_when_not_present(index, rule(block, index)) @@ -865,14 +796,14 @@ def display(self, prefix="", ostream=None): ) -class ScalarConstraint(_GeneralConstraintData, Constraint): +class ScalarConstraint(ConstraintData, Constraint): """ ScalarConstraint is the implementation representing a single, non-indexed constraint. """ def __init__(self, *args, **kwds): - _GeneralConstraintData.__init__(self, component=self, expr=None) + ConstraintData.__init__(self, component=self, expr=None) Constraint.__init__(self, *args, **kwds) self._index = UnindexedComponent_index @@ -883,12 +814,12 @@ def __init__(self, *args, **kwds): # currently in place). So during initialization only, we will # treat them as "indexed" objects where things like # Constraint.Skip are managed. But after that they will behave - # like _ConstraintData objects where set_value does not handle + # like ConstraintData objects where set_value does not handle # Constraint.Skip but expects a valid expression or None. # @property def body(self): - """Access the body of a constraint expression.""" + """The body (variable portion) of a constraint expression.""" if not self._data: raise ValueError( "Accessing the body of ScalarConstraint " @@ -896,11 +827,18 @@ def body(self): "an expression. There is currently " "nothing to access." % (self.name) ) - return _GeneralConstraintData.body.fget(self) + return ConstraintData.body.fget(self) @property def lower(self): - """Access the lower bound of a constraint expression.""" + """The lower bound of a constraint expression. + + This is the fixed lower bound of a Constraint as a Pyomo + expression. This may contain potentially variable terms + that are currently fixed. If there is no lower bound, this will + return `None`. + + """ if not self._data: raise ValueError( "Accessing the lower bound of ScalarConstraint " @@ -908,11 +846,18 @@ def lower(self): "an expression. There is currently " "nothing to access." % (self.name) ) - return _GeneralConstraintData.lower.fget(self) + return ConstraintData.lower.fget(self) @property def upper(self): - """Access the upper bound of a constraint expression.""" + """Access the upper bound of a constraint expression. + + This is the fixed upper bound of a Constraint as a Pyomo + expression. This may contain potentially variable terms + that are currently fixed. If there is no upper bound, this will + return `None`. + + """ if not self._data: raise ValueError( "Accessing the upper bound of ScalarConstraint " @@ -920,11 +865,11 @@ def upper(self): "an expression. There is currently " "nothing to access." % (self.name) ) - return _GeneralConstraintData.upper.fget(self) + return ConstraintData.upper.fget(self) @property def equality(self): - """A boolean indicating whether this is an equality constraint.""" + """bool : True if this is an equality constraint.""" if not self._data: raise ValueError( "Accessing the equality flag of ScalarConstraint " @@ -932,11 +877,11 @@ def equality(self): "an expression. There is currently " "nothing to access." % (self.name) ) - return _GeneralConstraintData.equality.fget(self) + return ConstraintData.equality.fget(self) @property def strict_lower(self): - """A boolean indicating whether this constraint has a strict lower bound.""" + """bool : True if this constraint has a strict lower bound.""" if not self._data: raise ValueError( "Accessing the strict_lower flag of ScalarConstraint " @@ -944,11 +889,11 @@ def strict_lower(self): "an expression. There is currently " "nothing to access." % (self.name) ) - return _GeneralConstraintData.strict_lower.fget(self) + return ConstraintData.strict_lower.fget(self) @property def strict_upper(self): - """A boolean indicating whether this constraint has a strict upper bound.""" + """bool : True if this constraint has a strict upper bound.""" if not self._data: raise ValueError( "Accessing the strict_upper flag of ScalarConstraint " @@ -956,7 +901,7 @@ def strict_upper(self): "an expression. There is currently " "nothing to access." % (self.name) ) - return _GeneralConstraintData.strict_upper.fget(self) + return ConstraintData.strict_upper.fget(self) def clear(self): self._data = {} @@ -984,19 +929,20 @@ def add(self, index, expr): class SimpleConstraint(metaclass=RenamedClass): __renamed__new_class__ = ScalarConstraint - __renamed__version__ = '6.0' + __renamed__version__ = "6.0" @disable_methods( { - 'add', - 'set_value', - 'body', - 'lower', - 'upper', - 'equality', - 'strict_lower', - 'strict_upper', + "add", + "set_value", + "to_bounded_expression", + "body", + "lower", + "upper", + "equality", + "strict_lower", + "strict_upper", } ) class AbstractScalarConstraint(ScalarConstraint): @@ -1005,7 +951,7 @@ class AbstractScalarConstraint(ScalarConstraint): class AbstractSimpleConstraint(metaclass=RenamedClass): __renamed__new_class__ = AbstractScalarConstraint - __renamed__version__ = '6.0' + __renamed__version__ = "6.0" class IndexedConstraint(Constraint): @@ -1020,6 +966,11 @@ def add(self, index, expr): """Add a constraint with a given index.""" return self.__setitem__(index, expr) + @overload + def __getitem__(self, index) -> ConstraintData: ... + + __getitem__ = IndexedComponent.__getitem__ # type: ignore + @ModelComponentFactory.register("A list of constraint expressions.") class ConstraintList(IndexedConstraint): @@ -1034,10 +985,10 @@ class End(object): def __init__(self, **kwargs): """Constructor""" - if 'expr' in kwargs: + if "expr" in kwargs: raise ValueError("ConstraintList does not accept the 'expr' keyword") - _rule = kwargs.pop('rule', None) - self._starting_index = kwargs.pop('starting_index', 1) + _rule = kwargs.pop("rule", None) + self._starting_index = kwargs.pop("starting_index", 1) super(ConstraintList, self).__init__(Set(dimen=1), **kwargs) diff --git a/pyomo/core/base/disable_methods.py b/pyomo/core/base/disable_methods.py index ff8eb98487a..7da75495249 100644 --- a/pyomo/core/base/disable_methods.py +++ b/pyomo/core/base/disable_methods.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/enums.py b/pyomo/core/base/enums.py index 31f2212a661..9fe6e4f9f36 100644 --- a/pyomo/core/base/enums.py +++ b/pyomo/core/base/enums.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/expression.py b/pyomo/core/base/expression.py index 3ce998b62a4..31974581953 100644 --- a/pyomo/core/base/expression.py +++ b/pyomo/core/base/expression.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -26,6 +26,7 @@ ) import pyomo.core.expr as EXPR +from pyomo.core.expr.expr_common import _type_check_exception_arg import pyomo.core.expr.numeric_expr as numeric_expr from pyomo.core.base.component import ComponentData, ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index @@ -36,32 +37,43 @@ logger = logging.getLogger('pyomo.core') -class _ExpressionData(numeric_expr.NumericValue): - """ - An object that defines a named expression. +class NamedExpressionData(numeric_expr.NumericValue): + """An object that defines a generic "named expression". - Public Class Attributes - expr The expression owned by this data. + This is the base class for both :class:`ExpressionData` and + :class:`ObjectiveData`. """ + # Note: derived classes are expected to declare the _args_ slot __slots__ = () EXPRESSION_SYSTEM = EXPR.ExpressionType.NUMERIC PRECEDENCE = 0 ASSOCIATIVITY = EXPR.OperatorAssociativity.NON_ASSOCIATIVE - # - # Interface - # - - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Compute the value of this expression.""" - (arg,) = self._args_ + exception = _type_check_exception_arg(self, exception) + (arg,) = self.args if arg.__class__ in native_types: # Note: native_types includes NoneType return arg return arg(exception=exception) + def create_node_with_local_data(self, values, classtype=None): + """ + Construct a simple expression after constructing the + contained expression. + + This class provides a consistent interface for constructing a + node, which is used in tree visitor scripts. + """ + if classtype is None: + classtype = self.parent_component()._ComponentDataClass + obj = classtype() + obj._args_ = values + return obj + def is_named_expression_type(self): """A boolean indicating whether this in a named expression.""" return True @@ -73,7 +85,7 @@ def is_expression_type(self, expression_system=None): def arg(self, index): if index != 0: raise KeyError("Invalid index for expression argument: %d" % index) - return self._args_[0] + return self.args[0] @property def args(self): @@ -85,7 +97,7 @@ def nargs(self): def _to_string(self, values, verbose, smap): if verbose: return "%s{%s}" % (str(self), values[0]) - if self._args_[0] is None: + if self.args[0] is None: return "%s{None}" % str(self) return values[0] @@ -100,7 +112,7 @@ def _apply_operation(self, result): def polynomial_degree(self): """A tuple of subexpressions involved in this expressions operation.""" - if self._args_[0] is None: + if self.args[0] is None: return None return self.expr.polynomial_degree() @@ -110,13 +122,14 @@ def _compute_polynomial_degree(self, result): def _is_fixed(self, values): return values[0] - # - # Abstract Interface - # + # NamedExpressionData should never return False because + # they can store subexpressions that contain variables + def is_potentially_variable(self): + return True @property def expr(self): - (arg,) = self._args_ + (arg,) = self.args if arg is None: return None return as_numeric(arg) @@ -125,58 +138,6 @@ def expr(self): def expr(self, value): self.set_value(value) - def set_value(self, expr): - """Set the expression on this expression.""" - raise NotImplementedError - - def is_constant(self): - """A boolean indicating whether this expression is constant.""" - raise NotImplementedError - - def is_fixed(self): - """A boolean indicating whether this expression is fixed.""" - raise NotImplementedError - - # _ExpressionData should never return False because - # they can store subexpressions that contain variables - def is_potentially_variable(self): - return True - - -class _GeneralExpressionDataImpl(_ExpressionData): - """ - An object that defines an expression that is never cloned - - Constructor Arguments - expr The Pyomo expression stored in this expression. - component The Expression object that owns this data. - - Public Class Attributes - expr The expression owned by this data. - """ - - __slots__ = () - - def __init__(self, expr=None): - self._args_ = (expr,) - - def create_node_with_local_data(self, values): - """ - Construct a simple expression after constructing the - contained expression. - - This class provides a consistent interface for constructing a - node, which is used in tree visitor scripts. - """ - obj = ScalarExpression() - obj.construct() - obj._args_ = values - return obj - - # - # Abstract Interface - # - def set_value(self, expr): """Set the expression on this expression.""" if expr is None or expr.__class__ in native_numeric_types: @@ -204,7 +165,7 @@ def is_constant(self): def is_fixed(self): """A boolean indicating whether this expression is fixed.""" - (e,) = self._args_ + (e,) = self.args return e.__class__ in native_types or e.is_fixed() # Override the in-place operators here so that we can redirect the @@ -212,70 +173,94 @@ def is_fixed(self): # this Expression object (which would map to "other") def __iadd__(self, other): - (e,) = self._args_ + (e,) = self.args return numeric_expr._add_dispatcher[e.__class__, other.__class__](e, other) # Note: the default implementation of __isub__ leverages __iadd__ # and doesn't need to be reimplemented here def __imul__(self, other): - (e,) = self._args_ + (e,) = self.args return numeric_expr._mul_dispatcher[e.__class__, other.__class__](e, other) def __idiv__(self, other): - (e,) = self._args_ + (e,) = self.args return numeric_expr._div_dispatcher[e.__class__, other.__class__](e, other) def __itruediv__(self, other): - (e,) = self._args_ + (e,) = self.args return numeric_expr._div_dispatcher[e.__class__, other.__class__](e, other) def __ipow__(self, other): - (e,) = self._args_ + (e,) = self.args return numeric_expr._pow_dispatcher[e.__class__, other.__class__](e, other) -class _GeneralExpressionData(_GeneralExpressionDataImpl, ComponentData): - """ - An object that defines an expression that is never cloned +class _ExpressionData(metaclass=RenamedClass): + __renamed__new_class__ = NamedExpressionData + __renamed__version__ = '6.7.2' + + +class _GeneralExpressionDataImpl(metaclass=RenamedClass): + __renamed__new_class__ = NamedExpressionData + __renamed__version__ = '6.7.2' + - Constructor Arguments - expr The Pyomo expression stored in this expression. - component The Expression object that owns this data. +class ExpressionData(NamedExpressionData, ComponentData): + """An object that defines an expression that is never cloned - Public Class Attributes - expr The expression owned by this data. + Parameters + ---------- + expr : NumericValue + The Pyomo expression stored in this expression. + + component : Expression + The Expression object that owns this data. - Private class attributes: - _component The expression component. """ __slots__ = ('_args_',) def __init__(self, expr=None, component=None): - _GeneralExpressionDataImpl.__init__(self, expr) - # Inlining ComponentData.__init__ + self._args_ = (expr,) self._component = weakref_ref(component) if (component is not None) else None self._index = NOTSET +class _GeneralExpressionData(metaclass=RenamedClass): + __renamed__new_class__ = ExpressionData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register( "Named expressions that can be used in other expressions." ) class Expression(IndexedComponent): - """ - A shared expression container, which may be defined over a index. - - Constructor Arguments: - initialize A Pyomo expression or dictionary of expressions - used to initialize this object. - expr A synonym for initialize. - rule A rule function used to initialize this object. - name Name for this component. - doc Text describing this component. + """A shared expression container, which may be defined over an index. + + Parameters + ---------- + rule : ~.Initializer + + The source to use to initialize the expression(s) in this + component. See :func:`.Initializer` for accepted argument types. + + initialize : + A synonym for `rule` + + expr : + A synonym for `rule` + + name : str + Name of this component; will be overridden if this is assigned + to a Block. + + doc : str + Text describing this component. + """ - _ComponentDataClass = _GeneralExpressionData + _ComponentDataClass = ExpressionData # This seems like a copy-paste error, and should be renamed/removed NoConstraint = IndexedComponent.Skip @@ -402,9 +387,9 @@ def construct(self, data=None): timer.report() -class ScalarExpression(_GeneralExpressionData, Expression): +class ScalarExpression(ExpressionData, Expression): def __init__(self, *args, **kwds): - _GeneralExpressionData.__init__(self, expr=None, component=self) + ExpressionData.__init__(self, expr=None, component=self) Expression.__init__(self, *args, **kwds) self._index = UnindexedComponent_index @@ -413,8 +398,9 @@ def __init__(self, *args, **kwds): # construction # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Return expression on this expression.""" + exception = _type_check_exception_arg(self, exception) if self._constructed: return super().__call__(exception) raise ValueError( @@ -427,7 +413,7 @@ def __call__(self, exception=True): def expr(self): """Return expression on this expression.""" if self._constructed: - return _GeneralExpressionData.expr.fget(self) + return ExpressionData.expr.fget(self) raise ValueError( "Accessing the expression of Expression '%s' " "before the Expression has been constructed (there " @@ -445,7 +431,7 @@ def clear(self): def set_value(self, expr): """Set the expression on this expression.""" if self._constructed: - return _GeneralExpressionData.set_value(self, expr) + return ExpressionData.set_value(self, expr) raise ValueError( "Setting the expression of Expression '%s' " "before the Expression has been constructed (there " @@ -455,7 +441,7 @@ def set_value(self, expr): def is_constant(self): """A boolean indicating whether this expression is constant.""" if self._constructed: - return _GeneralExpressionData.is_constant(self) + return ExpressionData.is_constant(self) raise ValueError( "Accessing the is_constant flag of Expression '%s' " "before the Expression has been constructed (there " @@ -465,7 +451,7 @@ def is_constant(self): def is_fixed(self): """A boolean indicating whether this expression is fixed.""" if self._constructed: - return _GeneralExpressionData.is_fixed(self) + return ExpressionData.is_fixed(self) raise ValueError( "Accessing the is_fixed flag of Expression '%s' " "before the Expression has been constructed (there " @@ -509,6 +495,6 @@ def add(self, index, expr): """Add an expression with a given index.""" if (type(expr) is tuple) and (expr == Expression.Skip): return None - cdata = _GeneralExpressionData(expr, component=self) + cdata = ExpressionData(expr, component=self) self._data[index] = cdata return cdata diff --git a/pyomo/core/base/external.py b/pyomo/core/base/external.py index 3c0038d745d..a968e886219 100644 --- a/pyomo/core/base/external.py +++ b/pyomo/core/base/external.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -31,14 +31,14 @@ from pyomo.common.autoslots import AutoSlots from pyomo.common.fileutils import find_library -from pyomo.core.expr.numvalue import ( +from pyomo.common.numeric_types import ( + check_if_native_type, native_types, native_numeric_types, - pyomo_constant_types, - NonNumericValue, - NumericConstant, value, + _pyomo_constant_types, ) +from pyomo.core.expr.numvalue import NonNumericValue, NumericConstant import pyomo.core.expr as EXPR from pyomo.core.base.component import Component from pyomo.core.base.units_container import units @@ -128,8 +128,7 @@ def gradient_or_hessian(args, fixed=None): List[float] **ASL function libraries** (:class:`AMPLExternalFunction` interface) Pyomo can also call functions compiled as part of an AMPL - External Function library (see the `User-defined functions - `_ section + External Function library (see the `Imported functions` section in the `Hooking your solver to AMPL `_ report). Links to these functions are declared by creating an @@ -197,14 +196,15 @@ def __call__(self, *args): pv = False for i, arg in enumerate(args_): try: - # Q: Is there a better way to test if a value is an object - # not in native_types and not a standard expression type? if arg.__class__ in native_types: continue if arg.is_potentially_variable(): pv = True + continue except AttributeError: - args_[i] = NonNumericValue(arg) + if check_if_native_type(arg): + continue + args_[i] = NonNumericValue(arg) # if pv: return EXPR.ExternalFunctionExpression(args_, self) @@ -491,7 +491,7 @@ def is_constant(self): return False -pyomo_constant_types.add(_PythonCallbackFunctionID) +_pyomo_constant_types.add(_PythonCallbackFunctionID) class PythonCallbackFunction(ExternalFunction): diff --git a/pyomo/core/base/global_set.py b/pyomo/core/base/global_set.py index b1bb98abee0..3c3e39cbc01 100644 --- a/pyomo/core/base/global_set.py +++ b/pyomo/core/base/global_set.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/indexed_component.py b/pyomo/core/base/indexed_component.py index 0d498da091d..7a23719aae4 100644 --- a/pyomo/core/base/indexed_component.py +++ b/pyomo/core/base/indexed_component.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,7 +18,7 @@ import pyomo.core.base as BASE from pyomo.core.base.indexed_component_slice import IndexedComponent_slice from pyomo.core.base.initializer import Initializer -from pyomo.core.base.component import Component, ActiveComponent +from pyomo.core.base.component import Component, ActiveComponent, ComponentData from pyomo.core.base.config import PyomoOptions from pyomo.core.base.enums import SortComponents from pyomo.core.base.global_set import UnindexedComponent_set @@ -63,6 +63,8 @@ def normalize_index(x): # new object) x = tuple(x) else: + # Note: new Sequence types will be caught below and added to the + # sequence_types set x = (x,) x_len = len(x) @@ -338,11 +340,11 @@ def _create_objects_for_deepcopy(self, memo, component_list): _new = self.__class__.__new__(self.__class__) _ans = memo.setdefault(id(self), _new) if _ans is _new: - component_list.append(self) + component_list.append((self, _new)) # For indexed components, we will pre-emptively clone all # component data objects as well (as those are the objects # that will be referenced by things like expressions). It - # is important to only clone "normal" ComponentData obects: + # is important to only clone "normal" ComponentData objects: # so we will want to skip this for all scalar components # (where the _data points back to self) and references # (where the data may be stored outside this block tree and @@ -352,10 +354,12 @@ def _create_objects_for_deepcopy(self, memo, component_list): # for the _data dict, we can effectively "deepcopy" it # right now (almost for free!) _src = self._data - memo[id(_src)] = _new._data = _data = _src.__class__() + memo[id(_src)] = _new._data = _src.__class__() + _setter = _new._data.__setitem__ for idx, obj in _src.items(): - _data[fast_deepcopy(idx, memo)] = obj._create_objects_for_deepcopy( - memo, component_list + _setter( + fast_deepcopy(idx, memo), + obj._create_objects_for_deepcopy(memo, component_list), ) return _ans @@ -606,7 +610,7 @@ def iteritems(self): """Return a list (index,data) tuples from the dictionary""" return self.items() - def __getitem__(self, index): + def __getitem__(self, index) -> ComponentData: """ This method returns the data corresponding to the given index. """ @@ -731,7 +735,7 @@ def __delitem__(self, index): # this supports "del m.x[:,1]" through a simple recursive call if index.__class__ is IndexedComponent_slice: - # Assert that this slice ws just generated + # Assert that this slice was just generated assert len(index._call_stack) == 1 # Make a copy of the slicer items *before* we start # iterating over it (since we will be removing items!). @@ -747,7 +751,7 @@ def __delitem__(self, index): def _construct_from_rule_using_setitem(self): if self._rule is None: return - index = None + index = None # set so it is defined for scalars for `except:` below rule = self._rule block = self.parent_block() try: @@ -1197,7 +1201,7 @@ def __array__(self, dtype=None): if not self.is_indexed(): ans = _ndarray.NumericNDArray(shape=(1,), dtype=object) ans[0] = self - return ans + return ans.reshape(()) _dim = self.dim() if _dim is None: diff --git a/pyomo/core/base/indexed_component_slice.py b/pyomo/core/base/indexed_component_slice.py index 37b3c452433..c0aa0049b47 100644 --- a/pyomo/core/base/indexed_component_slice.py +++ b/pyomo/core/base/indexed_component_slice.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/initializer.py b/pyomo/core/base/initializer.py index c87a4236abe..6dd9890c933 100644 --- a/pyomo/core/base/initializer.py +++ b/pyomo/core/base/initializer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,10 +12,12 @@ import collections import functools import inspect +import itertools from collections.abc import Sequence from collections.abc import Mapping +from pyomo.common.autoslots import AutoSlots from pyomo.common.dependencies import numpy, numpy_available, pandas, pandas_available from pyomo.common.modeling import NOTSET from pyomo.core.pyomoobject import PyomoObject @@ -37,6 +39,7 @@ def Initializer( allow_generators=False, treat_sequences_as_mappings=True, arg_not_specified=None, + additional_args=0, ): """Standardized processing of Component keyword arguments @@ -69,9 +72,54 @@ def Initializer( If ``arg`` is ``arg_not_specified``, then the function will return None (and not an InitializerBase object). + additional_args: int + + The number of additional arguments that will be passed to any + function calls (provided *before* the index value). + """ if arg is arg_not_specified: return None + if additional_args: + if arg.__class__ in function_types: + if allow_generators or inspect.isgeneratorfunction(arg): + raise ValueError( + "Generator functions are not allowed when passing additional args" + ) + _args = inspect.getfullargspec(arg) + _nargs = len(_args.args) + if inspect.ismethod(arg) and arg.__self__ is not None: + # Ignore 'self' for bound instance methods and 'cls' for + # @classmethods + _nargs -= 1 + if _nargs == 1 + additional_args and _args.varargs is None: + return ParameterizedScalarCallInitializer(arg, constant=True) + else: + return ParameterizedIndexedCallInitializer(arg) + else: + base_initializer = Initializer( + arg=arg, + allow_generators=allow_generators, + treat_sequences_as_mappings=treat_sequences_as_mappings, + arg_not_specified=arg_not_specified, + ) + if type(base_initializer) in ( + ScalarCallInitializer, + IndexedCallInitializer, + ): + # This is an edge case: if we are providing additional + # args, but this is the first time we are seeing a + # callable type, we will (potentially) incorrectly + # categorize this as an IndexedCallInitializer. Re-try + # now that we know this is a function_type. + return Initializer( + arg=base_initializer._fcn, + allow_generators=allow_generators, + treat_sequences_as_mappings=treat_sequences_as_mappings, + arg_not_specified=arg_not_specified, + additional_args=additional_args, + ) + return ParameterizedInitializer(base_initializer) if arg.__class__ in initializer_map: return initializer_map[arg.__class__](arg) if arg.__class__ in sequence_types: @@ -193,27 +241,13 @@ def Initializer( return ConstantInitializer(arg) -class InitializerBase(object): +class InitializerBase(AutoSlots.Mixin, object): """Base class for all Initializer objects""" __slots__ = () verified = False - def __getstate__(self): - """Class serializer - - This class must declare __getstate__ because it is slotized. - This implementation should be sufficient for simple derived - classes (where __slots__ are only declared on the most derived - class). - """ - return {k: getattr(self, k) for k in self.__slots__} - - def __setstate__(self, state): - for key, val in state.items(): - object.__setattr__(self, key, val) - def constant(self): """Return True if this initializer is constant across all indices""" return False @@ -282,18 +316,19 @@ def __init__(self, dataframe, column=None): elif len(dataframe.columns) == 1: self._column = dataframe.columns[0] else: - raise ValueError( - "Cannot construct DataFrameInitializer for DataFrame with " - "multiple columns without also specifying the data column" - ) + self._column = None def __call__(self, parent, idx): + if self._column is None: + return self._df.at[idx] return self._df.at[idx, self._column] def contains_indices(self): return True def indices(self): + if self._column is None: + return itertools.product(self._df.index, self._df) return self._df.index @@ -316,6 +351,18 @@ def __call__(self, parent, idx): return self._fcn(parent, idx) +class ParameterizedIndexedCallInitializer(IndexedCallInitializer): + """IndexedCallInitializer that accepts additional arguments""" + + __slots__ = () + + def __call__(self, parent, idx, *args): + if idx.__class__ is tuple: + return self._fcn(parent, *args, *idx) + else: + return self._fcn(parent, *args, idx) + + class CountedCallGenerator(object): """Generator implementing the "counted call" initialization scheme @@ -442,6 +489,15 @@ def constant(self): return self._constant +class ParameterizedScalarCallInitializer(ScalarCallInitializer): + """ScalarCallInitializer that accepts additional arguments""" + + __slots__ = () + + def __call__(self, parent, idx, *args): + return self._fcn(parent, *args) + + class DefaultInitializer(InitializerBase): """Initializer wrapper that maps exceptions to default values. @@ -485,6 +541,34 @@ def indices(self): return self._initializer.indices() +class ParameterizedInitializer(InitializerBase): + """Base class for all Initializer objects""" + + __slots__ = ('_base_initializer',) + + def __init__(self, base): + self._base_initializer = base + + def constant(self): + """Return True if this initializer is constant across all indices""" + return self._base_initializer.constant() + + def contains_indices(self): + """Return True if this initializer contains embedded indices""" + return self._base_initializer.contains_indices() + + def indices(self): + """Return a generator over the embedded indices + + This will raise a RuntimeError if this initializer does not + contain embedded indices + """ + return self._base_initializer.indices() + + def __call__(self, parent, idx, *args): + return self._base_initializer(parent, idx)(parent, *args) + + _bound_sequence_types = collections.defaultdict(None.__class__) diff --git a/pyomo/core/base/instance2dat.py b/pyomo/core/base/instance2dat.py index 5cd690b7ece..822d8726b9b 100644 --- a/pyomo/core/base/instance2dat.py +++ b/pyomo/core/base/instance2dat.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/label.py b/pyomo/core/base/label.py index e22c1283138..3b53cdb859f 100644 --- a/pyomo/core/base/label.py +++ b/pyomo/core/base/label.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/logical_constraint.py b/pyomo/core/base/logical_constraint.py index f32d727931a..ffaf545848e 100644 --- a/pyomo/core/base/logical_constraint.py +++ b/pyomo/core/base/logical_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,6 +20,7 @@ from pyomo.common.modeling import NOTSET from pyomo.common.timing import ConstructionTimer +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.boolean_value import as_boolean, BooleanConstant from pyomo.core.expr.numvalue import native_types, native_logical_types from pyomo.core.base.component import ActiveComponentData, ModelComponentFactory @@ -42,79 +43,29 @@ """ -class _LogicalConstraintData(ActiveComponentData): - """ - This class defines the data for a single logical constraint. - - It functions as a pure interface. - - Constructor arguments: - component The LogicalConstraint object that owns this data. - - Public class attributes: - active A boolean that is true if this statement is - active in the model. - body The Pyomo logical expression for this statement - - Private class attributes: - _component The statement component. - _active A boolean that indicates whether this data is active - """ - - __slots__ = () - - def __init__(self, component=None): - # - # These lines represent in-lining of the - # following constructors: - # - ActiveComponentData - # - ComponentData - self._component = weakref_ref(component) if (component is not None) else None - self._index = NOTSET - self._active = True - - # - # Interface - # - def __call__(self, exception=True): - """Compute the value of the body of this logical constraint.""" - if self.body is None: - return None - return self.body(exception=exception) - - # - # Abstract Interface - # - @property - def expr(self): - """Get the expression on this logical constraint.""" - raise NotImplementedError - - def set_value(self, expr): - """Set the expression on this logical constraint.""" - raise NotImplementedError - - def get_value(self): - """Get the expression on this logical constraint.""" - raise NotImplementedError - - -class _GeneralLogicalConstraintData(_LogicalConstraintData): +class LogicalConstraintData(ActiveComponentData): """ This class defines the data for a single general logical constraint. Constructor arguments: - component The LogicalStatement object that owns this data. - expr The Pyomo expression stored in this logical constraint. + component + The LogicalStatement object that owns this data. + expr + The Pyomo expression stored in this logical constraint. Public class attributes: - active A boolean that is true if this logical constraint is - active in the model. - expr The Pyomo expression for this logical constraint + active + A boolean that is true if this logical constraint is + active in the model. + expr + The Pyomo expression for this logical constraint Private class attributes: - _component The logical constraint component. - _active A boolean that indicates whether this data is active + _component + The logical constraint component. + _active + A boolean that indicates whether this data is active + """ __slots__ = ('_expr',) @@ -123,7 +74,7 @@ def __init__(self, expr=None, component=None): # # These lines represent in-lining of the # following constructors: - # - _LogicalConstraintData, + # - LogicalConstraintData, # - ActiveComponentData # - ComponentData self._component = weakref_ref(component) if (component is not None) else None @@ -134,6 +85,13 @@ def __init__(self, expr=None, component=None): if expr is not None: self.set_value(expr) + def __call__(self, exception=NOTSET): + """Compute the value of the body of this logical constraint.""" + exception = _type_check_exception_arg(self, exception) + if self.body is None: + return None + return self.body(exception=exception) + # # Abstract Interface # @@ -173,6 +131,16 @@ def get_value(self): return self._expr +class _LogicalConstraintData(metaclass=RenamedClass): + __renamed__new_class__ = LogicalConstraintData + __renamed__version__ = '6.7.2' + + +class _GeneralLogicalConstraintData(metaclass=RenamedClass): + __renamed__new_class__ = LogicalConstraintData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register("General logical constraints.") class LogicalConstraint(ActiveIndexedComponent): """ @@ -215,7 +183,7 @@ class LogicalConstraint(ActiveIndexedComponent): The class type for the derived subclass """ - _ComponentDataClass = _GeneralLogicalConstraintData + _ComponentDataClass = LogicalConstraintData class Infeasible(object): pass @@ -373,7 +341,7 @@ def display(self, prefix="", ostream=None): # # Checks flags like Constraint.Skip, etc. before actually creating a - # constraint object. Returns the _ConstraintData object when it should be + # constraint object. Returns the ConstraintData object when it should be # added to the _data dict; otherwise, None is returned or an exception # is raised. # @@ -409,14 +377,14 @@ def _check_skip_add(self, index, expr): return expr -class ScalarLogicalConstraint(_GeneralLogicalConstraintData, LogicalConstraint): +class ScalarLogicalConstraint(LogicalConstraintData, LogicalConstraint): """ ScalarLogicalConstraint is the implementation representing a single, non-indexed logical constraint. """ def __init__(self, *args, **kwds): - _GeneralLogicalConstraintData.__init__(self, component=self, expr=None) + LogicalConstraintData.__init__(self, component=self, expr=None) LogicalConstraint.__init__(self, *args, **kwds) self._index = UnindexedComponent_index @@ -436,7 +404,7 @@ def body(self): "an expression. There is currently " "nothing to access." % self.name ) - return _GeneralLogicalConstraintData.body.fget(self) + return LogicalConstraintData.body.fget(self) raise ValueError( "Accessing the body of logical constraint '%s' " "before the LogicalConstraint has been constructed (there " @@ -450,7 +418,7 @@ def body(self): # currently in place). So during initialization only, we will # treat them as "indexed" objects where things like # True are managed. But after that they will behave - # like _LogicalConstraintData objects where set_value expects + # like LogicalConstraintData objects where set_value expects # a valid expression or None. # diff --git a/pyomo/core/base/matrix_constraint.py b/pyomo/core/base/matrix_constraint.py index adc9742302e..b4b153a5589 100644 --- a/pyomo/core/base/matrix_constraint.py +++ b/pyomo/core/base/matrix_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,11 +15,13 @@ from pyomo.common.gc_manager import PauseGC from pyomo.common.log import is_debug_set +from pyomo.common.modeling import NOTSET from pyomo.core.base.set_types import Any +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import value from pyomo.core.expr.numeric_expr import LinearExpression from pyomo.core.base.component import ModelComponentFactory -from pyomo.core.base.constraint import IndexedConstraint, _ConstraintData +from pyomo.core.base.constraint import IndexedConstraint, ConstraintData from pyomo.repn.standard_repn import StandardRepn from collections.abc import Mapping @@ -28,7 +30,7 @@ logger = logging.getLogger('pyomo.core') -class _MatrixConstraintData(_ConstraintData): +class _MatrixConstraintData(ConstraintData): """ This class defines the data for a single linear constraint derived from a canonical form Ax=b constraint. @@ -104,7 +106,7 @@ def __init__(self, index, component_ref): # # These lines represent in-lining of the # following constructors: - # - _ConstraintData, + # - ConstraintData, # - ActiveComponentData # - ComponentData self._component = component_ref @@ -130,8 +132,9 @@ def __getstate__(self): # possible # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Compute the value of the body of this constraint.""" + exception = _type_check_exception_arg(self, exception) comp = self.parent_component() index = self._index data = comp._A_data @@ -209,7 +212,7 @@ def index(self): return self._index # - # Abstract Interface (_ConstraintData) + # Abstract Interface (ConstraintData) # @property diff --git a/pyomo/core/base/misc.py b/pyomo/core/base/misc.py index 456a4531e30..dfd355107ea 100644 --- a/pyomo/core/base/misc.py +++ b/pyomo/core/base/misc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/numvalue.py b/pyomo/core/base/numvalue.py index 75bceef7ebb..28fc1c5bbdb 100644 --- a/pyomo/core/base/numvalue.py +++ b/pyomo/core/base/numvalue.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/objective.py b/pyomo/core/base/objective.py index fcc63755f2b..2b05406c00f 100644 --- a/pyomo/core/base/objective.py +++ b/pyomo/core/base/objective.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,12 +15,16 @@ from pyomo.common.pyomo_typing import overload from pyomo.common.deprecation import RenamedClass +from pyomo.common.errors import TemplateExpressionError +from pyomo.common.enums import ObjectiveSense, minimize, maximize from pyomo.common.log import is_debug_set from pyomo.common.modeling import NOTSET from pyomo.common.formatting import tabular_writer from pyomo.common.timing import ConstructionTimer +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import value +from pyomo.core.expr.template_expr import templatize_rule from pyomo.core.base.component import ActiveComponentData, ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index from pyomo.core.base.indexed_component import ( @@ -28,16 +32,17 @@ UnindexedComponent_set, rule_wrapper, ) -from pyomo.core.base.expression import _ExpressionData, _GeneralExpressionDataImpl +from pyomo.core.base.expression import NamedExpressionData from pyomo.core.base.set import Set from pyomo.core.base.initializer import ( Initializer, IndexedCallInitializer, CountedCallInitializer, ) -from pyomo.core.base import minimize, maximize -logger = logging.getLogger('pyomo.core') +logger = logging.getLogger("pyomo.core") + +TEMPLATIZE_OBJECTIVES = False _rule_returned_none_error = """Objective '%s': rule returned None. @@ -55,11 +60,14 @@ def simple_objective_rule(rule): Example use: - @simple_objective_rule - def O_rule(model, i, j): - ... + .. code:: + + @simple_objective_rule + def O_rule(model, i, j): + # ... + + model.o = Objective(rule=simple_objective_rule(...)) - model.o = Objective(rule=simple_objective_rule(...)) """ return rule_wrapper(rule, {None: Objective.Skip}) @@ -72,94 +80,56 @@ def simple_objectivelist_rule(rule): Example use: - @simple_objectivelist_rule - def O_rule(model, i, j): - ... + .. code:: - model.o = ObjectiveList(expr=simple_objectivelist_rule(...)) - """ - return rule_wrapper(rule, {None: ObjectiveList.End}) - - -# -# This class is a pure interface -# + @simple_objectivelist_rule + def O_rule(model, i, j): + # ... + model.o = ObjectiveList(expr=simple_objectivelist_rule(...)) -class _ObjectiveData(_ExpressionData): """ - This class defines the data for a single objective. - - Public class attributes: - expr The Pyomo expression for this objective - sense The direction for this objective. - """ - - __slots__ = () - - # - # Interface - # - - def is_minimizing(self): - """Return True if this is a minimization objective.""" - return self.sense == minimize - - # - # Abstract Interface - # - - @property - def sense(self): - """Access sense (direction) of this objective.""" - raise NotImplementedError - - def set_sense(self, sense): - """Set the sense (direction) of this objective.""" - raise NotImplementedError + return rule_wrapper(rule, {None: ObjectiveList.End}) -class _GeneralObjectiveData( - _GeneralExpressionDataImpl, _ObjectiveData, ActiveComponentData -): - """ - This class defines the data for a single objective. +class ObjectiveData(NamedExpressionData, ActiveComponentData): + """This class defines the data for a single objective. Note that this is a subclass of NumericValue to allow objectives to be used as part of expressions. - Constructor arguments: - expr The Pyomo expression stored in this objective. - sense The direction for this objective. - component The Objective object that owns this data. + Parameters + ---------- + expr: + The Pyomo expression stored in this objective. - Public class attributes: - expr The Pyomo expression for this objective - active A boolean that is true if this objective is active - in the model. - sense The direction for this objective. + sense: + The direction for this objective. + + component: Objective + The Objective object that owns this data. + + Attributes + ---------- + expr: + The Pyomo expression for this objective - Private class attributes: - _component The objective component. - _active A boolean that indicates whether this data is active """ - __slots__ = ("_sense", "_args_") + __slots__ = ("_args_", "_sense") def __init__(self, expr=None, sense=minimize, component=None): - _GeneralExpressionDataImpl.__init__(self, expr) + # Inlining NamedExpressionData.__init__ + self._args_ = (expr,) # Inlining ActiveComponentData.__init__ self._component = weakref_ref(component) if (component is not None) else None self._index = NOTSET self._active = True - self._sense = sense + self._sense = ObjectiveSense(sense) - if (self._sense != minimize) and (self._sense != maximize): - raise ValueError( - "Objective sense must be set to one of " - "'minimize' (%s) or 'maximize' (%s). Invalid " - "value: %s'" % (minimize, maximize, sense) - ) + def is_minimizing(self): + """Return True if this is a minimization objective.""" + return self.sense == minimize def set_value(self, expr): if expr is None: @@ -182,14 +152,51 @@ def sense(self, sense): def set_sense(self, sense): """Set the sense (direction) of this objective.""" - if sense in {minimize, maximize}: - self._sense = sense - else: - raise ValueError( - "Objective sense must be set to one of " - "'minimize' (%s) or 'maximize' (%s). Invalid " - "value: %s'" % (minimize, maximize, sense) - ) + self._sense = ObjectiveSense(sense) + + +class _ObjectiveData(metaclass=RenamedClass): + __renamed__new_class__ = ObjectiveData + __renamed__version__ = "6.7.2" + + +class _GeneralObjectiveData(metaclass=RenamedClass): + __renamed__new_class__ = ObjectiveData + __renamed__version__ = "6.7.2" + + +class TemplateObjectiveData(ObjectiveData): + __slots__ = () + + def __init__(self, template_info, component, index, sense): + # + # These lines represent in-lining of the + # following constructors: + # - ObjectiveData + # - ActiveComponentData + # - ComponentData + self._component = component + self._active = True + self._index = index + self._args_ = template_info + self._sense = sense + logger.debug( + f"index={index}, template_info={template_info} ({type(template_info)})" + ) + + @property + def args(self): + # Note that it is faster to just generate the expression from + # scratch than it is to clone it and replace the IndexTemplate objects + self.set_value(self.parent_component().rule(self.parent_block(), self.index())) + return self._args_ + + def template_expr(self): + return self._args_ + + def set_value(self, expr): + self.__class__ = ObjectiveData + return self.set_value(expr) @ModelComponentFactory.register("Expressions that are minimized or maximized.") @@ -240,7 +247,7 @@ class Objective(ActiveIndexedComponent): The class type for the derived subclass """ - _ComponentDataClass = _GeneralObjectiveData + _ComponentDataClass = ObjectiveData NoObjective = ActiveIndexedComponent.Skip def __new__(cls, *args, **kwds): @@ -257,10 +264,10 @@ def __init__( ): ... def __init__(self, *args, **kwargs): - _sense = kwargs.pop('sense', minimize) - _init = self._pop_from_kwargs('Objective', kwargs, ('rule', 'expr'), None) + _sense = kwargs.pop("sense", minimize) + _init = self._pop_from_kwargs("Objective", kwargs, ("rule", "expr"), None) - kwargs.setdefault('ctype', Objective) + kwargs.setdefault("ctype", Objective) ActiveIndexedComponent.__init__(self, *args, **kwargs) self.rule = Initializer(_init) @@ -313,6 +320,20 @@ def construct(self, data=None): # indices to be created at a later time). pass else: + if TEMPLATIZE_OBJECTIVES: + try: + template_info = templatize_rule(block, rule, self.index_set()) + comp = weakref_ref(self) + self._data = { + idx: TemplateObjectiveData( + template_info, comp, idx, self._init_sense(block, index) + ) + for idx in self.index_set() + } + return + except TemplateExpressionError: + pass + # Bypass the index validation and create the member directly for index in self.index_set(): ans = self._setitem_when_not_present(index, rule(block, index)) @@ -353,11 +374,7 @@ def _pprint(self): ], self._data.items(), ("Active", "Sense", "Expression"), - lambda k, v: [ - v.active, - ("minimize" if (v.sense == minimize) else "maximize"), - v.expr, - ], + lambda k, v: [v.active, v.sense, v.expr], ) def display(self, prefix="", ostream=None): @@ -389,14 +406,14 @@ def display(self, prefix="", ostream=None): ) -class ScalarObjective(_GeneralObjectiveData, Objective): +class ScalarObjective(ObjectiveData, Objective): """ ScalarObjective is the implementation representing a single, non-indexed objective. """ def __init__(self, *args, **kwd): - _GeneralObjectiveData.__init__(self, expr=None, component=self) + ObjectiveData.__init__(self, expr=None, component=self) Objective.__init__(self, *args, **kwd) self._index = UnindexedComponent_index @@ -405,7 +422,8 @@ def __init__(self, *args, **kwd): # construction # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): + exception = _type_check_exception_arg(self, exception) if self._constructed: if len(self._data) == 0: raise ValueError( @@ -432,7 +450,7 @@ def expr(self): "a sense or expression (there is currently " "no value to return)." % (self.name) ) - return _GeneralObjectiveData.expr.fget(self) + return ObjectiveData.expr.fget(self) raise ValueError( "Accessing the expression of objective '%s' " "before the Objective has been constructed (there " @@ -455,7 +473,7 @@ def sense(self): "a sense or expression (there is currently " "no value to return)." % (self.name) ) - return _GeneralObjectiveData.sense.fget(self) + return ObjectiveData.sense.fget(self) raise ValueError( "Accessing the sense of objective '%s' " "before the Objective has been constructed (there " @@ -474,7 +492,7 @@ def sense(self, sense): # currently in place). So during initialization only, we will # treat them as "indexed" objects where things like # Objective.Skip are managed. But after that they will behave - # like _ObjectiveData objects where set_value does not handle + # like ObjectiveData objects where set_value does not handle # Objective.Skip but expects a valid expression or None # @@ -498,7 +516,7 @@ def set_sense(self, sense): if self._constructed: if len(self._data) == 0: self._data[None] = self - return _GeneralObjectiveData.set_sense(self, sense) + return ObjectiveData.set_sense(self, sense) raise ValueError( "Setting the sense of objective '%s' " "before the Objective has been constructed (there " @@ -522,7 +540,7 @@ def add(self, index, expr): class SimpleObjective(metaclass=RenamedClass): __renamed__new_class__ = ScalarObjective - __renamed__version__ = '6.0' + __renamed__version__ = "6.0" class IndexedObjective(Objective): @@ -551,10 +569,10 @@ class End(object): def __init__(self, **kwargs): """Constructor""" - if 'expr' in kwargs: + if "expr" in kwargs: raise ValueError("ObjectiveList does not accept the 'expr' keyword") - _rule = kwargs.pop('rule', None) - self._starting_index = kwargs.pop('starting_index', 1) + _rule = kwargs.pop("rule", None) + self._starting_index = kwargs.pop("starting_index", 1) super().__init__(Set(dimen=1), **kwargs) diff --git a/pyomo/core/base/param.py b/pyomo/core/base/param.py index 3ef33b9ee45..7944fadc430 100644 --- a/pyomo/core/base/param.py +++ b/pyomo/core/base/param.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from __future__ import annotations import sys import types import logging from weakref import ref as weakref_ref from pyomo.common.pyomo_typing import overload +from typing import Union, Type from pyomo.common.autoslots import AutoSlots from pyomo.common.deprecation import deprecation_warning, RenamedClass @@ -21,6 +23,7 @@ from pyomo.common.modeling import NOTSET from pyomo.common.numeric_types import native_types, value as expr_value from pyomo.common.timing import ConstructionTimer +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import NumericValue from pyomo.core.base.component import ComponentData, ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index @@ -116,7 +119,7 @@ def _parent(self, val): pass -class _ParamData(ComponentData, NumericValue): +class ParamData(ComponentData, NumericValue): """ This class defines the data for a mutable parameter. @@ -155,6 +158,10 @@ def clear(self): # set_value is called without specifying an index, this call # involves a linear scan of the _data dict. def set_value(self, value, idx=NOTSET): + """Set the value of this ParamData object, performing unit conversion + and validation as necessary. + + """ # # If this param has units, then we need to check the incoming # value and see if it is "units compatible". We only need to @@ -195,10 +202,12 @@ def set_value(self, value, idx=NOTSET): self._value = old_value raise - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """ Return the value of this object. """ + exception = _type_check_exception_arg(self, exception) + if self._value is Param.NoValue: if exception: raise ValueError( @@ -250,6 +259,11 @@ def _compute_polynomial_degree(self, result): return 0 +class _ParamData(metaclass=RenamedClass): + __renamed__new_class__ = ParamData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register( "Parameter data that is used to define a model instance." ) @@ -283,7 +297,7 @@ class Param(IndexedComponent, IndexedComponent_NDArrayMixin): """ DefaultMutable = False - _ComponentDataClass = _ParamData + _ComponentDataClass = ParamData class NoValue(object): """A dummy type that is pickle-safe that we can use as the default @@ -291,6 +305,17 @@ class NoValue(object): pass + @overload + def __new__( + cls: Type[Param], *args, **kwds + ) -> Union[ScalarParam, IndexedParam]: ... + + @overload + def __new__(cls: Type[ScalarParam], *args, **kwds) -> ScalarParam: ... + + @overload + def __new__(cls: Type[IndexedParam], *args, **kwds) -> IndexedParam: ... + def __new__(cls, *args, **kwds): if cls != Param: return super(Param, cls).__new__(cls) @@ -510,14 +535,14 @@ def store_values(self, new_values, check=True): # instead of incurring the penalty of checking. for index, new_value in new_values.items(): if index not in self._data: - self._data[index] = _ParamData(self) + self._data[index] = ParamData(self) self._data[index]._value = new_value else: # For scalars, we will choose an approach based on # how "dense" the Param is if not self._data: # empty for index in self._index_set: - p = self._data[index] = _ParamData(self) + p = self._data[index] = ParamData(self) p._value = new_values elif len(self._data) == len(self._index_set): for index in self._index_set: @@ -525,7 +550,7 @@ def store_values(self, new_values, check=True): else: for index in self._index_set: if index not in self._data: - self._data[index] = _ParamData(self) + self._data[index] = ParamData(self) self._data[index]._value = new_values else: # @@ -588,9 +613,9 @@ def _getitem_when_not_present(self, index): # a default value, as long as *solving* a model without # reasonable values produces an informative error. if self._mutable: - # Note: _ParamData defaults to Param.NoValue + # Note: ParamData defaults to Param.NoValue if self.is_indexed(): - ans = self._data[index] = _ParamData(self) + ans = self._data[index] = ParamData(self) else: ans = self._data[index] = self ans._index = index @@ -685,8 +710,8 @@ def _setitem_impl(self, index, obj, value): return obj else: old_value, self._data[index] = self._data[index], value - # Because we do not have a _ParamData, we cannot rely on the - # validation that occurs in _ParamData.set_value() + # Because we do not have a ParamData, we cannot rely on the + # validation that occurs in ParamData.set_value() try: self._validate_value(index, value) return value @@ -723,14 +748,14 @@ def _setitem_when_not_present(self, index, value, _check_domain=True): self._index = UnindexedComponent_index return self elif self._mutable: - obj = self._data[index] = _ParamData(self) + obj = self._data[index] = ParamData(self) obj.set_value(value, index) obj._index = index return obj else: self._data[index] = value - # Because we do not have a _ParamData, we cannot rely on the - # validation that occurs in _ParamData.set_value() + # Because we do not have a ParamData, we cannot rely on the + # validation that occurs in ParamData.set_value() self._validate_value(index, value, _check_domain) return value except: @@ -876,8 +901,12 @@ def _pprint(self): dataGen = lambda k, v: [v._value] else: dataGen = lambda k, v: [v] + if self.index_set().isfinite() or self._default_val is Param.NoValue: + _len = len(self) + else: + _len = 'inf' headers = [ - ("Size", len(self)), + ("Size", _len), ("Index", self._index_set if self.is_indexed() else None), ("Domain", self.domain.name), ("Default", default), @@ -888,9 +917,9 @@ def _pprint(self): return (headers, self.sparse_iteritems(), ("Value",), dataGen) -class ScalarParam(_ParamData, Param): +class ScalarParam(ParamData, Param): def __init__(self, *args, **kwds): - _ParamData.__init__(self, component=self) + ParamData.__init__(self, component=self) Param.__init__(self, *args, **kwds) self._index = UnindexedComponent_index @@ -903,10 +932,12 @@ def __init__(self, *args, **kwds): # up both the Component and Data base classes. # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """ Return the value of this parameter. """ + exception = _type_check_exception_arg(self, exception) + if self._constructed: if not self._data: if self._mutable: @@ -950,13 +981,6 @@ class SimpleParam(metaclass=RenamedClass): class IndexedParam(Param): - def __call__(self, exception=True): - """Compute the value of the parameter""" - if exception: - raise TypeError( - 'Cannot compute the value of an indexed Param (%s)' % (self.name,) - ) - # Because IndexedParam can use a non-standard data store (i.e., the # values in the _data dict may not be ComponentData objects), we # need to override the normal scheme for pre-allocating @@ -970,7 +994,7 @@ def _create_objects_for_deepcopy(self, memo, component_list): _new = self.__class__.__new__(self.__class__) _ans = memo.setdefault(id(self), _new) if _ans is _new: - component_list.append(self) + component_list.append((self, _new)) return _ans # Because CP supports indirection [the ability to index objects by @@ -983,7 +1007,7 @@ def _create_objects_for_deepcopy(self, memo, component_list): # between potentially variable GetItemExpression objects and # "constant" GetItemExpression objects. That will need to wait for # the expression rework [JDS; Nov 22]. - def __getitem__(self, args): + def __getitem__(self, args) -> ParamData: try: return super().__getitem__(args) except: diff --git a/pyomo/core/base/piecewise.py b/pyomo/core/base/piecewise.py index 7817a61b2f2..8654b153f87 100644 --- a/pyomo/core/base/piecewise.py +++ b/pyomo/core/base/piecewise.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,18 +18,18 @@ Unifying framework and Extensions (Vielma, Nemhauser 2008). TODO: Add regression tests for the following completed tasks -*) user not providing floats can be an major issue for BIGM's and MC -*) Other TODO's -*) nonconvex/nonconcave functions - BIGM_SOS1, BIGM_SOS2 ***** possible edge case bug + - user not providing floats can be an major issue for BIGM's and MC + - nonconvex/nonconcave functions - BIGM_SOS1, BIGM_SOS2 ***** possible edge case bug Possible Extensions -*) Consider another piecewise rep ("SOS2_MANUAL"?) where we manually implement - extra constraints to define an SOS2 set, this would be compatible with GLPK, - http://winglpk.sourceforge.net/media/glpk-sos2_02.pdf -*) double check that LOG and DLOG reps really do require (2^n)+1 points, or can - we just add integer cuts (or something more intelligent) in order to handle - piecewise functions without 2^n polytopes -*) piecewise for functions of the form y = f(x1,x2,...) + - Consider another piecewise rep ("SOS2_MANUAL"?) where we manually implement + extra constraints to define an SOS2 set, this would be compatible with GLPK, + http://winglpk.sourceforge.net/media/glpk-sos2_02.pdf + - double check that LOG and DLOG reps really do require (2^n)+1 points, or can + we just add integer cuts (or something more intelligent) in order to handle + piecewise functions without 2^n polytopes + - piecewise for functions of the form y = f(x1,x2,...) + """ import logging @@ -40,14 +40,14 @@ import enum from pyomo.common.log import is_debug_set -from pyomo.common.deprecation import deprecation_warning +from pyomo.common.deprecation import RenamedClass, deprecation_warning from pyomo.common.numeric_types import value from pyomo.common.timing import ConstructionTimer -from pyomo.core.base.block import Block, _BlockData +from pyomo.core.base.block import Block, BlockData from pyomo.core.base.component import ModelComponentFactory from pyomo.core.base.constraint import Constraint, ConstraintList from pyomo.core.base.sos import SOSConstraint -from pyomo.core.base.var import Var, _VarData, IndexedVar +from pyomo.core.base.var import Var, VarData, IndexedVar from pyomo.core.base.set_types import PositiveReals, NonNegativeReals, Binary from pyomo.core.base.util import flatten_tuple @@ -214,14 +214,14 @@ def _characterize_function(name, tol, f_rule, model, points, *index): return 0, values, False -class _PiecewiseData(_BlockData): +class PiecewiseData(BlockData): """ This class defines the base class for all linearization and piecewise constraint generators.. """ def __init__(self, parent): - _BlockData.__init__(self, parent) + BlockData.__init__(self, parent) self._constructed = True self._bound_type = None self._domain_pts = None @@ -272,6 +272,11 @@ def __call__(self, x): ) +class _PiecewiseData(metaclass=RenamedClass): + __renamed__new_class__ = PiecewiseData + __renamed__version__ = '6.7.2' + + class _SimpleSinglePiecewise(object): """ Called when the piecewise points list has only two points @@ -1018,114 +1023,129 @@ def _find_M(self, x_pts, y_pts, bound_type): "Constraints that contain piecewise linear expressions." ) class Piecewise(Block): - """ - Adds piecewise constraints to a Pyomo model for functions of the - form, y = f(x). - - Usage: - model.const = Piecewise(index_1,...,index_n,yvar,xvar,**Keywords) - model.const = Piecewise(yvar,xvar,**Keywords) - - Keywords: - - -pw_pts={},[],() - A dictionary of lists (keys are index set) or a single list - (for the non-indexed case or when an identical set of - breakpoints is used across all indices) defining the set of - domain breakpoints for the piecewise linear - function. **ALWAYS REQUIRED** - - -pw_repn='' - Indicates the type of piecewise representation to use. This - can have a major impact on solver performance. - Choices: (Default 'SOS2') - - ~ + 'SOS2' - Standard representation using sos2 constraints - ~ 'BIGM_BIN' - BigM constraints with binary variables. - Theoretically tightest M values are automatically - determined. - ~ 'BIGM_SOS1' - BigM constraints with sos1 variables. - Theoretically tightest M values are automatically - determined. - ~*+ 'DCC' - Disaggregated convex combination model - ~*+ 'DLOG' - Logarithmic disaggregated convex combination model - ~*+ 'CC' - Convex combination model - ~*+ 'LOG' - Logarithmic branching convex combination - ~* 'MC' - Multiple choice model - ~*+ 'INC' - Incremental (delta) method - - + Supports step functions - * Source: "Mixed-Integer Models for Non-separable Piecewise Linear - Optimization: Unifying framework and Extensions" (Vielma, - Nemhauser 2008) - ~ Refer to the optional 'force_pw' keyword. - - -pw_constr_type='' - Indicates the bound type of the piecewise function. - Choices: - - 'UB' - y variable is bounded above by piecewise function - 'LB' - y variable is bounded below by piecewise function - 'EQ' - y variable is equal to the piecewise function - - -f_rule=f(model,i,j,...,x), {}, [], () - An object that returns a numeric value that is the range - value corresponding to each piecewise domain point. For - functions, the first argument must be a Pyomo model. The - last argument is the domain value at which the function - evaluates (Not a Pyomo Var). Intermediate arguments are the - corresponding indices of the Piecewise component (if any). - Otherwise, the object can be a dictionary of lists/tuples - (with keys the same as the indexing set) or a singe - list/tuple (when no indexing set is used or when all indices - use an identical piecewise function). - Examples: - - # A function which changes with index - def f(model,j,x): - if (j == 2): - return x**2 + 1.0 - else: - return x**2 + 5.0 - - # A nonlinear function - f = lambda model,x: return exp(x) + value(model.p) - (model.p is a Pyomo Param) - - # A step function - f = [0,0,1,1,2,2] - - -force_pw=True/False - Using the given function rule and pw_pts, a check for - convexity/concavity is implemented. If (1) the function is - convex and the piecewise constraints are lower bounds or if - (2) the function is concave and the piecewise constraints - are upper bounds then the piecewise constraints will be - substituted for linear constraints. Setting 'force_pw=True' - will force the use of the original piecewise constraints - even when one of these two cases applies. - - -warning_tol= Default=1e-8 - To aid in debugging, a warning is printed when consecutive - slopes of piecewise segments are within of - each other. - - -warn_domain_coverage=True/False Default=True - Print a warning when the feasible region of the domain - variable is not completely covered by the piecewise - breakpoints. - - -unbounded_domain_var=True/False Default=False - Allow an unbounded or partially bounded Pyomo Var to be used - as the domain variable. - **NOTE: This does not imply unbounded piecewise segments - will be constructed. The outermost piecewise - breakpoints will bound the domain variable at each - index. However, the Var attributes .lb and .ub will - not be modified. + r"""Adds piecewise constraints to a Pyomo model for functions of the + form, y = f(x). + + Examples + -------- + + .. code:: + + model.const = Piecewise(index_1,...,index_n,yvar,xvar,**Keywords) + model.const = Piecewise(yvar,xvar,**Keywords) + + Parameters + ---------- + pw_pts : dict + A dictionary of lists (keys are index set) or a single list (for + the non-indexed case or when an identical set of breakpoints is + used across all indices) defining the set of domain breakpoints + for the piecewise linear function. **ALWAYS REQUIRED** + + pw_repn : str + + Indicates the type of piecewise representation to use. This can + have a major impact on solver performance. Choices: (Default + 'SOS2') + + - ``SOS2``: + + Standard representation using sos2 constraints + - ``BIGM_BIN``: + BigM constraints with binary variables. Theoretically + tightest M values are automatically determined. + - ``BIGM_SOS1``: + BigM constraints with sos1 variables. Theoretically + tightest M values are automatically determined. + - ``DCC``: \*+ + Disaggregated convex combination model + - ``DLOG``: \*+ + Logarithmic disaggregated convex combination model + - ``CC``: \*+ + Convex combination model + - ``LOG``: \*+ + Logarithmic branching convex combination + - ``MC``: \* + Multiple choice model + - ``INC``: \*+ + Incremental (delta) method + + .. note:: + + \+\: Supports step functions + + \*\: From "Mixed-Integer Models for Non-separable Piecewise Linear + Optimization: Unifying framework and Extensions" (Vielma, + Nemhauser 2008) + + .. seealso:: + Refer to the optional 'force_pw' keyword. + + pw_constr_type : str + Indicates the bound type of the piecewise function. Choices: + + - ``UB`` - y variable is bounded above by piecewise function + - ``LB`` - y variable is bounded below by piecewise function + - ``EQ`` - y variable is equal to the piecewise function + + f_rule : f(model,i,j,...,x), {}, [], () + An object that returns a numeric value that is the range value + corresponding to each piecewise domain point. For functions, the + first argument must be a Pyomo model. The last argument is the + domain value at which the function evaluates (Not a Pyomo + Var). Intermediate arguments are the corresponding indices of + the Piecewise component (if any). Otherwise, the object can be + a dictionary of lists/tuples (with keys the same as the indexing + set) or a singe list/tuple (when no indexing set is used or when + all indices use an identical piecewise function). Examples: + + .. code:: python + + # A function which changes with index + def f(model,j,x): + if (j == 2): + return x**2 + 1.0 + else: + return x**2 + 5.0 + + # A nonlinear function + f = lambda model, x: return exp(x) + value(model.p) + # (where model.p is a Pyomo Param) + + # A step function + f = [0,0,1,1,2,2] + + force_pw : bool + Using the given function rule and pw_pts, a check for + convexity/concavity is implemented. If (1) the function is + convex and the piecewise constraints are lower bounds or if (2) + the function is concave and the piecewise constraints are upper + bounds then the piecewise constraints will be substituted for + linear constraints. Setting 'force_pw=True' will force the use + of the original piecewise constraints even when one of these two + cases applies. + + warning_tol : float, default=1e-8 + To aid in debugging, a warning is printed when consecutive + slopes of piecewise segments are within of each + other. + + warn_domain_coverage : bool, default=True + Print a warning when the feasible region of the domain variable + is not completely covered by the piecewise breakpoints. + + unbounded_domain_var : bool, default=False + Allow an unbounded or partially bounded Pyomo Var to be used as + the domain variable. + + .. note:: + This does not imply unbounded piecewise segments will be + constructed. The outermost piecewise breakpoints will bound + the domain variable at each index. However, the Var + attributes .lb and .ub will not be modified. + """ - _ComponentDataClass = _PiecewiseData + _ComponentDataClass = PiecewiseData def __new__(cls, *args, **kwds): if cls != Piecewise: @@ -1235,7 +1255,7 @@ def __init__(self, *args, **kwds): # Check that the variables args are actually Pyomo Vars if not ( - isinstance(self._domain_var, _VarData) + isinstance(self._domain_var, VarData) or isinstance(self._domain_var, IndexedVar) ): msg = ( @@ -1244,7 +1264,7 @@ def __init__(self, *args, **kwds): ) raise TypeError(msg % (repr(self._domain_var),)) if not ( - isinstance(self._range_var, _VarData) + isinstance(self._range_var, VarData) or isinstance(self._range_var, IndexedVar) ): msg = ( @@ -1354,22 +1374,22 @@ def add(self, index, _is_indexed=None): _self_yvar = None _self_domain_pts_index = None if not _is_indexed: - # allows one to mix Var and _VarData as input to + # allows one to mix Var and VarData as input to # non-indexed Piecewise, index would be None in this case - # so for Var elements Var[None] is Var, but _VarData[None] would fail + # so for Var elements Var[None] is Var, but VarData[None] would fail _self_xvar = self._domain_var _self_yvar = self._range_var _self_domain_pts_index = self._domain_points[index] else: - # The following allows one to specify a Var or _VarData + # The following allows one to specify a Var or VarData # object even with an indexed Piecewise component. # The most common situation will most likely be a VarArray, # so we try this first. - if not isinstance(self._domain_var, _VarData): + if not isinstance(self._domain_var, VarData): _self_xvar = self._domain_var[index] else: _self_xvar = self._domain_var - if not isinstance(self._range_var, _VarData): + if not isinstance(self._range_var, VarData): _self_yvar = self._range_var[index] else: _self_yvar = self._range_var @@ -1541,7 +1561,7 @@ def add(self, index, _is_indexed=None): raise ValueError(msg % (self.name, index, self._pw_rep)) if _is_indexed: - comp = _PiecewiseData(self) + comp = PiecewiseData(self) else: comp = self self._data[index] = comp @@ -1551,9 +1571,9 @@ def add(self, index, _is_indexed=None): comp.build_constraints(func, _self_xvar, _self_yvar) -class SimplePiecewise(_PiecewiseData, Piecewise): +class SimplePiecewise(PiecewiseData, Piecewise): def __init__(self, *args, **kwds): - _PiecewiseData.__init__(self, self) + PiecewiseData.__init__(self, self) Piecewise.__init__(self, *args, **kwds) diff --git a/pyomo/core/base/range.py b/pyomo/core/base/range.py index acb004e3b10..98951f16439 100644 --- a/pyomo/core/base/range.py +++ b/pyomo/core/base/range.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,6 +12,7 @@ import math from collections.abc import Sequence +from pyomo.common.autoslots import AutoSlots from pyomo.common.numeric_types import check_if_numeric_type try: @@ -33,7 +34,7 @@ class RangeDifferenceError(ValueError): pass -class NumericRange(object): +class NumericRange(AutoSlots.Mixin): """A representation of a numeric range. This class represents a contiguous range of numbers. The class @@ -126,29 +127,6 @@ def __init__(self, start, end, step, closed=(True, True)): " Discrete ranges must be closed." % (self, self.closed) ) - def __getstate__(self): - """ - Retrieve the state of this object as a dictionary. - - This method must be defined because this class uses slots. - """ - state = {} # super(NumericRange, self).__getstate__() - for i in NumericRange.__slots__: - state[i] = getattr(self, i) - return state - - def __setstate__(self, state): - """ - Set the state of this object using values from a state dictionary. - - This method must be defined because this class uses slots. - """ - for key, val in state.items(): - # Note: per the Python data model docs, we explicitly - # set the attribute using object.__setattr__() instead - # of setting self.__dict__[key] = val. - object.__setattr__(self, key, val) - def __str__(self): if not self.isdiscrete(): return "%s%s..%s%s" % ( diff --git a/pyomo/core/base/rangeset.py b/pyomo/core/base/rangeset.py deleted file mode 100644 index 32e41698aab..00000000000 --- a/pyomo/core/base/rangeset.py +++ /dev/null @@ -1,20 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from .set import RangeSet - -from pyomo.common.deprecation import deprecation_warning - -deprecation_warning( - 'The pyomo.core.base.rangeset module is deprecated. ' - 'Import RangeSet objects from pyomo.core.base.set or pyomo.core.', - version='5.7', -) diff --git a/pyomo/core/base/reference.py b/pyomo/core/base/reference.py index 2279db067a6..3f3d71b9137 100644 --- a/pyomo/core/base/reference.py +++ b/pyomo/core/base/reference.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,7 +18,7 @@ Sequence, ) from pyomo.common.modeling import NOTSET -from pyomo.core.base.set import DeclareGlobalSet, Set, SetOf, OrderedSetOf, _SetDataBase +from pyomo.core.base.set import DeclareGlobalSet, Set, SetOf, OrderedSetOf, SetData from pyomo.core.base.component import Component, ComponentData from pyomo.core.base.global_set import UnindexedComponent_set from pyomo.core.base.enums import SortComponents @@ -579,7 +579,7 @@ def Reference(reference, ctype=NOTSET): :py:class:`IndexedComponent`. If the indices associated with wildcards in the component slice all - refer to the same :py:class:`Set` objects for all data identifed by + refer to the same :py:class:`Set` objects for all data identified by the slice, then the resulting indexed component will be indexed by the product of those sets. However, if all data do not share common set objects, or only a subset of indices in a multidimentional set @@ -774,10 +774,10 @@ def Reference(reference, ctype=NOTSET): # is that within the subsets list, and set is a wildcard set. index = wildcards[0][1] # index is the first wildcard set. - if not isinstance(index, _SetDataBase): + if not isinstance(index, SetData): index = SetOf(index) for lvl, idx in wildcards[1:]: - if not isinstance(idx, _SetDataBase): + if not isinstance(idx, SetData): idx = SetOf(idx) index = index * idx # index is now either a single Set, or a SetProduct of the diff --git a/pyomo/core/base/set.py b/pyomo/core/base/set.py index 2dc14460911..da405a4ddd9 100644 --- a/pyomo/core/base/set.py +++ b/pyomo/core/base/set.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,19 +9,25 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from __future__ import annotations import inspect import itertools import logging import math import sys import weakref -from pyomo.common.pyomo_typing import overload +from collections.abc import Iterator +from functools import partial +from typing import Union, Type, Any as typingAny + +from pyomo.common.autoslots import AutoSlots from pyomo.common.collections import ComponentSet from pyomo.common.deprecation import deprecated, deprecation_warning, RenamedClass from pyomo.common.errors import DeveloperError, PyomoException from pyomo.common.log import is_debug_set from pyomo.common.modeling import NOTSET +from pyomo.common.pyomo_typing import overload from pyomo.common.sorting import sorted_robust from pyomo.common.timing import ConstructionTimer @@ -34,10 +40,13 @@ ) from pyomo.core.base.disable_methods import disable_methods from pyomo.core.base.initializer import ( - InitializerBase, - Initializer, CountedCallInitializer, IndexedCallInitializer, + Initializer, + InitializerBase, + ParameterizedIndexedCallInitializer, + ParameterizedInitializer, + ParameterizedScalarCallInitializer, ) from pyomo.core.base.range import ( NumericRange, @@ -47,7 +56,7 @@ RangeDifferenceError, ) from pyomo.core.base.component import ( - _ComponentBase, + ComponentBase, Component, ComponentData, ModelComponentFactory, @@ -81,10 +90,7 @@ All Sets implement one of the following APIs: -0. `class _SetDataBase(ComponentData)` - *(pure virtual interface)* - -1. `class _SetData(_SetDataBase)` +1. `class SetData(ComponentData)` *(base class for all AML Sets)* 2. `class _FiniteSetMixin(object)` @@ -99,7 +105,7 @@ bounded continuous ranges as well as unbounded discrete ranges). As there are an infinite number of values, iteration is *not* supported. The base class also implements all Python set operations. -Note that `_SetData` does *not* implement `len()`, as Python requires +Note that `SetData` does *not* implement `len()`, as Python requires `len()` to return a positive integer. Finite sets add iteration and support for `len()`. In addition, they @@ -125,7 +131,7 @@ def process_setarg(arg): - if isinstance(arg, _SetDataBase): + if isinstance(arg, SetData): if ( getattr(arg, '_parent', None) is not None or getattr(arg, '_anonymous_sets', None) is GlobalSetBase @@ -137,7 +143,7 @@ def process_setarg(arg): _anonymous.update(arg._anonymous_sets) return arg, _anonymous - elif isinstance(arg, _ComponentBase): + elif isinstance(arg, ComponentBase): if isinstance(arg, IndexedComponent) and arg.is_indexed(): raise TypeError( "Cannot apply a Set operator to an " @@ -251,7 +257,11 @@ def set_options(**kwds): decorator allows an arbitrary dictionary of values to passed through to the set constructor. - Examples: + Examples + -------- + + .. code:: + @set_options(dimen=3) def B_index(model): return [(i,i+1,i*i) for i in model.A] @@ -259,6 +269,7 @@ def B_index(model): @set_options(domain=Integers) def B_index(model): return range(10) + """ def decorator(func): @@ -274,11 +285,15 @@ def simple_set_rule(rule): This supports a simpler syntax in set rules, though these can be more difficult to debug when errors occur. - Example: + Examples + -------- + + .. code:: + + @simple_set_rule + def A_rule(model, i, j): + ... - @simple_set_rule - def A_rule(model, i, j): - ... """ return rule_wrapper(rule, {None: Set.End}) @@ -478,9 +493,7 @@ def __call__(self, parent, index): if not isinstance(_val, Sequence): _val = tuple(_val) - if len(_val) == 0: - return _val - if isinstance(_val[0], tuple): + if not _val or isinstance(_val[0], tuple): return _val return self._tuplize(_val, parent, index) @@ -501,24 +514,17 @@ def _tuplize(self, _val, parent, index): "length %s is not a multiple of dimen=%s" % (len(_val), d) ) - return list(tuple(_val[d * i : d * (i + 1)]) for i in range(len(_val) // d)) + return (tuple(_val[i : i + d]) for i in range(0, len(_val), d)) class _NotFound(object): "Internal type flag used to indicate if an object is not found in a set" - pass - -# A trivial class that we can use to test if an object is a "legitimate" -# set (either ScalarSet, or a member of an IndexedSet) -class _SetDataBase(ComponentData): - """The base for all objects that can be used as a component indexing set.""" - - __slots__ = () + pass -class _SetData(_SetDataBase): - """The base for all Pyomo AML objects that can be used as a component +class SetData(ComponentData): + """The base for all Pyomo objects that can be used as a component indexing set. Derived versions of this class can be used as the Index for any @@ -531,13 +537,13 @@ def __contains__(self, value): ans = self.get(value, _NotFound) except TypeError: # In Python 3.x, Sets are unhashable - if isinstance(value, _SetData): + if isinstance(value, SetData): ans = _NotFound else: raise if ans is _NotFound: - if isinstance(value, _SetData): + if isinstance(value, SetData): deprecation_warning( "Testing for set subsets with 'a in b' is deprecated. " "Use 'a.issubset(b)'.", @@ -569,7 +575,7 @@ def isordered(self): def subsets(self, expand_all_set_operators=None): return iter((self,)) - def __iter__(self): + def __iter__(self) -> Iterator[typingAny]: """Iterate over the set members Raises AttributeError for non-finite sets. This must be @@ -891,7 +897,7 @@ def _get_continuous_interval(self): @property @deprecated("The 'virtual' attribute is no longer supported", version='5.7') def virtual(self): - return isinstance(self, (_AnySet, SetOperator, _InfiniteRangeSetData)) + return isinstance(self, (_AnySet, SetOperator, InfiniteRangeSetData)) @virtual.setter def virtual(self, value): @@ -1185,6 +1191,16 @@ def __gt__(self, other): return self >= other and not self == other +class _SetData(metaclass=RenamedClass): + __renamed__new_class__ = SetData + __renamed__version__ = '6.7.2' + + +class _SetDataBase(metaclass=RenamedClass): + __renamed__new_class__ = SetData + __renamed__version__ = '6.7.2' + + class _FiniteSetMixin(object): __slots__ = () @@ -1291,20 +1307,18 @@ def ranges(self): yield NonNumericRange(i) -class _FiniteSetData(_FiniteSetMixin, _SetData): +class FiniteSetData(_FiniteSetMixin, SetData): """A general unordered iterable Set""" - __slots__ = ('_values', '_domain', '_validate', '_filter', '_dimen') + __slots__ = ('_values', '_domain', '_dimen') def __init__(self, component): - _SetData.__init__(self, component=component) - # Derived classes (like _OrderedSetData) may want to change the + SetData.__init__(self, component=component) + # Derived classes (like OrderedSetData) may want to change the # storage if not hasattr(self, '_values'): self._values = set() self._domain = Any - self._validate = None - self._filter = None self._dimen = UnknownSetDimen def get(self, value, default=None): @@ -1362,109 +1376,235 @@ def filter(self): return self._filter def add(self, *values): - count = 0 - _block = self.parent_block() - for value in values: + N = len(self) + self.update(values) + return len(self) - N + + def _update_impl(self, values): + self._values.update(values) + + def remove(self, val): + self._values.remove(val) + + def discard(self, val): + self._values.discard(val) + + def clear(self): + self._values.clear() + + def set_value(self, val): + self.clear() + self.update(val) + + def _initialize(self, val): + try: + # We want to explicitly call the update() on *this class* to + # bypass potential double logging of the use of unordered + # data with ordered Sets + FiniteSetData.update(self, val) + except TypeError as e: + if 'not iterable' in str(e): + logger.error( + "Initializer for Set %s returned non-iterable object " + "of type %s." + % ( + self.name, + (val if val.__class__ is type else type(val).__name__), + ) + ) + raise + + def update(self, values): + # Special case: set operations that are not first attached + # to the model must be constructed. + if isinstance(values, SetOperator): + values.construct() + # It is important that val_iter is an actual iterator + val_iter = iter(values) + if self._dimen is not None: if normalize_index.flatten: - _value = normalize_index(value) - if _value.__class__ is tuple: - _d = len(_value) - else: - _d = 1 + val_iter = self._cb_normalized_dimen_verifier(self._dimen, val_iter) else: - # If we are not normalizing indices, then we cannot reliably - # infer the set dimen - _d = 1 - if isinstance(value, Sequence) and self.dimen != 1: - _d = len(value) - _value = value - if _value not in self._domain: + val_iter = self._cb_raw_dimen_verifier(self._dimen, val_iter) + elif normalize_index.flatten: + val_iter = map(normalize_index, val_iter) + else: + val_iter = self._cb_check_set_end(val_iter) + + if self._domain is not Any: + val_iter = self._cb_domain_verifier(self._domain, val_iter) + + comp = self.parent_component() + if comp._filter is not None: + val_iter = self._cb_validate_filter('filter', val_iter) + + if comp._validate is not None: + val_iter = self._cb_validate_filter('validate', val_iter) + + # We wrap this check in a try-except because some values + # (like lists) are not hashable and can raise exceptions. + try: + self._update_impl(val_iter) + except Set._SetEndException: + pass + + def pop(self): + return self._values.pop() + + def _cb_domain_verifier(self, domain, val_iter): + for value in val_iter: + if value not in domain: raise ValueError( "Cannot add value %s to Set %s.\n" "\tThe value is not in the domain %s" % (value, self.name, self._domain) ) + yield value - # We wrap this check in a try-except because some values - # (like lists) are not hashable and can raise exceptions. + def _cb_check_set_end(self, val_iter): + for value in val_iter: + if value is Set.End: + return + yield value + + def _cb_validate_filter(self, mode, val_iter): + fail_false = mode == 'validate' + comp = self.parent_component() + fcn = getattr(comp, '_' + mode) + block = comp.parent_block() + idx = self.index() + for value in val_iter: try: - if _value in self: - logger.warning( - "Element %s already exists in Set %s; no action taken" - % (value, self.name) - ) + flag = fcn(block, idx, value) + if flag: + yield value continue - except: - exc = sys.exc_info() - raise TypeError( - "Unable to insert '%s' into Set %s:\n\t%s: %s" - % (value, self.name, exc[0].__name__, exc[1]) - ) + except Exception as e: + flag = None + exc = e - if self._filter is not None: - if not self._filter(_block, _value): - continue + if isinstance(value, tuple): + vstar = value + else: + vstar = (value,) - if self._validate is not None: + # First: try the old format: *values and no index + if fcn.__class__ is ParameterizedIndexedCallInitializer: try: - flag = self._validate(_block, _value) - except: - logger.error( - "Exception raised while validating element '%s' " - "for Set %s" % (value, self.name) + flag = fcn(block, (), *vstar) + if flag: + self._filter_validate_scalar_api_deprecation(mode, warning=True) + yield value + continue + except TypeError: + pass + except Exception as e: + exc = e + + # Now try *values and index + try: + flag = fcn(block, idx, *value) + if flag: + deprecation_warning( + f"{self.__class__.__name__} {self.name}: '{mode}=' " + "callback signature matched (block, *value, *index). " + "Please update the callback to match the signature " + "(block, value, *index).", + version='6.8.0', ) - raise - if not flag: + if fcn.__class__ is not ParameterizedInitializer: + orig_fcn = fcn._fcn + fcn._fcn = lambda m, v, *i: orig_fcn(m, *v, *i) + yield value + continue + except TypeError: + pass + except Exception as e: + exc = e + if flag is not None: + if fail_false: raise ValueError( "The value=%s violates the validation rule of Set %s" % (value, self.name) ) + continue + logger.error( + "Exception raised while validating element '%s' " + "for Set %s" % (value, self.name) + ) + raise exc from None - # If the Set has a fixed dimension, check that this element is - # compatible. - if self._dimen is not None: - if _d != self._dimen: - if self._dimen is UnknownSetDimen: - # The first thing added to a Set with unknown - # dimension sets its dimension - self._dimen = _d - else: - raise ValueError( - "The value=%s has dimension %s and is not " - "valid for Set %s which has dimen=%s" - % (value, _d, self.name, self._dimen) - ) - - # Add the value to this object (this last redirection allows - # derived classes to implement a different storage mechanism) - self._add_impl(_value) - count += 1 - return count - - def _add_impl(self, value): - self._values.add(value) - - def remove(self, val): - self._values.remove(val) - - def discard(self, val): - self._values.discard(val) + def _filter_validate_scalar_api_deprecation(self, mode, warning): + comp = self.parent_component() + fcn = getattr(comp, '_' + mode) + if warning: + deprecation_warning( + f"{self.__class__.__name__} {self.name}: '{mode}=' " + "callback signature matched (block, *value). " + "Please update the callback to match the signature " + f"(block, value{', *index' if comp.is_indexed() else ''}).", + version='6.8.0', + ) + orig_fcn = fcn._fcn + fcn = ParameterizedScalarCallInitializer(lambda m, v: orig_fcn(m, *v), True) + setattr(comp, '_' + mode, fcn) + + def _cb_normalized_dimen_verifier(self, dimen, val_iter): + for value in val_iter: + if value.__class__ in native_types: + if dimen == 1: + yield value + continue + normalized_value = value + else: + normalized_value = normalize_index(value) + # Note: normalize_index() will never return a 1-tuple + if normalized_value.__class__ is tuple: + if dimen == len(normalized_value): + yield normalized_value[0] if dimen == 1 else normalized_value + continue - def clear(self): - self._values.clear() + _d = len(normalized_value) if normalized_value.__class__ is tuple else 1 + if _d == dimen: + yield normalized_value + elif dimen is UnknownSetDimen: + # The first thing added to a Set with unknown dimension + # sets its dimension + self._dimen = dimen = _d + yield normalized_value + else: + raise ValueError( + "The value=%s has dimension %s and is not " + "valid for Set %s which has dimen=%s" + % (value, _d, self.name, self._dimen) + ) - def set_value(self, val): - self.clear() - for x in val: - self.add(x) + def _cb_raw_dimen_verifier(self, dimen, val_iter): + for value in val_iter: + if isinstance(value, Sequence): + if dimen == len(value): + yield value + continue + elif dimen == 1: + yield value + continue + _d = len(value) if isinstance(value, Sequence) else 1 + if dimen is UnknownSetDimen: + # The first thing added to a Set with unknown dimension + # sets its dimension + self._dimen = dimen = _d + yield value + else: + raise ValueError( + "The value=%s has dimension %s and is not " + "valid for Set %s which has dimen=%s" + % (value, _d, self.name, self._dimen) + ) - def update(self, values): - for v in values: - if v not in self: - self.add(v) - def pop(self): - return self._values.pop() +class _FiniteSetData(metaclass=RenamedClass): + __renamed__new_class__ = FiniteSetData + __renamed__version__ = '6.7.2' class _ScalarOrderedSetMixin(object): @@ -1538,10 +1678,16 @@ def ordered_iter(self): return iter(self) def first(self): - return self.at(1) + try: + return next(iter(self)) + except StopIteration: + raise IndexError(f"{self.name} index out of range") from None def last(self): - return self.at(len(self)) + try: + return next(reversed(self)) + except StopIteration: + raise IndexError(f"{self.name} index out of range") from None def next(self, item, step=1): """ @@ -1627,16 +1773,16 @@ def _to_0_based_index(self, item): ) -class _OrderedSetData(_OrderedSetMixin, _FiniteSetData): +class OrderedSetData(_OrderedSetMixin, FiniteSetData): """ This class defines the base class for an ordered set of concrete data. In older Pyomo terms, this defines a "concrete" ordered set - that is, a set that "owns" the list of set members. While this class actually implements a set ordered by insertion order, we make the "official" - _InsertionOrderSetData an empty derivative class, so that + InsertionOrderSetData an empty derivative class, so that - issubclass(_SortedSetData, _InsertionOrderSetData) == False + issubclass(SortedSetData, InsertionOrderSetData) == False Constructor Arguments: component The Set object that owns this data. @@ -1648,27 +1794,30 @@ class _OrderedSetData(_OrderedSetMixin, _FiniteSetData): def __init__(self, component): self._values = {} - self._ordered_values = [] - _FiniteSetData.__init__(self, component=component) + self._ordered_values = None + FiniteSetData.__init__(self, component=component) def _iter_impl(self): """ Return an iterator for the set. """ - return iter(self._ordered_values) + return iter(self._values) def __reversed__(self): - return reversed(self._ordered_values) + return reversed(self._values) - def _add_impl(self, value): - self._values[value] = len(self._values) - self._ordered_values.append(value) + def _update_impl(self, values): + for val in values: + # Note that we reset _ordered_values within the loop because + # of an old example where the initializer rule makes + # reference to values previously inserted into the Set + # (which triggered the creation of the _ordered_values) + self._ordered_values = None + self._values[val] = None def remove(self, val): - idx = self._values.pop(val) - self._ordered_values.pop(idx) - for i in range(idx, len(self._ordered_values)): - self._values[self._ordered_values[i]] -= 1 + self._values.pop(val) + self._ordered_values = None def discard(self, val): try: @@ -1678,15 +1827,15 @@ def discard(self, val): def clear(self): self._values.clear() - self._ordered_values = [] + self._ordered_values = None def pop(self): try: ans = self.last() except IndexError: - # Map the index error to a KeyError for consistency with - # set().pop() - raise KeyError('pop from an empty set') + # Map the exception for iterating over an empty dict to a + # KeyError for consistency with set().pop() + raise KeyError('pop from an empty set') from None self.discard(ans) return ans @@ -1697,6 +1846,8 @@ def at(self, index): The public Set API is 1-based, even though the internal _lookup and _values are (pythonically) 0-based. """ + if self._ordered_values is None: + self._rebuild_ordered_values() i = self._to_0_based_index(index) try: return self._ordered_values[i] @@ -1716,6 +1867,8 @@ def ord(self, item): # when they are actually put as Set members. So, we will look # for the exact thing that the user sent us and then fall back # on the scalar. + if self._ordered_values is None: + self._rebuild_ordered_values() try: return self._values[item] + 1 except KeyError: @@ -1726,8 +1879,19 @@ def ord(self, item): except KeyError: raise ValueError("%s.ord(x): x not in %s" % (self.name, self.name)) + def _rebuild_ordered_values(self): + _set = self._values + self._ordered_values = list(_set) + for i, v in enumerate(self._ordered_values): + _set[v] = i + -class _InsertionOrderSetData(_OrderedSetData): +class _OrderedSetData(metaclass=RenamedClass): + __renamed__new_class__ = OrderedSetData + __renamed__version__ = '6.7.2' + + +class InsertionOrderSetData(OrderedSetData): """ This class defines the data for a ordered set where the items are ordered in insertion order (similar to Python's OrderedSet. @@ -1740,6 +1904,16 @@ class _InsertionOrderSetData(_OrderedSetData): __slots__ = () + def _initialize(self, val): + if type(val) in Set._UnorderedInitializers: + logger.warning( + "Initializing ordered Set %s with " + "a fundamentally unordered data source (type: %s). " + "This WILL potentially lead to nondeterministic behavior " + "in Pyomo" % (self.name, type(val).__name__) + ) + super()._initialize(val) + def set_value(self, val): if type(val) in Set._UnorderedInitializers: logger.warning( @@ -1748,7 +1922,8 @@ def set_value(self, val): "This WILL potentially lead to nondeterministic behavior " "in Pyomo" % (type(val).__name__,) ) - super(_InsertionOrderSetData, self).set_value(val) + self.clear() + super().update(val) def update(self, values): if type(values) in Set._UnorderedInitializers: @@ -1758,7 +1933,12 @@ def update(self, values): "This WILL potentially lead to nondeterministic behavior " "in Pyomo" % (type(values).__name__,) ) - super(_InsertionOrderSetData, self).update(values) + super().update(values) + + +class _InsertionOrderSetData(metaclass=RenamedClass): + __renamed__new_class__ = InsertionOrderSetData + __renamed__version__ = '6.7.2' class _SortedSetMixin(object): @@ -1773,7 +1953,7 @@ def sorted_iter(self): return iter(self) -class _SortedSetData(_SortedSetMixin, _OrderedSetData): +class SortedSetData(_SortedSetMixin, OrderedSetData): """ This class defines the data for a sorted set. @@ -1783,73 +1963,47 @@ class _SortedSetData(_SortedSetMixin, _OrderedSetData): Public Class Attributes: """ - __slots__ = ('_is_sorted',) - - def __init__(self, component): - # An empty set is sorted... - self._is_sorted = True - _OrderedSetData.__init__(self, component=component) + __slots__ = () def _iter_impl(self): """ Return an iterator for the set. """ - if not self._is_sorted: - self._sort() - return super(_SortedSetData, self)._iter_impl() + if self._ordered_values is None: + self._rebuild_ordered_values() + return iter(self._ordered_values) def __reversed__(self): - if not self._is_sorted: - self._sort() - return super(_SortedSetData, self).__reversed__() + if self._ordered_values is None: + self._rebuild_ordered_values() + return reversed(self._ordered_values) - def _add_impl(self, value): - # Note that the sorted status has no bearing on insertion, - # so there is no reason to check if the data is correctly sorted - self._values[value] = len(self._values) - self._ordered_values.append(value) - self._is_sorted = False + def _update_impl(self, values): + for val in values: + # Note that we reset _ordered_values within the loop because + # of an old example where the initializer rule makes + # reference to values previously inserted into the Set + # (which triggered the creation of the _ordered_values) + self._ordered_values = None + self._values[val] = None # Note: removing data does not affect the sorted flag # def remove(self, val): # def discard(self, val): - def clear(self): - super(_SortedSetData, self).clear() - self._is_sorted = True - - def at(self, index): - """ - Return the specified member of the set. - - The public Set API is 1-based, even though the - internal _lookup and _values are (pythonically) 0-based. - """ - if not self._is_sorted: - self._sort() - return super(_SortedSetData, self).at(index) - - def ord(self, item): - """ - Return the position index of the input value. - - Note that Pyomo Set objects have positions starting at 1 (not 0). - - If the search item is not in the Set, then an IndexError is raised. - """ - if not self._is_sorted: - self._sort() - return super(_SortedSetData, self).ord(item) - def sorted_data(self): return self.data() - def _sort(self): - self._ordered_values = list( - self.parent_component()._sort_fcn(self._ordered_values) - ) - self._values = {j: i for i, j in enumerate(self._ordered_values)} - self._is_sorted = True + def _rebuild_ordered_values(self): + _set = self._values + self._ordered_values = list(self.parent_component()._sort_fcn(_set)) + for i, v in enumerate(self._ordered_values): + _set[v] = i + + +class _SortedSetData(metaclass=RenamedClass): + __renamed__new_class__ = SortedSetData + __renamed__version__ = '6.7.2' ############################################################################ @@ -1910,7 +2064,8 @@ class Set(IndexedComponent): within : initialiser(set), optional A set that defines the valid values that can be contained - in this set + in this set. If the latter is indexed, the former can be indexed or + non-indexed, in which case it applies to all indices. domain : initializer(set), optional A set that defines the valid values that can be contained in this set @@ -1952,10 +2107,14 @@ class Set(IndexedComponent): """ - class End(object): + class _SetEndException(Exception): pass - class Skip(object): + class _SetEndType(type): + def __hash__(self): + raise Set._SetEndException() + + class End(metaclass=_SetEndType): pass class InsertionOrder(object): @@ -1964,9 +2123,15 @@ class InsertionOrder(object): class SortedOrder(object): pass - _ValidOrderedAuguments = {True, False, InsertionOrder, SortedOrder} + _ValidOrderedArguments = {True, False, InsertionOrder, SortedOrder} _UnorderedInitializers = {set} + @overload + def __new__(cls: Type[Set], *args, **kwds) -> Union[SetData, IndexedSet]: ... + + @overload + def __new__(cls: Type[OrderedScalarSet], *args, **kwds) -> OrderedScalarSet: ... + def __new__(cls, *args, **kwds): if cls is not Set: return super(Set, cls).__new__(cls) @@ -1976,7 +2141,7 @@ def __new__(cls, *args, **kwds): # Many things are easier by forcing it to be consistent across # the set (namely, the _ComponentDataClass is constant). # However, it is a bit off that 'ordered' it the only arg NOT - # processed by Initializer. We can mock up a _SortedSetData + # processed by Initializer. We can mock up a SortedSetData # sort function that preserves Insertion Order (lambda x: x), but # the unsorted is harder (it would effectively be insertion # order, but ordered() may not be deterministic based on how the @@ -1987,7 +2152,7 @@ def __new__(cls, *args, **kwds): ordered = kwds.get('ordered', Set.InsertionOrder) if ordered is True: ordered = Set.InsertionOrder - if ordered not in Set._ValidOrderedAuguments: + if ordered not in Set._ValidOrderedArguments: if inspect.isfunction(ordered): ordered = Set.SortedOrder else: @@ -2004,7 +2169,7 @@ def __new__(cls, *args, **kwds): str(_) for _ in sorted_robust( 'Set.' + x.__name__ if isinstance(x, type) else x - for x in Set._ValidOrderedAuguments.union( + for x in Set._ValidOrderedArguments.union( {''} ) ) @@ -2021,11 +2186,11 @@ def __new__(cls, *args, **kwds): else: newObj = super(Set, cls).__new__(IndexedSet) if ordered is Set.InsertionOrder: - newObj._ComponentDataClass = _InsertionOrderSetData + newObj._ComponentDataClass = InsertionOrderSetData elif ordered is Set.SortedOrder: - newObj._ComponentDataClass = _SortedSetData + newObj._ComponentDataClass = SortedSetData else: - newObj._ComponentDataClass = _FiniteSetData + newObj._ComponentDataClass = FiniteSetData return newObj @overload @@ -2081,8 +2246,8 @@ def __init__(self, *args, **kwds): allow_generators=True, ) ) - self._init_validate = Initializer(kwds.pop('validate', None)) - self._init_filter = Initializer(kwds.pop('filter', None)) + self._validate = Initializer(kwds.pop('validate', None), additional_args=1) + self._filter = Initializer(kwds.pop('filter', None), additional_args=1) if 'virtual' in kwds: deprecation_warning( @@ -2104,14 +2269,20 @@ def __init__(self, *args, **kwds): self._init_values._init = CountedCallInitializer( self, self._init_values._init ) - # HACK: the DAT parser needs to know the domain of a set in - # order to correctly parse the data stream. + if not self.is_indexed(): + # HACK: the DAT parser needs to know the domain of a set in + # order to correctly parse the data stream. if self._init_domain.constant(): self._domain = self._init_domain(self.parent_block(), None, self) if self._init_dimen.constant(): self._dimen = self._init_dimen(self.parent_block(), None) + if self._filter.__class__ is ParameterizedIndexedCallInitializer: + self._filter_validate_scalar_api_deprecation('filter', warning=False) + if self._validate.__class__ is ParameterizedIndexedCallInitializer: + self._filter_validate_scalar_api_deprecation('validate', warning=False) + @deprecated( "check_values() is deprecated: Sets only contain valid members", version='5.7' ) @@ -2169,7 +2340,7 @@ def _getitem_when_not_present(self, index): """Returns the default component data value.""" # Because we allow sets within an IndexedSet to have different # dimen, we have moved the tuplization logic from PyomoModel - # into Set (because we cannot know the dimen of a _SetData until + # into Set (because we cannot know the dimen of a SetData until # we are actually constructing that index). This also means # that we need to potentially communicate the dimen to the # (wrapped) value initializer. So, we will get the dimen first, @@ -2190,11 +2361,20 @@ def _getitem_when_not_present(self, index): domain = self._init_domain(_block, index, self) if domain is not None: - domain.construct() + domain.parent_component().construct() if _d is UnknownSetDimen and domain is not None and domain.dimen is not None: _d = domain.dimen + if index is None and not self.is_indexed(): + obj = self._data[index] = self + else: + obj = self._data[index] = self._ComponentDataClass(component=self) + obj._index = index + obj._domain = domain + if _d is not UnknownSetDimen: + obj._dimen = _d if self._init_values is not None: + # record the user-provided dimen in the initializer self._init_values._dimen = _d try: _values = self._init_values(_block, index) @@ -2202,83 +2382,15 @@ def _getitem_when_not_present(self, index): raise ValueError( str(e) % (self._name, "[%s]" % index if self.is_indexed() else "") ) - if _values is Set.Skip: + del self._data[index] return elif _values is None: raise ValueError( "Set rule or initializer returned None instead of Set.Skip" ) - if index is None and not self.is_indexed(): - obj = self._data[index] = self - else: - obj = self._data[index] = self._ComponentDataClass(component=self) - obj._index = index - obj._domain = domain - if _d is not UnknownSetDimen: - obj._dimen = _d - if self._init_validate is not None: - try: - obj._validate = Initializer(self._init_validate(_block, index)) - if obj._validate.constant(): - # _init_validate was the actual validate function; use it. - obj._validate = self._init_validate - except: - # We will assume any exceptions raised when getting the - # validator for this index indicate that the function - # should have been passed directly to the underlying sets. - obj._validate = self._init_validate - if self._init_filter is not None: - try: - _filter = Initializer(self._init_filter(_block, index)) - if _filter.constant(): - # _init_filter was the actual filter function; use it. - _filter = self._init_filter - except: - # We will assume any exceptions raised when getting the - # filter for this index indicate that the function - # should have been passed directly to the underlying sets. - _filter = self._init_filter - else: - _filter = None - if self._init_values is not None: - # _values was initialized above... - if obj.isordered() and type(_values) in Set._UnorderedInitializers: - logger.warning( - "Initializing ordered Set %s with a fundamentally " - "unordered data source (type: %s). This WILL potentially " - "lead to nondeterministic behavior in Pyomo" - % (self.name, type(_values).__name__) - ) - # Special case: set operations that are not first attached - # to the model must be constructed. - if isinstance(_values, SetOperator): - _values.construct() - try: - val_iter = iter(_values) - except TypeError: - logger.error( - "Initializer for Set %s%s returned non-iterable object " - "of type %s." - % ( - self.name, - ("[%s]" % (index,) if self.is_indexed() else ""), - ( - _values - if _values.__class__ is type - else type(_values).__name__ - ), - ) - ) - raise - for val in val_iter: - if val is Set.End: - break - if _filter is None or _filter(_block, val): - obj.add(val) - # We defer adding the filter until now so that add() doesn't - # call it a second time. - obj._filter = _filter + + obj._initialize(_values) return obj @staticmethod @@ -2329,7 +2441,7 @@ def _pprint(self): # else: # return '{' + str(ans)[1:-1] + "}" - # TBD: In the current design, we force all _SetData within an + # TBD: In the current design, we force all SetData within an # indexed Set to have the same isordered value, so we will only # print it once in the header. Is this a good design? try: @@ -2349,7 +2461,7 @@ def _pprint(self): _ordered = "Sorted" else: _ordered = "{user}" - elif issubclass(_refClass, _InsertionOrderSetData): + elif issubclass(_refClass, InsertionOrderSetData): _ordered = "Insertion" return ( [ @@ -2373,10 +2485,15 @@ def data(self): "Return a dict containing the data() of each Set in this IndexedSet" return {k: v.data() for k, v in self.items()} + @overload + def __getitem__(self, index) -> SetData: ... + + __getitem__ = IndexedComponent.__getitem__ # type: ignore + -class FiniteScalarSet(_FiniteSetData, Set): +class FiniteScalarSet(FiniteSetData, Set): def __init__(self, **kwds): - _FiniteSetData.__init__(self, component=self) + FiniteSetData.__init__(self, component=self) Set.__init__(self, **kwds) self._index = UnindexedComponent_index @@ -2386,13 +2503,13 @@ class FiniteSimpleSet(metaclass=RenamedClass): __renamed__version__ = '6.0' -class OrderedScalarSet(_ScalarOrderedSetMixin, _InsertionOrderSetData, Set): +class OrderedScalarSet(_ScalarOrderedSetMixin, InsertionOrderSetData, Set): def __init__(self, **kwds): # In case someone inherits from us, we will provide a rational # default for the "ordered" flag kwds.setdefault('ordered', Set.InsertionOrder) - _InsertionOrderSetData.__init__(self, component=self) + InsertionOrderSetData.__init__(self, component=self) Set.__init__(self, **kwds) @@ -2401,13 +2518,13 @@ class OrderedSimpleSet(metaclass=RenamedClass): __renamed__version__ = '6.0' -class SortedScalarSet(_ScalarOrderedSetMixin, _SortedSetData, Set): +class SortedScalarSet(_ScalarOrderedSetMixin, SortedSetData, Set): def __init__(self, **kwds): # In case someone inherits from us, we will provide a rational # default for the "ordered" flag kwds.setdefault('ordered', Set.SortedOrder) - _SortedSetData.__init__(self, component=self) + SortedSetData.__init__(self, component=self) Set.__init__(self, **kwds) self._index = UnindexedComponent_index @@ -2450,14 +2567,14 @@ class AbstractSortedSimpleSet(metaclass=RenamedClass): ############################################################################ -class SetOf(_SetData, Component): +class SetOf(SetData, Component): """""" def __new__(cls, *args, **kwds): if cls is not SetOf: return super(SetOf, cls).__new__(cls) (reference,) = args - if isinstance(reference, (_SetData, GlobalSetBase)): + if isinstance(reference, (SetData, GlobalSetBase)): if reference.isfinite(): if reference.isordered(): return super(SetOf, cls).__new__(OrderedSetOf) @@ -2471,7 +2588,7 @@ def __new__(cls, *args, **kwds): return super(SetOf, cls).__new__(FiniteSetOf) def __init__(self, reference, **kwds): - _SetData.__init__(self, component=self) + SetData.__init__(self, component=self) kwds.setdefault('ctype', SetOf) Component.__init__(self, **kwds) self._ref = reference @@ -2494,7 +2611,7 @@ def construct(self, data=None): @property def dimen(self): - if isinstance(self._ref, _SetData): + if isinstance(self._ref, SetData): return self._ref.dimen _iter = iter(self) try: @@ -2589,7 +2706,7 @@ def ord(self, item): ############################################################################ -class _InfiniteRangeSetData(_SetData): +class InfiniteRangeSetData(SetData): """Data class for a infinite set. This Set implements an interface to an *infinite set* defined by one @@ -2601,7 +2718,7 @@ class _InfiniteRangeSetData(_SetData): __slots__ = ('_ranges',) def __init__(self, component): - _SetData.__init__(self, component=component) + SetData.__init__(self, component=component) self._ranges = None def get(self, value, default=None): @@ -2634,8 +2751,13 @@ def ranges(self): return iter(self._ranges) -class _FiniteRangeSetData( - _SortedSetMixin, _OrderedSetMixin, _FiniteSetMixin, _InfiniteRangeSetData +class _InfiniteRangeSetData(metaclass=RenamedClass): + __renamed__new_class__ = InfiniteRangeSetData + __renamed__version__ = '6.7.2' + + +class FiniteRangeSetData( + _SortedSetMixin, _OrderedSetMixin, _FiniteSetMixin, InfiniteRangeSetData ): __slots__ = () @@ -2658,7 +2780,7 @@ def _iter_impl(self): # iterate over it nIters = len(self._ranges) - 1 if not nIters: - yield from _FiniteRangeSetData._range_gen(self._ranges[0]) + yield from FiniteRangeSetData._range_gen(self._ranges[0]) return # The trick here is that we need to remove any duplicates from @@ -2669,7 +2791,7 @@ def _iter_impl(self): for r in self._ranges: # Note: there should always be at least 1 member in each # NumericRange - i = _FiniteRangeSetData._range_gen(r) + i = FiniteRangeSetData._range_gen(r) iters.append([next(i), i]) iters.sort(reverse=True, key=lambda x: x[0]) @@ -2735,11 +2857,16 @@ def ord(self, item): ) # We must redefine ranges(), bounds(), and domain so that we get the - # _InfiniteRangeSetData version and not the one from + # InfiniteRangeSetData version and not the one from # _FiniteSetMixin. - bounds = _InfiniteRangeSetData.bounds - ranges = _InfiniteRangeSetData.ranges - domain = _InfiniteRangeSetData.domain + bounds = InfiniteRangeSetData.bounds + ranges = InfiniteRangeSetData.ranges + domain = InfiniteRangeSetData.domain + + +class _FiniteRangeSetData(metaclass=RenamedClass): + __renamed__new_class__ = FiniteRangeSetData + __renamed__version__ = '6.7.2' @ModelComponentFactory.register( @@ -2928,8 +3055,8 @@ def __init__(self, *args, **kwds): ) kwds.pop('finite', None) self._init_data = (args, kwds.pop('ranges', ())) - self._init_validate = Initializer(kwds.pop('validate', None)) - self._init_filter = Initializer(kwds.pop('filter', None)) + self._validate = Initializer(kwds.pop('validate', None), additional_args=1) + self._filter = Initializer(kwds.pop('filter', None), additional_args=1) self._init_bounds = kwds.pop('bounds', None) if self._init_bounds is not None: self._init_bounds = BoundsInitializer(self._init_bounds) @@ -3080,23 +3207,13 @@ def construct(self, data=None): self._ranges = ranges - if self._init_filter is not None: + if self._filter is not None: if not self.isfinite(): raise ValueError( "The 'filter' keyword argument is not valid for " "non-finite RangeSet component (%s)" % (self.name,) ) - - try: - _filter = Initializer(self._init_filter(_block, None)) - if _filter.constant(): - # _init_filter was the actual filter function; use it. - _filter = self._init_filter - except: - # We will assume any exceptions raised when getting the - # filter for this index indicate that the function - # should have been passed directly to the underlying sets. - _filter = self._init_filter + _filter = self._filter # If this is a finite set, then we can go ahead and filter # all the ranges. This allows pprint and len to be correct, @@ -3106,8 +3223,8 @@ def construct(self, data=None): old_ranges.reverse() while old_ranges: r = old_ranges.pop() - for i, val in enumerate(_FiniteRangeSetData._range_gen(r)): - if not _filter(_block, val): + for i, val in enumerate(FiniteRangeSetData._range_gen(r)): + if not _filter(_block, (), val): split_r = r.range_difference((NumericRange(val, val, 0),)) if len(split_r) == 2: new_ranges.append(split_r[0]) @@ -3123,38 +3240,25 @@ def construct(self, data=None): new_ranges.append(r) self._ranges = new_ranges - if self._init_validate is not None: + if self._validate is not None: if not self.isfinite(): raise ValueError( "The 'validate' keyword argument is not valid for " "non-finite RangeSet component (%s)" % (self.name,) ) - try: - _validate = Initializer(self._init_validate(_block, None)) - if _validate.constant(): - # _init_validate was the actual validate function; use it. - _validate = self._init_validate + for val in self: + if not self._validate(_block, None, val): + raise ValueError( + "The value=%s violates the validation rule of " + "Set %s" % (val, self.name) + ) except: - # We will assume any exceptions raised when getting the - # validator for this index indicate that the function - # should have been passed directly to the underlying set. - _validate = self._init_validate - - for val in self: - try: - flag = _validate(_block, val) - except: - logger.error( - "Exception raised while validating element '%s' " - "for Set %s" % (val, self.name) - ) - raise - if not flag: - raise ValueError( - "The value=%s violates the validation rule of " - "Set %s" % (val, self.name) - ) + logger.error( + "Exception raised while validating element '%s' " + "for Set %s" % (val, self.name) + ) + raise # Defer the warning about non-constant args until after the # component has been constructed, so that the conversion of the @@ -3204,9 +3308,9 @@ def _pprint(self): ) -class InfiniteScalarRangeSet(_InfiniteRangeSetData, RangeSet): +class InfiniteScalarRangeSet(InfiniteRangeSetData, RangeSet): def __init__(self, *args, **kwds): - _InfiniteRangeSetData.__init__(self, component=self) + InfiniteRangeSetData.__init__(self, component=self) RangeSet.__init__(self, *args, **kwds) self._index = UnindexedComponent_index @@ -3219,9 +3323,9 @@ class InfiniteSimpleRangeSet(metaclass=RenamedClass): __renamed__version__ = '6.0' -class FiniteScalarRangeSet(_ScalarOrderedSetMixin, _FiniteRangeSetData, RangeSet): +class FiniteScalarRangeSet(_ScalarOrderedSetMixin, FiniteRangeSetData, RangeSet): def __init__(self, *args, **kwds): - _FiniteRangeSetData.__init__(self, component=self) + FiniteRangeSetData.__init__(self, component=self) RangeSet.__init__(self, *args, **kwds) self._index = UnindexedComponent_index @@ -3259,11 +3363,11 @@ class AbstractFiniteSimpleRangeSet(metaclass=RenamedClass): ############################################################################ -class SetOperator(_SetData, Set): +class SetOperator(SetData, Set): __slots__ = ('_sets',) def __init__(self, *args, **kwds): - _SetData.__init__(self, component=self) + SetData.__init__(self, component=self) Set.__init__(self, **kwds) self._sets, _anonymous = zip(*(process_setarg(_set) for _set in args)) _anonymous = tuple(filter(None, _anonymous)) @@ -3346,40 +3450,6 @@ def __str__(self): return self.name return self._expression_str() - def __deepcopy__(self, memo): - # SetOperators form an expression system. As we allow operators - # on abstract Set objects, it is important to *always* deepcopy - # SetOperators that have not been assigned to a Block. For - # example, consider an abstract indexed model component whose - # domain is specified by a Set expression: - # - # def x_init(m,i): - # if i == 2: - # return Set.Skip - # else: - # return [] - # m.x = Set( [1,2], - # domain={1: m.A*m.B, 2: m.A*m.A}, - # initialize=x_init ) - # - # We do not want to automatically add all the Set operators to - # the model at declaration time, as m.x[2] is never actually - # created. Plus, doing so would require complex parsing of the - # initializers. BUT, we need to ensure that the operators are - # deepcopied, otherwise when the model is cloned before - # construction the operators will still refer to the sets on the - # original abstract model (in particular, the Set x will have an - # unknown dimen). - # - # Our solution is to cause SetOperators to be automatically - # cloned if they haven't been assigned to a block. - if '__block_scope__' in memo: - if self.parent_block() is None: - # Hijack the block scope rules to cause this object to - # be deepcopied. - memo['__block_scope__'][id(self)] = True - return super(SetOperator, self).__deepcopy__(memo) - def _expression_str(self): _args = [] for arg in self._sets: @@ -3447,7 +3517,7 @@ def _domain(self, val): def _checkArgs(*sets): ans = [] for s in sets: - if isinstance(s, _SetDataBase): + if isinstance(s, SetData): ans.append((s.isordered(), s.isfinite())) elif type(s) in {tuple, list}: ans.append((True, True)) @@ -4203,9 +4273,9 @@ def ord(self, item): ############################################################################ -class _AnySet(_SetData, Set): +class _AnySet(SetData, Set): def __init__(self, **kwds): - _SetData.__init__(self, component=self) + SetData.__init__(self, component=self) # There is a chicken-and-egg game here: the SetInitializer uses # Any as part of the processing of the domain/within/bounds # domain restrictions. However, Any has not been declared when @@ -4259,9 +4329,9 @@ def get(self, val, default=None): return super(_AnyWithNoneSet, self).get(val, default) -class _EmptySet(_FiniteSetMixin, _SetData, Set): +class _EmptySet(_FiniteSetMixin, SetData, Set): def __init__(self, **kwds): - _SetData.__init__(self, component=self) + SetData.__init__(self, component=self) Set.__init__(self, **kwds) self.construct() diff --git a/pyomo/core/base/set_types.py b/pyomo/core/base/set_types.py index 80c8a41ff2e..baaf78a0de5 100644 --- a/pyomo/core/base/set_types.py +++ b/pyomo/core/base/set_types.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/sets.py b/pyomo/core/base/sets.py deleted file mode 100644 index ca693cf7d8b..00000000000 --- a/pyomo/core/base/sets.py +++ /dev/null @@ -1,33 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -# TODO -# . rename 'filter' to something else -# . confirm that filtering is efficient - -from .set import ( - process_setarg, - set_options, - simple_set_rule, - _SetDataBase, - _SetData, - Set, - SetOf, - IndexedSet, -) - -from pyomo.common.deprecation import deprecation_warning - -deprecation_warning( - 'The pyomo.core.base.sets module is deprecated. ' - 'Import Set objects from pyomo.core.base.set or pyomo.core.', - version='5.7', -) diff --git a/pyomo/core/base/sos.py b/pyomo/core/base/sos.py index 6b8586c9b49..46581982468 100644 --- a/pyomo/core/base/sos.py +++ b/pyomo/core/base/sos.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -28,7 +28,7 @@ logger = logging.getLogger('pyomo.core') -class _SOSConstraintData(ActiveComponentData): +class SOSConstraintData(ActiveComponentData): """ This class defines the data for a single special ordered set. @@ -101,6 +101,11 @@ def set_items(self, variables, weights): self._weights.append(w) +class _SOSConstraintData(metaclass=RenamedClass): + __renamed__new_class__ = SOSConstraintData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register("SOS constraint expressions.") class SOSConstraint(ActiveIndexedComponent): """ @@ -512,10 +517,10 @@ def add(self, index, variables, weights=None): Add a component data for the specified index. """ if index is None: - # because ScalarSOSConstraint already makes an _SOSConstraintData instance + # because ScalarSOSConstraint already makes an SOSConstraintData instance soscondata = self else: - soscondata = _SOSConstraintData(self) + soscondata = SOSConstraintData(self) self._data[index] = soscondata soscondata._index = index @@ -549,9 +554,9 @@ def pprint(self, ostream=None, verbose=False, prefix=""): ostream.write("\t\t" + str(weight) + ' : ' + var.name + '\n') -class ScalarSOSConstraint(SOSConstraint, _SOSConstraintData): +class ScalarSOSConstraint(SOSConstraint, SOSConstraintData): def __init__(self, *args, **kwd): - _SOSConstraintData.__init__(self, self) + SOSConstraintData.__init__(self, self) SOSConstraint.__init__(self, *args, **kwd) self._index = UnindexedComponent_index diff --git a/pyomo/core/base/suffix.py b/pyomo/core/base/suffix.py index 0c27eee060f..fd6b8717eec 100644 --- a/pyomo/core/base/suffix.py +++ b/pyomo/core/base/suffix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,16 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import enum import logging from pyomo.common.collections import ComponentMap from pyomo.common.config import In from pyomo.common.deprecation import deprecated +from pyomo.common.enums import IntEnum from pyomo.common.log import is_debug_set from pyomo.common.modeling import NOTSET from pyomo.common.pyomo_typing import overload from pyomo.common.timing import ConstructionTimer +from pyomo.core.base.block import BlockData from pyomo.core.base.component import ActiveComponent, ModelComponentFactory from pyomo.core.base.disable_methods import disable_methods from pyomo.core.base.initializer import Initializer @@ -91,7 +92,7 @@ def active_suffix_generator(a_block, datatype=NOTSET): return suffix_generator(a_block, datatype, active=True) -class SuffixDataType(enum.IntEnum): +class SuffixDataType(IntEnum): """Suffix data types AMPL only supports two data types for Suffixes: int and float. The @@ -104,7 +105,7 @@ class SuffixDataType(enum.IntEnum): FLOAT = 4 -class SuffixDirection(enum.IntEnum): +class SuffixDirection(IntEnum): """Suffix data flow definition. This identifies if the specific Suffix is to be sent to the solver, @@ -341,7 +342,7 @@ def clear_all_values(self): @deprecated( 'Suffix.set_datatype is replaced with the Suffix.datatype property', - version='6.7.1.dev0', + version='6.7.1', ) def set_datatype(self, datatype): """ @@ -351,7 +352,7 @@ def set_datatype(self, datatype): @deprecated( 'Suffix.get_datatype is replaced with the Suffix.datatype property', - version='6.7.1.dev0', + version='6.7.1', ) def get_datatype(self): """ @@ -361,7 +362,7 @@ def get_datatype(self): @deprecated( 'Suffix.set_direction is replaced with the Suffix.direction property', - version='6.7.1.dev0', + version='6.7.1', ) def set_direction(self, direction): """ @@ -371,7 +372,7 @@ def set_direction(self, direction): @deprecated( 'Suffix.get_direction is replaced with the Suffix.direction property', - version='6.7.1.dev0', + version='6.7.1', ) def get_direction(self): """ @@ -409,7 +410,7 @@ class AbstractSuffix(Suffix): class SuffixFinder(object): - def __init__(self, name, default=None): + def __init__(self, name, default=None, context=None): """This provides an efficient utility for finding suffix values on a (hierarchical) Pyomo model. @@ -424,11 +425,26 @@ def __init__(self, name, default=None): Default value to return from `.find()` if no matching Suffix is found. + context: BlockData + + The root of the Block hierarchy to use when searching for + Suffix components. Suffixes outside this hierarchy will not + be interrogated and components that are queried (with + :py:meth:`find(component_data)` will return the default + value. + """ self.name = name self.default = default self.all_suffixes = [] - self._suffixes_by_block = {None: []} + self._context = context + self._suffixes_by_block = ComponentMap() + self._suffixes_by_block[self._context] = [] + if context is not None: + s = context.component(name) + if s is not None and s.ctype is Suffix and s.active: + self._suffixes_by_block[context].append(s) + self.all_suffixes.append(s) def find(self, component_data): """Find suffix value for a given component data object in model tree @@ -458,7 +474,17 @@ def find(self, component_data): """ # Walk parent tree and search for suffixes - suffixes = self._get_suffix_list(component_data.parent_block()) + if isinstance(component_data, BlockData): + _block = component_data + else: + _block = component_data.parent_block() + try: + suffixes = self._get_suffix_list(_block) + except AttributeError: + # Component was outside the context (eventually parent + # becomes None and parent.parent_block() raises an + # AttributeError): we will return the default value + return self.default # Pass 1: look for the component_data, working root to leaf for s in suffixes: if component_data in s: diff --git a/pyomo/core/base/symbol_map.py b/pyomo/core/base/symbol_map.py index 189cce7646a..a6b9ba66646 100644 --- a/pyomo/core/base/symbol_map.py +++ b/pyomo/core/base/symbol_map.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/symbolic.py b/pyomo/core/base/symbolic.py index c1ee08dd584..f3f61ad2f7f 100644 --- a/pyomo/core/base/symbolic.py +++ b/pyomo/core/base/symbolic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/template_expr.py b/pyomo/core/base/template_expr.py deleted file mode 100644 index c3697be7eb0..00000000000 --- a/pyomo/core/base/template_expr.py +++ /dev/null @@ -1,24 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.core.expr.template_expr import ( - IndexTemplate, - _GetItemIndexer, - TemplateExpressionError, -) - -from pyomo.common.deprecation import deprecation_warning - -deprecation_warning( - 'The pyomo.core.base.template_expr module is deprecated. ' - 'Import expression template objects from pyomo.core.expr.template_expr.', - version='5.7', -) diff --git a/pyomo/core/base/transformation.py b/pyomo/core/base/transformation.py index 31f5a251553..d2945d6bb66 100644 --- a/pyomo/core/base/transformation.py +++ b/pyomo/core/base/transformation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/units_container.py b/pyomo/core/base/units_container.py index 1bf25ffdead..96f7e85139c 100644 --- a/pyomo/core/base/units_container.py +++ b/pyomo/core/base/units_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -111,29 +111,20 @@ import logging import sys -from pyomo.common.dependencies import attempt_import +from pyomo.common.dependencies import pint as pint_module, pint_available from pyomo.common.modeling import NOTSET +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import ( NumericValue, nonpyomo_leaf_types, value, native_types, native_numeric_types, - pyomo_constant_types, ) from pyomo.core.expr.template_expr import IndexTemplate from pyomo.core.expr.visitor import ExpressionValueVisitor import pyomo.core.expr as EXPR -pint_module, pint_available = attempt_import( - 'pint', - defer_check=True, - error_message=( - 'The "pint" package failed to import. ' - 'This package is necessary to use Pyomo units.' - ), -) - logger = logging.getLogger(__name__) @@ -394,7 +385,7 @@ def to_string(self, verbose=None, labeler=None, smap=None, compute_values=False) else: return _str - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Unit is treated as a constant value, and this method always returns 1.0 Returns @@ -402,6 +393,7 @@ def __call__(self, exception=True): : float Returns 1.0 """ + _type_check_exception_arg(self, exception) return 1.0 @property @@ -902,7 +894,7 @@ def initializeWalker(self, expr): def beforeChild(self, node, child, child_idx): ctype = child.__class__ - if ctype in native_types or ctype in pyomo_constant_types: + if ctype in native_types: return False, self._pint_dimensionless if child.is_expression_type(): @@ -917,7 +909,7 @@ def beforeChild(self, node, child, child_idx): pint_unit = self._pyomo_units_container._get_pint_units(pyomo_unit) return False, pint_unit - return True, None + return False, self._pint_dimensionless def exitNode(self, node, data): """Visitor callback when moving up the expression tree. diff --git a/pyomo/core/base/util.py b/pyomo/core/base/util.py index 6a3885cedfb..af9da690a98 100644 --- a/pyomo/core/base/util.py +++ b/pyomo/core/base/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/base/var.py b/pyomo/core/base/var.py index f426c9c4f55..96e2f5a4991 100644 --- a/pyomo/core/base/var.py +++ b/pyomo/core/base/var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,10 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from __future__ import annotations import logging import sys from pyomo.common.pyomo_typing import overload from weakref import ref as weakref_ref +from typing import Union, Type from pyomo.common.deprecation import RenamedClass from pyomo.common.log import is_debug_set @@ -22,6 +24,7 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.core.expr import GetItemExpression from pyomo.core.expr.numeric_expr import NPV_MaxExpression, NPV_MinExpression +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import ( NumericValue, value, @@ -83,241 +86,11 @@ 'value', 'stale', 'fixed', + ('__call__', "access property 'value' on"), ) -class _VarData(ComponentData, NumericValue): - """This class defines the abstract interface for a single variable. - - Note that this "abstract" class is not intended to be directly - instantiated. - - """ - - __slots__ = () - - # - # Interface - # - - def has_lb(self): - """Returns :const:`False` when the lower bound is - :const:`None` or negative infinity""" - return self.lb is not None - - def has_ub(self): - """Returns :const:`False` when the upper bound is - :const:`None` or positive infinity""" - return self.ub is not None - - # TODO: deprecate this? Properties are generally preferred over "set*()" - def setlb(self, val): - """ - Set the lower bound for this variable after validating that - the value is fixed (or None). - """ - self.lower = val - - # TODO: deprecate this? Properties are generally preferred over "set*()" - def setub(self, val): - """ - Set the upper bound for this variable after validating that - the value is fixed (or None). - """ - self.upper = val - - @property - def bounds(self): - """Returns (or set) the tuple (lower bound, upper bound). - - This returns the current (numeric) values of the lower and upper - bounds as a tuple. If there is no bound, returns None (and not - +/-inf) - - """ - return self.lb, self.ub - - @bounds.setter - def bounds(self, val): - self.lower, self.upper = val - - @property - def lb(self): - """Return (or set) the numeric value of the variable lower bound.""" - lb = value(self.lower) - return None if lb == _ninf else lb - - @lb.setter - def lb(self, val): - self.lower = val - - @property - def ub(self): - """Return (or set) the numeric value of the variable upper bound.""" - ub = value(self.upper) - return None if ub == _inf else ub - - @ub.setter - def ub(self, val): - self.upper = val - - def is_integer(self): - """Returns True when the domain is a contiguous integer range.""" - _id = id(self.domain) - if _id in _known_global_real_domains: - return not _known_global_real_domains[_id] - _interval = self.domain.get_interval() - if _interval is None: - return False - # Note: it is not sufficient to just check the step: the - # starting / ending points must be integers (or not specified) - start, stop, step = _interval - return ( - step == 1 - and (start is None or int(start) == start) - and (stop is None or int(stop) == stop) - ) - - def is_binary(self): - """Returns True when the domain is restricted to Binary values.""" - domain = self.domain - if domain is Binary: - return True - if id(domain) in _known_global_real_domains: - return False - return domain.get_interval() == (0, 1, 1) - - def is_continuous(self): - """Returns True when the domain is a continuous real range""" - _id = id(self.domain) - if _id in _known_global_real_domains: - return _known_global_real_domains[_id] - _interval = self.domain.get_interval() - return _interval is not None and _interval[2] == 0 - - def is_fixed(self): - """Returns True if this variable is fixed, otherwise returns False.""" - return self.fixed - - def is_constant(self): - """Returns False because this is not a constant in an expression.""" - return False - - def is_variable_type(self): - """Returns True because this is a variable.""" - return True - - def is_potentially_variable(self): - """Returns True because this is a variable.""" - return True - - def _compute_polynomial_degree(self, result): - """ - If the variable is fixed, it represents a constant - is a polynomial with degree 0. Otherwise, it has - degree 1. This method is used in expressions to - compute polynomial degree. - """ - if self.fixed: - return 0 - return 1 - - def clear(self): - self.value = None - - def __call__(self, exception=True): - """Compute the value of this variable.""" - return self.value - - # - # Abstract Interface - # - - def set_value(self, val, skip_validation=False): - """Set the current variable value.""" - raise NotImplementedError - - @property - def value(self): - """Return (or set) the value for this variable.""" - raise NotImplementedError - - @property - def domain(self): - """Return (or set) the domain for this variable.""" - raise NotImplementedError - - @property - def lower(self): - """Return (or set) an expression for the variable lower bound.""" - raise NotImplementedError - - @property - def upper(self): - """Return (or set) an expression for the variable upper bound.""" - raise NotImplementedError - - @property - def fixed(self): - """Return (or set) the fixed indicator for this variable. - - Alias for :meth:`is_fixed` / :meth:`fix` / :meth:`unfix`. - - """ - raise NotImplementedError - - @property - def stale(self): - """The stale status for this variable. - - Variables are "stale" if their current value was not updated as - part of the most recent model update. A "model update" can be - one of several things: a solver invocation, loading a previous - solution, or manually updating a non-stale :class:`Var` value. - - Returns - ------- - bool - - Notes - ----- - Fixed :class:`Var` objects will be stale after invoking a solver - (as their value was not updated by the solver). - - Updating a stale :class:`Var` value will not cause other - variable values to be come stale. However, updating the first - non-stale :class:`Var` value after a solve or solution load - *will* cause all other variables to be marked as stale - - """ - raise NotImplementedError - - def fix(self, value=NOTSET, skip_validation=False): - """Fix the value of this variable (treat as nonvariable) - - This sets the :attr:`fixed` indicator to True. If ``value`` is - provided, the value (and the ``skip_validation`` flag) are first - passed to :meth:`set_value()`. - - """ - self.fixed = True - if value is not NOTSET: - self.set_value(value, skip_validation) - - def unfix(self): - """Unfix this variable (treat as variable in solver interfaces) - - This sets the :attr:`fixed` indicator to False. - - """ - self.fixed = False - - def free(self): - """Alias for :meth:`unfix`""" - return self.unfix() - - -class _GeneralVarData(_VarData): +class VarData(ComponentData, NumericValue): """This class defines the data for a single variable.""" __slots__ = ('_value', '_lb', '_ub', '_domain', '_fixed', '_stale') @@ -327,7 +100,7 @@ def __init__(self, component=None): # # These lines represent in-lining of the # following constructors: - # - _VarData + # - VarData # - ComponentData # - NumericValue self._component = weakref_ref(component) if (component is not None) else None @@ -358,10 +131,6 @@ def copy(cls, src): self._index = src._index return self - # - # Abstract Interface - # - def set_value(self, val, skip_validation=False): """Set the current variable value. @@ -422,14 +191,21 @@ def set_value(self, val, skip_validation=False): @property def value(self): + """Return (or set) the value for this variable.""" return self._value @value.setter def value(self, val): self.set_value(val) + def __call__(self, exception=NOTSET): + """Compute the value of this variable.""" + exception = _type_check_exception_arg(self, exception) + return self._value + @property def domain(self): + """Return (or set) the domain for this variable.""" return self._domain @domain.setter @@ -446,9 +222,42 @@ def domain(self, domain): ) raise - @_VarData.bounds.getter + def has_lb(self): + """Returns :const:`False` when the lower bound is + :const:`None` or negative infinity""" + return self.lb is not None + + def has_ub(self): + """Returns :const:`False` when the upper bound is + :const:`None` or positive infinity""" + return self.ub is not None + + # TODO: deprecate this? Properties are generally preferred over "set*()" + def setlb(self, val): + """ + Set the lower bound for this variable after validating that + the value is fixed (or None). + """ + self.lower = val + + # TODO: deprecate this? Properties are generally preferred over "set*()" + def setub(self, val): + """ + Set the upper bound for this variable after validating that + the value is fixed (or None). + """ + self.upper = val + + @property def bounds(self): - # Custom implementation of _VarData.bounds to avoid unnecessary + """Returns (or set) the tuple (lower bound, upper bound). + + This returns the current (numeric) values of the lower and upper + bounds as a tuple. If there is no bound, returns None (and not + +/-inf) + + """ + # Custom implementation of lb / ub to avoid unnecessary # expression generation and duplicate calls to domain.bounds() domain_lb, domain_ub = self.domain.bounds() # lb is the tighter of the domain and bounds @@ -489,10 +298,14 @@ def bounds(self): ub = min(ub, domain_ub) return lb, ub - @_VarData.lb.getter + @bounds.setter + def bounds(self, val): + self.lower, self.upper = val + + @property def lb(self): - # Custom implementation of _VarData.lb to avoid unnecessary - # expression generation + """Return (or set) the numeric value of the variable lower bound.""" + # Note: Implementation avoids unnecessary expression generation domain_lb, domain_ub = self.domain.bounds() # lb is the tighter of the domain and bounds lb = self._lb @@ -514,10 +327,14 @@ def lb(self): lb = max(lb, domain_lb) return lb - @_VarData.ub.getter + @lb.setter + def lb(self, val): + self.lower = val + + @property def ub(self): - # Custom implementation of _VarData.ub to avoid unnecessary - # expression generation + """Return (or set) the numeric value of the variable upper bound.""" + # Note: implementation avoids unnecessary expression generation domain_lb, domain_ub = self.domain.bounds() # ub is the tighter of the domain and bounds ub = self._ub @@ -539,6 +356,10 @@ def ub(self): ub = min(ub, domain_ub) return ub + @ub.setter + def ub(self, val): + self.upper = val + @property def lower(self): """Return (or set) an expression for the variable lower bound. @@ -595,8 +416,37 @@ def get_units(self): # component if not scalar return self.parent_component()._units + def fix(self, value=NOTSET, skip_validation=False): + """Fix the value of this variable (treat as nonvariable) + + This sets the :attr:`fixed` indicator to True. If ``value`` is + provided, the value (and the ``skip_validation`` flag) are first + passed to :meth:`set_value()`. + + """ + self.fixed = True + if value is not NOTSET: + self.set_value(value, skip_validation) + + def unfix(self): + """Unfix this variable (treat as variable in solver interfaces) + + This sets the :attr:`fixed` indicator to False. + + """ + self.fixed = False + + def free(self): + """Alias for :meth:`unfix`""" + return self.unfix() + @property def fixed(self): + """Return (or set) the fixed indicator for this variable. + + Alias for :meth:`is_fixed` / :meth:`fix` / :meth:`unfix`. + + """ return self._fixed @fixed.setter @@ -605,6 +455,28 @@ def fixed(self, val): @property def stale(self): + """The stale status for this variable. + + Variables are "stale" if their current value was not updated as + part of the most recent model update. A "model update" can be + one of several things: a solver invocation, loading a previous + solution, or manually updating a non-stale :class:`Var` value. + + Returns + ------- + bool + + Notes + ----- + Fixed :class:`Var` objects will be stale after invoking a solver + (as their value was not updated by the solver). + + Updating a stale :class:`Var` value will not cause other + variable values to be come stale. However, updating the first + non-stale :class:`Var` value after a solve or solution load + *will* cause all other variables to be marked as stale + + """ return StaleFlagManager.is_stale(self._stale) @stale.setter @@ -614,11 +486,70 @@ def stale(self, val): else: self._stale = StaleFlagManager.get_flag(0) - # Note: override the base class definition to avoid a call through a - # property + def is_integer(self): + """Returns True when the domain is a contiguous integer range.""" + _id = id(self.domain) + if _id in _known_global_real_domains: + return not _known_global_real_domains[_id] + _interval = self.domain.get_interval() + if _interval is None: + return False + # Note: it is not sufficient to just check the step: the + # starting / ending points must be integers (or not specified) + start, stop, step = _interval + return ( + step == 1 + and (start is None or int(start) == start) + and (stop is None or int(stop) == stop) + ) + + def is_binary(self): + """Returns True when the domain is restricted to Binary values.""" + domain = self.domain + if domain is Binary: + return True + if id(domain) in _known_global_real_domains: + return False + return domain.get_interval() == (0, 1, 1) + + def is_continuous(self): + """Returns True when the domain is a continuous real range""" + _id = id(self.domain) + if _id in _known_global_real_domains: + return _known_global_real_domains[_id] + _interval = self.domain.get_interval() + return _interval is not None and _interval[2] == 0 + def is_fixed(self): + """Returns True if this variable is fixed, otherwise returns False.""" return self._fixed + def is_constant(self): + """Returns False because this is not a constant in an expression.""" + return False + + def is_variable_type(self): + """Returns True because this is a variable.""" + return True + + def is_potentially_variable(self): + """Returns True because this is a variable.""" + return True + + def clear(self): + self.value = None + + def _compute_polynomial_degree(self, result): + """ + If the variable is fixed, it represents a constant + is a polynomial with degree 0. Otherwise, it has + degree 1. This method is used in expressions to + compute polynomial degree. + """ + if self._fixed: + return 0 + return 1 + def _process_bound(self, val, bound_type): if type(val) in native_numeric_types or val is None: # TODO: warn/error: check if this Var has units: assigning @@ -641,6 +572,16 @@ def _process_bound(self, val, bound_type): return val +class _VarData(metaclass=RenamedClass): + __renamed__new_class__ = VarData + __renamed__version__ = '6.7.2' + + +class _GeneralVarData(metaclass=RenamedClass): + __renamed__new_class__ = VarData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register("Decision variables.") class Var(IndexedComponent, IndexedComponent_NDArrayMixin): """A numeric variable, which may be defined over an index. @@ -666,7 +607,16 @@ class Var(IndexedComponent, IndexedComponent_NDArrayMixin): doc (str, optional): Text describing this component. """ - _ComponentDataClass = _GeneralVarData + _ComponentDataClass = VarData + + @overload + def __new__(cls: Type[Var], *args, **kwargs) -> Union[ScalarVar, IndexedVar]: ... + + @overload + def __new__(cls: Type[ScalarVar], *args, **kwargs) -> ScalarVar: ... + + @overload + def __new__(cls: Type[IndexedVar], *args, **kwargs) -> IndexedVar: ... def __new__(cls, *args, **kwargs): if cls is not Var: @@ -688,7 +638,7 @@ def __init__( dense=True, units=None, name=None, - doc=None + doc=None, ): ... def __init__(self, *args, **kwargs): @@ -764,7 +714,7 @@ def add(self, index): def construct(self, data=None): """ - Construct the _VarData objects for this variable + Construct the VarData objects for this variable """ if self._constructed: return @@ -823,7 +773,7 @@ def construct(self, data=None): # initializers that are constant, we can avoid # re-calling (and re-validating) the inputs in certain # cases. To support this, we will create the first - # _VarData and then use it as a template to initialize + # VarData and then use it as a template to initialize # (constant portions of) every VarData so as to not # repeat all the domain/bounds validation. try: @@ -941,11 +891,11 @@ def _pprint(self): ) -class ScalarVar(_GeneralVarData, Var): +class ScalarVar(VarData, Var): """A single variable.""" def __init__(self, *args, **kwd): - _GeneralVarData.__init__(self, component=self) + VarData.__init__(self, component=self) Var.__init__(self, *args, **kwd) self._index = UnindexedComponent_index @@ -992,7 +942,7 @@ def fix(self, value=NOTSET, skip_validation=False): def unfix(self): """Unfix all variables in this :class:`IndexedVar` (treat as variable) - This sets the :attr:`_VarData.fixed` indicator to False for + This sets the :attr:`VarData.fixed` indicator to False for every variable in this :class:`IndexedVar`. """ @@ -1046,7 +996,7 @@ def domain(self, domain): # between potentially variable GetItemExpression objects and # "constant" GetItemExpression objects. That will need to wait for # the expression rework [JDS; Nov 22]. - def __getitem__(self, args): + def __getitem__(self, args) -> VarData: try: return super().__getitem__(args) except RuntimeError: diff --git a/pyomo/core/beta/__init__.py b/pyomo/core/beta/__init__.py index a2d51d0b23e..b44b5319a74 100644 --- a/pyomo/core/beta/__init__.py +++ b/pyomo/core/beta/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,5 +9,4 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.core.beta.dict_objects -import pyomo.core.beta.list_objects +from pyomo.core.beta import dict_objects, list_objects diff --git a/pyomo/core/beta/dict_objects.py b/pyomo/core/beta/dict_objects.py index a8298b08e63..e8433308fdd 100644 --- a/pyomo/core/beta/dict_objects.py +++ b/pyomo/core/beta/dict_objects.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,10 +14,10 @@ from pyomo.common.log import is_debug_set from pyomo.core.base.set_types import Any -from pyomo.core.base.var import IndexedVar, _VarData -from pyomo.core.base.constraint import IndexedConstraint, _ConstraintData -from pyomo.core.base.objective import IndexedObjective, _ObjectiveData -from pyomo.core.base.expression import IndexedExpression, _ExpressionData +from pyomo.core.base.var import IndexedVar, VarData +from pyomo.core.base.constraint import IndexedConstraint, ConstraintData +from pyomo.core.base.objective import IndexedObjective, ObjectiveData +from pyomo.core.base.expression import IndexedExpression, ExpressionData from collections.abc import MutableMapping from collections.abc import Mapping @@ -184,7 +184,7 @@ def __init__(self, *args, **kwds): # Constructor for ComponentDict needs to # go last in order to handle any initialization # iterable as an argument - ComponentDict.__init__(self, _VarData, *args, **kwds) + ComponentDict.__init__(self, VarData, *args, **kwds) class ConstraintDict(ComponentDict, IndexedConstraint): @@ -193,7 +193,7 @@ def __init__(self, *args, **kwds): # Constructor for ComponentDict needs to # go last in order to handle any initialization # iterable as an argument - ComponentDict.__init__(self, _ConstraintData, *args, **kwds) + ComponentDict.__init__(self, ConstraintData, *args, **kwds) class ObjectiveDict(ComponentDict, IndexedObjective): @@ -202,7 +202,7 @@ def __init__(self, *args, **kwds): # Constructor for ComponentDict needs to # go last in order to handle any initialization # iterable as an argument - ComponentDict.__init__(self, _ObjectiveData, *args, **kwds) + ComponentDict.__init__(self, ObjectiveData, *args, **kwds) class ExpressionDict(ComponentDict, IndexedExpression): @@ -211,4 +211,4 @@ def __init__(self, *args, **kwds): # Constructor for ComponentDict needs to # go last in order to handle any initialization # iterable as an argument - ComponentDict.__init__(self, _ExpressionData, *args, **kwds) + ComponentDict.__init__(self, ExpressionData, *args, **kwds) diff --git a/pyomo/core/beta/list_objects.py b/pyomo/core/beta/list_objects.py index f53997fed17..bcac4bd8e2d 100644 --- a/pyomo/core/beta/list_objects.py +++ b/pyomo/core/beta/list_objects.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,10 +14,10 @@ from pyomo.common.log import is_debug_set from pyomo.core.base.set_types import Any -from pyomo.core.base.var import IndexedVar, _VarData -from pyomo.core.base.constraint import IndexedConstraint, _ConstraintData -from pyomo.core.base.objective import IndexedObjective, _ObjectiveData -from pyomo.core.base.expression import IndexedExpression, _ExpressionData +from pyomo.core.base.var import IndexedVar, VarData +from pyomo.core.base.constraint import IndexedConstraint, ConstraintData +from pyomo.core.base.objective import IndexedObjective, ObjectiveData +from pyomo.core.base.expression import IndexedExpression, ExpressionData from collections.abc import MutableSequence @@ -232,7 +232,7 @@ def __init__(self, *args, **kwds): # Constructor for ComponentList needs to # go last in order to handle any initialization # iterable as an argument - ComponentList.__init__(self, _VarData, *args, **kwds) + ComponentList.__init__(self, VarData, *args, **kwds) class XConstraintList(ComponentList, IndexedConstraint): @@ -241,7 +241,7 @@ def __init__(self, *args, **kwds): # Constructor for ComponentList needs to # go last in order to handle any initialization # iterable as an argument - ComponentList.__init__(self, _ConstraintData, *args, **kwds) + ComponentList.__init__(self, ConstraintData, *args, **kwds) class XObjectiveList(ComponentList, IndexedObjective): @@ -250,7 +250,7 @@ def __init__(self, *args, **kwds): # Constructor for ComponentList needs to # go last in order to handle any initialization # iterable as an argument - ComponentList.__init__(self, _ObjectiveData, *args, **kwds) + ComponentList.__init__(self, ObjectiveData, *args, **kwds) class XExpressionList(ComponentList, IndexedExpression): @@ -259,4 +259,4 @@ def __init__(self, *args, **kwds): # Constructor for ComponentList needs to # go last in order to handle any initialization # iterable as an argument - ComponentList.__init__(self, _ExpressionData, *args, **kwds) + ComponentList.__init__(self, ExpressionData, *args, **kwds) diff --git a/pyomo/core/expr/__init__.py b/pyomo/core/expr/__init__.py index b0ad2ac4892..b2534431ac8 100644 --- a/pyomo/core/expr/__init__.py +++ b/pyomo/core/expr/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,14 +19,9 @@ ) # -# FIXME: remove circular dependencies between relational_expr and numeric_expr +# FIXME: remove circular dependencies between logical_expr and numeric_expr # -# Initialize relational expression functions -numeric_expr._generate_relational_expression = ( - relational_expr._generate_relational_expression -) - # Initialize logicalvalue functions boolean_value._generate_logical_proposition = logical_expr._generate_logical_proposition @@ -202,3 +197,17 @@ from .calculus.derivatives import differentiate from .taylor_series import taylor_series_expansion + +# +# declare deprecation paths for removed modules and attributes +# +from pyomo.common.deprecation import moved_module + +moved_module( + "pyomo.core.expr.current", + "pyomo._archive.current", + msg="pyomo.core.expr.current is deprecated. " + "Please import expression symbols from pyomo.core.expr", + version='6.6.2', +) +del moved_module diff --git a/pyomo/core/expr/base.py b/pyomo/core/expr/base.py index f506956e478..390cb447c0d 100644 --- a/pyomo/core/expr/base.py +++ b/pyomo/core/expr/base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,12 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import enum - from pyomo.common.dependencies import attempt_import from pyomo.common.numeric_types import native_types +from pyomo.common.modeling import NOTSET from pyomo.core.pyomoobject import PyomoObject -from pyomo.core.expr.expr_common import OperatorAssociativity +from pyomo.core.expr.expr_common import OperatorAssociativity, _type_check_exception_arg visitor, _ = attempt_import('pyomo.core.expr.visitor') @@ -100,7 +99,7 @@ def args(self): f"Derived expression ({self.__class__}) failed to implement args()" ) - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Evaluate the value of the expression tree. Parameters @@ -115,6 +114,7 @@ def __call__(self, exception=True): The value of the expression or :const:`None`. """ + exception = _type_check_exception_arg(self, exception) return visitor.evaluate_expression(self, exception) def __str__(self): @@ -360,7 +360,7 @@ def size(self): """ return visitor.sizeof_expression(self) - def _apply_operation(self, result): # pragma: no cover + def _apply_operation(self, result): """ Compute the values of this node given the values of its children. diff --git a/pyomo/core/expr/boolean_value.py b/pyomo/core/expr/boolean_value.py index 002ec91be9d..d35ae953b3c 100644 --- a/pyomo/core/expr/boolean_value.py +++ b/pyomo/core/expr/boolean_value.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,7 +13,9 @@ import logging from pyomo.common.deprecation import deprecated -from pyomo.core.expr.numvalue import native_types, native_logical_types +from pyomo.common.modeling import NOTSET +from pyomo.common.numeric_types import native_types, native_logical_types +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.expr_common import _and, _or, _equiv, _inv, _xor, _impl from pyomo.core.pyomoobject import PyomoObject @@ -296,8 +298,9 @@ def __nonzero__(self): def __bool__(self): return self.value - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Return the constant value""" + exception = _type_check_exception_arg(self, exception) return self.value def pprint(self, ostream=None, verbose=False): diff --git a/pyomo/core/expr/calculus/__init__.py b/pyomo/core/expr/calculus/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/core/expr/calculus/__init__.py +++ b/pyomo/core/expr/calculus/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/expr/calculus/derivatives.py b/pyomo/core/expr/calculus/derivatives.py index ecfdce02fd4..1e486b8c4a7 100644 --- a/pyomo/core/expr/calculus/derivatives.py +++ b/pyomo/core/expr/calculus/derivatives.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -39,11 +39,11 @@ def differentiate(expr, wrt=None, wrt_list=None, mode=Modes.reverse_numeric): ---------- expr: pyomo.core.expr.numeric_expr.NumericExpression The expression to differentiate - wrt: pyomo.core.base.var._GeneralVarData + wrt: pyomo.core.base.var.VarData If specified, this function will return the derivative with - respect to wrt. wrt is normally a _GeneralVarData, but could - also be a _ParamData. wrt and wrt_list cannot both be specified. - wrt_list: list of pyomo.core.base.var._GeneralVarData + respect to wrt. wrt is normally a VarData, but could + also be a ParamData. wrt and wrt_list cannot both be specified. + wrt_list: list of pyomo.core.base.var.VarData If specified, this function will return the derivative with respect to each element in wrt_list. A list will be returned where the values are the derivatives with respect to the diff --git a/pyomo/core/expr/calculus/diff_with_pyomo.py b/pyomo/core/expr/calculus/diff_with_pyomo.py index fe3eddf1490..cc86c9db230 100644 --- a/pyomo/core/expr/calculus/diff_with_pyomo.py +++ b/pyomo/core/expr/calculus/diff_with_pyomo.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/expr/calculus/diff_with_sympy.py b/pyomo/core/expr/calculus/diff_with_sympy.py index ab62fa3c307..a1cc7827b8d 100644 --- a/pyomo/core/expr/calculus/diff_with_sympy.py +++ b/pyomo/core/expr/calculus/diff_with_sympy.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/expr/cnf_walker.py b/pyomo/core/expr/cnf_walker.py index 7b2081e5d36..58a817e9b13 100644 --- a/pyomo/core/expr/cnf_walker.py +++ b/pyomo/core/expr/cnf_walker.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -45,6 +45,8 @@ def to_cnf(expr, bool_varlist=None, bool_var_to_special_atoms=None): ExactlyExpression require special treatment if they are not the root node, or if their children are not atoms, e.g. + .. code:: + atmost(2, Y1, Y1 | Y2, Y2, Y3) As a result, the model may need to be augmented with @@ -54,13 +56,13 @@ def to_cnf(expr, bool_varlist=None, bool_var_to_special_atoms=None): and augmented variables are needed. This function will return a list of CNF logical constraints, including: - - CNF of original statement, including possible substitutions - - Additional CNF statements (for enforcing equivalence to augmented variables) + - CNF of original statement, including possible substitutions + - Additional CNF statements (for enforcing equivalence to augmented variables) In addition, the function will have side effects: - - augmented variables are added to the passed bool_varlist - - mapping from augmented variables to equivalent special atoms (see note above) - with only literals as logical arguments + - augmented variables are added to the passed bool_varlist + - mapping from augmented variables to equivalent special atoms + (see note above) with only literals as logical arguments """ if type(expr) in special_boolean_atom_types: diff --git a/pyomo/core/expr/compare.py b/pyomo/core/expr/compare.py index 790bc30aaee..e71dfe582c6 100644 --- a/pyomo/core/expr/compare.py +++ b/pyomo/core/expr/compare.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -66,6 +66,16 @@ def handle_external_function_expression(node: ExternalFunctionExpression, pn: Li return node.args +def handle_sequence(node: collections.abc.Sequence, pn: List): + pn.append((collections.abc.Sequence, len(node))) + return list(node) + + +def handle_inequality(node: collections.abc.Sequence, pn: List): + pn.append((type(node), node.nargs(), node.strict)) + return node.args + + def _generic_expression_handler(): return handle_expression @@ -78,7 +88,9 @@ def _generic_expression_handler(): handler[NPV_ExternalFunctionExpression] = handle_external_function_expression handler[AbsExpression] = handle_unary_expression handler[NPV_AbsExpression] = handle_unary_expression -handler[RangedExpression] = handle_expression +handler[InequalityExpression] = handle_inequality +handler[RangedExpression] = handle_inequality +handler[list] = handle_sequence class PrefixVisitor(StreamBasedExpressionVisitor): @@ -97,19 +109,26 @@ def enterNode(self, node): self._result.append(node) return tuple(), None - if node.is_expression_type(): - if node.is_named_expression_type(): - return ( - handle_named_expression( - node, self._result, self._include_named_exprs - ), - None, - ) - else: - return handler[ntype](node, self._result), None - else: - self._result.append(node) - return tuple(), None + if ntype in handler: + return handler[ntype](node, self._result), None + + if hasattr(node, 'is_expression_type'): + if node.is_expression_type(): + if node.is_named_expression_type(): + return ( + handle_named_expression( + node, self._result, self._include_named_exprs + ), + None, + ) + else: + return handler[ntype](node, self._result), None + elif hasattr(node, '__len__'): + handler[ntype] = handle_sequence + return handle_sequence(node, self._result), None + + self._result.append(node) + return tuple(), None def finalizeResult(self, result): ans = self._result @@ -161,10 +180,7 @@ def convert_expression_to_prefix_notation(expr, include_named_exprs=True): """ visitor = PrefixVisitor(include_named_exprs=include_named_exprs) - if isinstance(expr, Sequence): - return expr.__class__(visitor.walk_expression(e) for e in expr) - else: - return visitor.walk_expression(expr) + return visitor.walk_expression(expr) def compare_expressions(expr1, expr2, include_named_exprs=True): @@ -196,7 +212,7 @@ def compare_expressions(expr1, expr2, include_named_exprs=True): ) try: res = pn1 == pn2 - except PyomoException: + except (PyomoException, AttributeError): res = False return res @@ -216,13 +232,14 @@ def assertExpressionsEqual(test, a, b, include_named_exprs=True, places=None): b: ExpressionBase or native type - include_named_exprs: bool - If True (the default), the comparison expands all named - expressions when generating the prefix notation + include_named_exprs : bool + If True (the default), the comparison expands all named + expressions when generating the prefix notation - places: Number of decimal places required for equality of floating - point numbers in the expression. If None (the default), the - expressions must be exactly equal. + places : int + Number of decimal places required for equality of floating + point numbers in the expression. If None (the default), the + expressions must be exactly equal. """ prefix_a = convert_expression_to_prefix_notation(a, include_named_exprs) prefix_b = convert_expression_to_prefix_notation(b, include_named_exprs) @@ -230,10 +247,14 @@ def assertExpressionsEqual(test, a, b, include_named_exprs=True, places=None): test.assertEqual(len(prefix_a), len(prefix_b)) for _a, _b in zip(prefix_a, prefix_b): test.assertIs(_a.__class__, _b.__class__) - if places is None: - test.assertEqual(_a, _b) + # If _a is nan, check _b is nan + if _a != _a: + test.assertTrue(_b != _b) else: - test.assertAlmostEqual(_a, _b, places=places) + if places is None: + test.assertEqual(_a, _b) + else: + test.assertAlmostEqual(_a, _b, places=places) except (PyomoException, AssertionError): test.fail( f"Expressions not equal:\n\t" @@ -261,10 +282,14 @@ def assertExpressionsStructurallyEqual( b: ExpressionBase or native type - include_named_exprs: bool + include_named_exprs : bool If True (the default), the comparison expands all named expressions when generating the prefix notation + places : int + Number of decimal places required for equality of floating + point numbers in the expression. If None (the default), the + expressions must be exactly equal. """ prefix_a = convert_expression_to_prefix_notation(a, include_named_exprs) prefix_b = convert_expression_to_prefix_notation(b, include_named_exprs) @@ -292,10 +317,13 @@ def assertExpressionsStructurallyEqual( for _a, _b in zip(prefix_a, prefix_b): if _a.__class__ not in native_types and _b.__class__ not in native_types: test.assertIs(_a.__class__, _b.__class__) - if places is None: - test.assertEqual(_a, _b) + if _a != _a: + test.assertTrue(_b != _b) else: - test.assertAlmostEqual(_a, _b, places=places) + if places is None: + test.assertEqual(_a, _b) + else: + test.assertAlmostEqual(_a, _b, places=places) except (PyomoException, AssertionError): test.fail( f"Expressions not structurally equal:\n\t" diff --git a/pyomo/core/expr/expr_common.py b/pyomo/core/expr/expr_common.py index 88065e37a2c..8a6024bf3de 100644 --- a/pyomo/core/expr/expr_common.py +++ b/pyomo/core/expr/expr_common.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,17 +9,14 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import enum from contextlib import nullcontext +from pyomo.common import enums from pyomo.common.deprecation import deprecated +from pyomo.common.modeling import NOTSET TO_STRING_VERBOSE = False -_eq = 0 -_le = 1 -_lt = 2 - # logical propositions _and = 0 _or = 1 @@ -32,7 +29,7 @@ # # Provide a global value that indicates which expression system is being used # -class Mode(enum.IntEnum): +class Mode(enums.IntEnum): # coopr: Original Coopr/Pyomo expression system coopr_trees = 1 # coopr3: leverage reference counts to reduce the amount of required @@ -60,7 +57,7 @@ class Mode(enum.IntEnum): assert _mode == Mode.pyomo6_trees -class OperatorAssociativity(enum.IntEnum): +class OperatorAssociativity(enums.IntEnum): """Enum for indicating the associativity of an operator. LEFT_TO_RIGHT(1) if this operator is left-to-right associative or @@ -76,12 +73,126 @@ class OperatorAssociativity(enum.IntEnum): LEFT_TO_RIGHT = 1 -class ExpressionType(enum.Enum): +class ExpressionType(enums.Enum): NUMERIC = 0 RELATIONAL = 1 LOGICAL = 2 +class NUMERIC_ARG_TYPE(enums.IntEnum): + MUTABLE = -2 + ASNUMERIC = -1 + INVALID = 0 + NATIVE = 1 + NPV = 2 + PARAM = 3 + VAR = 4 + MONOMIAL = 5 + LINEAR = 6 + SUM = 7 + OTHER = 8 + + +class RELATIONAL_ARG_TYPE(enums.IntEnum, metaclass=enums.ExtendedEnumType): + __base_enum__ = NUMERIC_ARG_TYPE + + INEQUALITY = 100 + INVALID_RELATIONAL = 101 + + +def _invalid(*args): + return NotImplemented + + +def _recast_mutable(expr): + expr.make_immutable() + if expr._nargs > 1: + return expr + elif not expr._nargs: + return 0 + else: + return expr._args_[0] + + +def _unary_op_dispatcher_type_mapping(dispatcher, updates, TYPES=NUMERIC_ARG_TYPE): + # + # Special case (wrapping) operators + # + def _asnumeric(a): + a = a.as_numeric() + return dispatcher[a.__class__](a) + + def _mutable(a): + a = _recast_mutable(a) + return dispatcher[a.__class__](a) + + mapping = { + TYPES.ASNUMERIC: _asnumeric, + TYPES.MUTABLE: _mutable, + TYPES.INVALID: _invalid, + } + + mapping.update(updates) + return mapping + + +def _binary_op_dispatcher_type_mapping(dispatcher, updates, TYPES=NUMERIC_ARG_TYPE): + # + # Special case (wrapping) operators + # + def _any_asnumeric(a, b): + b = b.as_numeric() + return dispatcher[a.__class__, b.__class__](a, b) + + def _asnumeric_any(a, b): + a = a.as_numeric() + return dispatcher[a.__class__, b.__class__](a, b) + + def _asnumeric_asnumeric(a, b): + a = a.as_numeric() + b = b.as_numeric() + return dispatcher[a.__class__, b.__class__](a, b) + + def _any_mutable(a, b): + b = _recast_mutable(b) + return dispatcher[a.__class__, b.__class__](a, b) + + def _mutable_any(a, b): + a = _recast_mutable(a) + return dispatcher[a.__class__, b.__class__](a, b) + + def _mutable_mutable(a, b): + if a is b: + # Note: _recast_mutable is an in-place operation: make sure + # that we don't call it twice on the same object. + a = b = _recast_mutable(a) + else: + a = _recast_mutable(a) + b = _recast_mutable(b) + return dispatcher[a.__class__, b.__class__](a, b) + + mapping = {} + + # Because ASNUMERIC and MUTABLE re-call the dispatcher, we want to + # resolve ASNUMERIC first, MUTABLE second, and INVALID last. That + # means we will add them to the dispatcher dict in opposite order so + # "higher priority" callbacks override lower priority ones. + + mapping.update({(i, TYPES.INVALID): _invalid for i in TYPES}) + mapping.update({(TYPES.INVALID, i): _invalid for i in TYPES}) + + mapping.update({(i, TYPES.MUTABLE): _any_mutable for i in TYPES}) + mapping.update({(TYPES.MUTABLE, i): _mutable_any for i in TYPES}) + mapping[TYPES.MUTABLE, TYPES.MUTABLE] = _mutable_mutable + + mapping.update({(i, TYPES.ASNUMERIC): _any_asnumeric for i in TYPES}) + mapping.update({(TYPES.ASNUMERIC, i): _asnumeric_any for i in TYPES}) + mapping[TYPES.ASNUMERIC, TYPES.ASNUMERIC] = _asnumeric_asnumeric + + mapping.update(updates) + return mapping + + @deprecated( """The clone counter has been removed and will always return 0. @@ -108,3 +219,15 @@ def __init__(self): def count(self): """A property that returns the clone count value.""" return clone_counter._count + + +def _type_check_exception_arg(cls, exception): + if exception is NOTSET: + return True + elif type(exception) is not bool: + raise ValueError( + f"{cls.ctype.__name__} '{cls.name}' was called with a non-bool " + f"argument for 'exception': {exception}" + ) + else: + return exception diff --git a/pyomo/core/expr/expr_errors.py b/pyomo/core/expr/expr_errors.py index b0ad816d725..46e01549236 100644 --- a/pyomo/core/expr/expr_errors.py +++ b/pyomo/core/expr/expr_errors.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/expr/logical_expr.py b/pyomo/core/expr/logical_expr.py index 9519b02a43b..f481a00efe4 100644 --- a/pyomo/core/expr/logical_expr.py +++ b/pyomo/core/expr/logical_expr.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/expr/ndarray.py b/pyomo/core/expr/ndarray.py index 41514c91153..9555a317b5c 100644 --- a/pyomo/core/expr/ndarray.py +++ b/pyomo/core/expr/ndarray.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/expr/numeric_expr.py b/pyomo/core/expr/numeric_expr.py index c1199ffdcad..af5bc064c3f 100644 --- a/pyomo/core/expr/numeric_expr.py +++ b/pyomo/core/expr/numeric_expr.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,12 +10,11 @@ # ___________________________________________________________________________ import collections -import enum import logging import math import operator -logger = logging.getLogger('pyomo.core') +logger = logging.getLogger("pyomo.core") from pyomo.common.dependencies import attempt_import from pyomo.common.deprecation import deprecated, relocated_module_attribute @@ -33,74 +32,85 @@ from pyomo.core.expr.expr_common import ( OperatorAssociativity, ExpressionType, - _lt, - _le, - _eq, + _unary_op_dispatcher_type_mapping, + _binary_op_dispatcher_type_mapping, + _invalid, + _recast_mutable, + NUMERIC_ARG_TYPE as ARG_TYPE, ) # Note: pyggyback on expr.base's use of attempt_import(visitor) from pyomo.core.expr.base import ExpressionBase, NPV_Mixin, visitor +# Note: There is a circular dependency between relational_expr and this +# module: relational_expr would like to reuse/build on +# _categorize_arg_type(), and NumericValue needs to call the relational +# dispatchers from relational_expr. Instead of ensuring that one of the +# modules is fully declared before importing into the other, we will +# have BOTH modules assume that the other module has NOT been declared. +import pyomo.core.expr.relational_expr as relational_expr -_ndarray, _ = attempt_import('pyomo.core.expr.ndarray') + +_ndarray, _ = attempt_import("pyomo.core.expr.ndarray") relocated_module_attribute( - 'is_potentially_variable', - 'pyomo.core.expr.numvalue.is_potentially_variable', - version='6.6.2', + "is_potentially_variable", + "pyomo.core.expr.numvalue.is_potentially_variable", + version="6.6.2", f_globals=globals(), ) relocated_module_attribute( - 'as_numeric', - 'pyomo.core.expr.numvalue.as_numeric', - version='6.6.2', + "as_numeric", + "pyomo.core.expr.numvalue.as_numeric", + version="6.6.2", f_globals=globals(), ) relocated_module_attribute( - 'clone_counter', - 'pyomo.core.expr.expr_common.clone_counter', - version='6.6.2', + "clone_counter", + "pyomo.core.expr.expr_common.clone_counter", + version="6.6.2", f_globals=globals(), ) relocated_module_attribute( - 'evaluate_expression', - 'pyomo.core.expr.visitor.evaluate_expression', - version='6.6.2', + "evaluate_expression", + "pyomo.core.expr.visitor.evaluate_expression", + version="6.6.2", f_globals=globals(), ) relocated_module_attribute( - 'expression_to_string', - 'pyomo.core.expr.visitor.expression_to_string', - version='6.6.2', + "expression_to_string", + "pyomo.core.expr.visitor.expression_to_string", + version="6.6.2", f_globals=globals(), ) relocated_module_attribute( - 'polynomial_degree', - 'pyomo.core.expr.visitor.polynomial_degree', - version='6.6.2', + "polynomial_degree", + "pyomo.core.expr.visitor.polynomial_degree", + version="6.6.2", f_globals=globals(), ) relocated_module_attribute( - 'clone_expression', - 'pyomo.core.expr.visitor.clone_expression', - version='6.6.2', + "clone_expression", + "pyomo.core.expr.visitor.clone_expression", + version="6.6.2", f_globals=globals(), ) relocated_module_attribute( - 'sizeof_expression', - 'pyomo.core.expr.visitor.sizeof_expression', - version='6.6.2', + "sizeof_expression", + "pyomo.core.expr.visitor.sizeof_expression", + version="6.6.2", + f_globals=globals(), +) +relocated_module_attribute( + "register_arg_type", + "pyomo.core.expr.expr_common", + version="6.9.2", f_globals=globals(), ) _zero_one_optimizations = {1} -# Stub in the dispatchers -def _generate_relational_expression(etype, lhs, rhs): - raise RuntimeError("incomplete import of Pyomo expression system") - - def enable_expression_optimizations(zero=None, one=None): """Enable(disable) expression generation optimizations @@ -216,14 +226,14 @@ class NumericValue(PyomoObject): # This is required because we define __eq__ __hash__ = None - def getname(self, fully_qualified=False, name_buffer=None): + def getname(self, *args, **kwargs): """ If this is a component, return the component's name on the owning block; otherwise return the value converted to a string """ _base = super(NumericValue, self) - if hasattr(_base, 'getname'): - return _base.getname(fully_qualified, name_buffer) + if hasattr(_base, "getname"): + return _base.getname(*args, **kwargs) else: return str(type(self)) @@ -254,7 +264,7 @@ def is_potentially_variable(self): @deprecated( "is_relational() is deprecated in favor of " "is_expression_type(ExpressionType.RELATIONAL)", - version='6.4.3', + version="6.4.3", ) def is_relational(self): """ @@ -375,7 +385,9 @@ def __lt__(self, other): self < other other > self """ - return _generate_relational_expression(_lt, self, other) + return relational_expr._lt_dispatcher[self.__class__, other.__class__]( + self, other + ) def __gt__(self, other): """ @@ -386,7 +398,9 @@ def __gt__(self, other): self > other other < self """ - return _generate_relational_expression(_lt, other, self) + return relational_expr._lt_dispatcher[other.__class__, self.__class__]( + other, self + ) def __le__(self, other): """ @@ -397,7 +411,9 @@ def __le__(self, other): self <= other other >= self """ - return _generate_relational_expression(_le, self, other) + return relational_expr._le_dispatcher[self.__class__, other.__class__]( + self, other + ) def __ge__(self, other): """ @@ -408,7 +424,9 @@ def __ge__(self, other): self >= other other <= self """ - return _generate_relational_expression(_le, other, self) + return relational_expr._le_dispatcher[other.__class__, self.__class__]( + other, self + ) def __eq__(self, other): """ @@ -418,7 +436,13 @@ def __eq__(self, other): self == other """ - return _generate_relational_expression(_eq, self, other) + # Note: While it would appear that keeping the attribute lookup + # into the relational_expr module would be a performance hit, we + # want that indirection as it allows us to selectively disable + # operator overloading for comparisons. + return relational_expr._eq_dispatcher[self.__class__, other.__class__]( + self, other + ) def __add__(self, other): """ @@ -692,11 +716,12 @@ class NumericExpression(ExpressionBase, NumericValue): # Here, we use _args_ to force errors for code that was referencing this # data. There are now accessor methods, so in most cases users # and developers should not directly access the _args_ data values. - __slots__ = ('_args_',) + __slots__ = ("_args_",) EXPRESSION_SYSTEM = ExpressionType.NUMERIC PRECEDENCE = 0 def __init__(self, args): + logger.debug(f"{self.__class__}.__init__: args={args}") self._args_ = args def nargs(self): @@ -721,10 +746,10 @@ def args(self): @deprecated( 'The implicit recasting of a "not potentially variable" ' - 'expression node to a potentially variable one is no ' - 'longer supported (this violates that immutability ' - 'promise for Pyomo5 expression trees).', - version='6.4.3', + "expression node to a potentially variable one is no " + "longer supported (this violates the immutability " + "promise for Pyomo5 expression trees).", + version="6.4.3", ) def create_potentially_variable_object(self): """ @@ -745,9 +770,9 @@ def create_potentially_variable_object(self): """ if not self.is_potentially_variable(): logger.error( - 'recasting a non-potentially variable expression to a ' - 'potentially variable one violates the immutability ' - 'promise for Pyomo expression trees.' + "recasting a non-potentially variable expression to a " + "potentially variable one violates the immutability " + "promise for Pyomo expression trees." ) self.__class__ = self.potentially_variable_base_class() return self @@ -820,7 +845,7 @@ def nargs(self): return 1 def getname(self, *args, **kwds): - return 'neg' + return "neg" def _compute_polynomial_degree(self, result): return result[0] @@ -829,7 +854,7 @@ def _to_string(self, values, verbose, smap): if verbose: return f"{self.getname()}({values[0]})" tmp = values[0] - if tmp[0] == '-': + if tmp[0] == "-": return tmp[1:].strip() # TODO: remove space after negation return "- " + tmp @@ -865,7 +890,7 @@ class ExternalFunctionExpression(NumericExpression): fcn: a class that defines this external function """ - __slots__ = ('_fcn',) + __slots__ = ("_fcn",) # This operator does not have an infix representation PRECEDENCE = None @@ -952,7 +977,7 @@ def _apply_operation(self, result): return _l**_r def getname(self, *args, **kwds): - return 'pow' + return "pow" def _to_string(self, values, verbose, smap): if verbose: @@ -983,7 +1008,7 @@ def _apply_operation(self, result): return max(result) def getname(self, *args, **kwds): - return 'max' + return "max" def _to_string(self, values, verbose, smap): return f"{self.getname()}({', '.join(values)})" @@ -1012,7 +1037,7 @@ def _apply_operation(self, result): return min(result) def getname(self, *args, **kwds): - return 'min' + return "min" def _to_string(self, values, verbose, smap): return f"{self.getname()}({', '.join(values)})" @@ -1047,7 +1072,7 @@ def _compute_polynomial_degree(self, result): return a + b def getname(self, *args, **kwds): - return 'prod' + return "prod" def _is_fixed(self, args): # Anything times 0 equals 0, so one of the children is @@ -1086,7 +1111,7 @@ class MonomialTermExpression(ProductExpression): __slots__ = () def getname(self, *args, **kwds): - return 'mon' + return "mon" def create_node_with_local_data(self, args, classtype=None): if classtype is None: @@ -1094,7 +1119,7 @@ def create_node_with_local_data(self, args, classtype=None): # types, the simplest / fastest thing to do is just defer to # the operator dispatcher. return operator.mul(*args) - return self.__class__(args) + return classtype(args) class DivisionExpression(NumericExpression): @@ -1113,7 +1138,7 @@ def _compute_polynomial_degree(self, result): return None def getname(self, *args, **kwds): - return 'div' + return "div" def _to_string(self, values, verbose, smap): if verbose: @@ -1141,11 +1166,12 @@ class SumExpression(NumericExpression): """ - __slots__ = ('_nargs',) + __slots__ = ("_nargs",) PRECEDENCE = 6 def __init__(self, args): # unlike other expressions, we expect (require) args to be a list + logger.debug(f"SumExpression: args={args}") if args.__class__ is not list: args = list(args) self._args_ = args @@ -1161,7 +1187,7 @@ def args(self): return self._args_[: self._nargs] def getname(self, *args, **kwds): - return 'sum' + return "sum" def _trunc_append(self, other): _args = self._args_ @@ -1196,7 +1222,7 @@ def _compute_polynomial_degree(self, result): def _to_string(self, values, verbose, smap): if not values: - values = ['0'] + values = ["0"] if verbose: return f"{self.getname()}({', '.join(values)})" term = values[0] @@ -1210,16 +1236,16 @@ def _to_string(self, values, verbose, smap): # a term is enclosed in parens, then it is a nested sum.) # if term[0] == '(' and term[-1] == ')' and _balanced_parens(term[1:-1]): # term = term[1:-1] - if term[0] in '-+': - values[i] = term[0] + ' ' + term[1:].strip() + if term[0] in "-+": + values[i] = term[0] + " " + term[1:].strip() else: - values[i] = '+ ' + term.strip() - return ' '.join(values) + values[i] = "+ " + term.strip() + return " ".join(values) @deprecated( "SumExpression.add() is deprecated. Please use regular Python operators " "(infix '+' or inplace '+='.)", - version='6.6.0', + version="6.6.0", ) def add(self, new_arg): self += new_arg @@ -1234,9 +1260,11 @@ class LinearExpression(SumExpression): """An expression object for linear polynomials. This is a derived :py:class`SumExpression` that guarantees all - arguments are either not potentially variable (e.g., native types, - Params, or NPV expressions) OR :py:class:`MonomialTermExpression` - objects. + arguments are one of the following types: + + - not potentially variable (e.g., native types, Params, or NPV expressions) + - :py:class:`MonomialTermExpression` + - :py:class:`VarData` Args: args (tuple): Children nodes @@ -1253,7 +1281,7 @@ def __init__(self, args=None, constant=None, linear_coefs=None, linear_vars=None You can specify `args` OR (`constant`, `linear_coefs`, and `linear_vars`). If `args` is provided, it should be a list that - contains only constants, NPV objects/expressions, or + contains only constants, NPV objects/expressions, variables, or :py:class:`MonomialTermExpression` objects. Alternatively, you can specify the constant, the list of linear_coefs and the list of linear_vars separately. Note that these lists are NOT @@ -1263,6 +1291,10 @@ def __init__(self, args=None, constant=None, linear_coefs=None, linear_vars=None # I am not sure why LinearExpression allows omitting args, but # it does. If they are provided, they should be the (non-zero) # constant followed by MonomialTermExpressions. + logger.debug( + f"LinearExpression: args={args},constant={constant}, linear_coefs={linear_coefs}, " + f"linear_vars={linear_vars}" + ) if args is not None: if not (constant is None and linear_coefs is None and linear_vars is None): raise ValueError( @@ -1298,8 +1330,14 @@ def _build_cache(self): if arg.__class__ is MonomialTermExpression: coef.append(arg._args_[0]) var.append(arg._args_[1]) - else: + elif arg.__class__ in native_numeric_types: + const += arg + elif not arg.is_potentially_variable(): const += arg + else: + assert arg.is_potentially_variable() + coef.append(1) + var.append(arg) LinearExpression._cache = (self, const, coef, var) @property @@ -1325,7 +1363,7 @@ def create_node_with_local_data(self, args, classtype=None): classtype = self.__class__ if type(args) is not list: args = list(args) - for i, arg in enumerate(args): + for arg in args: if arg.__class__ in self._allowable_linear_expr_arg_types: # 99% of the time, the arg type hasn't changed continue @@ -1336,8 +1374,7 @@ def create_node_with_local_data(self, args, classtype=None): # NPV expressions are OK pass elif arg.is_variable_type(): - # vars are OK, but need to be mapped to monomial terms - args[i] = MonomialTermExpression((1, arg)) + # vars are OK continue else: # For anything else, convert this to a general sum @@ -1438,8 +1475,8 @@ def _compute_polynomial_degree(self, result): def _to_string(self, values, verbose, smap): return ( - f'{self.getname()}( ( {values[0]} ), then=( {values[1]} ), ' - f'else=( {values[2]} ) )' + f"{self.getname()}( ( {values[0]} ), then=( {values[1]} ), " + f"else=( {values[2]} ) )" ) def _apply_operation(self, result): @@ -1461,7 +1498,7 @@ class UnaryFunctionExpression(NumericExpression): fcn: The function that is used to evaluate this expression """ - __slots__ = ('_fcn', '_name') + __slots__ = ("_fcn", "_name") # This operator does not have an infix representation PRECEDENCE = None @@ -1506,7 +1543,7 @@ class AbsExpression(UnaryFunctionExpression): __slots__ = () def __init__(self, arg): - super(AbsExpression, self).__init__(arg, 'abs', abs) + super(AbsExpression, self).__init__(arg, "abs", abs) def create_node_with_local_data(self, args, classtype=None): # Because this class removes arguments from the __init__, we @@ -1630,21 +1667,6 @@ def _decompose_linear_terms(expr, multiplier=1): # # ------------------------------------------------------- - -class ARG_TYPE(enum.IntEnum): - MUTABLE = -2 - ASNUMERIC = -1 - INVALID = 0 - NATIVE = 1 - NPV = 2 - PARAM = 3 - VAR = 4 - MONOMIAL = 5 - LINEAR = 6 - SUM = 7 - OTHER = 8 - - _known_arg_types = {} @@ -1669,7 +1691,7 @@ def _categorize_arg_type(arg): else: if is_numeric: ans = None - elif hasattr(arg, 'as_numeric'): + elif hasattr(arg, "as_numeric"): ans = ARG_TYPE.ASNUMERIC else: ans = ARG_TYPE.INVALID @@ -1708,91 +1730,6 @@ def _categorize_arg_types(*args): return tuple(_categorize_arg_type(arg) for arg in args) -def _invalid(*args): - return NotImplemented - - -def _recast_mutable(expr): - expr.make_immutable() - if expr._nargs > 1: - return expr - elif not expr._nargs: - return 0 - else: - return expr._args_[0] - - -def _unary_op_dispatcher_type_mapping(dispatcher, updates): - # - # Special case (wrapping) operators - # - def _asnumeric(a): - a = a.as_numeric() - return dispatcher[a.__class__](a) - - def _mutable(a): - a = _recast_mutable(a) - return dispatcher[a.__class__](a) - - mapping = { - ARG_TYPE.ASNUMERIC: _asnumeric, - ARG_TYPE.MUTABLE: _mutable, - ARG_TYPE.INVALID: _invalid, - } - - mapping.update(updates) - return mapping - - -def _binary_op_dispatcher_type_mapping(dispatcher, updates): - # - # Special case (wrapping) operators - # - def _any_asnumeric(a, b): - b = b.as_numeric() - return dispatcher[a.__class__, b.__class__](a, b) - - def _asnumeric_any(a, b): - a = a.as_numeric() - return dispatcher[a.__class__, b.__class__](a, b) - - def _asnumeric_asnumeric(a, b): - a = a.as_numeric() - b = b.as_numeric() - return dispatcher[a.__class__, b.__class__](a, b) - - def _any_mutable(a, b): - b = _recast_mutable(b) - return dispatcher[a.__class__, b.__class__](a, b) - - def _mutable_any(a, b): - a = _recast_mutable(a) - return dispatcher[a.__class__, b.__class__](a, b) - - def _mutable_mutable(a, b): - if a is b: - a = b = _recast_mutable(a) - else: - a = _recast_mutable(a) - b = _recast_mutable(b) - return dispatcher[a.__class__, b.__class__](a, b) - - mapping = {} - mapping.update({(i, ARG_TYPE.ASNUMERIC): _any_asnumeric for i in ARG_TYPE}) - mapping.update({(ARG_TYPE.ASNUMERIC, i): _asnumeric_any for i in ARG_TYPE}) - mapping[ARG_TYPE.ASNUMERIC, ARG_TYPE.ASNUMERIC] = _asnumeric_asnumeric - - mapping.update({(i, ARG_TYPE.MUTABLE): _any_mutable for i in ARG_TYPE}) - mapping.update({(ARG_TYPE.MUTABLE, i): _mutable_any for i in ARG_TYPE}) - mapping[ARG_TYPE.MUTABLE, ARG_TYPE.MUTABLE] = _mutable_mutable - - mapping.update({(i, ARG_TYPE.INVALID): _invalid for i in ARG_TYPE}) - mapping.update({(ARG_TYPE.INVALID, i): _invalid for i in ARG_TYPE}) - - mapping.update(updates) - return mapping - - # # ADD: NATIVE handlers # @@ -1820,7 +1757,7 @@ def _add_native_param(a, b): def _add_native_var(a, b): if not a: return b - return LinearExpression([a, MonomialTermExpression((1, b))]) + return LinearExpression([a, b]) def _add_native_monomial(a, b): @@ -1871,7 +1808,7 @@ def _add_npv_param(a, b): def _add_npv_var(a, b): - return LinearExpression([a, MonomialTermExpression((1, b))]) + return LinearExpression([a, b]) def _add_npv_monomial(a, b): @@ -1929,7 +1866,7 @@ def _add_param_var(a, b): a = a.value if not a: return b - return LinearExpression([a, MonomialTermExpression((1, b))]) + return LinearExpression([a, b]) def _add_param_monomial(a, b): @@ -1972,11 +1909,11 @@ def _add_param_other(a, b): def _add_var_native(a, b): if not b: return a - return LinearExpression([MonomialTermExpression((1, a)), b]) + return LinearExpression([a, b]) def _add_var_npv(a, b): - return LinearExpression([MonomialTermExpression((1, a)), b]) + return LinearExpression([a, b]) def _add_var_param(a, b): @@ -1984,21 +1921,19 @@ def _add_var_param(a, b): b = b.value if not b: return a - return LinearExpression([MonomialTermExpression((1, a)), b]) + return LinearExpression([a, b]) def _add_var_var(a, b): - return LinearExpression( - [MonomialTermExpression((1, a)), MonomialTermExpression((1, b))] - ) + return LinearExpression([a, b]) def _add_var_monomial(a, b): - return LinearExpression([MonomialTermExpression((1, a)), b]) + return LinearExpression([a, b]) def _add_var_linear(a, b): - return b._trunc_append(MonomialTermExpression((1, a))) + return b._trunc_append(a) def _add_var_sum(a, b): @@ -2033,7 +1968,7 @@ def _add_monomial_param(a, b): def _add_monomial_var(a, b): - return LinearExpression([a, MonomialTermExpression((1, b))]) + return LinearExpression([a, b]) def _add_monomial_monomial(a, b): @@ -2076,7 +2011,7 @@ def _add_linear_param(a, b): def _add_linear_var(a, b): - return a._trunc_append(MonomialTermExpression((1, b))) + return a._trunc_append(b) def _add_linear_monomial(a, b): @@ -2283,8 +2218,11 @@ def _iadd_mutablenpvsum_mutable(a, b): def _iadd_mutablenpvsum_native(a, b): if not b: return a - a._args_.append(b) - a._nargs += 1 + if a._args_ and a._args_[-1].__class__ in native_numeric_types: + a._args_[-1] += b + else: + a._args_.append(b) + a._nargs += 1 return a @@ -2296,9 +2234,7 @@ def _iadd_mutablenpvsum_npv(a, b): def _iadd_mutablenpvsum_param(a, b): if b.is_constant(): - b = b.value - if not b: - return a + return _iadd_mutablesum_native(a, b.value) a._args_.append(b) a._nargs += 1 return a @@ -2379,8 +2315,11 @@ def _iadd_mutablelinear_mutable(a, b): def _iadd_mutablelinear_native(a, b): if not b: return a - a._args_.append(b) - a._nargs += 1 + if a._args_ and a._args_[-1].__class__ in native_numeric_types: + a._args_[-1] += b + else: + a._args_.append(b) + a._nargs += 1 return a @@ -2392,16 +2331,14 @@ def _iadd_mutablelinear_npv(a, b): def _iadd_mutablelinear_param(a, b): if b.is_constant(): - b = b.value - if not b: - return a + return _iadd_mutablesum_native(a, b.value) a._args_.append(b) a._nargs += 1 return a def _iadd_mutablelinear_var(a, b): - a._args_.append(MonomialTermExpression((1, b))) + a._args_.append(b) a._nargs += 1 return a @@ -2478,8 +2415,11 @@ def _iadd_mutablesum_mutable(a, b): def _iadd_mutablesum_native(a, b): if not b: return a - a._args_.append(b) - a._nargs += 1 + if a._args_ and a._args_[-1].__class__ in native_numeric_types: + a._args_[-1] += b + else: + a._args_.append(b) + a._nargs += 1 return a @@ -2491,9 +2431,7 @@ def _iadd_mutablesum_npv(a, b): def _iadd_mutablesum_param(a, b): if b.is_constant(): - b = b.value - if not b: - return a + return _iadd_mutablesum_native(a, b.value) a._args_.append(b) a._nargs += 1 return a @@ -3895,79 +3833,79 @@ def _register_new_fcn_dispatcher(a, name, fcn): # Python operators. # def ceil(arg): - return _fcn_dispatcher[arg.__class__](arg, 'ceil', math.ceil) + return _fcn_dispatcher[arg.__class__](arg, "ceil", math.ceil) def floor(arg): - return _fcn_dispatcher[arg.__class__](arg, 'floor', math.floor) + return _fcn_dispatcher[arg.__class__](arg, "floor", math.floor) # e ** x def exp(arg): - return _fcn_dispatcher[arg.__class__](arg, 'exp', math.exp) + return _fcn_dispatcher[arg.__class__](arg, "exp", math.exp) def log(arg): - return _fcn_dispatcher[arg.__class__](arg, 'log', math.log) + return _fcn_dispatcher[arg.__class__](arg, "log", math.log) def log10(arg): - return _fcn_dispatcher[arg.__class__](arg, 'log10', math.log10) + return _fcn_dispatcher[arg.__class__](arg, "log10", math.log10) # FIXME: this is nominally the same as x ** 0.5, but follows a different # path and produces a different NL file! def sqrt(arg): - return _fcn_dispatcher[arg.__class__](arg, 'sqrt', math.sqrt) + return _fcn_dispatcher[arg.__class__](arg, "sqrt", math.sqrt) # return _pow_dispatcher[arg.__class__, float](arg, 0.5) def sin(arg): - return _fcn_dispatcher[arg.__class__](arg, 'sin', math.sin) + return _fcn_dispatcher[arg.__class__](arg, "sin", math.sin) def cos(arg): - return _fcn_dispatcher[arg.__class__](arg, 'cos', math.cos) + return _fcn_dispatcher[arg.__class__](arg, "cos", math.cos) def tan(arg): - return _fcn_dispatcher[arg.__class__](arg, 'tan', math.tan) + return _fcn_dispatcher[arg.__class__](arg, "tan", math.tan) def sinh(arg): - return _fcn_dispatcher[arg.__class__](arg, 'sinh', math.sinh) + return _fcn_dispatcher[arg.__class__](arg, "sinh", math.sinh) def cosh(arg): - return _fcn_dispatcher[arg.__class__](arg, 'cosh', math.cosh) + return _fcn_dispatcher[arg.__class__](arg, "cosh", math.cosh) def tanh(arg): - return _fcn_dispatcher[arg.__class__](arg, 'tanh', math.tanh) + return _fcn_dispatcher[arg.__class__](arg, "tanh", math.tanh) def asin(arg): - return _fcn_dispatcher[arg.__class__](arg, 'asin', math.asin) + return _fcn_dispatcher[arg.__class__](arg, "asin", math.asin) def acos(arg): - return _fcn_dispatcher[arg.__class__](arg, 'acos', math.acos) + return _fcn_dispatcher[arg.__class__](arg, "acos", math.acos) def atan(arg): - return _fcn_dispatcher[arg.__class__](arg, 'atan', math.atan) + return _fcn_dispatcher[arg.__class__](arg, "atan", math.atan) def asinh(arg): - return _fcn_dispatcher[arg.__class__](arg, 'asinh', math.asinh) + return _fcn_dispatcher[arg.__class__](arg, "asinh", math.asinh) def acosh(arg): - return _fcn_dispatcher[arg.__class__](arg, 'acosh', math.acosh) + return _fcn_dispatcher[arg.__class__](arg, "acosh", math.acosh) def atanh(arg): - return _fcn_dispatcher[arg.__class__](arg, 'atanh', math.atanh) + return _fcn_dispatcher[arg.__class__](arg, "atanh", math.atanh) # @@ -3979,7 +3917,7 @@ def _process_expr_if_arg(arg, kwargs, name): alt = kwargs.pop(name, None) if alt is not None: if arg is not None: - raise ValueError(f'Cannot specify both {name}_ and {name}') + raise ValueError(f"Cannot specify both {name}_ and {name}") arg = alt _type = _categorize_arg_type(arg) # Note that relational expressions get mapped to INVALID @@ -3989,7 +3927,7 @@ def _process_expr_if_arg(arg, kwargs, name): elif _type is ARG_TYPE.ASNUMERIC: arg = arg.as_numeric() else: - raise DeveloperError('_categorize_arg_type() returned unexpected ARG_TYPE') + raise DeveloperError("_categorize_arg_type() returned unexpected ARG_TYPE") _type = _categorize_arg_type(arg) return arg, _type @@ -4006,14 +3944,14 @@ def Expr_if(IF_=None, THEN_=None, ELSE_=None, **kwargs): (the former is historical, and the latter is required to support Cythonization) """ _pv = False - ELSE_, _type = _process_expr_if_arg(ELSE_, kwargs, 'ELSE') + ELSE_, _type = _process_expr_if_arg(ELSE_, kwargs, "ELSE") _pv |= _type >= ARG_TYPE.VAR or _type == ARG_TYPE.INVALID - THEN_, _type = _process_expr_if_arg(THEN_, kwargs, 'THEN') + THEN_, _type = _process_expr_if_arg(THEN_, kwargs, "THEN") _pv |= _type >= ARG_TYPE.VAR or _type == ARG_TYPE.INVALID - IF_, _type = _process_expr_if_arg(IF_, kwargs, 'IF') + IF_, _type = _process_expr_if_arg(IF_, kwargs, "IF") _pv |= _type >= ARG_TYPE.VAR or _type == ARG_TYPE.INVALID if kwargs: - raise ValueError('Unrecognized arguments: ' + ', '.join(kwargs)) + raise ValueError("Unrecognized arguments: " + ", ".join(kwargs)) # Notes: # - side effect: IF is the last iteration, so _type == _categorize_arg_type(IF) # - we do NO error checking as to the actual arg types. That is @@ -4052,9 +3990,9 @@ def _balanced_parens(arg): """ _parenCount = 0 for c in arg: - if c == '(': + if c == "(": _parenCount += 1 - elif c == ')': + elif c == ")": _parenCount -= 1 if _parenCount < 0: return False diff --git a/pyomo/core/expr/numvalue.py b/pyomo/core/expr/numvalue.py index 3a4359af2f9..d03fbe292ce 100644 --- a/pyomo/core/expr/numvalue.py +++ b/pyomo/core/expr/numvalue.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,7 +17,8 @@ deprecation_warning, relocated_module_attribute, ) -from pyomo.core.expr.expr_common import ExpressionType +from pyomo.common.modeling import NOTSET +from pyomo.core.expr.expr_common import ExpressionType, _type_check_exception_arg from pyomo.core.expr.numeric_expr import NumericValue # TODO: update Pyomo to import these objects from common.numeric_types @@ -28,7 +29,7 @@ native_numeric_types, native_integer_types, native_logical_types, - pyomo_constant_types, + _pyomo_constant_types, check_if_numeric_type, value, ) @@ -44,6 +45,16 @@ "be treated as if they were bool (as was the case for the other " "native_*_types sets). Users likely should use native_logical_types.", ) +relocated_module_attribute( + 'pyomo_constant_types', + 'pyomo.common.numeric_types._pyomo_constant_types', + version='6.7.2', + f_globals=globals(), + msg="The pyomo_constant_types set will be removed in the future: the set " + "contained only NumericConstant and _PythonCallbackFunctionID, and provided " + "no meaningful value to clients or walkers. Users should likely handle " + "these types in the same manner as immutable Params.", +) relocated_module_attribute( 'RegisterNumericType', 'pyomo.common.numeric_types.RegisterNumericType', @@ -85,7 +96,7 @@ ##------------------------------------------------------------------------ -class NonNumericValue(object): +class NonNumericValue(PyomoObject): """An object that contains a non-numeric value Constructor Arguments: @@ -100,6 +111,19 @@ def __init__(self, value): def __str__(self): return str(self.value) + def __repr__(self): + return repr(self.value) + + def __call__(self, exception=NOTSET): + exception = _type_check_exception_arg(self, exception) + return self.value + + def is_constant(self): + return True + + def is_fixed(self): + return True + nonpyomo_leaf_types.add(NonNumericValue) @@ -400,8 +424,9 @@ def _compute_polynomial_degree(self, result): def __str__(self): return str(self.value) - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Return the constant value""" + exception = _type_check_exception_arg(self, exception) return self.value def pprint(self, ostream=None, verbose=False): @@ -410,7 +435,7 @@ def pprint(self, ostream=None, verbose=False): ostream.write(str(self)) -pyomo_constant_types.add(NumericConstant) +_pyomo_constant_types.add(NumericConstant) # We use as_numeric() so that the constant is also in the cache ZeroConstant = as_numeric(0) diff --git a/pyomo/core/expr/relational_expr.py b/pyomo/core/expr/relational_expr.py index c80fdd4930a..1704abec254 100644 --- a/pyomo/core/expr/relational_expr.py +++ b/pyomo/core/expr/relational_expr.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,9 +10,10 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import collections import operator -from pyomo.common.deprecation import deprecated +from pyomo.common.deprecation import deprecated, relocated_module_attribute from pyomo.common.errors import PyomoException, DeveloperError from pyomo.common.numeric_types import ( native_numeric_types, @@ -20,11 +21,24 @@ value, ) -from .base import ExpressionBase -from .boolean_value import BooleanValue -from .expr_common import _lt, _le, _eq, ExpressionType -from .numvalue import is_potentially_variable, is_constant -from .visitor import polynomial_degree +# Note: There is a circular dependence between numeric_expr and this +# module: this module would like to reuse/build on +# numeric_expr._categorize_arg_type(), and numeric_expr.NumericValue +# needs to call the relational dispatchers here. Instead of ensuring +# that one of the modules is fully declared before importing into the +# other, we will have BOTH modules assume that the other module has NOT +# been declared. +import pyomo.core.expr.numeric_expr as numeric_expr + +from pyomo.core.expr.base import ExpressionBase +from pyomo.core.expr.boolean_value import BooleanValue +from pyomo.core.expr.expr_common import ( + ExpressionType, + RELATIONAL_ARG_TYPE as ARG_TYPE, + _binary_op_dispatcher_type_mapping, +) +from pyomo.core.expr.visitor import polynomial_degree +from pyomo.core.pyomoobject import PyomoObject # ------------------------------------------------------- # @@ -33,6 +47,35 @@ # ------------------------------------------------------- +def _categorize_relational_arg_type(arg): + """Attempt to categorize an unknown object type into a RELATIONAL_ARG_TYPE + + Note that this can return the following types: + - MUTABLE + - ASNUMERIC + - INVALID + - NATIVE + - PARAM + - OTHER + - INEQUALITY + - INVALID_RELATIONAL + """ + arg_type = numeric_expr._categorize_arg_type(arg) + if arg_type is ARG_TYPE.INVALID: + if isinstance(arg, PyomoObject): + if isinstance(arg, InequalityExpression): + arg_type = ARG_TYPE.INEQUALITY + else: + arg_type = ARG_TYPE.INVALID_RELATIONAL + elif arg_type > ARG_TYPE.NATIVE and arg_type != ARG_TYPE.PARAM: + arg_type = ARG_TYPE.OTHER + return arg_type + + +def _categorize_relational_arg_types(*args): + return tuple(_categorize_relational_arg_type(arg) for arg in args) + + class RelationalExpression(ExpressionBase, BooleanValue): __slots__ = ('_args_',) @@ -80,7 +123,10 @@ def is_relational(self): return self.is_expression_type(ExpressionType.RELATIONAL) def is_potentially_variable(self): - return any(is_potentially_variable(arg) for arg in self._args_) + return any( + arg.__class__ not in native_numeric_types and arg.is_potentially_variable() + for arg in self._args_ + ) def polynomial_degree(self): """ @@ -112,7 +158,7 @@ def __eq__(self, other): self == other other == self """ - return _generate_relational_expression(_eq, self, other) + return _eq_dispatcher[self.__class__, other.__class__](self, other) def __lt__(self, other): """ @@ -123,7 +169,7 @@ def __lt__(self, other): self < other other > self """ - return _generate_relational_expression(_lt, self, other) + return _lt_dispatcher[self.__class__, other.__class__](self, other) def __gt__(self, other): """ @@ -134,7 +180,7 @@ def __gt__(self, other): self > other other < self """ - return _generate_relational_expression(_lt, other, self) + return _lt_dispatcher[other.__class__, self.__class__](other, self) def __le__(self, other): """ @@ -145,7 +191,7 @@ def __le__(self, other): self <= other other >= self """ - return _generate_relational_expression(_le, self, other) + return _le_dispatcher[self.__class__, other.__class__](self, other) def __ge__(self, other): """ @@ -156,7 +202,7 @@ def __ge__(self, other): self >= other other <= self """ - return _generate_relational_expression(_le, other, self) + return _le_dispatcher[other.__class__, self.__class__](other, self) class RangedExpression(RelationalExpression): @@ -363,105 +409,319 @@ def _to_string(self, values, verbose, smap): return "%s != %s" % (values[0], values[1]) -_relational_op = { - _eq: (operator.eq, '==', None), - _le: (operator.le, '<=', False), - _lt: (operator.lt, '<', True), -} - - -def _process_nonnumeric_arg(obj): - if hasattr(obj, 'as_numeric'): - # We assume non-numeric types that have an as_numeric method - # are instances of AutoLinkedBooleanVar. Calling as_numeric - # will return a valid Binary Var (and issue the appropriate - # deprecation warning) - obj = obj.as_numeric() - elif check_if_numeric_type(obj): - return obj +def tuple_to_relational_expr(args): + if len(args) == 2: + return EqualityExpression(args) else: - # User assistance: provide a helpful exception when using an - # indexed object in an expression - if obj.is_component_type() and obj.is_indexed(): - raise TypeError( - "Argument for expression is an indexed numeric " - "value\nspecified without an index:\n\t%s\nIs this " - "value defined over an index that you did not specify?" % (obj.name,) - ) + return inequality(*args) - raise TypeError( - "Attempting to use a non-numeric type (%s) in a " - "numeric expression context." % (obj.__class__.__name__,) - ) +def _invalid_relational(op_type, op_str, a, b): + def no(*args): + return False -def _process_relational_arg(arg, n): - try: - _numeric = arg.is_numeric_type() - except AttributeError: - _numeric = False - if _numeric: - if arg.is_constant(): - arg = value(arg) - else: - _process_relational_arg.constant = False - else: - if arg.__class__ is InequalityExpression: - _process_relational_arg.relational += n - _process_relational_arg.constant = False - else: - arg = _process_nonnumeric_arg(arg) - if arg.__class__ not in native_numeric_types: - _process_relational_arg.constant = False - return arg - - -def _generate_relational_expression(etype, lhs, rhs): - # Note that the use of "global" state flags is fast, but not - # thread-safe. This should not be an issue because the GIL - # effectively prevents parallel model construction. If we ever need - # to revisit this design, we can pass in a "state" to - # _process_relational_arg() - at the cost of creating/destroying the - # state and an extra function argument. - _process_relational_arg.relational = 0 - _process_relational_arg.constant = True - if lhs.__class__ not in native_numeric_types: - lhs = _process_relational_arg(lhs, 1) - if rhs.__class__ not in native_numeric_types: - rhs = _process_relational_arg(rhs, 2) - - if _process_relational_arg.constant: - return _relational_op[etype][0](value(lhs), value(rhs)) - - if etype == _eq: - if _process_relational_arg.relational: - raise TypeError( - "Cannot create an EqualityExpression where one of the " - "sub-expressions is a relational expression:\n" - " %s\n {==}\n %s" % (lhs, rhs) + if getattr(a, 'is_expression_type', no)(ExpressionType.RELATIONAL): + if getattr(b, 'is_expression_type', no)(ExpressionType.RELATIONAL): + msg = ( + f"Cannot create an {op_type} where both " + "sub-expressions are relational expressions:" ) - return EqualityExpression((lhs, rhs)) - elif _process_relational_arg.relational: - if _process_relational_arg.relational == 1: - return RangedExpression( - lhs._args_ + (rhs,), (lhs._strict, _relational_op[etype][2]) - ) - elif _process_relational_arg.relational == 2: - return RangedExpression( - (lhs,) + rhs._args_, (_relational_op[etype][2], rhs._strict) - ) - else: # _process_relational_arg.relational == 3 - raise TypeError( - "Cannot create an InequalityExpression where both " - "sub-expressions are relational expressions:\n" - " %s\n {%s}\n %s" % (lhs, _relational_op[etype][1], rhs) + else: + msg = ( + f"Cannot create an {op_type} where one of the " + "sub-expressions is a relational expression:" ) + elif getattr(b, 'is_expression_type', no)(ExpressionType.RELATIONAL): + msg = ( + f"Cannot create an {op_type} where one of the " + "sub-expressions is a relational expression:" + ) + elif getattr(a, 'is_component_type', no)() and a.is_indexed(): + msg = ( + f"Argument for {op_type} is an indexed numeric " + f"value specified without an index:\n\t{a.name}\nIs this " + "value defined over an index that you did not specify?" + ) + elif getattr(b, 'is_component_type', no)() and b.is_indexed(): + msg = ( + f"Argument for {op_type} is an indexed numeric " + f"value specified without an index:\n\t{b.name}\nIs this " + "value defined over an index that you did not specify?" + ) else: - return InequalityExpression((lhs, rhs), _relational_op[etype][2]) + msg = "Attempting to use a non-numeric type in a numeric expression context:" + raise TypeError(msg + f"\n {a}\n {{{op_str}}}\n {b}") + + +def _eq_invalid(a, b): + _invalid_relational('EqualityExpression', '==', a, b) + + +def _eq_native(a, b): + return a == b + + +def _eq_expr(a, b): + return EqualityExpression((a, b)) + + +def _eq_param_param(a, b): + if a.is_constant(): + a = a.value + if b.is_constant(): + return a == b.value + elif b.is_constant(): + b = b.value + return EqualityExpression((a, b)) + + +def _eq_param_any(a, b): + if a.is_constant(): + return a.value == b + return EqualityExpression((a, b)) + + +def _eq_any_param(a, b): + if b.is_constant(): + return a == b.value + return EqualityExpression((a, b)) + + +def _register_new_eq_handler(a, b): + types = _categorize_relational_arg_types(a, b) + # Retrieve the appropriate handler, record it in the main + # _eq_dispatcher dict (so this method is not called a second time for + # these types) + _eq_dispatcher[a.__class__, b.__class__] = handler = _eq_type_handler_mapping[types] + # Call the appropriate handler + return handler(a, b) + + +_eq_dispatcher = collections.defaultdict(lambda: _register_new_eq_handler) +_eq_type_handler_mapping = _binary_op_dispatcher_type_mapping( + _eq_dispatcher, + { + (ARG_TYPE.NATIVE, ARG_TYPE.NATIVE): _eq_native, + (ARG_TYPE.NATIVE, ARG_TYPE.PARAM): _eq_any_param, + (ARG_TYPE.NATIVE, ARG_TYPE.OTHER): _eq_expr, + (ARG_TYPE.NATIVE, ARG_TYPE.INEQUALITY): _eq_invalid, + (ARG_TYPE.NATIVE, ARG_TYPE.INVALID_RELATIONAL): _eq_invalid, + (ARG_TYPE.PARAM, ARG_TYPE.NATIVE): _eq_param_any, + (ARG_TYPE.PARAM, ARG_TYPE.PARAM): _eq_param_param, + (ARG_TYPE.PARAM, ARG_TYPE.OTHER): _eq_param_any, + (ARG_TYPE.PARAM, ARG_TYPE.INEQUALITY): _eq_invalid, + (ARG_TYPE.PARAM, ARG_TYPE.INVALID_RELATIONAL): _eq_invalid, + (ARG_TYPE.OTHER, ARG_TYPE.NATIVE): _eq_expr, + (ARG_TYPE.OTHER, ARG_TYPE.PARAM): _eq_any_param, + (ARG_TYPE.OTHER, ARG_TYPE.OTHER): _eq_expr, + (ARG_TYPE.OTHER, ARG_TYPE.INEQUALITY): _eq_invalid, + (ARG_TYPE.OTHER, ARG_TYPE.INVALID_RELATIONAL): _eq_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.NATIVE): _eq_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.PARAM): _eq_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.OTHER): _eq_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.INEQUALITY): _eq_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.INVALID_RELATIONAL): _eq_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.NATIVE): _eq_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.PARAM): _eq_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.OTHER): _eq_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.INEQUALITY): _eq_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.INVALID_RELATIONAL): _eq_invalid, + }, + ARG_TYPE, +) -def tuple_to_relational_expr(args): - if len(args) == 2: - return EqualityExpression(args) - else: - return inequality(*args) +def _le_invalid(a, b): + _invalid_relational('InequalityExpression', '<=', a, b) + + +def _le_native(a, b): + return a <= b + + +def _le_expr(a, b): + return InequalityExpression((a, b), False) + + +def _le_expr_ineq(a, b): + return RangedExpression((a,) + b.args, (False, b._strict)) + + +def _le_param_ineq(a, b): + if a.is_constant(): + a = a.value + return RangedExpression((a,) + b.args, (False, b._strict)) + + +def _le_ineq_expr(a, b): + return RangedExpression(a.args + (b,), (a._strict, False)) + + +def _le_ineq_param(a, b): + if b.is_constant(): + b = b.value + return RangedExpression(a.args + (b,), (a._strict, False)) + + +def _le_param_param(a, b): + if a.is_constant(): + a = a.value + if b.is_constant(): + return a <= b.value + elif b.is_constant(): + b = b.value + return InequalityExpression((a, b), False) + + +def _le_param_any(a, b): + if a.is_constant(): + return a.value <= b + return InequalityExpression((a, b), False) + + +def _le_any_param(a, b): + if b.is_constant(): + return a <= b.value + return InequalityExpression((a, b), False) + + +def _register_new_le_handler(a, b): + types = _categorize_relational_arg_types(a, b) + # Retrieve the appropriate handler, record it in the main + # _le_dispatcher dict (so this method is not called a second time for + # these types) + _le_dispatcher[a.__class__, b.__class__] = handler = _le_type_handler_mapping[types] + # Call the appropriate handler + return handler(a, b) + + +_le_dispatcher = collections.defaultdict(lambda: _register_new_le_handler) +_le_type_handler_mapping = _binary_op_dispatcher_type_mapping( + _le_dispatcher, + { + (ARG_TYPE.NATIVE, ARG_TYPE.NATIVE): _le_native, + (ARG_TYPE.NATIVE, ARG_TYPE.PARAM): _le_any_param, + (ARG_TYPE.NATIVE, ARG_TYPE.OTHER): _le_expr, + (ARG_TYPE.NATIVE, ARG_TYPE.INEQUALITY): _le_expr_ineq, + (ARG_TYPE.NATIVE, ARG_TYPE.INVALID_RELATIONAL): _le_invalid, + (ARG_TYPE.PARAM, ARG_TYPE.NATIVE): _le_param_any, + (ARG_TYPE.PARAM, ARG_TYPE.PARAM): _le_param_param, + (ARG_TYPE.PARAM, ARG_TYPE.OTHER): _le_param_any, + (ARG_TYPE.PARAM, ARG_TYPE.INEQUALITY): _le_param_ineq, + (ARG_TYPE.PARAM, ARG_TYPE.INVALID_RELATIONAL): _le_invalid, + (ARG_TYPE.OTHER, ARG_TYPE.NATIVE): _le_expr, + (ARG_TYPE.OTHER, ARG_TYPE.PARAM): _le_any_param, + (ARG_TYPE.OTHER, ARG_TYPE.OTHER): _le_expr, + (ARG_TYPE.OTHER, ARG_TYPE.INEQUALITY): _le_expr_ineq, + (ARG_TYPE.OTHER, ARG_TYPE.INVALID_RELATIONAL): _le_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.NATIVE): _le_ineq_expr, + (ARG_TYPE.INEQUALITY, ARG_TYPE.PARAM): _le_ineq_param, + (ARG_TYPE.INEQUALITY, ARG_TYPE.OTHER): _le_ineq_expr, + (ARG_TYPE.INEQUALITY, ARG_TYPE.INEQUALITY): _le_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.INVALID_RELATIONAL): _le_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.NATIVE): _le_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.PARAM): _le_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.OTHER): _le_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.INEQUALITY): _le_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.INVALID_RELATIONAL): _le_invalid, + }, + ARG_TYPE, +) + + +def _lt_invalid(a, b): + _invalid_relational('InequalityExpression', '<', a, b) + + +def _lt_native(a, b): + return a < b + + +def _lt_expr(a, b): + return InequalityExpression((a, b), True) + + +def _lt_expr_ineq(a, b): + return RangedExpression((a,) + b.args, (True, b._strict)) + + +def _lt_param_ineq(a, b): + if a.is_constant(): + a = a.value + return RangedExpression((a,) + b.args, (True, b._strict)) + + +def _lt_ineq_expr(a, b): + return RangedExpression(a.args + (b,), (a._strict, True)) + + +def _lt_ineq_param(a, b): + if b.is_constant(): + b = b.value + return RangedExpression(a.args + (b,), (a._strict, True)) + + +def _lt_param_param(a, b): + if a.is_constant(): + a = a.value + if b.is_constant(): + return a < b.value + elif b.is_constant(): + b = b.value + return InequalityExpression((a, b), True) + + +def _lt_param_any(a, b): + if a.is_constant(): + return a.value < b + return InequalityExpression((a, b), True) + + +def _lt_any_param(a, b): + if b.is_constant(): + return a < b.value + return InequalityExpression((a, b), True) + + +def _register_new_lt_handler(a, b): + types = _categorize_relational_arg_types(a, b) + # Retrieve the appropriate handler, record it in the main + # _lt_dispatcher dict (so this method is not called a second time for + # these types) + _lt_dispatcher[a.__class__, b.__class__] = handler = _lt_type_handler_mapping[types] + # Call the appropriate handler + return handler(a, b) + + +_lt_dispatcher = collections.defaultdict(lambda: _register_new_lt_handler) +_lt_type_handler_mapping = _binary_op_dispatcher_type_mapping( + _lt_dispatcher, + { + (ARG_TYPE.NATIVE, ARG_TYPE.NATIVE): _lt_native, + (ARG_TYPE.NATIVE, ARG_TYPE.PARAM): _lt_any_param, + (ARG_TYPE.NATIVE, ARG_TYPE.OTHER): _lt_expr, + (ARG_TYPE.NATIVE, ARG_TYPE.INEQUALITY): _lt_expr_ineq, + (ARG_TYPE.NATIVE, ARG_TYPE.INVALID_RELATIONAL): _lt_invalid, + (ARG_TYPE.PARAM, ARG_TYPE.NATIVE): _lt_param_any, + (ARG_TYPE.PARAM, ARG_TYPE.PARAM): _lt_param_param, + (ARG_TYPE.PARAM, ARG_TYPE.OTHER): _lt_param_any, + (ARG_TYPE.PARAM, ARG_TYPE.INEQUALITY): _lt_param_ineq, + (ARG_TYPE.PARAM, ARG_TYPE.INVALID_RELATIONAL): _lt_invalid, + (ARG_TYPE.OTHER, ARG_TYPE.NATIVE): _lt_expr, + (ARG_TYPE.OTHER, ARG_TYPE.PARAM): _lt_any_param, + (ARG_TYPE.OTHER, ARG_TYPE.OTHER): _lt_expr, + (ARG_TYPE.OTHER, ARG_TYPE.INEQUALITY): _lt_expr_ineq, + (ARG_TYPE.OTHER, ARG_TYPE.INVALID_RELATIONAL): _lt_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.NATIVE): _lt_ineq_expr, + (ARG_TYPE.INEQUALITY, ARG_TYPE.PARAM): _lt_ineq_param, + (ARG_TYPE.INEQUALITY, ARG_TYPE.OTHER): _lt_ineq_expr, + (ARG_TYPE.INEQUALITY, ARG_TYPE.INEQUALITY): _lt_invalid, + (ARG_TYPE.INEQUALITY, ARG_TYPE.INVALID_RELATIONAL): _lt_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.NATIVE): _lt_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.PARAM): _lt_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.OTHER): _lt_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.INEQUALITY): _lt_invalid, + (ARG_TYPE.INVALID_RELATIONAL, ARG_TYPE.INVALID_RELATIONAL): _lt_invalid, + }, + ARG_TYPE, +) diff --git a/pyomo/core/expr/symbol_map.py b/pyomo/core/expr/symbol_map.py index ebcf9b2953e..e9d157b4b67 100644 --- a/pyomo/core/expr/symbol_map.py +++ b/pyomo/core/expr/symbol_map.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from weakref import ref as weakref_ref - class SymbolMap(object): """ @@ -27,11 +25,16 @@ class SymbolMap(object): Note: We should change the API to not use camelcase. - Attributes: - byObject (dict): maps (object id) to (string label) - bySymbol (dict): maps (string label) to (object) - alias (dict): maps (string label) to (object) - default_labeler: used to compute a string label from an object + Attributes + ---------- + byObject : dict + maps (object id) to (string label) + bySymbol : dict + maps (string label) to (object) + aliases : dict + maps (string label) to (object) + default_labeler: + used to compute a string label from an object """ def __init__(self, labeler=None): @@ -152,6 +155,7 @@ def getSymbol(self, obj, labeler=None, *args): "component '%s' (conflicting component: '%s')" % (symbol, self.bySymbol[symbol].name, obj.name) ) + self.bySymbol[symbol] = obj self.byObject[obj_id] = symbol return symbol diff --git a/pyomo/core/expr/sympy_tools.py b/pyomo/core/expr/sympy_tools.py index 48bd542be0f..cd92f9023ff 100644 --- a/pyomo/core/expr/sympy_tools.py +++ b/pyomo/core/expr/sympy_tools.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ import operator -import sys +from math import prod as _prod +import pyomo.core.expr as EXPR from pyomo.common import DeveloperError from pyomo.common.collections import ComponentMap from pyomo.common.dependencies import attempt_import from pyomo.common.errors import NondifferentiableError -import pyomo.core.expr as EXPR from pyomo.core.expr.numvalue import value, native_types # @@ -28,6 +28,25 @@ _functionMap = {} +def _nondifferentiable(x): + if type(x[1]) is tuple: + # sympy >= 1.3 returns tuples (var, order) + wrt = x[1][0] + else: + # early versions of sympy returned the bare var + wrt = x[1] + raise NondifferentiableError( + "The sub-expression '%s' is not differentiable with respect to %s" % (x[0], wrt) + ) + + +def _external_fcn(*x): + raise TypeError( + "Expressions containing external functions are not convertible to " + f"sympy expressions (found 'f{x}')" + ) + + def _configure_sympy(sympy, available): if not available: return @@ -113,37 +132,6 @@ def _configure_sympy(sympy, available): sympy, sympy_available = attempt_import('sympy', callback=_configure_sympy) -if sys.version_info[:2] < (3, 8): - - def _prod(args): - ans = 1 - for arg in args: - ans *= arg - return ans - -else: - from math import prod as _prod - - -def _nondifferentiable(x): - if type(x[1]) is tuple: - # sympy >= 1.3 returns tuples (var, order) - wrt = x[1][0] - else: - # early versions of sympy returned the bare var - wrt = x[1] - raise NondifferentiableError( - "The sub-expression '%s' is not differentiable with respect to %s" % (x[0], wrt) - ) - - -def _external_fcn(*x): - raise TypeError( - "Expressions containing external functions are not convertible to " - f"sympy expressions (found 'f{x}')" - ) - - class PyomoSympyBimap(object): def __init__(self): self.pyomo2sympy = ComponentMap() @@ -175,10 +163,11 @@ def sympyVars(self): class Pyomo2SympyVisitor(EXPR.StreamBasedExpressionVisitor): - def __init__(self, object_map): + def __init__(self, object_map, keep_mutable_parameters=False): sympy.Add # this ensures _configure_sympy gets run super(Pyomo2SympyVisitor, self).__init__() self.object_map = object_map + self.keep_mutable_parameters = keep_mutable_parameters def initializeWalker(self, expr): return self.beforeChild(None, expr, None) @@ -212,6 +201,8 @@ def beforeChild(self, node, child, child_idx): # # Everything else is a constant... # + if self.keep_mutable_parameters and child.is_parameter_type() and child.mutable: + return False, self.object_map.getSympySymbol(child) return False, value(child) @@ -245,13 +236,15 @@ def beforeChild(self, node, child, child_idx): return True, None -def sympyify_expression(expr): +def sympyify_expression(expr, keep_mutable_parameters=False): """Convert a Pyomo expression to a Sympy expression""" # # Create the visitor and call it. # object_map = PyomoSympyBimap() - visitor = Pyomo2SympyVisitor(object_map) + visitor = Pyomo2SympyVisitor( + object_map, keep_mutable_parameters=keep_mutable_parameters + ) return object_map, visitor.walk_expression(expr) diff --git a/pyomo/core/expr/taylor_series.py b/pyomo/core/expr/taylor_series.py index 2658dd36ff5..7a21a8e73c5 100644 --- a/pyomo/core/expr/taylor_series.py +++ b/pyomo/core/expr/taylor_series.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -43,7 +43,7 @@ def taylor_series_expansion( The method for differentiation. order: The order of the taylor series expansion If order is not 1, then symbolic differentiation must - be used (differentiation.Modes.reverse_sybolic or + be used (differentiation.Modes.reverse_symbolic or differentiation.Modes.sympy). Returns diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index f65a1f2b9b0..0cc4c78b5c8 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,15 +15,18 @@ import builtins from contextlib import nullcontext +from pyomo.common.collections import MutableMapping from pyomo.common.errors import TemplateExpressionError +from pyomo.common.gc_manager import PauseGC from pyomo.core.expr.base import ExpressionBase, ExpressionArgs_Mixin, NPV_Mixin from pyomo.core.expr.logical_expr import BooleanExpression from pyomo.core.expr.numeric_expr import ( + ARG_TYPE, NumericExpression, - SumExpression, Numeric_NPV_Mixin, + SumExpression, + mutable_expression, register_arg_type, - ARG_TYPE, _balanced_parens, ) from pyomo.core.expr.numvalue import ( @@ -38,6 +41,8 @@ from pyomo.core.expr.visitor import ( ExpressionReplacementVisitor, StreamBasedExpressionVisitor, + expression_to_string, + _ToStringVisitor, ) logger = logging.getLogger(__name__) @@ -59,7 +64,7 @@ def __new__(cls, args=()): if cls is not GetItemExpression: return super().__new__(cls) npv_args = not any( - hasattr(arg, 'is_potentially_variable') and arg.is_potentially_variable() + hasattr(arg, "is_potentially_variable") and arg.is_potentially_variable() for arg in args ) try: @@ -87,7 +92,7 @@ def __new__(cls, args=()): return super().__new__(Structural_GetItemExpression) def __getattr__(self, attr): - if attr.startswith('__') and attr.endswith('__'): + if attr.startswith("__") and attr.endswith("__"): raise AttributeError() return GetAttrExpression((self, attr)) @@ -107,27 +112,19 @@ def _is_fixed(self, values): if not all(values[1:]): return False _true = lambda: True - return all(getattr(x, 'is_fixed', _true)() for x in values[0].values()) + return all(getattr(x, "is_fixed", _true)() for x in values[0].values()) def _to_string(self, values, verbose, smap): - values = tuple(_[1:-1] if _[0] == '(' and _[-1] == ')' else _ for _ in values) + values = tuple(_[1:-1] if _[0] == "(" and _[-1] == ")" else _ for _ in values) if verbose: - return "getitem(%s, %s)" % (values[0], ', '.join(values[1:])) - return "%s[%s]" % (values[0], ','.join(values[1:])) + return "getitem(%s, %s)" % (values[0], ", ".join(values[1:])) + return "%s[%s]" % (values[0], ",".join(values[1:])) def _resolve_template(self, args): - return args[0].__getitem__(tuple(args[1:])) + return args[0].__getitem__(args[1:]) def _apply_operation(self, result): - args = tuple( - ( - arg - if arg.__class__ in native_types or not arg.is_numeric_type() - else value(arg) - ) - for arg in result[1:] - ) - return result[0].__getitem__(tuple(result[1:])) + return result[0].__getitem__(result[1:]) class Numeric_GetItemExpression(GetItemExpression, NumericExpression): @@ -142,7 +139,7 @@ def _compute_polynomial_degree(self, result): ans = 0 for x in result[0].values(): if x.__class__ in nonpyomo_leaf_types or not hasattr( - x, 'polynomial_degree' + x, "polynomial_degree" ): continue tmp = x.polynomial_degree() @@ -211,7 +208,7 @@ def __new__(cls, args=()): return self def __getattr__(self, attr): - if attr.startswith('__') and attr.endswith('__'): + if attr.startswith("__") and attr.endswith("__"): raise AttributeError() return GetAttrExpression((self, attr)) @@ -232,11 +229,16 @@ def __call__(self, *args, **kwargs): # # TODO: deprecate (then remove) evaluating expressions by # "calling" them. + # + # [ESJ 3/25/25]: Note that since this always calls the ExpressionBase + # implementation of __call__ if 'exception' is specified, we need not + # check the type of the exception arg here--it will get checked in the + # base class. try: if not args: if not kwargs: return super().__call__() - elif len(kwargs) == 1 and 'exception' in kwargs: + elif len(kwargs) == 1 and "exception" in kwargs: return super().__call__(**kwargs) elif ( not kwargs and len(args) == 1 and (args[0] is True or args[0] is False) @@ -252,14 +254,14 @@ def __call__(self, *args, **kwargs): return CallExpression((self,) + args, kwargs) def getname(self, *args, **kwds): - return 'getattr' + return "getattr" def nargs(self): return 2 def _apply_operation(self, result): - assert len(result) == 2 - return getattr(result[0], result[1]) + obj, attr = result + return getattr(obj, attr) def _to_string(self, values, verbose, smap): assert len(values) == 2 @@ -268,12 +270,12 @@ def _to_string(self, values, verbose, smap): # Note that the string argument for getattr comes quoted, so we # need to remove the quotes. attr = values[1] - if attr[0] in '\"\'' and attr[0] == attr[-1]: + if attr[0] in "\"'" and attr[0] == attr[-1]: attr = attr[1:-1] return "%s.%s" % (values[0], attr) def _resolve_template(self, args): - return getattr(*tuple(args)) + return getattr(*args) class Numeric_GetAttrExpression(GetAttrExpression, NumericExpression): @@ -310,7 +312,7 @@ class CallExpression(NumericExpression): Expression to call :func:`__call__` on the base object. """ - __slots__ = ('_kwds',) + __slots__ = ("_kwds",) PRECEDENCE = None def __init__(self, args, kwargs): @@ -321,7 +323,7 @@ def nargs(self): return len(self._args_) def __getattr__(self, attr): - if attr.startswith('__') and attr.endswith('__'): + if attr.startswith("__") and attr.endswith("__"): raise AttributeError() return GetAttrExpression((self, attr)) @@ -335,7 +337,7 @@ def __len__(self): return len(value(self)) def getname(self, *args, **kwds): - return 'call' + return "call" def _compute_polynomial_degree(self, result): return None @@ -346,12 +348,12 @@ def _apply_operation(self, result): def _to_string(self, values, verbose, smap): na = len(self._args_) - len(self._kwds) - args = ', '.join(values[1:na]) + args = ", ".join(values[1:na]) if self._kwds: if na > 1: - args += ', ' - args += ', '.join( - f'{key}={val}' for key, val in zip(self._kwds, values[na:]) + args += ", " + args += ", ".join( + f"{key}={val}" for key, val in zip(self._kwds, values[na:]) ) if verbose: return f"call({values[0]}, {args})" @@ -443,7 +445,7 @@ class TemplateSumExpression(NumericExpression): Expression to represent an unexpanded sum over one or more sets. """ - __slots__ = ('_iters', '_local_args_') + __slots__ = ("_iters", "_local_args_") PRECEDENCE = 1 def __init__(self, args, _iters): @@ -471,6 +473,15 @@ def _args_(self): def _args_(self, args): self._local_args_ = args + def template_args(self): + ans = list(self._local_args_) + for itergroup in self._iters: + ans.append(itergroup[0]._set) + return tuple(ans) + + def template_iters(self): + return self._iters + def create_node_with_local_data(self, args): return self.__class__(args, self._iters) @@ -497,31 +508,51 @@ def _compute_polynomial_degree(self, result): def _apply_operation(self, result): return sum(result) - def _to_string(self, values, verbose, smap): - ans = '' - val = values[0] - if val[0] == '(' and val[-1] == ')' and _balanced_parens(val[1:-1]): + def to_string(self, verbose=None, smap=None): + ans = "" + assert len(self._local_args_) == 1 + val = expression_to_string(self._local_args_[0], verbose=verbose, smap=smap) + if val[0] == "(" and val[-1] == ")" and _balanced_parens(val[1:-1]): val = val[1:-1] iterStrGenerator = ( ( - ', '.join(str(i) for i in iterGroup), + ", ".join( + (smap.getSymbol(i) if smap is not None else str(i)) + for i in iterGroup + ), ( - iterGroup[0]._set.to_string(verbose=verbose) - if hasattr(iterGroup[0]._set, 'to_string') - else str(iterGroup[0]._set) + iterGroup[0]._set.to_string(verbose=verbose, smap=smap) + if hasattr(iterGroup[0]._set, "to_string") + else ( + smap.getSymbol(iterGroup[0]._set) + if smap is not None + else str(iterGroup[0]._set) + ) ), ) for iterGroup in self._iters ) if verbose: - iterStr = ', '.join('iter(%s, %s)' % x for x in iterStrGenerator) - return 'templatesum(%s, %s)' % (val, iterStr) + iterStr = ", ".join("iter(%s, %s)" % x for x in iterStrGenerator) + return "templatesum(%s, %s)" % (val, iterStr) else: - iterStr = ' '.join('for %s in %s' % x for x in iterStrGenerator) - return 'SUM(%s %s)' % (val, iterStr) + iterStr = " ".join("for %s in %s" % x for x in iterStrGenerator) + return "SUM(%s %s)" % (val, iterStr) def _resolve_template(self, args): - return SumExpression(args) + with mutable_expression() as e: + for arg in args: + e += arg + if e.nargs() > 1: + return e + elif not e.nargs(): + return 0 + else: + return e.arg(0) + + +# FIXME: This is a hack to get certain complex cases to print without error +_ToStringVisitor._leaf_node_types.add(TemplateSumExpression) class IndexTemplate(NumericValue): @@ -539,7 +570,7 @@ class IndexTemplate(NumericValue): _set: the Set from which this IndexTemplate can take values """ - __slots__ = ('_set', '_value', '_index', '_id', '_group', '_lock') + __slots__ = ("_set", "_value", "_index", "_id", "_group", "_lock") def __init__(self, _set, index=0, _id=None, _group=None): self._set = _set @@ -558,7 +589,7 @@ def __deepcopy__(self, memo): # a proper Component: that way it could leverage the normal # logic of using the parent_block scope to dictate the behavior # of deepcopy. - if '__block_scope__' in memo: + if "__block_scope__" in memo: memo[id(self)] = self return self # @@ -621,7 +652,7 @@ def set_value(self, values=_NotSpecified, lock=None): # is not present. if lock is not self._lock: raise RuntimeError( - "The TemplateIndex %s is currently locked by %s and " + "The IndexTemplate %s is currently locked by %s and " "cannot be set through lock %s" % (self, self._lock, lock) ) if values is _NotSpecified: @@ -652,32 +683,20 @@ def unlock(self, lock): register_arg_type(IndexTemplate, ARG_TYPE.NPV) -def resolve_template(expr): - """Resolve a template into a concrete expression - - This takes a template expression and returns the concrete equivalent - by substituting the current values of all IndexTemplate objects and - resolving (evaluating and removing) all GetItemExpression, - GetAttrExpression, and TemplateSumExpression expression nodes. - - """ - wildcards = [] - wildcard_groups = {} - level = -1 - - def beforeChild(node, child, child_idx): +class _TemplateResolver(StreamBasedExpressionVisitor): + def beforeChild(self, node, child, child_idx): # Efficiency: do not descend into leaf nodes. if type(child) in native_types: return False, child elif not child.is_expression_type(): - if hasattr(child, '_resolve_template'): + if hasattr(child, "_resolve_template"): return False, child._resolve_template(()) return False, child else: return True, None - def exitNode(node, args): - if hasattr(node, '_resolve_template'): + def exitNode(self, node, args): + if hasattr(node, "_resolve_template"): return node._resolve_template(args) if len(args) == node.nargs() and all(a is b for a, b in zip(node.args, args)): return node @@ -686,16 +705,29 @@ def exitNode(node, args): else: return node.create_node_with_local_data(args) - walker = StreamBasedExpressionVisitor( - initializeWalker=lambda x: beforeChild(None, x, None), - beforeChild=beforeChild, - exitNode=exitNode, - ) - return walker.walk_expression(expr) + def initializeWalker(self, expr): + return self.beforeChild(None, expr, None) + + +def resolve_template(expr): + """Resolve a template into a concrete expression + + This takes a template expression and returns the concrete equivalent + by substituting the current values of all IndexTemplate objects and + resolving (evaluating and removing) all GetItemExpression, + GetAttrExpression, and TemplateSumExpression expression nodes. + + """ + if resolve_template.visitor is None: + resolve_template.visitor = _TemplateResolver() + return resolve_template.visitor.walk_expression(expr) + + +resolve_template.visitor = None class _wildcard_info(object): - __slots__ = ('iter', 'source', 'value', 'original_value', 'objects') + __slots__ = ("iter", "source", "value", "original_value", "objects") def __init__(self, src, obj): self.source = src @@ -744,11 +776,12 @@ def _reduce_template_to_component(expr): level = -1 def beforeChild(node, child, child_idx): + logger.debug(f"({node}, {child}, {type(child)}, {child_idx})") # Efficiency: do not descend into leaf nodes. if type(child) in native_types: return False, child elif not child.is_expression_type(): - if hasattr(child, '_resolve_template'): + if hasattr(child, "_resolve_template"): try: ans = child._resolve_template(()) except TemplateExpressionError: @@ -785,7 +818,7 @@ def beforeChild(node, child, child_idx): return True, None def exitNode(node, args): - if hasattr(node, '_resolve_template'): + if hasattr(node, "_resolve_template"): return node._resolve_template(args) if len(args) == node.nargs() and all(a is b for a, b in zip(node.args, args)): return node @@ -842,7 +875,7 @@ class ReplaceTemplateExpression(ExpressionReplacementVisitor): } def __init__(self, substituter, *args, **kwargs): - kwargs.setdefault('remove_named_expressions', True) + kwargs.setdefault("remove_named_expressions", True) super().__init__(**kwargs) self.substituter = substituter self.substituter_args = args @@ -854,19 +887,28 @@ def beforeChild(self, node, child, child_idx): def substitute_template_expression(expr, substituter, *args, **kwargs): - """Substitute IndexTemplates in an expression tree. + r"""Substitute IndexTemplates in an expression tree. This is a general utility function for walking the expression tree and substituting all occurrences of IndexTemplate and GetItemExpression nodes. - Args: - substituter: method taking (expression, *args) and returning - the new object - *args: these are passed directly to the substituter + Parameters + ---------- + expr : NumericExpression + the source template expression - Returns: + substituter: Callable + method taking ``(expression, *args)`` and returning the new object + + \*args: + positional arguments passed directly to the substituter + + Returns + ------- + NumericExpression : a new expression tree with all substitutions done + """ visitor = ReplaceTemplateExpression(substituter, *args, **kwargs) return visitor.walk_expression(expr) @@ -924,7 +966,7 @@ def __eq__(self, other): return False def __str__(self): - return "%s[%s]" % (self._base.name, ','.join(str(x) for x in self._args)) + return "%s[%s]" % (self._base.name, ",".join(str(x) for x in self._args)) def substitute_getitem_with_param(expr, _map): @@ -943,7 +985,7 @@ def substitute_getitem_with_param(expr, _map): if _id not in _map: _map[_id] = pyomo.core.base.param.Param(mutable=True) _map[_id].construct() - _map[_id]._name = "%s[%s]" % (_id.base.name, ','.join(str(x) for x in _id.args)) + _map[_id]._name = "%s[%s]" % (_id.base.name, ",".join(str(x) for x in _id.args)) return _map[_id] @@ -1047,7 +1089,7 @@ def sum_template(self, generator): class _template_iter_manager(object): class _iter_wrapper(object): - __slots__ = ('_class', '_iter', '_old_iter') + __slots__ = ("_class", "_iter", "_old_iter") def __init__(self, cls, context): def _iter_fcn(obj): @@ -1064,7 +1106,7 @@ def release(self): self._class.__iter__ = self._old_iter class _pause_template_iter_manager(object): - __slots__ = ('iter_manager',) + __slots__ = ("iter_manager",) def __init__(self, iter_manager): self.iter_manager = iter_manager diff --git a/pyomo/core/expr/visitor.py b/pyomo/core/expr/visitor.py index 6a9b7955281..20d15c9c48f 100644 --- a/pyomo/core/expr/visitor.py +++ b/pyomo/core/expr/visitor.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,7 +16,7 @@ from copy import deepcopy from collections import deque -logger = logging.getLogger('pyomo.core') +logger = logging.getLogger("pyomo.core") from pyomo.common.deprecation import deprecated, deprecation_warning from pyomo.common.errors import DeveloperError, TemplateExpressionError @@ -192,13 +192,13 @@ class StreamBasedExpressionVisitor(object): # that supports beforeChild, enterNode, and exitNode, but NOT # afterChild or acceptChildResult. client_methods = { - 'enterNode': 'e', - 'exitNode': 'x', - 'beforeChild': 'b', - 'afterChild': 'a', - 'acceptChildResult': 'c', - 'initializeWalker': '', - 'finalizeResult': '', + "enterNode": "e", + "exitNode": "x", + "beforeChild": "b", + "afterChild": "a", + "acceptChildResult": "c", + "initializeWalker": "", + "finalizeResult": "", } def __init__(self, **kwds): @@ -216,7 +216,7 @@ def __init__(self, **kwds): raise RuntimeError("Unrecognized keyword arguments: %s" % (kwds,)) # Handle deprecated APIs - _fcns = (('beforeChild', 2), ('acceptChildResult', 3), ('afterChild', 2)) + _fcns = (("beforeChild", 2), ("acceptChildResult", 3), ("afterChild", 2)) for name, nargs in _fcns: fcn = getattr(self, name) if fcn is None: @@ -228,7 +228,7 @@ def __init__(self, **kwds): "Note that the API for the StreamBasedExpressionVisitor " "has changed to include the child index for the %s() " "method. Please update your walker callbacks." % (name,), - version='5.7.0', + version="5.7.0", ) def wrap(fcn, nargs): @@ -244,9 +244,9 @@ def wrapper(*args): # Set up the custom recursive node handler function (customized # for the specific set of callbacks that are defined for this # class instance). - recursive_node_handler = '_process_node_' + ''.join( + recursive_node_handler = "_process_node_" + "".join( sorted( - '' if getattr(self, f[0]) is None else f[1] + "" if getattr(self, f[0]) is None else f[1] for f in self.client_methods.items() ) ) @@ -276,6 +276,7 @@ def walk_expression(self, expr): result = self._process_node(root, RECURSION_LIMIT) _nonrecursive = None except RevertToNonrecursive: + logger.debug("RevertToNonrecursive") ptr = (None,) + self.recursion_stack.pop() while self.recursion_stack: ptr = (ptr,) + self.recursion_stack.pop() @@ -283,8 +284,8 @@ def walk_expression(self, expr): _nonrecursive = self._nonrecursive_walker_loop, ptr except RecursionError: logger.warning( - 'Unexpected RecursionError walking an expression tree.', - extra={'id': 'W1003'}, + "Unexpected RecursionError walking an expression tree.", + extra={"id": "W1003"}, ) _nonrecursive = self.walk_expression_nonrecursive, expr @@ -292,8 +293,11 @@ def walk_expression(self, expr): return _nonrecursive[0](_nonrecursive[1]) if self.finalizeResult is not None: - return self.finalizeResult(result) + sr = self.finalizeResult(result) + logger.debug(f"RETURNING: self.finalizeResult = {sr}") + return sr else: + logger.debug(f"RETURNING result: {type(result)}, {result}") return result def _compute_actual_recursion_limit(self): @@ -335,7 +339,7 @@ def _process_node_general(self, node, recursion_limit): # Because we do not require the args to be a context manager, we # will mock up the "with args" using a try-finally. - context_manager = hasattr(args, '__enter__') + context_manager = hasattr(args, "__enter__") if context_manager: args.__enter__() @@ -389,12 +393,17 @@ def _process_node_bex(self, node, recursion_limit): also the definition of the client_methods dict). """ + logger.debug( + f"{self.__class__.__name__}._process_node_bex({node}, level={RECURSION_LIMIT-recursion_limit})" + ) if not recursion_limit: recursion_limit = self._compute_actual_recursion_limit() else: recursion_limit -= 1 + logger.debug(f"self.enterNode = {self.enterNode}") tmp = self.enterNode(node) + logger.debug(f" args,data={tmp}") if tmp is None: args = data = None else: @@ -404,10 +413,11 @@ def _process_node_bex(self, node, recursion_limit): args = () else: args = node.args + logger.debug(f" args={args}\n") # Because we do not require the args to be a context manager, we # will mock up the "with args" using a try-finally. - context_manager = hasattr(args, '__enter__') + context_manager = hasattr(args, "__enter__") if context_manager: args.__enter__() @@ -419,7 +429,11 @@ def _process_node_bex(self, node, recursion_limit): arg_iter = iter(args) for child in arg_iter: child_idx += 1 + logger.debug( + f" child [{child_idx}: {node}] {child.__class__.__name__} {child}" + ) tmp = self.beforeChild(node, child, child_idx) + logger.debug(f" child descend, child_result={tmp}") if tmp is None: descend = True else: @@ -439,6 +453,7 @@ def _process_node_bex(self, node, recursion_limit): # We are done with this node. Call exitNode to compute # any result + logger.debug(f"self.exitNode={self.exitNode}") return self.exitNode(node, data) def _process_node_bx(self, node, recursion_limit): @@ -489,10 +504,10 @@ def _process_node_bx(self, node, recursion_limit): return self.exitNode(node, data) def _recursive_frame_to_nonrecursive_stack(self, local): - child_idx = local['child_idx'] + child_idx = local["child_idx"] _arg_list = [None] * child_idx - _arg_list.append(local['child']) - _arg_list.extend(local['arg_iter']) + _arg_list.append(local["child"]) + _arg_list.extend(local["arg_iter"]) if not self.recursion_stack: # For the deepest stack frame, the recursion limit hit # as we started to enter the child. As we haven't @@ -500,7 +515,7 @@ def _recursive_frame_to_nonrecursive_stack(self, local): # child_idx so that it is revisited child_idx -= 1 self.recursion_stack.append( - (local['node'], _arg_list, len(_arg_list) - 1, local['data'], child_idx) + (local["node"], _arg_list, len(_arg_list) - 1, local["data"], child_idx) ) def walk_expression_nonrecursive(self, expr): @@ -546,7 +561,7 @@ def walk_expression_nonrecursive(self, expr): args = () else: args = expr.args - if hasattr(args, '__enter__'): + if hasattr(args, "__enter__"): args.__enter__() node = expr # Note that because we increment child_idx just before fetching @@ -629,7 +644,7 @@ def _nonrecursive_walker_loop(self, ptr): args = () else: args = child.args - if hasattr(args, '__enter__'): + if hasattr(args, "__enter__"): args.__enter__() node = child child_idx = -1 @@ -638,7 +653,7 @@ def _nonrecursive_walker_loop(self, ptr): else: # child_idx == ptr[3]: # We are done with this node. Call exitNode to compute # any result - if hasattr(ptr[2], '__exit__'): + if hasattr(ptr[2], "__exit__"): ptr[2].__exit__(None, None, None) if self.exitNode is not None: node_result = self.exitNode(node, data) @@ -674,11 +689,16 @@ def _nonrecursive_walker_loop(self, ptr): finally: while ptr is not None: - if hasattr(ptr[2], '__exit__'): + if hasattr(ptr[2], "__exit__"): ptr[2].__exit__(None, None, None) ptr = ptr[0] +@deprecated( + "The SimpleExpressionVisitor is deprecated. " + "Please use the StreamBasedExpressionVisitor instead.", + version="6.9.0", +) class SimpleExpressionVisitor(object): """ Note: @@ -736,6 +756,14 @@ def xbfs(self, node): The return value is determined by the :func:`finalize` function, which may be defined by the user. Defaults to :const:`None`. """ + if ( + node.__class__ in nonpyomo_leaf_types + or not node.is_expression_type() + or node.nargs() == 0 + ): + self.visit(node) + return self.finalize() + dq = deque([node]) while dq: current = dq.popleft() @@ -894,7 +922,7 @@ def dfs_postorder_stack(self, node): if flag: return self.finalize(value) # _stack = [ (node, self.children(node), 0, len(self.children(node)), [])] - _stack = [(node, node._args_, 0, node.nargs(), [])] + _stack = [(node, node.args, 0, node.nargs(), [])] # # Iterate until the stack is empty # @@ -926,7 +954,7 @@ def dfs_postorder_stack(self, node): _stack.append((_obj, _argList, _idx, _len, _result)) _obj = _sub # _argList = self.children(_sub) - _argList = _sub._args_ + _argList = _sub.args _idx = 0 _len = _sub.nargs() _result = [] @@ -936,7 +964,8 @@ def dfs_postorder_stack(self, node): ans = self.visit(_obj, _result) if _stack: # - # "return" the recursion by putting the return value on the end of the results stack + # "return" the recursion by putting the return value on + # the end of the results stack # _stack[-1][-1].append(ans) else: @@ -991,23 +1020,23 @@ def __init__( self.rm_named_expr = remove_named_expressions kwds = {} - if hasattr(self, 'visiting_potential_leaf'): + if hasattr(self, "visiting_potential_leaf"): deprecation_warning( "ExpressionReplacementVisitor: this walker has been ported " "to derive from StreamBasedExpressionVisitor. " "visiting_potential_leaf() has been replaced by beforeChild()" "(note to implementers: the sense of the bool return value " "has been inverted).", - version='6.2', + version="6.2", ) def beforeChild(node, child, child_idx): is_leaf, ans = self.visiting_potential_leaf(child) return not is_leaf, ans - kwds['beforeChild'] = beforeChild + kwds["beforeChild"] = beforeChild - if hasattr(self, 'visit'): + if hasattr(self, "visit"): raise DeveloperError( "ExpressionReplacementVisitor: this walker has been ported " "to derive from StreamBasedExpressionVisitor. " @@ -1069,7 +1098,7 @@ def exitNode(self, node, data): "ExpressionReplacementVisitor: this walker has been ported " "to derive from StreamBasedExpressionVisitor. " "dfs_postorder_stack() has been replaced with walk_expression()", - version='6.2', + version="6.2", ) def dfs_postorder_stack(self, expr): return self.walk_expression(expr) @@ -1138,7 +1167,7 @@ def clone_expression(expr, substitute=None): """ common.clone_counter._count += 1 - memo = {'__block_scope__': {id(None): False}} + memo = {"__block_scope__": {id(None): False}} if substitute: expr = replace_expressions(expr, substitute) return deepcopy(expr, memo) @@ -1244,9 +1273,13 @@ def visiting_potential_leaf(self, node): # opportunity to map the error to a NonConstant / Fixed # expression error if not node.is_fixed(): - raise NonConstantExpressionError() + raise NonConstantExpressionError( + f"{node} ({type(node).__name__}) is not fixed" + ) if not node.is_constant(): - raise FixedExpressionError() + raise FixedExpressionError( + f"{node} ({type(node).__name__}) is not constant" + ) raise if not node.is_fixed(): @@ -1330,20 +1363,25 @@ def evaluate_expression(exp, exception=True, constant=False): # ===================================================== -class _ComponentVisitor(SimpleExpressionVisitor): +class _ComponentVisitor(StreamBasedExpressionVisitor): def __init__(self, types): - self.seen = set() - if types.__class__ is set: - self.types = types - else: - self.types = set(types) + super().__init__() + if types.__class__ is not set: + types = set(types) + self._types = types - def visit(self, node): - if node.__class__ in self.types: - if id(node) in self.seen: - return - self.seen.add(id(node)) - return node + def initializeWalker(self, expr): + self._objs = [] + self._seen = set() + return True, None + + def finalizeResult(self, result): + return self._objs + + def exitNode(self, node, data): + if node.__class__ in self._types and id(node) not in self._seen: + self._seen.add(id(node)) + self._objs.append(node) def identify_components(expr, component_types): @@ -1365,7 +1403,7 @@ def identify_components(expr, component_types): # in the expression. # visitor = _ComponentVisitor(component_types) - yield from visitor.xbfs_yield_leaves(expr) + yield from visitor.walk_expression(expr) # ===================================================== @@ -1373,22 +1411,100 @@ def identify_components(expr, component_types): # ===================================================== -class _VariableVisitor(SimpleExpressionVisitor): - def __init__(self): - self.seen = set() +class IdentifyVariableVisitor(StreamBasedExpressionVisitor): + def __init__(self, include_fixed=False, named_expression_cache=None): + """Visitor that collects all unique variables participating in an + expression - def visit(self, node): - if node.__class__ in nonpyomo_leaf_types: - return + Args: + include_fixed (bool): Whether to include fixed variables + named_expression_cache (optional, dict): Dict mapping ids of named + expressions to a tuple of the list of all variables and the + set of all variable ids contained in the named expression. - if node.is_variable_type(): - if id(node) in self.seen: - return - self.seen.add(id(node)) - return node + """ + super().__init__() + self._include_fixed = include_fixed + self._cache = named_expression_cache + # Stack of named expressions. This holds the tuple + # (eid, _seen, _exprs) + # where eid is the id() of the subexpression we are currently + # processing, and _seen and _exprs are from the parent context. + self._expr_stack = [] + # The following attributes will be added by initializeWalker: + # self._seen: dict(eid: obj) + # self._exprs: list of (e, e.expr) for any (nested) named expressions + + def initializeWalker(self, expr): + assert not self._expr_stack + self._seen = {} + self._exprs = None + if not self.beforeChild(None, expr, 0)[0]: + return False, self.finalizeResult(None) + return True, expr + def beforeChild(self, parent, child, index): + if child.__class__ in native_types: + return False, None + elif child.is_expression_type(): + if child.is_named_expression_type(): + return self._process_named_expr(child) + else: + return True, None + elif child.is_variable_type() and (self._include_fixed or not child.fixed): + if id(child) not in self._seen: + self._seen[id(child)] = child + return False, None -def identify_variables(expr, include_fixed=True): + def exitNode(self, node, data): + if node.is_named_expression_type() and self._cache is not None: + # If we are returning from a named expression, we must make + # sure that we properly restore the "outer" context and then + # merge the objects from the named expression we just exited + # into the list for the parent expression context. + _seen = self._seen + _exprs = self._exprs + eid, self._seen, self._exprs = self._expr_stack.pop() + assert eid == id(node) + self._merge_obj_lists(_seen, _exprs) + + def finalizeResult(self, result): + assert not self._expr_stack + return self._seen.values() + + def _merge_obj_lists(self, _seen, _exprs): + self._seen.update(_seen) + if self._exprs is not None: + self._exprs.update(_exprs) + + def _process_named_expr(self, child): + if self._cache is None: + return True, None + eid = id(child) + if eid in self._cache: + _seen, _exprs = self._cache[eid] + if all(c.expr is e for c, e in _exprs.values()): + # We have already encountered this named expression. We just add + # the cached objects to our list and don't descend. + # + # Note that a cache hit requires not only that we have seen + # this expression before, but also that none of the named + # expressions have changed. If they have, then the cache + # miss will fall over to the else clause below and descend + # into the expression, (implicitly) rebuilding the cache. + self._merge_obj_lists(_seen, _exprs) + return False, None + # If we are descending into a new named expression or a cached + # named expression where the cache is now invalid. Initialize a + # cache to store the expression's local objects. + self._expr_stack.append((eid, self._seen, self._exprs)) + self._seen = {} + self._exprs = {eid: (child, child.expr)} + self._cache[eid] = (self._seen, self._exprs) + return True, None + + +def identify_variables(expr, include_fixed=True, named_expression_cache=None): """ A generator that yields a sequence of variables in an expression tree. @@ -1402,22 +1518,17 @@ def identify_variables(expr, include_fixed=True): Yields: Each variable that is found. """ - visitor = _VariableVisitor() - if include_fixed: - for v in visitor.xbfs_yield_leaves(expr): - if isinstance(v, tuple): - yield from v - else: - yield v - else: - for v in visitor.xbfs_yield_leaves(expr): - if isinstance(v, tuple): - for v_i in v: - if not v_i.is_fixed(): - yield v_i - else: - if not v.is_fixed(): - yield v + v = identify_variables.visitor + save = v._include_fixed, v._cache + try: + v._include_fixed = include_fixed + v._cache = named_expression_cache + yield from v.walk_expression(expr) + finally: + v._include_fixed, v._cache = save + + +identify_variables.visitor = IdentifyVariableVisitor() # ===================================================== @@ -1425,20 +1536,27 @@ def identify_variables(expr, include_fixed=True): # ===================================================== -class _MutableParamVisitor(SimpleExpressionVisitor): +class IdentifyMutableParamVisitor(IdentifyVariableVisitor): def __init__(self): - self.seen = set() + # Hide the IdentifyVariableVisitor API (not relevant here) + super().__init__() - def visit(self, node): - if node.__class__ in nonpyomo_leaf_types: - return - - # TODO: Confirm that this has the right semantics - if not node.is_variable_type() and node.is_fixed() and not node.is_constant(): - if id(node) in self.seen: - return - self.seen.add(id(node)) - return node + def beforeChild(self, parent, child, index): + if child.__class__ in native_types: + return False, None + elif child.is_expression_type(): + if child.is_named_expression_type(): + return self._process_named_expr(child) + else: + return True, None + if ( + not child.is_variable_type() + and child.is_fixed() + and not child.is_constant() + ): + if id(child) not in self._seen: + self._seen[id(child)] = child + return False, None def identify_mutable_parameters(expr): @@ -1452,10 +1570,11 @@ def identify_mutable_parameters(expr): Yields: Each mutable parameter that is found. """ - visitor = _MutableParamVisitor() - yield from visitor.xbfs_yield_leaves(expr) + yield from identify_mutable_parameters.visitor.walk_expression(expr) +identify_mutable_parameters.visitor = IdentifyMutableParamVisitor() + # ===================================================== # polynomial_degree # ===================================================== @@ -1556,6 +1675,7 @@ def _expression_is_fixed(node): class _ToStringVisitor(ExpressionValueVisitor): _expression_handlers = None + _leaf_node_types = set() def __init__(self, verbose, smap): super(_ToStringVisitor, self).__init__() @@ -1564,35 +1684,33 @@ def __init__(self, verbose, smap): def visit(self, node, values): """Visit nodes that have been expanded""" - for i, val in enumerate(values): - arg = node._args_[i] - - if arg is None: - values[i] = 'Undefined' - elif arg.__class__ in native_numeric_types: - pass - elif arg.__class__ in nonpyomo_leaf_types: - values[i] = f"'{val}'" - else: - parens = False - if ( - not self.verbose - and arg.is_expression_type() - and node.PRECEDENCE is not None - ): - if arg.PRECEDENCE is None: - pass - elif node.PRECEDENCE < arg.PRECEDENCE: + node_prec = node.PRECEDENCE + if node_prec is not None and not self.verbose: + for i, (val, arg) in enumerate(zip(values, node.args)): + arg_prec = getattr(arg, "PRECEDENCE", None) + if arg_prec is None: + # This embedded constant (4) is evil, but to actually + # import the NegationExpression.PRECEDENCE from + # numeric_expr would create a circular dependency. + # + # FIXME: rework the dependencies between + # numeric_expr and visitor + if val[0] == "-" and node_prec < 4: + values[i] = f"({val})" + else: + if node_prec < arg_prec: parens = True - elif node.PRECEDENCE == arg.PRECEDENCE: + elif node_prec == arg_prec: if i == 0: parens = node.ASSOCIATIVITY != LEFT_TO_RIGHT - elif i == len(node._args_) - 1: + elif i == node.nargs() - 1: parens = node.ASSOCIATIVITY != RIGHT_TO_LEFT else: parens = True - if parens: - values[i] = f"({val})" + else: + parens = False + if parens: + values[i] = f"({val})" if self._expression_handlers and node.__class__ in self._expression_handlers: return self._expression_handlers[node.__class__](self, node, values) @@ -1606,16 +1724,21 @@ def visiting_potential_leaf(self, node): Return True if the node is not expanded. """ if node is None: - return True, None + return True, "Undefined" - if node.__class__ in nonpyomo_leaf_types: + if node.__class__ in native_numeric_types: return True, str(node) - if node.is_expression_type(): + if node.__class__ in nonpyomo_leaf_types: + return True, repr(node) + + if node.is_expression_type() and node.__class__ not in self._leaf_node_types: return False, None - if hasattr(node, 'to_string'): + if hasattr(node, "to_string"): return True, node.to_string(verbose=self.verbose, smap=self.smap) + elif self.smap is not None: + return True, self.smap.getSymbol(node) else: return True, str(node) diff --git a/pyomo/core/kernel/__init__.py b/pyomo/core/kernel/__init__.py index ffe0beee080..0f02a063537 100644 --- a/pyomo/core/kernel/__init__.py +++ b/pyomo/core/kernel/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -59,24 +59,65 @@ from pyomo.core.expr.calculus.derivatives import differentiate from pyomo.core.expr.taylor_series import taylor_series_expansion -import pyomo.core.kernel.base -import pyomo.core.kernel.homogeneous_container -import pyomo.core.kernel.heterogeneous_container -import pyomo.core.kernel.variable -import pyomo.core.kernel.constraint -import pyomo.core.kernel.matrix_constraint -import pyomo.core.kernel.parameter -import pyomo.core.kernel.expression -import pyomo.core.kernel.objective -import pyomo.core.kernel.sos -import pyomo.core.kernel.suffix -import pyomo.core.kernel.block -import pyomo.core.kernel.piecewise_library -import pyomo.core.kernel.set_types +from pyomo.core.kernel import ( + base, + homogeneous_container, + heterogeneous_container, + variable, + constraint, + matrix_constraint, + parameter, + expression, + objective, + sos, + suffix, + block, + piecewise_library, + set_types, +) + + +# +# declare deprecation paths for removed modules and attributes +# +from pyomo.common.deprecation import relocated_module_attribute, moved_module -# TODO: These are included for backwards compatibility. Accessing them -# will result in a deprecation warning -from pyomo.common.dependencies import attempt_import +relocated_module_attribute( + 'component_map', + 'pyomo.common.collections.component_map', + msg='The pyomo.core.kernel.component_map module is deprecated. ' + 'Import ComponentMap from pyomo.common.collections.', + version='5.7.1', + f_globals=globals(), +) +relocated_module_attribute( + 'component_set', + 'pyomo.common.collections.component_set', + msg='The pyomo.core.kernel.component_map module is deprecated. ' + 'Import ComponentMap from pyomo.common.collections.', + version='5.7.1', + f_globals=globals(), +) -component_map = attempt_import('pyomo.core.kernel.component_map')[0] -component_set = attempt_import('pyomo.core.kernel.component_set')[0] +moved_module( + "pyomo.core.kernel.component_map", + "pyomo._archive.component_map", + msg='The pyomo.core.kernel.component_map module is deprecated. ' + 'Import ComponentMap from pyomo.common.collections.', + version='5.7.1', +) +moved_module( + "pyomo.core.kernel.component_set", + "pyomo._archive.component_set", + msg='The pyomo.core.kernel.component_set module is deprecated. ' + 'Import ComponentSet from pyomo.common.collections.', + version='5.7.1', +) +moved_module( + "pyomo.core.kernel.register_numpy_types", + "pyomo._archive.register_numpy_types", + msg="pyomo.core.kernel.register_numpy_types is deprecated. NumPy type " + "registration is handled automatically by pyomo.common.dependencies.numpy", + version='6.1', +) +del relocated_module_attribute, moved_module diff --git a/pyomo/core/kernel/base.py b/pyomo/core/kernel/base.py index d599c76f6a1..60787a2fb70 100644 --- a/pyomo/core/kernel/base.py +++ b/pyomo/core/kernel/base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -156,7 +156,7 @@ def getname( Args: fully_qualified (bool): Generate a full name by - iterating through all anscestor containers. + iterating through all ancestor containers. Default is :const:`False`. convert (function): A function that converts a storage key into a string diff --git a/pyomo/core/kernel/block.py b/pyomo/core/kernel/block.py index 8ba332e5545..d13e34cb9d4 100644 --- a/pyomo/core/kernel/block.py +++ b/pyomo/core/kernel/block.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/component_map.py b/pyomo/core/kernel/component_map.py deleted file mode 100644 index 5b5b6e9a6f2..00000000000 --- a/pyomo/core/kernel/component_map.py +++ /dev/null @@ -1,19 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.common.collections import ComponentMap -from pyomo.common.deprecation import deprecation_warning - -deprecation_warning( - 'The pyomo.core.kernel.component_map module is deprecated. ' - 'Import ComponentMap from pyomo.common.collections.', - version='5.7.1', -) diff --git a/pyomo/core/kernel/component_set.py b/pyomo/core/kernel/component_set.py deleted file mode 100644 index 969b8b86372..00000000000 --- a/pyomo/core/kernel/component_set.py +++ /dev/null @@ -1,19 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.common.collections import ComponentSet -from pyomo.common.deprecation import deprecation_warning - -deprecation_warning( - 'The pyomo.core.kernel.component_set module is deprecated. ' - 'Import ComponentSet from pyomo.common.collections.', - version='5.7.1', -) diff --git a/pyomo/core/kernel/conic.py b/pyomo/core/kernel/conic.py index 1bb5f1b6ce8..767787d1cc5 100644 --- a/pyomo/core/kernel/conic.py +++ b/pyomo/core/kernel/conic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,8 +10,10 @@ # ___________________________________________________________________________ """Various conic constraint implementations.""" +from pyomo.common.modeling import NOTSET from pyomo.core.expr.numvalue import is_numeric_data from pyomo.core.expr import value, exp +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.kernel.block import block from pyomo.core.kernel.variable import IVariable, variable, variable_tuple from pyomo.core.kernel.constraint import ( @@ -133,7 +135,8 @@ def equality(self): # to avoid building the body expression, if possible # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): + exception = _type_check_exception_arg(self, exception) try: # we wrap the result with value(...) as the # alpha term used by some of the constraints @@ -150,6 +153,8 @@ def __call__(self, exception=True): class quadratic(_ConicBase): """A quadratic conic constraint of the form: + .. math:: + x[0]^2 + ... + x[n-1]^2 <= r^2, which is recognized as convex for r >= 0. @@ -241,6 +246,8 @@ def check_convexity_conditions(self, relax=False): class rotated_quadratic(_ConicBase): """A rotated quadratic conic constraint of the form: + .. math:: + x[0]^2 + ... + x[n-1]^2 <= 2*r1*r2, which is recognized as convex for r1,r2 >= 0. @@ -351,6 +358,8 @@ def check_convexity_conditions(self, relax=False): class primal_exponential(_ConicBase): """A primal exponential conic constraint of the form: + .. math:: + x1*exp(x2/x1) <= r, which is recognized as convex for x1,r >= 0. @@ -460,6 +469,9 @@ def check_convexity_conditions(self, relax=False): class primal_power(_ConicBase): """A primal power conic constraint of the form: + + .. math:: + sqrt(x[0]^2 + ... + x[n-1]^2) <= (r1^alpha)*(r2^(1-alpha)) which is recognized as convex for r1,r2 >= 0 @@ -587,6 +599,9 @@ def check_convexity_conditions(self, relax=False): class primal_geomean(_ConicBase): """A primal geometric mean conic constraint of the form: + + .. math:: + (r[0]*...*r[n-2])^(1/(n-1)) >= |x[n-1]| Parameters @@ -632,7 +647,7 @@ def as_domain(cls, r, x): b = block() b.r = variable_tuple([variable(lb=0) for i in range(len(r))]) b.x = variable() - b.c = _build_linking_constraints(list(r) + [x], list(b.r) + [x]) + b.c = _build_linking_constraints(list(r) + [x], list(b.r) + [b.x]) b.q = cls(r=b.r, x=b.x) return b @@ -648,6 +663,8 @@ def x(self): class dual_exponential(_ConicBase): """A dual exponential conic constraint of the form: + .. math:: + -x2*exp((x1/x2)-1) <= r which is recognized as convex for x2 <= 0 and r >= 0. @@ -758,6 +775,8 @@ def check_convexity_conditions(self, relax=False): class dual_power(_ConicBase): """A dual power conic constraint of the form: + .. math:: + sqrt(x[0]^2 + ... + x[n-1]^2) <= ((r1/alpha)^alpha) * ((r2/(1-alpha))^(1-alpha)) @@ -889,6 +908,9 @@ def check_convexity_conditions(self, relax=False): class dual_geomean(_ConicBase): """A dual geometric mean conic constraint of the form: + + .. math:: + (n-1)*(r[0]*...*r[n-2])^(1/(n-1)) >= |x[n-1]| Parameters @@ -934,7 +956,7 @@ def as_domain(cls, r, x): b = block() b.r = variable_tuple([variable(lb=0) for i in range(len(r))]) b.x = variable() - b.c = _build_linking_constraints(list(r) + [x], list(b.r) + [x]) + b.c = _build_linking_constraints(list(r) + [x], list(b.r) + [b.x]) b.q = cls(r=b.r, x=b.x) return b @@ -948,22 +970,29 @@ def x(self): class svec_psdcone(_ConicBase): - """A domain consisting of vectorizations of the lower-triangular + r"""A domain consisting of vectorizations of the lower-triangular part of a positive semidefinite matrx, with the non-diagonal elements additionally rescaled. In other words, if a vector 'x' - of length n = d*(d+1)/2 belongs to this cone, then the matrix: + of length :math:`n = d(d+1)/2` belongs to this cone, then the matrix: + + .. math:: + :nowrap: - sMat(x) = [[ x[1], x[2]/sqrt(2), ..., x[d]/sqrt(2)], - [x[2]/sqrt(2), x[d+1], ..., x[2d-1]/sqrt(2)], - ... - [x[d]/sqrt(2), x[2d-1]/sqrt(2), ..., x[d*(d+1)/2]/sqrt(2)]] + \[\begin{array}{rcclcl} + sMat(x) = [\;\; + {[} & x[1], & x[2]/\sqrt{2}, &...,& x[d]/\sqrt{2} & ], \\ + {[} &x[2]/\sqrt{2},& x[d+1], &...,& x[2d-1]/\sqrt{2} & ], \\ + & & \vdots & & & \\ + {[} &x[d]/\sqrt{2},&x[2d-1]/\sqrt{2},&...,&x[d(d+1)/2]/\sqrt{2}& ] + \;\;] + \end{array}\] will be restricted to be a positive-semidefinite matrix. Parameters ---------- x : :class:`variable` - An iterable of variables with length d*(d+1)/2. + An iterable of variables with length :math:`d(d+1)/2`. """ diff --git a/pyomo/core/kernel/constraint.py b/pyomo/core/kernel/constraint.py index 6aa4abc4bfe..120e3f3df02 100644 --- a/pyomo/core/kernel/constraint.py +++ b/pyomo/core/kernel/constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from pyomo.common.modeling import NOTSET from pyomo.core.expr.numvalue import ( ZeroConstant, as_numeric, @@ -16,7 +17,7 @@ is_numeric_data, value, ) -from pyomo.core.expr.expr_common import ExpressionType +from pyomo.core.expr.expr_common import ExpressionType, _type_check_exception_arg from pyomo.core.expr.relational_expr import ( EqualityExpression, RangedExpression, @@ -74,8 +75,9 @@ class IConstraint(ICategorizedObject): # Interface # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Compute the value of the body of this constraint.""" + exception = _type_check_exception_arg(self, exception) if exception and (self.body is None): raise ValueError("constraint body is None") elif self.body is None: @@ -160,6 +162,17 @@ def has_ub(self): ub = self.ub return (ub is not None) and (value(ub) != float('inf')) + def to_bounded_expression(self, evaluate_bounds=False): + if evaluate_bounds: + lb = self.lb + if lb == -float('inf'): + lb = None + ub = self.ub + if ub == float('inf'): + ub = None + return lb, self.body, ub + return self.lower, self.body, self.upper + class _MutableBoundsConstraintMixin(object): """ @@ -787,7 +800,8 @@ def terms(self, terms): # to avoid building the body expression # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): + exception = _type_check_exception_arg(self, exception) try: return sum( value(c, exception=exception) * v(exception=exception) diff --git a/pyomo/core/kernel/container_utils.py b/pyomo/core/kernel/container_utils.py index e197d0162b5..8f96a21dfe0 100644 --- a/pyomo/core/kernel/container_utils.py +++ b/pyomo/core/kernel/container_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -26,6 +26,8 @@ def define_homogeneous_container_type( is equivalent to placing the following class definition within that module: + .. code:: + class (): _ctype = @@ -43,6 +45,7 @@ def __init__(self, *args, **kwds): self._storage_key = None self._active = True super(, self).__init__(*args, **kwds) + """ assert name not in namespace cls_dict = {} diff --git a/pyomo/core/kernel/dict_container.py b/pyomo/core/kernel/dict_container.py index ae23044f8ed..c41181baf36 100644 --- a/pyomo/core/kernel/dict_container.py +++ b/pyomo/core/kernel/dict_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/expression.py b/pyomo/core/kernel/expression.py index a477ff9d0e3..be8707f743d 100644 --- a/pyomo/core/kernel/expression.py +++ b/pyomo/core/kernel/expression.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,6 +12,7 @@ from pyomo.common.deprecation import deprecated from pyomo.common.modeling import NOTSET import pyomo.core.expr as EXPR +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.kernel.base import ICategorizedObject, _abstract_readwrite_property from pyomo.core.kernel.container_utils import define_simple_containers from pyomo.core.expr.numvalue import ( @@ -46,7 +47,7 @@ def expr(self): # Implement the NumericValue abstract methods # - def __call__(self, exception=False): + def __call__(self, exception=NOTSET): """Compute the value of this expression. Args: @@ -61,6 +62,7 @@ def __call__(self, exception=False): Returns: numeric value or None """ + exception = _type_check_exception_arg(self, exception) return value(self._expr, exception=exception) def is_fixed(self): @@ -92,7 +94,7 @@ def _args_(self): @property def args(self): """A tuple of subexpressions involved in this expressions operation.""" - yield self._expr + return (self._expr,) def nargs(self): """Length of self._nargs()""" diff --git a/pyomo/core/kernel/heterogeneous_container.py b/pyomo/core/kernel/heterogeneous_container.py index 4783a2d3ec6..d15016d6e53 100644 --- a/pyomo/core/kernel/heterogeneous_container.py +++ b/pyomo/core/kernel/heterogeneous_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/homogeneous_container.py b/pyomo/core/kernel/homogeneous_container.py index edec98e9736..79bad123951 100644 --- a/pyomo/core/kernel/homogeneous_container.py +++ b/pyomo/core/kernel/homogeneous_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/list_container.py b/pyomo/core/kernel/list_container.py index d60b0c7678d..0814394600d 100644 --- a/pyomo/core/kernel/list_container.py +++ b/pyomo/core/kernel/list_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/matrix_constraint.py b/pyomo/core/kernel/matrix_constraint.py index ac0ec8e832d..1dcc84518bb 100644 --- a/pyomo/core/kernel/matrix_constraint.py +++ b/pyomo/core/kernel/matrix_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,6 +15,8 @@ scipy, scipy_available as has_scipy, ) +from pyomo.common.modeling import NOTSET +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import NumericValue, value from pyomo.core.kernel.constraint import IConstraint, constraint_tuple @@ -73,9 +75,10 @@ def terms(self): # to avoid building the body expression # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): # don't mask an exception in the terms # property method + exception = _type_check_exception_arg(self, exception) if self.parent.x is None: raise ValueError("No variable order has been assigned") try: @@ -441,8 +444,9 @@ def equality(self, equality): assert not equality self._equality.fill(False) - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Compute the value of the body of this constraint""" + exception = _type_check_exception_arg(self, exception) if self.x is None: raise ValueError("No variable order has been assigned") values = numpy.array([v.value for v in self.x], dtype=float) diff --git a/pyomo/core/kernel/objective.py b/pyomo/core/kernel/objective.py index 9aa8e3315ef..c686953959c 100644 --- a/pyomo/core/kernel/objective.py +++ b/pyomo/core/kernel/objective.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,15 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from pyomo.common.enums import ObjectiveSense, minimize, maximize from pyomo.core.expr.numvalue import as_numeric from pyomo.core.kernel.base import _abstract_readwrite_property from pyomo.core.kernel.container_utils import define_simple_containers from pyomo.core.kernel.expression import IExpression -# Constants used to define the optimization sense -minimize = 1 -maximize = -1 - class IObjective(IExpression): """ @@ -84,14 +81,7 @@ def sense(self): @sense.setter def sense(self, sense): """Set the sense (direction) of this objective.""" - if (sense == minimize) or (sense == maximize): - self._sense = sense - else: - raise ValueError( - "Objective sense must be set to one of: " - "[minimize (%s), maximize (%s)]. Invalid " - "value: %s'" % (minimize, maximize, sense) - ) + self._sense = ObjectiveSense(sense) # inserts class definitions for simple _tuple, _list, and diff --git a/pyomo/core/kernel/parameter.py b/pyomo/core/kernel/parameter.py index d4dd6336c69..7a45dbf9ce8 100644 --- a/pyomo/core/kernel/parameter.py +++ b/pyomo/core/kernel/parameter.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from pyomo.common.modeling import NOTSET +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import is_numeric_data, NumericValue from pyomo.core.kernel.base import ICategorizedObject from pyomo.core.kernel.container_utils import define_simple_containers @@ -19,7 +21,7 @@ class IParameter(ICategorizedObject, NumericValue): __slots__ = () - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Computes the numeric value of this object.""" raise NotImplementedError # pragma:nocover @@ -77,8 +79,9 @@ def __init__(self, value=None): # Implement the IParameter abstract methods # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Computes the numeric value of this object.""" + exception = _type_check_exception_arg(self, exception) return self.value # @@ -116,7 +119,8 @@ def __init__(self, fn=None): # Implement the IParameter abstract methods # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): + exception = _type_check_exception_arg(self, exception) if self._fn is None: return None try: diff --git a/pyomo/core/kernel/piecewise_library/__init__.py b/pyomo/core/kernel/piecewise_library/__init__.py index c4d2a751632..5a055850381 100644 --- a/pyomo/core/kernel/piecewise_library/__init__.py +++ b/pyomo/core/kernel/piecewise_library/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,4 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.core.kernel.piecewise_library.util -import pyomo.core.kernel.piecewise_library.transforms -import pyomo.core.kernel.piecewise_library.transforms_nd +from pyomo.core.kernel.piecewise_library import util, transforms, transforms_nd diff --git a/pyomo/core/kernel/piecewise_library/transforms.py b/pyomo/core/kernel/piecewise_library/transforms.py index bc6cb0f51ad..cbdbad33380 100644 --- a/pyomo/core/kernel/piecewise_library/transforms.py +++ b/pyomo/core/kernel/piecewise_library/transforms.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,11 +12,8 @@ """ This module contains transformations for representing a single-variate piecewise linear function using a -mixed-integer problem formulation. Reference:: +mixed-integer problem formulation (see [VAN10]_). - Mixed-Integer Models for Non-separable Piecewise Linear -Optimization: Unifying framework and Extensions (Vielma, -Nemhauser 2008) """ import logging diff --git a/pyomo/core/kernel/piecewise_library/transforms_nd.py b/pyomo/core/kernel/piecewise_library/transforms_nd.py index 2c4c8a1f1f2..12dd5005441 100644 --- a/pyomo/core/kernel/piecewise_library/transforms_nd.py +++ b/pyomo/core/kernel/piecewise_library/transforms_nd.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,11 +12,8 @@ """ This module contains transformations for representing a multi-variate piecewise linear function using a -mixed-integer problem formulation. Reference:: +mixed-integer problem formulation (see [VAN10]_). - Mixed-Integer Models for Non-separable Piecewise Linear -Optimization: Unifying framework and Extensions (Vielma, -Nemhauser 2008) """ from collections.abc import Sized diff --git a/pyomo/core/kernel/piecewise_library/util.py b/pyomo/core/kernel/piecewise_library/util.py index 23975d87596..41364df7f97 100644 --- a/pyomo/core/kernel/piecewise_library/util.py +++ b/pyomo/core/kernel/piecewise_library/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/set_types.py b/pyomo/core/kernel/set_types.py index 5915f0d64b3..ee5ea677ee8 100644 --- a/pyomo/core/kernel/set_types.py +++ b/pyomo/core/kernel/set_types.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/sos.py b/pyomo/core/kernel/sos.py index 1845343f526..59f7b69e7bb 100644 --- a/pyomo/core/kernel/sos.py +++ b/pyomo/core/kernel/sos.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/suffix.py b/pyomo/core/kernel/suffix.py index 56e13a371a3..d6e77cb6497 100644 --- a/pyomo/core/kernel/suffix.py +++ b/pyomo/core/kernel/suffix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/tuple_container.py b/pyomo/core/kernel/tuple_container.py index 83aab49e5db..2d715d72949 100644 --- a/pyomo/core/kernel/tuple_container.py +++ b/pyomo/core/kernel/tuple_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/kernel/variable.py b/pyomo/core/kernel/variable.py index 61324b3dc0f..c011165c78d 100644 --- a/pyomo/core/kernel/variable.py +++ b/pyomo/core/kernel/variable.py @@ -1,15 +1,16 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.modeling import NoArgumentGiven +from pyomo.common.modeling import NOTSET from pyomo.core.staleflag import StaleFlagManager +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import NumericValue, is_numeric_data, value from pyomo.core.kernel.base import ICategorizedObject, _abstract_readwrite_property from pyomo.core.kernel.container_utils import define_simple_containers @@ -123,13 +124,13 @@ def ub(self): def ub(self, val): self.upper = val - def fix(self, value=NoArgumentGiven): + def fix(self, value=NOTSET): """ Fix the variable. Sets the fixed indicator to :const:`True`. An optional value argument will update the variable's value before fixing. """ - if value is not NoArgumentGiven: + if value is not NOTSET: self.value = value self.fixed = True @@ -270,8 +271,9 @@ def polynomial_degree(self): return 0 return 1 - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """Return the value of this variable.""" + exception = _type_check_exception_arg(self, exception) if exception and (self.value is None): raise ValueError("value is None") return self.value diff --git a/pyomo/core/plugins/__init__.py b/pyomo/core/plugins/__init__.py index 23407cd77ef..6cd671c1d8a 100644 --- a/pyomo/core/plugins/__init__.py +++ b/pyomo/core/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,4 +11,4 @@ def load(): - import pyomo.core.plugins.transform + from pyomo.core.plugins import transform diff --git a/pyomo/core/plugins/transform/__init__.py b/pyomo/core/plugins/transform/__init__.py index 21e762047ca..401875d1e78 100644 --- a/pyomo/core/plugins/transform/__init__.py +++ b/pyomo/core/plugins/transform/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,18 +9,18 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.core.plugins.transform.relax_integrality - -# import pyomo.core.plugins.transform.eliminate_fixed_vars -# import pyomo.core.plugins.transform.standard_form -import pyomo.core.plugins.transform.expand_connectors - -# import pyomo.core.plugins.transform.equality_transform -import pyomo.core.plugins.transform.nonnegative_transform -import pyomo.core.plugins.transform.radix_linearization -import pyomo.core.plugins.transform.discrete_vars - -# import pyomo.core.plugins.transform.util -import pyomo.core.plugins.transform.add_slack_vars -import pyomo.core.plugins.transform.scaling -import pyomo.core.plugins.transform.logical_to_linear +from pyomo.core.plugins.transform import ( + relax_integrality, + # eliminate_fixed_vars, + # standard_form, + expand_connectors, + # equality_transform, + nonnegative_transform, + radix_linearization, + discrete_vars, + # util, + add_slack_vars, + scaling, + logical_to_linear, + lp_dual, +) diff --git a/pyomo/core/plugins/transform/add_slack_vars.py b/pyomo/core/plugins/transform/add_slack_vars.py index 6b5096d315c..cea92237130 100644 --- a/pyomo/core/plugins/transform/add_slack_vars.py +++ b/pyomo/core/plugins/transform/add_slack_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,7 +23,6 @@ from pyomo.core.plugins.transform.hierarchy import NonIsomorphicTransformation from pyomo.common.config import ConfigBlock, ConfigValue from pyomo.core.base import ComponentUID -from pyomo.core.base.constraint import _ConstraintData from pyomo.common.deprecation import deprecation_warning @@ -42,7 +41,7 @@ def target_list(x): # [ESJ 07/15/2020] We have to just pass it through because we need the # instance in order to be able to do anything about it... return [x] - elif isinstance(x, (Constraint, _ConstraintData)): + elif getattr(x, 'ctype', None) is Constraint: return [x] elif hasattr(x, '__iter__'): ans = [] @@ -53,7 +52,7 @@ def target_list(x): deprecation_msg = None # same as above... ans.append(i) - elif isinstance(i, (Constraint, _ConstraintData)): + elif getattr(i, 'ctype', None) is Constraint: ans.append(i) else: raise ValueError( @@ -151,26 +150,29 @@ def _apply_to_impl(self, instance, **kwds): if not cons.active: continue cons_name = cons.getname(fully_qualified=True) - if cons.lower is not None: + lower = cons.lower + body = cons.body + upper = cons.upper + if lower is not None: # we add positive slack variable to body: # declare positive slack varName = "_slack_plus_" + cons_name posSlack = Var(within=NonNegativeReals) xblock.add_component(varName, posSlack) # add positive slack to body expression - cons._body += posSlack + body += posSlack # penalize slack in objective obj_expr += posSlack - if cons.upper is not None: + if upper is not None: # we subtract a positive slack variable from the body: # declare slack varName = "_slack_minus_" + cons_name negSlack = Var(within=NonNegativeReals) xblock.add_component(varName, negSlack) # add negative slack to body expression - cons._body -= negSlack + body -= negSlack # add slack to objective obj_expr += negSlack - + cons.set_value((lower, body, upper)) # make a new objective that minimizes sum of slack variables xblock._slack_objective = Objective(expr=obj_expr) diff --git a/pyomo/core/plugins/transform/discrete_vars.py b/pyomo/core/plugins/transform/discrete_vars.py index 35729e76517..89cd1804266 100644 --- a/pyomo/core/plugins/transform/discrete_vars.py +++ b/pyomo/core/plugins/transform/discrete_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/plugins/transform/eliminate_fixed_vars.py b/pyomo/core/plugins/transform/eliminate_fixed_vars.py index 9312035b8c8..09c8278aa8d 100644 --- a/pyomo/core/plugins/transform/eliminate_fixed_vars.py +++ b/pyomo/core/plugins/transform/eliminate_fixed_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,7 @@ from pyomo.core.expr import ExpressionBase, as_numeric from pyomo.core import Constraint, Objective, TransformationFactory -from pyomo.core.base.var import Var, _VarData +from pyomo.core.base.var import Var, VarData from pyomo.core.util import sequence from pyomo.core.plugins.transform.hierarchy import IsomorphicTransformation @@ -77,7 +77,7 @@ def _fix_vars(self, expr, model): if isinstance(expr._args[i], ExpressionBase): _args.append(self._fix_vars(expr._args[i], model)) elif ( - isinstance(expr._args[i], Var) or isinstance(expr._args[i], _VarData) + isinstance(expr._args[i], Var) or isinstance(expr._args[i], VarData) ) and expr._args[i].fixed: if expr._args[i].value != 0.0: _args.append(as_numeric(expr._args[i].value)) diff --git a/pyomo/core/plugins/transform/equality_transform.py b/pyomo/core/plugins/transform/equality_transform.py index a1a1b72f146..e2dc2c75e04 100644 --- a/pyomo/core/plugins/transform/equality_transform.py +++ b/pyomo/core/plugins/transform/equality_transform.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -66,7 +66,7 @@ def _create_using(self, model, **kwds): con = equality.__getattribute__(con_name) # - # Get all _ConstraintData objects + # Get all ConstraintData objects # # We need to get the keys ahead of time because we are modifying # con._data on-the-fly. @@ -104,7 +104,7 @@ def _create_using(self, model, **kwds): con.add(ub_name, new_expr) # Since we explicitly `continue` for equality constraints, we - # can safely remove the old _ConstraintData object + # can safely remove the old ConstraintData object del con._data[ndx] return equality.create() diff --git a/pyomo/core/plugins/transform/expand_connectors.py b/pyomo/core/plugins/transform/expand_connectors.py index 8c02f3e5698..0f9d8f88e9a 100644 --- a/pyomo/core/plugins/transform/expand_connectors.py +++ b/pyomo/core/plugins/transform/expand_connectors.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,7 +25,7 @@ Var, SortComponents, ) -from pyomo.core.base.connector import _ConnectorData, ScalarConnector +from pyomo.core.base.connector import ConnectorData, ScalarConnector @TransformationFactory.register( @@ -69,7 +69,7 @@ def _apply_to(self, instance, **kwds): # The set of connectors found in the current constraint found = ComponentSet() - connector_types = set([ScalarConnector, _ConnectorData]) + connector_types = set([ScalarConnector, ConnectorData]) for constraint in instance.component_data_objects( Constraint, sort=SortComponents.deterministic ): diff --git a/pyomo/core/plugins/transform/hierarchy.py b/pyomo/core/plugins/transform/hierarchy.py index 86338d17f88..805bad930df 100644 --- a/pyomo/core/plugins/transform/hierarchy.py +++ b/pyomo/core/plugins/transform/hierarchy.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/plugins/transform/logical_to_linear.py b/pyomo/core/plugins/transform/logical_to_linear.py index 7aa541a5fdd..2d04ea7c5fb 100644 --- a/pyomo/core/plugins/transform/logical_to_linear.py +++ b/pyomo/core/plugins/transform/logical_to_linear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -29,7 +29,7 @@ BooleanVarList, SortComponents, ) -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.core.base.boolean_var import _DeprecatedImplicitAssociatedBinaryVariable from pyomo.core.expr.cnf_walker import to_cnf from pyomo.core.expr import ( @@ -100,7 +100,7 @@ def _apply_to(self, model, **kwds): # the GDP will be solved, and it would be wrong to assume that a GDP # will *necessarily* be solved as an algebraic model. The star # example of not doing so being GDPopt.) - if t.ctype is Block or isinstance(t, _BlockData): + if t.ctype is Block or isinstance(t, BlockData): self._transform_block(t, model, new_var_lists, transBlocks) elif t.ctype is LogicalConstraint: if t.is_indexed(): @@ -285,7 +285,7 @@ class CnfToLinearVisitor(StreamBasedExpressionVisitor): """Convert CNF logical constraint to linear constraints. Expected expression node types: AndExpression, OrExpression, NotExpression, - AtLeastExpression, AtMostExpression, ExactlyExpression, _BooleanVarData + AtLeastExpression, AtMostExpression, ExactlyExpression, BooleanVarData """ @@ -372,7 +372,7 @@ def beforeChild(self, node, child, child_idx): if child.is_expression_type(): return True, None - # Only thing left should be _BooleanVarData + # Only thing left should be BooleanVarData # # TODO: After the expr_multiple_dispatch is merged, this should # be switched to using as_numeric. diff --git a/pyomo/core/plugins/transform/lp_dual.py b/pyomo/core/plugins/transform/lp_dual.py new file mode 100644 index 00000000000..e2f0871dbb8 --- /dev/null +++ b/pyomo/core/plugins/transform/lp_dual.py @@ -0,0 +1,260 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.autoslots import AutoSlots +from pyomo.common.collections import ComponentMap +from pyomo.common.config import ConfigDict, ConfigValue +from pyomo.common.dependencies import scipy +from pyomo.core import ( + ConcreteModel, + Block, + Var, + Constraint, + Objective, + TransformationFactory, + NonNegativeReals, + NonPositiveReals, + maximize, + minimize, + Reals, +) +from pyomo.opt import WriterFactory +from pyomo.repn.standard_repn import isclose_const +from pyomo.util.config_domains import ComponentDataSet + + +class _LPDualData(AutoSlots.Mixin): + __slots__ = ('primal_var', 'dual_var', 'primal_constraint', 'dual_constraint') + + def __init__(self): + self.primal_var = {} + self.dual_var = {} + self.primal_constraint = ComponentMap() + self.dual_constraint = ComponentMap() + + +Block.register_private_data_initializer(_LPDualData) + + +@TransformationFactory.register( + 'core.lp_dual', 'Generate the linear programming dual of the given model' +) +class LinearProgrammingDual(object): + CONFIG = ConfigDict("core.lp_dual") + CONFIG.declare( + 'parameterize_wrt', + ConfigValue( + default=None, + domain=ComponentDataSet(Var), + description="Vars to treat as data for the purposes of taking the dual", + doc=""" + Optional list of Vars to be treated as data while taking the LP dual. + + For example, if this is the dual of the inner problem in a multilevel + optimization problem, then the outer problem's Vars would be specified + in this list since they are not variables from the perspective of the + inner problem. + """, + ), + ) + + def apply_to(self, model, **options): + raise NotImplementedError( + "The 'core.lp_dual' transformation does not implement " + "apply_to since it is ambiguous what it means to take a dual " + "in place. Please use 'create_using' and do what you wish with the " + "returned model." + ) + + def create_using(self, model, ostream=None, **kwds): + """Take linear programming dual of a model + + Returns + ------- + ConcreteModel containing linear programming dual + + Parameters + ---------- + model: ConcreteModel + The concrete Pyomo model to take the dual of + + ostream: None + This is provided for API compatibility with other writers + and is ignored here. + + """ + config = self.CONFIG(kwds.pop('options', {})) + config.set_value(kwds) + + if config.parameterize_wrt is None: + std_form = WriterFactory('compile_standard_form').write( + model, mixed_form=True, set_sense=None + ) + else: + std_form = WriterFactory('compile_parameterized_standard_form').write( + model, wrt=config.parameterize_wrt, mixed_form=True, set_sense=None + ) + return self._take_dual(model, std_form) + + def _take_dual(self, model, std_form): + if len(std_form.objectives) != 1: + raise ValueError( + "Model '%s' has no objective or multiple active objectives. Can " + "only take dual with exactly one active objective!" % model.name + ) + primal_sense = std_form.objectives[0].sense + + dual = ConcreteModel(name="%s dual" % model.name) + # This is a csc matrix, so we'll skip transposing and just work off + # of the columns + A = std_form.A + c = std_form.c.todense().ravel() + dual_rows = range(A.shape[1]) + dual_cols = range(A.shape[0]) + dual.x = Var(dual_cols, domain=NonNegativeReals) + trans_info = dual.private_data() + for j, (primal_cons, ineq) in enumerate(std_form.rows): + # maximize is -1 and minimize is +1 and ineq is +1 for <= and -1 for + # >=, so we need to change domain to NonPositiveReals if the product + # of these is +1. + if primal_sense * ineq == 1: + dual.x[j].domain = NonPositiveReals + elif ineq == 0: + # equality + dual.x[j].domain = Reals + trans_info.primal_constraint[dual.x[j]] = primal_cons + trans_info.dual_var[primal_cons] = dual.x[j] + + dual.constraints = Constraint(dual_rows) + for i, primal in enumerate(std_form.columns): + lhs = 0 + for j in range(A.indptr[i], A.indptr[i + 1]): + coef = A.data[j] + primal_row = A.indices[j] + lhs += coef * dual.x[primal_row] + + if primal.domain is Reals: + dual.constraints[i] = lhs == c[i] + elif primal_sense is minimize: + if primal.domain is NonNegativeReals: + dual.constraints[i] = lhs <= c[i] + else: # primal.domain is NonPositiveReals + dual.constraints[i] = lhs >= c[i] + else: + if primal.domain is NonNegativeReals: + dual.constraints[i] = lhs >= c[i] + else: # primal.domain is NonPositiveReals + dual.constraints[i] = lhs <= c[i] + trans_info.dual_constraint[primal] = dual.constraints[i] + trans_info.primal_var[dual.constraints[i]] = primal + + dual.obj = Objective( + expr=sum(std_form.rhs[j] * dual.x[j] for j in dual_cols), + sense=-primal_sense, + ) + + return dual + + def get_primal_constraint(self, model, dual_var): + """Return the primal constraint corresponding to 'dual_var' + + Returns + ------- + Constraint + + Parameters + ---------- + model: ConcreteModel + A dual model returned from the 'core.lp_dual' transformation + dual_var: Var + A dual variable on 'model' + + """ + primal_constraint = model.private_data().primal_constraint + if dual_var in primal_constraint: + return primal_constraint[dual_var] + else: + raise ValueError( + "It does not appear that Var '%s' is a dual variable on model '%s'" + % (dual_var.name, model.name) + ) + + def get_dual_constraint(self, model, primal_var): + """Return the dual constraint corresponding to 'primal_var' + + Returns + ------- + Constraint + + Parameters + ---------- + model: ConcreteModel + A primal model passed as an argument to the 'core.lp_dual' transformation + primal_var: Var + A primal variable on 'model' + + """ + dual_constraint = model.private_data().dual_constraint + if primal_var in dual_constraint: + return dual_constraint[primal_var] + else: + raise ValueError( + "It does not appear that Var '%s' is a primal variable on model '%s'" + % (primal_var.name, model.name) + ) + + def get_primal_var(self, model, dual_constraint): + """Return the primal variable corresponding to 'dual_constraint' + + Returns + ------- + Var + + Parameters + ---------- + model: ConcreteModel + A dual model returned from the 'core.lp_dual' transformation + dual_constraint: Constraint + A constraint on 'model' + + """ + primal_var = model.private_data().primal_var + if dual_constraint in primal_var: + return primal_var[dual_constraint] + else: + raise ValueError( + "It does not appear that Constraint '%s' is a dual constraint on " + "model '%s'" % (dual_constraint.name, model.name) + ) + + def get_dual_var(self, model, primal_constraint): + """Return the dual variable corresponding to 'primal_constraint' + + Returns + ------- + Var + + Parameters + ---------- + model: ConcreteModel + A primal model passed as an argument to the 'core.lp_dual' transformation + primal_constraint: Constraint + A constraint on 'model' + + """ + dual_var = model.private_data().dual_var + if primal_constraint in dual_var: + return dual_var[primal_constraint] + else: + raise ValueError( + "It does not appear that Constraint '%s' is a primal constraint on " + "model '%s'" % (primal_constraint.name, model.name) + ) diff --git a/pyomo/core/plugins/transform/model.py b/pyomo/core/plugins/transform/model.py index db8376afd29..d0b93719694 100644 --- a/pyomo/core/plugins/transform/model.py +++ b/pyomo/core/plugins/transform/model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,19 +16,28 @@ # because we may support an explicit matrix representation for models. # +from pyomo.common.deprecation import deprecated from pyomo.core.base import Objective, Constraint import array +@deprecated( + "to_standard_form() is deprecated. " + "Please use WriterFactory('compile_standard_form')", + version='6.7.3', + remove_in='6.8.0', +) def to_standard_form(self): - """ + r""" Produces a standard-form representation of the model. Returns the coefficient matrix (A), the cost vector (c), and the constraint vector (b), where the 'standard form' problem is - min/max c'x - s.t. Ax = b - x >= 0 + .. math:: + + \min/\max\ & c'x \\ + s.t.\ & Ax = b \\ + & x >= 0 All three returned values are instances of the array.array class, and store Python floats (C doubles). @@ -55,8 +64,8 @@ def to_standard_form(self): # N.B. Structure hierarchy: # # active_components: {class: {attr_name: object}} - # object -> Constraint: ._data: {ndx: _ConstraintData} - # _ConstraintData: .lower, .body, .upper + # object -> Constraint: ._data: {ndx: ConstraintData} + # ConstraintData: .lower, .body, .upper # # So, altogether, we access a lower bound via # diff --git a/pyomo/core/plugins/transform/nonnegative_transform.py b/pyomo/core/plugins/transform/nonnegative_transform.py index d123e68cb2e..245502a6a82 100644 --- a/pyomo/core/plugins/transform/nonnegative_transform.py +++ b/pyomo/core/plugins/transform/nonnegative_transform.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/plugins/transform/radix_linearization.py b/pyomo/core/plugins/transform/radix_linearization.py index c67e556d60c..0f3600f971a 100644 --- a/pyomo/core/plugins/transform/radix_linearization.py +++ b/pyomo/core/plugins/transform/radix_linearization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -21,7 +21,7 @@ Block, RangeSet, ) -from pyomo.core.base.var import _VarData +from pyomo.core.base.var import VarData import logging @@ -268,8 +268,8 @@ def _collect_bilinear(self, expr, bilin, quad): self._collect_bilinear(e, bilin, quad) # No need to check denominator, as this is poly_degree==2 return - if not isinstance(expr._numerator[0], _VarData) or not isinstance( - expr._numerator[1], _VarData + if not isinstance(expr._numerator[0], VarData) or not isinstance( + expr._numerator[1], VarData ): raise RuntimeError("Cannot yet handle complex subexpressions") if expr._numerator[0] is expr._numerator[1]: @@ -280,7 +280,7 @@ def _collect_bilinear(self, expr, bilin, quad): if type(expr) is PowExpression and value(expr._args[1]) == 2: # Note: directly testing the value of the exponent above is # safe: we have already verified that this expression is - # polynominal, so the exponent must be constant. + # polynomial, so the exponent must be constant. tmp = ProductExpression() tmp._numerator = [expr._args[0], expr._args[0]] tmp._denominator = [] diff --git a/pyomo/core/plugins/transform/relax_integrality.py b/pyomo/core/plugins/transform/relax_integrality.py index 40cf74ddbcc..e7caa9994a0 100644 --- a/pyomo/core/plugins/transform/relax_integrality.py +++ b/pyomo/core/plugins/transform/relax_integrality.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/plugins/transform/scaling.py b/pyomo/core/plugins/transform/scaling.py index ad894b31fde..982ccf82ce3 100644 --- a/pyomo/core/plugins/transform/scaling.py +++ b/pyomo/core/plugins/transform/scaling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,23 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import logging from pyomo.common.collections import ComponentMap -from pyomo.core.base import ( - Block, - Var, - Constraint, - Objective, - _ConstraintData, - _ObjectiveData, - Suffix, - value, -) +from pyomo.core.base import Block, Var, Constraint, Objective, Suffix, value from pyomo.core.plugins.transform.hierarchy import Transformation from pyomo.core.base import TransformationFactory from pyomo.core.base.suffix import SuffixFinder from pyomo.core.expr import replace_expressions from pyomo.util.components import rename_components +logger = logging.getLogger("pyomo.core.plugins.transform.scaling") + @TransformationFactory.register( 'core.scale_model', doc="Scale model variables, constraints, and objectives." @@ -35,7 +29,7 @@ class ScaleModel(Transformation): Transformation to scale a model. This plugin performs variable, constraint, and objective scaling on - a model based on the scaling factors in the suffix 'scaling_parameter' + a model based on the scaling factors in the suffix 'scaling_factor' set for the variables, constraints, and/or objective. This is typically done to scale the problem for improved numerical properties. @@ -44,6 +38,10 @@ class ScaleModel(Transformation): * :py:meth:`create_using ` * :py:meth:`propagate_solution ` + By default, scaling components are renamed with the prefix ``scaled_``. To disable + this behavior and scale variables in-place (or keep the same names in a new model), + use the ``rename=False`` argument to ``apply_to`` or ``create_using``. + Examples -------- @@ -76,8 +74,6 @@ class ScaleModel(Transformation): >>> print(value(scaled_model.scaled_obj)) 101.0 - .. todo:: Implement an option to change the variables names or not - """ def __init__(self, **kwds): @@ -91,15 +87,10 @@ def _create_using(self, original_model, **kwds): self._apply_to(scaled_model, **kwds) return scaled_model - def _get_float_scaling_factor(self, component): - if self._suffix_finder is None: - self._suffix_finder = SuffixFinder('scaling_factor', 1.0) - return self._suffix_finder.find(component) - def _apply_to(self, model, rename=True): # create a map of component to scaling factor component_scaling_factor_map = ComponentMap() - self._suffix_finder = SuffixFinder('scaling_factor', 1.0) + self._suffix_finder = SuffixFinder('scaling_factor', 1.0, model) # if the scaling_method is 'user', get the scaling parameters from the suffixes if self._scaling_method == 'user': @@ -197,7 +188,7 @@ def _apply_to(self, model, rename=True): already_scaled.add(id(c)) # perform the constraint/objective scaling and variable sub scaling_factor = component_scaling_factor_map[c] - if isinstance(c, _ConstraintData): + if c.ctype is Constraint: body = scaling_factor * replace_expressions( expr=c.body, substitution_map=variable_substitution_dict, @@ -226,7 +217,7 @@ def _apply_to(self, model, rename=True): else: c.set_value((lower, body, upper)) - elif isinstance(c, _ObjectiveData): + elif c.ctype is Objective: c.expr = scaling_factor * replace_expressions( expr=c.expr, substitution_map=variable_substitution_dict, @@ -322,10 +313,18 @@ def propagate_solution(self, scaled_model, original_model): original_v = original_model.find_component(original_v_path) for k in scaled_v: - original_v[k].set_value( - value(scaled_v[k]) / component_scaling_factor_map[scaled_v[k]], - skip_validation=True, - ) + if scaled_v[k].value is None and original_v[k].value is not None: + logger.warning( + "Variable with value None in the scaled model is replacing" + f" value of variable {original_v[k].name} in the original" + f" model with None (was {original_v[k].value})." + ) + original_v[k].set_value(None, skip_validation=True) + elif scaled_v[k].value is not None: + original_v[k].set_value( + value(scaled_v[k]) / component_scaling_factor_map[scaled_v[k]], + skip_validation=True, + ) if check_reduced_costs and scaled_v[k] in scaled_model.rc: original_model.rc[original_v[k]] = ( scaled_model.rc[scaled_v[k]] diff --git a/pyomo/core/plugins/transform/standard_form.py b/pyomo/core/plugins/transform/standard_form.py index ffc382a2cf7..96b084ce961 100644 --- a/pyomo/core/plugins/transform/standard_form.py +++ b/pyomo/core/plugins/transform/standard_form.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,14 +19,16 @@ "core.standard_form", doc="Create an equivalent LP model in standard form." ) class StandardForm(IsomorphicTransformation): - """ + r""" Produces a standard-form representation of the model. This form has the coefficient matrix (A), the cost vector (c), and the constraint vector (b), where the 'standard form' problem is - min/max c'x - s.t. Ax = b - x >= 0 + .. math:: + + \min/\max\ & c'x \\ + s.t.\ & Ax = b \\ + & x >= 0 Options slack_names Default auxiliary_slack @@ -35,6 +37,7 @@ class StandardForm(IsomorphicTransformation): up_names Default _upper_bound pos_suffix Default _plus neg_suffix Default _neg + """ def __init__(self, **kwds): diff --git a/pyomo/core/plugins/transform/util.py b/pyomo/core/plugins/transform/util.py index 9719b1f38d9..3c4c7aa1b65 100644 --- a/pyomo/core/plugins/transform/util.py +++ b/pyomo/core/plugins/transform/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/pyomoobject.py b/pyomo/core/pyomoobject.py index 3bf6de37489..4eedf3d22c8 100644 --- a/pyomo/core/pyomoobject.py +++ b/pyomo/core/pyomoobject.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/staleflag.py b/pyomo/core/staleflag.py index da90032a03c..86e68e0831e 100644 --- a/pyomo/core/staleflag.py +++ b/pyomo/core/staleflag.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/__init__.py b/pyomo/core/tests/__init__.py index 761a6e6c44c..4401c90fce4 100644 --- a/pyomo/core/tests/__init__.py +++ b/pyomo/core/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/data/__init__.py b/pyomo/core/tests/data/__init__.py index a73865ee112..a3be35cfbbc 100644 --- a/pyomo/core/tests/data/__init__.py +++ b/pyomo/core/tests/data/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/data/test_odbc_ini.py b/pyomo/core/tests/data/test_odbc_ini.py index 43584fe3ca9..63b4a65a94f 100644 --- a/pyomo/core/tests/data/test_odbc_ini.py +++ b/pyomo/core/tests/data/test_odbc_ini.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/diet/__init__.py b/pyomo/core/tests/diet/__init__.py index 717247051c4..1fa7c34c9f8 100644 --- a/pyomo/core/tests/diet/__init__.py +++ b/pyomo/core/tests/diet/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/diet/test_diet.py b/pyomo/core/tests/diet/test_diet.py index 9e11907179e..29830982cf7 100644 --- a/pyomo/core/tests/diet/test_diet.py +++ b/pyomo/core/tests/diet/test_diet.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/__init__.py b/pyomo/core/tests/examples/__init__.py index c5ecc4ee437..74834cbf561 100644 --- a/pyomo/core/tests/examples/__init__.py +++ b/pyomo/core/tests/examples/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/pmedian.py b/pyomo/core/tests/examples/pmedian.py index c476f01bd17..c453c718272 100644 --- a/pyomo/core/tests/examples/pmedian.py +++ b/pyomo/core/tests/examples/pmedian.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/pmedian1.py b/pyomo/core/tests/examples/pmedian1.py index 8e11383116b..5eb60b0986f 100644 --- a/pyomo/core/tests/examples/pmedian1.py +++ b/pyomo/core/tests/examples/pmedian1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/pmedian2.py b/pyomo/core/tests/examples/pmedian2.py index 88a9666fe41..d4567b6fbbd 100644 --- a/pyomo/core/tests/examples/pmedian2.py +++ b/pyomo/core/tests/examples/pmedian2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/pmedian4.py b/pyomo/core/tests/examples/pmedian4.py index 101ee3e7c46..716415111b6 100644 --- a/pyomo/core/tests/examples/pmedian4.py +++ b/pyomo/core/tests/examples/pmedian4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/pmedian_concrete.py b/pyomo/core/tests/examples/pmedian_concrete.py new file mode 100644 index 00000000000..f3dc8a70462 --- /dev/null +++ b/pyomo/core/tests/examples/pmedian_concrete.py @@ -0,0 +1,70 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import math +from pyomo.environ import ( + ConcreteModel, + Param, + RangeSet, + Var, + Reals, + Binary, + PositiveIntegers, +) + + +def _cost_rule(model, n, m): + # We will assume costs are an arbitrary function of the indices + return math.sin(n * 2.33333 + m * 7.99999) + + +def create_model(n=3, m=3, p=2): + model = ConcreteModel(name="M1") + + model.N = Param(initialize=n, within=PositiveIntegers) + model.M = Param(initialize=m, within=PositiveIntegers) + model.P = Param(initialize=p, within=RangeSet(1, model.N), mutable=True) + + model.Locations = RangeSet(1, model.N) + model.Customers = RangeSet(1, model.M) + + model.cost = Param( + model.Locations, model.Customers, initialize=_cost_rule, within=Reals + ) + model.serve_customer_from_location = Var( + model.Locations, model.Customers, bounds=(0.0, 1.0) + ) + model.select_location = Var(model.Locations, within=Binary) + + @model.Objective() + def obj(model): + return sum( + model.cost[n, m] * model.serve_customer_from_location[n, m] + for n in model.Locations + for m in model.Customers + ) + + @model.Constraint(model.Customers) + def single_x(model, m): + return ( + sum(model.serve_customer_from_location[n, m] for n in model.Locations) + == 1.0 + ) + + @model.Constraint(model.Locations, model.Customers) + def bound_y(model, n, m): + return model.serve_customer_from_location[n, m] <= model.select_location[n] + + @model.Constraint() + def num_facilities(model): + return sum(model.select_location[n] for n in model.Locations) == model.P + + return model diff --git a/pyomo/core/tests/examples/test7.txt b/pyomo/core/tests/examples/test7.txt index 9a8696c6e2b..899cd32258a 100644 --- a/pyomo/core/tests/examples/test7.txt +++ b/pyomo/core/tests/examples/test7.txt @@ -51,7 +51,7 @@ Options: --model-options=MODEL_OPTIONS Options passed into a create_model() function to construct the model - --disable-gc Disable the garbage collecter + --disable-gc Disable the garbage collector --solver-manager=SMANAGER_TYPE Specify the technique that is used to manage solver executions. diff --git a/pyomo/core/tests/examples/test_amplbook2.py b/pyomo/core/tests/examples/test_amplbook2.py index 72e3d2b4599..ff2a029bf1b 100644 --- a/pyomo/core/tests/examples/test_amplbook2.py +++ b/pyomo/core/tests/examples/test_amplbook2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/test_kernel_examples.py b/pyomo/core/tests/examples/test_kernel_examples.py index 61d0fa2527d..7e5b20e293a 100644 --- a/pyomo/core/tests/examples/test_kernel_examples.py +++ b/pyomo/core/tests/examples/test_kernel_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/test_pyomo.py b/pyomo/core/tests/examples/test_pyomo.py index 2d3a39ebdda..cdd98de5e3e 100644 --- a/pyomo/core/tests/examples/test_pyomo.py +++ b/pyomo/core/tests/examples/test_pyomo.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/examples/test_tutorials.py b/pyomo/core/tests/examples/test_tutorials.py index c8de003007e..6ea74bb02a2 100644 --- a/pyomo/core/tests/examples/test_tutorials.py +++ b/pyomo/core/tests/examples/test_tutorials.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,16 +15,14 @@ import runpy import sys import os -from os.path import abspath, dirname - -topdir = dirname(dirname(dirname(dirname(dirname(abspath(__file__)))))) -currdir = dirname(abspath(__file__)) + os.sep -tutorial_dir = ( - topdir + os.sep + "examples" + os.sep + "pyomo" + os.sep + "tutorials" + os.sep -) import pyomo.common.unittest as unittest +from pyomo.common.fileutils import PYOMO_ROOT_DIR +from pyomo.common.tee import capture_output + +tutorial_dir = os.path.join(PYOMO_ROOT_DIR, "examples", "pyomo", "tutorials") + try: from win32com.client.dynamic import Dispatch @@ -65,38 +63,25 @@ def setUp(self): self.cwd = os.getcwd() self.tmp_path = list(sys.path) os.chdir(tutorial_dir) - sys.path = [os.path.dirname(tutorial_dir)] + sys.path - sys.path.append(os.path.dirname(tutorial_dir)) - sys.stderr.flush() - sys.stdout.flush() - self.save_stdout = sys.stdout - self.save_stderr = sys.stderr + sys.path = [tutorial_dir] + sys.path def tearDown(self): os.chdir(self.cwd) sys.path = self.tmp_path - sys.stdout = self.save_stdout - sys.stderr = self.save_stderr def driver(self, name): - OUTPUT = open(currdir + name + '.log', 'w') - sys.stdout = OUTPUT - sys.stderr = OUTPUT - runpy.run_module(name, None, "__main__") - OUTPUT.close() + with capture_output() as OUT: + runpy.run_module(name, None, "__main__") self.assertIn( - open(tutorial_dir + name + ".out", 'r').read(), - open(currdir + name + ".log", 'r').read(), + open(os.path.join(tutorial_dir, name + ".out"), 'r').read(), OUT.getvalue() ) - os.remove(currdir + name + ".log") def test_data(self): self.driver('data') - @unittest.skipIf(not (_xlrd or _openpyxl), "Cannot read excel file.") - @unittest.skipIf( - not (_win32com and _excel_available and pyutilib_available), - "Cannot read excel file.", + @unittest.skipUnless(_xlrd or _openpyxl, "Cannot read excel file.") + @unittest.skipUnless( + _win32com and _excel_available and pyutilib_available, "Cannot read excel file." ) def test_excel(self): self.driver('excel') diff --git a/pyomo/core/tests/transform/__init__.py b/pyomo/core/tests/transform/__init__.py index f34c7624e25..9a518529502 100644 --- a/pyomo/core/tests/transform/__init__.py +++ b/pyomo/core/tests/transform/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/transform/test_add_slacks.py b/pyomo/core/tests/transform/test_add_slacks.py index 7896cab7e88..97d1d37d4cd 100644 --- a/pyomo/core/tests/transform/test_add_slacks.py +++ b/pyomo/core/tests/transform/test_add_slacks.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -102,10 +102,7 @@ def checkRule1(self, m): self, cons.body, EXPR.LinearExpression( - [ - EXPR.MonomialTermExpression((1, m.x)), - EXPR.MonomialTermExpression((-1, transBlock._slack_minus_rule1)), - ] + [m.x, EXPR.MonomialTermExpression((-1, transBlock._slack_minus_rule1))] ), ) @@ -118,14 +115,7 @@ def checkRule3(self, m): self.assertEqual(cons.lower, 0.1) assertExpressionsEqual( - self, - cons.body, - EXPR.LinearExpression( - [ - EXPR.MonomialTermExpression((1, m.x)), - EXPR.MonomialTermExpression((1, transBlock._slack_plus_rule3)), - ] - ), + self, cons.body, EXPR.LinearExpression([m.x, transBlock._slack_plus_rule3]) ) def test_ub_constraint_modified(self): @@ -154,8 +144,8 @@ def test_both_bounds_constraint_modified(self): cons.body, EXPR.LinearExpression( [ - EXPR.MonomialTermExpression((1, m.y)), - EXPR.MonomialTermExpression((1, transBlock._slack_plus_rule2)), + m.y, + transBlock._slack_plus_rule2, EXPR.MonomialTermExpression((-1, transBlock._slack_minus_rule2)), ] ), @@ -184,10 +174,10 @@ def test_new_obj_created(self): obj.expr, EXPR.LinearExpression( [ - EXPR.MonomialTermExpression((1, transBlock._slack_minus_rule1)), - EXPR.MonomialTermExpression((1, transBlock._slack_plus_rule2)), - EXPR.MonomialTermExpression((1, transBlock._slack_minus_rule2)), - EXPR.MonomialTermExpression((1, transBlock._slack_plus_rule3)), + transBlock._slack_minus_rule1, + transBlock._slack_plus_rule2, + transBlock._slack_minus_rule2, + transBlock._slack_plus_rule3, ] ), ) @@ -302,10 +292,7 @@ def checkTargetsObj(self, m): self, obj.expr, EXPR.LinearExpression( - [ - EXPR.MonomialTermExpression((1, transBlock._slack_minus_rule1)), - EXPR.MonomialTermExpression((1, transBlock._slack_plus_rule3)), - ] + [transBlock._slack_minus_rule1, transBlock._slack_plus_rule3] ), ) @@ -343,7 +330,7 @@ def test_error_for_non_constraint_noniterable_target(self): self.assertRaisesRegex( ValueError, "Expected Constraint or list of Constraints.\n\tReceived " - "", + "", TransformationFactory('core.add_slack_variables').apply_to, m, targets=m.indexedVar[1], @@ -423,9 +410,9 @@ def test_transformed_constraints_sumexpression_body(self): c.body, EXPR.LinearExpression( [ - EXPR.MonomialTermExpression((1, m.x)), + m.x, EXPR.MonomialTermExpression((-2, m.y)), - EXPR.MonomialTermExpression((1, transBlock._slack_plus_rule4)), + transBlock._slack_plus_rule4, EXPR.MonomialTermExpression((-1, transBlock._slack_minus_rule4)), ] ), @@ -518,15 +505,9 @@ def checkTargetObj(self, m): obj.expr, EXPR.LinearExpression( [ - EXPR.MonomialTermExpression( - (1, transBlock.component("_slack_plus_rule1[1]")) - ), - EXPR.MonomialTermExpression( - (1, transBlock.component("_slack_plus_rule1[2]")) - ), - EXPR.MonomialTermExpression( - (1, transBlock.component("_slack_plus_rule1[3]")) - ), + transBlock.component("_slack_plus_rule1[1]"), + transBlock.component("_slack_plus_rule1[2]"), + transBlock.component("_slack_plus_rule1[3]"), ] ), ) @@ -558,14 +539,7 @@ def checkTransformedRule1(self, m, i): EXPR.LinearExpression( [ EXPR.MonomialTermExpression((2, m.x[i])), - EXPR.MonomialTermExpression( - ( - 1, - m._core_add_slack_variables.component( - "_slack_plus_rule1[%s]" % i - ), - ) - ), + m._core_add_slack_variables.component("_slack_plus_rule1[%s]" % i), ] ), ) diff --git a/pyomo/core/tests/transform/test_scaling.py b/pyomo/core/tests/transform/test_scaling.py index d0fbfab61bd..7d82baf27fe 100644 --- a/pyomo/core/tests/transform/test_scaling.py +++ b/pyomo/core/tests/transform/test_scaling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,10 +10,12 @@ # ___________________________________________________________________________ # +import io import pyomo.common.unittest as unittest import pyomo.environ as pyo from pyomo.opt.base.solvers import UnknownSolver -from pyomo.core.plugins.transform.scaling import ScaleModel +from pyomo.core.plugins.transform.scaling import ScaleModel, SuffixFinder +from pyomo.common.log import LoggingIntercept class TestScaleModelTransformation(unittest.TestCase): @@ -600,6 +602,13 @@ def con_rule(m, i): self.assertAlmostEqual(pyo.value(model.zcon), -8, 4) def test_get_float_scaling_factor_top_level(self): + # Note: the transformation used to have a private method for + # finding suffix values (which this method tested). The + # transformation now leverages the SuffixFinder. To ensure that + # the SuffixFinder behaves in the same way as the original local + # method, we preserve these tests, but directly test the + # SuffixFinder + m = pyo.ConcreteModel() m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) @@ -616,17 +625,23 @@ def test_get_float_scaling_factor_top_level(self): m.scaling_factor[m.v1] = 0.1 m.scaling_factor[m.b1.v2] = 0.2 + _finder = SuffixFinder('scaling_factor', 1.0, m) + # SF should be 0.1 from top level - sf = ScaleModel()._get_float_scaling_factor(m.v1) - assert sf == float(0.1) + self.assertEqual(_finder.find(m.v1), 0.1) # SF should be 0.1 from top level, lower level ignored - sf = ScaleModel()._get_float_scaling_factor(m.b1.v2) - assert sf == float(0.2) + self.assertEqual(_finder.find(m.b1.v2), 0.2) # No SF, should return 1 - sf = ScaleModel()._get_float_scaling_factor(m.b1.b2.v3) - assert sf == 1.0 + self.assertEqual(_finder.find(m.b1.b2.v3), 1.0) def test_get_float_scaling_factor_local_level(self): + # Note: the transformation used to have a private method for + # finding suffix values (which this method tested). The + # transformation now leverages the SuffixFinder. To ensure that + # the SuffixFinder behaves in the same way as the original local + # method, we preserve these tests, but directly test the + # SuffixFinder + m = pyo.ConcreteModel() m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) @@ -647,15 +662,21 @@ def test_get_float_scaling_factor_local_level(self): # Add an intermediate scaling factor - this should take priority m.b1.scaling_factor[m.b1.b2.v3] = 0.4 + _finder = SuffixFinder('scaling_factor', 1.0, m) + # Should get SF from local levels - sf = ScaleModel()._get_float_scaling_factor(m.v1) - assert sf == float(0.1) - sf = ScaleModel()._get_float_scaling_factor(m.b1.v2) - assert sf == float(0.2) - sf = ScaleModel()._get_float_scaling_factor(m.b1.b2.v3) - assert sf == float(0.4) + self.assertEqual(_finder.find(m.v1), 0.1) + self.assertEqual(_finder.find(m.b1.v2), 0.2) + self.assertEqual(_finder.find(m.b1.b2.v3), 0.4) def test_get_float_scaling_factor_intermediate_level(self): + # Note: the transformation used to have a private method for + # finding suffix values (which this method tested). The + # transformation now leverages the SuffixFinder. To ensure that + # the SuffixFinder behaves in the same way as the original local + # method, we preserve these tests, but directly test the + # SuffixFinder + m = pyo.ConcreteModel() m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) @@ -680,15 +701,39 @@ def test_get_float_scaling_factor_intermediate_level(self): m.b1.b2.b3.scaling_factor[m.b1.b2.b3.v3] = 0.4 + _finder = SuffixFinder('scaling_factor', 1.0, m) + # v1 should be unscaled as SF set below variable level - sf = ScaleModel()._get_float_scaling_factor(m.v1) - assert sf == 1.0 + self.assertEqual(_finder.find(m.v1), 1.0) # v2 should get SF from b1 level - sf = ScaleModel()._get_float_scaling_factor(m.b1.b2.b3.v2) - assert sf == float(0.2) + self.assertEqual(_finder.find(m.b1.b2.b3.v2), 0.2) # v2 should get SF from highest level, ignoring b3 level - sf = ScaleModel()._get_float_scaling_factor(m.b1.b2.b3.v3) - assert sf == float(0.3) + self.assertEqual(_finder.find(m.b1.b2.b3.v3), 0.3) + + def test_propagate_solution_uninitialized_variable(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2], initialize=1.0) + m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) + m.scaling_factor[m.x[1]] = 10.0 + m.scaling_factor[m.x[2]] = 10.0 + scaled_model = pyo.TransformationFactory("core.scale_model").create_using(m) + scaled_model.scaled_x[1] = 20.0 + scaled_model.scaled_x[2] = None + + OUTPUT = io.StringIO() + with LoggingIntercept(OUTPUT, "pyomo.core.plugins.transform.scaling"): + pyo.TransformationFactory("core.scale_model").propagate_solution( + scaled_model, m + ) + msg = ( + "Variable with value None in the scaled model is replacing value of" + " variable x[2] in the original model with None (was 1.0).\n" + ) + self.assertEqual(OUTPUT.getvalue(), msg) + self.assertAlmostEqual(m.x[1].value, 2.0, delta=1e-8) + # Note that value of x[2] in original model *has* been overridden to None. + # In this case, a warning has been raised. + self.assertIs(m.x[2].value, None) if __name__ == "__main__": diff --git a/pyomo/core/tests/transform/test_transform.py b/pyomo/core/tests/transform/test_transform.py index cd1f26417a7..1d20c804094 100644 --- a/pyomo/core/tests/transform/test_transform.py +++ b/pyomo/core/tests/transform/test_transform.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/__init__.py b/pyomo/core/tests/unit/__init__.py index 85ece8d8cd5..8872067faf9 100644 --- a/pyomo/core/tests/unit/__init__.py +++ b/pyomo/core/tests/unit/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/__init__.py b/pyomo/core/tests/unit/kernel/__init__.py index e5231e0f859..c73bbbeb907 100644 --- a/pyomo/core/tests/unit/kernel/__init__.py +++ b/pyomo/core/tests/unit/kernel/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_block.py b/pyomo/core/tests/unit/kernel/test_block.py index b21771653bb..2949a018f5c 100644 --- a/pyomo/core/tests/unit/kernel/test_block.py +++ b/pyomo/core/tests/unit/kernel/test_block.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_component_map.py b/pyomo/core/tests/unit/kernel/test_component_map.py index 3fb8b99a9a3..3a99b4ee54b 100644 --- a/pyomo/core/tests/unit/kernel/test_component_map.py +++ b/pyomo/core/tests/unit/kernel/test_component_map.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_component_set.py b/pyomo/core/tests/unit/kernel/test_component_set.py index 38f17a702c1..92f1671138b 100644 --- a/pyomo/core/tests/unit/kernel/test_component_set.py +++ b/pyomo/core/tests/unit/kernel/test_component_set.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_conic.py b/pyomo/core/tests/unit/kernel/test_conic.py index ccfbcca7e1f..a5e228bc9e7 100644 --- a/pyomo/core/tests/unit/kernel/test_conic.py +++ b/pyomo/core/tests/unit/kernel/test_conic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -35,6 +35,8 @@ primal_power, dual_exponential, dual_power, + primal_geomean, + dual_geomean, ) @@ -784,6 +786,40 @@ def test_as_domain(self): x[1].value = None +# These mosek 10 constraints can't be evaluated, pprinted, checked for convexity, +# pickled, etc., so I won't use the _conic_tester_base for them +class Test_primal_geomean(unittest.TestCase): + def test_as_domain(self): + b = primal_geomean.as_domain(r=[2, 3], x=6) + self.assertIs(type(b), block) + self.assertIs(type(b.q), primal_geomean) + self.assertIs(type(b.r), variable_tuple) + self.assertIs(type(b.x), variable) + self.assertIs(type(b.c), constraint_tuple) + self.assertExpressionsEqual(b.c[0].body, b.r[0]) + self.assertExpressionsEqual(b.c[0].rhs, 2) + self.assertExpressionsEqual(b.c[1].body, b.r[1]) + self.assertExpressionsEqual(b.c[1].rhs, 3) + self.assertExpressionsEqual(b.c[2].body, b.x) + self.assertExpressionsEqual(b.c[2].rhs, 6) + + +class Test_dual_geomean(unittest.TestCase): + def test_as_domain(self): + b = dual_geomean.as_domain(r=[2, 3], x=6) + self.assertIs(type(b), block) + self.assertIs(type(b.q), dual_geomean) + self.assertIs(type(b.r), variable_tuple) + self.assertIs(type(b.x), variable) + self.assertIs(type(b.c), constraint_tuple) + self.assertExpressionsEqual(b.c[0].body, b.r[0]) + self.assertExpressionsEqual(b.c[0].rhs, 2) + self.assertExpressionsEqual(b.c[1].body, b.r[1]) + self.assertExpressionsEqual(b.c[1].rhs, 3) + self.assertExpressionsEqual(b.c[2].body, b.x) + self.assertExpressionsEqual(b.c[2].rhs, 6) + + class TestMisc(unittest.TestCase): def test_build_linking_constraints(self): c = _build_linking_constraints([], []) diff --git a/pyomo/core/tests/unit/kernel/test_constraint.py b/pyomo/core/tests/unit/kernel/test_constraint.py index 97832dd8bca..b31155cf2ae 100644 --- a/pyomo/core/tests/unit/kernel/test_constraint.py +++ b/pyomo/core/tests/unit/kernel/test_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_dict_container.py b/pyomo/core/tests/unit/kernel/test_dict_container.py index 6ae25362bb2..1cacc024099 100644 --- a/pyomo/core/tests/unit/kernel/test_dict_container.py +++ b/pyomo/core/tests/unit/kernel/test_dict_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_expression.py b/pyomo/core/tests/unit/kernel/test_expression.py index 39d3eaa463c..7ddbfea4f67 100644 --- a/pyomo/core/tests/unit/kernel/test_expression.py +++ b/pyomo/core/tests/unit/kernel/test_expression.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_kernel.py b/pyomo/core/tests/unit/kernel/test_kernel.py index b34bcdeaadb..9027a16eba0 100644 --- a/pyomo/core/tests/unit/kernel/test_kernel.py +++ b/pyomo/core/tests/unit/kernel/test_kernel.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_list_container.py b/pyomo/core/tests/unit/kernel/test_list_container.py index a4641f83295..a4e8ae5054e 100644 --- a/pyomo/core/tests/unit/kernel/test_list_container.py +++ b/pyomo/core/tests/unit/kernel/test_list_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_matrix_constraint.py b/pyomo/core/tests/unit/kernel/test_matrix_constraint.py index 24a2915f224..106cc3b269d 100644 --- a/pyomo/core/tests/unit/kernel/test_matrix_constraint.py +++ b/pyomo/core/tests/unit/kernel/test_matrix_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_objective.py b/pyomo/core/tests/unit/kernel/test_objective.py index 810218f1dc2..b99bfe5779b 100644 --- a/pyomo/core/tests/unit/kernel/test_objective.py +++ b/pyomo/core/tests/unit/kernel/test_objective.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_parameter.py b/pyomo/core/tests/unit/kernel/test_parameter.py index 469ed9fbe8c..5fa4bbdae5e 100644 --- a/pyomo/core/tests/unit/kernel/test_parameter.py +++ b/pyomo/core/tests/unit/kernel/test_parameter.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_piecewise.py b/pyomo/core/tests/unit/kernel/test_piecewise.py index 3d9cf66e39c..af52674efe2 100644 --- a/pyomo/core/tests/unit/kernel/test_piecewise.py +++ b/pyomo/core/tests/unit/kernel/test_piecewise.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -209,19 +209,17 @@ def test_generate_delaunay(self): vlist.append(variable(lb=0, ub=1)) vlist.append(variable(lb=1, ub=2)) vlist.append(variable(lb=2, ub=3)) - if not (util.numpy_available and util.scipy_available): - with self.assertRaises(ImportError): - util.generate_delaunay(vlist) - else: - tri = util.generate_delaunay(vlist, num=2) - self.assertTrue(isinstance(tri, util.scipy.spatial.Delaunay)) - self.assertEqual(len(tri.simplices), 6) - self.assertEqual(len(tri.points), 8) - - tri = util.generate_delaunay(vlist, num=3) - self.assertTrue(isinstance(tri, util.scipy.spatial.Delaunay)) - self.assertEqual(len(tri.simplices), 62) - self.assertEqual(len(tri.points), 27) + tri = util.generate_delaunay(vlist, num=2) + self.assertTrue(isinstance(tri, util.scipy.spatial.Delaunay)) + self.assertEqual(len(tri.simplices), 6) + self.assertEqual(len(tri.points), 8) + + tri = util.generate_delaunay(vlist, num=3) + self.assertTrue(isinstance(tri, util.scipy.spatial.Delaunay)) + # we got some simplices + self.assertTrue(len(tri.simplices) > 1) + # all the given points are accounted for + self.assertEqual(len(tri.points) + len(tri.coplanar), 27) # # Check cases where not all variables are bounded diff --git a/pyomo/core/tests/unit/kernel/test_sos.py b/pyomo/core/tests/unit/kernel/test_sos.py index b1cb67a96f8..680d2d3e4f8 100644 --- a/pyomo/core/tests/unit/kernel/test_sos.py +++ b/pyomo/core/tests/unit/kernel/test_sos.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_suffix.py b/pyomo/core/tests/unit/kernel/test_suffix.py index 2a73888c2d3..f9ca6c71148 100644 --- a/pyomo/core/tests/unit/kernel/test_suffix.py +++ b/pyomo/core/tests/unit/kernel/test_suffix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_tuple_container.py b/pyomo/core/tests/unit/kernel/test_tuple_container.py index c016c5fc789..2190ff5d2f7 100644 --- a/pyomo/core/tests/unit/kernel/test_tuple_container.py +++ b/pyomo/core/tests/unit/kernel/test_tuple_container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/kernel/test_variable.py b/pyomo/core/tests/unit/kernel/test_variable.py index 181eb15c972..49ff290ebe9 100644 --- a/pyomo/core/tests/unit/kernel/test_variable.py +++ b/pyomo/core/tests/unit/kernel/test_variable.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_action.py b/pyomo/core/tests/unit/test_action.py index 3481c90a021..b8f667af125 100644 --- a/pyomo/core/tests/unit/test_action.py +++ b/pyomo/core/tests/unit/test_action.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_block.py b/pyomo/core/tests/unit/test_block.py index 88646643703..54cf1607ff6 100644 --- a/pyomo/core/tests/unit/test_block.py +++ b/pyomo/core/tests/unit/test_block.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,6 +13,7 @@ # from io import StringIO +import logging import os import sys import types @@ -54,7 +55,7 @@ from pyomo.core.base.block import ( ScalarBlock, SubclassOf, - _BlockData, + BlockData, declare_custom_block, ) import pyomo.core.expr as EXPR @@ -851,7 +852,7 @@ class DerivedBlock(ScalarBlock): _Block_reserved_words = None DerivedBlock._Block_reserved_words = ( - set(['a', 'b', 'c']) | _BlockData._Block_reserved_words + set(['a', 'b', 'c']) | BlockData._Block_reserved_words ) m = ConcreteModel() @@ -965,7 +966,7 @@ def __init__(self, *args, **kwds): b.c.d.e = Block() with self.assertRaisesRegex( ValueError, - r'_BlockData.transfer_attributes_from\(\): ' + r'BlockData.transfer_attributes_from\(\): ' r'Cannot set a sub-block \(c.d.e\) to a parent block \(c\):', ): b.c.d.e.transfer_attributes_from(b.c) @@ -974,7 +975,7 @@ def __init__(self, *args, **kwds): b = Block(concrete=True) with self.assertRaisesRegex( ValueError, - r'_BlockData.transfer_attributes_from\(\): expected a Block ' + r'BlockData.transfer_attributes_from\(\): expected a Block ' 'or dict; received str', ): b.transfer_attributes_from('foo') @@ -2524,7 +2525,7 @@ def __deepcopy__(bogus): "'unknown' contains an uncopyable field 'bad1'", OUTPUT.getvalue() ) self.assertIn("'b' contains an uncopyable field 'bad2'", OUTPUT.getvalue()) - self.assertIn("'__paranoid__'", OUTPUT.getvalue()) + self.assertIn("outside the scope of Block.clone()", OUTPUT.getvalue()) self.assertTrue(hasattr(m.b, 'bad2')) self.assertIsNotNone(m.b.bad2) self.assertTrue(hasattr(nb, 'bad2')) @@ -2667,7 +2668,6 @@ def test_pprint(self): 5 Declarations: a1_IDX a3_IDX c a b """ - self.maxDiff = None self.assertEqual(ref, buf.getvalue()) @unittest.skipIf(not 'glpk' in solvers, "glpk solver is not available") @@ -2976,9 +2976,70 @@ def test_write_exceptions(self): with self.assertRaisesRegex(ValueError, ".*Cannot write model in format"): m.write(format="bogus") - def test_override_pprint(self): + def test_custom_block(self): + @declare_custom_block('TestingBlock') + class TestingBlockData(BlockData): + def __init__(self, component): + BlockData.__init__(self, component) + logging.getLogger(__name__).warning("TestingBlockData.__init__") + + self.assertIn('TestingBlock', globals()) + self.assertIn('ScalarTestingBlock', globals()) + self.assertIn('IndexedTestingBlock', globals()) + self.assertIs(TestingBlock.__module__, __name__) + self.assertIs(ScalarTestingBlock.__module__, __name__) + self.assertIs(IndexedTestingBlock.__module__, __name__) + + with LoggingIntercept() as LOG: + obj = TestingBlock() + self.assertIs(type(obj), ScalarTestingBlock) + self.assertEqual(LOG.getvalue().strip(), "TestingBlockData.__init__") + + with LoggingIntercept() as LOG: + obj = TestingBlock([1, 2]) + self.assertIs(type(obj), IndexedTestingBlock) + self.assertEqual(LOG.getvalue(), "") + + # Test that we can derive from a ScalarCustomBlock + class DerivedScalarTestingBlock(ScalarTestingBlock): + pass + + with LoggingIntercept() as LOG: + obj = DerivedScalarTestingBlock() + self.assertIs(type(obj), DerivedScalarTestingBlock) + self.assertEqual(LOG.getvalue().strip(), "TestingBlockData.__init__") + + def test_custom_block_ctypes(self): + @declare_custom_block('TestingBlock') + class TestingBlockData(BlockData): + pass + + self.assertIs(TestingBlock().ctype, Block) + + @declare_custom_block('TestingBlock', True) + class TestingBlockData(BlockData): + pass + + self.assertIs(TestingBlock().ctype, TestingBlock) + + @declare_custom_block('TestingBlock', Constraint) + class TestingBlockData(BlockData): + pass + + self.assertIs(TestingBlock().ctype, Constraint) + + with self.assertRaisesRegex( + ValueError, + r"Expected new_ctype to be either type or 'True'; received: \[\]", + ): + + @declare_custom_block('TestingBlock', []) + class TestingBlockData(BlockData): + pass + + def test_custom_block_override_pprint(self): @declare_custom_block('TempBlock') - class TempBlockData(_BlockData): + class TempBlockData(BlockData): def pprint(self, ostream=None, verbose=False, prefix=""): ostream.write('Testing pprint of a custom block.') @@ -3053,9 +3114,9 @@ def test_derived_block_construction(self): class ConcreteBlock(Block): pass - class ScalarConcreteBlock(_BlockData, ConcreteBlock): + class ScalarConcreteBlock(BlockData, ConcreteBlock): def __init__(self, *args, **kwds): - _BlockData.__init__(self, component=self) + BlockData.__init__(self, component=self) ConcreteBlock.__init__(self, *args, **kwds) _buf = [] diff --git a/pyomo/core/tests/unit/test_block_model.py b/pyomo/core/tests/unit/test_block_model.py index b4cf34e7516..29829e56363 100644 --- a/pyomo/core/tests/unit/test_block_model.py +++ b/pyomo/core/tests/unit/test_block_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_bounds.py b/pyomo/core/tests/unit/test_bounds.py index 23554f555c9..6abf4045e55 100644 --- a/pyomo/core/tests/unit/test_bounds.py +++ b/pyomo/core/tests/unit/test_bounds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_check.py b/pyomo/core/tests/unit/test_check.py index e61e3998fb7..c7d720a2d47 100644 --- a/pyomo/core/tests/unit/test_check.py +++ b/pyomo/core/tests/unit/test_check.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_compare.py b/pyomo/core/tests/unit/test_compare.py index f80753bdb61..e78a7167f13 100644 --- a/pyomo/core/tests/unit/test_compare.py +++ b/pyomo/core/tests/unit/test_compare.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ from pyomo.common import unittest -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.core.expr.numeric_expr import ( LinearExpression, MonomialTermExpression, @@ -39,8 +39,8 @@ class TestConvertToPrefixNotation(unittest.TestCase): def test_linear_expression(self): - m = pe.ConcreteModel() - m.x = pe.Var([1, 2, 3, 4]) + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3, 4]) e = LinearExpression( constant=3, linear_coefs=list(m.x.keys()), linear_vars=list(m.x.values()) ) @@ -64,9 +64,9 @@ def test_linear_expression(self): self.assertEqual(pn, expected) def test_multiple(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() e = m.x**2 + m.x * m.y / 3 + 4 expected = [ @@ -89,9 +89,9 @@ def test_multiple(self): self.assertFalse(compare_expressions(e, e3)) def test_unary(self): - m = pe.ConcreteModel() - m.x = pe.Var() - e = pe.log(m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var() + e = pyo.log(m.x) expected = [(UnaryFunctionExpression, 1, 'log'), m.x] pn = convert_expression_to_prefix_notation(e) self.assertEqual(expected, pn) @@ -101,10 +101,10 @@ def test_external_function(self): if not DLL: self.skipTest('Could not find the amplgsl.dll library') - m = pe.ConcreteModel() - m.hypot = pe.ExternalFunction(library=DLL, function='gsl_hypot') - m.x = pe.Var(initialize=0.5) - m.y = pe.Var(initialize=1.5) + m = pyo.ConcreteModel() + m.hypot = pyo.ExternalFunction(library=DLL, function='gsl_hypot') + m.x = pyo.Var(initialize=0.5) + m.y = pyo.Var(initialize=1.5) e = 2 * m.hypot(m.x, m.x * m.y) expected = [ (ProductExpression, 2), @@ -119,8 +119,8 @@ def test_external_function(self): self.assertEqual(expected, pn) def test_var(self): - m = pe.ConcreteModel() - m.x = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() pn = convert_expression_to_prefix_notation(m.x) self.assertEqual(pn, [m.x]) @@ -129,53 +129,47 @@ def test_float(self): self.assertEqual(pn, [4.3]) def test_monomial(self): - m = pe.ConcreteModel() - m.x = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() e = 2 * m.x pn = convert_expression_to_prefix_notation(e) expected = [(MonomialTermExpression, 2), 2, m.x] self.assertEqual(pn, expected) def test_negation(self): - m = pe.ConcreteModel() - m.x = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() e = -m.x**2 pn = convert_expression_to_prefix_notation(e) expected = [(NegationExpression, 1), (PowExpression, 2), m.x, 2] self.assertEqual(pn, expected) def test_abs(self): - m = pe.ConcreteModel() - m.x = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() e = abs(m.x) pn = convert_expression_to_prefix_notation(e) expected = [(AbsExpression, 1, 'abs'), m.x] self.assertEqual(pn, expected) def test_expr_if(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - e = pe.Expr_if(m.x <= 0, m.y + m.x == 0, m.y - m.x == 0) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + e = pyo.Expr_if(m.x <= 0, m.y + m.x == 0, m.y - m.x == 0) pn = convert_expression_to_prefix_notation(e) expected = [ (Expr_ifExpression, 3), - (InequalityExpression, 2), + (InequalityExpression, 2, False), m.x, 0, (EqualityExpression, 2), (LinearExpression, 2), - (MonomialTermExpression, 2), - 1, m.y, - (MonomialTermExpression, 2), - 1, m.x, 0, (EqualityExpression, 2), (LinearExpression, 2), - (MonomialTermExpression, 2), - 1, m.y, (MonomialTermExpression, 2), -1, @@ -185,20 +179,20 @@ def test_expr_if(self): self.assertEqual(pn, expected) def test_ranged_expression(self): - m = pe.ConcreteModel() - m.x = pe.Var() - e = pe.inequality(-1, m.x, 1) + m = pyo.ConcreteModel() + m.x = pyo.Var() + e = pyo.inequality(-1, m.x, 1) pn = convert_expression_to_prefix_notation(e) - expected = [(RangedExpression, 3), -1, m.x, 1] + expected = [(RangedExpression, 3, (False, False)), -1, m.x, 1] self.assertEqual(pn, expected) def test_assertExpressionsEqual(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.e1 = pe.Expression(expr=m.x**2 + m.x - 1) - m.e2 = pe.Expression(expr=m.x**2 + m.x - 1) - m.f = pe.Expression(expr=m.x**2 + 2 * m.x - 1) - m.g = pe.Expression(expr=m.x**2 + m.x - 2) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.e1 = pyo.Expression(expr=m.x**2 + m.x - 1) + m.e2 = pyo.Expression(expr=m.x**2 + m.x - 1) + m.f = pyo.Expression(expr=m.x**2 + 2 * m.x - 1) + m.g = pyo.Expression(expr=m.x**2 + m.x - 2) assertExpressionsEqual(self, m.e1.expr, m.e2.expr) assertExpressionsStructurallyEqual(self, m.e1.expr, m.e2.expr) diff --git a/pyomo/core/tests/unit/test_component.py b/pyomo/core/tests/unit/test_component.py index 175c4c47d46..1ac11e92fdf 100644 --- a/pyomo/core/tests/unit/test_component.py +++ b/pyomo/core/tests/unit/test_component.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -66,19 +66,17 @@ def test_getname(self): ) m.b[2]._component = None - self.assertEqual( - m.b[2].getname(fully_qualified=True), "[Unattached _BlockData]" - ) + self.assertEqual(m.b[2].getname(fully_qualified=True), "[Unattached BlockData]") # I think that getname() should do this: # self.assertEqual(m.b[2].c[2,4].getname(fully_qualified=True), - # "[Unattached _BlockData].c[2,4]") + # "[Unattached BlockData].c[2,4]") # but it doesn't match current behavior. I will file a PEP to # propose changing the behavior later and proceed to test # current behavior. self.assertEqual(m.b[2].c[2, 4].getname(fully_qualified=True), "c[2,4]") self.assertEqual( - m.b[2].getname(fully_qualified=False), "[Unattached _BlockData]" + m.b[2].getname(fully_qualified=False), "[Unattached BlockData]" ) self.assertEqual(m.b[2].c[2, 4].getname(fully_qualified=False), "c[2,4]") diff --git a/pyomo/core/tests/unit/test_componentuid.py b/pyomo/core/tests/unit/test_componentuid.py index 5808bedb7fd..bef90bac2bf 100644 --- a/pyomo/core/tests/unit/test_componentuid.py +++ b/pyomo/core/tests/unit/test_componentuid.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -81,11 +81,7 @@ def test_genFromComponent_context(self): ValueError, r"Context 'b\[1,'2'\]' does not apply to component 's'" ): ComponentUID(self.m.s, context=self.m.b[1, '2']) - with self.assertRaisesRegex( - ValueError, - "Context is not allowed when initializing a ComponentUID " - "object from a string type", - ): + with self.assertRaisesRegex(ValueError, "Context is not allowed"): ComponentUID("b[1,2].c.a[2]", context=self.m.b[1, '2']) def test_parseFromString(self): @@ -1248,6 +1244,26 @@ def test_cuid_from_slice_errors(self): ): cuid = ComponentUID(_slice) + def test_cuid_from_cuid(self): + def assert_equal(cuid1, cuid2): + self.assertEqual(cuid1, cuid2) + self.assertFalse(cuid1 is cuid2) + + cuid_str = ComponentUID("b.var[1]") + cuid_str_2 = ComponentUID(cuid_str) + assert_equal(cuid_str, cuid_str_2) + + cuid_comp = ComponentUID(self.m.b[1, 1].c) + cuid_comp_2 = ComponentUID(cuid_comp) + assert_equal(cuid_str, cuid_str_2) + + cuid_slice = ComponentUID(self.m.b[1, :].c) + cuid_slice_2 = ComponentUID(cuid_slice) + assert_equal(cuid_slice, cuid_slice_2) + + with self.assertRaisesRegex(ValueError, "Context is not allowed"): + ComponentUID(cuid_comp, context=self.m.b[1, 1]) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/core/tests/unit/test_con.py b/pyomo/core/tests/unit/test_con.py index 6ed19c1bcfd..af2fc879479 100644 --- a/pyomo/core/tests/unit/test_con.py +++ b/pyomo/core/tests/unit/test_con.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -44,7 +44,7 @@ InequalityExpression, RangedExpression, ) -from pyomo.core.base.constraint import _GeneralConstraintData +from pyomo.core.base.constraint import ConstraintData class TestConstraintCreation(unittest.TestCase): @@ -84,21 +84,55 @@ def rule(model): self.assertEqual(model.c.upper, 0) def test_tuple_construct_inf_equality(self): - model = self.create_model(abstract=True) - - def rule(model): - return (model.x, float('inf')) - - model.c = Constraint(rule=rule) - self.assertRaises(ValueError, model.create_instance) - - model = self.create_model(abstract=True) - - def rule(model): - return (float('inf'), model.x) + model = self.create_model(abstract=True).create_instance() - model.c = Constraint(rule=rule) - self.assertRaises(ValueError, model.create_instance) + model.c = Constraint(expr=(model.x, float('inf'))) + self.assertEqual(model.c.equality, True) + self.assertEqual(model.c.lower, float('inf')) + self.assertIs(model.c.body, model.x) + self.assertEqual(model.c.upper, float('inf')) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'c' created with an invalid non-finite lower bound \(inf\).", + ): + model.c.lb + self.assertEqual(model.c.ub, None) + + model.d = Constraint(expr=(float('inf'), model.x)) + self.assertEqual(model.d.equality, True) + self.assertEqual(model.d.lower, float('inf')) + self.assertIs(model.d.body, model.x) + self.assertEqual(model.d.upper, float('inf')) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'd' created with an invalid non-finite lower bound \(inf\).", + ): + model.d.lb + self.assertEqual(model.d.ub, None) + + model.e = Constraint(expr=(model.x, float('-inf'))) + self.assertEqual(model.e.equality, True) + self.assertEqual(model.e.lower, float('-inf')) + self.assertIs(model.e.body, model.x) + self.assertEqual(model.e.upper, float('-inf')) + self.assertEqual(model.e.lb, None) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'e' created with an invalid non-finite upper bound \(-inf\).", + ): + model.e.ub + + model.f = Constraint(expr=(float('-inf'), model.x)) + self.assertEqual(model.f.equality, True) + self.assertEqual(model.f.lower, float('-inf')) + self.assertIs(model.f.body, model.x) + self.assertEqual(model.f.upper, float('-inf')) + self.assertEqual(model.f.lb, None) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'f' created with an invalid non-finite upper bound \(-inf\).", + ): + model.f.ub def test_tuple_construct_1sided_inequality(self): model = self.create_model() @@ -134,9 +168,11 @@ def rule(model): model.c = Constraint(rule=rule) self.assertEqual(model.c.equality, False) - self.assertEqual(model.c.lower, None) + self.assertEqual(model.c.lower, float('-inf')) self.assertIs(model.c.body, model.y) self.assertEqual(model.c.upper, 1) + self.assertEqual(model.c.lb, None) + self.assertEqual(model.c.ub, 1) model = self.create_model() @@ -148,7 +184,9 @@ def rule(model): self.assertEqual(model.c.equality, False) self.assertEqual(model.c.lower, 0) self.assertIs(model.c.body, model.y) - self.assertEqual(model.c.upper, None) + self.assertEqual(model.c.upper, float('inf')) + self.assertEqual(model.c.lb, 0) + self.assertEqual(model.c.ub, None) def test_tuple_construct_unbounded_inequality(self): model = self.create_model() @@ -171,9 +209,11 @@ def rule(model): model.c = Constraint(rule=rule) self.assertEqual(model.c.equality, False) - self.assertEqual(model.c.lower, None) + self.assertEqual(model.c.lower, float('-inf')) self.assertIs(model.c.body, model.y) - self.assertEqual(model.c.upper, None) + self.assertEqual(model.c.upper, float('inf')) + self.assertEqual(model.c.lb, None) + self.assertEqual(model.c.ub, None) def test_tuple_construct_invalid_1sided_inequality(self): model = self.create_model(abstract=True) @@ -229,7 +269,11 @@ def rule(model): ): instance.c.lower self.assertIs(instance.c.body, instance.y) - self.assertEqual(instance.c.upper, 1) + with self.assertRaisesRegex( + ValueError, + "Constraint 'c' is a Ranged Inequality with a variable lower bound", + ): + instance.c.upper instance.x.fix(3) self.assertEqual(value(instance.c.lower), 3) @@ -240,7 +284,11 @@ def rule(model): model.c = Constraint(rule=rule) instance = model.create_instance() - self.assertEqual(instance.c.lower, 0) + with self.assertRaisesRegex( + ValueError, + "Constraint 'c' is a Ranged Inequality with a variable upper bound", + ): + instance.c.lower self.assertIs(instance.c.body, instance.y) with self.assertRaisesRegex( ValueError, @@ -276,21 +324,23 @@ def rule(model): self.assertEqual(model.c.upper, 0) def test_expr_construct_inf_equality(self): - model = self.create_model(abstract=True) - - def rule(model): - return model.x == float('inf') - - model.c = Constraint(rule=rule) - self.assertRaises(ValueError, model.create_instance) + model = self.create_model(abstract=True).create_instance() - model = self.create_model(abstract=True) - - def rule(model): - return float('inf') == model.x + model.c = Constraint(expr=model.x == float('inf')) + self.assertEqual(model.c.ub, None) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'c' created with an invalid non-finite lower bound \(inf\).", + ): + model.c.lb - model.c = Constraint(rule=rule) - self.assertRaises(ValueError, model.create_instance) + model.d = Constraint(expr=model.x == float('-inf')) + self.assertEqual(model.d.lb, None) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'd' created with an invalid non-finite upper bound \(-inf\).", + ): + model.d.ub def test_expr_construct_1sided_inequality(self): model = self.create_model() @@ -350,9 +400,11 @@ def rule(model): model.c = Constraint(rule=rule) self.assertEqual(model.c.equality, False) - self.assertEqual(model.c.lower, None) + self.assertIs(model.c.lower, None) self.assertIs(model.c.body, model.y) - self.assertEqual(model.c.upper, None) + self.assertEqual(model.c.upper, float('inf')) + self.assertIs(model.c.ub, None) + self.assertIs(model.c.lb, None) model = self.create_model() @@ -362,9 +414,11 @@ def rule(model): model.c = Constraint(rule=rule) self.assertEqual(model.c.equality, False) - self.assertEqual(model.c.lower, None) + self.assertEqual(model.c.lower, float('-inf')) self.assertIs(model.c.body, model.y) self.assertEqual(model.c.upper, None) + self.assertIs(model.c.ub, None) + self.assertIs(model.c.lb, None) model = self.create_model() @@ -374,9 +428,11 @@ def rule(model): model.c = Constraint(rule=rule) self.assertEqual(model.c.equality, False) - self.assertEqual(model.c.lower, None) + self.assertEqual(model.c.lower, float('-inf')) self.assertIs(model.c.body, model.y) self.assertEqual(model.c.upper, None) + self.assertIs(model.c.ub, None) + self.assertIs(model.c.lb, None) model = self.create_model() @@ -388,40 +444,40 @@ def rule(model): self.assertEqual(model.c.equality, False) self.assertEqual(model.c.lower, None) self.assertIs(model.c.body, model.y) - self.assertEqual(model.c.upper, None) + self.assertEqual(model.c.upper, float('inf')) + self.assertIs(model.c.ub, None) + self.assertIs(model.c.lb, None) def test_expr_construct_invalid_unbounded_inequality(self): - model = self.create_model(abstract=True) - - def rule(model): - return model.y <= float('-inf') - - model.c = Constraint(rule=rule) - self.assertRaises(ValueError, model.create_instance) - - model = self.create_model(abstract=True) - - def rule(model): - return float('inf') <= model.y - - model.c = Constraint(rule=rule) - self.assertRaises(ValueError, model.create_instance) - - model = self.create_model(abstract=True) - - def rule(model): - return model.y >= float('inf') + model = self.create_model(abstract=True).create_instance() - model.c = Constraint(rule=rule) - self.assertRaises(ValueError, model.create_instance) + model.c = Constraint(expr=model.y <= float('-inf')) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'c' created with an invalid non-finite upper bound \(-inf\).", + ): + model.c.ub - model = self.create_model(abstract=True) + model.d = Constraint(expr=float('inf') <= model.y) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'd' created with an invalid non-finite lower bound \(inf\).", + ): + model.d.lb - def rule(model): - return float('-inf') >= model.y + model.e = Constraint(expr=model.y >= float('inf')) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'e' created with an invalid non-finite lower bound \(inf\).", + ): + model.e.lb - model.c = Constraint(rule=rule) - self.assertRaises(ValueError, model.create_instance) + model.f = Constraint(expr=float('-inf') >= model.y) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'f' created with an invalid non-finite upper bound \(-inf\).", + ): + model.f.ub def test_expr_construct_invalid(self): m = ConcreteModel() @@ -484,9 +540,6 @@ def test_nondata_bounds(self): model.e2 = Expression() model.e3 = Expression() model.c.set_value((model.e1, model.e2, model.e3)) - self.assertIsNone(model.c._lower) - self.assertIsNone(model.c._body) - self.assertIsNone(model.c._upper) self.assertIs(model.c.lower, model.e1) self.assertIs(model.c.body, model.e2) self.assertIs(model.c.upper, model.e3) @@ -507,7 +560,7 @@ def test_nondata_bounds(self): self.assertIs(model.c.body.expr, model.v[2]) with self.assertRaisesRegex( ValueError, - "Constraint 'c' is a Ranged Inequality with a variable upper bound", + "Constraint 'c' is a Ranged Inequality with a variable lower bound", ): model.c.upper @@ -1074,7 +1127,7 @@ def test_setitem(self): m.c[2] = m.x**2 <= 4 self.assertEqual(len(m.c), 1) self.assertEqual(list(m.c.keys()), [2]) - self.assertIsInstance(m.c[2], _GeneralConstraintData) + self.assertIsInstance(m.c[2], ConstraintData) self.assertEqual(m.c[2].upper, 4) m.c[3] = Constraint.Skip @@ -1388,7 +1441,7 @@ def test_empty_singleton(self): # Even though we construct a ScalarConstraint, # if it is not initialized that means it is "empty" # and we should encounter errors when trying to access the - # _ConstraintData interface methods until we assign + # ConstraintData interface methods until we assign # something to the constraint. # self.assertEqual(a._constructed, True) @@ -1574,10 +1627,30 @@ def rule1(model): self.assertIs(instance.c.body, instance.x) with self.assertRaisesRegex( ValueError, - "Constraint 'c' is a Ranged Inequality with a variable upper bound", + "Constraint 'c' is a Ranged Inequality with a variable lower bound", ): instance.c.upper + # + def rule1(model): + return (0, model.x, model.z) + + model = AbstractModel() + model.x = Var() + model.z = Var() + model.c = Constraint(rule=rule1) + instance = model.create_instance() + with self.assertRaisesRegex( + ValueError, + "Constraint 'c' is a Ranged Inequality with a variable upper bound", + ): + instance.c.lower + self.assertIs(instance.c.body, instance.x) + with self.assertRaisesRegex( + ValueError, + "Constraint 'c' is a Ranged Inequality with a variable upper bound", + ): + instance.c.upper def test_expression_constructor_coverage(self): def rule1(model): @@ -1807,23 +1880,39 @@ def test_potentially_variable_bounds(self): r"Constraint 'c' is a Ranged Inequality with a variable lower bound", ): m.c.lower - self.assertIs(m.c.upper, m.u) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'c' is a Ranged Inequality with a variable lower bound", + ): + self.assertIs(m.c.upper, m.u) with self.assertRaisesRegex( ValueError, r"Constraint 'c' is a Ranged Inequality with a variable lower bound", ): m.c.lb - self.assertEqual(m.c.ub, 10) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'c' is a Ranged Inequality with a variable lower bound", + ): + self.assertEqual(m.c.ub, 10) m.l = 15 m.u.expr = m.x - self.assertIs(m.c.lower, m.l) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'c' is a Ranged Inequality with a variable upper bound", + ): + self.assertIs(m.c.lower, m.l) with self.assertRaisesRegex( ValueError, r"Constraint 'c' is a Ranged Inequality with a variable upper bound", ): m.c.upper - self.assertEqual(m.c.lb, 15) + with self.assertRaisesRegex( + ValueError, + r"Constraint 'c' is a Ranged Inequality with a variable upper bound", + ): + self.assertEqual(m.c.lb, 15) with self.assertRaisesRegex( ValueError, r"Constraint 'c' is a Ranged Inequality with a variable upper bound", @@ -1890,17 +1979,16 @@ def test_tuple_expression(self): ): m.c = (m.x, None) + # You can create it with an infinite value, but then one of the + # bounds will fail: + m.c = (m.x, float('inf')) + self.assertIsNone(m.c.ub) with self.assertRaisesRegex( ValueError, r"Constraint 'c' created with an invalid " r"non-finite lower bound \(inf\)", ): - m.c = (m.x, float('inf')) - - with self.assertRaisesRegex( - ValueError, r"Equality constraint 'c' defined with non-finite term" - ): - m.c = EqualityExpression((m.x, None)) + m.c.lb if __name__ == "__main__": diff --git a/pyomo/core/tests/unit/test_concrete.py b/pyomo/core/tests/unit/test_concrete.py index 9083c5cf7f9..139b0213c6d 100644 --- a/pyomo/core/tests/unit/test_concrete.py +++ b/pyomo/core/tests/unit/test_concrete.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_connector.py b/pyomo/core/tests/unit/test_connector.py index 3871f5f372a..e947ee116cb 100644 --- a/pyomo/core/tests/unit/test_connector.py +++ b/pyomo/core/tests/unit/test_connector.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_deprecation.py b/pyomo/core/tests/unit/test_deprecation.py index 7d718a4bd2a..d79478045d8 100644 --- a/pyomo/core/tests/unit/test_deprecation.py +++ b/pyomo/core/tests/unit/test_deprecation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_derivs.py b/pyomo/core/tests/unit/test_derivs.py index 6a4fc6814b3..d43f1a15a33 100644 --- a/pyomo/core/tests/unit/test_derivs.py +++ b/pyomo/core/tests/unit/test_derivs.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_dict_objects.py b/pyomo/core/tests/unit/test_dict_objects.py index 8260f1ae320..9f8849cc2b5 100644 --- a/pyomo/core/tests/unit/test_dict_objects.py +++ b/pyomo/core/tests/unit/test_dict_objects.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,10 +17,10 @@ ObjectiveDict, ExpressionDict, ) -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.objective import _GeneralObjectiveData -from pyomo.core.base.expression import _GeneralExpressionData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.base.expression import ExpressionData class _TestComponentDictBase(object): @@ -348,10 +348,10 @@ def test_active(self): class TestVarDict(_TestComponentDictBase, unittest.TestCase): - # Note: the updated _GeneralVarData class only takes an optional + # Note: the updated VarData class only takes an optional # parent argument (you no longer pass the domain in) _ctype = VarDict - _cdatatype = lambda self, arg: _GeneralVarData() + _cdatatype = lambda self, arg: VarData() def setUp(self): _TestComponentDictBase.setUp(self) @@ -360,7 +360,7 @@ def setUp(self): class TestExpressionDict(_TestComponentDictBase, unittest.TestCase): _ctype = ExpressionDict - _cdatatype = _GeneralExpressionData + _cdatatype = ExpressionData def setUp(self): _TestComponentDictBase.setUp(self) @@ -375,7 +375,7 @@ def setUp(self): class TestConstraintDict(_TestActiveComponentDictBase, unittest.TestCase): _ctype = ConstraintDict - _cdatatype = _GeneralConstraintData + _cdatatype = ConstraintData def setUp(self): _TestComponentDictBase.setUp(self) @@ -384,7 +384,7 @@ def setUp(self): class TestObjectiveDict(_TestActiveComponentDictBase, unittest.TestCase): _ctype = ObjectiveDict - _cdatatype = _GeneralObjectiveData + _cdatatype = ObjectiveData def setUp(self): _TestComponentDictBase.setUp(self) diff --git a/pyomo/core/tests/unit/test_disable_methods.py b/pyomo/core/tests/unit/test_disable_methods.py index 618752aee85..67dfed01cae 100644 --- a/pyomo/core/tests/unit/test_disable_methods.py +++ b/pyomo/core/tests/unit/test_disable_methods.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_enums.py b/pyomo/core/tests/unit/test_enums.py index cce908a87de..27bda103080 100644 --- a/pyomo/core/tests/unit/test_enums.py +++ b/pyomo/core/tests/unit/test_enums.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_expr_misc.py b/pyomo/core/tests/unit/test_expr_misc.py index f4fd7556117..c4799401def 100644 --- a/pyomo/core/tests/unit/test_expr_misc.py +++ b/pyomo/core/tests/unit/test_expr_misc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_expr_numpy.py b/pyomo/core/tests/unit/test_expr_numpy.py new file mode 100644 index 00000000000..84448bc2e0a --- /dev/null +++ b/pyomo/core/tests/unit/test_expr_numpy.py @@ -0,0 +1,95 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest + +from pyomo.common.dependencies import numpy as np, numpy_available +from pyomo.environ import ConcreteModel, Var, Constraint + + +@unittest.skipUnless(numpy_available, "tests require numpy") +class TestNumpyExpr(unittest.TestCase): + def test_scalar_operations(self): + m = ConcreteModel() + m.x = Var() + + a = np.array(m.x) + self.assertEqual(a.shape, ()) + + self.assertExpressionsEqual(5 * a, 5 * m.x) + self.assertExpressionsEqual(np.array([2, 3]) * a, [2 * m.x, 3 * m.x]) + self.assertExpressionsEqual(np.array([5, 6]) * m.x, [5 * m.x, 6 * m.x]) + self.assertExpressionsEqual(np.array([8, m.x]) * m.x, [8 * m.x, m.x * m.x]) + + a = np.array([m.x]) + self.assertEqual(a.shape, (1,)) + + self.assertExpressionsEqual(5 * a, [5 * m.x]) + self.assertExpressionsEqual(np.array([2, 3]) * a, [2 * m.x, 3 * m.x]) + self.assertExpressionsEqual(np.array([5, 6]) * m.x, [5 * m.x, 6 * m.x]) + self.assertExpressionsEqual(np.array([8, m.x]) * m.x, [8 * m.x, m.x * m.x]) + + def test_vector_operations(self): + m = ConcreteModel() + m.x = Var() + m.y = Var([0, 1, 2]) + + with self.assertRaisesRegex(TypeError, "unsupported operand"): + # TODO: when we finally support a true matrix expression + # system, this test should work + self.assertExpressionsEqual(5 * m.y, [5 * m.y[0], 5 * m.y[1], 5 * m.y[2]]) + + a = np.array(5) + self.assertExpressionsEqual(a * m.y, [5 * m.y[0], 5 * m.y[1], 5 * m.y[2]]) + self.assertExpressionsEqual(m.y * a, [5 * m.y[0], 5 * m.y[1], 5 * m.y[2]]) + a = np.array([5]) + self.assertExpressionsEqual(a * m.y, [5 * m.y[0], 5 * m.y[1], 5 * m.y[2]]) + self.assertExpressionsEqual(m.y * a, [5 * m.y[0], 5 * m.y[1], 5 * m.y[2]]) + + a = np.array(5) + with self.assertRaisesRegex(TypeError, "unsupported operand"): + # TODO: when we finally support a true matrix expression + # system, this test should work + self.assertExpressionsEqual( + a * m.x * m.y, [5 * m.x * m.y[0], 5 * m.x * m.y[1], 5 * m.x * m.y[2]] + ) + self.assertExpressionsEqual( + a * m.y * m.x, [5 * m.y[0] * m.x, 5 * m.y[1] * m.x, 5 * m.y[2] * m.x] + ) + self.assertExpressionsEqual( + a * m.y * m.y, + [5 * m.y[0] * m.y[0], 5 * m.y[1] * m.y[1], 5 * m.y[2] * m.y[2]], + ) + self.assertExpressionsEqual( + m.y * a * m.x, [5 * m.y[0] * m.x, 5 * m.y[1] * m.x, 5 * m.y[2] * m.x] + ) + with self.assertRaisesRegex(TypeError, "unsupported operand"): + # TODO: when we finally support a true matrix expression + # system, this test should work + self.assertExpressionsEqual( + m.y * m.x * a, [5 * m.y[0] * m.x, 5 * m.y[1] * m.x, 5 * m.y[2] * m.x] + ) + with self.assertRaisesRegex(TypeError, "unsupported operand"): + # TODO: when we finally support a true matrix expression + # system, this test should work + self.assertExpressionsEqual( + m.x * a * m.y, [5 * m.y[0] * m.x, 5 * m.y[1] * m.x, 5 * m.y[2] * m.x] + ) + with self.assertRaisesRegex(TypeError, "unsupported operand"): + # TODO: when we finally support a true matrix expression + # system, this test should work + self.assertExpressionsEqual( + m.x * m.y * a, [5 * m.y[0] * m.x, 5 * m.y[1] * m.x, 5 * m.y[2] * m.x] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/core/tests/unit/test_expression.py b/pyomo/core/tests/unit/test_expression.py index c9afc6a1f76..19ed9dafbc6 100644 --- a/pyomo/core/tests/unit/test_expression.py +++ b/pyomo/core/tests/unit/test_expression.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -29,7 +29,8 @@ value, sum_product, ) -from pyomo.core.base.expression import _GeneralExpressionData +from pyomo.core.base.expression import ExpressionData +from pyomo.core.base.objective import ObjectiveData from pyomo.core.expr.compare import compare_expressions, assertExpressionsEqual from pyomo.common.tee import capture_output @@ -290,6 +291,36 @@ def obj_rule(model): self.assertEqual(inst.obj.expr(), 3.0) self.assertEqual(id(inst.obj.expr.arg(1)), id(inst.ec)) + def test_create_node_with_local_data(self): + m = ConcreteModel() + m.x = Var() + + m.e = Expression(expr=m.x) + ee = m.e.create_node_with_local_data([5]) + self.assertIsNot(m.e, ee) + self.assertIs(type(ee), ExpressionData) + self.assertEqual(ee._args_, [5]) + + m.f = Expression([0], rule=lambda m, i: m.x) + ff = m.f[0].create_node_with_local_data([5]) + self.assertIsNot(m.f, ff) + self.assertIsNot(m.f[0], ff) + self.assertIs(type(ff), ExpressionData) + self.assertEqual(ff._args_, [5]) + + m.g = Objective(expr=m.x) + gg = m.g.create_node_with_local_data([5]) + self.assertIsNot(m.g, gg) + self.assertIs(type(gg), ObjectiveData) + self.assertEqual(gg._args_, [5]) + + m.h = Objective([0], rule=lambda m, i: m.x) + hh = m.h[0].create_node_with_local_data([5]) + self.assertIsNot(m.h, hh) + self.assertIsNot(m.h[0], hh) + self.assertIs(type(hh), ObjectiveData) + self.assertEqual(hh._args_, [5]) + class TestExpression(unittest.TestCase): def setUp(self): @@ -515,10 +546,10 @@ def test_implicit_definition(self): model.E = Expression(model.idx) self.assertEqual(len(model.E), 3) expr = model.E[1] - self.assertIs(type(expr), _GeneralExpressionData) + self.assertIs(type(expr), ExpressionData) model.E[1] = None self.assertIs(expr, model.E[1]) - self.assertIs(type(expr), _GeneralExpressionData) + self.assertIs(type(expr), ExpressionData) self.assertIs(expr.expr, None) model.E[1] = 5 self.assertIs(expr, model.E[1]) @@ -537,7 +568,7 @@ def test_explicit_skip_definition(self): model.E[1] = None expr = model.E[1] - self.assertIs(type(expr), _GeneralExpressionData) + self.assertIs(type(expr), ExpressionData) self.assertIs(expr.expr, None) model.E[1] = 5 self.assertIs(expr, model.E[1]) @@ -738,10 +769,10 @@ def test_pprint_oldStyle(self): expr = model.e * model.x**2 + model.E[1] output = """\ -sum(prod(e{sum(mon(1, x), 2)}, pow(x, 2)), E[1]{sum(pow(x, 2), 1)}) +sum(prod(e{sum(x, 2)}, pow(x, 2)), E[1]{sum(pow(x, 2), 1)}) e : Size=1, Index=None Key : Expression - None : sum(mon(1, x), 2) + None : sum(x, 2) E : Size=2, Index={1, 2} Key : Expression 1 : sum(pow(x, 2), 1) @@ -951,12 +982,7 @@ def test_isub(self): assertExpressionsEqual( self, m.e.expr, - EXPR.LinearExpression( - [ - EXPR.MonomialTermExpression((1, m.x)), - EXPR.MonomialTermExpression((-1, m.y)), - ] - ), + EXPR.LinearExpression([m.x, EXPR.MonomialTermExpression((-1, m.y))]), ) self.assertTrue(compare_expressions(m.e.expr, m.x - m.y)) diff --git a/pyomo/core/tests/unit/test_external.py b/pyomo/core/tests/unit/test_external.py index 1d4a59647c1..e23ff33c482 100644 --- a/pyomo/core/tests/unit/test_external.py +++ b/pyomo/core/tests/unit/test_external.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -585,6 +585,38 @@ def test_solve_gsl_function_const_arg(self): res = opt.solve(model, tee=True) self.assertAlmostEqual(value(model.x), 0.1, 5) + @unittest.skipIf( + not check_available_solvers('ipopt_v2'), + "The 'ipopt_v2' solver is not available", + ) + def test_solve_gsl_function(self): + DLL = find_GSL() + if not DLL: + self.skipTest("Could not find the amplgsl.dll library") + model = ConcreteModel() + model.z_func = ExternalFunction(library=DLL, function="gsl_sf_gamma") + model.x = Var(initialize=3, bounds=(1e-5, None)) + model.o = Objective(expr=model.z_func(model.x)) + opt = SolverFactory('ipopt_v2') + res = opt.solve(model, tee=True) + self.assertAlmostEqual(value(model.o), 0.885603194411, 7) + + @unittest.skipIf( + not check_available_solvers('ipopt_v2'), + "The 'ipopt_v2' solver is not available", + ) + def test_solve_gsl_function_const_arg(self): + DLL = find_GSL() + if not DLL: + self.skipTest("Could not find the amplgsl.dll library") + model = ConcreteModel() + model.z_func = ExternalFunction(library=DLL, function="gsl_sf_beta") + model.x = Var(initialize=1, bounds=(0.1, None)) + model.o = Objective(expr=-model.z_func(1, model.x)) + opt = SolverFactory('ipopt_v2') + res = opt.solve(model, tee=True) + self.assertAlmostEqual(value(model.x), 0.1, 5) + @unittest.skipIf( not check_available_solvers('ipopt'), "The 'ipopt' solver is not available" ) diff --git a/pyomo/core/tests/unit/test_indexed.py b/pyomo/core/tests/unit/test_indexed.py index 3480b653ea5..360d4ea43c5 100644 --- a/pyomo/core/tests/unit/test_indexed.py +++ b/pyomo/core/tests/unit/test_indexed.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_indexed_slice.py b/pyomo/core/tests/unit/test_indexed_slice.py index babd3f3c46a..06f45be16b7 100644 --- a/pyomo/core/tests/unit/test_indexed_slice.py +++ b/pyomo/core/tests/unit/test_indexed_slice.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,7 +17,7 @@ import pyomo.common.unittest as unittest from pyomo.environ import Var, Block, ConcreteModel, RangeSet, Set, Any -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.core.base.indexed_component_slice import IndexedComponent_slice from pyomo.core.base.set import normalize_index @@ -64,7 +64,7 @@ def tearDown(self): self.m = None def test_simple_getitem(self): - self.assertIsInstance(self.m.b[1, 4], _BlockData) + self.assertIsInstance(self.m.b[1, 4], BlockData) def test_simple_getslice(self): _slicer = self.m.b[:, 4] diff --git a/pyomo/core/tests/unit/test_initializer.py b/pyomo/core/tests/unit/test_initializer.py index c0f9ddc9565..4a4f683b33a 100644 --- a/pyomo/core/tests/unit/test_initializer.py +++ b/pyomo/core/tests/unit/test_initializer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -27,6 +27,7 @@ from pyomo.core.base.util import flatten_tuple from pyomo.core.base.initializer import ( Initializer, + BoundInitializer, ConstantInitializer, ItemInitializer, ScalarCallInitializer, @@ -35,6 +36,10 @@ CountedCallGenerator, DataFrameInitializer, DefaultInitializer, + ParameterizedInitializer, + ParameterizedIndexedCallInitializer, + ParameterizedScalarCallInitializer, + function_types, ) from pyomo.environ import ConcreteModel, Var @@ -550,6 +555,54 @@ def _indexed(m, i): self.assertFalse(a.verified) self.assertEqual(a(None, 5), 15) + def test_function(self): + def _scalar(m): + return 10 + + a = Initializer(_scalar) + self.assertIs(type(a), ScalarCallInitializer) + self.assertTrue(a.constant()) + self.assertFalse(a.verified) + self.assertEqual(a(None, None), 10) + + def _indexed(m, i): + return 10 + i + + a = Initializer(_indexed) + self.assertIs(type(a), IndexedCallInitializer) + self.assertFalse(a.constant()) + self.assertFalse(a.verified) + self.assertEqual(a(None, 5), 15) + + try: + original_fcn_types = set(function_types) + function_types.clear() + self.assertEqual(len(function_types), 0) + + a = Initializer(_scalar) + self.assertIs(type(a), ScalarCallInitializer) + self.assertTrue(a.constant()) + self.assertFalse(a.verified) + self.assertEqual(a(None, None), 10) + self.assertEqual(len(function_types), 1) + finally: + function_types.clear() + function_types.update(original_fcn_types) + + try: + original_fcn_types = set(function_types) + function_types.clear() + self.assertEqual(len(function_types), 0) + + a = Initializer(_indexed) + self.assertIs(type(a), IndexedCallInitializer) + self.assertFalse(a.constant()) + self.assertFalse(a.verified) + self.assertEqual(a(None, 5), 15) + finally: + function_types.clear() + function_types.update(original_fcn_types) + def test_no_argspec(self): a = Initializer(getattr) self.assertIs(type(a), IndexedCallInitializer) @@ -635,10 +688,26 @@ def test_dataframe(self): d = {'col1': [1, 2, 4], 'col2': [10, 20, 40]} df = pd.DataFrame(data=d) - with self.assertRaisesRegex( - ValueError, 'DataFrameInitializer for DataFrame with multiple columns' - ): - a = Initializer(df) + a = Initializer(df) + self.assertIs(type(a), DataFrameInitializer) + self.assertFalse(a.constant()) + self.assertFalse(a.verified) + self.assertTrue(a.contains_indices()) + self.assertEqual( + list(a.indices()), + [ + (0, 'col1'), + (0, 'col2'), + (1, 'col1'), + (1, 'col2'), + (2, 'col1'), + (2, 'col2'), + ], + ) + self.assertEqual(a(None, (0, 'col1')), 1) + self.assertEqual(a(None, (1, 'col2')), 20) + self.assertEqual(a(None, (2, 'col2')), 40) + a = DataFrameInitializer(df, 'col2') self.assertIs(type(a), DataFrameInitializer) self.assertFalse(a.constant()) @@ -805,3 +874,87 @@ def test_config_integration(self): self.assertEqual(a(None, 'opt_1'), 1) self.assertEqual(a(None, 'opt_3'), 3) self.assertEqual(a(None, 'opt_5'), 5) + + def _bound_function1(self, m, i): + return m, i + + def _bound_function2(self, m, i, j): + return m, i, j + + def test_additional_args(self): + def a_init(m): + yield 0 + yield 3 + + with self.assertRaisesRegex( + ValueError, + "Generator functions are not allowed when passing additional args", + ): + a = Initializer(a_init, additional_args=1) + + a = Initializer(self._bound_function1, additional_args=1) + self.assertIs(type(a), ParameterizedScalarCallInitializer) + self.assertEqual(a('m', None, 5), ('m', 5)) + + a = Initializer(self._bound_function2, additional_args=1) + self.assertIs(type(a), ParameterizedIndexedCallInitializer) + self.assertEqual(a('m', 1, 5), ('m', 5, 1)) + + class Functor(object): + def __init__(self, i): + self.i = i + + def __call__(self, m, i): + return m, i * self.i + + a = Initializer(Functor(10), additional_args=1) + self.assertIs(type(a), ParameterizedScalarCallInitializer) + self.assertEqual(a('m', None, 5), ('m', 50)) + + a_init = {1: lambda m, i: ('m', i), 2: lambda m, i: ('m', 2 * i)} + a = Initializer(a_init, additional_args=1) + self.assertIs(type(a), ParameterizedInitializer) + self.assertFalse(a.constant()) + self.assertTrue(a.contains_indices()) + self.assertEqual(list(a.indices()), [1, 2]) + self.assertEqual(a('m', 1, 5), ('m', 5)) + self.assertEqual(a('m', 2, 5), ('m', 10)) + + def test_bound_initializer(self): + m = ConcreteModel() + m.x = Var([0, 1, 2]) + m.y = Var() + + b = BoundInitializer(None, m.x) + self.assertIsNone(b) + + b = BoundInitializer((0, 1), m.x) + self.assertIs(type(b), BoundInitializer) + self.assertTrue(b.constant()) + self.assertFalse(b.verified) + self.assertFalse(b.contains_indices()) + self.assertEqual(b(None, 1), (0, 1)) + + b = BoundInitializer([(0, 1)], m.x) + self.assertIs(type(b), BoundInitializer) + self.assertFalse(b.constant()) + self.assertFalse(b.verified) + self.assertTrue(b.contains_indices()) + self.assertTrue(list(b.indices()), [0]) + self.assertEqual(b(None, 0), (0, 1)) + + init = {1: (2, 3), 4: (5, 6)} + b = BoundInitializer(init, m.x) + self.assertIs(type(b), BoundInitializer) + self.assertFalse(b.constant()) + self.assertFalse(b.verified) + self.assertTrue(b.contains_indices()) + self.assertEqual(list(b.indices()), [1, 4]) + self.assertEqual(b(None, 1), (2, 3)) + self.assertEqual(b(None, 4), (5, 6)) + + b = BoundInitializer((0, 1), m.y) + self.assertEqual(b(None, None), (0, 1)) + + b = BoundInitializer(5, m.y) + self.assertEqual(b(None, None), (5, 5)) diff --git a/pyomo/core/tests/unit/test_kernel_register_numpy_types.py b/pyomo/core/tests/unit/test_kernel_register_numpy_types.py index 91a0f571881..493b3b0828d 100644 --- a/pyomo/core/tests/unit/test_kernel_register_numpy_types.py +++ b/pyomo/core/tests/unit/test_kernel_register_numpy_types.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,7 +16,10 @@ # Boolean numpy_bool_names = [] if numpy_available: - numpy_bool_names.append('bool_') + if numpy.__version__[0] == '2': + numpy_bool_names.append('bool') + else: + numpy_bool_names.append('bool_') # Integers numpy_int_names = [] if numpy_available: @@ -34,7 +37,8 @@ # Reals numpy_float_names = [] if numpy_available: - numpy_float_names.append('float_') + if hasattr(numpy, 'float_'): + numpy_float_names.append('float_') numpy_float_names.append('float16') numpy_float_names.append('float32') numpy_float_names.append('float64') @@ -46,7 +50,8 @@ # Complex numpy_complex_names = [] if numpy_available: - numpy_complex_names.append('complex_') + if hasattr(numpy, 'complex_'): + numpy_complex_names.append('complex_') numpy_complex_names.append('complex64') numpy_complex_names.append('complex128') if hasattr(numpy, 'complex192'): diff --git a/pyomo/core/tests/unit/test_labelers.py b/pyomo/core/tests/unit/test_labelers.py index 579abfd8b52..791de2ea6f7 100644 --- a/pyomo/core/tests/unit/test_labelers.py +++ b/pyomo/core/tests/unit/test_labelers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_list_objects.py b/pyomo/core/tests/unit/test_list_objects.py index 3eb2e279964..379808aeb61 100644 --- a/pyomo/core/tests/unit/test_list_objects.py +++ b/pyomo/core/tests/unit/test_list_objects.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,10 +17,10 @@ XObjectiveList, XExpressionList, ) -from pyomo.core.base.var import _GeneralVarData -from pyomo.core.base.constraint import _GeneralConstraintData -from pyomo.core.base.objective import _GeneralObjectiveData -from pyomo.core.base.expression import _GeneralExpressionData +from pyomo.core.base.var import VarData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.base.expression import ExpressionData class _TestComponentListBase(object): @@ -365,10 +365,10 @@ def test_active(self): class TestVarList(_TestComponentListBase, unittest.TestCase): - # Note: the updated _GeneralVarData class only takes an optional + # Note: the updated VarData class only takes an optional # parent argument (you no longer pass the domain in) _ctype = XVarList - _cdatatype = lambda self, arg: _GeneralVarData() + _cdatatype = lambda self, arg: VarData() def setUp(self): _TestComponentListBase.setUp(self) @@ -377,7 +377,7 @@ def setUp(self): class TestExpressionList(_TestComponentListBase, unittest.TestCase): _ctype = XExpressionList - _cdatatype = _GeneralExpressionData + _cdatatype = ExpressionData def setUp(self): _TestComponentListBase.setUp(self) @@ -392,7 +392,7 @@ def setUp(self): class TestConstraintList(_TestActiveComponentListBase, unittest.TestCase): _ctype = XConstraintList - _cdatatype = _GeneralConstraintData + _cdatatype = ConstraintData def setUp(self): _TestComponentListBase.setUp(self) @@ -401,7 +401,7 @@ def setUp(self): class TestObjectiveList(_TestActiveComponentListBase, unittest.TestCase): _ctype = XObjectiveList - _cdatatype = _GeneralObjectiveData + _cdatatype = ObjectiveData def setUp(self): _TestComponentListBase.setUp(self) diff --git a/pyomo/core/tests/unit/test_logical_constraint.py b/pyomo/core/tests/unit/test_logical_constraint.py index b1f37996018..939178f618d 100644 --- a/pyomo/core/tests/unit/test_logical_constraint.py +++ b/pyomo/core/tests/unit/test_logical_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_logical_expr_expanded.py b/pyomo/core/tests/unit/test_logical_expr_expanded.py index 6468a21e336..0e67e084696 100644 --- a/pyomo/core/tests/unit/test_logical_expr_expanded.py +++ b/pyomo/core/tests/unit/test_logical_expr_expanded.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_logical_to_linear.py b/pyomo/core/tests/unit/test_logical_to_linear.py index e777259f8ce..23e3fca3927 100644 --- a/pyomo/core/tests/unit/test_logical_to_linear.py +++ b/pyomo/core/tests/unit/test_logical_to_linear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_lp_dual.py b/pyomo/core/tests/unit/test_lp_dual.py new file mode 100644 index 00000000000..40dcca468cd --- /dev/null +++ b/pyomo/core/tests/unit/test_lp_dual.py @@ -0,0 +1,400 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import scipy_available +import pyomo.common.unittest as unittest +from pyomo.environ import ( + Binary, + ConcreteModel, + Constraint, + maximize, + minimize, + NonNegativeReals, + NonPositiveReals, + Objective, + Reals, + Suffix, + TerminationCondition, + TransformationFactory, + value, + Var, +) +from pyomo.core.expr.compare import ( + assertExpressionsEqual, + assertExpressionsStructurallyEqual, +) +from pyomo.opt import SolverFactory, WriterFactory + + +@unittest.skipUnless(scipy_available, "Scipy not available") +class TestLPDual(unittest.TestCase): + def check_primal_dual_solns(self, m, dual): + lp_dual = TransformationFactory('core.lp_dual') + + m.dual = Suffix(direction=Suffix.IMPORT) + dual.dual = Suffix(direction=Suffix.IMPORT) + + opt = SolverFactory('gurobi') + results = opt.solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + results = opt.solve(dual) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + + self.assertAlmostEqual(value(m.obj), value(dual.obj)) + for cons in [m.c1, m.c2, m.c3, m.c4]: + self.assertAlmostEqual( + value(lp_dual.get_dual_var(dual, cons)), value(m.dual[cons]) + ) + for v in [m.x, m.y, m.z]: + self.assertAlmostEqual( + value(v), value(dual.dual[lp_dual.get_dual_constraint(dual, v)]) + ) + + @unittest.skipUnless( + SolverFactory('gurobi').available(exception_flag=False) + and SolverFactory('gurobi').license_is_valid(), + "Gurobi is not available", + ) + def test_lp_dual_solve(self): + m = ConcreteModel() + m.x = Var(domain=NonNegativeReals) + m.y = Var(domain=NonPositiveReals) + m.z = Var(domain=Reals) + + m.obj = Objective(expr=m.x + 2 * m.y - 3 * m.z) + m.c1 = Constraint(expr=-4 * m.x - 2 * m.y - m.z <= -5) + m.c2 = Constraint(expr=m.x + m.y <= 3) + m.c3 = Constraint(expr=-m.y - m.z <= -4.2) + m.c4 = Constraint(expr=m.z <= 42) + + lp_dual = TransformationFactory('core.lp_dual') + dual = lp_dual.create_using(m) + + self.check_primal_dual_solns(m, dual) + + def test_lp_dual(self): + m = ConcreteModel() + m.x = Var(domain=NonNegativeReals) + m.y = Var(domain=NonPositiveReals) + m.z = Var(domain=Reals) + + m.obj = Objective(expr=m.x + 2 * m.y - 3 * m.z) + m.c1 = Constraint(expr=-4 * m.x - 2 * m.y - m.z <= -5) + m.c2 = Constraint(expr=m.x + m.y >= 3) + m.c3 = Constraint(expr=-m.y - m.z == -4.2) + m.c4 = Constraint(expr=m.z <= 42) + + lp_dual = TransformationFactory('core.lp_dual') + dual = lp_dual.create_using(m) + + alpha = lp_dual.get_dual_var(dual, m.c1) + beta = lp_dual.get_dual_var(dual, m.c2) + lamb = lp_dual.get_dual_var(dual, m.c3) + xi = lp_dual.get_dual_var(dual, m.c4) + + self.assertIs(lp_dual.get_primal_constraint(dual, alpha), m.c1) + self.assertIs(lp_dual.get_primal_constraint(dual, beta), m.c2) + self.assertIs(lp_dual.get_primal_constraint(dual, lamb), m.c3) + self.assertIs(lp_dual.get_primal_constraint(dual, xi), m.c4) + + dx = lp_dual.get_dual_constraint(dual, m.x) + dy = lp_dual.get_dual_constraint(dual, m.y) + dz = lp_dual.get_dual_constraint(dual, m.z) + + self.assertIs(lp_dual.get_primal_var(dual, dx), m.x) + self.assertIs(lp_dual.get_primal_var(dual, dy), m.y) + self.assertIs(lp_dual.get_primal_var(dual, dz), m.z) + + self.assertIs(alpha.ctype, Var) + self.assertEqual(alpha.domain, NonPositiveReals) + self.assertEqual(alpha.ub, 0) + self.assertIsNone(alpha.lb) + self.assertIs(beta.ctype, Var) + self.assertEqual(beta.domain, NonNegativeReals) + self.assertEqual(beta.lb, 0) + self.assertIsNone(beta.ub) + self.assertIs(lamb.ctype, Var) + self.assertEqual(lamb.domain, Reals) + self.assertIsNone(lamb.ub) + self.assertIsNone(lamb.lb) + self.assertIs(xi.ctype, Var) + self.assertEqual(xi.domain, NonPositiveReals) + self.assertEqual(xi.ub, 0) + self.assertIsNone(xi.lb) + + self.assertIs(dx.ctype, Constraint) + self.assertIs(dy.ctype, Constraint) + self.assertIs(dz.ctype, Constraint) + + assertExpressionsStructurallyEqual(self, dx.expr, -4.0 * alpha + beta <= 1.0) + assertExpressionsStructurallyEqual( + self, dy.expr, -2.0 * alpha + beta - lamb >= 2.0 + ) + assertExpressionsStructurallyEqual( + self, dz.expr, -alpha - 1.0 * lamb + xi == -3.0 + ) + + dual_obj = dual.obj + self.assertIsInstance(dual_obj, Objective) + self.assertEqual(dual_obj.sense, maximize) + assertExpressionsEqual( + self, dual_obj.expr, -5 * alpha + 3 * beta - 4.2 * lamb + 42 * xi + ) + + ## + # now go the other way and recover the primal + ## + + primal = lp_dual.create_using(dual) + + x = lp_dual.get_dual_var(primal, dx) + y = lp_dual.get_dual_var(primal, dy) + z = lp_dual.get_dual_var(primal, dz) + + self.assertIs(x.ctype, Var) + self.assertEqual(x.domain, NonNegativeReals) + self.assertEqual(x.lb, 0) + self.assertIsNone(x.ub) + self.assertIs(y.ctype, Var) + self.assertEqual(y.domain, NonPositiveReals) + self.assertIsNone(y.lb) + self.assertEqual(y.ub, 0) + self.assertIs(z.ctype, Var) + self.assertEqual(z.domain, Reals) + self.assertIsNone(z.lb) + self.assertIsNone(z.ub) + + self.assertIs(lp_dual.get_primal_constraint(primal, x), dx) + self.assertIs(lp_dual.get_primal_constraint(primal, y), dy) + self.assertIs(lp_dual.get_primal_constraint(primal, z), dz) + + dalpha = lp_dual.get_dual_constraint(primal, alpha) + dbeta = lp_dual.get_dual_constraint(primal, beta) + dlambda = lp_dual.get_dual_constraint(primal, lamb) + dxi = lp_dual.get_dual_constraint(primal, xi) + + self.assertIs(lp_dual.get_primal_var(primal, dalpha), alpha) + self.assertIs(lp_dual.get_primal_var(primal, dbeta), beta) + self.assertIs(lp_dual.get_primal_var(primal, dlambda), lamb) + self.assertIs(lp_dual.get_primal_var(primal, dxi), xi) + + self.assertIs(dalpha.ctype, Constraint) + self.assertIs(dbeta.ctype, Constraint) + self.assertIs(dlambda.ctype, Constraint) + self.assertIs(dxi.ctype, Constraint) + + assertExpressionsStructurallyEqual( + self, dalpha.expr, -4.0 * x - 2.0 * y - z <= -5.0 + ) + assertExpressionsStructurallyEqual(self, dbeta.expr, x + y >= 3.0) + assertExpressionsStructurallyEqual(self, dlambda.expr, -y - z == -4.2) + assertExpressionsStructurallyEqual(self, dxi.expr, z <= 42.0) + + primal_obj = primal.obj + self.assertIsInstance(primal_obj, Objective) + self.assertEqual(primal_obj.sense, minimize) + assertExpressionsEqual(self, primal_obj.expr, x + 2.0 * y - 3.0 * z) + + def get_bilevel_model(self): + m = ConcreteModel(name='primal') + + m.outer1 = Var(domain=Binary) + m.outer = Var([2, 3], domain=Binary) + + m.x = Var(domain=NonNegativeReals) + m.y = Var(domain=NonPositiveReals) + m.z = Var(domain=Reals) + + m.obj = Objective(expr=m.x + 2 * m.y - 3 * m.outer[3] * m.z) + m.c1 = Constraint(expr=-4 * m.x - 2 * m.y - m.z <= -5 * m.outer1) + m.c2 = Constraint(expr=m.x + m.outer[2] * m.y >= 3) + m.c3 = Constraint(expr=-m.y - m.z == -4.2) + m.c4 = Constraint(expr=m.z <= 42) + + return m + + def test_parameterized_linear_dual(self): + m = self.get_bilevel_model() + + lp_dual = TransformationFactory('core.lp_dual') + dual = lp_dual.create_using(m, parameterize_wrt=[m.outer1, m.outer]) + + alpha = lp_dual.get_dual_var(dual, m.c1) + beta = lp_dual.get_dual_var(dual, m.c2) + lamb = lp_dual.get_dual_var(dual, m.c3) + mu = lp_dual.get_dual_var(dual, m.c4) + + self.assertIs(lp_dual.get_primal_constraint(dual, alpha), m.c1) + self.assertIs(lp_dual.get_primal_constraint(dual, beta), m.c2) + self.assertIs(lp_dual.get_primal_constraint(dual, lamb), m.c3) + self.assertIs(lp_dual.get_primal_constraint(dual, mu), m.c4) + + dx = lp_dual.get_dual_constraint(dual, m.x) + dy = lp_dual.get_dual_constraint(dual, m.y) + dz = lp_dual.get_dual_constraint(dual, m.z) + + self.assertIs(lp_dual.get_primal_var(dual, dx), m.x) + self.assertIs(lp_dual.get_primal_var(dual, dy), m.y) + self.assertIs(lp_dual.get_primal_var(dual, dz), m.z) + + self.assertIs(alpha.ctype, Var) + self.assertEqual(alpha.domain, NonPositiveReals) + self.assertEqual(alpha.ub, 0) + self.assertIsNone(alpha.lb) + self.assertIs(beta.ctype, Var) + self.assertEqual(beta.domain, NonNegativeReals) + self.assertEqual(beta.lb, 0) + self.assertIsNone(beta.ub) + self.assertIs(lamb.ctype, Var) + self.assertEqual(lamb.domain, Reals) + self.assertIsNone(lamb.ub) + self.assertIsNone(lamb.lb) + self.assertIs(mu.ctype, Var) + self.assertEqual(mu.domain, NonPositiveReals) + self.assertEqual(mu.ub, 0) + self.assertIsNone(mu.lb) + + self.assertIs(dx.ctype, Constraint) + self.assertIs(dy.ctype, Constraint) + self.assertIs(dz.ctype, Constraint) + + assertExpressionsStructurallyEqual(self, dx.expr, -4.0 * alpha + beta <= 1.0) + assertExpressionsStructurallyEqual( + self, dy.expr, -2.0 * alpha + m.outer[2] * beta - lamb >= 2.0 + ) + assertExpressionsStructurallyEqual( + self, dz.expr, -alpha - 1.0 * lamb + mu == -3.0 * m.outer[3] + ) + + dual_obj = dual.obj + self.assertIsInstance(dual_obj, Objective) + self.assertEqual(dual_obj.sense, maximize) + assertExpressionsEqual( + self, dual_obj.expr, -5 * m.outer1 * alpha + 3 * beta - 4.2 * lamb + 42 * mu + ) + + @unittest.skipUnless( + SolverFactory('gurobi').available(exception_flag=False) + and SolverFactory('gurobi').license_is_valid(), + "Gurobi is not available", + ) + def test_solve_parameterized_lp_dual(self): + m = self.get_bilevel_model() + + lp_dual = TransformationFactory('core.lp_dual') + dual = lp_dual.create_using(m, parameterize_wrt=[m.outer1, m.outer]) + + # We just check half of the possible permutations since we're calling a + # solver twice for all of these: + m.outer1.fix(1) + m.outer[2].fix(1) + m.outer[3].fix(1) + + self.check_primal_dual_solns(m, dual) + + m.outer1.fix(0) + m.outer[2].fix(1) + m.outer[3].fix(0) + + self.check_primal_dual_solns(m, dual) + + m.outer1.fix(0) + m.outer[2].fix(0) + m.outer[3].fix(0) + + self.check_primal_dual_solns(m, dual) + + m.outer1.fix(1) + m.outer[2].fix(1) + m.outer[3].fix(0) + + self.check_primal_dual_solns(m, dual) + + def test_multiple_obj_error(self): + m = self.get_bilevel_model() + m.obj.deactivate() + + lp_dual = TransformationFactory('core.lp_dual') + + with self.assertRaisesRegex( + ValueError, + "Model 'primal' has no objective or multiple active objectives. " + "Can only take dual with exactly one active objective!", + ): + dual = lp_dual.create_using(m, parameterize_wrt=[m.outer1, m.outer]) + + m.obj.activate() + m.obj2 = Objective(expr=m.outer1 + m.outer[3]) + + with self.assertRaisesRegex( + ValueError, + "Model 'primal' has no objective or multiple active objectives. " + "Can only take dual with exactly one active objective!", + ): + dual = lp_dual.create_using(m, parameterize_wrt=[m.outer1, m.outer]) + + def test_primal_constraint_map_error(self): + m = self.get_bilevel_model() + + lp_dual = TransformationFactory('core.lp_dual') + dual = lp_dual.create_using(m, parameterize_wrt=[m.outer1, m.outer]) + + with self.assertRaisesRegex( + ValueError, + "It does not appear that Var 'x' is a dual variable on model " + "'primal dual'", + ): + thing = lp_dual.get_primal_constraint(dual, m.x) + + def test_dual_constraint_map_error(self): + m = self.get_bilevel_model() + + lp_dual = TransformationFactory('core.lp_dual') + dual = lp_dual.create_using(m, parameterize_wrt=[m.outer1, m.outer]) + + with self.assertRaisesRegex( + ValueError, + "It does not appear that Var 'outer1' is a primal variable on model " + "'primal'", + ): + thing = lp_dual.get_dual_constraint(m, m.outer1) + + def test_primal_var_map_error(self): + m = self.get_bilevel_model() + + lp_dual = TransformationFactory('core.lp_dual') + dual = lp_dual.create_using(m, parameterize_wrt=[m.outer1, m.outer]) + + with self.assertRaisesRegex( + ValueError, + "It does not appear that Constraint 'c1' is a dual constraint " + "on model 'primal dual'", + ): + thing = lp_dual.get_primal_var(dual, m.c1) + + def test_dual_var_map_error(self): + m = self.get_bilevel_model() + + lp_dual = TransformationFactory('core.lp_dual') + dual = lp_dual.create_using(m, parameterize_wrt=[m.outer1, m.outer]) + + m.c_new = Constraint(expr=m.x + m.y <= 35) + + with self.assertRaisesRegex( + ValueError, + "It does not appear that Constraint 'c_new' is a primal constraint " + "on model 'primal'", + ): + thing = lp_dual.get_dual_var(m, m.c_new) diff --git a/pyomo/core/tests/unit/test_matrix_constraint.py b/pyomo/core/tests/unit/test_matrix_constraint.py index 993e2a18eb3..73a0c25bcc1 100644 --- a/pyomo/core/tests/unit/test_matrix_constraint.py +++ b/pyomo/core/tests/unit/test_matrix_constraint.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_misc.py b/pyomo/core/tests/unit/test_misc.py index 440c8807358..0c57d80a08c 100644 --- a/pyomo/core/tests/unit/test_misc.py +++ b/pyomo/core/tests/unit/test_misc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_model.py b/pyomo/core/tests/unit/test_model.py index 9016f9937c0..2b0c6fc794e 100644 --- a/pyomo/core/tests/unit/test_model.py +++ b/pyomo/core/tests/unit/test_model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_mutable.py b/pyomo/core/tests/unit/test_mutable.py index d10622d84c0..1714c884835 100644 --- a/pyomo/core/tests/unit/test_mutable.py +++ b/pyomo/core/tests/unit/test_mutable.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_numeric_expr.py b/pyomo/core/tests/unit/test_numeric_expr.py index c073ee0f726..5e35d4b6513 100644 --- a/pyomo/core/tests/unit/test_numeric_expr.py +++ b/pyomo/core/tests/unit/test_numeric_expr.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -112,7 +112,7 @@ from pyomo.core.base.label import NumericLabeler from pyomo.core.expr.template_expr import IndexTemplate from pyomo.core.expr import expr_common -from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.var import VarData from pyomo.repn import generate_standard_repn from pyomo.core.expr.numvalue import NumericValue @@ -294,7 +294,7 @@ def value_check(self, exp, val): class TestExpression_EvaluateVarData(TestExpression_EvaluateNumericValue): def create(self, val, domain): - tmp = _GeneralVarData() + tmp = VarData() tmp.domain = domain tmp.value = val return tmp @@ -638,12 +638,7 @@ def test_simpleSum(self): m.b = Var() e = m.a + m.b # - self.assertExpressionsEqual( - e, - LinearExpression( - [MonomialTermExpression((1, m.a)), MonomialTermExpression((1, m.b))] - ), - ) + self.assertExpressionsEqual(e, LinearExpression([m.a, m.b])) self.assertRaises(KeyError, e.arg, 3) @@ -654,14 +649,7 @@ def test_simpleSum_API(self): e = m.a + m.b e += 2 * m.a self.assertExpressionsEqual( - e, - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((1, m.b)), - MonomialTermExpression((2, m.a)), - ] - ), + e, LinearExpression([m.a, m.b, MonomialTermExpression((2, m.a))]) ) def test_constSum(self): @@ -669,13 +657,9 @@ def test_constSum(self): m = AbstractModel() m.a = Var() # - self.assertExpressionsEqual( - m.a + 5, LinearExpression([MonomialTermExpression((1, m.a)), 5]) - ) + self.assertExpressionsEqual(m.a + 5, LinearExpression([m.a, 5])) - self.assertExpressionsEqual( - 5 + m.a, LinearExpression([5, MonomialTermExpression((1, m.a))]) - ) + self.assertExpressionsEqual(5 + m.a, LinearExpression([5, m.a])) def test_nestedSum(self): # @@ -696,12 +680,7 @@ def test_nestedSum(self): # a b e1 = m.a + m.b e = e1 + 5 - self.assertExpressionsEqual( - e, - LinearExpression( - [MonomialTermExpression((1, m.a)), MonomialTermExpression((1, m.b)), 5] - ), - ) + self.assertExpressionsEqual(e, LinearExpression([m.a, m.b, 5])) # + # / \ @@ -710,12 +689,7 @@ def test_nestedSum(self): # a b e1 = m.a + m.b e = 5 + e1 - self.assertExpressionsEqual( - e, - LinearExpression( - [MonomialTermExpression((1, m.a)), MonomialTermExpression((1, m.b)), 5] - ), - ) + self.assertExpressionsEqual(e, LinearExpression([m.a, m.b, 5])) # + # / \ @@ -724,16 +698,7 @@ def test_nestedSum(self): # a b e1 = m.a + m.b e = e1 + m.c - self.assertExpressionsEqual( - e, - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((1, m.b)), - MonomialTermExpression((1, m.c)), - ] - ), - ) + self.assertExpressionsEqual(e, LinearExpression([m.a, m.b, m.c])) # + # / \ @@ -742,16 +707,7 @@ def test_nestedSum(self): # a b e1 = m.a + m.b e = m.c + e1 - self.assertExpressionsEqual( - e, - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((1, m.b)), - MonomialTermExpression((1, m.c)), - ] - ), - ) + self.assertExpressionsEqual(e, LinearExpression([m.a, m.b, m.c])) # + # / \ @@ -762,17 +718,7 @@ def test_nestedSum(self): e2 = m.c + m.d e = e1 + e2 # - self.assertExpressionsEqual( - e, - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((1, m.b)), - MonomialTermExpression((1, m.c)), - MonomialTermExpression((1, m.d)), - ] - ), - ) + self.assertExpressionsEqual(e, LinearExpression([m.a, m.b, m.c, m.d])) def test_nestedSum2(self): # @@ -798,22 +744,7 @@ def test_nestedSum2(self): self.assertExpressionsEqual( e, - SumExpression( - [ - ProductExpression( - ( - 2, - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((1, m.b)), - ] - ), - ) - ), - m.c, - ] - ), + SumExpression([ProductExpression((2, LinearExpression([m.a, m.b]))), m.c]), ) # * @@ -834,20 +765,7 @@ def test_nestedSum2(self): ( 3, SumExpression( - [ - ProductExpression( - ( - 2, - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((1, m.b)), - ] - ), - ) - ), - m.c, - ] + [ProductExpression((2, LinearExpression([m.a, m.b]))), m.c] ), ) ), @@ -891,10 +809,7 @@ def test_sumOf_nestedTrivialProduct(self): e = e1 + m.b # self.assertExpressionsEqual( - e, - LinearExpression( - [MonomialTermExpression((5, m.a)), MonomialTermExpression((1, m.b))] - ), + e, LinearExpression([MonomialTermExpression((5, m.a)), m.b]) ) # + @@ -905,10 +820,7 @@ def test_sumOf_nestedTrivialProduct(self): e = m.b + e1 # self.assertExpressionsEqual( - e, - LinearExpression( - [MonomialTermExpression((1, m.b)), MonomialTermExpression((5, m.a))] - ), + e, LinearExpression([m.b, MonomialTermExpression((5, m.a))]) ) # + @@ -920,14 +832,7 @@ def test_sumOf_nestedTrivialProduct(self): e = e1 + e2 # self.assertExpressionsEqual( - e, - LinearExpression( - [ - MonomialTermExpression((1, m.b)), - MonomialTermExpression((1, m.c)), - MonomialTermExpression((5, m.a)), - ] - ), + e, LinearExpression([m.b, m.c, MonomialTermExpression((5, m.a))]) ) # + @@ -939,14 +844,7 @@ def test_sumOf_nestedTrivialProduct(self): e = e2 + e1 # self.assertExpressionsEqual( - e, - LinearExpression( - [ - MonomialTermExpression((1, m.b)), - MonomialTermExpression((1, m.c)), - MonomialTermExpression((5, m.a)), - ] - ), + e, LinearExpression([m.b, m.c, MonomialTermExpression((5, m.a))]) ) def test_simpleDiff(self): @@ -962,10 +860,7 @@ def test_simpleDiff(self): # a b e = m.a - m.b self.assertExpressionsEqual( - e, - LinearExpression( - [MonomialTermExpression((1, m.a)), MonomialTermExpression((-1, m.b))] - ), + e, LinearExpression([m.a, MonomialTermExpression((-1, m.b))]) ) def test_constDiff(self): @@ -978,9 +873,7 @@ def test_constDiff(self): # - # / \ # a 5 - self.assertExpressionsEqual( - m.a - 5, LinearExpression([MonomialTermExpression((1, m.a)), -5]) - ) + self.assertExpressionsEqual(m.a - 5, LinearExpression([m.a, -5])) # - # / \ @@ -1002,10 +895,7 @@ def test_paramDiff(self): # a p e = m.a - m.p self.assertExpressionsEqual( - e, - LinearExpression( - [MonomialTermExpression((1, m.a)), NPV_NegationExpression((m.p,))] - ), + e, LinearExpression([m.a, NPV_NegationExpression((m.p,))]) ) # - @@ -1079,14 +969,7 @@ def test_nestedDiff(self): e1 = m.a - m.b e = e1 - 5 self.assertExpressionsEqual( - e, - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((-1, m.b)), - -5, - ] - ), + e, LinearExpression([m.a, MonomialTermExpression((-1, m.b)), -5]) ) # - @@ -1102,14 +985,7 @@ def test_nestedDiff(self): [ 5, NegationExpression( - ( - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((-1, m.b)), - ] - ), - ) + (LinearExpression([m.a, MonomialTermExpression((-1, m.b))]),) ), ] ), @@ -1126,7 +1002,7 @@ def test_nestedDiff(self): e, LinearExpression( [ - MonomialTermExpression((1, m.a)), + m.a, MonomialTermExpression((-1, m.b)), MonomialTermExpression((-1, m.c)), ] @@ -1146,14 +1022,7 @@ def test_nestedDiff(self): [ m.c, NegationExpression( - ( - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((-1, m.b)), - ] - ), - ) + (LinearExpression([m.a, MonomialTermExpression((-1, m.b))]),) ), ] ), @@ -1171,21 +1040,9 @@ def test_nestedDiff(self): e, SumExpression( [ - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((-1, m.b)), - ] - ), + LinearExpression([m.a, MonomialTermExpression((-1, m.b))]), NegationExpression( - ( - LinearExpression( - [ - MonomialTermExpression((1, m.c)), - MonomialTermExpression((-1, m.d)), - ] - ), - ) + (LinearExpression([m.c, MonomialTermExpression((-1, m.d))]),) ), ] ), @@ -1382,10 +1239,7 @@ def test_sumOf_nestedTrivialProduct2(self): self.assertExpressionsEqual( e, LinearExpression( - [ - MonomialTermExpression((1, m.b)), - MonomialTermExpression((NPV_NegationExpression((m.p,)), m.a)), - ] + [m.b, MonomialTermExpression((NPV_NegationExpression((m.p,)), m.a))] ), ) @@ -1403,14 +1257,7 @@ def test_sumOf_nestedTrivialProduct2(self): [ MonomialTermExpression((m.p, m.a)), NegationExpression( - ( - LinearExpression( - [ - MonomialTermExpression((1, m.b)), - MonomialTermExpression((-1, m.c)), - ] - ), - ) + (LinearExpression([m.b, MonomialTermExpression((-1, m.c))]),) ), ] ), @@ -1424,12 +1271,11 @@ def test_sumOf_nestedTrivialProduct2(self): e1 = m.a * m.p e2 = m.b - m.c e = e2 - e1 - self.maxDiff = None self.assertExpressionsEqual( e, LinearExpression( [ - MonomialTermExpression((1, m.b)), + m.b, MonomialTermExpression((-1, m.c)), MonomialTermExpression((NPV_NegationExpression((m.p,)), m.a)), ] @@ -1599,22 +1445,7 @@ def test_nestedProduct2(self): self.assertExpressionsEqual( e, ProductExpression( - ( - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((1, m.b)), - MonomialTermExpression((1, m.c)), - ] - ), - LinearExpression( - [ - MonomialTermExpression((1, m.a)), - MonomialTermExpression((1, m.b)), - MonomialTermExpression((1, m.d)), - ] - ), - ) + (LinearExpression([m.a, m.b, m.c]), LinearExpression([m.a, m.b, m.d])) ), ) # Verify shared args... @@ -1639,9 +1470,7 @@ def test_nestedProduct2(self): e3 = e1 * m.d e = e2 * e3 # - inner = LinearExpression( - [MonomialTermExpression((1, m.a)), MonomialTermExpression((1, m.b))] - ) + inner = LinearExpression([m.a, m.b]) self.assertExpressionsEqual( e, ProductExpression( @@ -2035,10 +1864,10 @@ def test_sum(self): model.p = Param(mutable=True) expr = 5 + model.a + model.a - self.assertEqual("sum(5, mon(1, a), mon(1, a))", str(expr)) + self.assertEqual("sum(5, a, a)", str(expr)) expr += 5 - self.assertEqual("sum(5, mon(1, a), mon(1, a), 5)", str(expr)) + self.assertEqual("sum(5, a, a, 5)", str(expr)) expr = 2 + model.p self.assertEqual("sum(2, p)", str(expr)) @@ -2054,24 +1883,18 @@ def test_linearsum(self): expr = quicksum(i * model.a[i] for i in A) self.assertEqual( - "sum(mon(0, a[0]), mon(1, a[1]), mon(2, a[2]), mon(3, a[3]), " - "mon(4, a[4]))", + "sum(mon(0, a[0]), a[1], mon(2, a[2]), mon(3, a[3]), " "mon(4, a[4]))", str(expr), ) expr = quicksum((i - 2) * model.a[i] for i in A) self.assertEqual( - "sum(mon(-2, a[0]), mon(-1, a[1]), mon(0, a[2]), mon(1, a[3]), " - "mon(2, a[4]))", + "sum(mon(-2, a[0]), mon(-1, a[1]), mon(0, a[2]), a[3], " "mon(2, a[4]))", str(expr), ) expr = quicksum(model.a[i] for i in A) - self.assertEqual( - "sum(mon(1, a[0]), mon(1, a[1]), mon(1, a[2]), mon(1, a[3]), " - "mon(1, a[4]))", - str(expr), - ) + self.assertEqual("sum(a[0], a[1], a[2], a[3], a[4])", str(expr)) model.p[1].value = 0 model.p[3].value = 3 @@ -2111,6 +1934,22 @@ def test_expr(self): expr = 5 * model.a / model.a / 2 self.assertEqual("div(div(mon(5, a), a), 2)", str(expr)) + def test_pow(self): + model = ConcreteModel() + + model.x = Var() + model.A = Expression(initialize=1) + model.B = Expression(initialize=-2) + + expr = model.A**2 + model.B**2 + self.assertEqual("sum(pow(A{1}, 2), pow(B{-2}, 2))", str(expr)) + + expr = model.A**2 - model.B**2 + self.assertEqual("sum(pow(A{1}, 2), neg(pow(B{-2}, 2)))", str(expr)) + + expr = (1) ** model.x + (-1) ** model.x + self.assertEqual("sum(pow(1, x), pow(-1, x))", str(expr)) + def test_other(self): # # Print other stuff @@ -2120,7 +1959,7 @@ def test_other(self): model.x = ExternalFunction(library='foo.so', function='bar') expr = model.x(model.a, 1, "foo", []) - self.assertEqual("x(a, 1, 'foo', '[]')", str(expr)) + self.assertEqual("x(a, 1, 'foo', [])", str(expr)) def test_inequality(self): # @@ -2139,10 +1978,10 @@ def test_inequality(self): self.assertEqual("5 <= a < 10", str(expr)) expr = 5 <= model.a + 5 - self.assertEqual("5 <= sum(mon(1, a), 5)", str(expr)) + self.assertEqual("5 <= sum(a, 5)", str(expr)) expr = expr < 10 - self.assertEqual("5 <= sum(mon(1, a), 5) < 10", str(expr)) + self.assertEqual("5 <= sum(a, 5) < 10", str(expr)) def test_equality(self): # @@ -2167,10 +2006,10 @@ def test_equality(self): self.assertEqual("a == 10", str(expr)) expr = 5 == model.a + 5 - self.assertEqual("sum(mon(1, a), 5) == 5", str(expr)) + self.assertEqual("sum(a, 5) == 5", str(expr)) expr = model.a + 5 == 5 - self.assertEqual("sum(mon(1, a), 5) == 5", str(expr)) + self.assertEqual("sum(a, 5) == 5", str(expr)) def test_getitem(self): m = ConcreteModel() @@ -2207,7 +2046,7 @@ def test_small_expression(self): expr = abs(expr) self.assertEqual( "abs(neg(pow(2, div(2, prod(2, sum(1, neg(pow(div(prod(sum(" - "mon(1, a), 1, -1), a), a), b)), 1))))))", + "a, 1, -1), a), a), b)), 1))))))", str(expr), ) @@ -2344,6 +2183,22 @@ def test_prod(self): model.a.fixed = True self.assertEqual("b", expression_to_string(expr, compute_values=True)) + def test_pow(self): + model = ConcreteModel() + + model.x = Var() + model.A = Expression(initialize=1) + model.B = Expression(initialize=-2) + + expr = model.A**2 + model.B**2 + self.assertEqual("1**2 + (-2)**2", str(expr)) + + expr = model.A**2 - model.B**2 + self.assertEqual("1**2 - (-2)**2", str(expr)) + + expr = (1) ** model.x + (-1) ** model.x + self.assertEqual("1**x + (-1)**x", str(expr)) + def test_inequality(self): # # Print inequalities @@ -3512,7 +3367,7 @@ def test_simple_product(self): self.assertEqual(expr.polynomial_degree(), 1) # # A fraction with a variable in the denominator has degree None. - # This indicates that it is not a polyomial. + # This indicates that it is not a polynomial. # expr = self.model.c / self.model.a self.assertEqual(expr.polynomial_degree(), None) @@ -3654,7 +3509,7 @@ def test_nonpolynomial_pow(self): def test_Expr_if(self): m = self.instance # - # When IF conditional is constant, then polynomial degree is propigated + # When IF conditional is constant, then polynomial degree is propagated # expr = Expr_if(1, m.a**3, m.a**2) self.assertEqual(expr.polynomial_degree(), 3) @@ -3755,13 +3610,7 @@ def test_summation1(self): self.assertExpressionsEqual( e, LinearExpression( - [ - MonomialTermExpression((1, self.m.a[1])), - MonomialTermExpression((1, self.m.a[2])), - MonomialTermExpression((1, self.m.a[3])), - MonomialTermExpression((1, self.m.a[4])), - MonomialTermExpression((1, self.m.a[5])), - ] + [self.m.a[1], self.m.a[2], self.m.a[3], self.m.a[4], self.m.a[5]] ), ) @@ -3873,16 +3722,16 @@ def test_summation_compression(self): e, LinearExpression( [ - MonomialTermExpression((1, self.m.a[1])), - MonomialTermExpression((1, self.m.a[2])), - MonomialTermExpression((1, self.m.a[3])), - MonomialTermExpression((1, self.m.a[4])), - MonomialTermExpression((1, self.m.a[5])), - MonomialTermExpression((1, self.m.b[1])), - MonomialTermExpression((1, self.m.b[2])), - MonomialTermExpression((1, self.m.b[3])), - MonomialTermExpression((1, self.m.b[4])), - MonomialTermExpression((1, self.m.b[5])), + self.m.a[1], + self.m.a[2], + self.m.a[3], + self.m.a[4], + self.m.a[5], + self.m.b[1], + self.m.b[2], + self.m.b[3], + self.m.b[4], + self.m.b[5], ] ), ) @@ -3913,13 +3762,7 @@ def test_deprecation(self): self.assertExpressionsEqual( e, LinearExpression( - [ - MonomialTermExpression((1, self.m.a[1])), - MonomialTermExpression((1, self.m.a[2])), - MonomialTermExpression((1, self.m.a[3])), - MonomialTermExpression((1, self.m.a[4])), - MonomialTermExpression((1, self.m.a[5])), - ] + [self.m.a[1], self.m.a[2], self.m.a[3], self.m.a[4], self.m.a[5]] ), ) @@ -3929,13 +3772,7 @@ def test_summation1(self): self.assertExpressionsEqual( e, LinearExpression( - [ - MonomialTermExpression((1, self.m.a[1])), - MonomialTermExpression((1, self.m.a[2])), - MonomialTermExpression((1, self.m.a[3])), - MonomialTermExpression((1, self.m.a[4])), - MonomialTermExpression((1, self.m.a[5])), - ] + [self.m.a[1], self.m.a[2], self.m.a[3], self.m.a[4], self.m.a[5]] ), ) @@ -4157,15 +3994,15 @@ def test_SumExpression(self): self.assertEqual(expr2(), 15) self.assertNotEqual(id(expr1), id(expr2)) self.assertNotEqual(id(expr1._args_), id(expr2._args_)) - self.assertIs(expr1.arg(0).arg(1), expr2.arg(0).arg(1)) - self.assertIs(expr1.arg(1).arg(1), expr2.arg(1).arg(1)) + self.assertIs(expr1.arg(0), expr2.arg(0)) + self.assertIs(expr1.arg(1), expr2.arg(1)) expr1 += self.m.b self.assertEqual(expr1(), 25) self.assertEqual(expr2(), 15) self.assertNotEqual(id(expr1), id(expr2)) self.assertNotEqual(id(expr1._args_), id(expr2._args_)) - self.assertIs(expr1.arg(0).arg(1), expr2.arg(0).arg(1)) - self.assertIs(expr1.arg(1).arg(1), expr2.arg(1).arg(1)) + self.assertIs(expr1.arg(0), expr2.arg(0)) + self.assertIs(expr1.arg(1), expr2.arg(1)) # total = counter.count - start self.assertEqual(total, 1) @@ -4342,9 +4179,9 @@ def test_productOfExpressions(self): self.assertEqual(expr1.arg(1).nargs(), 2) self.assertEqual(expr2.arg(1).nargs(), 2) - self.assertIs(expr1.arg(0).arg(0).arg(1), expr2.arg(0).arg(0).arg(1)) - self.assertIs(expr1.arg(0).arg(1).arg(1), expr2.arg(0).arg(1).arg(1)) - self.assertIs(expr1.arg(1).arg(0).arg(1), expr2.arg(1).arg(0).arg(1)) + self.assertIs(expr1.arg(0).arg(0), expr2.arg(0).arg(0)) + self.assertIs(expr1.arg(0).arg(1), expr2.arg(0).arg(1)) + self.assertIs(expr1.arg(1).arg(0), expr2.arg(1).arg(0)) expr1 *= self.m.b self.assertEqual(expr1(), 1500) @@ -4383,8 +4220,8 @@ def test_productOfExpressions_div(self): self.assertEqual(expr1.arg(1).nargs(), 2) self.assertEqual(expr2.arg(1).nargs(), 2) - self.assertIs(expr1.arg(0).arg(0).arg(1), expr2.arg(0).arg(0).arg(1)) - self.assertIs(expr1.arg(0).arg(1).arg(1), expr2.arg(0).arg(1).arg(1)) + self.assertIs(expr1.arg(0).arg(0), expr2.arg(0).arg(0)) + self.assertIs(expr1.arg(0).arg(1), expr2.arg(0).arg(1)) expr1 /= self.m.b self.assertAlmostEqual(expr1(), 0.15) @@ -4508,6 +4345,18 @@ def test_sin(self): total = counter.count - start self.assertEqual(total, 1) + def test_create_node_with_local_data(self): + e = self.m.p * self.m.a + self.assertIs(type(e), MonomialTermExpression) + + f = e.create_node_with_local_data([self.m.b, self.m.p]) + self.assertIs(type(f), MonomialTermExpression) + self.assertStructuredAlmostEqual(f._args_, [self.m.p, self.m.b]) + + g = e.create_node_with_local_data([self.m.b, self.m.p], ProductExpression) + self.assertIs(type(g), ProductExpression) + self.assertStructuredAlmostEqual(g._args_, [self.m.b, self.m.p]) + # # Fixed - Expr has a fixed value @@ -5215,18 +5064,7 @@ def test_pow_other(self): e += m.v[0] + m.v[1] e = m.v[0] ** e self.assertExpressionsEqual( - e, - PowExpression( - ( - m.v[0], - LinearExpression( - [ - MonomialTermExpression((1, m.v[0])), - MonomialTermExpression((1, m.v[1])), - ] - ), - ) - ), + e, PowExpression((m.v[0], LinearExpression([m.v[0], m.v[1]]))) ) diff --git a/pyomo/core/tests/unit/test_numeric_expr_api.py b/pyomo/core/tests/unit/test_numeric_expr_api.py index 4e0af126315..e6258cf29b8 100644 --- a/pyomo/core/tests/unit/test_numeric_expr_api.py +++ b/pyomo/core/tests/unit/test_numeric_expr_api.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -223,7 +223,7 @@ def test_negation(self): self.assertEqual(is_fixed(e), False) self.assertEqual(value(e), -15) self.assertEqual(str(e), "- (x + 2*x)") - self.assertEqual(e.to_string(verbose=True), "neg(sum(mon(1, x), mon(2, x)))") + self.assertEqual(e.to_string(verbose=True), "neg(sum(x, mon(2, x)))") # This can't occur through operator overloading, but could # through expression substitution @@ -634,8 +634,7 @@ def test_linear(self): self.assertEqual(value(e), 1 + 4 + 5 + 2) self.assertEqual(str(e), "0*x[0] + x[1] + 2*x[2] + 5 + y - 3") self.assertEqual( - e.to_string(verbose=True), - "sum(mon(0, x[0]), mon(1, x[1]), mon(2, x[2]), 5, mon(1, y), -3)", + e.to_string(verbose=True), "sum(mon(0, x[0]), x[1], mon(2, x[2]), 5, y, -3)" ) self.assertIs(type(e), LinearExpression) @@ -701,7 +700,7 @@ def test_expr_if(self): ) self.assertEqual( e.to_string(verbose=True), - "Expr_if( ( 5 <= y ), then=( sum(mon(1, x[0]), 5) ), else=( pow(x[1], 2) ) )", + "Expr_if( ( 5 <= y ), then=( sum(x[0], 5) ), else=( pow(x[1], 2) ) )", ) m.y.fix() @@ -972,9 +971,7 @@ def test_sum(self): f = e.create_node_with_local_data((m.p, m.x)) self.assertIsNot(f, e) self.assertIs(type(f), LinearExpression) - assertExpressionsStructurallyEqual( - self, f.args, [m.p, MonomialTermExpression((1, m.x))] - ) + assertExpressionsStructurallyEqual(self, f.args, [m.p, m.x]) f = e.create_node_with_local_data((m.p, m.x**2)) self.assertIsNot(f, e) diff --git a/pyomo/core/tests/unit/test_numeric_expr_dispatcher.py b/pyomo/core/tests/unit/test_numeric_expr_dispatcher.py index 3787f00de47..48e70781022 100644 --- a/pyomo/core/tests/unit/test_numeric_expr_dispatcher.py +++ b/pyomo/core/tests/unit/test_numeric_expr_dispatcher.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,6 @@ import logging import math import operator -import sys import pyomo.common.unittest as unittest @@ -67,20 +66,15 @@ class SKIP: NUM_TESTS = 21 def tearDown(self): - # Restore the state of 0 and 1 optimizations - _zero_one_optimizations.clear() - _zero_one_optimizations.update(self.zero_one_optimizations) + pass def setUp(self): - # Save (and standardize) the state of 0 and 1 optimizations - self.zero_one_optimizations = set(_zero_one_optimizations) - # Note there are 11 basic argument "types" that determine how # expressions are generated (defined by the _EXPR_TYPE enum): # # class _EXPR_TYPE(enum.Enum): # MUTABLE = -2 - # ASBINARY = -1 + # ASNUMERIC = -1 # INVALID = 0 # NATIVE = 1 # NPV = 2 @@ -121,10 +115,12 @@ def setUp(self): self.mutable_l1 = _MutableLinearExpression([self.mon_npv]) self.mutable_l2 = _MutableSumExpression([self.mon_npv, self.other]) self.mutable_l3 = _MutableNPVSumExpression([self.npv]) + self.l0 = 0 + self.l1 = self.mon_npv + self.l2 = SumExpression([self.mon_npv, self.other]) + self.l3 = self.npv # often repeated reference expressions - self.mon_bin = MonomialTermExpression((1, self.bin)) - self.mon_var = MonomialTermExpression((1, self.var)) self.minus_bin = MonomialTermExpression((-1, self.bin)) self.minus_npv = NPV_NegationExpression((self.npv,)) self.minus_param_mut = NPV_NegationExpression((self.param_mut,)) @@ -139,7 +135,7 @@ def setUp(self): self.minus_linear = NegationExpression((self.linear,)) self.minus_sum = NegationExpression((self.sum,)) self.minus_other = NegationExpression((self.other,)) - self.minus_mutable_l2 = NegationExpression((self.mutable_l2,)) + self.minus_l2 = NegationExpression((self.l2,)) self.TEMPLATE = [ self.invalid, @@ -203,8 +199,8 @@ def _run_cases(self, tests, op): try: for test_num, test in enumerate(tests): ans = None - args = test[:-1] - result = test[-1] + args = [clone_expression(arg) for arg in test[:-1]] + result = clone_expression(test[-1]) if result is self.SKIP: continue orig_args = list(args) @@ -214,7 +210,7 @@ def _run_cases(self, tests, op): classes = [arg.__class__ for arg in args] with LoggingIntercept() as LOG: ans = op(*args) - if not any(arg is self.asbinary for arg in args): + if not any(arg is self.asbinary for arg in test): self.assertEqual(LOG.getvalue(), "") assertExpressionsEqual(self, result, ans) for i, arg in enumerate(args): @@ -230,8 +226,10 @@ def _run_cases(self, tests, op): else: self.assertIs(arg.__class__, classes[i]) assertExpressionsEqual(self, arg, orig_args_clone[i]) - except TypeError: - if result is not NotImplemented: + except TypeError as e: + if type(result) is str: + self.assertRegex(str(e), result) + elif result is not NotImplemented: raise except ZeroDivisionError: if result is not ZeroDivisionError: @@ -247,6 +245,21 @@ def _run_cases(self, tests, op): self._print_error(test_num, test, ans) raise + +class BaseNumeric(Base): + def tearDown(self): + # Restore the state of 0 and 1 optimizations + _zero_one_optimizations.clear() + _zero_one_optimizations.update(self.zero_one_optimizations) + + return super().tearDown() + + def setUp(self): + # Save (and standardize) the state of 0 and 1 optimizations + self.zero_one_optimizations = set(_zero_one_optimizations) + + return super().setUp() + def _run_iadd_cases(self, tests, op): self.assertEqual(len(tests), self.NUM_TESTS) # Check that this test is checking the correct arg type (catch @@ -259,8 +272,8 @@ def _run_iadd_cases(self, tests, op): try: for test_num, test in enumerate(tests): ans = None - args = test[:-1] - result = test[-1] + args = [clone_expression(arg) for arg in test[:-1]] + result = clone_expression(test[-1]) if result is self.SKIP: continue orig_args = list(args) @@ -270,7 +283,7 @@ def _run_iadd_cases(self, tests, op): classes = [arg.__class__ for arg in args] with LoggingIntercept() as LOG: ans = op(*args) - if not any(arg is self.asbinary for arg in args): + if not any(arg is self.asbinary for arg in test): self.assertEqual(LOG.getvalue(), "") assertExpressionsEqual(self, result, ans) for i, arg in enumerate(args): @@ -316,7 +329,7 @@ def _run_iadd_cases(self, tests, op): raise -class TestExpressionGeneration(Base, unittest.TestCase): +class TestExpressionGeneration(BaseNumeric, unittest.TestCase): def setUp(self): super().setUp() enable_expression_optimizations(zero=False, one=True) @@ -368,38 +381,34 @@ def test_add_asbinary(self): # BooleanVar objects do not support addition (self.asbinary, self.asbinary, NotImplemented), (self.asbinary, self.zero, self.bin), - (self.asbinary, self.one, LinearExpression([self.mon_bin, 1])), + (self.asbinary, self.one, LinearExpression([self.bin, 1])), # 4: - (self.asbinary, self.native, LinearExpression([self.mon_bin, 5])), - (self.asbinary, self.npv, LinearExpression([self.mon_bin, self.npv])), - (self.asbinary, self.param, LinearExpression([self.mon_bin, 6])), + (self.asbinary, self.native, LinearExpression([self.bin, 5])), + (self.asbinary, self.npv, LinearExpression([self.bin, self.npv])), + (self.asbinary, self.param, LinearExpression([self.bin, 6])), ( self.asbinary, self.param_mut, - LinearExpression([self.mon_bin, self.param_mut]), + LinearExpression([self.bin, self.param_mut]), ), # 8: - (self.asbinary, self.var, LinearExpression([self.mon_bin, self.mon_var])), + (self.asbinary, self.var, LinearExpression([self.bin, self.var])), ( self.asbinary, self.mon_native, - LinearExpression([self.mon_bin, self.mon_native]), + LinearExpression([self.bin, self.mon_native]), ), ( self.asbinary, self.mon_param, - LinearExpression([self.mon_bin, self.mon_param]), - ), - ( - self.asbinary, - self.mon_npv, - LinearExpression([self.mon_bin, self.mon_npv]), + LinearExpression([self.bin, self.mon_param]), ), + (self.asbinary, self.mon_npv, LinearExpression([self.bin, self.mon_npv])), # 12: ( self.asbinary, self.linear, - LinearExpression(self.linear.args + [self.mon_bin]), + LinearExpression(self.linear.args + [self.bin]), ), (self.asbinary, self.sum, SumExpression(self.sum.args + [self.bin])), (self.asbinary, self.other, SumExpression([self.bin, self.other])), @@ -408,21 +417,13 @@ def test_add_asbinary(self): ( self.asbinary, self.mutable_l1, - LinearExpression([self.mon_bin, self.mon_npv]), - ), - ( - self.asbinary, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.bin]), + LinearExpression([self.bin, self.mon_npv]), ), + (self.asbinary, self.mutable_l2, SumExpression(self.l2.args + [self.bin])), (self.asbinary, self.param0, self.bin), - (self.asbinary, self.param1, LinearExpression([self.mon_bin, 1])), + (self.asbinary, self.param1, LinearExpression([self.bin, 1])), # 20: - ( - self.asbinary, - self.mutable_l3, - LinearExpression([self.mon_bin, self.npv]), - ), + (self.asbinary, self.mutable_l3, LinearExpression([self.bin, self.npv])), ] self._run_cases(tests, operator.add) self._run_cases(tests, operator.iadd) @@ -450,7 +451,7 @@ def test_add_zero(self): (self.zero, self.mutable_l0, 0), # 16: (self.zero, self.mutable_l1, self.mon_npv), - (self.zero, self.mutable_l2, self.mutable_l2), + (self.zero, self.mutable_l2, self.l2), (self.zero, self.param0, 0), (self.zero, self.param1, 1), # 20: @@ -462,7 +463,7 @@ def test_add_zero(self): def test_add_one(self): tests = [ (self.one, self.invalid, NotImplemented), - (self.one, self.asbinary, LinearExpression([1, self.mon_bin])), + (self.one, self.asbinary, LinearExpression([1, self.bin])), (self.one, self.zero, 1), (self.one, self.one, 2), # 4: @@ -471,7 +472,7 @@ def test_add_one(self): (self.one, self.param, 7), (self.one, self.param_mut, NPV_SumExpression([1, self.param_mut])), # 8: - (self.one, self.var, LinearExpression([1, self.mon_var])), + (self.one, self.var, LinearExpression([1, self.var])), (self.one, self.mon_native, LinearExpression([1, self.mon_native])), (self.one, self.mon_param, LinearExpression([1, self.mon_param])), (self.one, self.mon_npv, LinearExpression([1, self.mon_npv])), @@ -481,8 +482,8 @@ def test_add_one(self): (self.one, self.other, SumExpression([1, self.other])), (self.one, self.mutable_l0, 1), # 16: - (self.one, self.mutable_l1, LinearExpression([1] + self.mutable_l1.args)), - (self.one, self.mutable_l2, SumExpression(self.mutable_l2.args + [1])), + (self.one, self.mutable_l1, LinearExpression([1, self.l1])), + (self.one, self.mutable_l2, SumExpression(self.l2.args + [1])), (self.one, self.param0, 1), (self.one, self.param1, 2), # 20: @@ -494,7 +495,7 @@ def test_add_one(self): def test_add_native(self): tests = [ (self.native, self.invalid, NotImplemented), - (self.native, self.asbinary, LinearExpression([5, self.mon_bin])), + (self.native, self.asbinary, LinearExpression([5, self.bin])), (self.native, self.zero, 5), (self.native, self.one, 6), # 4: @@ -503,7 +504,7 @@ def test_add_native(self): (self.native, self.param, 11), (self.native, self.param_mut, NPV_SumExpression([5, self.param_mut])), # 8: - (self.native, self.var, LinearExpression([5, self.mon_var])), + (self.native, self.var, LinearExpression([5, self.var])), (self.native, self.mon_native, LinearExpression([5, self.mon_native])), (self.native, self.mon_param, LinearExpression([5, self.mon_param])), (self.native, self.mon_npv, LinearExpression([5, self.mon_npv])), @@ -513,12 +514,8 @@ def test_add_native(self): (self.native, self.other, SumExpression([5, self.other])), (self.native, self.mutable_l0, 5), # 16: - ( - self.native, - self.mutable_l1, - LinearExpression([5] + self.mutable_l1.args), - ), - (self.native, self.mutable_l2, SumExpression(self.mutable_l2.args + [5])), + (self.native, self.mutable_l1, LinearExpression([5, self.l1])), + (self.native, self.mutable_l2, SumExpression(self.l2.args + [5])), (self.native, self.param0, 5), (self.native, self.param1, 6), # 20: @@ -530,7 +527,7 @@ def test_add_native(self): def test_add_npv(self): tests = [ (self.npv, self.invalid, NotImplemented), - (self.npv, self.asbinary, LinearExpression([self.npv, self.mon_bin])), + (self.npv, self.asbinary, LinearExpression([self.npv, self.bin])), (self.npv, self.zero, self.npv), (self.npv, self.one, NPV_SumExpression([self.npv, 1])), # 4: @@ -539,7 +536,7 @@ def test_add_npv(self): (self.npv, self.param, NPV_SumExpression([self.npv, 6])), (self.npv, self.param_mut, NPV_SumExpression([self.npv, self.param_mut])), # 8: - (self.npv, self.var, LinearExpression([self.npv, self.mon_var])), + (self.npv, self.var, LinearExpression([self.npv, self.var])), (self.npv, self.mon_native, LinearExpression([self.npv, self.mon_native])), (self.npv, self.mon_param, LinearExpression([self.npv, self.mon_param])), (self.npv, self.mon_npv, LinearExpression([self.npv, self.mon_npv])), @@ -549,16 +546,8 @@ def test_add_npv(self): (self.npv, self.other, SumExpression([self.npv, self.other])), (self.npv, self.mutable_l0, self.npv), # 16: - ( - self.npv, - self.mutable_l1, - LinearExpression([self.npv] + self.mutable_l1.args), - ), - ( - self.npv, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.npv]), - ), + (self.npv, self.mutable_l1, LinearExpression([self.npv, self.l1])), + (self.npv, self.mutable_l2, SumExpression(self.l2.args + [self.npv])), (self.npv, self.param0, self.npv), (self.npv, self.param1, NPV_SumExpression([self.npv, 1])), # 20: @@ -570,7 +559,7 @@ def test_add_npv(self): def test_add_param(self): tests = [ (self.param, self.invalid, NotImplemented), - (self.param, self.asbinary, LinearExpression([6, self.mon_bin])), + (self.param, self.asbinary, LinearExpression([6, self.bin])), (self.param, self.zero, 6), (self.param, self.one, 7), # 4: @@ -579,7 +568,7 @@ def test_add_param(self): (self.param, self.param, 12), (self.param, self.param_mut, NPV_SumExpression([6, self.param_mut])), # 8: - (self.param, self.var, LinearExpression([6, self.mon_var])), + (self.param, self.var, LinearExpression([6, self.var])), (self.param, self.mon_native, LinearExpression([6, self.mon_native])), (self.param, self.mon_param, LinearExpression([6, self.mon_param])), (self.param, self.mon_npv, LinearExpression([6, self.mon_npv])), @@ -589,8 +578,8 @@ def test_add_param(self): (self.param, self.other, SumExpression([6, self.other])), (self.param, self.mutable_l0, 6), # 16: - (self.param, self.mutable_l1, LinearExpression([6] + self.mutable_l1.args)), - (self.param, self.mutable_l2, SumExpression(self.mutable_l2.args + [6])), + (self.param, self.mutable_l1, LinearExpression([6, self.l1])), + (self.param, self.mutable_l2, SumExpression(self.l2.args + [6])), (self.param, self.param0, 6), (self.param, self.param1, 7), # 20: @@ -605,7 +594,7 @@ def test_add_param_mut(self): ( self.param_mut, self.asbinary, - LinearExpression([self.param_mut, self.mon_bin]), + LinearExpression([self.param_mut, self.bin]), ), (self.param_mut, self.zero, self.param_mut), (self.param_mut, self.one, NPV_SumExpression([self.param_mut, 1])), @@ -619,11 +608,7 @@ def test_add_param_mut(self): NPV_SumExpression([self.param_mut, self.param_mut]), ), # 8: - ( - self.param_mut, - self.var, - LinearExpression([self.param_mut, self.mon_var]), - ), + (self.param_mut, self.var, LinearExpression([self.param_mut, self.var])), ( self.param_mut, self.mon_native, @@ -652,12 +637,12 @@ def test_add_param_mut(self): ( self.param_mut, self.mutable_l1, - LinearExpression([self.param_mut] + self.mutable_l1.args), + LinearExpression([self.param_mut, self.l1]), ), ( self.param_mut, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.param_mut]), + SumExpression(self.l2.args + [self.param_mut]), ), (self.param_mut, self.param0, self.param_mut), (self.param_mut, self.param1, NPV_SumExpression([self.param_mut, 1])), @@ -674,59 +659,31 @@ def test_add_param_mut(self): def test_add_var(self): tests = [ (self.var, self.invalid, NotImplemented), - (self.var, self.asbinary, LinearExpression([self.mon_var, self.mon_bin])), + (self.var, self.asbinary, LinearExpression([self.var, self.bin])), (self.var, self.zero, self.var), - (self.var, self.one, LinearExpression([self.mon_var, 1])), + (self.var, self.one, LinearExpression([self.var, 1])), # 4: - (self.var, self.native, LinearExpression([self.mon_var, 5])), - (self.var, self.npv, LinearExpression([self.mon_var, self.npv])), - (self.var, self.param, LinearExpression([self.mon_var, 6])), - ( - self.var, - self.param_mut, - LinearExpression([self.mon_var, self.param_mut]), - ), + (self.var, self.native, LinearExpression([self.var, 5])), + (self.var, self.npv, LinearExpression([self.var, self.npv])), + (self.var, self.param, LinearExpression([self.var, 6])), + (self.var, self.param_mut, LinearExpression([self.var, self.param_mut])), # 8: - (self.var, self.var, LinearExpression([self.mon_var, self.mon_var])), - ( - self.var, - self.mon_native, - LinearExpression([self.mon_var, self.mon_native]), - ), - ( - self.var, - self.mon_param, - LinearExpression([self.mon_var, self.mon_param]), - ), - (self.var, self.mon_npv, LinearExpression([self.mon_var, self.mon_npv])), + (self.var, self.var, LinearExpression([self.var, self.var])), + (self.var, self.mon_native, LinearExpression([self.var, self.mon_native])), + (self.var, self.mon_param, LinearExpression([self.var, self.mon_param])), + (self.var, self.mon_npv, LinearExpression([self.var, self.mon_npv])), # 12: - ( - self.var, - self.linear, - LinearExpression(self.linear.args + [self.mon_var]), - ), + (self.var, self.linear, LinearExpression(self.linear.args + [self.var])), (self.var, self.sum, SumExpression(self.sum.args + [self.var])), (self.var, self.other, SumExpression([self.var, self.other])), (self.var, self.mutable_l0, self.var), # 16: - ( - self.var, - self.mutable_l1, - LinearExpression([self.mon_var] + self.mutable_l1.args), - ), - ( - self.var, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.var]), - ), + (self.var, self.mutable_l1, LinearExpression([self.var, self.l1])), + (self.var, self.mutable_l2, SumExpression(self.l2.args + [self.var])), (self.var, self.param0, self.var), - (self.var, self.param1, LinearExpression([self.mon_var, 1])), + (self.var, self.param1, LinearExpression([self.var, 1])), # 20: - ( - self.var, - self.mutable_l3, - LinearExpression([MonomialTermExpression((1, self.var)), self.npv]), - ), + (self.var, self.mutable_l3, LinearExpression([self.var, self.npv])), ] self._run_cases(tests, operator.add) self._run_cases(tests, operator.iadd) @@ -737,7 +694,7 @@ def test_add_mon_native(self): ( self.mon_native, self.asbinary, - LinearExpression([self.mon_native, self.mon_bin]), + LinearExpression([self.mon_native, self.bin]), ), (self.mon_native, self.zero, self.mon_native), (self.mon_native, self.one, LinearExpression([self.mon_native, 1])), @@ -751,11 +708,7 @@ def test_add_mon_native(self): LinearExpression([self.mon_native, self.param_mut]), ), # 8: - ( - self.mon_native, - self.var, - LinearExpression([self.mon_native, self.mon_var]), - ), + (self.mon_native, self.var, LinearExpression([self.mon_native, self.var])), ( self.mon_native, self.mon_native, @@ -788,12 +741,12 @@ def test_add_mon_native(self): ( self.mon_native, self.mutable_l1, - LinearExpression([self.mon_native] + self.mutable_l1.args), + LinearExpression([self.mon_native, self.l1]), ), ( self.mon_native, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.mon_native]), + SumExpression(self.l2.args + [self.mon_native]), ), (self.mon_native, self.param0, self.mon_native), (self.mon_native, self.param1, LinearExpression([self.mon_native, 1])), @@ -813,7 +766,7 @@ def test_add_mon_param(self): ( self.mon_param, self.asbinary, - LinearExpression([self.mon_param, self.mon_bin]), + LinearExpression([self.mon_param, self.bin]), ), (self.mon_param, self.zero, self.mon_param), (self.mon_param, self.one, LinearExpression([self.mon_param, 1])), @@ -827,11 +780,7 @@ def test_add_mon_param(self): LinearExpression([self.mon_param, self.param_mut]), ), # 8: - ( - self.mon_param, - self.var, - LinearExpression([self.mon_param, self.mon_var]), - ), + (self.mon_param, self.var, LinearExpression([self.mon_param, self.var])), ( self.mon_param, self.mon_native, @@ -860,12 +809,12 @@ def test_add_mon_param(self): ( self.mon_param, self.mutable_l1, - LinearExpression([self.mon_param] + self.mutable_l1.args), + LinearExpression([self.mon_param, self.l1]), ), ( self.mon_param, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.mon_param]), + SumExpression(self.l2.args + [self.mon_param]), ), (self.mon_param, self.param0, self.mon_param), (self.mon_param, self.param1, LinearExpression([self.mon_param, 1])), @@ -882,11 +831,7 @@ def test_add_mon_param(self): def test_add_mon_npv(self): tests = [ (self.mon_npv, self.invalid, NotImplemented), - ( - self.mon_npv, - self.asbinary, - LinearExpression([self.mon_npv, self.mon_bin]), - ), + (self.mon_npv, self.asbinary, LinearExpression([self.mon_npv, self.bin])), (self.mon_npv, self.zero, self.mon_npv), (self.mon_npv, self.one, LinearExpression([self.mon_npv, 1])), # 4: @@ -899,7 +844,7 @@ def test_add_mon_npv(self): LinearExpression([self.mon_npv, self.param_mut]), ), # 8: - (self.mon_npv, self.var, LinearExpression([self.mon_npv, self.mon_var])), + (self.mon_npv, self.var, LinearExpression([self.mon_npv, self.var])), ( self.mon_npv, self.mon_native, @@ -925,15 +870,11 @@ def test_add_mon_npv(self): (self.mon_npv, self.other, SumExpression([self.mon_npv, self.other])), (self.mon_npv, self.mutable_l0, self.mon_npv), # 16: - ( - self.mon_npv, - self.mutable_l1, - LinearExpression([self.mon_npv] + self.mutable_l1.args), - ), + (self.mon_npv, self.mutable_l1, LinearExpression([self.mon_npv, self.l1])), ( self.mon_npv, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.mon_npv]), + SumExpression(self.l2.args + [self.mon_npv]), ), (self.mon_npv, self.param0, self.mon_npv), (self.mon_npv, self.param1, LinearExpression([self.mon_npv, 1])), @@ -949,7 +890,7 @@ def test_add_linear(self): ( self.linear, self.asbinary, - LinearExpression(self.linear.args + [self.mon_bin]), + LinearExpression(self.linear.args + [self.bin]), ), (self.linear, self.zero, self.linear), (self.linear, self.one, LinearExpression(self.linear.args + [1])), @@ -963,11 +904,7 @@ def test_add_linear(self): LinearExpression(self.linear.args + [self.param_mut]), ), # 8: - ( - self.linear, - self.var, - LinearExpression(self.linear.args + [self.mon_var]), - ), + (self.linear, self.var, LinearExpression(self.linear.args + [self.var])), ( self.linear, self.mon_native, @@ -996,13 +933,9 @@ def test_add_linear(self): ( self.linear, self.mutable_l1, - LinearExpression(self.linear.args + self.mutable_l1.args), - ), - ( - self.linear, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.linear]), + LinearExpression(self.linear.args + [self.l1]), ), + (self.linear, self.mutable_l2, SumExpression(self.l2.args + [self.linear])), (self.linear, self.param0, self.linear), (self.linear, self.param1, LinearExpression(self.linear.args + [1])), # 20: @@ -1041,16 +974,8 @@ def test_add_sum(self): (self.sum, self.other, SumExpression(self.sum.args + [self.other])), (self.sum, self.mutable_l0, self.sum), # 16: - ( - self.sum, - self.mutable_l1, - SumExpression(self.sum.args + self.mutable_l1.args), - ), - ( - self.sum, - self.mutable_l2, - SumExpression(self.sum.args + self.mutable_l2.args), - ), + (self.sum, self.mutable_l1, SumExpression(self.sum.args + [self.l1])), + (self.sum, self.mutable_l2, SumExpression(self.sum.args + self.l2.args)), (self.sum, self.param0, self.sum), (self.sum, self.param1, SumExpression(self.sum.args + [1])), # 20: @@ -1082,11 +1007,7 @@ def test_add_other(self): (self.other, self.mutable_l0, self.other), # 16: (self.other, self.mutable_l1, SumExpression([self.other, self.mon_npv])), - ( - self.other, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.other]), - ), + (self.other, self.mutable_l2, SumExpression(self.l2.args + [self.other])), (self.other, self.param0, self.other), (self.other, self.param1, SumExpression([self.other, 1])), # 20: @@ -1118,7 +1039,7 @@ def test_add_mutable_l0(self): (self.mutable_l0, self.mutable_l0, 0), # 16: (self.mutable_l0, self.mutable_l1, self.mon_npv), - (self.mutable_l0, self.mutable_l2, self.mutable_l2), + (self.mutable_l0, self.mutable_l2, self.l2), (self.mutable_l0, self.param0, 0), (self.mutable_l0, self.param1, 1), # 20: @@ -1131,91 +1052,47 @@ def test_add_mutable_l0(self): def test_add_mutable_l1(self): tests = [ (self.mutable_l1, self.invalid, NotImplemented), - ( - self.mutable_l1, - self.asbinary, - LinearExpression(self.mutable_l1.args + [self.mon_bin]), - ), + (self.mutable_l1, self.asbinary, LinearExpression([self.l1, self.bin])), (self.mutable_l1, self.zero, self.mon_npv), - (self.mutable_l1, self.one, LinearExpression(self.mutable_l1.args + [1])), + (self.mutable_l1, self.one, LinearExpression([self.l1, 1])), # 4: - ( - self.mutable_l1, - self.native, - LinearExpression(self.mutable_l1.args + [5]), - ), - ( - self.mutable_l1, - self.npv, - LinearExpression(self.mutable_l1.args + [self.npv]), - ), - (self.mutable_l1, self.param, LinearExpression(self.mutable_l1.args + [6])), + (self.mutable_l1, self.native, LinearExpression([self.l1, 5])), + (self.mutable_l1, self.npv, LinearExpression([self.l1, self.npv])), + (self.mutable_l1, self.param, LinearExpression([self.l1, 6])), ( self.mutable_l1, self.param_mut, - LinearExpression(self.mutable_l1.args + [self.param_mut]), + LinearExpression([self.l1, self.param_mut]), ), # 8: - ( - self.mutable_l1, - self.var, - LinearExpression(self.mutable_l1.args + [self.mon_var]), - ), + (self.mutable_l1, self.var, LinearExpression([self.l1, self.var])), ( self.mutable_l1, self.mon_native, - LinearExpression(self.mutable_l1.args + [self.mon_native]), + LinearExpression([self.l1, self.mon_native]), ), ( self.mutable_l1, self.mon_param, - LinearExpression(self.mutable_l1.args + [self.mon_param]), - ), - ( - self.mutable_l1, - self.mon_npv, - LinearExpression(self.mutable_l1.args + [self.mon_npv]), + LinearExpression([self.l1, self.mon_param]), ), + (self.mutable_l1, self.mon_npv, LinearExpression([self.l1, self.mon_npv])), # 12: ( self.mutable_l1, self.linear, - LinearExpression(self.linear.args + self.mutable_l1.args), - ), - ( - self.mutable_l1, - self.sum, - SumExpression(self.sum.args + self.mutable_l1.args), - ), - ( - self.mutable_l1, - self.other, - SumExpression(self.mutable_l1.args + [self.other]), + LinearExpression(self.linear.args + [self.l1]), ), + (self.mutable_l1, self.sum, SumExpression(self.sum.args + [self.l1])), + (self.mutable_l1, self.other, SumExpression([self.l1, self.other])), (self.mutable_l1, self.mutable_l0, self.mon_npv), # 16: - ( - self.mutable_l1, - self.mutable_l1, - LinearExpression(self.mutable_l1.args + self.mutable_l1.args), - ), - ( - self.mutable_l1, - self.mutable_l2, - SumExpression(self.mutable_l2.args + self.mutable_l1.args), - ), + (self.mutable_l1, self.mutable_l1, LinearExpression([self.l1, self.l1])), + (self.mutable_l1, self.mutable_l2, SumExpression(self.l2.args + [self.l1])), (self.mutable_l1, self.param0, self.mon_npv), - ( - self.mutable_l1, - self.param1, - LinearExpression(self.mutable_l1.args + [1]), - ), + (self.mutable_l1, self.param1, LinearExpression([self.l1, 1])), # 20: - ( - self.mutable_l1, - self.mutable_l3, - LinearExpression(self.mutable_l1.args + [self.npv]), - ), + (self.mutable_l1, self.mutable_l3, LinearExpression([self.l1, self.npv])), ] self._run_cases(tests, operator.add) # Mutable iadd handled by separate tests @@ -1224,82 +1101,54 @@ def test_add_mutable_l1(self): def test_add_mutable_l2(self): tests = [ (self.mutable_l2, self.invalid, NotImplemented), - ( - self.mutable_l2, - self.asbinary, - SumExpression(self.mutable_l2.args + [self.bin]), - ), - (self.mutable_l2, self.zero, self.mutable_l2), - (self.mutable_l2, self.one, SumExpression(self.mutable_l2.args + [1])), + (self.mutable_l2, self.asbinary, SumExpression(self.l2.args + [self.bin])), + (self.mutable_l2, self.zero, self.l2), + (self.mutable_l2, self.one, SumExpression(self.l2.args + [1])), # 4: - (self.mutable_l2, self.native, SumExpression(self.mutable_l2.args + [5])), - ( - self.mutable_l2, - self.npv, - SumExpression(self.mutable_l2.args + [self.npv]), - ), - (self.mutable_l2, self.param, SumExpression(self.mutable_l2.args + [6])), + (self.mutable_l2, self.native, SumExpression(self.l2.args + [5])), + (self.mutable_l2, self.npv, SumExpression(self.l2.args + [self.npv])), + (self.mutable_l2, self.param, SumExpression(self.l2.args + [6])), ( self.mutable_l2, self.param_mut, - SumExpression(self.mutable_l2.args + [self.param_mut]), + SumExpression(self.l2.args + [self.param_mut]), ), # 8: - ( - self.mutable_l2, - self.var, - SumExpression(self.mutable_l2.args + [self.var]), - ), + (self.mutable_l2, self.var, SumExpression(self.l2.args + [self.var])), ( self.mutable_l2, self.mon_native, - SumExpression(self.mutable_l2.args + [self.mon_native]), + SumExpression(self.l2.args + [self.mon_native]), ), ( self.mutable_l2, self.mon_param, - SumExpression(self.mutable_l2.args + [self.mon_param]), + SumExpression(self.l2.args + [self.mon_param]), ), ( self.mutable_l2, self.mon_npv, - SumExpression(self.mutable_l2.args + [self.mon_npv]), + SumExpression(self.l2.args + [self.mon_npv]), ), # 12: - ( - self.mutable_l2, - self.linear, - SumExpression(self.mutable_l2.args + [self.linear]), - ), - ( - self.mutable_l2, - self.sum, - SumExpression(self.mutable_l2.args + self.sum.args), - ), - ( - self.mutable_l2, - self.other, - SumExpression(self.mutable_l2.args + [self.other]), - ), - (self.mutable_l2, self.mutable_l0, self.mutable_l2), + (self.mutable_l2, self.linear, SumExpression(self.l2.args + [self.linear])), + (self.mutable_l2, self.sum, SumExpression(self.l2.args + self.sum.args)), + (self.mutable_l2, self.other, SumExpression(self.l2.args + [self.other])), + (self.mutable_l2, self.mutable_l0, self.l2), # 16: - ( - self.mutable_l2, - self.mutable_l1, - SumExpression(self.mutable_l2.args + self.mutable_l1.args), - ), + (self.mutable_l2, self.mutable_l1, SumExpression(self.l2.args + [self.l1])), ( self.mutable_l2, self.mutable_l2, - SumExpression(self.mutable_l2.args + self.mutable_l2.args), + SumExpression(self.l2.args + self.l2.args), ), - (self.mutable_l2, self.param0, self.mutable_l2), - (self.mutable_l2, self.param1, SumExpression(self.mutable_l2.args + [1])), + (self.mutable_l2, self.param0, self.l2), + (self.mutable_l2, self.param1, SumExpression(self.l2.args + [1])), # 20: ( self.mutable_l2, self.mutable_l3, - SumExpression(self.mutable_l2.args + [self.npv]), + SumExpression(self.l2.args + [self.npv]), ), ] self._run_cases(tests, operator.add) @@ -1329,7 +1178,7 @@ def test_add_param0(self): (self.param0, self.mutable_l0, 0), # 16: (self.param0, self.mutable_l1, self.mon_npv), - (self.param0, self.mutable_l2, self.mutable_l2), + (self.param0, self.mutable_l2, self.l2), (self.param0, self.param0, 0), (self.param0, self.param1, 1), # 20: @@ -1341,7 +1190,7 @@ def test_add_param0(self): def test_add_param1(self): tests = [ (self.param1, self.invalid, NotImplemented), - (self.param1, self.asbinary, LinearExpression([1, self.mon_bin])), + (self.param1, self.asbinary, LinearExpression([1, self.bin])), (self.param1, self.zero, 1), (self.param1, self.one, 2), # 4: @@ -1350,7 +1199,7 @@ def test_add_param1(self): (self.param1, self.param, 7), (self.param1, self.param_mut, NPV_SumExpression([1, self.param_mut])), # 8: - (self.param1, self.var, LinearExpression([1, self.mon_var])), + (self.param1, self.var, LinearExpression([1, self.var])), (self.param1, self.mon_native, LinearExpression([1, self.mon_native])), (self.param1, self.mon_param, LinearExpression([1, self.mon_param])), (self.param1, self.mon_npv, LinearExpression([1, self.mon_npv])), @@ -1360,12 +1209,8 @@ def test_add_param1(self): (self.param1, self.other, SumExpression([1, self.other])), (self.param1, self.mutable_l0, 1), # 16: - ( - self.param1, - self.mutable_l1, - LinearExpression([1] + self.mutable_l1.args), - ), - (self.param1, self.mutable_l2, SumExpression(self.mutable_l2.args + [1])), + (self.param1, self.mutable_l1, LinearExpression([1, self.l1])), + (self.param1, self.mutable_l2, SumExpression(self.l2.args + [1])), (self.param1, self.param0, 1), (self.param1, self.param1, 2), # 20: @@ -1377,95 +1222,47 @@ def test_add_param1(self): def test_add_mutable_l3(self): tests = [ (self.mutable_l3, self.invalid, NotImplemented), - ( - self.mutable_l3, - self.asbinary, - LinearExpression(self.mutable_l3.args + [self.mon_bin]), - ), + (self.mutable_l3, self.asbinary, LinearExpression([self.l3, self.bin])), (self.mutable_l3, self.zero, self.npv), - (self.mutable_l3, self.one, NPV_SumExpression(self.mutable_l3.args + [1])), + (self.mutable_l3, self.one, NPV_SumExpression([self.l3, 1])), # 4: - ( - self.mutable_l3, - self.native, - NPV_SumExpression(self.mutable_l3.args + [5]), - ), - ( - self.mutable_l3, - self.npv, - NPV_SumExpression(self.mutable_l3.args + [self.npv]), - ), - ( - self.mutable_l3, - self.param, - NPV_SumExpression(self.mutable_l3.args + [6]), - ), + (self.mutable_l3, self.native, NPV_SumExpression([self.l3, 5])), + (self.mutable_l3, self.npv, NPV_SumExpression([self.l3, self.npv])), + (self.mutable_l3, self.param, NPV_SumExpression([self.l3, 6])), ( self.mutable_l3, self.param_mut, - NPV_SumExpression(self.mutable_l3.args + [self.param_mut]), + NPV_SumExpression([self.l3, self.param_mut]), ), # 8: - ( - self.mutable_l3, - self.var, - LinearExpression(self.mutable_l3.args + [self.mon_var]), - ), + (self.mutable_l3, self.var, LinearExpression([self.l3, self.var])), ( self.mutable_l3, self.mon_native, - LinearExpression(self.mutable_l3.args + [self.mon_native]), + LinearExpression([self.l3, self.mon_native]), ), ( self.mutable_l3, self.mon_param, - LinearExpression(self.mutable_l3.args + [self.mon_param]), - ), - ( - self.mutable_l3, - self.mon_npv, - LinearExpression(self.mutable_l3.args + [self.mon_npv]), + LinearExpression([self.l3, self.mon_param]), ), + (self.mutable_l3, self.mon_npv, LinearExpression([self.l3, self.mon_npv])), # 12: ( self.mutable_l3, self.linear, - LinearExpression(self.linear.args + self.mutable_l3.args), - ), - ( - self.mutable_l3, - self.sum, - SumExpression(self.sum.args + self.mutable_l3.args), - ), - ( - self.mutable_l3, - self.other, - SumExpression(self.mutable_l3.args + [self.other]), + LinearExpression(self.linear.args + [self.l3]), ), + (self.mutable_l3, self.sum, SumExpression(self.sum.args + [self.l3])), + (self.mutable_l3, self.other, SumExpression([self.l3, self.other])), (self.mutable_l3, self.mutable_l0, self.npv), # 16: - ( - self.mutable_l3, - self.mutable_l1, - LinearExpression(self.mutable_l3.args + self.mutable_l1.args), - ), - ( - self.mutable_l3, - self.mutable_l2, - SumExpression(self.mutable_l2.args + self.mutable_l3.args), - ), + (self.mutable_l3, self.mutable_l1, LinearExpression([self.l3, self.l1])), + (self.mutable_l3, self.mutable_l2, SumExpression(self.l2.args + [self.l3])), (self.mutable_l3, self.param0, self.npv), - ( - self.mutable_l3, - self.param1, - NPV_SumExpression(self.mutable_l3.args + [1]), - ), + (self.mutable_l3, self.param1, NPV_SumExpression([self.l3, 1])), # 20: - ( - self.mutable_l3, - self.mutable_l3, - NPV_SumExpression(self.mutable_l3.args + [self.npv]), - ), + (self.mutable_l3, self.mutable_l3, NPV_SumExpression([self.l3, self.npv])), ] self._run_cases(tests, operator.add) # Mutable iadd handled by separate tests @@ -1515,32 +1312,32 @@ def test_sub_asbinary(self): # BooleanVar objects do not support addition (self.asbinary, self.asbinary, NotImplemented), (self.asbinary, self.zero, self.bin), - (self.asbinary, self.one, LinearExpression([self.mon_bin, -1])), + (self.asbinary, self.one, LinearExpression([self.bin, -1])), # 4: - (self.asbinary, self.native, LinearExpression([self.mon_bin, -5])), - (self.asbinary, self.npv, LinearExpression([self.mon_bin, self.minus_npv])), - (self.asbinary, self.param, LinearExpression([self.mon_bin, -6])), + (self.asbinary, self.native, LinearExpression([self.bin, -5])), + (self.asbinary, self.npv, LinearExpression([self.bin, self.minus_npv])), + (self.asbinary, self.param, LinearExpression([self.bin, -6])), ( self.asbinary, self.param_mut, - LinearExpression([self.mon_bin, self.minus_param_mut]), + LinearExpression([self.bin, self.minus_param_mut]), ), # 8: - (self.asbinary, self.var, LinearExpression([self.mon_bin, self.minus_var])), + (self.asbinary, self.var, LinearExpression([self.bin, self.minus_var])), ( self.asbinary, self.mon_native, - LinearExpression([self.mon_bin, self.minus_mon_native]), + LinearExpression([self.bin, self.minus_mon_native]), ), ( self.asbinary, self.mon_param, - LinearExpression([self.mon_bin, self.minus_mon_param]), + LinearExpression([self.bin, self.minus_mon_param]), ), ( self.asbinary, self.mon_npv, - LinearExpression([self.mon_bin, self.minus_mon_npv]), + LinearExpression([self.bin, self.minus_mon_npv]), ), # 12: (self.asbinary, self.linear, SumExpression([self.bin, self.minus_linear])), @@ -1551,20 +1348,16 @@ def test_sub_asbinary(self): ( self.asbinary, self.mutable_l1, - LinearExpression([self.mon_bin, self.minus_mon_npv]), - ), - ( - self.asbinary, - self.mutable_l2, - SumExpression([self.bin, self.minus_mutable_l2]), + LinearExpression([self.bin, self.minus_mon_npv]), ), + (self.asbinary, self.mutable_l2, SumExpression([self.bin, self.minus_l2])), (self.asbinary, self.param0, self.bin), - (self.asbinary, self.param1, LinearExpression([self.mon_bin, -1])), + (self.asbinary, self.param1, LinearExpression([self.bin, -1])), # 20: ( self.asbinary, self.mutable_l3, - LinearExpression([self.mon_bin, self.minus_npv]), + LinearExpression([self.bin, self.minus_npv]), ), ] self._run_cases(tests, operator.sub) @@ -1593,7 +1386,7 @@ def test_sub_zero(self): (self.zero, self.mutable_l0, 0), # 16: (self.zero, self.mutable_l1, self.minus_mon_npv), - (self.zero, self.mutable_l2, self.minus_mutable_l2), + (self.zero, self.mutable_l2, self.minus_l2), (self.zero, self.param0, 0), (self.zero, self.param1, -1), # 20: @@ -1625,7 +1418,7 @@ def test_sub_one(self): (self.one, self.mutable_l0, 1), # 16: (self.one, self.mutable_l1, LinearExpression([1, self.minus_mon_npv])), - (self.one, self.mutable_l2, SumExpression([1, self.minus_mutable_l2])), + (self.one, self.mutable_l2, SumExpression([1, self.minus_l2])), (self.one, self.param0, 1), (self.one, self.param1, 0), # 20: @@ -1661,7 +1454,7 @@ def test_sub_native(self): (self.native, self.mutable_l0, 5), # 16: (self.native, self.mutable_l1, LinearExpression([5, self.minus_mon_npv])), - (self.native, self.mutable_l2, SumExpression([5, self.minus_mutable_l2])), + (self.native, self.mutable_l2, SumExpression([5, self.minus_l2])), (self.native, self.param0, 5), (self.native, self.param1, 4), # 20: @@ -1709,11 +1502,7 @@ def test_sub_npv(self): self.mutable_l1, LinearExpression([self.npv, self.minus_mon_npv]), ), - ( - self.npv, - self.mutable_l2, - SumExpression([self.npv, self.minus_mutable_l2]), - ), + (self.npv, self.mutable_l2, SumExpression([self.npv, self.minus_l2])), (self.npv, self.param0, self.npv), (self.npv, self.param1, NPV_SumExpression([self.npv, -1])), # 20: @@ -1745,7 +1534,7 @@ def test_sub_param(self): (self.param, self.mutable_l0, 6), # 16: (self.param, self.mutable_l1, LinearExpression([6, self.minus_mon_npv])), - (self.param, self.mutable_l2, SumExpression([6, self.minus_mutable_l2])), + (self.param, self.mutable_l2, SumExpression([6, self.minus_l2])), (self.param, self.param0, 6), (self.param, self.param1, 5), # 20: @@ -1820,7 +1609,7 @@ def test_sub_param_mut(self): ( self.param_mut, self.mutable_l2, - SumExpression([self.param_mut, self.minus_mutable_l2]), + SumExpression([self.param_mut, self.minus_l2]), ), (self.param_mut, self.param0, self.param_mut), (self.param_mut, self.param1, NPV_SumExpression([self.param_mut, -1])), @@ -1837,35 +1626,31 @@ def test_sub_param_mut(self): def test_sub_var(self): tests = [ (self.var, self.invalid, NotImplemented), - (self.var, self.asbinary, LinearExpression([self.mon_var, self.minus_bin])), + (self.var, self.asbinary, LinearExpression([self.var, self.minus_bin])), (self.var, self.zero, self.var), - (self.var, self.one, LinearExpression([self.mon_var, -1])), + (self.var, self.one, LinearExpression([self.var, -1])), # 4: - (self.var, self.native, LinearExpression([self.mon_var, -5])), - (self.var, self.npv, LinearExpression([self.mon_var, self.minus_npv])), - (self.var, self.param, LinearExpression([self.mon_var, -6])), + (self.var, self.native, LinearExpression([self.var, -5])), + (self.var, self.npv, LinearExpression([self.var, self.minus_npv])), + (self.var, self.param, LinearExpression([self.var, -6])), ( self.var, self.param_mut, - LinearExpression([self.mon_var, self.minus_param_mut]), + LinearExpression([self.var, self.minus_param_mut]), ), # 8: - (self.var, self.var, LinearExpression([self.mon_var, self.minus_var])), + (self.var, self.var, LinearExpression([self.var, self.minus_var])), ( self.var, self.mon_native, - LinearExpression([self.mon_var, self.minus_mon_native]), + LinearExpression([self.var, self.minus_mon_native]), ), ( self.var, self.mon_param, - LinearExpression([self.mon_var, self.minus_mon_param]), - ), - ( - self.var, - self.mon_npv, - LinearExpression([self.mon_var, self.minus_mon_npv]), + LinearExpression([self.var, self.minus_mon_param]), ), + (self.var, self.mon_npv, LinearExpression([self.var, self.minus_mon_npv])), # 12: ( self.var, @@ -1879,21 +1664,13 @@ def test_sub_var(self): ( self.var, self.mutable_l1, - LinearExpression([self.mon_var, self.minus_mon_npv]), - ), - ( - self.var, - self.mutable_l2, - SumExpression([self.var, self.minus_mutable_l2]), + LinearExpression([self.var, self.minus_mon_npv]), ), + (self.var, self.mutable_l2, SumExpression([self.var, self.minus_l2])), (self.var, self.param0, self.var), - (self.var, self.param1, LinearExpression([self.mon_var, -1])), + (self.var, self.param1, LinearExpression([self.var, -1])), # 20: - ( - self.var, - self.mutable_l3, - LinearExpression([self.mon_var, self.minus_npv]), - ), + (self.var, self.mutable_l3, LinearExpression([self.var, self.minus_npv])), ] self._run_cases(tests, operator.sub) self._run_cases(tests, operator.isub) @@ -1968,7 +1745,7 @@ def test_sub_mon_native(self): ( self.mon_native, self.mutable_l2, - SumExpression([self.mon_native, self.minus_mutable_l2]), + SumExpression([self.mon_native, self.minus_l2]), ), (self.mon_native, self.param0, self.mon_native), (self.mon_native, self.param1, LinearExpression([self.mon_native, -1])), @@ -2048,7 +1825,7 @@ def test_sub_mon_param(self): ( self.mon_param, self.mutable_l2, - SumExpression([self.mon_param, self.minus_mutable_l2]), + SumExpression([self.mon_param, self.minus_l2]), ), (self.mon_param, self.param0, self.mon_param), (self.mon_param, self.param1, LinearExpression([self.mon_param, -1])), @@ -2116,7 +1893,7 @@ def test_sub_mon_npv(self): ( self.mon_npv, self.mutable_l2, - SumExpression([self.mon_npv, self.minus_mutable_l2]), + SumExpression([self.mon_npv, self.minus_l2]), ), (self.mon_npv, self.param0, self.mon_npv), (self.mon_npv, self.param1, LinearExpression([self.mon_npv, -1])), @@ -2185,11 +1962,7 @@ def test_sub_linear(self): self.mutable_l1, LinearExpression(self.linear.args + [self.minus_mon_npv]), ), - ( - self.linear, - self.mutable_l2, - SumExpression([self.linear, self.minus_mutable_l2]), - ), + (self.linear, self.mutable_l2, SumExpression([self.linear, self.minus_l2])), (self.linear, self.param0, self.linear), (self.linear, self.param1, LinearExpression(self.linear.args + [-1])), # 20: @@ -2245,11 +2018,7 @@ def test_sub_sum(self): self.mutable_l1, SumExpression(self.sum.args + [self.minus_mon_npv]), ), - ( - self.sum, - self.mutable_l2, - SumExpression(self.sum.args + [self.minus_mutable_l2]), - ), + (self.sum, self.mutable_l2, SumExpression(self.sum.args + [self.minus_l2])), (self.sum, self.param0, self.sum), (self.sum, self.param1, SumExpression(self.sum.args + [-1])), # 20: @@ -2301,11 +2070,7 @@ def test_sub_other(self): self.mutable_l1, SumExpression([self.other, self.minus_mon_npv]), ), - ( - self.other, - self.mutable_l2, - SumExpression([self.other, self.minus_mutable_l2]), - ), + (self.other, self.mutable_l2, SumExpression([self.other, self.minus_l2])), (self.other, self.param0, self.other), (self.other, self.param1, SumExpression([self.other, -1])), # 20: @@ -2334,10 +2099,10 @@ def test_sub_mutable_l0(self): (self.mutable_l0, self.linear, self.minus_linear), (self.mutable_l0, self.sum, self.minus_sum), (self.mutable_l0, self.other, self.minus_other), - (self.mutable_l0, self.mutable_l0, self.mutable_l0), + (self.mutable_l0, self.mutable_l0, self.l0), # 16: (self.mutable_l0, self.mutable_l1, self.minus_mon_npv), - (self.mutable_l0, self.mutable_l2, self.minus_mutable_l2), + (self.mutable_l0, self.mutable_l2, self.minus_l2), (self.mutable_l0, self.param0, 0), (self.mutable_l0, self.param1, -1), # 20: @@ -2353,91 +2118,55 @@ def test_sub_mutable_l1(self): ( self.mutable_l1, self.asbinary, - LinearExpression(self.mutable_l1.args + [self.minus_bin]), + LinearExpression([self.l1, self.minus_bin]), ), (self.mutable_l1, self.zero, self.mon_npv), - (self.mutable_l1, self.one, LinearExpression(self.mutable_l1.args + [-1])), + (self.mutable_l1, self.one, LinearExpression([self.l1, -1])), # 4: - ( - self.mutable_l1, - self.native, - LinearExpression(self.mutable_l1.args + [-5]), - ), - ( - self.mutable_l1, - self.npv, - LinearExpression(self.mutable_l1.args + [self.minus_npv]), - ), - ( - self.mutable_l1, - self.param, - LinearExpression(self.mutable_l1.args + [-6]), - ), + (self.mutable_l1, self.native, LinearExpression([self.l1, -5])), + (self.mutable_l1, self.npv, LinearExpression([self.l1, self.minus_npv])), + (self.mutable_l1, self.param, LinearExpression([self.l1, -6])), ( self.mutable_l1, self.param_mut, - LinearExpression(self.mutable_l1.args + [self.minus_param_mut]), + LinearExpression([self.l1, self.minus_param_mut]), ), # 8: - ( - self.mutable_l1, - self.var, - LinearExpression(self.mutable_l1.args + [self.minus_var]), - ), + (self.mutable_l1, self.var, LinearExpression([self.l1, self.minus_var])), ( self.mutable_l1, self.mon_native, - LinearExpression(self.mutable_l1.args + [self.minus_mon_native]), + LinearExpression([self.l1, self.minus_mon_native]), ), ( self.mutable_l1, self.mon_param, - LinearExpression(self.mutable_l1.args + [self.minus_mon_param]), + LinearExpression([self.l1, self.minus_mon_param]), ), ( self.mutable_l1, self.mon_npv, - LinearExpression(self.mutable_l1.args + [self.minus_mon_npv]), - ), - # 12: - ( - self.mutable_l1, - self.linear, - SumExpression(self.mutable_l1.args + [self.minus_linear]), - ), - ( - self.mutable_l1, - self.sum, - SumExpression(self.mutable_l1.args + [self.minus_sum]), - ), - ( - self.mutable_l1, - self.other, - SumExpression(self.mutable_l1.args + [self.minus_other]), + LinearExpression([self.l1, self.minus_mon_npv]), ), + # 12: + (self.mutable_l1, self.linear, SumExpression([self.l1, self.minus_linear])), + (self.mutable_l1, self.sum, SumExpression([self.l1, self.minus_sum])), + (self.mutable_l1, self.other, SumExpression([self.l1, self.minus_other])), (self.mutable_l1, self.mutable_l0, self.mon_npv), # 16: ( self.mutable_l1, self.mutable_l1, - LinearExpression(self.mutable_l1.args + [self.minus_mon_npv]), - ), - ( - self.mutable_l1, - self.mutable_l2, - SumExpression(self.mutable_l1.args + [self.minus_mutable_l2]), + LinearExpression([self.l1, self.minus_mon_npv]), ), + (self.mutable_l1, self.mutable_l2, SumExpression([self.l1, self.minus_l2])), (self.mutable_l1, self.param0, self.mon_npv), - ( - self.mutable_l1, - self.param1, - LinearExpression(self.mutable_l1.args + [-1]), - ), + (self.mutable_l1, self.param1, LinearExpression([self.l1, -1])), # 20: ( self.mutable_l1, self.mutable_l3, - LinearExpression(self.mutable_l1.args + [self.minus_npv]), + LinearExpression([self.l1, self.minus_npv]), ), ] self._run_cases(tests, operator.sub) @@ -2450,79 +2179,67 @@ def test_sub_mutable_l2(self): ( self.mutable_l2, self.asbinary, - SumExpression(self.mutable_l2.args + [self.minus_bin]), + SumExpression(self.l2.args + [self.minus_bin]), ), - (self.mutable_l2, self.zero, self.mutable_l2), - (self.mutable_l2, self.one, SumExpression(self.mutable_l2.args + [-1])), + (self.mutable_l2, self.zero, self.l2), + (self.mutable_l2, self.one, SumExpression(self.l2.args + [-1])), # 4: - (self.mutable_l2, self.native, SumExpression(self.mutable_l2.args + [-5])), - ( - self.mutable_l2, - self.npv, - SumExpression(self.mutable_l2.args + [self.minus_npv]), - ), - (self.mutable_l2, self.param, SumExpression(self.mutable_l2.args + [-6])), + (self.mutable_l2, self.native, SumExpression(self.l2.args + [-5])), + (self.mutable_l2, self.npv, SumExpression(self.l2.args + [self.minus_npv])), + (self.mutable_l2, self.param, SumExpression(self.l2.args + [-6])), ( self.mutable_l2, self.param_mut, - SumExpression(self.mutable_l2.args + [self.minus_param_mut]), + SumExpression(self.l2.args + [self.minus_param_mut]), ), # 8: - ( - self.mutable_l2, - self.var, - SumExpression(self.mutable_l2.args + [self.minus_var]), - ), + (self.mutable_l2, self.var, SumExpression(self.l2.args + [self.minus_var])), ( self.mutable_l2, self.mon_native, - SumExpression(self.mutable_l2.args + [self.minus_mon_native]), + SumExpression(self.l2.args + [self.minus_mon_native]), ), ( self.mutable_l2, self.mon_param, - SumExpression(self.mutable_l2.args + [self.minus_mon_param]), + SumExpression(self.l2.args + [self.minus_mon_param]), ), ( self.mutable_l2, self.mon_npv, - SumExpression(self.mutable_l2.args + [self.minus_mon_npv]), + SumExpression(self.l2.args + [self.minus_mon_npv]), ), # 12: ( self.mutable_l2, self.linear, - SumExpression(self.mutable_l2.args + [self.minus_linear]), - ), - ( - self.mutable_l2, - self.sum, - SumExpression(self.mutable_l2.args + [self.minus_sum]), + SumExpression(self.l2.args + [self.minus_linear]), ), + (self.mutable_l2, self.sum, SumExpression(self.l2.args + [self.minus_sum])), ( self.mutable_l2, self.other, - SumExpression(self.mutable_l2.args + [self.minus_other]), + SumExpression(self.l2.args + [self.minus_other]), ), - (self.mutable_l2, self.mutable_l0, self.mutable_l2), + (self.mutable_l2, self.mutable_l0, self.l2), # 16: ( self.mutable_l2, self.mutable_l1, - SumExpression(self.mutable_l2.args + [self.minus_mon_npv]), + SumExpression(self.l2.args + [self.minus_mon_npv]), ), ( self.mutable_l2, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.minus_mutable_l2]), + SumExpression(self.l2.args + [self.minus_l2]), ), - (self.mutable_l2, self.param0, self.mutable_l2), - (self.mutable_l2, self.param1, SumExpression(self.mutable_l2.args + [-1])), + (self.mutable_l2, self.param0, self.l2), + (self.mutable_l2, self.param1, SumExpression(self.l2.args + [-1])), # 20: ( self.mutable_l2, self.mutable_l3, - SumExpression(self.mutable_l2.args + [self.minus_npv]), + SumExpression(self.l2.args + [self.minus_npv]), ), ] self._run_cases(tests, operator.sub) @@ -2552,7 +2269,7 @@ def test_sub_param0(self): (self.param0, self.mutable_l0, 0), # 16: (self.param0, self.mutable_l1, self.minus_mon_npv), - (self.param0, self.mutable_l2, self.minus_mutable_l2), + (self.param0, self.mutable_l2, self.minus_l2), (self.param0, self.param0, 0), (self.param0, self.param1, -1), # 20: @@ -2588,7 +2305,7 @@ def test_sub_param1(self): (self.param1, self.mutable_l0, 1), # 16: (self.param1, self.mutable_l1, LinearExpression([1, self.minus_mon_npv])), - (self.param1, self.mutable_l2, SumExpression([1, self.minus_mutable_l2])), + (self.param1, self.mutable_l2, SumExpression([1, self.minus_l2])), (self.param1, self.param0, 1), (self.param1, self.param1, 0), # 20: @@ -2603,86 +2320,50 @@ def test_sub_mutable_l3(self): ( self.mutable_l3, self.asbinary, - LinearExpression(self.mutable_l3.args + [self.minus_bin]), + LinearExpression([self.l3, self.minus_bin]), ), (self.mutable_l3, self.zero, self.npv), - (self.mutable_l3, self.one, NPV_SumExpression(self.mutable_l3.args + [-1])), + (self.mutable_l3, self.one, NPV_SumExpression([self.l3, -1])), # 4: - ( - self.mutable_l3, - self.native, - NPV_SumExpression(self.mutable_l3.args + [-5]), - ), - ( - self.mutable_l3, - self.npv, - NPV_SumExpression(self.mutable_l3.args + [self.minus_npv]), - ), - ( - self.mutable_l3, - self.param, - NPV_SumExpression(self.mutable_l3.args + [-6]), - ), + (self.mutable_l3, self.native, NPV_SumExpression([self.l3, -5])), + (self.mutable_l3, self.npv, NPV_SumExpression([self.l3, self.minus_npv])), + (self.mutable_l3, self.param, NPV_SumExpression([self.l3, -6])), ( self.mutable_l3, self.param_mut, - NPV_SumExpression(self.mutable_l3.args + [self.minus_param_mut]), + NPV_SumExpression([self.l3, self.minus_param_mut]), ), # 8: - ( - self.mutable_l3, - self.var, - LinearExpression(self.mutable_l3.args + [self.minus_var]), - ), + (self.mutable_l3, self.var, LinearExpression([self.l3, self.minus_var])), ( self.mutable_l3, self.mon_native, - LinearExpression(self.mutable_l3.args + [self.minus_mon_native]), + LinearExpression([self.l3, self.minus_mon_native]), ), ( self.mutable_l3, self.mon_param, - LinearExpression(self.mutable_l3.args + [self.minus_mon_param]), + LinearExpression([self.l3, self.minus_mon_param]), ), ( self.mutable_l3, self.mon_npv, - LinearExpression(self.mutable_l3.args + [self.minus_mon_npv]), + LinearExpression([self.l3, self.minus_mon_npv]), ), # 12: - ( - self.mutable_l3, - self.linear, - SumExpression(self.mutable_l3.args + [self.minus_linear]), - ), - ( - self.mutable_l3, - self.sum, - SumExpression(self.mutable_l3.args + [self.minus_sum]), - ), - ( - self.mutable_l3, - self.other, - SumExpression(self.mutable_l3.args + [self.minus_other]), - ), + (self.mutable_l3, self.linear, SumExpression([self.l3, self.minus_linear])), + (self.mutable_l3, self.sum, SumExpression([self.l3, self.minus_sum])), + (self.mutable_l3, self.other, SumExpression([self.l3, self.minus_other])), (self.mutable_l3, self.mutable_l0, self.npv), # 16: ( self.mutable_l3, self.mutable_l1, - LinearExpression(self.mutable_l3.args + [self.minus_mon_npv]), - ), - ( - self.mutable_l3, - self.mutable_l2, - SumExpression(self.mutable_l3.args + [self.minus_mutable_l2]), + LinearExpression([self.l3, self.minus_mon_npv]), ), + (self.mutable_l3, self.mutable_l2, SumExpression([self.l3, self.minus_l2])), (self.mutable_l3, self.param0, self.npv), - ( - self.mutable_l3, - self.param1, - NPV_SumExpression(self.mutable_l3.args + [-1]), - ), + (self.mutable_l3, self.param1, NPV_SumExpression([self.l3, -1])), # 20: # Note that because the mutable is resolved to a NPV_Sum in # the negation, the 1-term summation for the first arg is @@ -2690,9 +2371,7 @@ def test_sub_mutable_l3(self): ( self.mutable_l3, self.mutable_l3, - NPV_SumExpression( - [NPV_SumExpression(self.mutable_l3.args), self.minus_npv] - ), + NPV_SumExpression([self.l3, self.minus_npv]), ), ] self._run_cases(tests, operator.sub) @@ -2780,11 +2459,7 @@ def test_mul_asbinary(self): self.mutable_l1, ProductExpression((self.bin, self.mon_npv)), ), - ( - self.asbinary, - self.mutable_l2, - ProductExpression((self.bin, self.mutable_l2)), - ), + (self.asbinary, self.mutable_l2, ProductExpression((self.bin, self.l2))), (self.asbinary, self.param0, MonomialTermExpression((0, self.bin))), (self.asbinary, self.param1, self.bin), # 20: @@ -2853,7 +2528,7 @@ def test_mul_zero(self): ) ), ), - (self.zero, self.mutable_l2, ProductExpression((0, self.mutable_l2))), + (self.zero, self.mutable_l2, ProductExpression((0, self.l2))), (self.zero, self.param0, 0), (self.zero, self.param1, 0), # 20: @@ -2887,7 +2562,7 @@ def test_mul_one(self): (self.one, self.mutable_l0, 0), # 16: (self.one, self.mutable_l1, self.mon_npv), - (self.one, self.mutable_l2, self.mutable_l2), + (self.one, self.mutable_l2, self.l2), (self.one, self.param0, self.param0), (self.one, self.param1, self.param1), # 20: @@ -2952,7 +2627,7 @@ def test_mul_native(self): ) ), ), - (self.native, self.mutable_l2, ProductExpression((5, self.mutable_l2))), + (self.native, self.mutable_l2, ProductExpression((5, self.l2))), (self.native, self.param0, 0), (self.native, self.param1, 5), # 20: @@ -3024,7 +2699,7 @@ def test_mul_npv(self): ) ), ), - (self.npv, self.mutable_l2, ProductExpression((self.npv, self.mutable_l2))), + (self.npv, self.mutable_l2, ProductExpression((self.npv, self.l2))), (self.npv, self.param0, NPV_ProductExpression((self.npv, 0))), (self.npv, self.param1, self.npv), # 20: @@ -3087,7 +2762,7 @@ def test_mul_param(self): ) ), ), - (self.param, self.mutable_l2, ProductExpression((6, self.mutable_l2))), + (self.param, self.mutable_l2, ProductExpression((6, self.l2))), (self.param, self.param0, 0), (self.param, self.param1, 6), # 20: @@ -3186,7 +2861,7 @@ def test_mul_param_mut(self): ( self.param_mut, self.mutable_l2, - ProductExpression((self.param_mut, self.mutable_l2)), + ProductExpression((self.param_mut, self.l2)), ), (self.param_mut, self.param0, NPV_ProductExpression((self.param_mut, 0))), (self.param_mut, self.param1, self.param_mut), @@ -3227,7 +2902,7 @@ def test_mul_var(self): (self.var, self.mutable_l0, MonomialTermExpression((0, self.var))), # 16: (self.var, self.mutable_l1, ProductExpression((self.var, self.mon_npv))), - (self.var, self.mutable_l2, ProductExpression((self.var, self.mutable_l2))), + (self.var, self.mutable_l2, ProductExpression((self.var, self.l2))), (self.var, self.param0, MonomialTermExpression((0, self.var))), (self.var, self.param1, self.var), # 20: @@ -3324,7 +2999,7 @@ def test_mul_mon_native(self): ( self.mon_native, self.mutable_l2, - ProductExpression((self.mon_native, self.mutable_l2)), + ProductExpression((self.mon_native, self.l2)), ), ( self.mon_native, @@ -3455,7 +3130,7 @@ def test_mul_mon_param(self): ( self.mon_param, self.mutable_l2, - ProductExpression((self.mon_param, self.mutable_l2)), + ProductExpression((self.mon_param, self.l2)), ), ( self.mon_param, @@ -3576,11 +3251,7 @@ def test_mul_mon_npv(self): self.mutable_l1, ProductExpression((self.mon_npv, self.mon_npv)), ), - ( - self.mon_npv, - self.mutable_l2, - ProductExpression((self.mon_npv, self.mutable_l2)), - ), + (self.mon_npv, self.mutable_l2, ProductExpression((self.mon_npv, self.l2))), ( self.mon_npv, self.param0, @@ -3646,11 +3317,7 @@ def test_mul_linear(self): self.mutable_l1, ProductExpression((self.linear, self.mon_npv)), ), - ( - self.linear, - self.mutable_l2, - ProductExpression((self.linear, self.mutable_l2)), - ), + (self.linear, self.mutable_l2, ProductExpression((self.linear, self.l2))), (self.linear, self.param0, ProductExpression((self.linear, 0))), (self.linear, self.param1, self.linear), # 20: @@ -3682,7 +3349,7 @@ def test_mul_sum(self): (self.sum, self.mutable_l0, ProductExpression((self.sum, 0))), # 16: (self.sum, self.mutable_l1, ProductExpression((self.sum, self.mon_npv))), - (self.sum, self.mutable_l2, ProductExpression((self.sum, self.mutable_l2))), + (self.sum, self.mutable_l2, ProductExpression((self.sum, self.l2))), (self.sum, self.param0, ProductExpression((self.sum, 0))), (self.sum, self.param1, self.sum), # 20: @@ -3730,11 +3397,7 @@ def test_mul_other(self): self.mutable_l1, ProductExpression((self.other, self.mon_npv)), ), - ( - self.other, - self.mutable_l2, - ProductExpression((self.other, self.mutable_l2)), - ), + (self.other, self.mutable_l2, ProductExpression((self.other, self.l2))), (self.other, self.param0, ProductExpression((self.other, 0))), (self.other, self.param1, self.other), # 20: @@ -3801,7 +3464,7 @@ def test_mul_mutable_l0(self): ) ), ), - (self.mutable_l0, self.mutable_l2, ProductExpression((0, self.mutable_l2))), + (self.mutable_l0, self.mutable_l2, ProductExpression((0, self.l2))), (self.mutable_l0, self.param0, 0), (self.mutable_l0, self.param1, 0), # 20: @@ -3918,7 +3581,7 @@ def test_mul_mutable_l1(self): ( self.mutable_l1, self.mutable_l2, - ProductExpression((self.mon_npv, self.mutable_l2)), + ProductExpression((self.mon_npv, self.l2)), ), ( self.mutable_l1, @@ -3949,71 +3612,47 @@ def test_mul_mutable_l1(self): def test_mul_mutable_l2(self): tests = [ (self.mutable_l2, self.invalid, NotImplemented), - ( - self.mutable_l2, - self.asbinary, - ProductExpression((self.mutable_l2, self.bin)), - ), - (self.mutable_l2, self.zero, ProductExpression((self.mutable_l2, 0))), - (self.mutable_l2, self.one, self.mutable_l2), + (self.mutable_l2, self.asbinary, ProductExpression((self.l2, self.bin))), + (self.mutable_l2, self.zero, ProductExpression((self.l2, 0))), + (self.mutable_l2, self.one, self.l2), # 4: - (self.mutable_l2, self.native, ProductExpression((self.mutable_l2, 5))), - (self.mutable_l2, self.npv, ProductExpression((self.mutable_l2, self.npv))), - (self.mutable_l2, self.param, ProductExpression((self.mutable_l2, 6))), + (self.mutable_l2, self.native, ProductExpression((self.l2, 5))), + (self.mutable_l2, self.npv, ProductExpression((self.l2, self.npv))), + (self.mutable_l2, self.param, ProductExpression((self.l2, 6))), ( self.mutable_l2, self.param_mut, - ProductExpression((self.mutable_l2, self.param_mut)), + ProductExpression((self.l2, self.param_mut)), ), # 8: - (self.mutable_l2, self.var, ProductExpression((self.mutable_l2, self.var))), + (self.mutable_l2, self.var, ProductExpression((self.l2, self.var))), ( self.mutable_l2, self.mon_native, - ProductExpression((self.mutable_l2, self.mon_native)), + ProductExpression((self.l2, self.mon_native)), ), ( self.mutable_l2, self.mon_param, - ProductExpression((self.mutable_l2, self.mon_param)), - ), - ( - self.mutable_l2, - self.mon_npv, - ProductExpression((self.mutable_l2, self.mon_npv)), + ProductExpression((self.l2, self.mon_param)), ), + (self.mutable_l2, self.mon_npv, ProductExpression((self.l2, self.mon_npv))), # 12: - ( - self.mutable_l2, - self.linear, - ProductExpression((self.mutable_l2, self.linear)), - ), - (self.mutable_l2, self.sum, ProductExpression((self.mutable_l2, self.sum))), - ( - self.mutable_l2, - self.other, - ProductExpression((self.mutable_l2, self.other)), - ), - (self.mutable_l2, self.mutable_l0, ProductExpression((self.mutable_l2, 0))), + (self.mutable_l2, self.linear, ProductExpression((self.l2, self.linear))), + (self.mutable_l2, self.sum, ProductExpression((self.l2, self.sum))), + (self.mutable_l2, self.other, ProductExpression((self.l2, self.other))), + (self.mutable_l2, self.mutable_l0, ProductExpression((self.l2, 0))), # 16: ( self.mutable_l2, self.mutable_l1, - ProductExpression((self.mutable_l2, self.mon_npv)), - ), - ( - self.mutable_l2, - self.mutable_l2, - ProductExpression((self.mutable_l2, self.mutable_l2)), + ProductExpression((self.l2, self.mon_npv)), ), - (self.mutable_l2, self.param0, ProductExpression((self.mutable_l2, 0))), - (self.mutable_l2, self.param1, self.mutable_l2), + (self.mutable_l2, self.mutable_l2, ProductExpression((self.l2, self.l2))), + (self.mutable_l2, self.param0, ProductExpression((self.l2, 0))), + (self.mutable_l2, self.param1, self.l2), # 20: - ( - self.mutable_l2, - self.mutable_l3, - ProductExpression((self.mutable_l2, self.npv)), - ), + (self.mutable_l2, self.mutable_l3, ProductExpression((self.l2, self.npv))), ] self._run_cases(tests, operator.mul) self._run_cases(tests, operator.imul) @@ -4074,7 +3713,7 @@ def test_mul_param0(self): ) ), ), - (self.param0, self.mutable_l2, ProductExpression((0, self.mutable_l2))), + (self.param0, self.mutable_l2, ProductExpression((0, self.l2))), (self.param0, self.param0, 0), (self.param0, self.param1, 0), # 20: @@ -4108,7 +3747,7 @@ def test_mul_param1(self): (self.param1, self.mutable_l0, 0), # 16: (self.param1, self.mutable_l1, self.mon_npv), - (self.param1, self.mutable_l2, self.mutable_l2), + (self.param1, self.mutable_l2, self.l2), (self.param1, self.param0, self.param0), (self.param1, self.param1, self.param1), # 20: @@ -4184,11 +3823,7 @@ def test_mul_mutable_l3(self): ) ), ), - ( - self.mutable_l3, - self.mutable_l2, - ProductExpression((self.npv, self.mutable_l2)), - ), + (self.mutable_l3, self.mutable_l2, ProductExpression((self.npv, self.l2))), (self.mutable_l3, self.param0, NPV_ProductExpression((self.npv, 0))), (self.mutable_l3, self.param1, self.npv), # 20: @@ -4287,11 +3922,7 @@ def test_div_asbinary(self): self.mutable_l1, DivisionExpression((self.bin, self.mon_npv)), ), - ( - self.asbinary, - self.mutable_l2, - DivisionExpression((self.bin, self.mutable_l2)), - ), + (self.asbinary, self.mutable_l2, DivisionExpression((self.bin, self.l2))), (self.asbinary, self.param0, ZeroDivisionError), (self.asbinary, self.param1, self.bin), # 20: @@ -4329,7 +3960,7 @@ def test_div_zero(self): (self.zero, self.mutable_l0, ZeroDivisionError), # 16: (self.zero, self.mutable_l1, DivisionExpression((0, self.mon_npv))), - (self.zero, self.mutable_l2, DivisionExpression((0, self.mutable_l2))), + (self.zero, self.mutable_l2, DivisionExpression((0, self.l2))), (self.zero, self.param0, ZeroDivisionError), (self.zero, self.param1, 0.0), # 20: @@ -4361,7 +3992,7 @@ def test_div_one(self): (self.one, self.mutable_l0, ZeroDivisionError), # 16: (self.one, self.mutable_l1, DivisionExpression((1, self.mon_npv))), - (self.one, self.mutable_l2, DivisionExpression((1, self.mutable_l2))), + (self.one, self.mutable_l2, DivisionExpression((1, self.l2))), (self.one, self.param0, ZeroDivisionError), (self.one, self.param1, 1.0), # 20: @@ -4393,7 +4024,7 @@ def test_div_native(self): (self.native, self.mutable_l0, ZeroDivisionError), # 16: (self.native, self.mutable_l1, DivisionExpression((5, self.mon_npv))), - (self.native, self.mutable_l2, DivisionExpression((5, self.mutable_l2))), + (self.native, self.mutable_l2, DivisionExpression((5, self.l2))), (self.native, self.param0, ZeroDivisionError), (self.native, self.param1, 5.0), # 20: @@ -4433,11 +4064,7 @@ def test_div_npv(self): (self.npv, self.mutable_l0, ZeroDivisionError), # 16: (self.npv, self.mutable_l1, DivisionExpression((self.npv, self.mon_npv))), - ( - self.npv, - self.mutable_l2, - DivisionExpression((self.npv, self.mutable_l2)), - ), + (self.npv, self.mutable_l2, DivisionExpression((self.npv, self.l2))), (self.npv, self.param0, ZeroDivisionError), (self.npv, self.param1, self.npv), # 20: @@ -4469,7 +4096,7 @@ def test_div_param(self): (self.param, self.mutable_l0, ZeroDivisionError), # 16: (self.param, self.mutable_l1, DivisionExpression((6, self.mon_npv))), - (self.param, self.mutable_l2, DivisionExpression((6, self.mutable_l2))), + (self.param, self.mutable_l2, DivisionExpression((6, self.l2))), (self.param, self.param0, ZeroDivisionError), (self.param, self.param1, 6.0), # 20: @@ -4540,7 +4167,7 @@ def test_div_param_mut(self): ( self.param_mut, self.mutable_l2, - DivisionExpression((self.param_mut, self.mutable_l2)), + DivisionExpression((self.param_mut, self.l2)), ), (self.param_mut, self.param0, ZeroDivisionError), (self.param_mut, self.param1, self.param_mut), @@ -4593,11 +4220,7 @@ def test_div_var(self): (self.var, self.mutable_l0, ZeroDivisionError), # 16: (self.var, self.mutable_l1, DivisionExpression((self.var, self.mon_npv))), - ( - self.var, - self.mutable_l2, - DivisionExpression((self.var, self.mutable_l2)), - ), + (self.var, self.mutable_l2, DivisionExpression((self.var, self.l2))), (self.var, self.param0, ZeroDivisionError), (self.var, self.param1, self.var), # 20: @@ -4702,7 +4325,7 @@ def test_div_mon_native(self): ( self.mon_native, self.mutable_l2, - DivisionExpression((self.mon_native, self.mutable_l2)), + DivisionExpression((self.mon_native, self.l2)), ), (self.mon_native, self.param0, ZeroDivisionError), (self.mon_native, self.param1, self.mon_native), @@ -4811,7 +4434,7 @@ def test_div_mon_param(self): ( self.mon_param, self.mutable_l2, - DivisionExpression((self.mon_param, self.mutable_l2)), + DivisionExpression((self.mon_param, self.l2)), ), (self.mon_param, self.param0, ZeroDivisionError), (self.mon_param, self.param1, self.mon_param), @@ -4912,7 +4535,7 @@ def test_div_mon_npv(self): ( self.mon_npv, self.mutable_l2, - DivisionExpression((self.mon_npv, self.mutable_l2)), + DivisionExpression((self.mon_npv, self.l2)), ), (self.mon_npv, self.param0, ZeroDivisionError), (self.mon_npv, self.param1, self.mon_npv), @@ -4974,11 +4597,7 @@ def test_div_linear(self): self.mutable_l1, DivisionExpression((self.linear, self.mon_npv)), ), - ( - self.linear, - self.mutable_l2, - DivisionExpression((self.linear, self.mutable_l2)), - ), + (self.linear, self.mutable_l2, DivisionExpression((self.linear, self.l2))), (self.linear, self.param0, ZeroDivisionError), (self.linear, self.param1, self.linear), # 20: @@ -5014,11 +4633,7 @@ def test_div_sum(self): (self.sum, self.mutable_l0, ZeroDivisionError), # 16: (self.sum, self.mutable_l1, DivisionExpression((self.sum, self.mon_npv))), - ( - self.sum, - self.mutable_l2, - DivisionExpression((self.sum, self.mutable_l2)), - ), + (self.sum, self.mutable_l2, DivisionExpression((self.sum, self.l2))), (self.sum, self.param0, ZeroDivisionError), (self.sum, self.param1, self.sum), # 20: @@ -5066,11 +4681,7 @@ def test_div_other(self): self.mutable_l1, DivisionExpression((self.other, self.mon_npv)), ), - ( - self.other, - self.mutable_l2, - DivisionExpression((self.other, self.mutable_l2)), - ), + (self.other, self.mutable_l2, DivisionExpression((self.other, self.l2))), (self.other, self.param0, ZeroDivisionError), (self.other, self.param1, self.other), # 20: @@ -5110,11 +4721,7 @@ def test_div_mutable_l0(self): (self.mutable_l0, self.mutable_l0, ZeroDivisionError), # 16: (self.mutable_l0, self.mutable_l1, DivisionExpression((0, self.mon_npv))), - ( - self.mutable_l0, - self.mutable_l2, - DivisionExpression((0, self.mutable_l2)), - ), + (self.mutable_l0, self.mutable_l2, DivisionExpression((0, self.l2))), (self.mutable_l0, self.param0, ZeroDivisionError), (self.mutable_l0, self.param1, 0.0), # 20: @@ -5213,7 +4820,7 @@ def test_div_mutable_l1(self): ( self.mutable_l1, self.mutable_l2, - DivisionExpression((self.mon_npv, self.mutable_l2)), + DivisionExpression((self.mon_npv, self.l2)), ), (self.mutable_l1, self.param0, ZeroDivisionError), (self.mutable_l1, self.param1, self.mon_npv), @@ -5235,83 +4842,51 @@ def test_div_mutable_l1(self): def test_div_mutable_l2(self): tests = [ (self.mutable_l2, self.invalid, NotImplemented), - ( - self.mutable_l2, - self.asbinary, - DivisionExpression((self.mutable_l2, self.bin)), - ), + (self.mutable_l2, self.asbinary, DivisionExpression((self.l2, self.bin))), (self.mutable_l2, self.zero, ZeroDivisionError), - (self.mutable_l2, self.one, self.mutable_l2), + (self.mutable_l2, self.one, self.l2), # 4: - (self.mutable_l2, self.native, DivisionExpression((self.mutable_l2, 5))), - ( - self.mutable_l2, - self.npv, - DivisionExpression((self.mutable_l2, self.npv)), - ), - (self.mutable_l2, self.param, DivisionExpression((self.mutable_l2, 6))), + (self.mutable_l2, self.native, DivisionExpression((self.l2, 5))), + (self.mutable_l2, self.npv, DivisionExpression((self.l2, self.npv))), + (self.mutable_l2, self.param, DivisionExpression((self.l2, 6))), ( self.mutable_l2, self.param_mut, - DivisionExpression((self.mutable_l2, self.param_mut)), + DivisionExpression((self.l2, self.param_mut)), ), # 8: - ( - self.mutable_l2, - self.var, - DivisionExpression((self.mutable_l2, self.var)), - ), + (self.mutable_l2, self.var, DivisionExpression((self.l2, self.var))), ( self.mutable_l2, self.mon_native, - DivisionExpression((self.mutable_l2, self.mon_native)), + DivisionExpression((self.l2, self.mon_native)), ), ( self.mutable_l2, self.mon_param, - DivisionExpression((self.mutable_l2, self.mon_param)), + DivisionExpression((self.l2, self.mon_param)), ), ( self.mutable_l2, self.mon_npv, - DivisionExpression((self.mutable_l2, self.mon_npv)), + DivisionExpression((self.l2, self.mon_npv)), ), # 12: - ( - self.mutable_l2, - self.linear, - DivisionExpression((self.mutable_l2, self.linear)), - ), - ( - self.mutable_l2, - self.sum, - DivisionExpression((self.mutable_l2, self.sum)), - ), - ( - self.mutable_l2, - self.other, - DivisionExpression((self.mutable_l2, self.other)), - ), + (self.mutable_l2, self.linear, DivisionExpression((self.l2, self.linear))), + (self.mutable_l2, self.sum, DivisionExpression((self.l2, self.sum))), + (self.mutable_l2, self.other, DivisionExpression((self.l2, self.other))), (self.mutable_l2, self.mutable_l0, ZeroDivisionError), # 16: ( self.mutable_l2, self.mutable_l1, - DivisionExpression((self.mutable_l2, self.mon_npv)), - ), - ( - self.mutable_l2, - self.mutable_l2, - DivisionExpression((self.mutable_l2, self.mutable_l2)), + DivisionExpression((self.l2, self.mon_npv)), ), + (self.mutable_l2, self.mutable_l2, DivisionExpression((self.l2, self.l2))), (self.mutable_l2, self.param0, ZeroDivisionError), - (self.mutable_l2, self.param1, self.mutable_l2), + (self.mutable_l2, self.param1, self.l2), # 20: - ( - self.mutable_l2, - self.mutable_l3, - DivisionExpression((self.mutable_l2, self.npv)), - ), + (self.mutable_l2, self.mutable_l3, DivisionExpression((self.l2, self.npv))), ] self._run_cases(tests, operator.truediv) self._run_cases(tests, operator.itruediv) @@ -5339,7 +4914,7 @@ def test_div_param0(self): (self.param0, self.mutable_l0, ZeroDivisionError), # 16: (self.param0, self.mutable_l1, DivisionExpression((0, self.mon_npv))), - (self.param0, self.mutable_l2, DivisionExpression((0, self.mutable_l2))), + (self.param0, self.mutable_l2, DivisionExpression((0, self.l2))), (self.param0, self.param0, ZeroDivisionError), (self.param0, self.param1, 0.0), # 20: @@ -5371,7 +4946,7 @@ def test_div_param1(self): (self.param1, self.mutable_l0, ZeroDivisionError), # 16: (self.param1, self.mutable_l1, DivisionExpression((1, self.mon_npv))), - (self.param1, self.mutable_l2, DivisionExpression((1, self.mutable_l2))), + (self.param1, self.mutable_l2, DivisionExpression((1, self.l2))), (self.param1, self.param0, ZeroDivisionError), (self.param1, self.param1, 1.0), # 20: @@ -5423,11 +4998,7 @@ def test_div_mutable_l3(self): self.mutable_l1, DivisionExpression((self.npv, self.mon_npv)), ), - ( - self.mutable_l3, - self.mutable_l2, - DivisionExpression((self.npv, self.mutable_l2)), - ), + (self.mutable_l3, self.mutable_l2, DivisionExpression((self.npv, self.l2))), (self.mutable_l3, self.param0, ZeroDivisionError), (self.mutable_l3, self.param1, self.npv), # 20: @@ -5506,11 +5077,7 @@ def test_pow_asbinary(self): (self.asbinary, self.mutable_l0, PowExpression((self.bin, 0))), # 16: (self.asbinary, self.mutable_l1, PowExpression((self.bin, self.mon_npv))), - ( - self.asbinary, - self.mutable_l2, - PowExpression((self.bin, self.mutable_l2)), - ), + (self.asbinary, self.mutable_l2, PowExpression((self.bin, self.l2))), (self.asbinary, self.param0, PowExpression((self.bin, 0))), (self.asbinary, self.param1, self.bin), # 20: @@ -5542,7 +5109,7 @@ def test_pow_zero(self): (self.zero, self.mutable_l0, 1), # 16: (self.zero, self.mutable_l1, PowExpression((0, self.mon_npv))), - (self.zero, self.mutable_l2, PowExpression((0, self.mutable_l2))), + (self.zero, self.mutable_l2, PowExpression((0, self.l2))), (self.zero, self.param0, 1), (self.zero, self.param1, 0), # 20: @@ -5574,7 +5141,7 @@ def test_pow_one(self): (self.one, self.mutable_l0, 1), # 16: (self.one, self.mutable_l1, PowExpression((1, self.mon_npv))), - (self.one, self.mutable_l2, PowExpression((1, self.mutable_l2))), + (self.one, self.mutable_l2, PowExpression((1, self.l2))), (self.one, self.param0, 1), (self.one, self.param1, 1), # 20: @@ -5606,7 +5173,7 @@ def test_pow_native(self): (self.native, self.mutable_l0, 1), # 16: (self.native, self.mutable_l1, PowExpression((5, self.mon_npv))), - (self.native, self.mutable_l2, PowExpression((5, self.mutable_l2))), + (self.native, self.mutable_l2, PowExpression((5, self.l2))), (self.native, self.param0, 1), (self.native, self.param1, 5), # 20: @@ -5638,7 +5205,7 @@ def test_pow_npv(self): (self.npv, self.mutable_l0, NPV_PowExpression((self.npv, 0))), # 16: (self.npv, self.mutable_l1, PowExpression((self.npv, self.mon_npv))), - (self.npv, self.mutable_l2, PowExpression((self.npv, self.mutable_l2))), + (self.npv, self.mutable_l2, PowExpression((self.npv, self.l2))), (self.npv, self.param0, NPV_PowExpression((self.npv, 0))), (self.npv, self.param1, self.npv), # 20: @@ -5670,7 +5237,7 @@ def test_pow_param(self): (self.param, self.mutable_l0, 1), # 16: (self.param, self.mutable_l1, PowExpression((6, self.mon_npv))), - (self.param, self.mutable_l2, PowExpression((6, self.mutable_l2))), + (self.param, self.mutable_l2, PowExpression((6, self.l2))), (self.param, self.param0, 1), (self.param, self.param1, 6), # 20: @@ -5722,11 +5289,7 @@ def test_pow_param_mut(self): self.mutable_l1, PowExpression((self.param_mut, self.mon_npv)), ), - ( - self.param_mut, - self.mutable_l2, - PowExpression((self.param_mut, self.mutable_l2)), - ), + (self.param_mut, self.mutable_l2, PowExpression((self.param_mut, self.l2))), (self.param_mut, self.param0, NPV_PowExpression((self.param_mut, 0))), (self.param_mut, self.param1, self.param_mut), # 20: @@ -5762,7 +5325,7 @@ def test_pow_var(self): (self.var, self.mutable_l0, PowExpression((self.var, 0))), # 16: (self.var, self.mutable_l1, PowExpression((self.var, self.mon_npv))), - (self.var, self.mutable_l2, PowExpression((self.var, self.mutable_l2))), + (self.var, self.mutable_l2, PowExpression((self.var, self.l2))), (self.var, self.param0, PowExpression((self.var, 0))), (self.var, self.param1, self.var), # 20: @@ -5825,7 +5388,7 @@ def test_pow_mon_native(self): ( self.mon_native, self.mutable_l2, - PowExpression((self.mon_native, self.mutable_l2)), + PowExpression((self.mon_native, self.l2)), ), (self.mon_native, self.param0, PowExpression((self.mon_native, 0))), (self.mon_native, self.param1, self.mon_native), @@ -5882,11 +5445,7 @@ def test_pow_mon_param(self): self.mutable_l1, PowExpression((self.mon_param, self.mon_npv)), ), - ( - self.mon_param, - self.mutable_l2, - PowExpression((self.mon_param, self.mutable_l2)), - ), + (self.mon_param, self.mutable_l2, PowExpression((self.mon_param, self.l2))), (self.mon_param, self.param0, PowExpression((self.mon_param, 0))), (self.mon_param, self.param1, self.mon_param), # 20: @@ -5938,11 +5497,7 @@ def test_pow_mon_npv(self): self.mutable_l1, PowExpression((self.mon_npv, self.mon_npv)), ), - ( - self.mon_npv, - self.mutable_l2, - PowExpression((self.mon_npv, self.mutable_l2)), - ), + (self.mon_npv, self.mutable_l2, PowExpression((self.mon_npv, self.l2))), (self.mon_npv, self.param0, PowExpression((self.mon_npv, 0))), (self.mon_npv, self.param1, self.mon_npv), # 20: @@ -5978,11 +5533,7 @@ def test_pow_linear(self): (self.linear, self.mutable_l0, PowExpression((self.linear, 0))), # 16: (self.linear, self.mutable_l1, PowExpression((self.linear, self.mon_npv))), - ( - self.linear, - self.mutable_l2, - PowExpression((self.linear, self.mutable_l2)), - ), + (self.linear, self.mutable_l2, PowExpression((self.linear, self.l2))), (self.linear, self.param0, PowExpression((self.linear, 0))), (self.linear, self.param1, self.linear), # 20: @@ -6014,7 +5565,7 @@ def test_pow_sum(self): (self.sum, self.mutable_l0, PowExpression((self.sum, 0))), # 16: (self.sum, self.mutable_l1, PowExpression((self.sum, self.mon_npv))), - (self.sum, self.mutable_l2, PowExpression((self.sum, self.mutable_l2))), + (self.sum, self.mutable_l2, PowExpression((self.sum, self.l2))), (self.sum, self.param0, PowExpression((self.sum, 0))), (self.sum, self.param1, self.sum), # 20: @@ -6046,7 +5597,7 @@ def test_pow_other(self): (self.other, self.mutable_l0, PowExpression((self.other, 0))), # 16: (self.other, self.mutable_l1, PowExpression((self.other, self.mon_npv))), - (self.other, self.mutable_l2, PowExpression((self.other, self.mutable_l2))), + (self.other, self.mutable_l2, PowExpression((self.other, self.l2))), (self.other, self.param0, PowExpression((self.other, 0))), (self.other, self.param1, self.other), # 20: @@ -6078,7 +5629,7 @@ def test_pow_mutable_l0(self): (self.mutable_l0, self.mutable_l0, 1), # 16: (self.mutable_l0, self.mutable_l1, PowExpression((0, self.mon_npv))), - (self.mutable_l0, self.mutable_l2, PowExpression((0, self.mutable_l2))), + (self.mutable_l0, self.mutable_l2, PowExpression((0, self.l2))), (self.mutable_l0, self.param0, 1), (self.mutable_l0, self.param1, 0), # 20: @@ -6130,11 +5681,7 @@ def test_pow_mutable_l1(self): self.mutable_l1, PowExpression((self.mon_npv, self.mon_npv)), ), - ( - self.mutable_l1, - self.mutable_l2, - PowExpression((self.mon_npv, self.mutable_l2)), - ), + (self.mutable_l1, self.mutable_l2, PowExpression((self.mon_npv, self.l2))), (self.mutable_l1, self.param0, PowExpression((self.mon_npv, 0))), (self.mutable_l1, self.param1, self.mon_npv), # 20: @@ -6146,67 +5693,35 @@ def test_pow_mutable_l1(self): def test_pow_mutable_l2(self): tests = [ (self.mutable_l2, self.invalid, NotImplemented), - ( - self.mutable_l2, - self.asbinary, - PowExpression((self.mutable_l2, self.bin)), - ), - (self.mutable_l2, self.zero, PowExpression((self.mutable_l2, 0))), - (self.mutable_l2, self.one, self.mutable_l2), + (self.mutable_l2, self.asbinary, PowExpression((self.l2, self.bin))), + (self.mutable_l2, self.zero, PowExpression((self.l2, 0))), + (self.mutable_l2, self.one, self.l2), # 4: - (self.mutable_l2, self.native, PowExpression((self.mutable_l2, 5))), - (self.mutable_l2, self.npv, PowExpression((self.mutable_l2, self.npv))), - (self.mutable_l2, self.param, PowExpression((self.mutable_l2, 6))), - ( - self.mutable_l2, - self.param_mut, - PowExpression((self.mutable_l2, self.param_mut)), - ), + (self.mutable_l2, self.native, PowExpression((self.l2, 5))), + (self.mutable_l2, self.npv, PowExpression((self.l2, self.npv))), + (self.mutable_l2, self.param, PowExpression((self.l2, 6))), + (self.mutable_l2, self.param_mut, PowExpression((self.l2, self.param_mut))), # 8: - (self.mutable_l2, self.var, PowExpression((self.mutable_l2, self.var))), + (self.mutable_l2, self.var, PowExpression((self.l2, self.var))), ( self.mutable_l2, self.mon_native, - PowExpression((self.mutable_l2, self.mon_native)), - ), - ( - self.mutable_l2, - self.mon_param, - PowExpression((self.mutable_l2, self.mon_param)), - ), - ( - self.mutable_l2, - self.mon_npv, - PowExpression((self.mutable_l2, self.mon_npv)), + PowExpression((self.l2, self.mon_native)), ), + (self.mutable_l2, self.mon_param, PowExpression((self.l2, self.mon_param))), + (self.mutable_l2, self.mon_npv, PowExpression((self.l2, self.mon_npv))), # 12: - ( - self.mutable_l2, - self.linear, - PowExpression((self.mutable_l2, self.linear)), - ), - (self.mutable_l2, self.sum, PowExpression((self.mutable_l2, self.sum))), - (self.mutable_l2, self.other, PowExpression((self.mutable_l2, self.other))), - (self.mutable_l2, self.mutable_l0, PowExpression((self.mutable_l2, 0))), + (self.mutable_l2, self.linear, PowExpression((self.l2, self.linear))), + (self.mutable_l2, self.sum, PowExpression((self.l2, self.sum))), + (self.mutable_l2, self.other, PowExpression((self.l2, self.other))), + (self.mutable_l2, self.mutable_l0, PowExpression((self.l2, 0))), # 16: - ( - self.mutable_l2, - self.mutable_l1, - PowExpression((self.mutable_l2, self.mon_npv)), - ), - ( - self.mutable_l2, - self.mutable_l2, - PowExpression((self.mutable_l2, self.mutable_l2)), - ), - (self.mutable_l2, self.param0, PowExpression((self.mutable_l2, 0))), - (self.mutable_l2, self.param1, self.mutable_l2), + (self.mutable_l2, self.mutable_l1, PowExpression((self.l2, self.mon_npv))), + (self.mutable_l2, self.mutable_l2, PowExpression((self.l2, self.l2))), + (self.mutable_l2, self.param0, PowExpression((self.l2, 0))), + (self.mutable_l2, self.param1, self.l2), # 20: - ( - self.mutable_l2, - self.mutable_l3, - PowExpression((self.mutable_l2, self.npv)), - ), + (self.mutable_l2, self.mutable_l3, PowExpression((self.l2, self.npv))), ] self._run_cases(tests, operator.pow) self._run_cases(tests, operator.ipow) @@ -6234,7 +5749,7 @@ def test_pow_param0(self): (self.param0, self.mutable_l0, 1), # 16: (self.param0, self.mutable_l1, PowExpression((0, self.mon_npv))), - (self.param0, self.mutable_l2, PowExpression((0, self.mutable_l2))), + (self.param0, self.mutable_l2, PowExpression((0, self.l2))), (self.param0, self.param0, 1), (self.param0, self.param1, 0), # 20: @@ -6266,7 +5781,7 @@ def test_pow_param1(self): (self.param1, self.mutable_l0, 1), # 16: (self.param1, self.mutable_l1, PowExpression((1, self.mon_npv))), - (self.param1, self.mutable_l2, PowExpression((1, self.mutable_l2))), + (self.param1, self.mutable_l2, PowExpression((1, self.l2))), (self.param1, self.param0, 1), (self.param1, self.param1, 1), # 20: @@ -6310,11 +5825,7 @@ def test_pow_mutable_l3(self): (self.mutable_l3, self.mutable_l0, NPV_PowExpression((self.npv, 0))), # 16: (self.mutable_l3, self.mutable_l1, PowExpression((self.npv, self.mon_npv))), - ( - self.mutable_l3, - self.mutable_l2, - PowExpression((self.npv, self.mutable_l2)), - ), + (self.mutable_l3, self.mutable_l2, PowExpression((self.npv, self.l2))), (self.mutable_l3, self.param0, NPV_PowExpression((self.npv, 0))), (self.mutable_l3, self.param1, self.npv), # 20: @@ -6352,7 +5863,7 @@ def test_neg(self): (self.mutable_l0, 0), # 16: (self.mutable_l1, self.minus_mon_npv), - (self.mutable_l2, NegationExpression((self.mutable_l2,))), + (self.mutable_l2, NegationExpression((self.l2,))), (self.param0, 0), (self.param1, -1), # 20: @@ -6386,7 +5897,7 @@ def _neg_neg(x): (self.mutable_l0, 0), # 16: (self.mutable_l1, self.mon_npv), - (self.mutable_l2, self.mutable_l2), + (self.mutable_l2, self.l2), (self.param0, 0), (self.param1, 1), # 20: @@ -6423,7 +5934,7 @@ def test_abs(self): (self.mutable_l0, 0), # 16: (self.mutable_l1, AbsExpression((self.mon_npv,))), - (self.mutable_l2, AbsExpression((self.mutable_l2,))), + (self.mutable_l2, AbsExpression((self.l2,))), (self.param0, 0), (self.param1, 1), # 20: @@ -6489,11 +6000,8 @@ def test_unary(self): (self.other, UnaryFunctionExpression((self.other,), name, fcn)), (self.mutable_l0, ValueError if name in SKIP_0 else fcn(0)), # 16: - (self.mutable_l1, UnaryFunctionExpression((self.mon_npv,), name, fcn)), - ( - self.mutable_l2, - UnaryFunctionExpression((self.mutable_l2,), name, fcn), - ), + (self.mutable_l1, UnaryFunctionExpression((self.l1,), name, fcn)), + (self.mutable_l2, UnaryFunctionExpression((self.l2,), name, fcn)), (self.param0, ValueError if name in SKIP_0 else fcn(0)), (self.param1, ValueError if name in SKIP_1 else fcn(1)), # 20: @@ -6511,7 +6019,7 @@ def test_mutable_nvp_iadd(self): mutable_npv = _MutableNPVSumExpression([]) tests = [ (mutable_npv, self.invalid, NotImplemented), - (mutable_npv, self.asbinary, _MutableLinearExpression([self.mon_bin])), + (mutable_npv, self.asbinary, _MutableLinearExpression([self.bin])), (mutable_npv, self.zero, _MutableNPVSumExpression([])), (mutable_npv, self.one, _MutableNPVSumExpression([1])), # 4: @@ -6520,7 +6028,7 @@ def test_mutable_nvp_iadd(self): (mutable_npv, self.param, _MutableNPVSumExpression([6])), (mutable_npv, self.param_mut, _MutableNPVSumExpression([self.param_mut])), # 8: - (mutable_npv, self.var, _MutableLinearExpression([self.mon_var])), + (mutable_npv, self.var, _MutableLinearExpression([self.var])), (mutable_npv, self.mon_native, _MutableLinearExpression([self.mon_native])), (mutable_npv, self.mon_param, _MutableLinearExpression([self.mon_param])), (mutable_npv, self.mon_npv, _MutableLinearExpression([self.mon_npv])), @@ -6530,12 +6038,8 @@ def test_mutable_nvp_iadd(self): (mutable_npv, self.other, _MutableSumExpression([self.other])), (mutable_npv, self.mutable_l0, _MutableNPVSumExpression([])), # 16: - ( - mutable_npv, - self.mutable_l1, - _MutableLinearExpression(self.mutable_l1.args), - ), - (mutable_npv, self.mutable_l2, _MutableSumExpression(self.mutable_l2.args)), + (mutable_npv, self.mutable_l1, _MutableLinearExpression([self.l1])), + (mutable_npv, self.mutable_l2, _MutableSumExpression(self.l2.args)), (mutable_npv, self.param0, _MutableNPVSumExpression([])), (mutable_npv, self.param1, _MutableNPVSumExpression([1])), # 20: @@ -6546,20 +6050,20 @@ def test_mutable_nvp_iadd(self): mutable_npv = _MutableNPVSumExpression([10]) tests = [ (mutable_npv, self.invalid, NotImplemented), - (mutable_npv, self.asbinary, _MutableLinearExpression([10, self.mon_bin])), + (mutable_npv, self.asbinary, _MutableLinearExpression([10, self.bin])), (mutable_npv, self.zero, _MutableNPVSumExpression([10])), - (mutable_npv, self.one, _MutableNPVSumExpression([10, 1])), + (mutable_npv, self.one, _MutableNPVSumExpression([11])), # 4: - (mutable_npv, self.native, _MutableNPVSumExpression([10, 5])), + (mutable_npv, self.native, _MutableNPVSumExpression([15])), (mutable_npv, self.npv, _MutableNPVSumExpression([10, self.npv])), - (mutable_npv, self.param, _MutableNPVSumExpression([10, 6])), + (mutable_npv, self.param, _MutableNPVSumExpression([16])), ( mutable_npv, self.param_mut, _MutableNPVSumExpression([10, self.param_mut]), ), # 8: - (mutable_npv, self.var, _MutableLinearExpression([10, self.mon_var])), + (mutable_npv, self.var, _MutableLinearExpression([10, self.var])), ( mutable_npv, self.mon_native, @@ -6581,18 +6085,10 @@ def test_mutable_nvp_iadd(self): (mutable_npv, self.other, _MutableSumExpression([10, self.other])), (mutable_npv, self.mutable_l0, _MutableNPVSumExpression([10])), # 16: - ( - mutable_npv, - self.mutable_l1, - _MutableLinearExpression([10] + self.mutable_l1.args), - ), - ( - mutable_npv, - self.mutable_l2, - _MutableSumExpression([10] + self.mutable_l2.args), - ), + (mutable_npv, self.mutable_l1, _MutableLinearExpression([10, self.l1])), + (mutable_npv, self.mutable_l2, _MutableSumExpression([10] + self.l2.args)), (mutable_npv, self.param0, _MutableNPVSumExpression([10])), - (mutable_npv, self.param1, _MutableNPVSumExpression([10, 1])), + (mutable_npv, self.param1, _MutableNPVSumExpression([11])), # 20: (mutable_npv, self.mutable_l3, _MutableNPVSumExpression([10, self.npv])), ] @@ -6602,7 +6098,7 @@ def test_mutable_lin_iadd(self): mutable_lin = _MutableLinearExpression([]) tests = [ (mutable_lin, self.invalid, NotImplemented), - (mutable_lin, self.asbinary, _MutableLinearExpression([self.mon_bin])), + (mutable_lin, self.asbinary, _MutableLinearExpression([self.bin])), (mutable_lin, self.zero, _MutableLinearExpression([])), (mutable_lin, self.one, _MutableLinearExpression([1])), # 4: @@ -6611,7 +6107,7 @@ def test_mutable_lin_iadd(self): (mutable_lin, self.param, _MutableLinearExpression([6])), (mutable_lin, self.param_mut, _MutableLinearExpression([self.param_mut])), # 8: - (mutable_lin, self.var, _MutableLinearExpression([self.mon_var])), + (mutable_lin, self.var, _MutableLinearExpression([self.var])), (mutable_lin, self.mon_native, _MutableLinearExpression([self.mon_native])), (mutable_lin, self.mon_param, _MutableLinearExpression([self.mon_param])), (mutable_lin, self.mon_npv, _MutableLinearExpression([self.mon_npv])), @@ -6621,12 +6117,8 @@ def test_mutable_lin_iadd(self): (mutable_lin, self.other, _MutableSumExpression([self.other])), (mutable_lin, self.mutable_l0, _MutableLinearExpression([])), # 16: - ( - mutable_lin, - self.mutable_l1, - _MutableLinearExpression(self.mutable_l1.args), - ), - (mutable_lin, self.mutable_l2, _MutableSumExpression(self.mutable_l2.args)), + (mutable_lin, self.mutable_l1, _MutableLinearExpression([self.l1])), + (mutable_lin, self.mutable_l2, _MutableSumExpression(self.l2.args)), (mutable_lin, self.param0, _MutableLinearExpression([])), (mutable_lin, self.param1, _MutableLinearExpression([1])), # 20: @@ -6634,81 +6126,69 @@ def test_mutable_lin_iadd(self): ] self._run_iadd_cases(tests, operator.iadd) - mutable_lin = _MutableLinearExpression([self.mon_bin]) + mutable_lin = _MutableLinearExpression([self.bin]) tests = [ (mutable_lin, self.invalid, NotImplemented), ( mutable_lin, self.asbinary, - _MutableLinearExpression([self.mon_bin, self.mon_bin]), + _MutableLinearExpression([self.bin, self.bin]), ), - (mutable_lin, self.zero, _MutableLinearExpression([self.mon_bin])), - (mutable_lin, self.one, _MutableLinearExpression([self.mon_bin, 1])), + (mutable_lin, self.zero, _MutableLinearExpression([self.bin])), + (mutable_lin, self.one, _MutableLinearExpression([self.bin, 1])), # 4: - (mutable_lin, self.native, _MutableLinearExpression([self.mon_bin, 5])), - (mutable_lin, self.npv, _MutableLinearExpression([self.mon_bin, self.npv])), - (mutable_lin, self.param, _MutableLinearExpression([self.mon_bin, 6])), + (mutable_lin, self.native, _MutableLinearExpression([self.bin, 5])), + (mutable_lin, self.npv, _MutableLinearExpression([self.bin, self.npv])), + (mutable_lin, self.param, _MutableLinearExpression([self.bin, 6])), ( mutable_lin, self.param_mut, - _MutableLinearExpression([self.mon_bin, self.param_mut]), + _MutableLinearExpression([self.bin, self.param_mut]), ), # 8: - ( - mutable_lin, - self.var, - _MutableLinearExpression([self.mon_bin, self.mon_var]), - ), + (mutable_lin, self.var, _MutableLinearExpression([self.bin, self.var])), ( mutable_lin, self.mon_native, - _MutableLinearExpression([self.mon_bin, self.mon_native]), + _MutableLinearExpression([self.bin, self.mon_native]), ), ( mutable_lin, self.mon_param, - _MutableLinearExpression([self.mon_bin, self.mon_param]), + _MutableLinearExpression([self.bin, self.mon_param]), ), ( mutable_lin, self.mon_npv, - _MutableLinearExpression([self.mon_bin, self.mon_npv]), + _MutableLinearExpression([self.bin, self.mon_npv]), ), # 12: ( mutable_lin, self.linear, - _MutableLinearExpression([self.mon_bin] + self.linear.args), - ), - ( - mutable_lin, - self.sum, - _MutableSumExpression([self.mon_bin] + self.sum.args), - ), - ( - mutable_lin, - self.other, - _MutableSumExpression([self.mon_bin, self.other]), + _MutableLinearExpression([self.bin] + self.linear.args), ), - (mutable_lin, self.mutable_l0, _MutableLinearExpression([self.mon_bin])), + (mutable_lin, self.sum, _MutableSumExpression([self.bin] + self.sum.args)), + (mutable_lin, self.other, _MutableSumExpression([self.bin, self.other])), + (mutable_lin, self.mutable_l0, _MutableLinearExpression([self.bin])), # 16: ( mutable_lin, self.mutable_l1, - _MutableLinearExpression([self.mon_bin] + self.mutable_l1.args), + _MutableLinearExpression([self.bin, self.l1]), ), ( mutable_lin, self.mutable_l2, - _MutableSumExpression([self.mon_bin] + self.mutable_l2.args), + _MutableSumExpression([self.bin] + self.l2.args), ), - (mutable_lin, self.param0, _MutableLinearExpression([self.mon_bin])), - (mutable_lin, self.param1, _MutableLinearExpression([self.mon_bin, 1])), + (mutable_lin, self.param0, _MutableLinearExpression([self.bin])), + (mutable_lin, self.param1, _MutableLinearExpression([self.bin, 1])), # 20: ( mutable_lin, self.mutable_l3, - _MutableLinearExpression([self.mon_bin, self.npv]), + _MutableLinearExpression([self.bin, self.npv]), ), ] self._run_iadd_cases(tests, operator.iadd) @@ -6736,8 +6216,8 @@ def test_mutable_sum_iadd(self): (mutable_sum, self.other, _MutableSumExpression([self.other])), (mutable_sum, self.mutable_l0, _MutableSumExpression([])), # 16: - (mutable_sum, self.mutable_l1, _MutableSumExpression(self.mutable_l1.args)), - (mutable_sum, self.mutable_l2, _MutableSumExpression(self.mutable_l2.args)), + (mutable_sum, self.mutable_l1, _MutableSumExpression([self.l1])), + (mutable_sum, self.mutable_l2, _MutableSumExpression(self.l2.args)), (mutable_sum, self.param0, _MutableSumExpression([])), (mutable_sum, self.param1, _MutableSumExpression([1])), # 20: @@ -6794,12 +6274,12 @@ def test_mutable_sum_iadd(self): ( mutable_sum, self.mutable_l1, - _MutableSumExpression([self.other] + self.mutable_l1.args), + _MutableSumExpression([self.other, self.l1]), ), ( mutable_sum, self.mutable_l2, - _MutableSumExpression([self.other] + self.mutable_l2.args), + _MutableSumExpression([self.other] + self.l2.args), ), (mutable_sum, self.param0, _MutableSumExpression([self.other])), (mutable_sum, self.param1, _MutableSumExpression([self.other, 1])), @@ -6813,7 +6293,7 @@ def test_mutable_sum_iadd(self): self._run_iadd_cases(tests, operator.iadd) -class TestExpressionGeneration_Misc(Base, unittest.TestCase): +class TestExpressionGeneration_Misc(BaseNumeric, unittest.TestCase): def test_enable_optimizations(self): enable_expression_optimizations(zero=False, one=False) self.assertEqual(_zero_one_optimizations, set()) @@ -6854,7 +6334,7 @@ def as_numeric(self): assertExpressionsEqual(self, PowExpression((self.var, 2)), e) e = obj + obj - assertExpressionsEqual(self, LinearExpression((self.mon_var, self.mon_var)), e) + assertExpressionsEqual(self, LinearExpression((self.var, self.var)), e) def test_categorize_arg_type(self): class CustomAsNumeric(NumericValue): diff --git a/pyomo/core/tests/unit/test_numeric_expr_zerofilter.py b/pyomo/core/tests/unit/test_numeric_expr_zerofilter.py index 162d664e0f8..6a02ddb00da 100644 --- a/pyomo/core/tests/unit/test_numeric_expr_zerofilter.py +++ b/pyomo/core/tests/unit/test_numeric_expr_zerofilter.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -47,10 +47,10 @@ ) from pyomo.core.expr.numvalue import NumericValue, native_types, native_numeric_types -from .test_numeric_expr_dispatcher import Base +from .test_numeric_expr_dispatcher import BaseNumeric -class TestExpressionGeneration_ZeroFilter(Base, unittest.TestCase): +class TestExpressionGeneration_ZeroFilter(BaseNumeric, unittest.TestCase): def setUp(self): super().setUp() enable_expression_optimizations(zero=True, one=True) @@ -102,38 +102,34 @@ def test_add_asbinary(self): # BooleanVar objects do not support addition (self.asbinary, self.asbinary, NotImplemented), (self.asbinary, self.zero, self.bin), - (self.asbinary, self.one, LinearExpression([self.mon_bin, 1])), + (self.asbinary, self.one, LinearExpression([self.bin, 1])), # 4: - (self.asbinary, self.native, LinearExpression([self.mon_bin, 5])), - (self.asbinary, self.npv, LinearExpression([self.mon_bin, self.npv])), - (self.asbinary, self.param, LinearExpression([self.mon_bin, 6])), + (self.asbinary, self.native, LinearExpression([self.bin, 5])), + (self.asbinary, self.npv, LinearExpression([self.bin, self.npv])), + (self.asbinary, self.param, LinearExpression([self.bin, 6])), ( self.asbinary, self.param_mut, - LinearExpression([self.mon_bin, self.param_mut]), + LinearExpression([self.bin, self.param_mut]), ), # 8: - (self.asbinary, self.var, LinearExpression([self.mon_bin, self.mon_var])), + (self.asbinary, self.var, LinearExpression([self.bin, self.var])), ( self.asbinary, self.mon_native, - LinearExpression([self.mon_bin, self.mon_native]), + LinearExpression([self.bin, self.mon_native]), ), ( self.asbinary, self.mon_param, - LinearExpression([self.mon_bin, self.mon_param]), - ), - ( - self.asbinary, - self.mon_npv, - LinearExpression([self.mon_bin, self.mon_npv]), + LinearExpression([self.bin, self.mon_param]), ), + (self.asbinary, self.mon_npv, LinearExpression([self.bin, self.mon_npv])), # 12: ( self.asbinary, self.linear, - LinearExpression(self.linear.args + [self.mon_bin]), + LinearExpression(self.linear.args + [self.bin]), ), (self.asbinary, self.sum, SumExpression(self.sum.args + [self.bin])), (self.asbinary, self.other, SumExpression([self.bin, self.other])), @@ -142,21 +138,13 @@ def test_add_asbinary(self): ( self.asbinary, self.mutable_l1, - LinearExpression([self.mon_bin, self.mon_npv]), - ), - ( - self.asbinary, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.bin]), + LinearExpression([self.bin, self.mon_npv]), ), + (self.asbinary, self.mutable_l2, SumExpression(self.l2.args + [self.bin])), (self.asbinary, self.param0, self.bin), - (self.asbinary, self.param1, LinearExpression([self.mon_bin, 1])), + (self.asbinary, self.param1, LinearExpression([self.bin, 1])), # 20: - ( - self.asbinary, - self.mutable_l3, - LinearExpression([self.mon_bin, self.npv]), - ), + (self.asbinary, self.mutable_l3, LinearExpression([self.bin, self.npv])), ] self._run_cases(tests, operator.add) self._run_cases(tests, operator.iadd) @@ -184,7 +172,7 @@ def test_add_zero(self): (self.zero, self.mutable_l0, 0), # 16: (self.zero, self.mutable_l1, self.mon_npv), - (self.zero, self.mutable_l2, self.mutable_l2), + (self.zero, self.mutable_l2, self.l2), (self.zero, self.param0, 0), (self.zero, self.param1, 1), # 20: @@ -196,7 +184,7 @@ def test_add_zero(self): def test_add_one(self): tests = [ (self.one, self.invalid, NotImplemented), - (self.one, self.asbinary, LinearExpression([1, self.mon_bin])), + (self.one, self.asbinary, LinearExpression([1, self.bin])), (self.one, self.zero, 1), (self.one, self.one, 2), # 4: @@ -205,7 +193,7 @@ def test_add_one(self): (self.one, self.param, 7), (self.one, self.param_mut, NPV_SumExpression([1, self.param_mut])), # 8: - (self.one, self.var, LinearExpression([1, self.mon_var])), + (self.one, self.var, LinearExpression([1, self.var])), (self.one, self.mon_native, LinearExpression([1, self.mon_native])), (self.one, self.mon_param, LinearExpression([1, self.mon_param])), (self.one, self.mon_npv, LinearExpression([1, self.mon_npv])), @@ -215,8 +203,8 @@ def test_add_one(self): (self.one, self.other, SumExpression([1, self.other])), (self.one, self.mutable_l0, 1), # 16: - (self.one, self.mutable_l1, LinearExpression([1] + self.mutable_l1.args)), - (self.one, self.mutable_l2, SumExpression(self.mutable_l2.args + [1])), + (self.one, self.mutable_l1, LinearExpression([1, self.l1])), + (self.one, self.mutable_l2, SumExpression(self.l2.args + [1])), (self.one, self.param0, 1), (self.one, self.param1, 2), # 20: @@ -228,7 +216,7 @@ def test_add_one(self): def test_add_native(self): tests = [ (self.native, self.invalid, NotImplemented), - (self.native, self.asbinary, LinearExpression([5, self.mon_bin])), + (self.native, self.asbinary, LinearExpression([5, self.bin])), (self.native, self.zero, 5), (self.native, self.one, 6), # 4: @@ -237,7 +225,7 @@ def test_add_native(self): (self.native, self.param, 11), (self.native, self.param_mut, NPV_SumExpression([5, self.param_mut])), # 8: - (self.native, self.var, LinearExpression([5, self.mon_var])), + (self.native, self.var, LinearExpression([5, self.var])), (self.native, self.mon_native, LinearExpression([5, self.mon_native])), (self.native, self.mon_param, LinearExpression([5, self.mon_param])), (self.native, self.mon_npv, LinearExpression([5, self.mon_npv])), @@ -247,12 +235,8 @@ def test_add_native(self): (self.native, self.other, SumExpression([5, self.other])), (self.native, self.mutable_l0, 5), # 16: - ( - self.native, - self.mutable_l1, - LinearExpression([5] + self.mutable_l1.args), - ), - (self.native, self.mutable_l2, SumExpression(self.mutable_l2.args + [5])), + (self.native, self.mutable_l1, LinearExpression([5, self.l1])), + (self.native, self.mutable_l2, SumExpression(self.l2.args + [5])), (self.native, self.param0, 5), (self.native, self.param1, 6), # 20: @@ -264,7 +248,7 @@ def test_add_native(self): def test_add_npv(self): tests = [ (self.npv, self.invalid, NotImplemented), - (self.npv, self.asbinary, LinearExpression([self.npv, self.mon_bin])), + (self.npv, self.asbinary, LinearExpression([self.npv, self.bin])), (self.npv, self.zero, self.npv), (self.npv, self.one, NPV_SumExpression([self.npv, 1])), # 4: @@ -273,7 +257,7 @@ def test_add_npv(self): (self.npv, self.param, NPV_SumExpression([self.npv, 6])), (self.npv, self.param_mut, NPV_SumExpression([self.npv, self.param_mut])), # 8: - (self.npv, self.var, LinearExpression([self.npv, self.mon_var])), + (self.npv, self.var, LinearExpression([self.npv, self.var])), (self.npv, self.mon_native, LinearExpression([self.npv, self.mon_native])), (self.npv, self.mon_param, LinearExpression([self.npv, self.mon_param])), (self.npv, self.mon_npv, LinearExpression([self.npv, self.mon_npv])), @@ -283,16 +267,8 @@ def test_add_npv(self): (self.npv, self.other, SumExpression([self.npv, self.other])), (self.npv, self.mutable_l0, self.npv), # 16: - ( - self.npv, - self.mutable_l1, - LinearExpression([self.npv] + self.mutable_l1.args), - ), - ( - self.npv, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.npv]), - ), + (self.npv, self.mutable_l1, LinearExpression([self.npv, self.l1])), + (self.npv, self.mutable_l2, SumExpression(self.l2.args + [self.npv])), (self.npv, self.param0, self.npv), (self.npv, self.param1, NPV_SumExpression([self.npv, 1])), # 20: @@ -304,7 +280,7 @@ def test_add_npv(self): def test_add_param(self): tests = [ (self.param, self.invalid, NotImplemented), - (self.param, self.asbinary, LinearExpression([6, self.mon_bin])), + (self.param, self.asbinary, LinearExpression([6, self.bin])), (self.param, self.zero, 6), (self.param, self.one, 7), # 4: @@ -313,7 +289,7 @@ def test_add_param(self): (self.param, self.param, 12), (self.param, self.param_mut, NPV_SumExpression([6, self.param_mut])), # 8: - (self.param, self.var, LinearExpression([6, self.mon_var])), + (self.param, self.var, LinearExpression([6, self.var])), (self.param, self.mon_native, LinearExpression([6, self.mon_native])), (self.param, self.mon_param, LinearExpression([6, self.mon_param])), (self.param, self.mon_npv, LinearExpression([6, self.mon_npv])), @@ -323,8 +299,8 @@ def test_add_param(self): (self.param, self.other, SumExpression([6, self.other])), (self.param, self.mutable_l0, 6), # 16: - (self.param, self.mutable_l1, LinearExpression([6] + self.mutable_l1.args)), - (self.param, self.mutable_l2, SumExpression(self.mutable_l2.args + [6])), + (self.param, self.mutable_l1, LinearExpression([6, self.l1])), + (self.param, self.mutable_l2, SumExpression(self.l2.args + [6])), (self.param, self.param0, 6), (self.param, self.param1, 7), # 20: @@ -339,7 +315,7 @@ def test_add_param_mut(self): ( self.param_mut, self.asbinary, - LinearExpression([self.param_mut, self.mon_bin]), + LinearExpression([self.param_mut, self.bin]), ), (self.param_mut, self.zero, self.param_mut), (self.param_mut, self.one, NPV_SumExpression([self.param_mut, 1])), @@ -353,11 +329,7 @@ def test_add_param_mut(self): NPV_SumExpression([self.param_mut, self.param_mut]), ), # 8: - ( - self.param_mut, - self.var, - LinearExpression([self.param_mut, self.mon_var]), - ), + (self.param_mut, self.var, LinearExpression([self.param_mut, self.var])), ( self.param_mut, self.mon_native, @@ -386,12 +358,12 @@ def test_add_param_mut(self): ( self.param_mut, self.mutable_l1, - LinearExpression([self.param_mut] + self.mutable_l1.args), + LinearExpression([self.param_mut, self.l1]), ), ( self.param_mut, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.param_mut]), + SumExpression(self.l2.args + [self.param_mut]), ), (self.param_mut, self.param0, self.param_mut), (self.param_mut, self.param1, NPV_SumExpression([self.param_mut, 1])), @@ -408,59 +380,31 @@ def test_add_param_mut(self): def test_add_var(self): tests = [ (self.var, self.invalid, NotImplemented), - (self.var, self.asbinary, LinearExpression([self.mon_var, self.mon_bin])), + (self.var, self.asbinary, LinearExpression([self.var, self.bin])), (self.var, self.zero, self.var), - (self.var, self.one, LinearExpression([self.mon_var, 1])), + (self.var, self.one, LinearExpression([self.var, 1])), # 4: - (self.var, self.native, LinearExpression([self.mon_var, 5])), - (self.var, self.npv, LinearExpression([self.mon_var, self.npv])), - (self.var, self.param, LinearExpression([self.mon_var, 6])), - ( - self.var, - self.param_mut, - LinearExpression([self.mon_var, self.param_mut]), - ), + (self.var, self.native, LinearExpression([self.var, 5])), + (self.var, self.npv, LinearExpression([self.var, self.npv])), + (self.var, self.param, LinearExpression([self.var, 6])), + (self.var, self.param_mut, LinearExpression([self.var, self.param_mut])), # 8: - (self.var, self.var, LinearExpression([self.mon_var, self.mon_var])), - ( - self.var, - self.mon_native, - LinearExpression([self.mon_var, self.mon_native]), - ), - ( - self.var, - self.mon_param, - LinearExpression([self.mon_var, self.mon_param]), - ), - (self.var, self.mon_npv, LinearExpression([self.mon_var, self.mon_npv])), + (self.var, self.var, LinearExpression([self.var, self.var])), + (self.var, self.mon_native, LinearExpression([self.var, self.mon_native])), + (self.var, self.mon_param, LinearExpression([self.var, self.mon_param])), + (self.var, self.mon_npv, LinearExpression([self.var, self.mon_npv])), # 12: - ( - self.var, - self.linear, - LinearExpression(self.linear.args + [self.mon_var]), - ), + (self.var, self.linear, LinearExpression(self.linear.args + [self.var])), (self.var, self.sum, SumExpression(self.sum.args + [self.var])), (self.var, self.other, SumExpression([self.var, self.other])), (self.var, self.mutable_l0, self.var), # 16: - ( - self.var, - self.mutable_l1, - LinearExpression([self.mon_var] + self.mutable_l1.args), - ), - ( - self.var, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.var]), - ), + (self.var, self.mutable_l1, LinearExpression([self.var, self.l1])), + (self.var, self.mutable_l2, SumExpression(self.l2.args + [self.var])), (self.var, self.param0, self.var), - (self.var, self.param1, LinearExpression([self.mon_var, 1])), + (self.var, self.param1, LinearExpression([self.var, 1])), # 20: - ( - self.var, - self.mutable_l3, - LinearExpression([MonomialTermExpression((1, self.var)), self.npv]), - ), + (self.var, self.mutable_l3, LinearExpression([self.var, self.npv])), ] self._run_cases(tests, operator.add) self._run_cases(tests, operator.iadd) @@ -471,7 +415,7 @@ def test_add_mon_native(self): ( self.mon_native, self.asbinary, - LinearExpression([self.mon_native, self.mon_bin]), + LinearExpression([self.mon_native, self.bin]), ), (self.mon_native, self.zero, self.mon_native), (self.mon_native, self.one, LinearExpression([self.mon_native, 1])), @@ -485,11 +429,7 @@ def test_add_mon_native(self): LinearExpression([self.mon_native, self.param_mut]), ), # 8: - ( - self.mon_native, - self.var, - LinearExpression([self.mon_native, self.mon_var]), - ), + (self.mon_native, self.var, LinearExpression([self.mon_native, self.var])), ( self.mon_native, self.mon_native, @@ -522,12 +462,12 @@ def test_add_mon_native(self): ( self.mon_native, self.mutable_l1, - LinearExpression([self.mon_native] + self.mutable_l1.args), + LinearExpression([self.mon_native, self.l1]), ), ( self.mon_native, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.mon_native]), + SumExpression(self.l2.args + [self.mon_native]), ), (self.mon_native, self.param0, self.mon_native), (self.mon_native, self.param1, LinearExpression([self.mon_native, 1])), @@ -547,7 +487,7 @@ def test_add_mon_param(self): ( self.mon_param, self.asbinary, - LinearExpression([self.mon_param, self.mon_bin]), + LinearExpression([self.mon_param, self.bin]), ), (self.mon_param, self.zero, self.mon_param), (self.mon_param, self.one, LinearExpression([self.mon_param, 1])), @@ -561,11 +501,7 @@ def test_add_mon_param(self): LinearExpression([self.mon_param, self.param_mut]), ), # 8: - ( - self.mon_param, - self.var, - LinearExpression([self.mon_param, self.mon_var]), - ), + (self.mon_param, self.var, LinearExpression([self.mon_param, self.var])), ( self.mon_param, self.mon_native, @@ -594,12 +530,12 @@ def test_add_mon_param(self): ( self.mon_param, self.mutable_l1, - LinearExpression([self.mon_param] + self.mutable_l1.args), + LinearExpression([self.mon_param, self.l1]), ), ( self.mon_param, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.mon_param]), + SumExpression(self.l2.args + [self.mon_param]), ), (self.mon_param, self.param0, self.mon_param), (self.mon_param, self.param1, LinearExpression([self.mon_param, 1])), @@ -616,11 +552,7 @@ def test_add_mon_param(self): def test_add_mon_npv(self): tests = [ (self.mon_npv, self.invalid, NotImplemented), - ( - self.mon_npv, - self.asbinary, - LinearExpression([self.mon_npv, self.mon_bin]), - ), + (self.mon_npv, self.asbinary, LinearExpression([self.mon_npv, self.bin])), (self.mon_npv, self.zero, self.mon_npv), (self.mon_npv, self.one, LinearExpression([self.mon_npv, 1])), # 4: @@ -633,7 +565,7 @@ def test_add_mon_npv(self): LinearExpression([self.mon_npv, self.param_mut]), ), # 8: - (self.mon_npv, self.var, LinearExpression([self.mon_npv, self.mon_var])), + (self.mon_npv, self.var, LinearExpression([self.mon_npv, self.var])), ( self.mon_npv, self.mon_native, @@ -659,15 +591,11 @@ def test_add_mon_npv(self): (self.mon_npv, self.other, SumExpression([self.mon_npv, self.other])), (self.mon_npv, self.mutable_l0, self.mon_npv), # 16: - ( - self.mon_npv, - self.mutable_l1, - LinearExpression([self.mon_npv] + self.mutable_l1.args), - ), + (self.mon_npv, self.mutable_l1, LinearExpression([self.mon_npv, self.l1])), ( self.mon_npv, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.mon_npv]), + SumExpression(self.l2.args + [self.mon_npv]), ), (self.mon_npv, self.param0, self.mon_npv), (self.mon_npv, self.param1, LinearExpression([self.mon_npv, 1])), @@ -683,7 +611,7 @@ def test_add_linear(self): ( self.linear, self.asbinary, - LinearExpression(self.linear.args + [self.mon_bin]), + LinearExpression(self.linear.args + [self.bin]), ), (self.linear, self.zero, self.linear), (self.linear, self.one, LinearExpression(self.linear.args + [1])), @@ -697,11 +625,7 @@ def test_add_linear(self): LinearExpression(self.linear.args + [self.param_mut]), ), # 8: - ( - self.linear, - self.var, - LinearExpression(self.linear.args + [self.mon_var]), - ), + (self.linear, self.var, LinearExpression(self.linear.args + [self.var])), ( self.linear, self.mon_native, @@ -730,13 +654,9 @@ def test_add_linear(self): ( self.linear, self.mutable_l1, - LinearExpression(self.linear.args + self.mutable_l1.args), - ), - ( - self.linear, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.linear]), + LinearExpression(self.linear.args + [self.l1]), ), + (self.linear, self.mutable_l2, SumExpression(self.l2.args + [self.linear])), (self.linear, self.param0, self.linear), (self.linear, self.param1, LinearExpression(self.linear.args + [1])), # 20: @@ -775,16 +695,8 @@ def test_add_sum(self): (self.sum, self.other, SumExpression(self.sum.args + [self.other])), (self.sum, self.mutable_l0, self.sum), # 16: - ( - self.sum, - self.mutable_l1, - SumExpression(self.sum.args + self.mutable_l1.args), - ), - ( - self.sum, - self.mutable_l2, - SumExpression(self.sum.args + self.mutable_l2.args), - ), + (self.sum, self.mutable_l1, SumExpression(self.sum.args + [self.l1])), + (self.sum, self.mutable_l2, SumExpression(self.sum.args + self.l2.args)), (self.sum, self.param0, self.sum), (self.sum, self.param1, SumExpression(self.sum.args + [1])), # 20: @@ -816,11 +728,7 @@ def test_add_other(self): (self.other, self.mutable_l0, self.other), # 16: (self.other, self.mutable_l1, SumExpression([self.other, self.mon_npv])), - ( - self.other, - self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.other]), - ), + (self.other, self.mutable_l2, SumExpression(self.l2.args + [self.other])), (self.other, self.param0, self.other), (self.other, self.param1, SumExpression([self.other, 1])), # 20: @@ -852,7 +760,7 @@ def test_add_mutable_l0(self): (self.mutable_l0, self.mutable_l0, 0), # 16: (self.mutable_l0, self.mutable_l1, self.mon_npv), - (self.mutable_l0, self.mutable_l2, self.mutable_l2), + (self.mutable_l0, self.mutable_l2, self.l2), (self.mutable_l0, self.param0, 0), (self.mutable_l0, self.param1, 1), # 20: @@ -865,91 +773,47 @@ def test_add_mutable_l0(self): def test_add_mutable_l1(self): tests = [ (self.mutable_l1, self.invalid, NotImplemented), - ( - self.mutable_l1, - self.asbinary, - LinearExpression(self.mutable_l1.args + [self.mon_bin]), - ), + (self.mutable_l1, self.asbinary, LinearExpression([self.l1, self.bin])), (self.mutable_l1, self.zero, self.mon_npv), - (self.mutable_l1, self.one, LinearExpression(self.mutable_l1.args + [1])), + (self.mutable_l1, self.one, LinearExpression([self.l1, 1])), # 4: - ( - self.mutable_l1, - self.native, - LinearExpression(self.mutable_l1.args + [5]), - ), - ( - self.mutable_l1, - self.npv, - LinearExpression(self.mutable_l1.args + [self.npv]), - ), - (self.mutable_l1, self.param, LinearExpression(self.mutable_l1.args + [6])), + (self.mutable_l1, self.native, LinearExpression([self.l1, 5])), + (self.mutable_l1, self.npv, LinearExpression([self.l1, self.npv])), + (self.mutable_l1, self.param, LinearExpression([self.l1, 6])), ( self.mutable_l1, self.param_mut, - LinearExpression(self.mutable_l1.args + [self.param_mut]), + LinearExpression([self.l1, self.param_mut]), ), # 8: - ( - self.mutable_l1, - self.var, - LinearExpression(self.mutable_l1.args + [self.mon_var]), - ), + (self.mutable_l1, self.var, LinearExpression([self.l1, self.var])), ( self.mutable_l1, self.mon_native, - LinearExpression(self.mutable_l1.args + [self.mon_native]), + LinearExpression([self.l1, self.mon_native]), ), ( self.mutable_l1, self.mon_param, - LinearExpression(self.mutable_l1.args + [self.mon_param]), - ), - ( - self.mutable_l1, - self.mon_npv, - LinearExpression(self.mutable_l1.args + [self.mon_npv]), + LinearExpression([self.l1, self.mon_param]), ), + (self.mutable_l1, self.mon_npv, LinearExpression([self.l1, self.mon_npv])), # 12: ( self.mutable_l1, self.linear, - LinearExpression(self.linear.args + self.mutable_l1.args), - ), - ( - self.mutable_l1, - self.sum, - SumExpression(self.sum.args + self.mutable_l1.args), - ), - ( - self.mutable_l1, - self.other, - SumExpression(self.mutable_l1.args + [self.other]), + LinearExpression(self.linear.args + [self.l1]), ), + (self.mutable_l1, self.sum, SumExpression(self.sum.args + [self.l1])), + (self.mutable_l1, self.other, SumExpression([self.l1, self.other])), (self.mutable_l1, self.mutable_l0, self.mon_npv), # 16: - ( - self.mutable_l1, - self.mutable_l1, - LinearExpression(self.mutable_l1.args + self.mutable_l1.args), - ), - ( - self.mutable_l1, - self.mutable_l2, - SumExpression(self.mutable_l2.args + self.mutable_l1.args), - ), + (self.mutable_l1, self.mutable_l1, LinearExpression([self.l1, self.l1])), + (self.mutable_l1, self.mutable_l2, SumExpression(self.l2.args + [self.l1])), (self.mutable_l1, self.param0, self.mon_npv), - ( - self.mutable_l1, - self.param1, - LinearExpression(self.mutable_l1.args + [1]), - ), + (self.mutable_l1, self.param1, LinearExpression([self.l1, 1])), # 20: - ( - self.mutable_l1, - self.mutable_l3, - LinearExpression(self.mutable_l1.args + [self.npv]), - ), + (self.mutable_l1, self.mutable_l3, LinearExpression([self.l1, self.npv])), ] self._run_cases(tests, operator.add) # Mutable iadd handled by separate tests @@ -958,82 +822,54 @@ def test_add_mutable_l1(self): def test_add_mutable_l2(self): tests = [ (self.mutable_l2, self.invalid, NotImplemented), - ( - self.mutable_l2, - self.asbinary, - SumExpression(self.mutable_l2.args + [self.bin]), - ), - (self.mutable_l2, self.zero, self.mutable_l2), - (self.mutable_l2, self.one, SumExpression(self.mutable_l2.args + [1])), + (self.mutable_l2, self.asbinary, SumExpression(self.l2.args + [self.bin])), + (self.mutable_l2, self.zero, self.l2), + (self.mutable_l2, self.one, SumExpression(self.l2.args + [1])), # 4: - (self.mutable_l2, self.native, SumExpression(self.mutable_l2.args + [5])), - ( - self.mutable_l2, - self.npv, - SumExpression(self.mutable_l2.args + [self.npv]), - ), - (self.mutable_l2, self.param, SumExpression(self.mutable_l2.args + [6])), + (self.mutable_l2, self.native, SumExpression(self.l2.args + [5])), + (self.mutable_l2, self.npv, SumExpression(self.l2.args + [self.npv])), + (self.mutable_l2, self.param, SumExpression(self.l2.args + [6])), ( self.mutable_l2, self.param_mut, - SumExpression(self.mutable_l2.args + [self.param_mut]), + SumExpression(self.l2.args + [self.param_mut]), ), # 8: - ( - self.mutable_l2, - self.var, - SumExpression(self.mutable_l2.args + [self.var]), - ), + (self.mutable_l2, self.var, SumExpression(self.l2.args + [self.var])), ( self.mutable_l2, self.mon_native, - SumExpression(self.mutable_l2.args + [self.mon_native]), + SumExpression(self.l2.args + [self.mon_native]), ), ( self.mutable_l2, self.mon_param, - SumExpression(self.mutable_l2.args + [self.mon_param]), + SumExpression(self.l2.args + [self.mon_param]), ), ( self.mutable_l2, self.mon_npv, - SumExpression(self.mutable_l2.args + [self.mon_npv]), + SumExpression(self.l2.args + [self.mon_npv]), ), # 12: - ( - self.mutable_l2, - self.linear, - SumExpression(self.mutable_l2.args + [self.linear]), - ), - ( - self.mutable_l2, - self.sum, - SumExpression(self.mutable_l2.args + self.sum.args), - ), - ( - self.mutable_l2, - self.other, - SumExpression(self.mutable_l2.args + [self.other]), - ), - (self.mutable_l2, self.mutable_l0, self.mutable_l2), + (self.mutable_l2, self.linear, SumExpression(self.l2.args + [self.linear])), + (self.mutable_l2, self.sum, SumExpression(self.l2.args + self.sum.args)), + (self.mutable_l2, self.other, SumExpression(self.l2.args + [self.other])), + (self.mutable_l2, self.mutable_l0, self.l2), # 16: - ( - self.mutable_l2, - self.mutable_l1, - SumExpression(self.mutable_l2.args + self.mutable_l1.args), - ), + (self.mutable_l2, self.mutable_l1, SumExpression(self.l2.args + [self.l1])), ( self.mutable_l2, self.mutable_l2, - SumExpression(self.mutable_l2.args + self.mutable_l2.args), + SumExpression(self.l2.args + self.l2.args), ), - (self.mutable_l2, self.param0, self.mutable_l2), - (self.mutable_l2, self.param1, SumExpression(self.mutable_l2.args + [1])), + (self.mutable_l2, self.param0, self.l2), + (self.mutable_l2, self.param1, SumExpression(self.l2.args + [1])), # 20: ( self.mutable_l2, self.mutable_l3, - SumExpression(self.mutable_l2.args + [self.npv]), + SumExpression(self.l2.args + [self.npv]), ), ] self._run_cases(tests, operator.add) @@ -1063,7 +899,7 @@ def test_add_param0(self): (self.param0, self.mutable_l0, 0), # 16: (self.param0, self.mutable_l1, self.mon_npv), - (self.param0, self.mutable_l2, self.mutable_l2), + (self.param0, self.mutable_l2, self.l2), (self.param0, self.param0, 0), (self.param0, self.param1, 1), # 20: @@ -1075,7 +911,7 @@ def test_add_param0(self): def test_add_param1(self): tests = [ (self.param1, self.invalid, NotImplemented), - (self.param1, self.asbinary, LinearExpression([1, self.mon_bin])), + (self.param1, self.asbinary, LinearExpression([1, self.bin])), (self.param1, self.zero, 1), (self.param1, self.one, 2), # 4: @@ -1084,7 +920,7 @@ def test_add_param1(self): (self.param1, self.param, 7), (self.param1, self.param_mut, NPV_SumExpression([1, self.param_mut])), # 8: - (self.param1, self.var, LinearExpression([1, self.mon_var])), + (self.param1, self.var, LinearExpression([1, self.var])), (self.param1, self.mon_native, LinearExpression([1, self.mon_native])), (self.param1, self.mon_param, LinearExpression([1, self.mon_param])), (self.param1, self.mon_npv, LinearExpression([1, self.mon_npv])), @@ -1094,12 +930,8 @@ def test_add_param1(self): (self.param1, self.other, SumExpression([1, self.other])), (self.param1, self.mutable_l0, 1), # 16: - ( - self.param1, - self.mutable_l1, - LinearExpression([1] + self.mutable_l1.args), - ), - (self.param1, self.mutable_l2, SumExpression(self.mutable_l2.args + [1])), + (self.param1, self.mutable_l1, LinearExpression([1, self.l1])), + (self.param1, self.mutable_l2, SumExpression(self.l2.args + [1])), (self.param1, self.param0, 1), (self.param1, self.param1, 2), # 20: @@ -1111,95 +943,47 @@ def test_add_param1(self): def test_add_mutable_l3(self): tests = [ (self.mutable_l3, self.invalid, NotImplemented), - ( - self.mutable_l3, - self.asbinary, - LinearExpression(self.mutable_l3.args + [self.mon_bin]), - ), + (self.mutable_l3, self.asbinary, LinearExpression([self.l3, self.bin])), (self.mutable_l3, self.zero, self.npv), - (self.mutable_l3, self.one, NPV_SumExpression(self.mutable_l3.args + [1])), + (self.mutable_l3, self.one, NPV_SumExpression([self.l3, 1])), # 4: - ( - self.mutable_l3, - self.native, - NPV_SumExpression(self.mutable_l3.args + [5]), - ), - ( - self.mutable_l3, - self.npv, - NPV_SumExpression(self.mutable_l3.args + [self.npv]), - ), - ( - self.mutable_l3, - self.param, - NPV_SumExpression(self.mutable_l3.args + [6]), - ), + (self.mutable_l3, self.native, NPV_SumExpression([self.l3, 5])), + (self.mutable_l3, self.npv, NPV_SumExpression([self.l3, self.npv])), + (self.mutable_l3, self.param, NPV_SumExpression([self.l3, 6])), ( self.mutable_l3, self.param_mut, - NPV_SumExpression(self.mutable_l3.args + [self.param_mut]), + NPV_SumExpression([self.l3, self.param_mut]), ), # 8: - ( - self.mutable_l3, - self.var, - LinearExpression(self.mutable_l3.args + [self.mon_var]), - ), + (self.mutable_l3, self.var, LinearExpression([self.l3, self.var])), ( self.mutable_l3, self.mon_native, - LinearExpression(self.mutable_l3.args + [self.mon_native]), + LinearExpression([self.l3, self.mon_native]), ), ( self.mutable_l3, self.mon_param, - LinearExpression(self.mutable_l3.args + [self.mon_param]), - ), - ( - self.mutable_l3, - self.mon_npv, - LinearExpression(self.mutable_l3.args + [self.mon_npv]), + LinearExpression([self.l3, self.mon_param]), ), + (self.mutable_l3, self.mon_npv, LinearExpression([self.l3, self.mon_npv])), # 12: ( self.mutable_l3, self.linear, - LinearExpression(self.linear.args + self.mutable_l3.args), - ), - ( - self.mutable_l3, - self.sum, - SumExpression(self.sum.args + self.mutable_l3.args), - ), - ( - self.mutable_l3, - self.other, - SumExpression(self.mutable_l3.args + [self.other]), + LinearExpression(self.linear.args + [self.l3]), ), + (self.mutable_l3, self.sum, SumExpression(self.sum.args + [self.l3])), + (self.mutable_l3, self.other, SumExpression([self.l3, self.other])), (self.mutable_l3, self.mutable_l0, self.npv), # 16: - ( - self.mutable_l3, - self.mutable_l1, - LinearExpression(self.mutable_l3.args + self.mutable_l1.args), - ), - ( - self.mutable_l3, - self.mutable_l2, - SumExpression(self.mutable_l2.args + self.mutable_l3.args), - ), + (self.mutable_l3, self.mutable_l1, LinearExpression([self.l3, self.l1])), + (self.mutable_l3, self.mutable_l2, SumExpression(self.l2.args + [self.l3])), (self.mutable_l3, self.param0, self.npv), - ( - self.mutable_l3, - self.param1, - NPV_SumExpression(self.mutable_l3.args + [1]), - ), + (self.mutable_l3, self.param1, NPV_SumExpression([self.l3, 1])), # 20: - ( - self.mutable_l3, - self.mutable_l3, - NPV_SumExpression(self.mutable_l3.args + [self.npv]), - ), + (self.mutable_l3, self.mutable_l3, NPV_SumExpression([self.l3, self.npv])), ] self._run_cases(tests, operator.add) # Mutable iadd handled by separate tests @@ -1249,32 +1033,32 @@ def test_sub_asbinary(self): # BooleanVar objects do not support addition (self.asbinary, self.asbinary, NotImplemented), (self.asbinary, self.zero, self.bin), - (self.asbinary, self.one, LinearExpression([self.mon_bin, -1])), + (self.asbinary, self.one, LinearExpression([self.bin, -1])), # 4: - (self.asbinary, self.native, LinearExpression([self.mon_bin, -5])), - (self.asbinary, self.npv, LinearExpression([self.mon_bin, self.minus_npv])), - (self.asbinary, self.param, LinearExpression([self.mon_bin, -6])), + (self.asbinary, self.native, LinearExpression([self.bin, -5])), + (self.asbinary, self.npv, LinearExpression([self.bin, self.minus_npv])), + (self.asbinary, self.param, LinearExpression([self.bin, -6])), ( self.asbinary, self.param_mut, - LinearExpression([self.mon_bin, self.minus_param_mut]), + LinearExpression([self.bin, self.minus_param_mut]), ), # 8: - (self.asbinary, self.var, LinearExpression([self.mon_bin, self.minus_var])), + (self.asbinary, self.var, LinearExpression([self.bin, self.minus_var])), ( self.asbinary, self.mon_native, - LinearExpression([self.mon_bin, self.minus_mon_native]), + LinearExpression([self.bin, self.minus_mon_native]), ), ( self.asbinary, self.mon_param, - LinearExpression([self.mon_bin, self.minus_mon_param]), + LinearExpression([self.bin, self.minus_mon_param]), ), ( self.asbinary, self.mon_npv, - LinearExpression([self.mon_bin, self.minus_mon_npv]), + LinearExpression([self.bin, self.minus_mon_npv]), ), # 12: (self.asbinary, self.linear, SumExpression([self.bin, self.minus_linear])), @@ -1285,20 +1069,16 @@ def test_sub_asbinary(self): ( self.asbinary, self.mutable_l1, - LinearExpression([self.mon_bin, self.minus_mon_npv]), - ), - ( - self.asbinary, - self.mutable_l2, - SumExpression([self.bin, self.minus_mutable_l2]), + LinearExpression([self.bin, self.minus_mon_npv]), ), + (self.asbinary, self.mutable_l2, SumExpression([self.bin, self.minus_l2])), (self.asbinary, self.param0, self.bin), - (self.asbinary, self.param1, LinearExpression([self.mon_bin, -1])), + (self.asbinary, self.param1, LinearExpression([self.bin, -1])), # 20: ( self.asbinary, self.mutable_l3, - LinearExpression([self.mon_bin, self.minus_npv]), + LinearExpression([self.bin, self.minus_npv]), ), ] self._run_cases(tests, operator.sub) @@ -1327,7 +1107,7 @@ def test_sub_zero(self): (self.zero, self.mutable_l0, 0), # 16: (self.zero, self.mutable_l1, self.minus_mon_npv), - (self.zero, self.mutable_l2, self.minus_mutable_l2), + (self.zero, self.mutable_l2, self.minus_l2), (self.zero, self.param0, 0), (self.zero, self.param1, -1), # 20: @@ -1359,7 +1139,7 @@ def test_sub_one(self): (self.one, self.mutable_l0, 1), # 16: (self.one, self.mutable_l1, LinearExpression([1, self.minus_mon_npv])), - (self.one, self.mutable_l2, SumExpression([1, self.minus_mutable_l2])), + (self.one, self.mutable_l2, SumExpression([1, self.minus_l2])), (self.one, self.param0, 1), (self.one, self.param1, 0), # 20: @@ -1395,7 +1175,7 @@ def test_sub_native(self): (self.native, self.mutable_l0, 5), # 16: (self.native, self.mutable_l1, LinearExpression([5, self.minus_mon_npv])), - (self.native, self.mutable_l2, SumExpression([5, self.minus_mutable_l2])), + (self.native, self.mutable_l2, SumExpression([5, self.minus_l2])), (self.native, self.param0, 5), (self.native, self.param1, 4), # 20: @@ -1443,11 +1223,7 @@ def test_sub_npv(self): self.mutable_l1, LinearExpression([self.npv, self.minus_mon_npv]), ), - ( - self.npv, - self.mutable_l2, - SumExpression([self.npv, self.minus_mutable_l2]), - ), + (self.npv, self.mutable_l2, SumExpression([self.npv, self.minus_l2])), (self.npv, self.param0, self.npv), (self.npv, self.param1, NPV_SumExpression([self.npv, -1])), # 20: @@ -1479,7 +1255,7 @@ def test_sub_param(self): (self.param, self.mutable_l0, 6), # 16: (self.param, self.mutable_l1, LinearExpression([6, self.minus_mon_npv])), - (self.param, self.mutable_l2, SumExpression([6, self.minus_mutable_l2])), + (self.param, self.mutable_l2, SumExpression([6, self.minus_l2])), (self.param, self.param0, 6), (self.param, self.param1, 5), # 20: @@ -1554,7 +1330,7 @@ def test_sub_param_mut(self): ( self.param_mut, self.mutable_l2, - SumExpression([self.param_mut, self.minus_mutable_l2]), + SumExpression([self.param_mut, self.minus_l2]), ), (self.param_mut, self.param0, self.param_mut), (self.param_mut, self.param1, NPV_SumExpression([self.param_mut, -1])), @@ -1571,35 +1347,31 @@ def test_sub_param_mut(self): def test_sub_var(self): tests = [ (self.var, self.invalid, NotImplemented), - (self.var, self.asbinary, LinearExpression([self.mon_var, self.minus_bin])), + (self.var, self.asbinary, LinearExpression([self.var, self.minus_bin])), (self.var, self.zero, self.var), - (self.var, self.one, LinearExpression([self.mon_var, -1])), + (self.var, self.one, LinearExpression([self.var, -1])), # 4: - (self.var, self.native, LinearExpression([self.mon_var, -5])), - (self.var, self.npv, LinearExpression([self.mon_var, self.minus_npv])), - (self.var, self.param, LinearExpression([self.mon_var, -6])), + (self.var, self.native, LinearExpression([self.var, -5])), + (self.var, self.npv, LinearExpression([self.var, self.minus_npv])), + (self.var, self.param, LinearExpression([self.var, -6])), ( self.var, self.param_mut, - LinearExpression([self.mon_var, self.minus_param_mut]), + LinearExpression([self.var, self.minus_param_mut]), ), # 8: - (self.var, self.var, LinearExpression([self.mon_var, self.minus_var])), + (self.var, self.var, LinearExpression([self.var, self.minus_var])), ( self.var, self.mon_native, - LinearExpression([self.mon_var, self.minus_mon_native]), + LinearExpression([self.var, self.minus_mon_native]), ), ( self.var, self.mon_param, - LinearExpression([self.mon_var, self.minus_mon_param]), - ), - ( - self.var, - self.mon_npv, - LinearExpression([self.mon_var, self.minus_mon_npv]), + LinearExpression([self.var, self.minus_mon_param]), ), + (self.var, self.mon_npv, LinearExpression([self.var, self.minus_mon_npv])), # 12: ( self.var, @@ -1613,21 +1385,13 @@ def test_sub_var(self): ( self.var, self.mutable_l1, - LinearExpression([self.mon_var, self.minus_mon_npv]), - ), - ( - self.var, - self.mutable_l2, - SumExpression([self.var, self.minus_mutable_l2]), + LinearExpression([self.var, self.minus_mon_npv]), ), + (self.var, self.mutable_l2, SumExpression([self.var, self.minus_l2])), (self.var, self.param0, self.var), - (self.var, self.param1, LinearExpression([self.mon_var, -1])), + (self.var, self.param1, LinearExpression([self.var, -1])), # 20: - ( - self.var, - self.mutable_l3, - LinearExpression([self.mon_var, self.minus_npv]), - ), + (self.var, self.mutable_l3, LinearExpression([self.var, self.minus_npv])), ] self._run_cases(tests, operator.sub) self._run_cases(tests, operator.isub) @@ -1702,7 +1466,7 @@ def test_sub_mon_native(self): ( self.mon_native, self.mutable_l2, - SumExpression([self.mon_native, self.minus_mutable_l2]), + SumExpression([self.mon_native, self.minus_l2]), ), (self.mon_native, self.param0, self.mon_native), (self.mon_native, self.param1, LinearExpression([self.mon_native, -1])), @@ -1782,7 +1546,7 @@ def test_sub_mon_param(self): ( self.mon_param, self.mutable_l2, - SumExpression([self.mon_param, self.minus_mutable_l2]), + SumExpression([self.mon_param, self.minus_l2]), ), (self.mon_param, self.param0, self.mon_param), (self.mon_param, self.param1, LinearExpression([self.mon_param, -1])), @@ -1850,7 +1614,7 @@ def test_sub_mon_npv(self): ( self.mon_npv, self.mutable_l2, - SumExpression([self.mon_npv, self.minus_mutable_l2]), + SumExpression([self.mon_npv, self.minus_l2]), ), (self.mon_npv, self.param0, self.mon_npv), (self.mon_npv, self.param1, LinearExpression([self.mon_npv, -1])), @@ -1919,11 +1683,7 @@ def test_sub_linear(self): self.mutable_l1, LinearExpression(self.linear.args + [self.minus_mon_npv]), ), - ( - self.linear, - self.mutable_l2, - SumExpression([self.linear, self.minus_mutable_l2]), - ), + (self.linear, self.mutable_l2, SumExpression([self.linear, self.minus_l2])), (self.linear, self.param0, self.linear), (self.linear, self.param1, LinearExpression(self.linear.args + [-1])), # 20: @@ -1979,11 +1739,7 @@ def test_sub_sum(self): self.mutable_l1, SumExpression(self.sum.args + [self.minus_mon_npv]), ), - ( - self.sum, - self.mutable_l2, - SumExpression(self.sum.args + [self.minus_mutable_l2]), - ), + (self.sum, self.mutable_l2, SumExpression(self.sum.args + [self.minus_l2])), (self.sum, self.param0, self.sum), (self.sum, self.param1, SumExpression(self.sum.args + [-1])), # 20: @@ -2035,11 +1791,7 @@ def test_sub_other(self): self.mutable_l1, SumExpression([self.other, self.minus_mon_npv]), ), - ( - self.other, - self.mutable_l2, - SumExpression([self.other, self.minus_mutable_l2]), - ), + (self.other, self.mutable_l2, SumExpression([self.other, self.minus_l2])), (self.other, self.param0, self.other), (self.other, self.param1, SumExpression([self.other, -1])), # 20: @@ -2068,10 +1820,10 @@ def test_sub_mutable_l0(self): (self.mutable_l0, self.linear, self.minus_linear), (self.mutable_l0, self.sum, self.minus_sum), (self.mutable_l0, self.other, self.minus_other), - (self.mutable_l0, self.mutable_l0, self.mutable_l0), + (self.mutable_l0, self.mutable_l0, self.l0), # 16: (self.mutable_l0, self.mutable_l1, self.minus_mon_npv), - (self.mutable_l0, self.mutable_l2, self.minus_mutable_l2), + (self.mutable_l0, self.mutable_l2, self.minus_l2), (self.mutable_l0, self.param0, 0), (self.mutable_l0, self.param1, -1), # 20: @@ -2087,91 +1839,55 @@ def test_sub_mutable_l1(self): ( self.mutable_l1, self.asbinary, - LinearExpression(self.mutable_l1.args + [self.minus_bin]), + LinearExpression([self.l1, self.minus_bin]), ), (self.mutable_l1, self.zero, self.mon_npv), - (self.mutable_l1, self.one, LinearExpression(self.mutable_l1.args + [-1])), + (self.mutable_l1, self.one, LinearExpression([self.l1, -1])), # 4: - ( - self.mutable_l1, - self.native, - LinearExpression(self.mutable_l1.args + [-5]), - ), - ( - self.mutable_l1, - self.npv, - LinearExpression(self.mutable_l1.args + [self.minus_npv]), - ), - ( - self.mutable_l1, - self.param, - LinearExpression(self.mutable_l1.args + [-6]), - ), + (self.mutable_l1, self.native, LinearExpression([self.l1, -5])), + (self.mutable_l1, self.npv, LinearExpression([self.l1, self.minus_npv])), + (self.mutable_l1, self.param, LinearExpression([self.l1, -6])), ( self.mutable_l1, self.param_mut, - LinearExpression(self.mutable_l1.args + [self.minus_param_mut]), - ), - # 8: - ( - self.mutable_l1, - self.var, - LinearExpression(self.mutable_l1.args + [self.minus_var]), + LinearExpression([self.l1, self.minus_param_mut]), ), + # 8: + (self.mutable_l1, self.var, LinearExpression([self.l1, self.minus_var])), ( self.mutable_l1, self.mon_native, - LinearExpression(self.mutable_l1.args + [self.minus_mon_native]), + LinearExpression([self.l1, self.minus_mon_native]), ), ( self.mutable_l1, self.mon_param, - LinearExpression(self.mutable_l1.args + [self.minus_mon_param]), + LinearExpression([self.l1, self.minus_mon_param]), ), ( self.mutable_l1, self.mon_npv, - LinearExpression(self.mutable_l1.args + [self.minus_mon_npv]), + LinearExpression([self.l1, self.minus_mon_npv]), ), # 12: - ( - self.mutable_l1, - self.linear, - SumExpression(self.mutable_l1.args + [self.minus_linear]), - ), - ( - self.mutable_l1, - self.sum, - SumExpression(self.mutable_l1.args + [self.minus_sum]), - ), - ( - self.mutable_l1, - self.other, - SumExpression(self.mutable_l1.args + [self.minus_other]), - ), + (self.mutable_l1, self.linear, SumExpression([self.l1, self.minus_linear])), + (self.mutable_l1, self.sum, SumExpression([self.l1, self.minus_sum])), + (self.mutable_l1, self.other, SumExpression([self.l1, self.minus_other])), (self.mutable_l1, self.mutable_l0, self.mon_npv), # 16: ( self.mutable_l1, self.mutable_l1, - LinearExpression(self.mutable_l1.args + [self.minus_mon_npv]), - ), - ( - self.mutable_l1, - self.mutable_l2, - SumExpression(self.mutable_l1.args + [self.minus_mutable_l2]), + LinearExpression([self.l1, self.minus_mon_npv]), ), + (self.mutable_l1, self.mutable_l2, SumExpression([self.l1, self.minus_l2])), (self.mutable_l1, self.param0, self.mon_npv), - ( - self.mutable_l1, - self.param1, - LinearExpression(self.mutable_l1.args + [-1]), - ), + (self.mutable_l1, self.param1, LinearExpression([self.l1, -1])), # 20: ( self.mutable_l1, self.mutable_l3, - LinearExpression(self.mutable_l1.args + [self.minus_npv]), + LinearExpression([self.l1, self.minus_npv]), ), ] self._run_cases(tests, operator.sub) @@ -2184,79 +1900,67 @@ def test_sub_mutable_l2(self): ( self.mutable_l2, self.asbinary, - SumExpression(self.mutable_l2.args + [self.minus_bin]), + SumExpression(self.l2.args + [self.minus_bin]), ), - (self.mutable_l2, self.zero, self.mutable_l2), - (self.mutable_l2, self.one, SumExpression(self.mutable_l2.args + [-1])), + (self.mutable_l2, self.zero, self.l2), + (self.mutable_l2, self.one, SumExpression(self.l2.args + [-1])), # 4: - (self.mutable_l2, self.native, SumExpression(self.mutable_l2.args + [-5])), - ( - self.mutable_l2, - self.npv, - SumExpression(self.mutable_l2.args + [self.minus_npv]), - ), - (self.mutable_l2, self.param, SumExpression(self.mutable_l2.args + [-6])), + (self.mutable_l2, self.native, SumExpression(self.l2.args + [-5])), + (self.mutable_l2, self.npv, SumExpression(self.l2.args + [self.minus_npv])), + (self.mutable_l2, self.param, SumExpression(self.l2.args + [-6])), ( self.mutable_l2, self.param_mut, - SumExpression(self.mutable_l2.args + [self.minus_param_mut]), + SumExpression(self.l2.args + [self.minus_param_mut]), ), # 8: - ( - self.mutable_l2, - self.var, - SumExpression(self.mutable_l2.args + [self.minus_var]), - ), + (self.mutable_l2, self.var, SumExpression(self.l2.args + [self.minus_var])), ( self.mutable_l2, self.mon_native, - SumExpression(self.mutable_l2.args + [self.minus_mon_native]), + SumExpression(self.l2.args + [self.minus_mon_native]), ), ( self.mutable_l2, self.mon_param, - SumExpression(self.mutable_l2.args + [self.minus_mon_param]), + SumExpression(self.l2.args + [self.minus_mon_param]), ), ( self.mutable_l2, self.mon_npv, - SumExpression(self.mutable_l2.args + [self.minus_mon_npv]), + SumExpression(self.l2.args + [self.minus_mon_npv]), ), # 12: ( self.mutable_l2, self.linear, - SumExpression(self.mutable_l2.args + [self.minus_linear]), - ), - ( - self.mutable_l2, - self.sum, - SumExpression(self.mutable_l2.args + [self.minus_sum]), + SumExpression(self.l2.args + [self.minus_linear]), ), + (self.mutable_l2, self.sum, SumExpression(self.l2.args + [self.minus_sum])), ( self.mutable_l2, self.other, - SumExpression(self.mutable_l2.args + [self.minus_other]), + SumExpression(self.l2.args + [self.minus_other]), ), - (self.mutable_l2, self.mutable_l0, self.mutable_l2), + (self.mutable_l2, self.mutable_l0, self.l2), # 16: ( self.mutable_l2, self.mutable_l1, - SumExpression(self.mutable_l2.args + [self.minus_mon_npv]), + SumExpression(self.l2.args + [self.minus_mon_npv]), ), ( self.mutable_l2, self.mutable_l2, - SumExpression(self.mutable_l2.args + [self.minus_mutable_l2]), + SumExpression(self.l2.args + [self.minus_l2]), ), - (self.mutable_l2, self.param0, self.mutable_l2), - (self.mutable_l2, self.param1, SumExpression(self.mutable_l2.args + [-1])), + (self.mutable_l2, self.param0, self.l2), + (self.mutable_l2, self.param1, SumExpression(self.l2.args + [-1])), # 20: ( self.mutable_l2, self.mutable_l3, - SumExpression(self.mutable_l2.args + [self.minus_npv]), + SumExpression(self.l2.args + [self.minus_npv]), ), ] self._run_cases(tests, operator.sub) @@ -2286,7 +1990,7 @@ def test_sub_param0(self): (self.param0, self.mutable_l0, 0), # 16: (self.param0, self.mutable_l1, self.minus_mon_npv), - (self.param0, self.mutable_l2, self.minus_mutable_l2), + (self.param0, self.mutable_l2, self.minus_l2), (self.param0, self.param0, 0), (self.param0, self.param1, -1), # 20: @@ -2322,7 +2026,7 @@ def test_sub_param1(self): (self.param1, self.mutable_l0, 1), # 16: (self.param1, self.mutable_l1, LinearExpression([1, self.minus_mon_npv])), - (self.param1, self.mutable_l2, SumExpression([1, self.minus_mutable_l2])), + (self.param1, self.mutable_l2, SumExpression([1, self.minus_l2])), (self.param1, self.param0, 1), (self.param1, self.param1, 0), # 20: @@ -2337,86 +2041,50 @@ def test_sub_mutable_l3(self): ( self.mutable_l3, self.asbinary, - LinearExpression(self.mutable_l3.args + [self.minus_bin]), + LinearExpression([self.l3, self.minus_bin]), ), (self.mutable_l3, self.zero, self.npv), - (self.mutable_l3, self.one, NPV_SumExpression(self.mutable_l3.args + [-1])), + (self.mutable_l3, self.one, NPV_SumExpression([self.l3, -1])), # 4: - ( - self.mutable_l3, - self.native, - NPV_SumExpression(self.mutable_l3.args + [-5]), - ), - ( - self.mutable_l3, - self.npv, - NPV_SumExpression(self.mutable_l3.args + [self.minus_npv]), - ), - ( - self.mutable_l3, - self.param, - NPV_SumExpression(self.mutable_l3.args + [-6]), - ), + (self.mutable_l3, self.native, NPV_SumExpression([self.l3, -5])), + (self.mutable_l3, self.npv, NPV_SumExpression([self.l3, self.minus_npv])), + (self.mutable_l3, self.param, NPV_SumExpression([self.l3, -6])), ( self.mutable_l3, self.param_mut, - NPV_SumExpression(self.mutable_l3.args + [self.minus_param_mut]), + NPV_SumExpression([self.l3, self.minus_param_mut]), ), # 8: - ( - self.mutable_l3, - self.var, - LinearExpression(self.mutable_l3.args + [self.minus_var]), - ), + (self.mutable_l3, self.var, LinearExpression([self.l3, self.minus_var])), ( self.mutable_l3, self.mon_native, - LinearExpression(self.mutable_l3.args + [self.minus_mon_native]), + LinearExpression([self.l3, self.minus_mon_native]), ), ( self.mutable_l3, self.mon_param, - LinearExpression(self.mutable_l3.args + [self.minus_mon_param]), + LinearExpression([self.l3, self.minus_mon_param]), ), ( self.mutable_l3, self.mon_npv, - LinearExpression(self.mutable_l3.args + [self.minus_mon_npv]), + LinearExpression([self.l3, self.minus_mon_npv]), ), # 12: - ( - self.mutable_l3, - self.linear, - SumExpression(self.mutable_l3.args + [self.minus_linear]), - ), - ( - self.mutable_l3, - self.sum, - SumExpression(self.mutable_l3.args + [self.minus_sum]), - ), - ( - self.mutable_l3, - self.other, - SumExpression(self.mutable_l3.args + [self.minus_other]), - ), + (self.mutable_l3, self.linear, SumExpression([self.l3, self.minus_linear])), + (self.mutable_l3, self.sum, SumExpression([self.l3, self.minus_sum])), + (self.mutable_l3, self.other, SumExpression([self.l3, self.minus_other])), (self.mutable_l3, self.mutable_l0, self.npv), # 16: ( self.mutable_l3, self.mutable_l1, - LinearExpression(self.mutable_l3.args + [self.minus_mon_npv]), - ), - ( - self.mutable_l3, - self.mutable_l2, - SumExpression(self.mutable_l3.args + [self.minus_mutable_l2]), + LinearExpression([self.l3, self.minus_mon_npv]), ), + (self.mutable_l3, self.mutable_l2, SumExpression([self.l3, self.minus_l2])), (self.mutable_l3, self.param0, self.npv), - ( - self.mutable_l3, - self.param1, - NPV_SumExpression(self.mutable_l3.args + [-1]), - ), + (self.mutable_l3, self.param1, NPV_SumExpression([self.l3, -1])), # 20: # Note that because the mutable is resolved to a NPV_Sum in # the negation, the 1-term summation for the first arg is @@ -2424,9 +2092,7 @@ def test_sub_mutable_l3(self): ( self.mutable_l3, self.mutable_l3, - NPV_SumExpression( - [NPV_SumExpression(self.mutable_l3.args), self.minus_npv] - ), + NPV_SumExpression([self.l3, self.minus_npv]), ), ] self._run_cases(tests, operator.sub) @@ -2514,11 +2180,7 @@ def test_mul_asbinary(self): self.mutable_l1, ProductExpression((self.bin, self.mon_npv)), ), - ( - self.asbinary, - self.mutable_l2, - ProductExpression((self.bin, self.mutable_l2)), - ), + (self.asbinary, self.mutable_l2, ProductExpression((self.bin, self.l2))), (self.asbinary, self.param0, 0), (self.asbinary, self.param1, self.bin), # 20: @@ -2590,7 +2252,7 @@ def test_mul_one(self): (self.one, self.mutable_l0, 0), # 16: (self.one, self.mutable_l1, self.mon_npv), - (self.one, self.mutable_l2, self.mutable_l2), + (self.one, self.mutable_l2, self.l2), (self.one, self.param0, self.param0), (self.one, self.param1, self.param1), # 20: @@ -2655,7 +2317,7 @@ def test_mul_native(self): ) ), ), - (self.native, self.mutable_l2, ProductExpression((5, self.mutable_l2))), + (self.native, self.mutable_l2, ProductExpression((5, self.l2))), (self.native, self.param0, 0), (self.native, self.param1, 5), # 20: @@ -2727,7 +2389,7 @@ def test_mul_npv(self): ) ), ), - (self.npv, self.mutable_l2, ProductExpression((self.npv, self.mutable_l2))), + (self.npv, self.mutable_l2, ProductExpression((self.npv, self.l2))), (self.npv, self.param0, 0), (self.npv, self.param1, self.npv), # 20: @@ -2790,7 +2452,7 @@ def test_mul_param(self): ) ), ), - (self.param, self.mutable_l2, ProductExpression((6, self.mutable_l2))), + (self.param, self.mutable_l2, ProductExpression((6, self.l2))), (self.param, self.param0, 0), (self.param, self.param1, 6), # 20: @@ -2885,7 +2547,7 @@ def test_mul_param_mut(self): ( self.param_mut, self.mutable_l2, - ProductExpression((self.param_mut, self.mutable_l2)), + ProductExpression((self.param_mut, self.l2)), ), (self.param_mut, self.param0, 0), (self.param_mut, self.param1, self.param_mut), @@ -2926,7 +2588,7 @@ def test_mul_var(self): (self.var, self.mutable_l0, 0), # 16: (self.var, self.mutable_l1, ProductExpression((self.var, self.mon_npv))), - (self.var, self.mutable_l2, ProductExpression((self.var, self.mutable_l2))), + (self.var, self.mutable_l2, ProductExpression((self.var, self.l2))), (self.var, self.param0, 0), (self.var, self.param1, self.var), # 20: @@ -3015,7 +2677,7 @@ def test_mul_mon_native(self): ( self.mon_native, self.mutable_l2, - ProductExpression((self.mon_native, self.mutable_l2)), + ProductExpression((self.mon_native, self.l2)), ), (self.mon_native, self.param0, 0), (self.mon_native, self.param1, self.mon_native), @@ -3124,7 +2786,7 @@ def test_mul_mon_param(self): ( self.mon_param, self.mutable_l2, - ProductExpression((self.mon_param, self.mutable_l2)), + ProductExpression((self.mon_param, self.l2)), ), (self.mon_param, self.param0, 0), (self.mon_param, self.param1, self.mon_param), @@ -3218,11 +2880,7 @@ def test_mul_mon_npv(self): self.mutable_l1, ProductExpression((self.mon_npv, self.mon_npv)), ), - ( - self.mon_npv, - self.mutable_l2, - ProductExpression((self.mon_npv, self.mutable_l2)), - ), + (self.mon_npv, self.mutable_l2, ProductExpression((self.mon_npv, self.l2))), (self.mon_npv, self.param0, 0), (self.mon_npv, self.param1, self.mon_npv), # 20: @@ -3279,11 +2937,7 @@ def test_mul_linear(self): self.mutable_l1, ProductExpression((self.linear, self.mon_npv)), ), - ( - self.linear, - self.mutable_l2, - ProductExpression((self.linear, self.mutable_l2)), - ), + (self.linear, self.mutable_l2, ProductExpression((self.linear, self.l2))), (self.linear, self.param0, 0), (self.linear, self.param1, self.linear), # 20: @@ -3315,7 +2969,7 @@ def test_mul_sum(self): (self.sum, self.mutable_l0, 0), # 16: (self.sum, self.mutable_l1, ProductExpression((self.sum, self.mon_npv))), - (self.sum, self.mutable_l2, ProductExpression((self.sum, self.mutable_l2))), + (self.sum, self.mutable_l2, ProductExpression((self.sum, self.l2))), (self.sum, self.param0, 0), (self.sum, self.param1, self.sum), # 20: @@ -3363,11 +3017,7 @@ def test_mul_other(self): self.mutable_l1, ProductExpression((self.other, self.mon_npv)), ), - ( - self.other, - self.mutable_l2, - ProductExpression((self.other, self.mutable_l2)), - ), + (self.other, self.mutable_l2, ProductExpression((self.other, self.l2))), (self.other, self.param0, 0), (self.other, self.param1, self.other), # 20: @@ -3498,7 +3148,7 @@ def test_mul_mutable_l1(self): ( self.mutable_l1, self.mutable_l2, - ProductExpression((self.mon_npv, self.mutable_l2)), + ProductExpression((self.mon_npv, self.l2)), ), (self.mutable_l1, self.param0, 0), (self.mutable_l1, self.param1, self.mon_npv), @@ -3520,71 +3170,47 @@ def test_mul_mutable_l1(self): def test_mul_mutable_l2(self): tests = [ (self.mutable_l2, self.invalid, NotImplemented), - ( - self.mutable_l2, - self.asbinary, - ProductExpression((self.mutable_l2, self.bin)), - ), + (self.mutable_l2, self.asbinary, ProductExpression((self.l2, self.bin))), (self.mutable_l2, self.zero, 0), - (self.mutable_l2, self.one, self.mutable_l2), + (self.mutable_l2, self.one, self.l2), # 4: - (self.mutable_l2, self.native, ProductExpression((self.mutable_l2, 5))), - (self.mutable_l2, self.npv, ProductExpression((self.mutable_l2, self.npv))), - (self.mutable_l2, self.param, ProductExpression((self.mutable_l2, 6))), + (self.mutable_l2, self.native, ProductExpression((self.l2, 5))), + (self.mutable_l2, self.npv, ProductExpression((self.l2, self.npv))), + (self.mutable_l2, self.param, ProductExpression((self.l2, 6))), ( self.mutable_l2, self.param_mut, - ProductExpression((self.mutable_l2, self.param_mut)), + ProductExpression((self.l2, self.param_mut)), ), # 8: - (self.mutable_l2, self.var, ProductExpression((self.mutable_l2, self.var))), + (self.mutable_l2, self.var, ProductExpression((self.l2, self.var))), ( self.mutable_l2, self.mon_native, - ProductExpression((self.mutable_l2, self.mon_native)), + ProductExpression((self.l2, self.mon_native)), ), ( self.mutable_l2, self.mon_param, - ProductExpression((self.mutable_l2, self.mon_param)), - ), - ( - self.mutable_l2, - self.mon_npv, - ProductExpression((self.mutable_l2, self.mon_npv)), + ProductExpression((self.l2, self.mon_param)), ), + (self.mutable_l2, self.mon_npv, ProductExpression((self.l2, self.mon_npv))), # 12: - ( - self.mutable_l2, - self.linear, - ProductExpression((self.mutable_l2, self.linear)), - ), - (self.mutable_l2, self.sum, ProductExpression((self.mutable_l2, self.sum))), - ( - self.mutable_l2, - self.other, - ProductExpression((self.mutable_l2, self.other)), - ), + (self.mutable_l2, self.linear, ProductExpression((self.l2, self.linear))), + (self.mutable_l2, self.sum, ProductExpression((self.l2, self.sum))), + (self.mutable_l2, self.other, ProductExpression((self.l2, self.other))), (self.mutable_l2, self.mutable_l0, 0), # 16: ( self.mutable_l2, self.mutable_l1, - ProductExpression((self.mutable_l2, self.mon_npv)), - ), - ( - self.mutable_l2, - self.mutable_l2, - ProductExpression((self.mutable_l2, self.mutable_l2)), + ProductExpression((self.l2, self.mon_npv)), ), + (self.mutable_l2, self.mutable_l2, ProductExpression((self.l2, self.l2))), (self.mutable_l2, self.param0, 0), - (self.mutable_l2, self.param1, self.mutable_l2), + (self.mutable_l2, self.param1, self.l2), # 20: - ( - self.mutable_l2, - self.mutable_l3, - ProductExpression((self.mutable_l2, self.npv)), - ), + (self.mutable_l2, self.mutable_l3, ProductExpression((self.l2, self.npv))), ] self._run_cases(tests, operator.mul) self._run_cases(tests, operator.imul) @@ -3648,7 +3274,7 @@ def test_mul_param1(self): (self.param1, self.mutable_l0, 0), # 16: (self.param1, self.mutable_l1, self.mon_npv), - (self.param1, self.mutable_l2, self.mutable_l2), + (self.param1, self.mutable_l2, self.l2), (self.param1, self.param0, self.param0), (self.param1, self.param1, self.param1), # 20: @@ -3724,11 +3350,7 @@ def test_mul_mutable_l3(self): ) ), ), - ( - self.mutable_l3, - self.mutable_l2, - ProductExpression((self.npv, self.mutable_l2)), - ), + (self.mutable_l3, self.mutable_l2, ProductExpression((self.npv, self.l2))), (self.mutable_l3, self.param0, 0), (self.mutable_l3, self.param1, self.npv), # 20: @@ -3827,11 +3449,7 @@ def test_div_asbinary(self): self.mutable_l1, DivisionExpression((self.bin, self.mon_npv)), ), - ( - self.asbinary, - self.mutable_l2, - DivisionExpression((self.bin, self.mutable_l2)), - ), + (self.asbinary, self.mutable_l2, DivisionExpression((self.bin, self.l2))), (self.asbinary, self.param0, ZeroDivisionError), (self.asbinary, self.param1, self.bin), # 20: @@ -3901,7 +3519,7 @@ def test_div_one(self): (self.one, self.mutable_l0, ZeroDivisionError), # 16: (self.one, self.mutable_l1, DivisionExpression((1, self.mon_npv))), - (self.one, self.mutable_l2, DivisionExpression((1, self.mutable_l2))), + (self.one, self.mutable_l2, DivisionExpression((1, self.l2))), (self.one, self.param0, ZeroDivisionError), (self.one, self.param1, 1.0), # 20: @@ -3933,7 +3551,7 @@ def test_div_native(self): (self.native, self.mutable_l0, ZeroDivisionError), # 16: (self.native, self.mutable_l1, DivisionExpression((5, self.mon_npv))), - (self.native, self.mutable_l2, DivisionExpression((5, self.mutable_l2))), + (self.native, self.mutable_l2, DivisionExpression((5, self.l2))), (self.native, self.param0, ZeroDivisionError), (self.native, self.param1, 5.0), # 20: @@ -3973,11 +3591,7 @@ def test_div_npv(self): (self.npv, self.mutable_l0, ZeroDivisionError), # 16: (self.npv, self.mutable_l1, DivisionExpression((self.npv, self.mon_npv))), - ( - self.npv, - self.mutable_l2, - DivisionExpression((self.npv, self.mutable_l2)), - ), + (self.npv, self.mutable_l2, DivisionExpression((self.npv, self.l2))), (self.npv, self.param0, ZeroDivisionError), (self.npv, self.param1, self.npv), # 20: @@ -4009,7 +3623,7 @@ def test_div_param(self): (self.param, self.mutable_l0, ZeroDivisionError), # 16: (self.param, self.mutable_l1, DivisionExpression((6, self.mon_npv))), - (self.param, self.mutable_l2, DivisionExpression((6, self.mutable_l2))), + (self.param, self.mutable_l2, DivisionExpression((6, self.l2))), (self.param, self.param0, ZeroDivisionError), (self.param, self.param1, 6.0), # 20: @@ -4080,7 +3694,7 @@ def test_div_param_mut(self): ( self.param_mut, self.mutable_l2, - DivisionExpression((self.param_mut, self.mutable_l2)), + DivisionExpression((self.param_mut, self.l2)), ), (self.param_mut, self.param0, ZeroDivisionError), (self.param_mut, self.param1, self.param_mut), @@ -4133,11 +3747,7 @@ def test_div_var(self): (self.var, self.mutable_l0, ZeroDivisionError), # 16: (self.var, self.mutable_l1, DivisionExpression((self.var, self.mon_npv))), - ( - self.var, - self.mutable_l2, - DivisionExpression((self.var, self.mutable_l2)), - ), + (self.var, self.mutable_l2, DivisionExpression((self.var, self.l2))), (self.var, self.param0, ZeroDivisionError), (self.var, self.param1, self.var), # 20: @@ -4242,7 +3852,7 @@ def test_div_mon_native(self): ( self.mon_native, self.mutable_l2, - DivisionExpression((self.mon_native, self.mutable_l2)), + DivisionExpression((self.mon_native, self.l2)), ), (self.mon_native, self.param0, ZeroDivisionError), (self.mon_native, self.param1, self.mon_native), @@ -4351,7 +3961,7 @@ def test_div_mon_param(self): ( self.mon_param, self.mutable_l2, - DivisionExpression((self.mon_param, self.mutable_l2)), + DivisionExpression((self.mon_param, self.l2)), ), (self.mon_param, self.param0, ZeroDivisionError), (self.mon_param, self.param1, self.mon_param), @@ -4452,7 +4062,7 @@ def test_div_mon_npv(self): ( self.mon_npv, self.mutable_l2, - DivisionExpression((self.mon_npv, self.mutable_l2)), + DivisionExpression((self.mon_npv, self.l2)), ), (self.mon_npv, self.param0, ZeroDivisionError), (self.mon_npv, self.param1, self.mon_npv), @@ -4514,11 +4124,7 @@ def test_div_linear(self): self.mutable_l1, DivisionExpression((self.linear, self.mon_npv)), ), - ( - self.linear, - self.mutable_l2, - DivisionExpression((self.linear, self.mutable_l2)), - ), + (self.linear, self.mutable_l2, DivisionExpression((self.linear, self.l2))), (self.linear, self.param0, ZeroDivisionError), (self.linear, self.param1, self.linear), # 20: @@ -4554,11 +4160,7 @@ def test_div_sum(self): (self.sum, self.mutable_l0, ZeroDivisionError), # 16: (self.sum, self.mutable_l1, DivisionExpression((self.sum, self.mon_npv))), - ( - self.sum, - self.mutable_l2, - DivisionExpression((self.sum, self.mutable_l2)), - ), + (self.sum, self.mutable_l2, DivisionExpression((self.sum, self.l2))), (self.sum, self.param0, ZeroDivisionError), (self.sum, self.param1, self.sum), # 20: @@ -4606,11 +4208,7 @@ def test_div_other(self): self.mutable_l1, DivisionExpression((self.other, self.mon_npv)), ), - ( - self.other, - self.mutable_l2, - DivisionExpression((self.other, self.mutable_l2)), - ), + (self.other, self.mutable_l2, DivisionExpression((self.other, self.l2))), (self.other, self.param0, ZeroDivisionError), (self.other, self.param1, self.other), # 20: @@ -4741,7 +4339,7 @@ def test_div_mutable_l1(self): ( self.mutable_l1, self.mutable_l2, - DivisionExpression((self.mon_npv, self.mutable_l2)), + DivisionExpression((self.mon_npv, self.l2)), ), (self.mutable_l1, self.param0, ZeroDivisionError), (self.mutable_l1, self.param1, self.mon_npv), @@ -4763,83 +4361,51 @@ def test_div_mutable_l1(self): def test_div_mutable_l2(self): tests = [ (self.mutable_l2, self.invalid, NotImplemented), - ( - self.mutable_l2, - self.asbinary, - DivisionExpression((self.mutable_l2, self.bin)), - ), + (self.mutable_l2, self.asbinary, DivisionExpression((self.l2, self.bin))), (self.mutable_l2, self.zero, ZeroDivisionError), - (self.mutable_l2, self.one, self.mutable_l2), + (self.mutable_l2, self.one, self.l2), # 4: - (self.mutable_l2, self.native, DivisionExpression((self.mutable_l2, 5))), - ( - self.mutable_l2, - self.npv, - DivisionExpression((self.mutable_l2, self.npv)), - ), - (self.mutable_l2, self.param, DivisionExpression((self.mutable_l2, 6))), + (self.mutable_l2, self.native, DivisionExpression((self.l2, 5))), + (self.mutable_l2, self.npv, DivisionExpression((self.l2, self.npv))), + (self.mutable_l2, self.param, DivisionExpression((self.l2, 6))), ( self.mutable_l2, self.param_mut, - DivisionExpression((self.mutable_l2, self.param_mut)), + DivisionExpression((self.l2, self.param_mut)), ), # 8: - ( - self.mutable_l2, - self.var, - DivisionExpression((self.mutable_l2, self.var)), - ), + (self.mutable_l2, self.var, DivisionExpression((self.l2, self.var))), ( self.mutable_l2, self.mon_native, - DivisionExpression((self.mutable_l2, self.mon_native)), + DivisionExpression((self.l2, self.mon_native)), ), ( self.mutable_l2, self.mon_param, - DivisionExpression((self.mutable_l2, self.mon_param)), + DivisionExpression((self.l2, self.mon_param)), ), ( self.mutable_l2, self.mon_npv, - DivisionExpression((self.mutable_l2, self.mon_npv)), + DivisionExpression((self.l2, self.mon_npv)), ), # 12: - ( - self.mutable_l2, - self.linear, - DivisionExpression((self.mutable_l2, self.linear)), - ), - ( - self.mutable_l2, - self.sum, - DivisionExpression((self.mutable_l2, self.sum)), - ), - ( - self.mutable_l2, - self.other, - DivisionExpression((self.mutable_l2, self.other)), - ), + (self.mutable_l2, self.linear, DivisionExpression((self.l2, self.linear))), + (self.mutable_l2, self.sum, DivisionExpression((self.l2, self.sum))), + (self.mutable_l2, self.other, DivisionExpression((self.l2, self.other))), (self.mutable_l2, self.mutable_l0, ZeroDivisionError), # 16: ( self.mutable_l2, self.mutable_l1, - DivisionExpression((self.mutable_l2, self.mon_npv)), - ), - ( - self.mutable_l2, - self.mutable_l2, - DivisionExpression((self.mutable_l2, self.mutable_l2)), + DivisionExpression((self.l2, self.mon_npv)), ), + (self.mutable_l2, self.mutable_l2, DivisionExpression((self.l2, self.l2))), (self.mutable_l2, self.param0, ZeroDivisionError), - (self.mutable_l2, self.param1, self.mutable_l2), + (self.mutable_l2, self.param1, self.l2), # 20: - ( - self.mutable_l2, - self.mutable_l3, - DivisionExpression((self.mutable_l2, self.npv)), - ), + (self.mutable_l2, self.mutable_l3, DivisionExpression((self.l2, self.npv))), ] self._run_cases(tests, operator.truediv) self._run_cases(tests, operator.itruediv) @@ -4899,7 +4465,7 @@ def test_div_param1(self): (self.param1, self.mutable_l0, ZeroDivisionError), # 16: (self.param1, self.mutable_l1, DivisionExpression((1, self.mon_npv))), - (self.param1, self.mutable_l2, DivisionExpression((1, self.mutable_l2))), + (self.param1, self.mutable_l2, DivisionExpression((1, self.l2))), (self.param1, self.param0, ZeroDivisionError), (self.param1, self.param1, 1.0), # 20: @@ -4951,11 +4517,7 @@ def test_div_mutable_l3(self): self.mutable_l1, DivisionExpression((self.npv, self.mon_npv)), ), - ( - self.mutable_l3, - self.mutable_l2, - DivisionExpression((self.npv, self.mutable_l2)), - ), + (self.mutable_l3, self.mutable_l2, DivisionExpression((self.npv, self.l2))), (self.mutable_l3, self.param0, ZeroDivisionError), (self.mutable_l3, self.param1, self.npv), # 20: @@ -5034,11 +4596,7 @@ def test_pow_asbinary(self): (self.asbinary, self.mutable_l0, 1), # 16: (self.asbinary, self.mutable_l1, PowExpression((self.bin, self.mon_npv))), - ( - self.asbinary, - self.mutable_l2, - PowExpression((self.bin, self.mutable_l2)), - ), + (self.asbinary, self.mutable_l2, PowExpression((self.bin, self.l2))), (self.asbinary, self.param0, 1), (self.asbinary, self.param1, self.bin), # 20: @@ -5070,7 +4628,7 @@ def test_pow_zero(self): (self.zero, self.mutable_l0, 1), # 16: (self.zero, self.mutable_l1, PowExpression((0, self.mon_npv))), - (self.zero, self.mutable_l2, PowExpression((0, self.mutable_l2))), + (self.zero, self.mutable_l2, PowExpression((0, self.l2))), (self.zero, self.param0, 1), (self.zero, self.param1, 0), # 20: @@ -5102,7 +4660,7 @@ def test_pow_one(self): (self.one, self.mutable_l0, 1), # 16: (self.one, self.mutable_l1, PowExpression((1, self.mon_npv))), - (self.one, self.mutable_l2, PowExpression((1, self.mutable_l2))), + (self.one, self.mutable_l2, PowExpression((1, self.l2))), (self.one, self.param0, 1), (self.one, self.param1, 1), # 20: @@ -5134,7 +4692,7 @@ def test_pow_native(self): (self.native, self.mutable_l0, 1), # 16: (self.native, self.mutable_l1, PowExpression((5, self.mon_npv))), - (self.native, self.mutable_l2, PowExpression((5, self.mutable_l2))), + (self.native, self.mutable_l2, PowExpression((5, self.l2))), (self.native, self.param0, 1), (self.native, self.param1, 5), # 20: @@ -5166,7 +4724,7 @@ def test_pow_npv(self): (self.npv, self.mutable_l0, 1), # 16: (self.npv, self.mutable_l1, PowExpression((self.npv, self.mon_npv))), - (self.npv, self.mutable_l2, PowExpression((self.npv, self.mutable_l2))), + (self.npv, self.mutable_l2, PowExpression((self.npv, self.l2))), (self.npv, self.param0, 1), (self.npv, self.param1, self.npv), # 20: @@ -5198,7 +4756,7 @@ def test_pow_param(self): (self.param, self.mutable_l0, 1), # 16: (self.param, self.mutable_l1, PowExpression((6, self.mon_npv))), - (self.param, self.mutable_l2, PowExpression((6, self.mutable_l2))), + (self.param, self.mutable_l2, PowExpression((6, self.l2))), (self.param, self.param0, 1), (self.param, self.param1, 6), # 20: @@ -5250,11 +4808,7 @@ def test_pow_param_mut(self): self.mutable_l1, PowExpression((self.param_mut, self.mon_npv)), ), - ( - self.param_mut, - self.mutable_l2, - PowExpression((self.param_mut, self.mutable_l2)), - ), + (self.param_mut, self.mutable_l2, PowExpression((self.param_mut, self.l2))), (self.param_mut, self.param0, 1), (self.param_mut, self.param1, self.param_mut), # 20: @@ -5290,7 +4844,7 @@ def test_pow_var(self): (self.var, self.mutable_l0, 1), # 16: (self.var, self.mutable_l1, PowExpression((self.var, self.mon_npv))), - (self.var, self.mutable_l2, PowExpression((self.var, self.mutable_l2))), + (self.var, self.mutable_l2, PowExpression((self.var, self.l2))), (self.var, self.param0, 1), (self.var, self.param1, self.var), # 20: @@ -5353,7 +4907,7 @@ def test_pow_mon_native(self): ( self.mon_native, self.mutable_l2, - PowExpression((self.mon_native, self.mutable_l2)), + PowExpression((self.mon_native, self.l2)), ), (self.mon_native, self.param0, 1), (self.mon_native, self.param1, self.mon_native), @@ -5410,11 +4964,7 @@ def test_pow_mon_param(self): self.mutable_l1, PowExpression((self.mon_param, self.mon_npv)), ), - ( - self.mon_param, - self.mutable_l2, - PowExpression((self.mon_param, self.mutable_l2)), - ), + (self.mon_param, self.mutable_l2, PowExpression((self.mon_param, self.l2))), (self.mon_param, self.param0, 1), (self.mon_param, self.param1, self.mon_param), # 20: @@ -5466,11 +5016,7 @@ def test_pow_mon_npv(self): self.mutable_l1, PowExpression((self.mon_npv, self.mon_npv)), ), - ( - self.mon_npv, - self.mutable_l2, - PowExpression((self.mon_npv, self.mutable_l2)), - ), + (self.mon_npv, self.mutable_l2, PowExpression((self.mon_npv, self.l2))), (self.mon_npv, self.param0, 1), (self.mon_npv, self.param1, self.mon_npv), # 20: @@ -5506,11 +5052,7 @@ def test_pow_linear(self): (self.linear, self.mutable_l0, 1), # 16: (self.linear, self.mutable_l1, PowExpression((self.linear, self.mon_npv))), - ( - self.linear, - self.mutable_l2, - PowExpression((self.linear, self.mutable_l2)), - ), + (self.linear, self.mutable_l2, PowExpression((self.linear, self.l2))), (self.linear, self.param0, 1), (self.linear, self.param1, self.linear), # 20: @@ -5542,7 +5084,7 @@ def test_pow_sum(self): (self.sum, self.mutable_l0, 1), # 16: (self.sum, self.mutable_l1, PowExpression((self.sum, self.mon_npv))), - (self.sum, self.mutable_l2, PowExpression((self.sum, self.mutable_l2))), + (self.sum, self.mutable_l2, PowExpression((self.sum, self.l2))), (self.sum, self.param0, 1), (self.sum, self.param1, self.sum), # 20: @@ -5574,7 +5116,7 @@ def test_pow_other(self): (self.other, self.mutable_l0, 1), # 16: (self.other, self.mutable_l1, PowExpression((self.other, self.mon_npv))), - (self.other, self.mutable_l2, PowExpression((self.other, self.mutable_l2))), + (self.other, self.mutable_l2, PowExpression((self.other, self.l2))), (self.other, self.param0, 1), (self.other, self.param1, self.other), # 20: @@ -5606,7 +5148,7 @@ def test_pow_mutable_l0(self): (self.mutable_l0, self.mutable_l0, 1), # 16: (self.mutable_l0, self.mutable_l1, PowExpression((0, self.mon_npv))), - (self.mutable_l0, self.mutable_l2, PowExpression((0, self.mutable_l2))), + (self.mutable_l0, self.mutable_l2, PowExpression((0, self.l2))), (self.mutable_l0, self.param0, 1), (self.mutable_l0, self.param1, 0), # 20: @@ -5658,11 +5200,7 @@ def test_pow_mutable_l1(self): self.mutable_l1, PowExpression((self.mon_npv, self.mon_npv)), ), - ( - self.mutable_l1, - self.mutable_l2, - PowExpression((self.mon_npv, self.mutable_l2)), - ), + (self.mutable_l1, self.mutable_l2, PowExpression((self.mon_npv, self.l2))), (self.mutable_l1, self.param0, 1), (self.mutable_l1, self.param1, self.mon_npv), # 20: @@ -5674,67 +5212,35 @@ def test_pow_mutable_l1(self): def test_pow_mutable_l2(self): tests = [ (self.mutable_l2, self.invalid, NotImplemented), - ( - self.mutable_l2, - self.asbinary, - PowExpression((self.mutable_l2, self.bin)), - ), + (self.mutable_l2, self.asbinary, PowExpression((self.l2, self.bin))), (self.mutable_l2, self.zero, 1), - (self.mutable_l2, self.one, self.mutable_l2), + (self.mutable_l2, self.one, self.l2), # 4: - (self.mutable_l2, self.native, PowExpression((self.mutable_l2, 5))), - (self.mutable_l2, self.npv, PowExpression((self.mutable_l2, self.npv))), - (self.mutable_l2, self.param, PowExpression((self.mutable_l2, 6))), - ( - self.mutable_l2, - self.param_mut, - PowExpression((self.mutable_l2, self.param_mut)), - ), + (self.mutable_l2, self.native, PowExpression((self.l2, 5))), + (self.mutable_l2, self.npv, PowExpression((self.l2, self.npv))), + (self.mutable_l2, self.param, PowExpression((self.l2, 6))), + (self.mutable_l2, self.param_mut, PowExpression((self.l2, self.param_mut))), # 8: - (self.mutable_l2, self.var, PowExpression((self.mutable_l2, self.var))), + (self.mutable_l2, self.var, PowExpression((self.l2, self.var))), ( self.mutable_l2, self.mon_native, - PowExpression((self.mutable_l2, self.mon_native)), - ), - ( - self.mutable_l2, - self.mon_param, - PowExpression((self.mutable_l2, self.mon_param)), - ), - ( - self.mutable_l2, - self.mon_npv, - PowExpression((self.mutable_l2, self.mon_npv)), + PowExpression((self.l2, self.mon_native)), ), + (self.mutable_l2, self.mon_param, PowExpression((self.l2, self.mon_param))), + (self.mutable_l2, self.mon_npv, PowExpression((self.l2, self.mon_npv))), # 12: - ( - self.mutable_l2, - self.linear, - PowExpression((self.mutable_l2, self.linear)), - ), - (self.mutable_l2, self.sum, PowExpression((self.mutable_l2, self.sum))), - (self.mutable_l2, self.other, PowExpression((self.mutable_l2, self.other))), + (self.mutable_l2, self.linear, PowExpression((self.l2, self.linear))), + (self.mutable_l2, self.sum, PowExpression((self.l2, self.sum))), + (self.mutable_l2, self.other, PowExpression((self.l2, self.other))), (self.mutable_l2, self.mutable_l0, 1), # 16: - ( - self.mutable_l2, - self.mutable_l1, - PowExpression((self.mutable_l2, self.mon_npv)), - ), - ( - self.mutable_l2, - self.mutable_l2, - PowExpression((self.mutable_l2, self.mutable_l2)), - ), + (self.mutable_l2, self.mutable_l1, PowExpression((self.l2, self.mon_npv))), + (self.mutable_l2, self.mutable_l2, PowExpression((self.l2, self.l2))), (self.mutable_l2, self.param0, 1), - (self.mutable_l2, self.param1, self.mutable_l2), + (self.mutable_l2, self.param1, self.l2), # 20: - ( - self.mutable_l2, - self.mutable_l3, - PowExpression((self.mutable_l2, self.npv)), - ), + (self.mutable_l2, self.mutable_l3, PowExpression((self.l2, self.npv))), ] self._run_cases(tests, operator.pow) self._run_cases(tests, operator.ipow) @@ -5762,7 +5268,7 @@ def test_pow_param0(self): (self.param0, self.mutable_l0, 1), # 16: (self.param0, self.mutable_l1, PowExpression((0, self.mon_npv))), - (self.param0, self.mutable_l2, PowExpression((0, self.mutable_l2))), + (self.param0, self.mutable_l2, PowExpression((0, self.l2))), (self.param0, self.param0, 1), (self.param0, self.param1, 0), # 20: @@ -5794,7 +5300,7 @@ def test_pow_param1(self): (self.param1, self.mutable_l0, 1), # 16: (self.param1, self.mutable_l1, PowExpression((1, self.mon_npv))), - (self.param1, self.mutable_l2, PowExpression((1, self.mutable_l2))), + (self.param1, self.mutable_l2, PowExpression((1, self.l2))), (self.param1, self.param0, 1), (self.param1, self.param1, 1), # 20: @@ -5838,11 +5344,7 @@ def test_pow_mutable_l3(self): (self.mutable_l3, self.mutable_l0, 1), # 16: (self.mutable_l3, self.mutable_l1, PowExpression((self.npv, self.mon_npv))), - ( - self.mutable_l3, - self.mutable_l2, - PowExpression((self.npv, self.mutable_l2)), - ), + (self.mutable_l3, self.mutable_l2, PowExpression((self.npv, self.l2))), (self.mutable_l3, self.param0, 1), (self.mutable_l3, self.param1, self.npv), # 20: @@ -5880,7 +5382,7 @@ def test_neg(self): (self.mutable_l0, 0), # 16: (self.mutable_l1, self.minus_mon_npv), - (self.mutable_l2, NegationExpression((self.mutable_l2,))), + (self.mutable_l2, NegationExpression((self.l2,))), (self.param0, 0), (self.param1, -1), # 20: @@ -5914,7 +5416,7 @@ def _neg_neg(x): (self.mutable_l0, 0), # 16: (self.mutable_l1, self.mon_npv), - (self.mutable_l2, self.mutable_l2), + (self.mutable_l2, self.l2), (self.param0, 0), (self.param1, 1), # 20: @@ -5951,7 +5453,7 @@ def test_abs(self): (self.mutable_l0, 0), # 16: (self.mutable_l1, AbsExpression((self.mon_npv,))), - (self.mutable_l2, AbsExpression((self.mutable_l2,))), + (self.mutable_l2, AbsExpression((self.l2,))), (self.param0, 0), (self.param1, 1), # 20: @@ -6018,10 +5520,7 @@ def test_unary(self): (self.mutable_l0, ValueError if name in SKIP_0 else fcn(0)), # 16: (self.mutable_l1, UnaryFunctionExpression((self.mon_npv,), name, fcn)), - ( - self.mutable_l2, - UnaryFunctionExpression((self.mutable_l2,), name, fcn), - ), + (self.mutable_l2, UnaryFunctionExpression((self.l2,), name, fcn)), (self.param0, ValueError if name in SKIP_0 else fcn(0)), (self.param1, ValueError if name in SKIP_1 else fcn(1)), # 20: @@ -6039,7 +5538,7 @@ def test_mutable_nvp_iadd(self): mutable_npv = _MutableNPVSumExpression([]) tests = [ (mutable_npv, self.invalid, NotImplemented), - (mutable_npv, self.asbinary, _MutableLinearExpression([self.mon_bin])), + (mutable_npv, self.asbinary, _MutableLinearExpression([self.bin])), (mutable_npv, self.zero, _MutableNPVSumExpression([])), (mutable_npv, self.one, _MutableNPVSumExpression([1])), # 4: @@ -6048,7 +5547,7 @@ def test_mutable_nvp_iadd(self): (mutable_npv, self.param, _MutableNPVSumExpression([6])), (mutable_npv, self.param_mut, _MutableNPVSumExpression([self.param_mut])), # 8: - (mutable_npv, self.var, _MutableLinearExpression([self.mon_var])), + (mutable_npv, self.var, _MutableLinearExpression([self.var])), (mutable_npv, self.mon_native, _MutableLinearExpression([self.mon_native])), (mutable_npv, self.mon_param, _MutableLinearExpression([self.mon_param])), (mutable_npv, self.mon_npv, _MutableLinearExpression([self.mon_npv])), @@ -6063,7 +5562,7 @@ def test_mutable_nvp_iadd(self): self.mutable_l1, _MutableLinearExpression(self.mutable_l1.args), ), - (mutable_npv, self.mutable_l2, _MutableSumExpression(self.mutable_l2.args)), + (mutable_npv, self.mutable_l2, _MutableSumExpression(self.l2.args)), (mutable_npv, self.param0, _MutableNPVSumExpression([])), (mutable_npv, self.param1, _MutableNPVSumExpression([1])), # 20: @@ -6074,20 +5573,20 @@ def test_mutable_nvp_iadd(self): mutable_npv = _MutableNPVSumExpression([10]) tests = [ (mutable_npv, self.invalid, NotImplemented), - (mutable_npv, self.asbinary, _MutableLinearExpression([10, self.mon_bin])), + (mutable_npv, self.asbinary, _MutableLinearExpression([10, self.bin])), (mutable_npv, self.zero, _MutableNPVSumExpression([10])), - (mutable_npv, self.one, _MutableNPVSumExpression([10, 1])), + (mutable_npv, self.one, _MutableNPVSumExpression([11])), # 4: - (mutable_npv, self.native, _MutableNPVSumExpression([10, 5])), + (mutable_npv, self.native, _MutableNPVSumExpression([15])), (mutable_npv, self.npv, _MutableNPVSumExpression([10, self.npv])), - (mutable_npv, self.param, _MutableNPVSumExpression([10, 6])), + (mutable_npv, self.param, _MutableNPVSumExpression([16])), ( mutable_npv, self.param_mut, _MutableNPVSumExpression([10, self.param_mut]), ), # 8: - (mutable_npv, self.var, _MutableLinearExpression([10, self.mon_var])), + (mutable_npv, self.var, _MutableLinearExpression([10, self.var])), ( mutable_npv, self.mon_native, @@ -6109,18 +5608,10 @@ def test_mutable_nvp_iadd(self): (mutable_npv, self.other, _MutableSumExpression([10, self.other])), (mutable_npv, self.mutable_l0, _MutableNPVSumExpression([10])), # 16: - ( - mutable_npv, - self.mutable_l1, - _MutableLinearExpression([10] + self.mutable_l1.args), - ), - ( - mutable_npv, - self.mutable_l2, - _MutableSumExpression([10] + self.mutable_l2.args), - ), + (mutable_npv, self.mutable_l1, _MutableLinearExpression([10, self.l1])), + (mutable_npv, self.mutable_l2, _MutableSumExpression([10] + self.l2.args)), (mutable_npv, self.param0, _MutableNPVSumExpression([10])), - (mutable_npv, self.param1, _MutableNPVSumExpression([10, 1])), + (mutable_npv, self.param1, _MutableNPVSumExpression([11])), # 20: (mutable_npv, self.mutable_l3, _MutableNPVSumExpression([10, self.npv])), ] @@ -6130,7 +5621,7 @@ def test_mutable_lin_iadd(self): mutable_lin = _MutableLinearExpression([]) tests = [ (mutable_lin, self.invalid, NotImplemented), - (mutable_lin, self.asbinary, _MutableLinearExpression([self.mon_bin])), + (mutable_lin, self.asbinary, _MutableLinearExpression([self.bin])), (mutable_lin, self.zero, _MutableLinearExpression([])), (mutable_lin, self.one, _MutableLinearExpression([1])), # 4: @@ -6139,7 +5630,7 @@ def test_mutable_lin_iadd(self): (mutable_lin, self.param, _MutableLinearExpression([6])), (mutable_lin, self.param_mut, _MutableLinearExpression([self.param_mut])), # 8: - (mutable_lin, self.var, _MutableLinearExpression([self.mon_var])), + (mutable_lin, self.var, _MutableLinearExpression([self.var])), (mutable_lin, self.mon_native, _MutableLinearExpression([self.mon_native])), (mutable_lin, self.mon_param, _MutableLinearExpression([self.mon_param])), (mutable_lin, self.mon_npv, _MutableLinearExpression([self.mon_npv])), @@ -6154,7 +5645,7 @@ def test_mutable_lin_iadd(self): self.mutable_l1, _MutableLinearExpression(self.mutable_l1.args), ), - (mutable_lin, self.mutable_l2, _MutableSumExpression(self.mutable_l2.args)), + (mutable_lin, self.mutable_l2, _MutableSumExpression(self.l2.args)), (mutable_lin, self.param0, _MutableLinearExpression([])), (mutable_lin, self.param1, _MutableLinearExpression([1])), # 20: @@ -6162,81 +5653,69 @@ def test_mutable_lin_iadd(self): ] self._run_iadd_cases(tests, operator.iadd) - mutable_lin = _MutableLinearExpression([self.mon_bin]) + mutable_lin = _MutableLinearExpression([self.bin]) tests = [ (mutable_lin, self.invalid, NotImplemented), ( mutable_lin, self.asbinary, - _MutableLinearExpression([self.mon_bin, self.mon_bin]), + _MutableLinearExpression([self.bin, self.bin]), ), - (mutable_lin, self.zero, _MutableLinearExpression([self.mon_bin])), - (mutable_lin, self.one, _MutableLinearExpression([self.mon_bin, 1])), + (mutable_lin, self.zero, _MutableLinearExpression([self.bin])), + (mutable_lin, self.one, _MutableLinearExpression([self.bin, 1])), # 4: - (mutable_lin, self.native, _MutableLinearExpression([self.mon_bin, 5])), - (mutable_lin, self.npv, _MutableLinearExpression([self.mon_bin, self.npv])), - (mutable_lin, self.param, _MutableLinearExpression([self.mon_bin, 6])), + (mutable_lin, self.native, _MutableLinearExpression([self.bin, 5])), + (mutable_lin, self.npv, _MutableLinearExpression([self.bin, self.npv])), + (mutable_lin, self.param, _MutableLinearExpression([self.bin, 6])), ( mutable_lin, self.param_mut, - _MutableLinearExpression([self.mon_bin, self.param_mut]), + _MutableLinearExpression([self.bin, self.param_mut]), ), # 8: - ( - mutable_lin, - self.var, - _MutableLinearExpression([self.mon_bin, self.mon_var]), - ), + (mutable_lin, self.var, _MutableLinearExpression([self.bin, self.var])), ( mutable_lin, self.mon_native, - _MutableLinearExpression([self.mon_bin, self.mon_native]), + _MutableLinearExpression([self.bin, self.mon_native]), ), ( mutable_lin, self.mon_param, - _MutableLinearExpression([self.mon_bin, self.mon_param]), + _MutableLinearExpression([self.bin, self.mon_param]), ), ( mutable_lin, self.mon_npv, - _MutableLinearExpression([self.mon_bin, self.mon_npv]), + _MutableLinearExpression([self.bin, self.mon_npv]), ), # 12: ( mutable_lin, self.linear, - _MutableLinearExpression([self.mon_bin] + self.linear.args), - ), - ( - mutable_lin, - self.sum, - _MutableSumExpression([self.mon_bin] + self.sum.args), - ), - ( - mutable_lin, - self.other, - _MutableSumExpression([self.mon_bin, self.other]), + _MutableLinearExpression([self.bin] + self.linear.args), ), - (mutable_lin, self.mutable_l0, _MutableLinearExpression([self.mon_bin])), + (mutable_lin, self.sum, _MutableSumExpression([self.bin] + self.sum.args)), + (mutable_lin, self.other, _MutableSumExpression([self.bin, self.other])), + (mutable_lin, self.mutable_l0, _MutableLinearExpression([self.bin])), # 16: ( mutable_lin, self.mutable_l1, - _MutableLinearExpression([self.mon_bin] + self.mutable_l1.args), + _MutableLinearExpression([self.bin, self.l1]), ), ( mutable_lin, self.mutable_l2, - _MutableSumExpression([self.mon_bin] + self.mutable_l2.args), + _MutableSumExpression([self.bin] + self.l2.args), ), - (mutable_lin, self.param0, _MutableLinearExpression([self.mon_bin])), - (mutable_lin, self.param1, _MutableLinearExpression([self.mon_bin, 1])), + (mutable_lin, self.param0, _MutableLinearExpression([self.bin])), + (mutable_lin, self.param1, _MutableLinearExpression([self.bin, 1])), # 20: ( mutable_lin, self.mutable_l3, - _MutableLinearExpression([self.mon_bin, self.npv]), + _MutableLinearExpression([self.bin, self.npv]), ), ] self._run_iadd_cases(tests, operator.iadd) @@ -6265,7 +5744,7 @@ def test_mutable_sum_iadd(self): (mutable_sum, self.mutable_l0, _MutableSumExpression([])), # 16: (mutable_sum, self.mutable_l1, _MutableSumExpression(self.mutable_l1.args)), - (mutable_sum, self.mutable_l2, _MutableSumExpression(self.mutable_l2.args)), + (mutable_sum, self.mutable_l2, _MutableSumExpression(self.l2.args)), (mutable_sum, self.param0, _MutableSumExpression([])), (mutable_sum, self.param1, _MutableSumExpression([1])), # 20: @@ -6322,12 +5801,12 @@ def test_mutable_sum_iadd(self): ( mutable_sum, self.mutable_l1, - _MutableSumExpression([self.other] + self.mutable_l1.args), + _MutableSumExpression([self.other, self.l1]), ), ( mutable_sum, self.mutable_l2, - _MutableSumExpression([self.other] + self.mutable_l2.args), + _MutableSumExpression([self.other] + self.l2.args), ), (mutable_sum, self.param0, _MutableSumExpression([self.other])), (mutable_sum, self.param1, _MutableSumExpression([self.other, 1])), diff --git a/pyomo/core/tests/unit/test_numpy_expr.py b/pyomo/core/tests/unit/test_numpy_expr.py index fb81dfe809f..3b918a987bb 100644 --- a/pyomo/core/tests/unit/test_numpy_expr.py +++ b/pyomo/core/tests/unit/test_numpy_expr.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_numvalue.py b/pyomo/core/tests/unit/test_numvalue.py index bd784d655e8..1466c7bcb49 100644 --- a/pyomo/core/tests/unit/test_numvalue.py +++ b/pyomo/core/tests/unit/test_numvalue.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,6 +18,7 @@ import pyomo.common.unittest as unittest from pyomo.common.dependencies import numpy, numpy_available +from pyomo.core.base.units_container import pint_available from pyomo.environ import ( value, @@ -50,7 +51,16 @@ def __init__(self, val=0): class MyBogusNumericType(MyBogusType): def __add__(self, other): - return MyBogusNumericType(self.val + float(other)) + if other.__class__ in native_numeric_types: + return MyBogusNumericType(self.val + float(other)) + else: + return NotImplemented + + def __le__(self, other): + if other.__class__ in native_numeric_types: + return self.val <= float(other) + else: + return NotImplemented def __lt__(self, other): return self.val < float(other) @@ -534,16 +544,18 @@ def test_unknownNumericType(self): try: val = as_numeric(ref) self.assertEqual(val().val, 42.0) + self.assertIn(MyBogusNumericType, native_numeric_types) + self.assertIn(MyBogusNumericType, native_types) finally: native_numeric_types.remove(MyBogusNumericType) native_types.remove(MyBogusNumericType) @unittest.skipUnless(numpy_available, "This test requires NumPy") def test_numpy_basic_float_registration(self): - self.assertIn(numpy.float_, native_numeric_types) - self.assertNotIn(numpy.float_, native_integer_types) - self.assertIn(numpy.float_, _native_boolean_types) - self.assertIn(numpy.float_, native_types) + self.assertIn(numpy.float64, native_numeric_types) + self.assertNotIn(numpy.float64, native_integer_types) + self.assertIn(numpy.float64, _native_boolean_types) + self.assertIn(numpy.float64, native_types) @unittest.skipUnless(numpy_available, "This test requires NumPy") def test_numpy_basic_int_registration(self): @@ -562,10 +574,43 @@ def test_numpy_basic_bool_registration(self): @unittest.skipUnless(numpy_available, "This test requires NumPy") def test_automatic_numpy_registration(self): cmd = ( - 'import pyomo; from pyomo.core.base import Var, Param; ' - 'from pyomo.core.base.units_container import units; import numpy as np; ' - 'print(np.float64 in pyomo.common.numeric_types.native_numeric_types); ' - '%s; print(np.float64 in pyomo.common.numeric_types.native_numeric_types)' + 'from pyomo.common.numeric_types import native_numeric_types as nnt; ' + 'print("float64" in [_.__name__ for _ in nnt]); ' + 'import numpy; ' + 'print("float64" in [_.__name__ for _ in nnt])' + ) + + rc = subprocess.run( + [sys.executable, '-c', cmd], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + self.assertEqual((rc.returncode, rc.stdout), (0, "False\nTrue\n")) + + cmd = ( + 'import numpy; ' + 'from pyomo.common.numeric_types import native_numeric_types as nnt; ' + 'print("float64" in [_.__name__ for _ in nnt])' + ) + + rc = subprocess.run( + [sys.executable, '-c', cmd], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + self.assertEqual((rc.returncode, rc.stdout), (0, "True\n")) + + def test_unknownNumericType_expr_registration(self): + cmd = ( + 'import pyomo; ' + 'from pyomo.core.base import Var, Param; ' + 'from pyomo.core.base.units_container import units; ' + 'from pyomo.common.numeric_types import native_numeric_types as nnt; ' + f'from {__name__} import MyBogusNumericType; ' + 'ref = MyBogusNumericType(42); ' + 'print(MyBogusNumericType in nnt); %s; print(MyBogusNumericType in nnt); ' ) def _tester(expr): @@ -575,19 +620,32 @@ def _tester(expr): stderr=subprocess.STDOUT, text=True, ) - self.assertEqual((rc.returncode, rc.stdout), (0, "False\nTrue\n")) - - _tester('Var() <= np.float64(5)') - _tester('np.float64(5) <= Var()') - _tester('np.float64(5) + Var()') - _tester('Var() + np.float64(5)') - _tester('v = Var(); v.construct(); v.value = np.float64(5)') - _tester('p = Param(mutable=True); p.construct(); p.value = np.float64(5)') - _tester('v = Var(units=units.m); v.construct(); v.value = np.float64(5)') - _tester( - 'p = Param(mutable=True, units=units.m); p.construct(); ' - 'p.value = np.float64(5)' - ) + self.assertEqual( + (rc.returncode, rc.stdout), + ( + 0, + '''False +WARNING: Dynamically registering the following numeric type: + pyomo.core.tests.unit.test_numvalue.MyBogusNumericType + Dynamic registration is supported for convenience, but there are known + limitations to this approach. We recommend explicitly registering numeric + types using RegisterNumericType() or RegisterIntegerType(). +True +''', + ), + ) + + _tester('Var() <= ref') + _tester('ref <= Var()') + _tester('ref + Var()') + _tester('Var() + ref') + _tester('v = Var(); v.construct(); v.value = ref') + _tester('p = Param(mutable=True); p.construct(); p.value = ref') + if pint_available: + _tester('v = Var(units=units.m); v.construct(); v.value = ref') + _tester( + 'p = Param(mutable=True, units=units.m); p.construct(); p.value = ref' + ) if __name__ == "__main__": diff --git a/pyomo/core/tests/unit/test_obj.py b/pyomo/core/tests/unit/test_obj.py index 3c8a05f7058..52125107175 100644 --- a/pyomo/core/tests/unit/test_obj.py +++ b/pyomo/core/tests/unit/test_obj.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -78,7 +78,7 @@ def test_empty_singleton(self): # Even though we construct a ScalarObjective, # if it is not initialized that means it is "empty" # and we should encounter errors when trying to access the - # _ObjectiveData interface methods until we assign + # ObjectiveData interface methods until we assign # something to the objective. # self.assertEqual(a._constructed, True) diff --git a/pyomo/core/tests/unit/test_param.py b/pyomo/core/tests/unit/test_param.py index 9bc0c4b2ad2..66fbb742b70 100644 --- a/pyomo/core/tests/unit/test_param.py +++ b/pyomo/core/tests/unit/test_param.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -65,8 +65,8 @@ from pyomo.common.errors import PyomoException from pyomo.common.log import LoggingIntercept from pyomo.common.tempfiles import TempfileManager -from pyomo.core.base.param import _ParamData -from pyomo.core.base.set import _SetData +from pyomo.core.base.param import ParamData +from pyomo.core.base.set import SetData from pyomo.core.base.units_container import units, pint_available, UnitsError from io import StringIO @@ -181,7 +181,7 @@ def test_setitem_preexisting(self): idx = sorted(keys)[0] self.assertEqual(value(self.instance.A[idx]), self.data[idx]) if self.instance.A.mutable: - self.assertTrue(isinstance(self.instance.A[idx], _ParamData)) + self.assertTrue(isinstance(self.instance.A[idx], ParamData)) else: self.assertEqual(type(self.instance.A[idx]), float) @@ -190,7 +190,7 @@ def test_setitem_preexisting(self): if not self.instance.A.mutable: self.fail("Expected setitem[%s] to fail for immutable Params" % (idx,)) self.assertEqual(value(self.instance.A[idx]), 4.3) - self.assertTrue(isinstance(self.instance.A[idx], _ParamData)) + self.assertTrue(isinstance(self.instance.A[idx], ParamData)) except TypeError: # immutable Params should raise a TypeError exception if self.instance.A.mutable: @@ -249,7 +249,7 @@ def test_setitem_default_override(self): self.assertEqual(value(self.instance.A[idx]), self.instance.A._default_val) if self.instance.A.mutable: - self.assertIsInstance(self.instance.A[idx], _ParamData) + self.assertIsInstance(self.instance.A[idx], ParamData) else: self.assertEqual( type(self.instance.A[idx]), type(value(self.instance.A._default_val)) @@ -260,7 +260,7 @@ def test_setitem_default_override(self): if not self.instance.A.mutable: self.fail("Expected setitem[%s] to fail for immutable Params" % (idx,)) self.assertEqual(self.instance.A[idx].value, 4.3) - self.assertIsInstance(self.instance.A[idx], _ParamData) + self.assertIsInstance(self.instance.A[idx], ParamData) except TypeError: # immutable Params should raise a TypeError exception if self.instance.A.mutable: @@ -1487,7 +1487,7 @@ def test_domain_set_initializer(self): m.I = Set(initialize=[1, 2, 3]) param_vals = {1: 1, 2: 1, 3: -1} m.p = Param(m.I, initialize=param_vals, domain={-1, 1}) - self.assertIsInstance(m.p.domain, _SetData) + self.assertIsInstance(m.p.domain, SetData) @unittest.skipUnless(pint_available, "units test requires pint module") def test_set_value_units(self): @@ -1561,6 +1561,88 @@ def test_scalar_set_mutable_when_not_present(self): m.p = 20 self.assertEqual(m.x_p.bounds, (0, 20)) + def test_nonfinite_pprint(self): + m = ConcreteModel() + + # Test from #3379 + m.p = Param(Any, default=1) + self.assertEqual(m.p['foo'], 1) + OUT = StringIO() + m.p.pprint(OUT) + self.assertEqual( + OUT.getvalue(), + "p : Size=inf, Index=Any, Domain=Any, Default=1, Mutable=False\n" + " Key : Value\n", + ) + + # Other useful checks + m.q = Param(Any, default=1, initialize={1: 2, 'a': 3, 'bb': 4}) + OUT = StringIO() + m.q.pprint(OUT) + self.assertEqual( + OUT.getvalue(), + "q : Size=inf, Index=Any, Domain=Any, Default=1, Mutable=False\n" + " Key : Value\n" + " 1 : 2\n" + " a : 3\n" + " bb : 4\n", + ) + + m.r = Param(Any, initialize={1: 2, 'a': 3, 'bb': 4}) + OUT = StringIO() + m.r.pprint(OUT) + self.assertEqual( + OUT.getvalue(), + "r : Size=3, Index=Any, Domain=Any, Default=None, Mutable=False\n" + " Key : Value\n" + " 1 : 2\n" + " a : 3\n" + " bb : 4\n", + ) + + # Other useful (mutable) checks + m.q = Param(Any, default=1, mutable=True, initialize={1: 2, 'a': 3, 'bb': 4}) + OUT = StringIO() + m.q.pprint(OUT) + self.assertEqual( + OUT.getvalue(), + "q : Size=inf, Index=Any, Domain=Any, Default=1, Mutable=True\n" + " Key : Value\n" + " 1 : 2\n" + " a : 3\n" + " bb : 4\n", + ) + + m.r = Param(Any, mutable=True, initialize={1: 2, 'a': 3, 'bb': 4}) + OUT = StringIO() + m.r.pprint(OUT) + self.assertEqual( + OUT.getvalue(), + "r : Size=3, Index=Any, Domain=Any, Default=None, Mutable=True\n" + " Key : Value\n" + " 1 : 2\n" + " a : 3\n" + " bb : 4\n", + ) + + def test_invalid_exception_argument(self): + m = ConcreteModel() + m.p = Param(initialize=7, mutable=True) + m.indexed = Param([1, 2], initialize={1: 3, 2: 4}, mutable=True) + with self.assertRaisesRegex( + ValueError, + r"Param 'p' was called with a non-bool argument for 'exception': " + r"p \+ 2", + ): + m.p(m.p + 2) + + with self.assertRaisesRegex( + ValueError, + r"Param 'indexed\[1\]' was called with a non-bool argument for " + r"'exception': 3.2", + ): + m.indexed[1](3.2) + def createNonIndexedParamMethod(func, init_xy, new_xy, tol=1e-10): def testMethod(self): diff --git a/pyomo/core/tests/unit/test_pickle.py b/pyomo/core/tests/unit/test_pickle.py index fccc92bbfa2..ad0fbb9c2f5 100644 --- a/pyomo/core/tests/unit/test_pickle.py +++ b/pyomo/core/tests/unit/test_pickle.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_piecewise.py b/pyomo/core/tests/unit/test_piecewise.py index af82ef7c06d..f467de170ef 100644 --- a/pyomo/core/tests/unit/test_piecewise.py +++ b/pyomo/core/tests/unit/test_piecewise.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -104,7 +104,7 @@ def test_indexed_with_nonindexed_vars(self): model.con3 = Piecewise(*args, **keywords) # test that nonindexed Piecewise can handle - # _VarData (e.g model.x[1] + # VarData (e.g model.x[1] def test_nonindexed_with_indexed_vars(self): model = ConcreteModel() model.range = Var([1]) diff --git a/pyomo/core/tests/unit/test_preprocess.py b/pyomo/core/tests/unit/test_preprocess.py index ce7924f3ac5..1452f11bc9e 100644 --- a/pyomo/core/tests/unit/test_preprocess.py +++ b/pyomo/core/tests/unit/test_preprocess.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_range.py b/pyomo/core/tests/unit/test_range.py index 4b489f50d44..cb4399beb22 100644 --- a/pyomo/core/tests/unit/test_range.py +++ b/pyomo/core/tests/unit/test_range.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_reference.py b/pyomo/core/tests/unit/test_reference.py index 287ff204f9e..adeb0a52caa 100644 --- a/pyomo/core/tests/unit/test_reference.py +++ b/pyomo/core/tests/unit/test_reference.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -800,8 +800,8 @@ def test_reference_indexedcomponent_pprint(self): buf.getvalue(), """r : Size=2, Index={1, 2}, ReferenceTo=x Key : Object - 1 : - 2 : + 1 : + 2 : """, ) m.s = Reference(m.x[:, ...], ctype=IndexedComponent) @@ -811,8 +811,8 @@ def test_reference_indexedcomponent_pprint(self): buf.getvalue(), """s : Size=2, Index={1, 2}, ReferenceTo=x[:, ...] Key : Object - 1 : - 2 : + 1 : + 2 : """, ) @@ -1280,7 +1280,6 @@ def test_contains_with_nonflattened(self): normalize_index.flatten = _old_flatten def test_pprint_nonfinite_sets(self): - self.maxDiff = None m = ConcreteModel() m.v = Var(NonNegativeIntegers, dense=False) m.ref = Reference(m.v) @@ -1322,7 +1321,6 @@ def test_pprint_nonfinite_sets(self): def test_pprint_nonfinite_sets_ctypeNone(self): # test issue #2039 - self.maxDiff = None m = ConcreteModel() m.v = Var(NonNegativeIntegers, dense=False) m.ref = Reference(m.v, ctype=None) @@ -1359,8 +1357,8 @@ def test_pprint_nonfinite_sets_ctypeNone(self): 1 IndexedComponent Declarations ref : Size=2, Index=NonNegativeIntegers, ReferenceTo=v Key : Object - 3 : - 5 : + 3 : + 5 : 2 Declarations: v ref """.strip(), diff --git a/pyomo/core/tests/unit/test_relational_expr.py b/pyomo/core/tests/unit/test_relational_expr.py index d361bfcc83c..9f29da3f420 100644 --- a/pyomo/core/tests/unit/test_relational_expr.py +++ b/pyomo/core/tests/unit/test_relational_expr.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -77,14 +77,14 @@ def test_equalityErrors(self): # a b with self.assertRaisesRegex( TypeError, - "Attempting to use a non-numeric type " - r"\(EqualityExpression\) in a numeric expression context.", + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression:", ): e == m.a with self.assertRaisesRegex( TypeError, - "Attempting to use a non-numeric type " - r"\(EqualityExpression\) in a numeric expression context.", + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression:", ): m.a == e diff --git a/pyomo/core/tests/unit/test_relational_expr_dispatcher.py b/pyomo/core/tests/unit/test_relational_expr_dispatcher.py new file mode 100644 index 00000000000..bb79bd41e4e --- /dev/null +++ b/pyomo/core/tests/unit/test_relational_expr_dispatcher.py @@ -0,0 +1,7239 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +# +# Unit Tests for expression generation +# +import logging +import operator + +import pyomo.common.unittest as unittest + +from pyomo.core.expr import ( + inequality, + EqualityExpression, + InequalityExpression, + RangedExpression, +) + +from pyomo.core.tests.unit.test_numeric_expr_dispatcher import Base + +logger = logging.getLogger(__name__) + + +class BaseRelational(Base): + NUM_TESTS = 25 + + def tearDown(self): + pass + + def setUp(self): + super().setUp() + + # Note there are 13 basic argument "types" that determine how + # expressions are generated (defined by the _EXPR_TYPE enum): + # + # class RELATIONAL_ARG_TYPE(enum.Enum): + # MUTABLE = -2 + # ASNUMERIC = -1 + # INVALID = 0 + # NATIVE = 1 + # NPV = 2 + # PARAM = 3 + # VAR = 4 + # MONOMIAL = 5 + # LINEAR = 6 + # SUM = 7 + # OTHER = 8 + # INEQUALITY = 100 + # INVALID_RELATIONAL = 101 + + # self.m = ConcreteModel() + # self.m.p0 = Param(initialize=0, mutable=False) + # self.m.p1 = Param(initialize=1, mutable=False) + # self.m.p = Param(initialize=6, mutable=False) + # self.m.q = Param(initialize=7, mutable=True) + # self.m.x = Var() + # self.m.d = Disjunct() + # self.bin = self.m.d.indicator_var.as_numeric() + + self.eq = self.m.x == self.m.q + self.le = self.m.x <= self.m.q + self.lt = self.m.x < self.m.p + self.ranged = inequality(self.m.p, self.m.x, self.m.q) + + # self.TEMPLATE = [ + # self.invalid, + # self.asbinary, + # self.zero, + # self.one, + # # 4: + # self.native, + # self.npv, + # self.param, + # self.param_mut, + # # 8: + # self.var, + # self.mon_native, + # self.mon_param, + # self.mon_npv, + # # 12: + # self.linear, + # self.sum, + # self.other, + # self.mutable_l0, + # # 16: + # self.mutable_l1, + # self.mutable_l2, + # self.param0, + # self.param1, + # # 20: + # self.mutable_l3, + # ] + self.TEMPLATE.extend( + [ + # 21: + self.eq, + self.le, + self.lt, + self.ranged, + ] + ) + + +# +# +# EQUALITY +# +# + + +class TestEquality(BaseRelational, unittest.TestCase): + + def test_eq_invalid(self): + tests = [ + # "invalid(str) == invalid(str)" is a legitimate Python + # operation and should never hit the Pyomo expression + # system + (self.invalid, self.invalid, True), + (self.invalid, self.asbinary, False), + (self.invalid, self.zero, False), + (self.invalid, self.one, False), + # 4: + (self.invalid, self.native, False), + (self.invalid, self.npv, False), + (self.invalid, self.param, False), + (self.invalid, self.param_mut, False), + # 8: + (self.invalid, self.var, False), + (self.invalid, self.mon_native, False), + (self.invalid, self.mon_param, False), + (self.invalid, self.mon_npv, False), + # 12: + (self.invalid, self.linear, False), + (self.invalid, self.sum, False), + (self.invalid, self.other, False), + (self.invalid, self.mutable_l0, False), + # 16: + (self.invalid, self.mutable_l1, False), + (self.invalid, self.mutable_l2, False), + (self.invalid, self.param0, False), + (self.invalid, self.param1, False), + # 20: + (self.invalid, self.mutable_l3, False), + (self.invalid, self.eq, False), + (self.invalid, self.le, False), + (self.invalid, self.lt, False), + # 24: + (self.invalid, self.ranged, False), + ] + self._run_cases(tests, operator.eq) + + def test_eq_asbinary(self): + tests = [ + (self.asbinary, self.invalid, False), + (self.asbinary, self.asbinary, True), + (self.asbinary, self.zero, EqualityExpression((self.bin, 0))), + (self.asbinary, self.one, EqualityExpression((self.bin, 1))), + # 4: + (self.asbinary, self.native, EqualityExpression((self.bin, 5))), + (self.asbinary, self.npv, EqualityExpression((self.bin, self.npv))), + (self.asbinary, self.param, EqualityExpression((self.bin, 6))), + ( + self.asbinary, + self.param_mut, + EqualityExpression((self.bin, self.param_mut)), + ), + # 8: + (self.asbinary, self.var, EqualityExpression((self.bin, self.var))), + ( + self.asbinary, + self.mon_native, + EqualityExpression((self.bin, self.mon_native)), + ), + ( + self.asbinary, + self.mon_param, + EqualityExpression((self.bin, self.mon_param)), + ), + (self.asbinary, self.mon_npv, EqualityExpression((self.bin, self.mon_npv))), + # 12: + (self.asbinary, self.linear, EqualityExpression((self.bin, self.linear))), + (self.asbinary, self.sum, EqualityExpression((self.bin, self.sum))), + (self.asbinary, self.other, EqualityExpression((self.bin, self.other))), + (self.asbinary, self.mutable_l0, EqualityExpression((self.bin, self.l0))), + # 16: + (self.asbinary, self.mutable_l1, EqualityExpression((self.bin, self.l1))), + (self.asbinary, self.mutable_l2, EqualityExpression((self.bin, self.l2))), + (self.asbinary, self.param0, EqualityExpression((self.bin, 0))), + (self.asbinary, self.param1, EqualityExpression((self.bin, 1))), + # 20: + (self.asbinary, self.mutable_l3, EqualityExpression((self.bin, self.l3))), + ( + self.asbinary, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.asbinary, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.asbinary, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.asbinary, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_zero(self): + tests = [ + (self.zero, self.invalid, False), + (self.zero, self.asbinary, EqualityExpression((self.bin, 0))), + (self.zero, self.zero, True), + (self.zero, self.one, False), + # 4: + (self.zero, self.native, False), + (self.zero, self.npv, EqualityExpression((self.npv, 0))), + (self.zero, self.param, False), + (self.zero, self.param_mut, EqualityExpression((self.param_mut, 0))), + # 8: + (self.zero, self.var, EqualityExpression((self.var, 0))), + (self.zero, self.mon_native, EqualityExpression((self.mon_native, 0))), + (self.zero, self.mon_param, EqualityExpression((self.mon_param, 0))), + (self.zero, self.mon_npv, EqualityExpression((self.mon_npv, 0))), + # 12: + (self.zero, self.linear, EqualityExpression((self.linear, 0))), + (self.zero, self.sum, EqualityExpression((self.sum, 0))), + (self.zero, self.other, EqualityExpression((self.other, 0))), + (self.zero, self.mutable_l0, True), + # 16: + (self.zero, self.mutable_l1, EqualityExpression((self.l1, 0))), + (self.zero, self.mutable_l2, EqualityExpression((self.l2, 0))), + (self.zero, self.param0, True), + (self.zero, self.param1, False), + # 20: + (self.zero, self.mutable_l3, EqualityExpression((self.l3, 0))), + ( + self.zero, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.zero, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.zero, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.zero, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_one(self): + tests = [ + (self.one, self.invalid, False), + (self.one, self.asbinary, EqualityExpression((self.bin, 1))), + (self.one, self.zero, False), + (self.one, self.one, True), + # 4: + (self.one, self.native, False), + (self.one, self.npv, EqualityExpression((self.npv, 1))), + (self.one, self.param, False), + (self.one, self.param_mut, EqualityExpression((self.param_mut, 1))), + # 8: + (self.one, self.var, EqualityExpression((self.var, 1))), + (self.one, self.mon_native, EqualityExpression((self.mon_native, 1))), + (self.one, self.mon_param, EqualityExpression((self.mon_param, 1))), + (self.one, self.mon_npv, EqualityExpression((self.mon_npv, 1))), + # 12: + (self.one, self.linear, EqualityExpression((self.linear, 1))), + (self.one, self.sum, EqualityExpression((self.sum, 1))), + (self.one, self.other, EqualityExpression((self.other, 1))), + (self.one, self.mutable_l0, False), + # 16: + (self.one, self.mutable_l1, EqualityExpression((self.l1, 1))), + (self.one, self.mutable_l2, EqualityExpression((self.l2, 1))), + (self.one, self.param0, False), + (self.one, self.param1, True), + # 20: + (self.one, self.mutable_l3, EqualityExpression((self.l3, 1))), + ( + self.one, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.one, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.one, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.one, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_native(self): + tests = [ + (self.native, self.invalid, False), + (self.native, self.asbinary, EqualityExpression((self.bin, 5))), + (self.native, self.zero, False), + (self.native, self.one, False), + # 4: + (self.native, self.native, True), + (self.native, self.npv, EqualityExpression((self.npv, 5))), + (self.native, self.param, False), + (self.native, self.param_mut, EqualityExpression((self.param_mut, 5))), + # 8: + (self.native, self.var, EqualityExpression((self.var, 5))), + (self.native, self.mon_native, EqualityExpression((self.mon_native, 5))), + (self.native, self.mon_param, EqualityExpression((self.mon_param, 5))), + (self.native, self.mon_npv, EqualityExpression((self.mon_npv, 5))), + # 12: + (self.native, self.linear, EqualityExpression((self.linear, 5))), + (self.native, self.sum, EqualityExpression((self.sum, 5))), + (self.native, self.other, EqualityExpression((self.other, 5))), + (self.native, self.mutable_l0, False), + # 16: + (self.native, self.mutable_l1, EqualityExpression((self.l1, 5))), + (self.native, self.mutable_l2, EqualityExpression((self.l2, 5))), + (self.native, self.param0, False), + (self.native, self.param1, False), + # 20: + (self.native, self.mutable_l3, EqualityExpression((self.l3, 5))), + ( + self.native, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.native, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.native, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.native, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_npv(self): + tests = [ + (self.npv, self.invalid, False), + (self.npv, self.asbinary, EqualityExpression((self.npv, self.bin))), + (self.npv, self.zero, EqualityExpression((self.npv, 0))), + (self.npv, self.one, EqualityExpression((self.npv, 1))), + # 4: + (self.npv, self.native, EqualityExpression((self.npv, 5))), + (self.npv, self.npv, EqualityExpression((self.npv, self.npv))), + (self.npv, self.param, EqualityExpression((self.npv, 6))), + (self.npv, self.param_mut, EqualityExpression((self.npv, self.param_mut))), + # 8: + (self.npv, self.var, EqualityExpression((self.npv, self.var))), + ( + self.npv, + self.mon_native, + EqualityExpression((self.npv, self.mon_native)), + ), + (self.npv, self.mon_param, EqualityExpression((self.npv, self.mon_param))), + (self.npv, self.mon_npv, EqualityExpression((self.npv, self.mon_npv))), + # 12: + (self.npv, self.linear, EqualityExpression((self.npv, self.linear))), + (self.npv, self.sum, EqualityExpression((self.npv, self.sum))), + (self.npv, self.other, EqualityExpression((self.npv, self.other))), + (self.npv, self.mutable_l0, EqualityExpression((self.npv, self.l0))), + # 16: + (self.npv, self.mutable_l1, EqualityExpression((self.npv, self.l1))), + (self.npv, self.mutable_l2, EqualityExpression((self.npv, self.l2))), + (self.npv, self.param0, EqualityExpression((self.npv, 0))), + (self.npv, self.param1, EqualityExpression((self.npv, 1))), + # 20: + (self.npv, self.mutable_l3, EqualityExpression((self.npv, self.l3))), + ( + self.npv, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.npv, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.npv, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.npv, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_param(self): + tests = [ + (self.param, self.invalid, False), + (self.param, self.asbinary, EqualityExpression((self.bin, 6))), + (self.param, self.zero, False), + (self.param, self.one, False), + # 4: + (self.param, self.native, False), + (self.param, self.npv, EqualityExpression((self.npv, 6))), + (self.param, self.param, True), + (self.param, self.param_mut, EqualityExpression((6, self.param_mut))), + # 8: + (self.param, self.var, EqualityExpression((self.var, 6))), + (self.param, self.mon_native, EqualityExpression((self.mon_native, 6))), + (self.param, self.mon_param, EqualityExpression((self.mon_param, 6))), + (self.param, self.mon_npv, EqualityExpression((self.mon_npv, 6))), + # 12: + (self.param, self.linear, EqualityExpression((self.linear, 6))), + (self.param, self.sum, EqualityExpression((self.sum, 6))), + (self.param, self.other, EqualityExpression((self.other, 6))), + (self.param, self.mutable_l0, False), + # 16: + (self.param, self.mutable_l1, EqualityExpression((self.l1, 6))), + (self.param, self.mutable_l2, EqualityExpression((self.l2, 6))), + (self.param, self.param0, False), + (self.param, self.param1, False), + # 20: + (self.param, self.mutable_l3, EqualityExpression((self.l3, 6))), + ( + self.param, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.param, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_param_mut(self): + tests = [ + (self.param_mut, self.invalid, False), + ( + self.param_mut, + self.asbinary, + EqualityExpression((self.param_mut, self.bin)), + ), + (self.param_mut, self.zero, EqualityExpression((self.param_mut, 0))), + (self.param_mut, self.one, EqualityExpression((self.param_mut, 1))), + # 4: + (self.param_mut, self.native, EqualityExpression((self.param_mut, 5))), + (self.param_mut, self.npv, EqualityExpression((self.param_mut, self.npv))), + (self.param_mut, self.param, EqualityExpression((self.param_mut, 6))), + ( + self.param_mut, + self.param_mut, + EqualityExpression((self.param_mut, self.param_mut)), + ), + # 8: + (self.param_mut, self.var, EqualityExpression((self.param_mut, self.var))), + ( + self.param_mut, + self.mon_native, + EqualityExpression((self.param_mut, self.mon_native)), + ), + ( + self.param_mut, + self.mon_param, + EqualityExpression((self.param_mut, self.mon_param)), + ), + ( + self.param_mut, + self.mon_npv, + EqualityExpression((self.param_mut, self.mon_npv)), + ), + # 12: + ( + self.param_mut, + self.linear, + EqualityExpression((self.param_mut, self.linear)), + ), + (self.param_mut, self.sum, EqualityExpression((self.param_mut, self.sum))), + ( + self.param_mut, + self.other, + EqualityExpression((self.param_mut, self.other)), + ), + ( + self.param_mut, + self.mutable_l0, + EqualityExpression((self.param_mut, self.l0)), + ), + # 16: + ( + self.param_mut, + self.mutable_l1, + EqualityExpression((self.param_mut, self.l1)), + ), + ( + self.param_mut, + self.mutable_l2, + EqualityExpression((self.param_mut, self.l2)), + ), + (self.param_mut, self.param0, EqualityExpression((self.param_mut, 0))), + (self.param_mut, self.param1, EqualityExpression((self.param_mut, 1))), + # 20: + ( + self.param_mut, + self.mutable_l3, + EqualityExpression((self.param_mut, self.l3)), + ), + ( + self.param_mut, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param_mut, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param_mut, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.param_mut, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_var(self): + tests = [ + (self.var, self.invalid, False), + (self.var, self.asbinary, EqualityExpression((self.var, self.bin))), + (self.var, self.zero, EqualityExpression((self.var, 0))), + (self.var, self.one, EqualityExpression((self.var, 1))), + # 4: + (self.var, self.native, EqualityExpression((self.var, 5))), + (self.var, self.npv, EqualityExpression((self.var, self.npv))), + (self.var, self.param, EqualityExpression((self.var, 6))), + (self.var, self.param_mut, EqualityExpression((self.var, self.param_mut))), + # 8: + (self.var, self.var, EqualityExpression((self.var, self.var))), + ( + self.var, + self.mon_native, + EqualityExpression((self.var, self.mon_native)), + ), + (self.var, self.mon_param, EqualityExpression((self.var, self.mon_param))), + (self.var, self.mon_npv, EqualityExpression((self.var, self.mon_npv))), + # 12: + (self.var, self.linear, EqualityExpression((self.var, self.linear))), + (self.var, self.sum, EqualityExpression((self.var, self.sum))), + (self.var, self.other, EqualityExpression((self.var, self.other))), + (self.var, self.mutable_l0, EqualityExpression((self.var, self.l0))), + # 16: + (self.var, self.mutable_l1, EqualityExpression((self.var, self.l1))), + (self.var, self.mutable_l2, EqualityExpression((self.var, self.l2))), + (self.var, self.param0, EqualityExpression((self.var, 0))), + (self.var, self.param1, EqualityExpression((self.var, 1))), + # 20: + (self.var, self.mutable_l3, EqualityExpression((self.var, self.l3))), + ( + self.var, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.var, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.var, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.var, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_mon_native(self): + tests = [ + (self.mon_native, self.invalid, False), + ( + self.mon_native, + self.asbinary, + EqualityExpression((self.mon_native, self.bin)), + ), + (self.mon_native, self.zero, EqualityExpression((self.mon_native, 0))), + (self.mon_native, self.one, EqualityExpression((self.mon_native, 1))), + # 4: + (self.mon_native, self.native, EqualityExpression((self.mon_native, 5))), + ( + self.mon_native, + self.npv, + EqualityExpression((self.mon_native, self.npv)), + ), + (self.mon_native, self.param, EqualityExpression((self.mon_native, 6))), + ( + self.mon_native, + self.param_mut, + EqualityExpression((self.mon_native, self.param_mut)), + ), + # 8: + ( + self.mon_native, + self.var, + EqualityExpression((self.mon_native, self.var)), + ), + ( + self.mon_native, + self.mon_native, + EqualityExpression((self.mon_native, self.mon_native)), + ), + ( + self.mon_native, + self.mon_param, + EqualityExpression((self.mon_native, self.mon_param)), + ), + ( + self.mon_native, + self.mon_npv, + EqualityExpression((self.mon_native, self.mon_npv)), + ), + # 12: + ( + self.mon_native, + self.linear, + EqualityExpression((self.mon_native, self.linear)), + ), + ( + self.mon_native, + self.sum, + EqualityExpression((self.mon_native, self.sum)), + ), + ( + self.mon_native, + self.other, + EqualityExpression((self.mon_native, self.other)), + ), + ( + self.mon_native, + self.mutable_l0, + EqualityExpression((self.mon_native, self.l0)), + ), + # 16: + ( + self.mon_native, + self.mutable_l1, + EqualityExpression((self.mon_native, self.l1)), + ), + ( + self.mon_native, + self.mutable_l2, + EqualityExpression((self.mon_native, self.l2)), + ), + (self.mon_native, self.param0, EqualityExpression((self.mon_native, 0))), + (self.mon_native, self.param1, EqualityExpression((self.mon_native, 1))), + # 20: + ( + self.mon_native, + self.mutable_l3, + EqualityExpression((self.mon_native, self.l3)), + ), + ( + self.mon_native, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_native, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_native, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.mon_native, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_mon_param(self): + tests = [ + (self.mon_param, self.invalid, False), + ( + self.mon_param, + self.asbinary, + EqualityExpression((self.mon_param, self.bin)), + ), + (self.mon_param, self.zero, EqualityExpression((self.mon_param, 0))), + (self.mon_param, self.one, EqualityExpression((self.mon_param, 1))), + # 4: + (self.mon_param, self.native, EqualityExpression((self.mon_param, 5))), + (self.mon_param, self.npv, EqualityExpression((self.mon_param, self.npv))), + (self.mon_param, self.param, EqualityExpression((self.mon_param, 6))), + ( + self.mon_param, + self.param_mut, + EqualityExpression((self.mon_param, self.param_mut)), + ), + # 8: + (self.mon_param, self.var, EqualityExpression((self.mon_param, self.var))), + ( + self.mon_param, + self.mon_native, + EqualityExpression((self.mon_param, self.mon_native)), + ), + ( + self.mon_param, + self.mon_param, + EqualityExpression((self.mon_param, self.mon_param)), + ), + ( + self.mon_param, + self.mon_npv, + EqualityExpression((self.mon_param, self.mon_npv)), + ), + # 12: + ( + self.mon_param, + self.linear, + EqualityExpression((self.mon_param, self.linear)), + ), + (self.mon_param, self.sum, EqualityExpression((self.mon_param, self.sum))), + ( + self.mon_param, + self.other, + EqualityExpression((self.mon_param, self.other)), + ), + ( + self.mon_param, + self.mutable_l0, + EqualityExpression((self.mon_param, self.l0)), + ), + # 16: + ( + self.mon_param, + self.mutable_l1, + EqualityExpression((self.mon_param, self.l1)), + ), + ( + self.mon_param, + self.mutable_l2, + EqualityExpression((self.mon_param, self.l2)), + ), + (self.mon_param, self.param0, EqualityExpression((self.mon_param, 0))), + (self.mon_param, self.param1, EqualityExpression((self.mon_param, 1))), + # 20: + ( + self.mon_param, + self.mutable_l3, + EqualityExpression((self.mon_param, self.l3)), + ), + ( + self.mon_param, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_param, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_param, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.mon_param, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_mon_npv(self): + tests = [ + (self.mon_npv, self.invalid, False), + (self.mon_npv, self.asbinary, EqualityExpression((self.mon_npv, self.bin))), + (self.mon_npv, self.zero, EqualityExpression((self.mon_npv, 0))), + (self.mon_npv, self.one, EqualityExpression((self.mon_npv, 1))), + # 4: + (self.mon_npv, self.native, EqualityExpression((self.mon_npv, 5))), + (self.mon_npv, self.npv, EqualityExpression((self.mon_npv, self.npv))), + (self.mon_npv, self.param, EqualityExpression((self.mon_npv, 6))), + ( + self.mon_npv, + self.param_mut, + EqualityExpression((self.mon_npv, self.param_mut)), + ), + # 8: + (self.mon_npv, self.var, EqualityExpression((self.mon_npv, self.var))), + ( + self.mon_npv, + self.mon_native, + EqualityExpression((self.mon_npv, self.mon_native)), + ), + ( + self.mon_npv, + self.mon_param, + EqualityExpression((self.mon_npv, self.mon_param)), + ), + ( + self.mon_npv, + self.mon_npv, + EqualityExpression((self.mon_npv, self.mon_npv)), + ), + # 12: + ( + self.mon_npv, + self.linear, + EqualityExpression((self.mon_npv, self.linear)), + ), + (self.mon_npv, self.sum, EqualityExpression((self.mon_npv, self.sum))), + (self.mon_npv, self.other, EqualityExpression((self.mon_npv, self.other))), + ( + self.mon_npv, + self.mutable_l0, + EqualityExpression((self.mon_npv, self.l0)), + ), + # 16: + ( + self.mon_npv, + self.mutable_l1, + EqualityExpression((self.mon_npv, self.l1)), + ), + ( + self.mon_npv, + self.mutable_l2, + EqualityExpression((self.mon_npv, self.l2)), + ), + (self.mon_npv, self.param0, EqualityExpression((self.mon_npv, 0))), + (self.mon_npv, self.param1, EqualityExpression((self.mon_npv, 1))), + # 20: + ( + self.mon_npv, + self.mutable_l3, + EqualityExpression((self.mon_npv, self.l3)), + ), + ( + self.mon_npv, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_npv, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_npv, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.mon_npv, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_linear(self): + tests = [ + (self.linear, self.invalid, False), + (self.linear, self.asbinary, EqualityExpression((self.linear, self.bin))), + (self.linear, self.zero, EqualityExpression((self.linear, 0))), + (self.linear, self.one, EqualityExpression((self.linear, 1))), + # 4: + (self.linear, self.native, EqualityExpression((self.linear, 5))), + (self.linear, self.npv, EqualityExpression((self.linear, self.npv))), + (self.linear, self.param, EqualityExpression((self.linear, 6))), + ( + self.linear, + self.param_mut, + EqualityExpression((self.linear, self.param_mut)), + ), + # 8: + (self.linear, self.var, EqualityExpression((self.linear, self.var))), + ( + self.linear, + self.mon_native, + EqualityExpression((self.linear, self.mon_native)), + ), + ( + self.linear, + self.mon_param, + EqualityExpression((self.linear, self.mon_param)), + ), + ( + self.linear, + self.mon_npv, + EqualityExpression((self.linear, self.mon_npv)), + ), + # 12: + (self.linear, self.linear, EqualityExpression((self.linear, self.linear))), + (self.linear, self.sum, EqualityExpression((self.linear, self.sum))), + (self.linear, self.other, EqualityExpression((self.linear, self.other))), + (self.linear, self.mutable_l0, EqualityExpression((self.linear, self.l0))), + # 16: + (self.linear, self.mutable_l1, EqualityExpression((self.linear, self.l1))), + (self.linear, self.mutable_l2, EqualityExpression((self.linear, self.l2))), + (self.linear, self.param0, EqualityExpression((self.linear, 0))), + (self.linear, self.param1, EqualityExpression((self.linear, 1))), + # 20: + (self.linear, self.mutable_l3, EqualityExpression((self.linear, self.l3))), + ( + self.linear, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.linear, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.linear, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.linear, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_sum(self): + tests = [ + (self.sum, self.invalid, False), + (self.sum, self.asbinary, EqualityExpression((self.sum, self.bin))), + (self.sum, self.zero, EqualityExpression((self.sum, 0))), + (self.sum, self.one, EqualityExpression((self.sum, 1))), + # 4: + (self.sum, self.native, EqualityExpression((self.sum, 5))), + (self.sum, self.npv, EqualityExpression((self.sum, self.npv))), + (self.sum, self.param, EqualityExpression((self.sum, 6))), + (self.sum, self.param_mut, EqualityExpression((self.sum, self.param_mut))), + # 8: + (self.sum, self.var, EqualityExpression((self.sum, self.var))), + ( + self.sum, + self.mon_native, + EqualityExpression((self.sum, self.mon_native)), + ), + (self.sum, self.mon_param, EqualityExpression((self.sum, self.mon_param))), + (self.sum, self.mon_npv, EqualityExpression((self.sum, self.mon_npv))), + # 12: + (self.sum, self.linear, EqualityExpression((self.linear, self.sum))), + (self.sum, self.sum, EqualityExpression((self.sum, self.sum))), + (self.sum, self.other, EqualityExpression((self.sum, self.other))), + (self.sum, self.mutable_l0, EqualityExpression((self.l0, self.sum))), + # 16: + (self.sum, self.mutable_l1, EqualityExpression((self.l1, self.sum))), + (self.sum, self.mutable_l2, EqualityExpression((self.l2, self.sum))), + (self.sum, self.param0, EqualityExpression((self.sum, 0))), + (self.sum, self.param1, EqualityExpression((self.sum, 1))), + # 20: + (self.sum, self.mutable_l3, EqualityExpression((self.l3, self.sum))), + ( + self.sum, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.sum, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.sum, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.sum, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_other(self): + tests = [ + (self.other, self.invalid, False), + (self.other, self.asbinary, EqualityExpression((self.other, self.bin))), + (self.other, self.zero, EqualityExpression((self.other, 0))), + (self.other, self.one, EqualityExpression((self.other, 1))), + # 4: + (self.other, self.native, EqualityExpression((self.other, 5))), + (self.other, self.npv, EqualityExpression((self.npv, self.other))), + (self.other, self.param, EqualityExpression((self.other, 6))), + ( + self.other, + self.param_mut, + EqualityExpression((self.other, self.param_mut)), + ), + # 8: + (self.other, self.var, EqualityExpression((self.other, self.var))), + ( + self.other, + self.mon_native, + EqualityExpression((self.other, self.mon_native)), + ), + ( + self.other, + self.mon_param, + EqualityExpression((self.other, self.mon_param)), + ), + (self.other, self.mon_npv, EqualityExpression((self.other, self.mon_npv))), + # 12: + (self.other, self.linear, EqualityExpression((self.other, self.linear))), + (self.other, self.sum, EqualityExpression((self.other, self.sum))), + (self.other, self.other, EqualityExpression((self.other, self.other))), + (self.other, self.mutable_l0, EqualityExpression((self.other, self.l0))), + # 16: + (self.other, self.mutable_l1, EqualityExpression((self.other, self.l1))), + (self.other, self.mutable_l2, EqualityExpression((self.other, self.l2))), + (self.other, self.param0, EqualityExpression((self.other, 0))), + (self.other, self.param1, EqualityExpression((self.other, 1))), + # 20: + (self.other, self.mutable_l3, EqualityExpression((self.other, self.l3))), + ( + self.other, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.other, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.other, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.other, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_mutable_l0(self): + tests = [ + (self.mutable_l0, self.invalid, False), + (self.mutable_l0, self.asbinary, EqualityExpression((self.l0, self.bin))), + (self.mutable_l0, self.zero, True), + (self.mutable_l0, self.one, False), + # 4: + (self.mutable_l0, self.native, False), + (self.mutable_l0, self.npv, EqualityExpression((self.l0, self.npv))), + (self.mutable_l0, self.param, False), + ( + self.mutable_l0, + self.param_mut, + EqualityExpression((self.l0, self.param_mut)), + ), + # 8: + (self.mutable_l0, self.var, EqualityExpression((self.l0, self.var))), + ( + self.mutable_l0, + self.mon_native, + EqualityExpression((self.l0, self.mon_native)), + ), + ( + self.mutable_l0, + self.mon_param, + EqualityExpression((self.l0, self.mon_param)), + ), + ( + self.mutable_l0, + self.mon_npv, + EqualityExpression((self.l0, self.mon_npv)), + ), + # 12: + (self.mutable_l0, self.linear, EqualityExpression((self.l0, self.linear))), + (self.mutable_l0, self.sum, EqualityExpression((self.l0, self.sum))), + (self.mutable_l0, self.other, EqualityExpression((self.l0, self.other))), + (self.mutable_l0, self.mutable_l0, True), + # 16: + (self.mutable_l0, self.mutable_l1, EqualityExpression((self.l1, self.l0))), + (self.mutable_l0, self.mutable_l2, EqualityExpression((self.l0, self.l2))), + (self.mutable_l0, self.param0, True), + (self.mutable_l0, self.param1, False), + # 20: + (self.mutable_l0, self.mutable_l3, EqualityExpression((self.l3, self.l0))), + ( + self.mutable_l0, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l0, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l0, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.mutable_l0, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_mutable_l1(self): + tests = [ + (self.mutable_l1, self.invalid, False), + (self.mutable_l1, self.asbinary, EqualityExpression((self.l1, self.bin))), + (self.mutable_l1, self.zero, EqualityExpression((self.l1, 0))), + (self.mutable_l1, self.one, EqualityExpression((self.l1, 1))), + # 4: + (self.mutable_l1, self.native, EqualityExpression((self.l1, 5))), + (self.mutable_l1, self.npv, EqualityExpression((self.l1, self.npv))), + (self.mutable_l1, self.param, EqualityExpression((self.l1, 6))), + ( + self.mutable_l1, + self.param_mut, + EqualityExpression((self.l1, self.param_mut)), + ), + # 8: + (self.mutable_l1, self.var, EqualityExpression((self.l1, self.var))), + ( + self.mutable_l1, + self.mon_native, + EqualityExpression((self.l1, self.mon_native)), + ), + ( + self.mutable_l1, + self.mon_param, + EqualityExpression((self.l1, self.mon_param)), + ), + ( + self.mutable_l1, + self.mon_npv, + EqualityExpression((self.l1, self.mon_npv)), + ), + # 12: + (self.mutable_l1, self.linear, EqualityExpression((self.l1, self.linear))), + (self.mutable_l1, self.sum, EqualityExpression((self.l1, self.sum))), + (self.mutable_l1, self.other, EqualityExpression((self.l1, self.other))), + (self.mutable_l1, self.mutable_l0, EqualityExpression((self.l1, self.l0))), + # 16: + (self.mutable_l1, self.mutable_l1, EqualityExpression((self.l1, self.l1))), + (self.mutable_l1, self.mutable_l2, EqualityExpression((self.l1, self.l2))), + (self.mutable_l1, self.param0, EqualityExpression((self.l1, 0))), + (self.mutable_l1, self.param1, EqualityExpression((self.l1, 1))), + # 20: + (self.mutable_l1, self.mutable_l3, EqualityExpression((self.l3, self.l1))), + ( + self.mutable_l1, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l1, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l1, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.mutable_l1, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_mutable_l2(self): + tests = [ + (self.mutable_l2, self.invalid, False), + (self.mutable_l2, self.asbinary, EqualityExpression((self.l2, self.bin))), + (self.mutable_l2, self.zero, EqualityExpression((self.l2, 0))), + (self.mutable_l2, self.one, EqualityExpression((self.l2, 1))), + # 4: + (self.mutable_l2, self.native, EqualityExpression((self.l2, 5))), + (self.mutable_l2, self.npv, EqualityExpression((self.l2, self.npv))), + (self.mutable_l2, self.param, EqualityExpression((self.l2, 6))), + ( + self.mutable_l2, + self.param_mut, + EqualityExpression((self.l2, self.param_mut)), + ), + # 8: + (self.mutable_l2, self.var, EqualityExpression((self.l2, self.var))), + ( + self.mutable_l2, + self.mon_native, + EqualityExpression((self.l2, self.mon_native)), + ), + ( + self.mutable_l2, + self.mon_param, + EqualityExpression((self.l2, self.mon_param)), + ), + ( + self.mutable_l2, + self.mon_npv, + EqualityExpression((self.l2, self.mon_npv)), + ), + # 12: + (self.mutable_l2, self.linear, EqualityExpression((self.l2, self.linear))), + (self.mutable_l2, self.sum, EqualityExpression((self.l2, self.sum))), + (self.mutable_l2, self.other, EqualityExpression((self.l2, self.other))), + (self.mutable_l2, self.mutable_l0, EqualityExpression((self.l2, self.l0))), + # 16: + (self.mutable_l2, self.mutable_l1, EqualityExpression((self.l1, self.l2))), + (self.mutable_l2, self.mutable_l2, EqualityExpression((self.l2, self.l2))), + (self.mutable_l2, self.param0, EqualityExpression((self.l2, 0))), + (self.mutable_l2, self.param1, EqualityExpression((self.l2, 1))), + # 20: + (self.mutable_l2, self.mutable_l3, EqualityExpression((self.l3, self.l2))), + ( + self.mutable_l2, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l2, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l2, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.mutable_l2, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_param0(self): + tests = [ + (self.param0, self.invalid, False), + (self.param0, self.asbinary, EqualityExpression((self.bin, 0))), + (self.param0, self.zero, True), + (self.param0, self.one, False), + # 4: + (self.param0, self.native, False), + (self.param0, self.npv, EqualityExpression((self.npv, 0))), + (self.param0, self.param, False), + (self.param0, self.param_mut, EqualityExpression((0, self.param_mut))), + # 8: + (self.param0, self.var, EqualityExpression((self.var, 0))), + (self.param0, self.mon_native, EqualityExpression((self.mon_native, 0))), + (self.param0, self.mon_param, EqualityExpression((self.mon_param, 0))), + (self.param0, self.mon_npv, EqualityExpression((self.mon_npv, 0))), + # 12: + (self.param0, self.linear, EqualityExpression((self.linear, 0))), + (self.param0, self.sum, EqualityExpression((self.sum, 0))), + (self.param0, self.other, EqualityExpression((self.other, 0))), + (self.param0, self.mutable_l0, True), + # 16: + (self.param0, self.mutable_l1, EqualityExpression((self.l1, 0))), + (self.param0, self.mutable_l2, EqualityExpression((self.l2, 0))), + (self.param0, self.param0, True), + (self.param0, self.param1, False), + # 20: + (self.param0, self.mutable_l3, EqualityExpression((self.l3, 0))), + ( + self.param0, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param0, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param0, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.param0, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_param1(self): + tests = [ + (self.param1, self.invalid, False), + (self.param1, self.asbinary, EqualityExpression((self.bin, 1))), + (self.param1, self.zero, False), + (self.param1, self.one, True), + # 4: + (self.param1, self.native, False), + (self.param1, self.npv, EqualityExpression((self.npv, 1))), + (self.param1, self.param, False), + (self.param1, self.param_mut, EqualityExpression((1, self.param_mut))), + # 8: + (self.param1, self.var, EqualityExpression((self.var, 1))), + (self.param1, self.mon_native, EqualityExpression((self.mon_native, 1))), + (self.param1, self.mon_param, EqualityExpression((self.mon_param, 1))), + (self.param1, self.mon_npv, EqualityExpression((self.mon_npv, 1))), + # 12: + (self.param1, self.linear, EqualityExpression((self.linear, 1))), + (self.param1, self.sum, EqualityExpression((self.sum, 1))), + (self.param1, self.other, EqualityExpression((self.other, 1))), + (self.param1, self.mutable_l0, False), + # 16: + (self.param1, self.mutable_l1, EqualityExpression((self.l1, 1))), + (self.param1, self.mutable_l2, EqualityExpression((self.l2, 1))), + (self.param1, self.param0, False), + (self.param1, self.param1, True), + # 20: + (self.param1, self.mutable_l3, EqualityExpression((self.l3, 1))), + ( + self.param1, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param1, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param1, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.param1, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_mutable_l3(self): + tests = [ + (self.mutable_l3, self.invalid, False), + (self.mutable_l3, self.asbinary, EqualityExpression((self.l3, self.bin))), + (self.mutable_l3, self.zero, EqualityExpression((self.l3, 0))), + (self.mutable_l3, self.one, EqualityExpression((self.l3, 1))), + # 4: + (self.mutable_l3, self.native, EqualityExpression((self.l3, 5))), + (self.mutable_l3, self.npv, EqualityExpression((self.l3, self.npv))), + (self.mutable_l3, self.param, EqualityExpression((self.l3, 6))), + ( + self.mutable_l3, + self.param_mut, + EqualityExpression((self.l3, self.param_mut)), + ), + # 8: + (self.mutable_l3, self.var, EqualityExpression((self.l3, self.var))), + ( + self.mutable_l3, + self.mon_native, + EqualityExpression((self.l3, self.mon_native)), + ), + ( + self.mutable_l3, + self.mon_param, + EqualityExpression((self.l3, self.mon_param)), + ), + ( + self.mutable_l3, + self.mon_npv, + EqualityExpression((self.l3, self.mon_npv)), + ), + # 12: + (self.mutable_l3, self.linear, EqualityExpression((self.l3, self.linear))), + (self.mutable_l3, self.sum, EqualityExpression((self.l3, self.sum))), + (self.mutable_l3, self.other, EqualityExpression((self.l3, self.other))), + (self.mutable_l3, self.mutable_l0, EqualityExpression((self.l3, self.l0))), + # 16: + (self.mutable_l3, self.mutable_l1, EqualityExpression((self.l3, self.l1))), + (self.mutable_l3, self.mutable_l2, EqualityExpression((self.l3, self.l2))), + (self.mutable_l3, self.param0, EqualityExpression((self.l3, 0))), + (self.mutable_l3, self.param1, EqualityExpression((self.l3, 1))), + # 20: + (self.mutable_l3, self.mutable_l3, EqualityExpression((self.l3, self.l3))), + ( + self.mutable_l3, + self.eq, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l3, + self.le, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l3, + self.lt, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 24 + ( + self.mutable_l3, + self.ranged, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_eq(self): + tests = [ + (self.eq, self.invalid, False), + ( + self.eq, + self.asbinary, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.zero, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.one, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 4: + ( + self.eq, + self.native, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.npv, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param_mut, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 8: + ( + self.eq, + self.var, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_native, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_param, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_npv, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 12: + ( + self.eq, + self.linear, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.sum, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.other, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mutable_l0, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 16: + ( + self.eq, + self.mutable_l1, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mutable_l2, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param0, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param1, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 20: + ( + self.eq, + self.mutable_l3, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.eq, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.eq, + self.le, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.eq, + self.lt, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.eq, + self.ranged, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_le(self): + tests = [ + (self.le, self.invalid, False), + ( + self.le, + self.asbinary, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.zero, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.one, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 4: + ( + self.le, + self.native, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.npv, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.param, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.param_mut, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 8: + ( + self.le, + self.var, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.mon_native, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.mon_param, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.mon_npv, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 12: + ( + self.le, + self.linear, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.sum, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.other, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.mutable_l0, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 16: + ( + self.le, + self.mutable_l1, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.mutable_l2, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.param0, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.param1, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 20: + ( + self.le, + self.mutable_l3, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.le, + self.eq, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.le, + self.le, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.le, + self.lt, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.le, + self.ranged, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_lt(self): + tests = [ + (self.lt, self.invalid, False), + ( + self.lt, + self.asbinary, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.zero, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.one, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 4: + ( + self.lt, + self.native, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.npv, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.param, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.param_mut, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 8: + ( + self.lt, + self.var, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.mon_native, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.mon_param, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.mon_npv, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 12: + ( + self.lt, + self.linear, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.sum, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.other, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.mutable_l0, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 16: + ( + self.lt, + self.mutable_l1, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.mutable_l2, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.param0, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.param1, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 20: + ( + self.lt, + self.mutable_l3, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.lt, + self.eq, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.lt, + self.le, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.lt, + self.lt, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.lt, + self.ranged, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.eq) + + def test_eq_ranged(self): + tests = [ + (self.ranged, self.invalid, False), + ( + self.ranged, + self.asbinary, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.zero, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.one, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 4: + ( + self.ranged, + self.native, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.npv, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param_mut, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 8: + ( + self.ranged, + self.var, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_native, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_param, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_npv, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 12: + ( + self.ranged, + self.linear, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.sum, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.other, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mutable_l0, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 16: + ( + self.ranged, + self.mutable_l1, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mutable_l2, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param0, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param1, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 20: + ( + self.ranged, + self.mutable_l3, + "Cannot create an EqualityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.eq, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.ranged, + self.le, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.ranged, + self.lt, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.ranged, + self.ranged, + "Cannot create an EqualityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.eq) + + +# +# +# INEQUALITY (non-strict) +# +# + + +class TestInequality(BaseRelational, unittest.TestCase): + + def test_le_invalid(self): + tests = [ + # "invalid(str) == invalid(str)" is a legitimate Python + # operation and should never hit the Pyomo expression + # system + (self.invalid, self.invalid, True), + (self.invalid, self.asbinary, NotImplemented), + (self.invalid, self.zero, NotImplemented), + (self.invalid, self.one, NotImplemented), + # 4: + (self.invalid, self.native, NotImplemented), + (self.invalid, self.npv, NotImplemented), + (self.invalid, self.param, NotImplemented), + (self.invalid, self.param_mut, NotImplemented), + # 8: + (self.invalid, self.var, NotImplemented), + (self.invalid, self.mon_native, NotImplemented), + (self.invalid, self.mon_param, NotImplemented), + (self.invalid, self.mon_npv, NotImplemented), + # 12: + (self.invalid, self.linear, NotImplemented), + (self.invalid, self.sum, NotImplemented), + (self.invalid, self.other, NotImplemented), + (self.invalid, self.mutable_l0, NotImplemented), + # 16: + (self.invalid, self.mutable_l1, NotImplemented), + (self.invalid, self.mutable_l2, NotImplemented), + (self.invalid, self.param0, NotImplemented), + (self.invalid, self.param1, NotImplemented), + # 20: + (self.invalid, self.mutable_l3, NotImplemented), + (self.invalid, self.eq, NotImplemented), + (self.invalid, self.le, NotImplemented), + (self.invalid, self.lt, NotImplemented), + # 24: + (self.invalid, self.ranged, NotImplemented), + ] + self._run_cases(tests, operator.le) + + def test_le_asbinary(self): + tests = [ + (self.asbinary, self.invalid, NotImplemented), + ( + self.asbinary, + self.asbinary, + InequalityExpression((self.bin, self.bin), False), + ), + (self.asbinary, self.zero, InequalityExpression((self.bin, 0), False)), + (self.asbinary, self.one, InequalityExpression((self.bin, 1), False)), + # 4: + (self.asbinary, self.native, InequalityExpression((self.bin, 5), False)), + ( + self.asbinary, + self.npv, + InequalityExpression((self.bin, self.npv), False), + ), + (self.asbinary, self.param, InequalityExpression((self.bin, 6), False)), + ( + self.asbinary, + self.param_mut, + InequalityExpression((self.bin, self.param_mut), False), + ), + # 8: + ( + self.asbinary, + self.var, + InequalityExpression((self.bin, self.var), False), + ), + ( + self.asbinary, + self.mon_native, + InequalityExpression((self.bin, self.mon_native), False), + ), + ( + self.asbinary, + self.mon_param, + InequalityExpression((self.bin, self.mon_param), False), + ), + ( + self.asbinary, + self.mon_npv, + InequalityExpression((self.bin, self.mon_npv), False), + ), + # 12: + ( + self.asbinary, + self.linear, + InequalityExpression((self.bin, self.linear), False), + ), + ( + self.asbinary, + self.sum, + InequalityExpression((self.bin, self.sum), False), + ), + ( + self.asbinary, + self.other, + InequalityExpression((self.bin, self.other), False), + ), + ( + self.asbinary, + self.mutable_l0, + InequalityExpression((self.bin, self.l0), False), + ), + # 16: + ( + self.asbinary, + self.mutable_l1, + InequalityExpression((self.bin, self.l1), False), + ), + ( + self.asbinary, + self.mutable_l2, + InequalityExpression((self.bin, self.l2), False), + ), + (self.asbinary, self.param0, InequalityExpression((self.bin, 0), False)), + (self.asbinary, self.param1, InequalityExpression((self.bin, 1), False)), + # 20: + ( + self.asbinary, + self.mutable_l3, + InequalityExpression((self.bin, self.l3), False), + ), + ( + self.asbinary, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.asbinary, + self.le, + RangedExpression((self.bin,) + self.le.args, (False, False)), + ), + ( + self.asbinary, + self.lt, + RangedExpression((self.bin,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.asbinary, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_zero(self): + tests = [ + (self.zero, self.invalid, NotImplemented), + (self.zero, self.asbinary, InequalityExpression((0, self.bin), False)), + (self.zero, self.zero, True), + (self.zero, self.one, True), + # 4: + (self.zero, self.native, True), + (self.zero, self.npv, InequalityExpression((0, self.npv), False)), + (self.zero, self.param, True), + ( + self.zero, + self.param_mut, + InequalityExpression((0, self.param_mut), False), + ), + # 8: + (self.zero, self.var, InequalityExpression((0, self.var), False)), + ( + self.zero, + self.mon_native, + InequalityExpression((0, self.mon_native), False), + ), + ( + self.zero, + self.mon_param, + InequalityExpression((0, self.mon_param), False), + ), + (self.zero, self.mon_npv, InequalityExpression((0, self.mon_npv), False)), + # 12: + (self.zero, self.linear, InequalityExpression((0, self.linear), False)), + (self.zero, self.sum, InequalityExpression((0, self.sum), False)), + (self.zero, self.other, InequalityExpression((0, self.other), False)), + (self.zero, self.mutable_l0, True), + # 16: + (self.zero, self.mutable_l1, InequalityExpression((0, self.l1), False)), + (self.zero, self.mutable_l2, InequalityExpression((0, self.l2), False)), + (self.zero, self.param0, True), + (self.zero, self.param1, True), + # 20: + (self.zero, self.mutable_l3, InequalityExpression((0, self.l3), False)), + ( + self.zero, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + (self.zero, self.le, RangedExpression((0,) + self.le.args, (False, False))), + (self.zero, self.lt, RangedExpression((0,) + self.lt.args, (False, True))), + # 24 + ( + self.zero, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_one(self): + tests = [ + (self.one, self.invalid, NotImplemented), + (self.one, self.asbinary, InequalityExpression((1, self.bin), False)), + (self.one, self.zero, False), + (self.one, self.one, True), + # 4: + (self.one, self.native, True), + (self.one, self.npv, InequalityExpression((1, self.npv), False)), + (self.one, self.param, True), + ( + self.one, + self.param_mut, + InequalityExpression((1, self.param_mut), False), + ), + # 8: + (self.one, self.var, InequalityExpression((1, self.var), False)), + ( + self.one, + self.mon_native, + InequalityExpression((1, self.mon_native), False), + ), + ( + self.one, + self.mon_param, + InequalityExpression((1, self.mon_param), False), + ), + (self.one, self.mon_npv, InequalityExpression((1, self.mon_npv), False)), + # 12: + (self.one, self.linear, InequalityExpression((1, self.linear), False)), + (self.one, self.sum, InequalityExpression((1, self.sum), False)), + (self.one, self.other, InequalityExpression((1, self.other), False)), + (self.one, self.mutable_l0, False), + # 16: + (self.one, self.mutable_l1, InequalityExpression((1, self.l1), False)), + (self.one, self.mutable_l2, InequalityExpression((1, self.l2), False)), + (self.one, self.param0, False), + (self.one, self.param1, True), + # 20: + (self.one, self.mutable_l3, InequalityExpression((1, self.l3), False)), + ( + self.one, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + (self.one, self.le, RangedExpression((1,) + self.le.args, (False, False))), + (self.one, self.lt, RangedExpression((1,) + self.lt.args, (False, True))), + # 24 + ( + self.one, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_native(self): + tests = [ + (self.native, self.invalid, NotImplemented), + (self.native, self.asbinary, InequalityExpression((5, self.bin), False)), + (self.native, self.zero, False), + (self.native, self.one, False), + # 4: + (self.native, self.native, True), + (self.native, self.npv, InequalityExpression((5, self.npv), False)), + (self.native, self.param, True), + ( + self.native, + self.param_mut, + InequalityExpression((5, self.param_mut), False), + ), + # 8: + (self.native, self.var, InequalityExpression((5, self.var), False)), + ( + self.native, + self.mon_native, + InequalityExpression((5, self.mon_native), False), + ), + ( + self.native, + self.mon_param, + InequalityExpression((5, self.mon_param), False), + ), + (self.native, self.mon_npv, InequalityExpression((5, self.mon_npv), False)), + # 12: + (self.native, self.linear, InequalityExpression((5, self.linear), False)), + (self.native, self.sum, InequalityExpression((5, self.sum), False)), + (self.native, self.other, InequalityExpression((5, self.other), False)), + (self.native, self.mutable_l0, False), + # 16: + (self.native, self.mutable_l1, InequalityExpression((5, self.l1), False)), + (self.native, self.mutable_l2, InequalityExpression((5, self.l2), False)), + (self.native, self.param0, False), + (self.native, self.param1, False), + # 20: + (self.native, self.mutable_l3, InequalityExpression((5, self.l3), False)), + ( + self.native, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.native, + self.le, + RangedExpression((5,) + self.le.args, (False, False)), + ), + ( + self.native, + self.lt, + RangedExpression((5,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.native, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_npv(self): + tests = [ + (self.npv, self.invalid, NotImplemented), + ( + self.npv, + self.asbinary, + InequalityExpression((self.npv, self.bin), False), + ), + (self.npv, self.zero, InequalityExpression((self.npv, 0), False)), + (self.npv, self.one, InequalityExpression((self.npv, 1), False)), + # 4: + (self.npv, self.native, InequalityExpression((self.npv, 5), False)), + (self.npv, self.npv, InequalityExpression((self.npv, self.npv), False)), + (self.npv, self.param, InequalityExpression((self.npv, 6), False)), + ( + self.npv, + self.param_mut, + InequalityExpression((self.npv, self.param_mut), False), + ), + # 8: + (self.npv, self.var, InequalityExpression((self.npv, self.var), False)), + ( + self.npv, + self.mon_native, + InequalityExpression((self.npv, self.mon_native), False), + ), + ( + self.npv, + self.mon_param, + InequalityExpression((self.npv, self.mon_param), False), + ), + ( + self.npv, + self.mon_npv, + InequalityExpression((self.npv, self.mon_npv), False), + ), + # 12: + ( + self.npv, + self.linear, + InequalityExpression((self.npv, self.linear), False), + ), + (self.npv, self.sum, InequalityExpression((self.npv, self.sum), False)), + (self.npv, self.other, InequalityExpression((self.npv, self.other), False)), + ( + self.npv, + self.mutable_l0, + InequalityExpression((self.npv, self.l0), False), + ), + # 16: + ( + self.npv, + self.mutable_l1, + InequalityExpression((self.npv, self.l1), False), + ), + ( + self.npv, + self.mutable_l2, + InequalityExpression((self.npv, self.l2), False), + ), + (self.npv, self.param0, InequalityExpression((self.npv, 0), False)), + (self.npv, self.param1, InequalityExpression((self.npv, 1), False)), + # 20: + ( + self.npv, + self.mutable_l3, + InequalityExpression((self.npv, self.l3), False), + ), + ( + self.npv, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.npv, + self.le, + RangedExpression((self.npv,) + self.le.args, (False, False)), + ), + ( + self.npv, + self.lt, + RangedExpression((self.npv,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.npv, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_param(self): + tests = [ + (self.param, self.invalid, NotImplemented), + (self.param, self.asbinary, InequalityExpression((6, self.bin), False)), + (self.param, self.zero, False), + (self.param, self.one, False), + # 4: + (self.param, self.native, False), + (self.param, self.npv, InequalityExpression((6, self.npv), False)), + (self.param, self.param, True), + ( + self.param, + self.param_mut, + InequalityExpression((6, self.param_mut), False), + ), + # 8: + (self.param, self.var, InequalityExpression((6, self.var), False)), + ( + self.param, + self.mon_native, + InequalityExpression((6, self.mon_native), False), + ), + ( + self.param, + self.mon_param, + InequalityExpression((6, self.mon_param), False), + ), + (self.param, self.mon_npv, InequalityExpression((6, self.mon_npv), False)), + # 12: + (self.param, self.linear, InequalityExpression((6, self.linear), False)), + (self.param, self.sum, InequalityExpression((6, self.sum), False)), + (self.param, self.other, InequalityExpression((6, self.other), False)), + (self.param, self.mutable_l0, False), + # 16: + (self.param, self.mutable_l1, InequalityExpression((6, self.l1), False)), + (self.param, self.mutable_l2, InequalityExpression((6, self.l2), False)), + (self.param, self.param0, False), + (self.param, self.param1, False), + # 20: + (self.param, self.mutable_l3, InequalityExpression((6, self.l3), False)), + ( + self.param, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param, + self.le, + RangedExpression((6,) + self.le.args, (False, False)), + ), + (self.param, self.lt, RangedExpression((6,) + self.lt.args, (False, True))), + # 24 + ( + self.param, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_param_mut(self): + tests = [ + (self.param_mut, self.invalid, NotImplemented), + ( + self.param_mut, + self.asbinary, + InequalityExpression((self.param_mut, self.bin), False), + ), + ( + self.param_mut, + self.zero, + InequalityExpression((self.param_mut, 0), False), + ), + ( + self.param_mut, + self.one, + InequalityExpression((self.param_mut, 1), False), + ), + # 4: + ( + self.param_mut, + self.native, + InequalityExpression((self.param_mut, 5), False), + ), + ( + self.param_mut, + self.npv, + InequalityExpression((self.param_mut, self.npv), False), + ), + ( + self.param_mut, + self.param, + InequalityExpression((self.param_mut, 6), False), + ), + ( + self.param_mut, + self.param_mut, + InequalityExpression((self.param_mut, self.param_mut), False), + ), + # 8: + ( + self.param_mut, + self.var, + InequalityExpression((self.param_mut, self.var), False), + ), + ( + self.param_mut, + self.mon_native, + InequalityExpression((self.param_mut, self.mon_native), False), + ), + ( + self.param_mut, + self.mon_param, + InequalityExpression((self.param_mut, self.mon_param), False), + ), + ( + self.param_mut, + self.mon_npv, + InequalityExpression((self.param_mut, self.mon_npv), False), + ), + # 12: + ( + self.param_mut, + self.linear, + InequalityExpression((self.param_mut, self.linear), False), + ), + ( + self.param_mut, + self.sum, + InequalityExpression((self.param_mut, self.sum), False), + ), + ( + self.param_mut, + self.other, + InequalityExpression((self.param_mut, self.other), False), + ), + ( + self.param_mut, + self.mutable_l0, + InequalityExpression((self.param_mut, self.l0), False), + ), + # 16: + ( + self.param_mut, + self.mutable_l1, + InequalityExpression((self.param_mut, self.l1), False), + ), + ( + self.param_mut, + self.mutable_l2, + InequalityExpression((self.param_mut, self.l2), False), + ), + ( + self.param_mut, + self.param0, + InequalityExpression((self.param_mut, 0), False), + ), + ( + self.param_mut, + self.param1, + InequalityExpression((self.param_mut, 1), False), + ), + # 20: + ( + self.param_mut, + self.mutable_l3, + InequalityExpression((self.param_mut, self.l3), False), + ), + ( + self.param_mut, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param_mut, + self.le, + RangedExpression((self.param_mut,) + self.le.args, (False, False)), + ), + ( + self.param_mut, + self.lt, + RangedExpression((self.param_mut,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.param_mut, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_var(self): + tests = [ + (self.var, self.invalid, NotImplemented), + ( + self.var, + self.asbinary, + InequalityExpression((self.var, self.bin), False), + ), + (self.var, self.zero, InequalityExpression((self.var, 0), False)), + (self.var, self.one, InequalityExpression((self.var, 1), False)), + # 4: + (self.var, self.native, InequalityExpression((self.var, 5), False)), + (self.var, self.npv, InequalityExpression((self.var, self.npv), False)), + (self.var, self.param, InequalityExpression((self.var, 6), False)), + ( + self.var, + self.param_mut, + InequalityExpression((self.var, self.param_mut), False), + ), + # 8: + (self.var, self.var, InequalityExpression((self.var, self.var), False)), + ( + self.var, + self.mon_native, + InequalityExpression((self.var, self.mon_native), False), + ), + ( + self.var, + self.mon_param, + InequalityExpression((self.var, self.mon_param), False), + ), + ( + self.var, + self.mon_npv, + InequalityExpression((self.var, self.mon_npv), False), + ), + # 12: + ( + self.var, + self.linear, + InequalityExpression((self.var, self.linear), False), + ), + (self.var, self.sum, InequalityExpression((self.var, self.sum), False)), + (self.var, self.other, InequalityExpression((self.var, self.other), False)), + ( + self.var, + self.mutable_l0, + InequalityExpression((self.var, self.l0), False), + ), + # 16: + ( + self.var, + self.mutable_l1, + InequalityExpression((self.var, self.l1), False), + ), + ( + self.var, + self.mutable_l2, + InequalityExpression((self.var, self.l2), False), + ), + (self.var, self.param0, InequalityExpression((self.var, 0), False)), + (self.var, self.param1, InequalityExpression((self.var, 1), False)), + # 20: + ( + self.var, + self.mutable_l3, + InequalityExpression((self.var, self.l3), False), + ), + ( + self.var, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.var, + self.le, + RangedExpression((self.var,) + self.le.args, (False, False)), + ), + ( + self.var, + self.lt, + RangedExpression((self.var,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.var, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_mon_native(self): + tests = [ + (self.mon_native, self.invalid, NotImplemented), + ( + self.mon_native, + self.asbinary, + InequalityExpression((self.mon_native, self.bin), False), + ), + ( + self.mon_native, + self.zero, + InequalityExpression((self.mon_native, 0), False), + ), + ( + self.mon_native, + self.one, + InequalityExpression((self.mon_native, 1), False), + ), + # 4: + ( + self.mon_native, + self.native, + InequalityExpression((self.mon_native, 5), False), + ), + ( + self.mon_native, + self.npv, + InequalityExpression((self.mon_native, self.npv), False), + ), + ( + self.mon_native, + self.param, + InequalityExpression((self.mon_native, 6), False), + ), + ( + self.mon_native, + self.param_mut, + InequalityExpression((self.mon_native, self.param_mut), False), + ), + # 8: + ( + self.mon_native, + self.var, + InequalityExpression((self.mon_native, self.var), False), + ), + ( + self.mon_native, + self.mon_native, + InequalityExpression((self.mon_native, self.mon_native), False), + ), + ( + self.mon_native, + self.mon_param, + InequalityExpression((self.mon_native, self.mon_param), False), + ), + ( + self.mon_native, + self.mon_npv, + InequalityExpression((self.mon_native, self.mon_npv), False), + ), + # 12: + ( + self.mon_native, + self.linear, + InequalityExpression((self.mon_native, self.linear), False), + ), + ( + self.mon_native, + self.sum, + InequalityExpression((self.mon_native, self.sum), False), + ), + ( + self.mon_native, + self.other, + InequalityExpression((self.mon_native, self.other), False), + ), + ( + self.mon_native, + self.mutable_l0, + InequalityExpression((self.mon_native, self.l0), False), + ), + # 16: + ( + self.mon_native, + self.mutable_l1, + InequalityExpression((self.mon_native, self.l1), False), + ), + ( + self.mon_native, + self.mutable_l2, + InequalityExpression((self.mon_native, self.l2), False), + ), + ( + self.mon_native, + self.param0, + InequalityExpression((self.mon_native, 0), False), + ), + ( + self.mon_native, + self.param1, + InequalityExpression((self.mon_native, 1), False), + ), + # 20: + ( + self.mon_native, + self.mutable_l3, + InequalityExpression((self.mon_native, self.l3), False), + ), + ( + self.mon_native, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_native, + self.le, + RangedExpression((self.mon_native,) + self.le.args, (False, False)), + ), + ( + self.mon_native, + self.lt, + RangedExpression((self.mon_native,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.mon_native, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_mon_param(self): + tests = [ + (self.mon_param, self.invalid, NotImplemented), + ( + self.mon_param, + self.asbinary, + InequalityExpression((self.mon_param, self.bin), False), + ), + ( + self.mon_param, + self.zero, + InequalityExpression((self.mon_param, 0), False), + ), + ( + self.mon_param, + self.one, + InequalityExpression((self.mon_param, 1), False), + ), + # 4: + ( + self.mon_param, + self.native, + InequalityExpression((self.mon_param, 5), False), + ), + ( + self.mon_param, + self.npv, + InequalityExpression((self.mon_param, self.npv), False), + ), + ( + self.mon_param, + self.param, + InequalityExpression((self.mon_param, 6), False), + ), + ( + self.mon_param, + self.param_mut, + InequalityExpression((self.mon_param, self.param_mut), False), + ), + # 8: + ( + self.mon_param, + self.var, + InequalityExpression((self.mon_param, self.var), False), + ), + ( + self.mon_param, + self.mon_native, + InequalityExpression((self.mon_param, self.mon_native), False), + ), + ( + self.mon_param, + self.mon_param, + InequalityExpression((self.mon_param, self.mon_param), False), + ), + ( + self.mon_param, + self.mon_npv, + InequalityExpression((self.mon_param, self.mon_npv), False), + ), + # 12: + ( + self.mon_param, + self.linear, + InequalityExpression((self.mon_param, self.linear), False), + ), + ( + self.mon_param, + self.sum, + InequalityExpression((self.mon_param, self.sum), False), + ), + ( + self.mon_param, + self.other, + InequalityExpression((self.mon_param, self.other), False), + ), + ( + self.mon_param, + self.mutable_l0, + InequalityExpression((self.mon_param, self.l0), False), + ), + # 16: + ( + self.mon_param, + self.mutable_l1, + InequalityExpression((self.mon_param, self.l1), False), + ), + ( + self.mon_param, + self.mutable_l2, + InequalityExpression((self.mon_param, self.l2), False), + ), + ( + self.mon_param, + self.param0, + InequalityExpression((self.mon_param, 0), False), + ), + ( + self.mon_param, + self.param1, + InequalityExpression((self.mon_param, 1), False), + ), + # 20: + ( + self.mon_param, + self.mutable_l3, + InequalityExpression((self.mon_param, self.l3), False), + ), + ( + self.mon_param, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_param, + self.le, + RangedExpression((self.mon_param,) + self.le.args, (False, False)), + ), + ( + self.mon_param, + self.lt, + RangedExpression((self.mon_param,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.mon_param, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_mon_npv(self): + tests = [ + (self.mon_npv, self.invalid, NotImplemented), + ( + self.mon_npv, + self.asbinary, + InequalityExpression((self.mon_npv, self.bin), False), + ), + (self.mon_npv, self.zero, InequalityExpression((self.mon_npv, 0), False)), + (self.mon_npv, self.one, InequalityExpression((self.mon_npv, 1), False)), + # 4: + (self.mon_npv, self.native, InequalityExpression((self.mon_npv, 5), False)), + ( + self.mon_npv, + self.npv, + InequalityExpression((self.mon_npv, self.npv), False), + ), + (self.mon_npv, self.param, InequalityExpression((self.mon_npv, 6), False)), + ( + self.mon_npv, + self.param_mut, + InequalityExpression((self.mon_npv, self.param_mut), False), + ), + # 8: + ( + self.mon_npv, + self.var, + InequalityExpression((self.mon_npv, self.var), False), + ), + ( + self.mon_npv, + self.mon_native, + InequalityExpression((self.mon_npv, self.mon_native), False), + ), + ( + self.mon_npv, + self.mon_param, + InequalityExpression((self.mon_npv, self.mon_param), False), + ), + ( + self.mon_npv, + self.mon_npv, + InequalityExpression((self.mon_npv, self.mon_npv), False), + ), + # 12: + ( + self.mon_npv, + self.linear, + InequalityExpression((self.mon_npv, self.linear), False), + ), + ( + self.mon_npv, + self.sum, + InequalityExpression((self.mon_npv, self.sum), False), + ), + ( + self.mon_npv, + self.other, + InequalityExpression((self.mon_npv, self.other), False), + ), + ( + self.mon_npv, + self.mutable_l0, + InequalityExpression((self.mon_npv, self.l0), False), + ), + # 16: + ( + self.mon_npv, + self.mutable_l1, + InequalityExpression((self.mon_npv, self.l1), False), + ), + ( + self.mon_npv, + self.mutable_l2, + InequalityExpression((self.mon_npv, self.l2), False), + ), + (self.mon_npv, self.param0, InequalityExpression((self.mon_npv, 0), False)), + (self.mon_npv, self.param1, InequalityExpression((self.mon_npv, 1), False)), + # 20: + ( + self.mon_npv, + self.mutable_l3, + InequalityExpression((self.mon_npv, self.l3), False), + ), + ( + self.mon_npv, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_npv, + self.le, + RangedExpression((self.mon_npv,) + self.le.args, (False, False)), + ), + ( + self.mon_npv, + self.lt, + RangedExpression((self.mon_npv,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.mon_npv, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_linear(self): + tests = [ + (self.linear, self.invalid, NotImplemented), + ( + self.linear, + self.asbinary, + InequalityExpression((self.linear, self.bin), False), + ), + (self.linear, self.zero, InequalityExpression((self.linear, 0), False)), + (self.linear, self.one, InequalityExpression((self.linear, 1), False)), + # 4: + (self.linear, self.native, InequalityExpression((self.linear, 5), False)), + ( + self.linear, + self.npv, + InequalityExpression((self.linear, self.npv), False), + ), + (self.linear, self.param, InequalityExpression((self.linear, 6), False)), + ( + self.linear, + self.param_mut, + InequalityExpression((self.linear, self.param_mut), False), + ), + # 8: + ( + self.linear, + self.var, + InequalityExpression((self.linear, self.var), False), + ), + ( + self.linear, + self.mon_native, + InequalityExpression((self.linear, self.mon_native), False), + ), + ( + self.linear, + self.mon_param, + InequalityExpression((self.linear, self.mon_param), False), + ), + ( + self.linear, + self.mon_npv, + InequalityExpression((self.linear, self.mon_npv), False), + ), + # 12: + ( + self.linear, + self.linear, + InequalityExpression((self.linear, self.linear), False), + ), + ( + self.linear, + self.sum, + InequalityExpression((self.linear, self.sum), False), + ), + ( + self.linear, + self.other, + InequalityExpression((self.linear, self.other), False), + ), + ( + self.linear, + self.mutable_l0, + InequalityExpression((self.linear, self.l0), False), + ), + # 16: + ( + self.linear, + self.mutable_l1, + InequalityExpression((self.linear, self.l1), False), + ), + ( + self.linear, + self.mutable_l2, + InequalityExpression((self.linear, self.l2), False), + ), + (self.linear, self.param0, InequalityExpression((self.linear, 0), False)), + (self.linear, self.param1, InequalityExpression((self.linear, 1), False)), + # 20: + ( + self.linear, + self.mutable_l3, + InequalityExpression((self.linear, self.l3), False), + ), + ( + self.linear, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.linear, + self.le, + RangedExpression(((self.linear,) + self.le.args), (False, False)), + ), + ( + self.linear, + self.lt, + RangedExpression(((self.linear,) + self.lt.args), (False, True)), + ), + # 24 + ( + self.linear, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_sum(self): + tests = [ + (self.sum, self.invalid, NotImplemented), + ( + self.sum, + self.asbinary, + InequalityExpression((self.sum, self.bin), False), + ), + (self.sum, self.zero, InequalityExpression((self.sum, 0), False)), + (self.sum, self.one, InequalityExpression((self.sum, 1), False)), + # 4: + (self.sum, self.native, InequalityExpression((self.sum, 5), False)), + (self.sum, self.npv, InequalityExpression((self.sum, self.npv), False)), + (self.sum, self.param, InequalityExpression((self.sum, 6), False)), + ( + self.sum, + self.param_mut, + InequalityExpression((self.sum, self.param_mut), False), + ), + # 8: + (self.sum, self.var, InequalityExpression((self.sum, self.var), False)), + ( + self.sum, + self.mon_native, + InequalityExpression((self.sum, self.mon_native), False), + ), + ( + self.sum, + self.mon_param, + InequalityExpression((self.sum, self.mon_param), False), + ), + ( + self.sum, + self.mon_npv, + InequalityExpression((self.sum, self.mon_npv), False), + ), + # 12: + ( + self.sum, + self.linear, + InequalityExpression((self.sum, self.linear), False), + ), + (self.sum, self.sum, InequalityExpression((self.sum, self.sum), False)), + (self.sum, self.other, InequalityExpression((self.sum, self.other), False)), + ( + self.sum, + self.mutable_l0, + InequalityExpression((self.sum, self.l0), False), + ), + # 16: + ( + self.sum, + self.mutable_l1, + InequalityExpression((self.sum, self.l1), False), + ), + ( + self.sum, + self.mutable_l2, + InequalityExpression((self.sum, self.l2), False), + ), + (self.sum, self.param0, InequalityExpression((self.sum, 0), False)), + (self.sum, self.param1, InequalityExpression((self.sum, 1), False)), + # 20: + ( + self.sum, + self.mutable_l3, + InequalityExpression((self.sum, self.l3), False), + ), + ( + self.sum, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.sum, + self.le, + RangedExpression((self.sum,) + self.le.args, (False, False)), + ), + ( + self.sum, + self.lt, + RangedExpression((self.sum,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.sum, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_other(self): + tests = [ + (self.other, self.invalid, NotImplemented), + ( + self.other, + self.asbinary, + InequalityExpression((self.other, self.bin), False), + ), + (self.other, self.zero, InequalityExpression((self.other, 0), False)), + (self.other, self.one, InequalityExpression((self.other, 1), False)), + # 4: + (self.other, self.native, InequalityExpression((self.other, 5), False)), + (self.other, self.npv, InequalityExpression((self.other, self.npv), False)), + (self.other, self.param, InequalityExpression((self.other, 6), False)), + ( + self.other, + self.param_mut, + InequalityExpression((self.other, self.param_mut), False), + ), + # 8: + (self.other, self.var, InequalityExpression((self.other, self.var), False)), + ( + self.other, + self.mon_native, + InequalityExpression((self.other, self.mon_native), False), + ), + ( + self.other, + self.mon_param, + InequalityExpression((self.other, self.mon_param), False), + ), + ( + self.other, + self.mon_npv, + InequalityExpression((self.other, self.mon_npv), False), + ), + # 12: + ( + self.other, + self.linear, + InequalityExpression((self.other, self.linear), False), + ), + (self.other, self.sum, InequalityExpression((self.other, self.sum), False)), + ( + self.other, + self.other, + InequalityExpression((self.other, self.other), False), + ), + ( + self.other, + self.mutable_l0, + InequalityExpression((self.other, self.l0), False), + ), + # 16: + ( + self.other, + self.mutable_l1, + InequalityExpression((self.other, self.l1), False), + ), + ( + self.other, + self.mutable_l2, + InequalityExpression((self.other, self.l2), False), + ), + (self.other, self.param0, InequalityExpression((self.other, 0), False)), + (self.other, self.param1, InequalityExpression((self.other, 1), False)), + # 20: + ( + self.other, + self.mutable_l3, + InequalityExpression((self.other, self.l3), False), + ), + ( + self.other, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.other, + self.le, + RangedExpression((self.other,) + self.le.args, (False, False)), + ), + ( + self.other, + self.lt, + RangedExpression((self.other,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.other, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_mutable_l0(self): + tests = [ + (self.mutable_l0, self.invalid, NotImplemented), + ( + self.mutable_l0, + self.asbinary, + InequalityExpression((self.l0, self.bin), False), + ), + (self.mutable_l0, self.zero, True), + (self.mutable_l0, self.one, True), + # 4: + (self.mutable_l0, self.native, True), + ( + self.mutable_l0, + self.npv, + InequalityExpression((self.l0, self.npv), False), + ), + (self.mutable_l0, self.param, True), + ( + self.mutable_l0, + self.param_mut, + InequalityExpression((self.l0, self.param_mut), False), + ), + # 8: + ( + self.mutable_l0, + self.var, + InequalityExpression((self.l0, self.var), False), + ), + ( + self.mutable_l0, + self.mon_native, + InequalityExpression((self.l0, self.mon_native), False), + ), + ( + self.mutable_l0, + self.mon_param, + InequalityExpression((self.l0, self.mon_param), False), + ), + ( + self.mutable_l0, + self.mon_npv, + InequalityExpression((self.l0, self.mon_npv), False), + ), + # 12: + ( + self.mutable_l0, + self.linear, + InequalityExpression((self.l0, self.linear), False), + ), + ( + self.mutable_l0, + self.sum, + InequalityExpression((self.l0, self.sum), False), + ), + ( + self.mutable_l0, + self.other, + InequalityExpression((self.l0, self.other), False), + ), + (self.mutable_l0, self.mutable_l0, True), + # 16: + ( + self.mutable_l0, + self.mutable_l1, + InequalityExpression((self.l0, self.l1), False), + ), + ( + self.mutable_l0, + self.mutable_l2, + InequalityExpression((self.l0, self.l2), False), + ), + (self.mutable_l0, self.param0, True), + (self.mutable_l0, self.param1, True), + # 20: + ( + self.mutable_l0, + self.mutable_l3, + InequalityExpression((self.l0, self.l3), False), + ), + ( + self.mutable_l0, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l0, + self.le, + RangedExpression((self.l0,) + self.le.args, (False, False)), + ), + ( + self.mutable_l0, + self.lt, + RangedExpression((self.l0,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.mutable_l0, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_mutable_l1(self): + tests = [ + (self.mutable_l1, self.invalid, NotImplemented), + ( + self.mutable_l1, + self.asbinary, + InequalityExpression((self.l1, self.bin), False), + ), + (self.mutable_l1, self.zero, InequalityExpression((self.l1, 0), False)), + (self.mutable_l1, self.one, InequalityExpression((self.l1, 1), False)), + # 4: + (self.mutable_l1, self.native, InequalityExpression((self.l1, 5), False)), + ( + self.mutable_l1, + self.npv, + InequalityExpression((self.l1, self.npv), False), + ), + (self.mutable_l1, self.param, InequalityExpression((self.l1, 6), False)), + ( + self.mutable_l1, + self.param_mut, + InequalityExpression((self.l1, self.param_mut), False), + ), + # 8: + ( + self.mutable_l1, + self.var, + InequalityExpression((self.l1, self.var), False), + ), + ( + self.mutable_l1, + self.mon_native, + InequalityExpression((self.l1, self.mon_native), False), + ), + ( + self.mutable_l1, + self.mon_param, + InequalityExpression((self.l1, self.mon_param), False), + ), + ( + self.mutable_l1, + self.mon_npv, + InequalityExpression((self.l1, self.mon_npv), False), + ), + # 12: + ( + self.mutable_l1, + self.linear, + InequalityExpression((self.l1, self.linear), False), + ), + ( + self.mutable_l1, + self.sum, + InequalityExpression((self.l1, self.sum), False), + ), + ( + self.mutable_l1, + self.other, + InequalityExpression((self.l1, self.other), False), + ), + ( + self.mutable_l1, + self.mutable_l0, + InequalityExpression((self.l1, self.l0), False), + ), + # 16: + ( + self.mutable_l1, + self.mutable_l1, + InequalityExpression((self.l1, self.l1), False), + ), + ( + self.mutable_l1, + self.mutable_l2, + InequalityExpression((self.l1, self.l2), False), + ), + (self.mutable_l1, self.param0, InequalityExpression((self.l1, 0), False)), + (self.mutable_l1, self.param1, InequalityExpression((self.l1, 1), False)), + # 20: + ( + self.mutable_l1, + self.mutable_l3, + InequalityExpression((self.l1, self.l3), False), + ), + ( + self.mutable_l1, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l1, + self.le, + RangedExpression((self.l1,) + self.le.args, (False, False)), + ), + ( + self.mutable_l1, + self.lt, + RangedExpression((self.l1,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.mutable_l1, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_mutable_l2(self): + tests = [ + (self.mutable_l2, self.invalid, NotImplemented), + ( + self.mutable_l2, + self.asbinary, + InequalityExpression((self.l2, self.bin), False), + ), + (self.mutable_l2, self.zero, InequalityExpression((self.l2, 0), False)), + (self.mutable_l2, self.one, InequalityExpression((self.l2, 1), False)), + # 4: + (self.mutable_l2, self.native, InequalityExpression((self.l2, 5), False)), + ( + self.mutable_l2, + self.npv, + InequalityExpression((self.l2, self.npv), False), + ), + (self.mutable_l2, self.param, InequalityExpression((self.l2, 6), False)), + ( + self.mutable_l2, + self.param_mut, + InequalityExpression((self.l2, self.param_mut), False), + ), + # 8: + ( + self.mutable_l2, + self.var, + InequalityExpression((self.l2, self.var), False), + ), + ( + self.mutable_l2, + self.mon_native, + InequalityExpression((self.l2, self.mon_native), False), + ), + ( + self.mutable_l2, + self.mon_param, + InequalityExpression((self.l2, self.mon_param), False), + ), + ( + self.mutable_l2, + self.mon_npv, + InequalityExpression((self.l2, self.mon_npv), False), + ), + # 12: + ( + self.mutable_l2, + self.linear, + InequalityExpression((self.l2, self.linear), False), + ), + ( + self.mutable_l2, + self.sum, + InequalityExpression((self.l2, self.sum), False), + ), + ( + self.mutable_l2, + self.other, + InequalityExpression((self.l2, self.other), False), + ), + ( + self.mutable_l2, + self.mutable_l0, + InequalityExpression((self.l2, self.l0), False), + ), + # 16: + ( + self.mutable_l2, + self.mutable_l1, + InequalityExpression((self.l2, self.l1), False), + ), + ( + self.mutable_l2, + self.mutable_l2, + InequalityExpression((self.l2, self.l2), False), + ), + (self.mutable_l2, self.param0, InequalityExpression((self.l2, 0), False)), + (self.mutable_l2, self.param1, InequalityExpression((self.l2, 1), False)), + # 20: + ( + self.mutable_l2, + self.mutable_l3, + InequalityExpression((self.l2, self.l3), False), + ), + ( + self.mutable_l2, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l2, + self.le, + RangedExpression((self.l2,) + self.le.args, (False, False)), + ), + ( + self.mutable_l2, + self.lt, + RangedExpression((self.l2,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.mutable_l2, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_param0(self): + tests = [ + (self.param0, self.invalid, NotImplemented), + (self.param0, self.asbinary, InequalityExpression((0, self.bin), False)), + (self.param0, self.zero, True), + (self.param0, self.one, True), + # 4: + (self.param0, self.native, True), + (self.param0, self.npv, InequalityExpression((0, self.npv), False)), + (self.param0, self.param, True), + ( + self.param0, + self.param_mut, + InequalityExpression((0, self.param_mut), False), + ), + # 8: + (self.param0, self.var, InequalityExpression((0, self.var), False)), + ( + self.param0, + self.mon_native, + InequalityExpression((0, self.mon_native), False), + ), + ( + self.param0, + self.mon_param, + InequalityExpression((0, self.mon_param), False), + ), + (self.param0, self.mon_npv, InequalityExpression((0, self.mon_npv), False)), + # 12: + (self.param0, self.linear, InequalityExpression((0, self.linear), False)), + (self.param0, self.sum, InequalityExpression((0, self.sum), False)), + (self.param0, self.other, InequalityExpression((0, self.other), False)), + (self.param0, self.mutable_l0, True), + # 16: + (self.param0, self.mutable_l1, InequalityExpression((0, self.l1), False)), + (self.param0, self.mutable_l2, InequalityExpression((0, self.l2), False)), + (self.param0, self.param0, True), + (self.param0, self.param1, True), + # 20: + (self.param0, self.mutable_l3, InequalityExpression((0, self.l3), False)), + ( + self.param0, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param0, + self.le, + RangedExpression((0,) + self.le.args, (False, False)), + ), + ( + self.param0, + self.lt, + RangedExpression((0,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.param0, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_param1(self): + tests = [ + (self.param1, self.invalid, NotImplemented), + (self.param1, self.asbinary, InequalityExpression((1, self.bin), False)), + (self.param1, self.zero, False), + (self.param1, self.one, True), + # 4: + (self.param1, self.native, True), + (self.param1, self.npv, InequalityExpression((1, self.npv), False)), + (self.param1, self.param, True), + ( + self.param1, + self.param_mut, + InequalityExpression((1, self.param_mut), False), + ), + # 8: + (self.param1, self.var, InequalityExpression((1, self.var), False)), + ( + self.param1, + self.mon_native, + InequalityExpression((1, self.mon_native), False), + ), + ( + self.param1, + self.mon_param, + InequalityExpression((1, self.mon_param), False), + ), + (self.param1, self.mon_npv, InequalityExpression((1, self.mon_npv), False)), + # 12: + (self.param1, self.linear, InequalityExpression((1, self.linear), False)), + (self.param1, self.sum, InequalityExpression((1, self.sum), False)), + (self.param1, self.other, InequalityExpression((1, self.other), False)), + (self.param1, self.mutable_l0, False), + # 16: + (self.param1, self.mutable_l1, InequalityExpression((1, self.l1), False)), + (self.param1, self.mutable_l2, InequalityExpression((1, self.l2), False)), + (self.param1, self.param0, False), + (self.param1, self.param1, True), + # 20: + (self.param1, self.mutable_l3, InequalityExpression((1, self.l3), False)), + ( + self.param1, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param1, + self.le, + RangedExpression((1,) + self.le.args, (False, False)), + ), + ( + self.param1, + self.lt, + RangedExpression((1,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.param1, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_mutable_l3(self): + tests = [ + (self.mutable_l3, self.invalid, NotImplemented), + ( + self.mutable_l3, + self.asbinary, + InequalityExpression((self.l3, self.bin), False), + ), + (self.mutable_l3, self.zero, InequalityExpression((self.l3, 0), False)), + (self.mutable_l3, self.one, InequalityExpression((self.l3, 1), False)), + # 4: + (self.mutable_l3, self.native, InequalityExpression((self.l3, 5), False)), + ( + self.mutable_l3, + self.npv, + InequalityExpression((self.l3, self.npv), False), + ), + (self.mutable_l3, self.param, InequalityExpression((self.l3, 6), False)), + ( + self.mutable_l3, + self.param_mut, + InequalityExpression((self.l3, self.param_mut), False), + ), + # 8: + ( + self.mutable_l3, + self.var, + InequalityExpression((self.l3, self.var), False), + ), + ( + self.mutable_l3, + self.mon_native, + InequalityExpression((self.l3, self.mon_native), False), + ), + ( + self.mutable_l3, + self.mon_param, + InequalityExpression((self.l3, self.mon_param), False), + ), + ( + self.mutable_l3, + self.mon_npv, + InequalityExpression((self.l3, self.mon_npv), False), + ), + # 12: + ( + self.mutable_l3, + self.linear, + InequalityExpression((self.l3, self.linear), False), + ), + ( + self.mutable_l3, + self.sum, + InequalityExpression((self.l3, self.sum), False), + ), + ( + self.mutable_l3, + self.other, + InequalityExpression((self.l3, self.other), False), + ), + ( + self.mutable_l3, + self.mutable_l0, + InequalityExpression((self.l3, self.l0), False), + ), + # 16: + ( + self.mutable_l3, + self.mutable_l1, + InequalityExpression((self.l3, self.l1), False), + ), + ( + self.mutable_l3, + self.mutable_l2, + InequalityExpression((self.l3, self.l2), False), + ), + (self.mutable_l3, self.param0, InequalityExpression((self.l3, 0), False)), + (self.mutable_l3, self.param1, InequalityExpression((self.l3, 1), False)), + # 20: + ( + self.mutable_l3, + self.mutable_l3, + InequalityExpression((self.l3, self.l3), False), + ), + ( + self.mutable_l3, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l3, + self.le, + RangedExpression((self.l3,) + self.le.args, (False, False)), + ), + ( + self.mutable_l3, + self.lt, + RangedExpression((self.l3,) + self.lt.args, (False, True)), + ), + # 24 + ( + self.mutable_l3, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_eq(self): + tests = [ + (self.eq, self.invalid, NotImplemented), + ( + self.eq, + self.asbinary, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.zero, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.one, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 4: + ( + self.eq, + self.native, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.npv, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param_mut, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 8: + ( + self.eq, + self.var, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_native, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_param, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_npv, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 12: + ( + self.eq, + self.linear, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.sum, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.other, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mutable_l0, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 16: + ( + self.eq, + self.mutable_l1, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mutable_l2, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param0, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param1, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 20: + ( + self.eq, + self.mutable_l3, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.eq, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.eq, + self.le, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.eq, + self.lt, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.eq, + self.ranged, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_le(self): + tests = [ + (self.le, self.invalid, NotImplemented), + ( + self.le, + self.asbinary, + RangedExpression((self.le.args + (self.bin,)), (False, False)), + ), + ( + self.le, + self.zero, + RangedExpression((self.le.args + (0,)), (False, False)), + ), + ( + self.le, + self.one, + RangedExpression((self.le.args + (1,)), (False, False)), + ), + # 4: + ( + self.le, + self.native, + RangedExpression((self.le.args + (5,)), (False, False)), + ), + ( + self.le, + self.npv, + RangedExpression((self.le.args + (self.npv,)), (False, False)), + ), + ( + self.le, + self.param, + RangedExpression((self.le.args + (6,)), (False, False)), + ), + ( + self.le, + self.param_mut, + RangedExpression((self.le.args + (self.param_mut,)), (False, False)), + ), + # 8: + ( + self.le, + self.var, + RangedExpression((self.le.args + (self.var,)), (False, False)), + ), + ( + self.le, + self.mon_native, + RangedExpression((self.le.args + (self.mon_native,)), (False, False)), + ), + ( + self.le, + self.mon_param, + RangedExpression((self.le.args + (self.mon_param,)), (False, False)), + ), + ( + self.le, + self.mon_npv, + RangedExpression((self.le.args + (self.mon_npv,)), (False, False)), + ), + # 12: + ( + self.le, + self.linear, + RangedExpression((self.le.args + (self.linear,)), (False, False)), + ), + ( + self.le, + self.sum, + RangedExpression((self.le.args + (self.sum,)), (False, False)), + ), + ( + self.le, + self.other, + RangedExpression((self.le.args + (self.other,)), (False, False)), + ), + ( + self.le, + self.mutable_l0, + RangedExpression((self.le.args + (self.l0,)), (False, False)), + ), + # 16: + ( + self.le, + self.mutable_l1, + RangedExpression((self.le.args + (self.l1,)), (False, False)), + ), + ( + self.le, + self.mutable_l2, + RangedExpression((self.le.args + (self.l2,)), (False, False)), + ), + ( + self.le, + self.param0, + RangedExpression((self.le.args + (0,)), (False, False)), + ), + ( + self.le, + self.param1, + RangedExpression((self.le.args + (1,)), (False, False)), + ), + # 20: + ( + self.le, + self.mutable_l3, + RangedExpression((self.le.args + (self.l3,)), (False, False)), + ), + ( + self.le, + self.eq, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.le, + self.le, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.le, + self.lt, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.le, + self.ranged, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_lt(self): + tests = [ + (self.lt, self.invalid, NotImplemented), + ( + self.lt, + self.asbinary, + RangedExpression((self.lt.args + (self.bin,)), (True, False)), + ), + ( + self.lt, + self.zero, + RangedExpression((self.lt.args + (0,)), (True, False)), + ), + (self.lt, self.one, RangedExpression((self.lt.args + (1,)), (True, False))), + # 4: + ( + self.lt, + self.native, + RangedExpression((self.lt.args + (5,)), (True, False)), + ), + ( + self.lt, + self.npv, + RangedExpression((self.lt.args + (self.npv,)), (True, False)), + ), + ( + self.lt, + self.param, + RangedExpression((self.lt.args + (6,)), (True, False)), + ), + ( + self.lt, + self.param_mut, + RangedExpression((self.lt.args + (self.param_mut,)), (True, False)), + ), + # 8: + ( + self.lt, + self.var, + RangedExpression((self.lt.args + (self.var,)), (True, False)), + ), + ( + self.lt, + self.mon_native, + RangedExpression((self.lt.args + (self.mon_native,)), (True, False)), + ), + ( + self.lt, + self.mon_param, + RangedExpression((self.lt.args + (self.mon_param,)), (True, False)), + ), + ( + self.lt, + self.mon_npv, + RangedExpression((self.lt.args + (self.mon_npv,)), (True, False)), + ), + # 12: + ( + self.lt, + self.linear, + RangedExpression((self.lt.args + (self.linear,)), (True, False)), + ), + ( + self.lt, + self.sum, + RangedExpression((self.lt.args + (self.sum,)), (True, False)), + ), + ( + self.lt, + self.other, + RangedExpression((self.lt.args + (self.other,)), (True, False)), + ), + ( + self.lt, + self.mutable_l0, + RangedExpression((self.lt.args + (self.l0,)), (True, False)), + ), + # 16: + ( + self.lt, + self.mutable_l1, + RangedExpression((self.lt.args + (self.l1,)), (True, False)), + ), + ( + self.lt, + self.mutable_l2, + RangedExpression((self.lt.args + (self.l2,)), (True, False)), + ), + ( + self.lt, + self.param0, + RangedExpression((self.lt.args + (0,)), (True, False)), + ), + ( + self.lt, + self.param1, + RangedExpression((self.lt.args + (1,)), (True, False)), + ), + # 20: + ( + self.lt, + self.mutable_l3, + RangedExpression((self.lt.args + (self.l3,)), (True, False)), + ), + ( + self.lt, + self.eq, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.lt, + self.le, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.lt, + self.lt, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.lt, + self.ranged, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.le) + + def test_le_ranged(self): + tests = [ + (self.ranged, self.invalid, NotImplemented), + ( + self.ranged, + self.asbinary, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.zero, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.one, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 4: + ( + self.ranged, + self.native, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.npv, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param_mut, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 8: + ( + self.ranged, + self.var, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_native, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_param, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_npv, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 12: + ( + self.ranged, + self.linear, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.sum, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.other, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mutable_l0, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 16: + ( + self.ranged, + self.mutable_l1, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mutable_l2, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param0, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param1, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 20: + ( + self.ranged, + self.mutable_l3, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.eq, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.ranged, + self.le, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.ranged, + self.lt, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.ranged, + self.ranged, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.le) + + +# +# +# INEQUALITY (strict) +# +# + + +class TestStrictInequality(BaseRelational, unittest.TestCase): + + def test_lt_invalid(self): + tests = [ + # "invalid(str) == invalid(str)" is a legitimate Python + # operation and should never hit the Pyomo expression + # system + (self.invalid, self.invalid, False), + (self.invalid, self.asbinary, NotImplemented), + (self.invalid, self.zero, NotImplemented), + (self.invalid, self.one, NotImplemented), + # 4: + (self.invalid, self.native, NotImplemented), + (self.invalid, self.npv, NotImplemented), + (self.invalid, self.param, NotImplemented), + (self.invalid, self.param_mut, NotImplemented), + # 8: + (self.invalid, self.var, NotImplemented), + (self.invalid, self.mon_native, NotImplemented), + (self.invalid, self.mon_param, NotImplemented), + (self.invalid, self.mon_npv, NotImplemented), + # 12: + (self.invalid, self.linear, NotImplemented), + (self.invalid, self.sum, NotImplemented), + (self.invalid, self.other, NotImplemented), + (self.invalid, self.mutable_l0, NotImplemented), + # 16: + (self.invalid, self.mutable_l1, NotImplemented), + (self.invalid, self.mutable_l2, NotImplemented), + (self.invalid, self.param0, NotImplemented), + (self.invalid, self.param1, NotImplemented), + # 20: + (self.invalid, self.mutable_l3, NotImplemented), + (self.invalid, self.eq, NotImplemented), + (self.invalid, self.le, NotImplemented), + (self.invalid, self.lt, NotImplemented), + # 24: + (self.invalid, self.ranged, NotImplemented), + ] + self._run_cases(tests, operator.lt) + + def test_lt_asbinary(self): + tests = [ + (self.asbinary, self.invalid, NotImplemented), + ( + self.asbinary, + self.asbinary, + InequalityExpression((self.bin, self.bin), True), + ), + (self.asbinary, self.zero, InequalityExpression((self.bin, 0), True)), + (self.asbinary, self.one, InequalityExpression((self.bin, 1), True)), + # 4: + (self.asbinary, self.native, InequalityExpression((self.bin, 5), True)), + (self.asbinary, self.npv, InequalityExpression((self.bin, self.npv), True)), + (self.asbinary, self.param, InequalityExpression((self.bin, 6), True)), + ( + self.asbinary, + self.param_mut, + InequalityExpression((self.bin, self.param_mut), True), + ), + # 8: + (self.asbinary, self.var, InequalityExpression((self.bin, self.var), True)), + ( + self.asbinary, + self.mon_native, + InequalityExpression((self.bin, self.mon_native), True), + ), + ( + self.asbinary, + self.mon_param, + InequalityExpression((self.bin, self.mon_param), True), + ), + ( + self.asbinary, + self.mon_npv, + InequalityExpression((self.bin, self.mon_npv), True), + ), + # 12: + ( + self.asbinary, + self.linear, + InequalityExpression((self.bin, self.linear), True), + ), + (self.asbinary, self.sum, InequalityExpression((self.bin, self.sum), True)), + ( + self.asbinary, + self.other, + InequalityExpression((self.bin, self.other), True), + ), + ( + self.asbinary, + self.mutable_l0, + InequalityExpression((self.bin, self.l0), True), + ), + # 16: + ( + self.asbinary, + self.mutable_l1, + InequalityExpression((self.bin, self.l1), True), + ), + ( + self.asbinary, + self.mutable_l2, + InequalityExpression((self.bin, self.l2), True), + ), + (self.asbinary, self.param0, InequalityExpression((self.bin, 0), True)), + (self.asbinary, self.param1, InequalityExpression((self.bin, 1), True)), + # 20: + ( + self.asbinary, + self.mutable_l3, + InequalityExpression((self.bin, self.l3), True), + ), + ( + self.asbinary, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.asbinary, + self.le, + RangedExpression((self.bin,) + self.le.args, (True, False)), + ), + ( + self.asbinary, + self.lt, + RangedExpression((self.bin,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.asbinary, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_zero(self): + tests = [ + (self.zero, self.invalid, NotImplemented), + (self.zero, self.asbinary, InequalityExpression((0, self.bin), True)), + (self.zero, self.zero, False), + (self.zero, self.one, True), + # 4: + (self.zero, self.native, True), + (self.zero, self.npv, InequalityExpression((0, self.npv), True)), + (self.zero, self.param, True), + ( + self.zero, + self.param_mut, + InequalityExpression((0, self.param_mut), True), + ), + # 8: + (self.zero, self.var, InequalityExpression((0, self.var), True)), + ( + self.zero, + self.mon_native, + InequalityExpression((0, self.mon_native), True), + ), + ( + self.zero, + self.mon_param, + InequalityExpression((0, self.mon_param), True), + ), + (self.zero, self.mon_npv, InequalityExpression((0, self.mon_npv), True)), + # 12: + (self.zero, self.linear, InequalityExpression((0, self.linear), True)), + (self.zero, self.sum, InequalityExpression((0, self.sum), True)), + (self.zero, self.other, InequalityExpression((0, self.other), True)), + (self.zero, self.mutable_l0, False), + # 16: + (self.zero, self.mutable_l1, InequalityExpression((0, self.l1), True)), + (self.zero, self.mutable_l2, InequalityExpression((0, self.l2), True)), + (self.zero, self.param0, False), + (self.zero, self.param1, True), + # 20: + (self.zero, self.mutable_l3, InequalityExpression((0, self.l3), True)), + ( + self.zero, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + (self.zero, self.le, RangedExpression((0,) + self.le.args, (True, False))), + (self.zero, self.lt, RangedExpression((0,) + self.lt.args, (True, True))), + # 24 + ( + self.zero, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_one(self): + tests = [ + (self.one, self.invalid, NotImplemented), + (self.one, self.asbinary, InequalityExpression((1, self.bin), True)), + (self.one, self.zero, False), + (self.one, self.one, False), + # 4: + (self.one, self.native, True), + (self.one, self.npv, InequalityExpression((1, self.npv), True)), + (self.one, self.param, True), + (self.one, self.param_mut, InequalityExpression((1, self.param_mut), True)), + # 8: + (self.one, self.var, InequalityExpression((1, self.var), True)), + ( + self.one, + self.mon_native, + InequalityExpression((1, self.mon_native), True), + ), + (self.one, self.mon_param, InequalityExpression((1, self.mon_param), True)), + (self.one, self.mon_npv, InequalityExpression((1, self.mon_npv), True)), + # 12: + (self.one, self.linear, InequalityExpression((1, self.linear), True)), + (self.one, self.sum, InequalityExpression((1, self.sum), True)), + (self.one, self.other, InequalityExpression((1, self.other), True)), + (self.one, self.mutable_l0, False), + # 16: + (self.one, self.mutable_l1, InequalityExpression((1, self.l1), True)), + (self.one, self.mutable_l2, InequalityExpression((1, self.l2), True)), + (self.one, self.param0, False), + (self.one, self.param1, False), + # 20: + (self.one, self.mutable_l3, InequalityExpression((1, self.l3), True)), + ( + self.one, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + (self.one, self.le, RangedExpression((1,) + self.le.args, (True, False))), + (self.one, self.lt, RangedExpression((1,) + self.lt.args, (True, True))), + # 24 + ( + self.one, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_native(self): + tests = [ + (self.native, self.invalid, NotImplemented), + (self.native, self.asbinary, InequalityExpression((5, self.bin), True)), + (self.native, self.zero, False), + (self.native, self.one, False), + # 4: + (self.native, self.native, False), + (self.native, self.npv, InequalityExpression((5, self.npv), True)), + (self.native, self.param, True), + ( + self.native, + self.param_mut, + InequalityExpression((5, self.param_mut), True), + ), + # 8: + (self.native, self.var, InequalityExpression((5, self.var), True)), + ( + self.native, + self.mon_native, + InequalityExpression((5, self.mon_native), True), + ), + ( + self.native, + self.mon_param, + InequalityExpression((5, self.mon_param), True), + ), + (self.native, self.mon_npv, InequalityExpression((5, self.mon_npv), True)), + # 12: + (self.native, self.linear, InequalityExpression((5, self.linear), True)), + (self.native, self.sum, InequalityExpression((5, self.sum), True)), + (self.native, self.other, InequalityExpression((5, self.other), True)), + (self.native, self.mutable_l0, False), + # 16: + (self.native, self.mutable_l1, InequalityExpression((5, self.l1), True)), + (self.native, self.mutable_l2, InequalityExpression((5, self.l2), True)), + (self.native, self.param0, False), + (self.native, self.param1, False), + # 20: + (self.native, self.mutable_l3, InequalityExpression((5, self.l3), True)), + ( + self.native, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.native, + self.le, + RangedExpression((5,) + self.le.args, (True, False)), + ), + (self.native, self.lt, RangedExpression((5,) + self.lt.args, (True, True))), + # 24 + ( + self.native, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_npv(self): + tests = [ + (self.npv, self.invalid, NotImplemented), + (self.npv, self.asbinary, InequalityExpression((self.npv, self.bin), True)), + (self.npv, self.zero, InequalityExpression((self.npv, 0), True)), + (self.npv, self.one, InequalityExpression((self.npv, 1), True)), + # 4: + (self.npv, self.native, InequalityExpression((self.npv, 5), True)), + (self.npv, self.npv, InequalityExpression((self.npv, self.npv), True)), + (self.npv, self.param, InequalityExpression((self.npv, 6), True)), + ( + self.npv, + self.param_mut, + InequalityExpression((self.npv, self.param_mut), True), + ), + # 8: + (self.npv, self.var, InequalityExpression((self.npv, self.var), True)), + ( + self.npv, + self.mon_native, + InequalityExpression((self.npv, self.mon_native), True), + ), + ( + self.npv, + self.mon_param, + InequalityExpression((self.npv, self.mon_param), True), + ), + ( + self.npv, + self.mon_npv, + InequalityExpression((self.npv, self.mon_npv), True), + ), + # 12: + ( + self.npv, + self.linear, + InequalityExpression((self.npv, self.linear), True), + ), + (self.npv, self.sum, InequalityExpression((self.npv, self.sum), True)), + (self.npv, self.other, InequalityExpression((self.npv, self.other), True)), + ( + self.npv, + self.mutable_l0, + InequalityExpression((self.npv, self.l0), True), + ), + # 16: + ( + self.npv, + self.mutable_l1, + InequalityExpression((self.npv, self.l1), True), + ), + ( + self.npv, + self.mutable_l2, + InequalityExpression((self.npv, self.l2), True), + ), + (self.npv, self.param0, InequalityExpression((self.npv, 0), True)), + (self.npv, self.param1, InequalityExpression((self.npv, 1), True)), + # 20: + ( + self.npv, + self.mutable_l3, + InequalityExpression((self.npv, self.l3), True), + ), + ( + self.npv, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.npv, + self.le, + RangedExpression((self.npv,) + self.le.args, (True, False)), + ), + ( + self.npv, + self.lt, + RangedExpression((self.npv,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.npv, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_param(self): + tests = [ + (self.param, self.invalid, NotImplemented), + (self.param, self.asbinary, InequalityExpression((6, self.bin), True)), + (self.param, self.zero, False), + (self.param, self.one, False), + # 4: + (self.param, self.native, False), + (self.param, self.npv, InequalityExpression((6, self.npv), True)), + (self.param, self.param, False), + ( + self.param, + self.param_mut, + InequalityExpression((6, self.param_mut), True), + ), + # 8: + (self.param, self.var, InequalityExpression((6, self.var), True)), + ( + self.param, + self.mon_native, + InequalityExpression((6, self.mon_native), True), + ), + ( + self.param, + self.mon_param, + InequalityExpression((6, self.mon_param), True), + ), + (self.param, self.mon_npv, InequalityExpression((6, self.mon_npv), True)), + # 12: + (self.param, self.linear, InequalityExpression((6, self.linear), True)), + (self.param, self.sum, InequalityExpression((6, self.sum), True)), + (self.param, self.other, InequalityExpression((6, self.other), True)), + (self.param, self.mutable_l0, False), + # 16: + (self.param, self.mutable_l1, InequalityExpression((6, self.l1), True)), + (self.param, self.mutable_l2, InequalityExpression((6, self.l2), True)), + (self.param, self.param0, False), + (self.param, self.param1, False), + # 20: + (self.param, self.mutable_l3, InequalityExpression((6, self.l3), True)), + ( + self.param, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + (self.param, self.le, RangedExpression((6,) + self.le.args, (True, False))), + (self.param, self.lt, RangedExpression((6,) + self.lt.args, (True, True))), + # 24 + ( + self.param, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_param_mut(self): + tests = [ + (self.param_mut, self.invalid, NotImplemented), + ( + self.param_mut, + self.asbinary, + InequalityExpression((self.param_mut, self.bin), True), + ), + ( + self.param_mut, + self.zero, + InequalityExpression((self.param_mut, 0), True), + ), + (self.param_mut, self.one, InequalityExpression((self.param_mut, 1), True)), + # 4: + ( + self.param_mut, + self.native, + InequalityExpression((self.param_mut, 5), True), + ), + ( + self.param_mut, + self.npv, + InequalityExpression((self.param_mut, self.npv), True), + ), + ( + self.param_mut, + self.param, + InequalityExpression((self.param_mut, 6), True), + ), + ( + self.param_mut, + self.param_mut, + InequalityExpression((self.param_mut, self.param_mut), True), + ), + # 8: + ( + self.param_mut, + self.var, + InequalityExpression((self.param_mut, self.var), True), + ), + ( + self.param_mut, + self.mon_native, + InequalityExpression((self.param_mut, self.mon_native), True), + ), + ( + self.param_mut, + self.mon_param, + InequalityExpression((self.param_mut, self.mon_param), True), + ), + ( + self.param_mut, + self.mon_npv, + InequalityExpression((self.param_mut, self.mon_npv), True), + ), + # 12: + ( + self.param_mut, + self.linear, + InequalityExpression((self.param_mut, self.linear), True), + ), + ( + self.param_mut, + self.sum, + InequalityExpression((self.param_mut, self.sum), True), + ), + ( + self.param_mut, + self.other, + InequalityExpression((self.param_mut, self.other), True), + ), + ( + self.param_mut, + self.mutable_l0, + InequalityExpression((self.param_mut, self.l0), True), + ), + # 16: + ( + self.param_mut, + self.mutable_l1, + InequalityExpression((self.param_mut, self.l1), True), + ), + ( + self.param_mut, + self.mutable_l2, + InequalityExpression((self.param_mut, self.l2), True), + ), + ( + self.param_mut, + self.param0, + InequalityExpression((self.param_mut, 0), True), + ), + ( + self.param_mut, + self.param1, + InequalityExpression((self.param_mut, 1), True), + ), + # 20: + ( + self.param_mut, + self.mutable_l3, + InequalityExpression((self.param_mut, self.l3), True), + ), + ( + self.param_mut, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param_mut, + self.le, + RangedExpression((self.param_mut,) + self.le.args, (True, False)), + ), + ( + self.param_mut, + self.lt, + RangedExpression((self.param_mut,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.param_mut, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_var(self): + tests = [ + (self.var, self.invalid, NotImplemented), + (self.var, self.asbinary, InequalityExpression((self.var, self.bin), True)), + (self.var, self.zero, InequalityExpression((self.var, 0), True)), + (self.var, self.one, InequalityExpression((self.var, 1), True)), + # 4: + (self.var, self.native, InequalityExpression((self.var, 5), True)), + (self.var, self.npv, InequalityExpression((self.var, self.npv), True)), + (self.var, self.param, InequalityExpression((self.var, 6), True)), + ( + self.var, + self.param_mut, + InequalityExpression((self.var, self.param_mut), True), + ), + # 8: + (self.var, self.var, InequalityExpression((self.var, self.var), True)), + ( + self.var, + self.mon_native, + InequalityExpression((self.var, self.mon_native), True), + ), + ( + self.var, + self.mon_param, + InequalityExpression((self.var, self.mon_param), True), + ), + ( + self.var, + self.mon_npv, + InequalityExpression((self.var, self.mon_npv), True), + ), + # 12: + ( + self.var, + self.linear, + InequalityExpression((self.var, self.linear), True), + ), + (self.var, self.sum, InequalityExpression((self.var, self.sum), True)), + (self.var, self.other, InequalityExpression((self.var, self.other), True)), + ( + self.var, + self.mutable_l0, + InequalityExpression((self.var, self.l0), True), + ), + # 16: + ( + self.var, + self.mutable_l1, + InequalityExpression((self.var, self.l1), True), + ), + ( + self.var, + self.mutable_l2, + InequalityExpression((self.var, self.l2), True), + ), + (self.var, self.param0, InequalityExpression((self.var, 0), True)), + (self.var, self.param1, InequalityExpression((self.var, 1), True)), + # 20: + ( + self.var, + self.mutable_l3, + InequalityExpression((self.var, self.l3), True), + ), + ( + self.var, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.var, + self.le, + RangedExpression((self.var,) + self.le.args, (True, False)), + ), + ( + self.var, + self.lt, + RangedExpression((self.var,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.var, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_mon_native(self): + tests = [ + (self.mon_native, self.invalid, NotImplemented), + ( + self.mon_native, + self.asbinary, + InequalityExpression((self.mon_native, self.bin), True), + ), + ( + self.mon_native, + self.zero, + InequalityExpression((self.mon_native, 0), True), + ), + ( + self.mon_native, + self.one, + InequalityExpression((self.mon_native, 1), True), + ), + # 4: + ( + self.mon_native, + self.native, + InequalityExpression((self.mon_native, 5), True), + ), + ( + self.mon_native, + self.npv, + InequalityExpression((self.mon_native, self.npv), True), + ), + ( + self.mon_native, + self.param, + InequalityExpression((self.mon_native, 6), True), + ), + ( + self.mon_native, + self.param_mut, + InequalityExpression((self.mon_native, self.param_mut), True), + ), + # 8: + ( + self.mon_native, + self.var, + InequalityExpression((self.mon_native, self.var), True), + ), + ( + self.mon_native, + self.mon_native, + InequalityExpression((self.mon_native, self.mon_native), True), + ), + ( + self.mon_native, + self.mon_param, + InequalityExpression((self.mon_native, self.mon_param), True), + ), + ( + self.mon_native, + self.mon_npv, + InequalityExpression((self.mon_native, self.mon_npv), True), + ), + # 12: + ( + self.mon_native, + self.linear, + InequalityExpression((self.mon_native, self.linear), True), + ), + ( + self.mon_native, + self.sum, + InequalityExpression((self.mon_native, self.sum), True), + ), + ( + self.mon_native, + self.other, + InequalityExpression((self.mon_native, self.other), True), + ), + ( + self.mon_native, + self.mutable_l0, + InequalityExpression((self.mon_native, self.l0), True), + ), + # 16: + ( + self.mon_native, + self.mutable_l1, + InequalityExpression((self.mon_native, self.l1), True), + ), + ( + self.mon_native, + self.mutable_l2, + InequalityExpression((self.mon_native, self.l2), True), + ), + ( + self.mon_native, + self.param0, + InequalityExpression((self.mon_native, 0), True), + ), + ( + self.mon_native, + self.param1, + InequalityExpression((self.mon_native, 1), True), + ), + # 20: + ( + self.mon_native, + self.mutable_l3, + InequalityExpression((self.mon_native, self.l3), True), + ), + ( + self.mon_native, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_native, + self.le, + RangedExpression((self.mon_native,) + self.le.args, (True, False)), + ), + ( + self.mon_native, + self.lt, + RangedExpression((self.mon_native,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.mon_native, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_mon_param(self): + tests = [ + (self.mon_param, self.invalid, NotImplemented), + ( + self.mon_param, + self.asbinary, + InequalityExpression((self.mon_param, self.bin), True), + ), + ( + self.mon_param, + self.zero, + InequalityExpression((self.mon_param, 0), True), + ), + (self.mon_param, self.one, InequalityExpression((self.mon_param, 1), True)), + # 4: + ( + self.mon_param, + self.native, + InequalityExpression((self.mon_param, 5), True), + ), + ( + self.mon_param, + self.npv, + InequalityExpression((self.mon_param, self.npv), True), + ), + ( + self.mon_param, + self.param, + InequalityExpression((self.mon_param, 6), True), + ), + ( + self.mon_param, + self.param_mut, + InequalityExpression((self.mon_param, self.param_mut), True), + ), + # 8: + ( + self.mon_param, + self.var, + InequalityExpression((self.mon_param, self.var), True), + ), + ( + self.mon_param, + self.mon_native, + InequalityExpression((self.mon_param, self.mon_native), True), + ), + ( + self.mon_param, + self.mon_param, + InequalityExpression((self.mon_param, self.mon_param), True), + ), + ( + self.mon_param, + self.mon_npv, + InequalityExpression((self.mon_param, self.mon_npv), True), + ), + # 12: + ( + self.mon_param, + self.linear, + InequalityExpression((self.mon_param, self.linear), True), + ), + ( + self.mon_param, + self.sum, + InequalityExpression((self.mon_param, self.sum), True), + ), + ( + self.mon_param, + self.other, + InequalityExpression((self.mon_param, self.other), True), + ), + ( + self.mon_param, + self.mutable_l0, + InequalityExpression((self.mon_param, self.l0), True), + ), + # 16: + ( + self.mon_param, + self.mutable_l1, + InequalityExpression((self.mon_param, self.l1), True), + ), + ( + self.mon_param, + self.mutable_l2, + InequalityExpression((self.mon_param, self.l2), True), + ), + ( + self.mon_param, + self.param0, + InequalityExpression((self.mon_param, 0), True), + ), + ( + self.mon_param, + self.param1, + InequalityExpression((self.mon_param, 1), True), + ), + # 20: + ( + self.mon_param, + self.mutable_l3, + InequalityExpression((self.mon_param, self.l3), True), + ), + ( + self.mon_param, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_param, + self.le, + RangedExpression((self.mon_param,) + self.le.args, (True, False)), + ), + ( + self.mon_param, + self.lt, + RangedExpression((self.mon_param,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.mon_param, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_mon_npv(self): + tests = [ + (self.mon_npv, self.invalid, NotImplemented), + ( + self.mon_npv, + self.asbinary, + InequalityExpression((self.mon_npv, self.bin), True), + ), + (self.mon_npv, self.zero, InequalityExpression((self.mon_npv, 0), True)), + (self.mon_npv, self.one, InequalityExpression((self.mon_npv, 1), True)), + # 4: + (self.mon_npv, self.native, InequalityExpression((self.mon_npv, 5), True)), + ( + self.mon_npv, + self.npv, + InequalityExpression((self.mon_npv, self.npv), True), + ), + (self.mon_npv, self.param, InequalityExpression((self.mon_npv, 6), True)), + ( + self.mon_npv, + self.param_mut, + InequalityExpression((self.mon_npv, self.param_mut), True), + ), + # 8: + ( + self.mon_npv, + self.var, + InequalityExpression((self.mon_npv, self.var), True), + ), + ( + self.mon_npv, + self.mon_native, + InequalityExpression((self.mon_npv, self.mon_native), True), + ), + ( + self.mon_npv, + self.mon_param, + InequalityExpression((self.mon_npv, self.mon_param), True), + ), + ( + self.mon_npv, + self.mon_npv, + InequalityExpression((self.mon_npv, self.mon_npv), True), + ), + # 12: + ( + self.mon_npv, + self.linear, + InequalityExpression((self.mon_npv, self.linear), True), + ), + ( + self.mon_npv, + self.sum, + InequalityExpression((self.mon_npv, self.sum), True), + ), + ( + self.mon_npv, + self.other, + InequalityExpression((self.mon_npv, self.other), True), + ), + ( + self.mon_npv, + self.mutable_l0, + InequalityExpression((self.mon_npv, self.l0), True), + ), + # 16: + ( + self.mon_npv, + self.mutable_l1, + InequalityExpression((self.mon_npv, self.l1), True), + ), + ( + self.mon_npv, + self.mutable_l2, + InequalityExpression((self.mon_npv, self.l2), True), + ), + (self.mon_npv, self.param0, InequalityExpression((self.mon_npv, 0), True)), + (self.mon_npv, self.param1, InequalityExpression((self.mon_npv, 1), True)), + # 20: + ( + self.mon_npv, + self.mutable_l3, + InequalityExpression((self.mon_npv, self.l3), True), + ), + ( + self.mon_npv, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mon_npv, + self.le, + RangedExpression((self.mon_npv,) + self.le.args, (True, False)), + ), + ( + self.mon_npv, + self.lt, + RangedExpression((self.mon_npv,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.mon_npv, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_linear(self): + tests = [ + (self.linear, self.invalid, NotImplemented), + ( + self.linear, + self.asbinary, + InequalityExpression((self.linear, self.bin), True), + ), + (self.linear, self.zero, InequalityExpression((self.linear, 0), True)), + (self.linear, self.one, InequalityExpression((self.linear, 1), True)), + # 4: + (self.linear, self.native, InequalityExpression((self.linear, 5), True)), + ( + self.linear, + self.npv, + InequalityExpression((self.linear, self.npv), True), + ), + (self.linear, self.param, InequalityExpression((self.linear, 6), True)), + ( + self.linear, + self.param_mut, + InequalityExpression((self.linear, self.param_mut), True), + ), + # 8: + ( + self.linear, + self.var, + InequalityExpression((self.linear, self.var), True), + ), + ( + self.linear, + self.mon_native, + InequalityExpression((self.linear, self.mon_native), True), + ), + ( + self.linear, + self.mon_param, + InequalityExpression((self.linear, self.mon_param), True), + ), + ( + self.linear, + self.mon_npv, + InequalityExpression((self.linear, self.mon_npv), True), + ), + # 12: + ( + self.linear, + self.linear, + InequalityExpression((self.linear, self.linear), True), + ), + ( + self.linear, + self.sum, + InequalityExpression((self.linear, self.sum), True), + ), + ( + self.linear, + self.other, + InequalityExpression((self.linear, self.other), True), + ), + ( + self.linear, + self.mutable_l0, + InequalityExpression((self.linear, self.l0), True), + ), + # 16: + ( + self.linear, + self.mutable_l1, + InequalityExpression((self.linear, self.l1), True), + ), + ( + self.linear, + self.mutable_l2, + InequalityExpression((self.linear, self.l2), True), + ), + (self.linear, self.param0, InequalityExpression((self.linear, 0), True)), + (self.linear, self.param1, InequalityExpression((self.linear, 1), True)), + # 20: + ( + self.linear, + self.mutable_l3, + InequalityExpression((self.linear, self.l3), True), + ), + ( + self.linear, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.linear, + self.le, + RangedExpression(((self.linear,) + self.le.args), (True, False)), + ), + ( + self.linear, + self.lt, + RangedExpression(((self.linear,) + self.lt.args), (True, True)), + ), + # 24 + ( + self.linear, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_sum(self): + tests = [ + (self.sum, self.invalid, NotImplemented), + (self.sum, self.asbinary, InequalityExpression((self.sum, self.bin), True)), + (self.sum, self.zero, InequalityExpression((self.sum, 0), True)), + (self.sum, self.one, InequalityExpression((self.sum, 1), True)), + # 4: + (self.sum, self.native, InequalityExpression((self.sum, 5), True)), + (self.sum, self.npv, InequalityExpression((self.sum, self.npv), True)), + (self.sum, self.param, InequalityExpression((self.sum, 6), True)), + ( + self.sum, + self.param_mut, + InequalityExpression((self.sum, self.param_mut), True), + ), + # 8: + (self.sum, self.var, InequalityExpression((self.sum, self.var), True)), + ( + self.sum, + self.mon_native, + InequalityExpression((self.sum, self.mon_native), True), + ), + ( + self.sum, + self.mon_param, + InequalityExpression((self.sum, self.mon_param), True), + ), + ( + self.sum, + self.mon_npv, + InequalityExpression((self.sum, self.mon_npv), True), + ), + # 12: + ( + self.sum, + self.linear, + InequalityExpression((self.sum, self.linear), True), + ), + (self.sum, self.sum, InequalityExpression((self.sum, self.sum), True)), + (self.sum, self.other, InequalityExpression((self.sum, self.other), True)), + ( + self.sum, + self.mutable_l0, + InequalityExpression((self.sum, self.l0), True), + ), + # 16: + ( + self.sum, + self.mutable_l1, + InequalityExpression((self.sum, self.l1), True), + ), + ( + self.sum, + self.mutable_l2, + InequalityExpression((self.sum, self.l2), True), + ), + (self.sum, self.param0, InequalityExpression((self.sum, 0), True)), + (self.sum, self.param1, InequalityExpression((self.sum, 1), True)), + # 20: + ( + self.sum, + self.mutable_l3, + InequalityExpression((self.sum, self.l3), True), + ), + ( + self.sum, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.sum, + self.le, + RangedExpression((self.sum,) + self.le.args, (True, False)), + ), + ( + self.sum, + self.lt, + RangedExpression((self.sum,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.sum, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_other(self): + tests = [ + (self.other, self.invalid, NotImplemented), + ( + self.other, + self.asbinary, + InequalityExpression((self.other, self.bin), True), + ), + (self.other, self.zero, InequalityExpression((self.other, 0), True)), + (self.other, self.one, InequalityExpression((self.other, 1), True)), + # 4: + (self.other, self.native, InequalityExpression((self.other, 5), True)), + (self.other, self.npv, InequalityExpression((self.other, self.npv), True)), + (self.other, self.param, InequalityExpression((self.other, 6), True)), + ( + self.other, + self.param_mut, + InequalityExpression((self.other, self.param_mut), True), + ), + # 8: + (self.other, self.var, InequalityExpression((self.other, self.var), True)), + ( + self.other, + self.mon_native, + InequalityExpression((self.other, self.mon_native), True), + ), + ( + self.other, + self.mon_param, + InequalityExpression((self.other, self.mon_param), True), + ), + ( + self.other, + self.mon_npv, + InequalityExpression((self.other, self.mon_npv), True), + ), + # 12: + ( + self.other, + self.linear, + InequalityExpression((self.other, self.linear), True), + ), + (self.other, self.sum, InequalityExpression((self.other, self.sum), True)), + ( + self.other, + self.other, + InequalityExpression((self.other, self.other), True), + ), + ( + self.other, + self.mutable_l0, + InequalityExpression((self.other, self.l0), True), + ), + # 16: + ( + self.other, + self.mutable_l1, + InequalityExpression((self.other, self.l1), True), + ), + ( + self.other, + self.mutable_l2, + InequalityExpression((self.other, self.l2), True), + ), + (self.other, self.param0, InequalityExpression((self.other, 0), True)), + (self.other, self.param1, InequalityExpression((self.other, 1), True)), + # 20: + ( + self.other, + self.mutable_l3, + InequalityExpression((self.other, self.l3), True), + ), + ( + self.other, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.other, + self.le, + RangedExpression((self.other,) + self.le.args, (True, False)), + ), + ( + self.other, + self.lt, + RangedExpression((self.other,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.other, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_mutable_l0(self): + tests = [ + (self.mutable_l0, self.invalid, NotImplemented), + ( + self.mutable_l0, + self.asbinary, + InequalityExpression((self.l0, self.bin), True), + ), + (self.mutable_l0, self.zero, False), + (self.mutable_l0, self.one, True), + # 4: + (self.mutable_l0, self.native, True), + ( + self.mutable_l0, + self.npv, + InequalityExpression((self.l0, self.npv), True), + ), + (self.mutable_l0, self.param, True), + ( + self.mutable_l0, + self.param_mut, + InequalityExpression((self.l0, self.param_mut), True), + ), + # 8: + ( + self.mutable_l0, + self.var, + InequalityExpression((self.l0, self.var), True), + ), + ( + self.mutable_l0, + self.mon_native, + InequalityExpression((self.l0, self.mon_native), True), + ), + ( + self.mutable_l0, + self.mon_param, + InequalityExpression((self.l0, self.mon_param), True), + ), + ( + self.mutable_l0, + self.mon_npv, + InequalityExpression((self.l0, self.mon_npv), True), + ), + # 12: + ( + self.mutable_l0, + self.linear, + InequalityExpression((self.l0, self.linear), True), + ), + ( + self.mutable_l0, + self.sum, + InequalityExpression((self.l0, self.sum), True), + ), + ( + self.mutable_l0, + self.other, + InequalityExpression((self.l0, self.other), True), + ), + (self.mutable_l0, self.mutable_l0, False), + # 16: + ( + self.mutable_l0, + self.mutable_l1, + InequalityExpression((self.l0, self.l1), True), + ), + ( + self.mutable_l0, + self.mutable_l2, + InequalityExpression((self.l0, self.l2), True), + ), + (self.mutable_l0, self.param0, False), + (self.mutable_l0, self.param1, True), + # 20: + ( + self.mutable_l0, + self.mutable_l3, + InequalityExpression((self.l0, self.l3), True), + ), + ( + self.mutable_l0, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l0, + self.le, + RangedExpression((self.l0,) + self.le.args, (True, False)), + ), + ( + self.mutable_l0, + self.lt, + RangedExpression((self.l0,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.mutable_l0, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_mutable_l1(self): + tests = [ + (self.mutable_l1, self.invalid, NotImplemented), + ( + self.mutable_l1, + self.asbinary, + InequalityExpression((self.l1, self.bin), True), + ), + (self.mutable_l1, self.zero, InequalityExpression((self.l1, 0), True)), + (self.mutable_l1, self.one, InequalityExpression((self.l1, 1), True)), + # 4: + (self.mutable_l1, self.native, InequalityExpression((self.l1, 5), True)), + ( + self.mutable_l1, + self.npv, + InequalityExpression((self.l1, self.npv), True), + ), + (self.mutable_l1, self.param, InequalityExpression((self.l1, 6), True)), + ( + self.mutable_l1, + self.param_mut, + InequalityExpression((self.l1, self.param_mut), True), + ), + # 8: + ( + self.mutable_l1, + self.var, + InequalityExpression((self.l1, self.var), True), + ), + ( + self.mutable_l1, + self.mon_native, + InequalityExpression((self.l1, self.mon_native), True), + ), + ( + self.mutable_l1, + self.mon_param, + InequalityExpression((self.l1, self.mon_param), True), + ), + ( + self.mutable_l1, + self.mon_npv, + InequalityExpression((self.l1, self.mon_npv), True), + ), + # 12: + ( + self.mutable_l1, + self.linear, + InequalityExpression((self.l1, self.linear), True), + ), + ( + self.mutable_l1, + self.sum, + InequalityExpression((self.l1, self.sum), True), + ), + ( + self.mutable_l1, + self.other, + InequalityExpression((self.l1, self.other), True), + ), + ( + self.mutable_l1, + self.mutable_l0, + InequalityExpression((self.l1, self.l0), True), + ), + # 16: + ( + self.mutable_l1, + self.mutable_l1, + InequalityExpression((self.l1, self.l1), True), + ), + ( + self.mutable_l1, + self.mutable_l2, + InequalityExpression((self.l1, self.l2), True), + ), + (self.mutable_l1, self.param0, InequalityExpression((self.l1, 0), True)), + (self.mutable_l1, self.param1, InequalityExpression((self.l1, 1), True)), + # 20: + ( + self.mutable_l1, + self.mutable_l3, + InequalityExpression((self.l1, self.l3), True), + ), + ( + self.mutable_l1, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l1, + self.le, + RangedExpression((self.l1,) + self.le.args, (True, False)), + ), + ( + self.mutable_l1, + self.lt, + RangedExpression((self.l1,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.mutable_l1, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_mutable_l2(self): + tests = [ + (self.mutable_l2, self.invalid, NotImplemented), + ( + self.mutable_l2, + self.asbinary, + InequalityExpression((self.l2, self.bin), True), + ), + (self.mutable_l2, self.zero, InequalityExpression((self.l2, 0), True)), + (self.mutable_l2, self.one, InequalityExpression((self.l2, 1), True)), + # 4: + (self.mutable_l2, self.native, InequalityExpression((self.l2, 5), True)), + ( + self.mutable_l2, + self.npv, + InequalityExpression((self.l2, self.npv), True), + ), + (self.mutable_l2, self.param, InequalityExpression((self.l2, 6), True)), + ( + self.mutable_l2, + self.param_mut, + InequalityExpression((self.l2, self.param_mut), True), + ), + # 8: + ( + self.mutable_l2, + self.var, + InequalityExpression((self.l2, self.var), True), + ), + ( + self.mutable_l2, + self.mon_native, + InequalityExpression((self.l2, self.mon_native), True), + ), + ( + self.mutable_l2, + self.mon_param, + InequalityExpression((self.l2, self.mon_param), True), + ), + ( + self.mutable_l2, + self.mon_npv, + InequalityExpression((self.l2, self.mon_npv), True), + ), + # 12: + ( + self.mutable_l2, + self.linear, + InequalityExpression((self.l2, self.linear), True), + ), + ( + self.mutable_l2, + self.sum, + InequalityExpression((self.l2, self.sum), True), + ), + ( + self.mutable_l2, + self.other, + InequalityExpression((self.l2, self.other), True), + ), + ( + self.mutable_l2, + self.mutable_l0, + InequalityExpression((self.l2, self.l0), True), + ), + # 16: + ( + self.mutable_l2, + self.mutable_l1, + InequalityExpression((self.l2, self.l1), True), + ), + ( + self.mutable_l2, + self.mutable_l2, + InequalityExpression((self.l2, self.l2), True), + ), + (self.mutable_l2, self.param0, InequalityExpression((self.l2, 0), True)), + (self.mutable_l2, self.param1, InequalityExpression((self.l2, 1), True)), + # 20: + ( + self.mutable_l2, + self.mutable_l3, + InequalityExpression((self.l2, self.l3), True), + ), + ( + self.mutable_l2, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l2, + self.le, + RangedExpression((self.l2,) + self.le.args, (True, False)), + ), + ( + self.mutable_l2, + self.lt, + RangedExpression((self.l2,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.mutable_l2, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_param0(self): + tests = [ + (self.param0, self.invalid, NotImplemented), + (self.param0, self.asbinary, InequalityExpression((0, self.bin), True)), + (self.param0, self.zero, False), + (self.param0, self.one, True), + # 4: + (self.param0, self.native, True), + (self.param0, self.npv, InequalityExpression((0, self.npv), True)), + (self.param0, self.param, True), + ( + self.param0, + self.param_mut, + InequalityExpression((0, self.param_mut), True), + ), + # 8: + (self.param0, self.var, InequalityExpression((0, self.var), True)), + ( + self.param0, + self.mon_native, + InequalityExpression((0, self.mon_native), True), + ), + ( + self.param0, + self.mon_param, + InequalityExpression((0, self.mon_param), True), + ), + (self.param0, self.mon_npv, InequalityExpression((0, self.mon_npv), True)), + # 12: + (self.param0, self.linear, InequalityExpression((0, self.linear), True)), + (self.param0, self.sum, InequalityExpression((0, self.sum), True)), + (self.param0, self.other, InequalityExpression((0, self.other), True)), + (self.param0, self.mutable_l0, False), + # 16: + (self.param0, self.mutable_l1, InequalityExpression((0, self.l1), True)), + (self.param0, self.mutable_l2, InequalityExpression((0, self.l2), True)), + (self.param0, self.param0, False), + (self.param0, self.param1, True), + # 20: + (self.param0, self.mutable_l3, InequalityExpression((0, self.l3), True)), + ( + self.param0, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param0, + self.le, + RangedExpression((0,) + self.le.args, (True, False)), + ), + (self.param0, self.lt, RangedExpression((0,) + self.lt.args, (True, True))), + # 24 + ( + self.param0, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_param1(self): + tests = [ + (self.param1, self.invalid, NotImplemented), + (self.param1, self.asbinary, InequalityExpression((1, self.bin), True)), + (self.param1, self.zero, False), + (self.param1, self.one, False), + # 4: + (self.param1, self.native, True), + (self.param1, self.npv, InequalityExpression((1, self.npv), True)), + (self.param1, self.param, True), + ( + self.param1, + self.param_mut, + InequalityExpression((1, self.param_mut), True), + ), + # 8: + (self.param1, self.var, InequalityExpression((1, self.var), True)), + ( + self.param1, + self.mon_native, + InequalityExpression((1, self.mon_native), True), + ), + ( + self.param1, + self.mon_param, + InequalityExpression((1, self.mon_param), True), + ), + (self.param1, self.mon_npv, InequalityExpression((1, self.mon_npv), True)), + # 12: + (self.param1, self.linear, InequalityExpression((1, self.linear), True)), + (self.param1, self.sum, InequalityExpression((1, self.sum), True)), + (self.param1, self.other, InequalityExpression((1, self.other), True)), + (self.param1, self.mutable_l0, False), + # 16: + (self.param1, self.mutable_l1, InequalityExpression((1, self.l1), True)), + (self.param1, self.mutable_l2, InequalityExpression((1, self.l2), True)), + (self.param1, self.param0, False), + (self.param1, self.param1, False), + # 20: + (self.param1, self.mutable_l3, InequalityExpression((1, self.l3), True)), + ( + self.param1, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.param1, + self.le, + RangedExpression((1,) + self.le.args, (True, False)), + ), + (self.param1, self.lt, RangedExpression((1,) + self.lt.args, (True, True))), + # 24 + ( + self.param1, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_mutable_l3(self): + tests = [ + (self.mutable_l3, self.invalid, NotImplemented), + ( + self.mutable_l3, + self.asbinary, + InequalityExpression((self.l3, self.bin), True), + ), + (self.mutable_l3, self.zero, InequalityExpression((self.l3, 0), True)), + (self.mutable_l3, self.one, InequalityExpression((self.l3, 1), True)), + # 4: + (self.mutable_l3, self.native, InequalityExpression((self.l3, 5), True)), + ( + self.mutable_l3, + self.npv, + InequalityExpression((self.l3, self.npv), True), + ), + (self.mutable_l3, self.param, InequalityExpression((self.l3, 6), True)), + ( + self.mutable_l3, + self.param_mut, + InequalityExpression((self.l3, self.param_mut), True), + ), + # 8: + ( + self.mutable_l3, + self.var, + InequalityExpression((self.l3, self.var), True), + ), + ( + self.mutable_l3, + self.mon_native, + InequalityExpression((self.l3, self.mon_native), True), + ), + ( + self.mutable_l3, + self.mon_param, + InequalityExpression((self.l3, self.mon_param), True), + ), + ( + self.mutable_l3, + self.mon_npv, + InequalityExpression((self.l3, self.mon_npv), True), + ), + # 12: + ( + self.mutable_l3, + self.linear, + InequalityExpression((self.l3, self.linear), True), + ), + ( + self.mutable_l3, + self.sum, + InequalityExpression((self.l3, self.sum), True), + ), + ( + self.mutable_l3, + self.other, + InequalityExpression((self.l3, self.other), True), + ), + ( + self.mutable_l3, + self.mutable_l0, + InequalityExpression((self.l3, self.l0), True), + ), + # 16: + ( + self.mutable_l3, + self.mutable_l1, + InequalityExpression((self.l3, self.l1), True), + ), + ( + self.mutable_l3, + self.mutable_l2, + InequalityExpression((self.l3, self.l2), True), + ), + (self.mutable_l3, self.param0, InequalityExpression((self.l3, 0), True)), + (self.mutable_l3, self.param1, InequalityExpression((self.l3, 1), True)), + # 20: + ( + self.mutable_l3, + self.mutable_l3, + InequalityExpression((self.l3, self.l3), True), + ), + ( + self.mutable_l3, + self.eq, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.mutable_l3, + self.le, + RangedExpression((self.l3,) + self.le.args, (True, False)), + ), + ( + self.mutable_l3, + self.lt, + RangedExpression((self.l3,) + self.lt.args, (True, True)), + ), + # 24 + ( + self.mutable_l3, + self.ranged, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_eq(self): + tests = [ + (self.eq, self.invalid, NotImplemented), + ( + self.eq, + self.asbinary, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.zero, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.one, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 4: + ( + self.eq, + self.native, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.npv, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param_mut, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 8: + ( + self.eq, + self.var, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_native, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_param, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mon_npv, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 12: + ( + self.eq, + self.linear, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.sum, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.other, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mutable_l0, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 16: + ( + self.eq, + self.mutable_l1, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.mutable_l2, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param0, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.param1, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 20: + ( + self.eq, + self.mutable_l3, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.eq, + self.eq, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.eq, + self.le, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.eq, + self.lt, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.eq, + self.ranged, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_le(self): + tests = [ + (self.le, self.invalid, NotImplemented), + ( + self.le, + self.asbinary, + RangedExpression((self.le.args + (self.bin,)), (False, True)), + ), + ( + self.le, + self.zero, + RangedExpression((self.le.args + (0,)), (False, True)), + ), + (self.le, self.one, RangedExpression((self.le.args + (1,)), (False, True))), + # 4: + ( + self.le, + self.native, + RangedExpression((self.le.args + (5,)), (False, True)), + ), + ( + self.le, + self.npv, + RangedExpression((self.le.args + (self.npv,)), (False, True)), + ), + ( + self.le, + self.param, + RangedExpression((self.le.args + (6,)), (False, True)), + ), + ( + self.le, + self.param_mut, + RangedExpression((self.le.args + (self.param_mut,)), (False, True)), + ), + # 8: + ( + self.le, + self.var, + RangedExpression((self.le.args + (self.var,)), (False, True)), + ), + ( + self.le, + self.mon_native, + RangedExpression((self.le.args + (self.mon_native,)), (False, True)), + ), + ( + self.le, + self.mon_param, + RangedExpression((self.le.args + (self.mon_param,)), (False, True)), + ), + ( + self.le, + self.mon_npv, + RangedExpression((self.le.args + (self.mon_npv,)), (False, True)), + ), + # 12: + ( + self.le, + self.linear, + RangedExpression((self.le.args + (self.linear,)), (False, True)), + ), + ( + self.le, + self.sum, + RangedExpression((self.le.args + (self.sum,)), (False, True)), + ), + ( + self.le, + self.other, + RangedExpression((self.le.args + (self.other,)), (False, True)), + ), + ( + self.le, + self.mutable_l0, + RangedExpression((self.le.args + (self.l0,)), (False, True)), + ), + # 16: + ( + self.le, + self.mutable_l1, + RangedExpression((self.le.args + (self.l1,)), (False, True)), + ), + ( + self.le, + self.mutable_l2, + RangedExpression((self.le.args + (self.l2,)), (False, True)), + ), + ( + self.le, + self.param0, + RangedExpression((self.le.args + (0,)), (False, True)), + ), + ( + self.le, + self.param1, + RangedExpression((self.le.args + (1,)), (False, True)), + ), + # 20: + ( + self.le, + self.mutable_l3, + RangedExpression((self.le.args + (self.l3,)), (False, True)), + ), + ( + self.le, + self.eq, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.le, + self.le, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.le, + self.lt, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.le, + self.ranged, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_lt(self): + tests = [ + (self.lt, self.invalid, NotImplemented), + ( + self.lt, + self.asbinary, + RangedExpression((self.lt.args + (self.bin,)), (True, True)), + ), + (self.lt, self.zero, RangedExpression((self.lt.args + (0,)), (True, True))), + (self.lt, self.one, RangedExpression((self.lt.args + (1,)), (True, True))), + # 4: + ( + self.lt, + self.native, + RangedExpression((self.lt.args + (5,)), (True, True)), + ), + ( + self.lt, + self.npv, + RangedExpression((self.lt.args + (self.npv,)), (True, True)), + ), + ( + self.lt, + self.param, + RangedExpression((self.lt.args + (6,)), (True, True)), + ), + ( + self.lt, + self.param_mut, + RangedExpression((self.lt.args + (self.param_mut,)), (True, True)), + ), + # 8: + ( + self.lt, + self.var, + RangedExpression((self.lt.args + (self.var,)), (True, True)), + ), + ( + self.lt, + self.mon_native, + RangedExpression((self.lt.args + (self.mon_native,)), (True, True)), + ), + ( + self.lt, + self.mon_param, + RangedExpression((self.lt.args + (self.mon_param,)), (True, True)), + ), + ( + self.lt, + self.mon_npv, + RangedExpression((self.lt.args + (self.mon_npv,)), (True, True)), + ), + # 12: + ( + self.lt, + self.linear, + RangedExpression((self.lt.args + (self.linear,)), (True, True)), + ), + ( + self.lt, + self.sum, + RangedExpression((self.lt.args + (self.sum,)), (True, True)), + ), + ( + self.lt, + self.other, + RangedExpression((self.lt.args + (self.other,)), (True, True)), + ), + ( + self.lt, + self.mutable_l0, + RangedExpression((self.lt.args + (self.l0,)), (True, True)), + ), + # 16: + ( + self.lt, + self.mutable_l1, + RangedExpression((self.lt.args + (self.l1,)), (True, True)), + ), + ( + self.lt, + self.mutable_l2, + RangedExpression((self.lt.args + (self.l2,)), (True, True)), + ), + ( + self.lt, + self.param0, + RangedExpression((self.lt.args + (0,)), (True, True)), + ), + ( + self.lt, + self.param1, + RangedExpression((self.lt.args + (1,)), (True, True)), + ), + # 20: + ( + self.lt, + self.mutable_l3, + RangedExpression((self.lt.args + (self.l3,)), (True, True)), + ), + ( + self.lt, + self.eq, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.lt, + self.le, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.lt, + self.lt, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.lt, + self.ranged, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.lt) + + def test_lt_ranged(self): + tests = [ + (self.ranged, self.invalid, NotImplemented), + ( + self.ranged, + self.asbinary, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.zero, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.one, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 4: + ( + self.ranged, + self.native, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.npv, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param_mut, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 8: + ( + self.ranged, + self.var, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_native, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_param, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mon_npv, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 12: + ( + self.ranged, + self.linear, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.sum, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.other, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mutable_l0, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 16: + ( + self.ranged, + self.mutable_l1, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.mutable_l2, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param0, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.param1, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + # 20: + ( + self.ranged, + self.mutable_l3, + "Cannot create an InequalityExpression where one of the " + "sub-expressions is a relational expression", + ), + ( + self.ranged, + self.eq, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.ranged, + self.le, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ( + self.ranged, + self.lt, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + # 24 + ( + self.ranged, + self.ranged, + "Cannot create an InequalityExpression where both " + "sub-expressions are relational expressions", + ), + ] + self._run_cases(tests, operator.lt) diff --git a/pyomo/core/tests/unit/test_set.py b/pyomo/core/tests/unit/test_set.py index 1ad08ba025c..ea4931f9767 100644 --- a/pyomo/core/tests/unit/test_set.py +++ b/pyomo/core/tests/unit/test_set.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -34,6 +34,7 @@ ConstantInitializer, ItemInitializer, IndexedCallInitializer, + ParameterizedScalarCallInitializer, ) from pyomo.core.base.set import ( NumericRange as NR, @@ -60,8 +61,8 @@ FiniteSetOf, InfiniteSetOf, RangeSet, - _FiniteRangeSetData, - _InfiniteRangeSetData, + FiniteRangeSetData, + InfiniteRangeSetData, FiniteScalarRangeSet, InfiniteScalarRangeSet, AbstractFiniteScalarRangeSet, @@ -81,10 +82,10 @@ SetProduct_InfiniteSet, SetProduct_FiniteSet, SetProduct_OrderedSet, - _SetData, - _FiniteSetData, - _InsertionOrderSetData, - _SortedSetData, + SetData, + FiniteSetData, + InsertionOrderSetData, + SortedSetData, _FiniteSetMixin, _OrderedSetMixin, SetInitializer, @@ -1285,19 +1286,19 @@ def test_is_functions(self): self.assertTrue(i.isdiscrete()) self.assertTrue(i.isfinite()) self.assertTrue(i.isordered()) - self.assertIsInstance(i, _FiniteRangeSetData) + self.assertIsInstance(i, FiniteRangeSetData) i = RangeSet(1, 3) self.assertTrue(i.isdiscrete()) self.assertTrue(i.isfinite()) self.assertTrue(i.isordered()) - self.assertIsInstance(i, _FiniteRangeSetData) + self.assertIsInstance(i, FiniteRangeSetData) i = RangeSet(1, 3, 0) self.assertFalse(i.isdiscrete()) self.assertFalse(i.isfinite()) self.assertFalse(i.isordered()) - self.assertIsInstance(i, _InfiniteRangeSetData) + self.assertIsInstance(i, InfiniteRangeSetData) def test_pprint(self): m = ConcreteModel() @@ -3518,21 +3519,25 @@ def test_iteration(self): def test_declare(self): NS = {} DeclareGlobalSet(RangeSet(name='TrinarySet', ranges=(NR(0, 2, 1),)), NS) - self.assertEqual(list(NS['TrinarySet']), [0, 1, 2]) - a = pickle.loads(pickle.dumps(NS['TrinarySet'])) - self.assertIs(a, NS['TrinarySet']) - with self.assertRaisesRegex(NameError, "name 'TrinarySet' is not defined"): - TrinarySet - del SetModule.GlobalSets['TrinarySet'] - del NS['TrinarySet'] + try: + self.assertEqual(list(NS['TrinarySet']), [0, 1, 2]) + a = pickle.loads(pickle.dumps(NS['TrinarySet'])) + self.assertIs(a, NS['TrinarySet']) + with self.assertRaisesRegex(NameError, "name 'TrinarySet' is not defined"): + TrinarySet + finally: + del SetModule.GlobalSets['TrinarySet'] + del NS['TrinarySet'] # Now test the automatic identification of the globals() scope DeclareGlobalSet(RangeSet(name='TrinarySet', ranges=(NR(0, 2, 1),))) - self.assertEqual(list(TrinarySet), [0, 1, 2]) - a = pickle.loads(pickle.dumps(TrinarySet)) - self.assertIs(a, TrinarySet) - del SetModule.GlobalSets['TrinarySet'] - del globals()['TrinarySet'] + try: + self.assertEqual(list(TrinarySet), [0, 1, 2]) + a = pickle.loads(pickle.dumps(TrinarySet)) + self.assertIs(a, TrinarySet) + finally: + del SetModule.GlobalSets['TrinarySet'] + del globals()['TrinarySet'] with self.assertRaisesRegex(NameError, "name 'TrinarySet' is not defined"): TrinarySet @@ -3551,18 +3556,22 @@ def test_exceptions(self): NS = {} ts = DeclareGlobalSet(RangeSet(name='TrinarySet', ranges=(NR(0, 2, 1),)), NS) - self.assertIs(NS['TrinarySet'], ts) + try: + self.assertIs(NS['TrinarySet'], ts) - # Repeat declaration is OK - DeclareGlobalSet(ts, NS) - self.assertIs(NS['TrinarySet'], ts) + # Repeat declaration is OK + DeclareGlobalSet(ts, NS) + self.assertIs(NS['TrinarySet'], ts) - # but conflicting one raises exception - NS['foo'] = None - with self.assertRaisesRegex( - RuntimeError, "Refusing to overwrite global object, foo" - ): - DeclareGlobalSet(RangeSet(name='foo', ranges=(NR(0, 2, 1),)), NS) + # but conflicting one raises exception + NS['foo'] = None + with self.assertRaisesRegex( + RuntimeError, "Refusing to overwrite global object, foo" + ): + DeclareGlobalSet(RangeSet(name='foo', ranges=(NR(0, 2, 1),)), NS) + finally: + del SetModule.GlobalSets['TrinarySet'] + del NS['TrinarySet'] def test_RealSet_IntegerSet(self): output = StringIO() @@ -3763,8 +3772,8 @@ def I_init(m): m = ConcreteModel() m.I = Set(initialize={1, 3, 2, 4}) ref = ( - "Initializing ordered Set I with a " - "fundamentally unordered data source (type: set)." + 'Initializing ordered Set I with a fundamentally ' + 'unordered data source (type: set).' ) self.assertIn(ref, output.getvalue()) self.assertEqual(m.I.sorted_data(), (1, 2, 3, 4)) @@ -3811,6 +3820,7 @@ def I_init(m): self.assertEqual(m.I.data(), (4, 3, 2, 1)) self.assertEqual(m.I.dimen, 1) + def test_initialize_with_noniterable(self): output = StringIO() with LoggingIntercept(output, 'pyomo.core'): with self.assertRaisesRegex(TypeError, "'int' object is not iterable"): @@ -3819,6 +3829,14 @@ def I_init(m): ref = "Initializer for Set I returned non-iterable object of type int." self.assertIn(ref, output.getvalue()) + output = StringIO() + with LoggingIntercept(output, 'pyomo.core'): + with self.assertRaisesRegex(TypeError, "'int' object is not iterable"): + m = ConcreteModel() + m.I = Set([1, 2], initialize=5) + ref = "Initializer for Set I[1] returned non-iterable object of type int." + self.assertIn(ref, output.getvalue()) + def test_scalar_indexed_api(self): m = ConcreteModel() m.I = Set(initialize=range(3)) @@ -3877,12 +3895,13 @@ def _verify(_s, _l): m.I.add(4) _verify(m.I, [1, 3, 2, 4]) + N = len(m.I) output = StringIO() with LoggingIntercept(output, 'pyomo.core'): m.I.add(3) - self.assertEqual( - output.getvalue(), "Element 3 already exists in Set I; no action taken\n" - ) + # In Pyomo <= 6.7.3 duplicate values logged a warning. + self.assertEqual(output.getvalue(), "") + self.assertEqual(N, len(m.I)) _verify(m.I, [1, 3, 2, 4]) m.I.remove(3) @@ -3959,12 +3978,13 @@ def _verify(_s, _l): m.I.add(4) _verify(m.I, [1, 2, 3, 4]) + N = len(m.I) output = StringIO() with LoggingIntercept(output, 'pyomo.core'): m.I.add(3) - self.assertEqual( - output.getvalue(), "Element 3 already exists in Set I; no action taken\n" - ) + # In Pyomo <= 6.7.3 duplicate values logged a warning. + self.assertEqual(output.getvalue(), "") + self.assertEqual(N, len(m.I)) _verify(m.I, [1, 2, 3, 4]) m.I.remove(3) @@ -4052,12 +4072,13 @@ def _verify(_s, _l): m.I.add(4) _verify(m.I, [1, 2, 3, 4]) + N = len(m.I) output = StringIO() with LoggingIntercept(output, 'pyomo.core'): m.I.add(3) - self.assertEqual( - output.getvalue(), "Element 3 already exists in Set I; no action taken\n" - ) + # In Pyomo <= 6.7.3 duplicate values logged a warning. + self.assertEqual(output.getvalue(), "") + self.assertEqual(N, len(m.I)) _verify(m.I, [1, 2, 3, 4]) m.I.remove(3) @@ -4137,9 +4158,9 @@ def test_indexed_set(self): self.assertFalse(m.I[1].isordered()) self.assertFalse(m.I[2].isordered()) self.assertFalse(m.I[3].isordered()) - self.assertIs(type(m.I[1]), _FiniteSetData) - self.assertIs(type(m.I[2]), _FiniteSetData) - self.assertIs(type(m.I[3]), _FiniteSetData) + self.assertIs(type(m.I[1]), FiniteSetData) + self.assertIs(type(m.I[2]), FiniteSetData) + self.assertIs(type(m.I[3]), FiniteSetData) self.assertEqual(m.I.data(), {1: (1,), 2: (2,), 3: (4,)}) # Explicit (constant) construction @@ -4155,11 +4176,24 @@ def test_indexed_set(self): self.assertTrue(m.I[1].isordered()) self.assertTrue(m.I[2].isordered()) self.assertTrue(m.I[3].isordered()) - self.assertIs(type(m.I[1]), _InsertionOrderSetData) - self.assertIs(type(m.I[2]), _InsertionOrderSetData) - self.assertIs(type(m.I[3]), _InsertionOrderSetData) + self.assertIs(type(m.I[1]), InsertionOrderSetData) + self.assertIs(type(m.I[2]), InsertionOrderSetData) + self.assertIs(type(m.I[3]), InsertionOrderSetData) self.assertEqual(m.I.data(), {1: (4, 2, 5), 2: (4, 2, 5), 3: (4, 2, 5)}) + # Explicit (constant dict) construction + m = ConcreteModel() + m.I = Set([1, 2], initialize={1: (4, 2, 5), 2: (7, 6)}) + self.assertEqual(len(m.I), 2) + self.assertEqual(list(m.I[1]), [4, 2, 5]) + self.assertEqual(list(m.I[2]), [7, 6]) + self.assertIsNot(m.I[1], m.I[2]) + self.assertTrue(m.I[1].isordered()) + self.assertTrue(m.I[2].isordered()) + self.assertIs(type(m.I[1]), InsertionOrderSetData) + self.assertIs(type(m.I[2]), InsertionOrderSetData) + self.assertEqual(m.I.data(), {1: (4, 2, 5), 2: (7, 6)}) + # Explicit (constant) construction m = ConcreteModel() m.I = Set([1, 2, 3], initialize=(4, 2, 5), ordered=Set.SortedOrder) @@ -4173,9 +4207,9 @@ def test_indexed_set(self): self.assertTrue(m.I[1].isordered()) self.assertTrue(m.I[2].isordered()) self.assertTrue(m.I[3].isordered()) - self.assertIs(type(m.I[1]), _SortedSetData) - self.assertIs(type(m.I[2]), _SortedSetData) - self.assertIs(type(m.I[3]), _SortedSetData) + self.assertIs(type(m.I[1]), SortedSetData) + self.assertIs(type(m.I[2]), SortedSetData) + self.assertIs(type(m.I[3]), SortedSetData) self.assertEqual(m.I.data(), {1: (2, 4, 5), 2: (2, 4, 5), 3: (2, 4, 5)}) # Explicit (procedural) construction @@ -4234,7 +4268,7 @@ def test_indexing(self): def test_add_filter_validate(self): m = ConcreteModel() m.I = Set(domain=Integers) - self.assertIs(m.I.filter, None) + self.assertIs(m.I._filter, None) with self.assertRaisesRegex( ValueError, r"Cannot add value 1.5 to Set I.\n" @@ -4248,26 +4282,23 @@ def test_add_filter_validate(self): self.assertIn(1, m.I) self.assertIn(1.0, m.I) + N = len(m.I) output = StringIO() with LoggingIntercept(output, 'pyomo.core'): self.assertFalse(m.I.add(1)) - self.assertEqual( - output.getvalue(), "Element 1 already exists in Set I; no action taken\n" - ) + # In Pyomo <= 6.7.3 duplicate values logged a warning. + self.assertEqual(output.getvalue(), "") + self.assertEqual(N, len(m.I)) output = StringIO() with LoggingIntercept(output, 'pyomo.core'): self.assertFalse(m.I.add((1,))) - self.assertEqual( - output.getvalue(), "Element (1,) already exists in Set I; no action taken\n" - ) + # In Pyomo <= 6.7.3 duplicate values logged a warning. + self.assertEqual(output.getvalue(), "") m.J = Set() # Note that pypy raises a different exception from cpython - err = ( - r"Unable to insert '{}' into Set J:\n\tTypeError: " - r"((unhashable type: 'dict')|('dict' objects are unhashable))" - ) + err = r"((unhashable type: 'dict')|('dict' objects are unhashable))" with self.assertRaisesRegex(TypeError, err): m.J.add({}) @@ -4275,17 +4306,16 @@ def test_add_filter_validate(self): output = StringIO() with LoggingIntercept(output, 'pyomo.core'): self.assertFalse(m.J.add(1)) - self.assertEqual( - output.getvalue(), "Element 1 already exists in Set J; no action taken\n" - ) + # In Pyomo <= 6.7.3 duplicate values logged a warning. + self.assertEqual(output.getvalue(), "") + self.assertEqual(N, len(m.I)) def _l_tri(model, i, j): self.assertIs(model, m) return i >= j m.K = Set(initialize=RangeSet(3) * RangeSet(3), filter=_l_tri) - self.assertIsInstance(m.K.filter, IndexedCallInitializer) - self.assertIs(m.K.filter._fcn, _l_tri) + self.assertIsInstance(m.K._filter, ParameterizedScalarCallInitializer) self.assertEqual(list(m.K), [(1, 1), (2, 1), (2, 2), (3, 1), (3, 2), (3, 3)]) output = StringIO() @@ -4300,7 +4330,7 @@ def _l_tri(model, i, j): # This tests a filter that matches the dimentionality of the # component. construct() needs to recognize that the filter is # returning a constant in construct() and re-assign it to be the - # _filter for each _SetData + # _filter for each SetData def _lt_3(model, i): self.assertIs(model, m) return i < 3 @@ -4317,9 +4347,22 @@ def _lt_3(model, i): self.assertEqual(output.getvalue(), "") self.assertEqual(list(m.L[2]), [1, 2, 0]) + # This tests that the deprecation path works correctly in the + # case that the callback doesn't raise an error or ever return + # False + + def _l_off_diag(model, i, j): + self.assertIs(model, m) + return i != j + + m.M = Set(initialize=RangeSet(3) * RangeSet(3), filter=_l_off_diag) + self.assertIsInstance(m.M._filter, ParameterizedScalarCallInitializer) + self.assertEqual(list(m.M), [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)]) + m = ConcreteModel() - def _validate(model, i, j): + def _validate(model, val): + i, j = val self.assertIs(model, m) if i + j < 2: return True @@ -4327,44 +4370,188 @@ def _validate(model, i, j): return False raise RuntimeError("Bogus value") - m.I = Set(validate=_validate) + m.I1 = Set(validate=_validate) output = StringIO() with LoggingIntercept(output, 'pyomo.core'): - self.assertTrue(m.I.add((0, 1))) + self.assertTrue(m.I1.add((0, 1))) self.assertEqual(output.getvalue(), "") with self.assertRaisesRegex( ValueError, - r"The value=\(4, 1\) violates the validation rule of " r"Set I", + r"The value=\(4, 1\) violates the validation rule of " r"Set I1", ): - m.I.add((4, 1)) + m.I1.add((4, 1)) self.assertEqual(output.getvalue(), "") with self.assertRaisesRegex(RuntimeError, "Bogus value"): - m.I.add((2, 2)) + m.I1.add((2, 2)) self.assertEqual( output.getvalue(), - "Exception raised while validating element '(2, 2)' for Set I\n", + "Exception raised while validating element '(2, 2)' for Set I1\n", ) - # Note: one of these indices will trigger the exception in the - # validot when it is called for the index. - m.J = Set([(0, 0), (2, 2)], validate=_validate) - output = StringIO() - with LoggingIntercept(output, 'pyomo.core'): - self.assertTrue(m.J[2, 2].add((0, 1))) + def _validate(model, i, j): + self.assertIs(model, m) + if i + j < 2: + return True + if i - j > 2: + return False + raise RuntimeError("Bogus value") + + m.I2 = Set(validate=_validate) + with LoggingIntercept(module='pyomo.core') as output: + self.assertTrue(m.I2.add((0, 1))) + # Note that we are not emitting a deprecation warning (yet) + # for scalar sets + # self.assertEqual(output.getvalue(), "") + # output.getvalue().replace('\n', ' '), + # r"DEPRECATED: OrderedScalarSet I2: 'validate=' callback " + # r"signature matched \(block, \*value\). Please update the " + # r"callback to match the signature \(block, value\)", + # ) self.assertEqual(output.getvalue(), "") + with LoggingIntercept(module='pyomo.core') as output: with self.assertRaisesRegex( ValueError, - r"The value=\(4, 1\) violates the validation rule of " r"Set J\[0,0\]", + r"The value=\(4, 1\) violates the validation rule of " r"Set I2", ): - m.J[0, 0].add((4, 1)) - self.assertEqual(output.getvalue(), "") + m.I2.add((4, 1)) + self.assertEqual(output.getvalue(), "") + with LoggingIntercept(module='pyomo.core') as output: with self.assertRaisesRegex(RuntimeError, "Bogus value"): - m.J[2, 2].add((2, 2)) + m.I2.add((2, 2)) self.assertEqual( output.getvalue(), - "Exception raised while validating element '(2, 2)' for Set J[2,2]\n", + "Exception raised while validating element '(2, 2)' for Set I2\n", + ) + + m.J1 = Set([(0, 0), (2, 2)], validate=_validate) + with LoggingIntercept() as OUT: + self.assertTrue(m.J1[2, 2].add((0, 1))) + self.assertRegex( + OUT.getvalue().replace('\n', ' '), + r"DEPRECATED: InsertionOrderSetData J1\[2,2\]: 'validate=' callback " + r"signature matched \(block, \*value\). Please update the " + r"callback to match the signature \(block, value, \*index\)", + ) + with LoggingIntercept() as OUT: + with self.assertRaisesRegex( + ValueError, + r"The value=\(4, 1\) violates the validation rule of " r"Set J1\[0,0\]", + ): + m.J1[0, 0].add((4, 1)) + with self.assertRaisesRegex(RuntimeError, "Bogus value"): + m.J1[2, 2].add((2, 2)) + self.assertEqual( + OUT.getvalue(), + "Exception raised while validating element '(2, 2)' for Set J1[2,2]\n", ) + def _validate(model, i, j, ind1, ind2): + self.assertIs(model, m) + if i + j < ind1 + ind2: + return True + if i - j > ind1 + ind2: + return False + raise RuntimeError("Bogus value") + + m.J2 = Set([(0, 0), (2, 2)], validate=_validate) + with LoggingIntercept() as OUT: + self.assertTrue(m.J2[2, 2].add((0, 1))) + self.assertRegex( + OUT.getvalue().replace('\n', ' '), + r"DEPRECATED: InsertionOrderSetData J2\[2,2\]: 'validate=' callback " + r"signature matched \(block, \*value, \*index\). Please update the " + r"callback to match the signature \(block, value, \*index\)", + ) + + with LoggingIntercept() as OUT: + self.assertEqual(OUT.getvalue(), "") + with self.assertRaisesRegex( + ValueError, + r"The value=\(1, 0\) violates the validation rule of Set J2\[0,0\]", + ): + m.J2[0, 0].add((1, 0)) + with self.assertRaisesRegex( + ValueError, + r"The value=\(4, 1\) violates the validation rule of Set J2\[0,0\]", + ): + m.J2[0, 0].add((4, 1)) + self.assertEqual(OUT.getvalue(), "") + with self.assertRaisesRegex(RuntimeError, "Bogus value"): + m.J2[2, 2].add((2, 2)) + self.assertEqual( + OUT.getvalue(), + "Exception raised while validating element '(2, 2)' for Set J2[2,2]\n", + ) + + def _validate(model, v, ind1, ind2): + self.assertIs(model, m) + i, j = v + if i + j < ind1 + ind2: + return True + if i - j > ind1 + ind2: + return False + raise RuntimeError("Bogus value") + + m.J3 = Set([(0, 0), (2, 2)], validate=_validate) + with LoggingIntercept() as OUT: + self.assertTrue(m.J3[2, 2].add((0, 1))) + self.assertEqual(OUT.getvalue(), "") + + with LoggingIntercept() as OUT: + self.assertEqual(OUT.getvalue(), "") + with self.assertRaisesRegex( + ValueError, + r"The value=\(1, 0\) violates the validation rule of Set J3\[0,0\]", + ): + m.J3[0, 0].add((1, 0)) + with self.assertRaisesRegex( + ValueError, + r"The value=\(4, 1\) violates the validation rule of Set J3\[0,0\]", + ): + m.J3[0, 0].add((4, 1)) + self.assertEqual(OUT.getvalue(), "") + with self.assertRaisesRegex(RuntimeError, "Bogus value"): + m.J3[2, 2].add((2, 2)) + self.assertEqual( + OUT.getvalue(), + "Exception raised while validating element '(2, 2)' for Set J3[2,2]\n", + ) + + # Testing the processing of (deprecated) APIs that raise exceptions + def _validate(m, i, j): + assert i == 2 + assert j == 3 + raise RuntimeError("Bogus value") + + m.K1 = Set([1], dimen=2, validate=_validate) + with self.assertRaisesRegex(RuntimeError, "Bogus value"): + m.K1[1].add((2, 3)) + + # Testing the processing of (deprecated) APIs that raise exceptions + def _validate(m, i, j, k): + assert i == 2 + assert j == 3 + assert k == 1 + raise RuntimeError("Bogus value") + + m.K2 = Set([1], dimen=2, validate=_validate) + with self.assertRaisesRegex(RuntimeError, "Bogus value"): + m.K2[1].add((2, 3)) + + # Testing passing the validation rule by dict + _validate = {1: lambda m, i: i == 10, 2: lambda m, i: i == 20} + m.L = Set([1, 2], validate=_validate) + m.L[1].add(10) + with self.assertRaisesRegex( + ValueError, r"The value=20 violates the validation rule of Set L\[1\]" + ): + m.L[1].add(20) + with self.assertRaisesRegex( + ValueError, r"The value=10 violates the validation rule of Set L\[2\]" + ): + m.L[2].add(10) + m.L[2].add(20) + def test_domain(self): m = ConcreteModel() m.I = Set() @@ -4543,9 +4730,11 @@ def test_construction(self): m.I = Set(initialize=[1, 2, 3]) m.J = Set(initialize=[4, 5, 6]) m.K = Set(initialize=[(1, 4), (2, 6), (3, 5)], within=m.I * m.J) + m.L = Set(initialize=[1, 3], within=m.I) m.II = Set([1, 2, 3], initialize={1: [0], 2: [1, 2], 3: range(3)}) m.JJ = Set([1, 2, 3], initialize={1: [0], 2: [1, 2], 3: range(3)}) m.KK = Set([1, 2], initialize=[], dimen=lambda m, i: i) + m.LL = Set([2, 3], within=m.II, initialize={2: [1, 2], 3: [1]}) output = StringIO() m.I.pprint(ostream=output) @@ -4569,6 +4758,8 @@ def test_construction(self): 'I': [-1, 0], 'II': {1: [10, 11], 3: [30]}, 'K': [-1, 4, -1, 6, 0, 5], + 'L': [-1], + 'LL': {3: [30]}, } } ) @@ -4576,6 +4767,7 @@ def test_construction(self): self.assertEqual(list(i.I), [-1, 0]) self.assertEqual(list(i.J), [4, 5, 6]) self.assertEqual(list(i.K), [(-1, 4), (-1, 6), (0, 5)]) + self.assertEqual(list(i.L), [-1]) self.assertEqual(list(i.II[1]), [10, 11]) self.assertEqual(list(i.II[3]), [30]) self.assertEqual(list(i.JJ[1]), [0]) @@ -4583,9 +4775,11 @@ def test_construction(self): self.assertEqual(list(i.JJ[3]), [0, 1, 2]) self.assertEqual(list(i.KK[1]), []) self.assertEqual(list(i.KK[2]), []) + self.assertEqual(list(i.LL[3]), [30]) # Implicitly-constructed set should fall back on initialize! self.assertEqual(list(i.II[2]), [1, 2]) + self.assertEqual(list(i.LL[2]), [1, 2]) # Additional tests for tuplize: i = m.create_instance(data={None: {'K': [(1, 4), (2, 6)], 'KK': [1, 4, 2, 6]}}) @@ -5247,6 +5441,21 @@ def Bindex(m): self.assertIs(m.K.index_set()._domain, Integers) self.assertEqual(m.K.index_set(), [0, 1, 2, 3, 4]) + def test_normalize_index(self): + try: + _oldFlatten = normalize_index.flatten + normalize_index.flatten = True + + m = ConcreteModel() + with self.assertRaisesRegex( + ValueError, + r"The value=\(\(2, 3\),\) has dimension 2 and is not " + "valid for Set I which has dimen=1", + ): + m.I = Set(initialize=[1, ((2, 3),)]) + finally: + normalize_index.flatten = _oldFlatten + def test_no_normalize_index(self): try: _oldFlatten = normalize_index.flatten @@ -5297,15 +5506,15 @@ def test_no_normalize_index(self): class TestAbstractSetAPI(unittest.TestCase): - def test_SetData(self): + def testSetData(self): # This tests an anstract non-finite set API m = ConcreteModel() m.I = Set(initialize=[1]) - s = _SetData(m.I) + s = SetData(m.I) # - # _SetData API + # SetData API # with self.assertRaises(DeveloperError): @@ -5395,7 +5604,7 @@ def test_SetData(self): def test_FiniteMixin(self): # This tests an anstract finite set API - class FiniteMixin(_FiniteSetMixin, _SetData): + class FiniteMixin(_FiniteSetMixin, SetData): pass m = ConcreteModel() @@ -5403,7 +5612,7 @@ class FiniteMixin(_FiniteSetMixin, _SetData): s = FiniteMixin(m.I) # - # _SetData API + # SetData API # with self.assertRaises(DeveloperError): @@ -5520,7 +5729,7 @@ class FiniteMixin(_FiniteSetMixin, _SetData): def test_OrderedMixin(self): # This tests an anstract ordered set API - class OrderedMixin(_OrderedSetMixin, _FiniteSetMixin, _SetData): + class OrderedMixin(_OrderedSetMixin, _FiniteSetMixin, SetData): pass m = ConcreteModel() @@ -5528,7 +5737,7 @@ class OrderedMixin(_OrderedSetMixin, _FiniteSetMixin, _SetData): s = OrderedMixin(m.I) # - # _SetData API + # SetData API # with self.assertRaises(DeveloperError): @@ -5815,7 +6024,7 @@ def test_filter(self): output = StringIO() with LoggingIntercept(output, 'pyomo.core', logging.DEBUG): - self.assertIsInstance(m.K.filter, IndexedCallInitializer) + self.assertIsInstance(m.K.filter, ParameterizedScalarCallInitializer) self.assertRegex( output.getvalue(), "^DEPRECATED: 'filter' is no longer a public attribute" ) @@ -6267,7 +6476,6 @@ def test_issue_835(self): @unittest.skipIf(NamedTuple is None, "typing module not available") def test_issue_938(self): - self.maxDiff = None NodeKey = NamedTuple('NodeKey', [('id', int)]) ArcKey = NamedTuple('ArcKey', [('node_from', NodeKey), ('node_to', NodeKey)]) @@ -6389,3 +6597,209 @@ def test_issue_1112(self): self.assertEqual(len(vals), 1) self.assertIsInstance(vals[0], SetProduct_OrderedSet) self.assertIsNot(vals[0], cross) + + def test_issue_3284(self): + # test creating (indexed and non-indexed) sets using the within argument + # using concrete model and initialization + problem = ConcreteModel() + # non-indexed sets not using the within argument + problem.A = Set(initialize=[1, 2, 3]) + problem.B = Set(dimen=2, initialize=[(1, 2), (3, 4), (5, 6)]) + # non-indexed sets using within argument + problem.subset_A = Set(within=problem.A, initialize=[2, 3]) + problem.subset_B = Set(within=problem.B, dimen=2, initialize=[(1, 2), (5, 6)]) + # indexed sets not using the within argument + problem.C = Set(problem.A, initialize={1: [-1, 3], 2: [4, 7], 3: [3, 8]}) + problem.D = Set( + problem.B, initialize={(1, 2): [1, 5], (3, 4): [3], (5, 6): [6, 8, 9]} + ) + # indexed sets using an indexed set for the within argument + problem.subset_C = Set( + problem.A, within=problem.C, initialize={1: [-1], 2: [4], 3: [3, 8]} + ) + problem.subset_D = Set( + problem.B, + within=problem.D, + initialize={(1, 2): [1, 5], (3, 4): [], (5, 6): [6]}, + ) + # indexed sets using a non-indexed set for the within argument + problem.E = Set([0, 1], within=problem.A, initialize={0: [1, 2], 1: [3]}) + problem.F = Set( + [(1, 2, 3), (4, 5, 6)], + within=problem.B, + initialize={(1, 2, 3): [(1, 2)], (4, 5, 6): [(3, 4)]}, + ) + # check them + self.assertEqual(list(problem.A), [1, 2, 3]) + self.assertEqual(list(problem.B), [(1, 2), (3, 4), (5, 6)]) + self.assertEqual(list(problem.subset_A), [2, 3]) + self.assertEqual(list(problem.subset_B), [(1, 2), (5, 6)]) + self.assertEqual(list(problem.C[1]), [-1, 3]) + self.assertEqual(list(problem.C[2]), [4, 7]) + self.assertEqual(list(problem.C[3]), [3, 8]) + self.assertEqual(list(problem.D[(1, 2)]), [1, 5]) + self.assertEqual(list(problem.D[(3, 4)]), [3]) + self.assertEqual(list(problem.D[(5, 6)]), [6, 8, 9]) + self.assertEqual(list(problem.subset_C[1]), [-1]) + self.assertEqual(list(problem.subset_C[2]), [4]) + self.assertEqual(list(problem.subset_C[3]), [3, 8]) + self.assertEqual(list(problem.subset_D[(1, 2)]), [1, 5]) + self.assertEqual(list(problem.subset_D[(3, 4)]), []) + self.assertEqual(list(problem.subset_D[(5, 6)]), [6]) + self.assertEqual(list(problem.E[0]), [1, 2]) + self.assertEqual(list(problem.E[1]), [3]) + self.assertEqual(list(problem.F[(1, 2, 3)]), [(1, 2)]) + self.assertEqual(list(problem.F[(4, 5, 6)]), [(3, 4)]) + + # try adding elements to test the domains (1 compatible, 1 incompatible) + # set subset_A + problem.subset_A.add(1) + error_message = ( + "Cannot add value 4 to Set subset_A.\n\tThe value is not in the domain A" + ) + with self.assertRaisesRegex(ValueError, error_message): + problem.subset_A.add(4) + # set subset_B + problem.subset_B.add((3, 4)) + with self.assertRaisesRegex(ValueError, r".*Cannot add value \(7, 8\)"): + problem.subset_B.add((7, 8)) + # set subset_C + problem.subset_C[2].add(7) + with self.assertRaisesRegex(ValueError, ".*Cannot add value 8 to Set"): + problem.subset_C[2].add(8) + # set subset_D + problem.subset_D[(5, 6)].add(9) + with self.assertRaisesRegex(ValueError, ".*Cannot add value 2 to Set"): + problem.subset_D[(3, 4)].add(2) + # set E + problem.E[1].add(2) + with self.assertRaisesRegex(ValueError, ".*Cannot add value 4 to Set"): + problem.E[1].add(4) + # set F + problem.F[(1, 2, 3)].add((3, 4)) + with self.assertRaisesRegex(ValueError, r".*Cannot add value \(4, 3\)"): + problem.F[(4, 5, 6)].add((4, 3)) + # check them + self.assertEqual(list(problem.A), [1, 2, 3]) + self.assertEqual(list(problem.B), [(1, 2), (3, 4), (5, 6)]) + self.assertEqual(list(problem.subset_A), [2, 3, 1]) + self.assertEqual(list(problem.subset_B), [(1, 2), (5, 6), (3, 4)]) + self.assertEqual(list(problem.C[1]), [-1, 3]) + self.assertEqual(list(problem.C[2]), [4, 7]) + self.assertEqual(list(problem.C[3]), [3, 8]) + self.assertEqual(list(problem.D[(1, 2)]), [1, 5]) + self.assertEqual(list(problem.D[(3, 4)]), [3]) + self.assertEqual(list(problem.D[(5, 6)]), [6, 8, 9]) + self.assertEqual(list(problem.subset_C[1]), [-1]) + self.assertEqual(list(problem.subset_C[2]), [4, 7]) + self.assertEqual(list(problem.subset_C[3]), [3, 8]) + self.assertEqual(list(problem.subset_D[(1, 2)]), [1, 5]) + self.assertEqual(list(problem.subset_D[(3, 4)]), []) + self.assertEqual(list(problem.subset_D[(5, 6)]), [6, 9]) + self.assertEqual(list(problem.E[0]), [1, 2]) + self.assertEqual(list(problem.E[1]), [3, 2]) + self.assertEqual(list(problem.F[(1, 2, 3)]), [(1, 2), (3, 4)]) + self.assertEqual(list(problem.F[(4, 5, 6)]), [(3, 4)]) + + # using abstract model and no initialization + model = AbstractModel() + # non-indexed sets not using the within argument + model.A = Set() + model.B = Set(dimen=2) + # non-indexed sets using within argument + model.subset_A = Set(within=model.A) + model.subset_B = Set(within=model.B, dimen=2) + # indexed sets not using the within argument + model.C = Set(model.A) + model.D = Set(model.B) + # indexed sets using an indexed set for the within argument + model.subset_C = Set(model.A, within=model.C) + model.subset_D = Set(model.B, within=model.D) + # indexed sets using a non-indexed set for the within argument + model.E_index = Set() + model.F_index = Set() + model.E = Set(model.E_index, within=model.A) + model.F = Set(model.F_index, within=model.B) + problem = model.create_instance( + data={ + None: { + 'A': [3, 4, 5], + 'B': [(1, 2), (7, 8)], + 'subset_A': [3, 4], + 'subset_B': [(1, 2)], + 'C': {3: [3], 4: [4, 8], 5: [5, 6]}, + 'D': {(1, 2): [2], (7, 8): [0, 1]}, + 'subset_C': {3: [3], 4: [8], 5: []}, + 'subset_D': {(1, 2): [], (7, 8): [0, 1]}, + 'E_index': [0, 1], + 'F_index': [(1, 2, 3), (4, 5, 6)], + 'E': {0: [3, 4], 1: [5]}, + 'F': {(1, 2, 3): [(1, 2)], (4, 5, 6): [(7, 8)]}, + } + } + ) + + # check them + self.assertEqual(list(problem.A), [3, 4, 5]) + self.assertEqual(list(problem.B), [(1, 2), (7, 8)]) + self.assertEqual(list(problem.subset_A), [3, 4]) + self.assertEqual(list(problem.subset_B), [(1, 2)]) + self.assertEqual(list(problem.C[3]), [3]) + self.assertEqual(list(problem.C[4]), [4, 8]) + self.assertEqual(list(problem.C[5]), [5, 6]) + self.assertEqual(list(problem.D[(1, 2)]), [2]) + self.assertEqual(list(problem.D[(7, 8)]), [0, 1]) + self.assertEqual(list(problem.subset_C[3]), [3]) + self.assertEqual(list(problem.subset_C[4]), [8]) + self.assertEqual(list(problem.subset_C[5]), []) + self.assertEqual(list(problem.subset_D[(1, 2)]), []) + self.assertEqual(list(problem.subset_D[(7, 8)]), [0, 1]) + self.assertEqual(list(problem.E[0]), [3, 4]) + self.assertEqual(list(problem.E[1]), [5]) + self.assertEqual(list(problem.F[(1, 2, 3)]), [(1, 2)]) + self.assertEqual(list(problem.F[(4, 5, 6)]), [(7, 8)]) + + # try adding elements to test the domains (1 compatible, 1 incompatible) + # set subset_A + problem.subset_A.add(5) + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): + problem.subset_A.add(6) + # set subset_B + problem.subset_B.add((7, 8)) + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): + problem.subset_B.add((3, 4)) + # set subset_C + problem.subset_C[4].add(4) + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): + problem.subset_C[4].add(9) + # set subset_D + problem.subset_D[(1, 2)].add(2) + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): + problem.subset_D[(1, 2)].add(3) + # set E + problem.E[1].add(4) + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): + problem.E[1].add(1) + # set F + problem.F[(1, 2, 3)].add((7, 8)) + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): + problem.F[(4, 5, 6)].add((4, 3)) + # check them + self.assertEqual(list(problem.A), [3, 4, 5]) + self.assertEqual(list(problem.B), [(1, 2), (7, 8)]) + self.assertEqual(list(problem.subset_A), [3, 4, 5]) + self.assertEqual(list(problem.subset_B), [(1, 2), (7, 8)]) + self.assertEqual(list(problem.C[3]), [3]) + self.assertEqual(list(problem.C[4]), [4, 8]) + self.assertEqual(list(problem.C[5]), [5, 6]) + self.assertEqual(list(problem.D[(1, 2)]), [2]) + self.assertEqual(list(problem.D[(7, 8)]), [0, 1]) + self.assertEqual(list(problem.subset_C[3]), [3]) + self.assertEqual(list(problem.subset_C[4]), [8, 4]) + self.assertEqual(list(problem.subset_C[5]), []) + self.assertEqual(list(problem.subset_D[(1, 2)]), [2]) + self.assertEqual(list(problem.subset_D[(7, 8)]), [0, 1]) + self.assertEqual(list(problem.E[0]), [3, 4]) + self.assertEqual(list(problem.E[1]), [5, 4]) + self.assertEqual(list(problem.F[(1, 2, 3)]), [(1, 2), (7, 8)]) + self.assertEqual(list(problem.F[(4, 5, 6)]), [(7, 8)]) diff --git a/pyomo/core/tests/unit/test_sets.py b/pyomo/core/tests/unit/test_sets.py index 48869397aae..43a8bb732a2 100644 --- a/pyomo/core/tests/unit/test_sets.py +++ b/pyomo/core/tests/unit/test_sets.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -1051,7 +1051,7 @@ def setUp(self): self.instance = self.model.create_instance(currdir + "setA.dat") self.e1 = numpy.bool_(1) self.e2 = numpy.int_(2) - self.e3 = numpy.float_(3.0) + self.e3 = numpy.float64(3.0) self.e4 = numpy.int_(4) self.e5 = numpy.int_(5) self.e6 = numpy.int_(6) @@ -1068,7 +1068,7 @@ def test_numpy_int(self): def test_numpy_float(self): model = ConcreteModel() - model.A = Set(initialize=[numpy.float_(1.0), numpy.float_(0.0)]) + model.A = Set(initialize=[numpy.float64(1.0), numpy.float64(0.0)]) self.assertEqual(model.A.bounds(), (0, 1)) @@ -2396,24 +2396,18 @@ def test_dimen1(self): self.model.A = Set(initialize=[1, 2, 3], dimen=1) self.instance = self.model.create_instance() # - try: + with self.assertRaisesRegex(ValueError, ".*Cannot tuplize list data for set"): self.model.A = Set(initialize=[4, 5, 6], dimen=2) self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("test_dimen") - # + self.model.A = Set(initialize=[(1, 2), (2, 3), (3, 4)], dimen=2) self.instance = self.model.create_instance() # - try: + with self.assertRaisesRegex( + ValueError, ".*has dimension 2 and is not valid for " + ): self.model.A = Set(initialize=[(1, 2), (2, 3), (3, 4)], dimen=1) self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("test_dimen") # def f(model): @@ -2422,22 +2416,19 @@ def f(model): self.model.A = Set(initialize=f, dimen=2) self.instance = self.model.create_instance() # - try: + with self.assertRaisesRegex( + ValueError, ".*has dimension 2 and is not valid for " + ): self.model.A = Set(initialize=f, dimen=3) self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("test_dimen") def test_dimen2(self): - try: + with self.assertRaisesRegex( + ValueError, ".*has dimension 2 and is not valid for " + ): self.model.A = Set(initialize=[1, 2, (3, 4)]) self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("test_dimen2") + self.model.A = Set(dimen=None, initialize=[1, 2, (3, 4)]) self.instance = self.model.create_instance() @@ -2496,7 +2487,7 @@ def tmp_init(model, z): self.instance = self.model.create_instance(currdir + "setA.dat") self.assertEqual(len(self.instance.A), 5) - def test_within1(self): + def test_within_fail(self): # # Create Set 'A' data file # @@ -2507,14 +2498,10 @@ def test_within1(self): # Create A with an error # self.model.A = Set(within=Integers) - try: + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - pass - else: - self.fail("fail test_within1") - def test_within2(self): + def test_within_pass(self): # # Create Set 'A' data file # @@ -2522,17 +2509,12 @@ def test_within2(self): OUTPUT.write("data; set A := 1 3 5 7.5; end;") OUTPUT.close() # - # Create A with an error + # Create A without an error # self.model.A = Set(within=Reals) - try: - self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - self.fail("fail test_within2") - else: - pass + self.instance = self.model.create_instance(currdir + "setA.dat") - def test_validation1(self): + def test_validation_fail(self): # # Create Set 'A' data file # @@ -2543,14 +2525,10 @@ def test_validation1(self): # Create A with an error # self.model.A = Set(validate=lambda model, x: x < 6) - try: + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - pass - else: - self.fail("fail test_validation1") - def test_validation2(self): + def test_validation_pass(self): # # Create Set 'A' data file # @@ -2558,35 +2536,22 @@ def test_validation2(self): OUTPUT.write("data; set A := 1 3 5 5.5; end;") OUTPUT.close() # - # Create A with an error + # Create A without an error # self.model.A = Set(validate=lambda model, x: x < 6) - try: - self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - self.fail("fail test_validation2") - else: - pass + self.instance = self.model.create_instance(currdir + "setA.dat") def test_other1(self): self.model.A = Set( initialize=[1, 2, 3, 'A'], validate=lambda model, x: x in Integers ) - try: + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("fail test_other1") def test_other2(self): self.model.A = Set(initialize=[1, 2, 3, 'A'], within=Integers) - try: + with self.assertRaisesRegex(ValueError, ".*Cannot add value"): self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("fail test_other1") def test_other3(self): OUTPUT = open(currdir + "setA.dat", "w") @@ -2601,12 +2566,8 @@ def tmp_init(model): self.model.n = Param() self.model.A = Set(initialize=tmp_init, validate=lambda model, x: x in Integers) - try: + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - pass - else: - self.fail("fail test_other1") def test_other4(self): OUTPUT = open(currdir + "setA.dat", "w") @@ -2621,12 +2582,8 @@ def tmp_init(model): self.model.n = Param() self.model.A = Set(initialize=tmp_init, within=Integers) - try: + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - pass - else: - self.fail("fail test_other1") class TestSetArgs2(PyomoModel): @@ -2666,24 +2623,18 @@ def test_dimen(self): self.model.Z = Set(initialize=[1, 2]) self.model.A = Set(self.model.Z, initialize=[1, 2, 3], dimen=1) self.instance = self.model.create_instance() - try: + with self.assertRaisesRegex(ValueError, ".*Cannot tuplize list data for set"): self.model.A = Set(self.model.Z, initialize=[4, 5, 6], dimen=2) self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("test_dimen") self.model.A = Set(self.model.Z, initialize=[(1, 2), (2, 3), (3, 4)], dimen=2) self.instance = self.model.create_instance() - try: + with self.assertRaisesRegex( + ValueError, ".*has dimension 2 and is not valid for" + ): self.model.A = Set( self.model.Z, initialize=[(1, 2), (2, 3), (3, 4)], dimen=1 ) self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("test_dimen") def test_rule(self): # @@ -2753,12 +2704,8 @@ def test_within1(self): # self.model.Z = Set() self.model.A = Set(self.model.Z, within=Integers) - try: + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - pass - else: - self.fail("fail test_within1") def test_within2(self): # @@ -2768,16 +2715,11 @@ def test_within2(self): OUTPUT.write("data; set Z := A C; set A[A] := 1 3 5 7.5; end;") OUTPUT.close() # - # Create A with an error + # Create A without an error # self.model.Z = Set() self.model.A = Set(self.model.Z, within=Reals) - try: - self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - self.fail("fail test_within2") - else: - pass + self.instance = self.model.create_instance(currdir + "setA.dat") def test_validation1(self): # @@ -2791,12 +2733,8 @@ def test_validation1(self): # self.model.Z = Set() self.model.A = Set(self.model.Z, validate=lambda model, x: x < 6) - try: + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - pass - else: - self.fail("fail test_within1") def test_validation2(self): # @@ -2806,16 +2744,120 @@ def test_validation2(self): OUTPUT.write("data; set Z := A C; set A[A] := 1 3 5 5.5; end;") OUTPUT.close() # + # Create A without an error + # + self.model.Z = Set() + self.model.A = Set(self.model.Z, validate=lambda model, x, i: x < 6) + self.instance = self.model.create_instance(currdir + "setA.dat") + + def test_validation3_pass(self): + # + # Create data file to test a successful validation using indexed sets + # + OUTPUT = open(currdir + "setsAB.dat", "w") + OUTPUT.write( + "data; set Z := A C; set A[A] := 1 3 5 5.5; set B[A] := 1 3 5; end;" + ) + OUTPUT.close() + # # Create A with an error # self.model.Z = Set() - self.model.A = Set(self.model.Z, validate=lambda model, x: x < 6) - try: - self.instance = self.model.create_instance(currdir + "setA.dat") - except ValueError: - self.fail("fail test_within2") - else: - pass + self.model.A = Set(self.model.Z, validate=lambda model, x, i: x < 6) + self.model.B = Set(self.model.Z, validate=lambda model, x, i: x in model.A[i]) + self.instance = self.model.create_instance(currdir + "setsAB.dat") + + def test_validation3_fail(self): + # + # Create data file to test a failed validation using indexed sets + # + OUTPUT = open(currdir + "setsAB.dat", "w") + OUTPUT.write( + "data; set Z := A C; set A[A] := 1 3 5 5.5; set B[A] := 1 3 5 6; end;" + ) + OUTPUT.close() + # + # Create A with an error + # + self.model.Z = Set() + self.model.A = Set(self.model.Z, validate=lambda model, x, i: x < 6) + self.model.B = Set(self.model.Z, validate=lambda model, x, i: x in model.A[i]) + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): + self.instance = self.model.create_instance(currdir + "setsAB.dat") + + def test_validation4_pass(self): + # + # Test a successful validation using indexed sets and tuple entries + # + self.model.Z = Set(initialize=['A', 'B']) + self.model.A = Set( + self.model.Z, dimen=2, initialize={'A': [(1, 2), (3, 4)], 'B': [(5, 6)]} + ) + self.model.B = Set( + self.model.Z, + dimen=2, + initialize={'A': [(1, 2), (3, 4)]}, + validate=lambda model, x, y, i: (x, y) in model.A[i], + ) + self.instance = self.model.create_instance() + + def test_validation4_fail(self): + # + # Test a failed validation using indexed sets and tuple entries + # + self.model.Z = Set(initialize=['A', 'B']) + self.model.A = Set( + self.model.Z, dimen=2, initialize={'A': [(1, 2), (3, 4)], 'B': [(5, 6)]} + ) + self.model.B = Set( + self.model.Z, + dimen=2, + initialize={'A': [(1, 2), (3, 4), (5, 6)]}, + validate=lambda model, x, y, i: (x, y) in model.A[i], + ) + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): + self.instance = self.model.create_instance() + + def test_validation5_pass(self): + # + # Test a successful validation using indexed sets and tuple entries + # + self.model.Z = Set(initialize=['A', 'B']) + self.model.A = Set( + self.model.Z, dimen=2, initialize={'A': [(1, 2), (3, 4)], 'B': [(5, 6)]} + ) + + def validate_B(m, e1, e2, i): + return (e1, e2) in m.A[i] + + self.model.B = Set( + self.model.Z, + dimen=2, + initialize={'A': [(1, 2), (3, 4)]}, + validate=validate_B, + ) + self.instance = self.model.create_instance() + + def test_validation5_fail(self): + # + # Test a failed validation using indexed sets and tuple entries + # + self.model.Z = Set(initialize=['A', 'B']) + self.model.A = Set( + self.model.Z, dimen=2, initialize={'A': [(1, 2), (3, 4)], 'B': [(5, 6)]} + ) + + def validate_B(m, e1, e2, i): + return (e1, e2) in m.A[i] + + self.model.B = Set( + self.model.Z, + dimen=2, + initialize={'A': [(1, 2), (3, 4), (5, 6)]}, + validate=validate_B, + ) + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): + self.instance = self.model.create_instance() def test_other1(self): self.model.Z = Set(initialize=['A']) @@ -2824,24 +2866,16 @@ def test_other1(self): initialize={'A': [1, 2, 3, 'A']}, validate=lambda model, x: x in Integers, ) - try: + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("fail test_other1") def test_other2(self): self.model.Z = Set(initialize=['A']) self.model.A = Set( self.model.Z, initialize={'A': [1, 2, 3, 'A']}, within=Integers ) - try: + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("fail test_other1") def test_other3(self): def tmp_init(model, i): @@ -2855,12 +2889,8 @@ def tmp_init(model, i): self.model.A = Set( self.model.Z, initialize=tmp_init, validate=lambda model, x: x in Integers ) - try: + with self.assertRaisesRegex(ValueError, ".*violates the validation rule of"): self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("fail test_other1") def test_other4(self): def tmp_init(model, i): @@ -2873,12 +2903,8 @@ def tmp_init(model, i): self.model.Z = Set(initialize=['A']) self.model.A = Set(self.model.Z, initialize=tmp_init, within=Integers) self.model.B = Set(self.model.Z, initialize=tmp_init, within=Integers) - try: + with self.assertRaisesRegex(ValueError, ".*Cannot add value "): self.instance = self.model.create_instance() - except ValueError: - pass - else: - self.fail("fail test_other1") class TestMisc(PyomoModel): @@ -2953,7 +2979,7 @@ def test_initialize_and_clone_from_dict_keys(self): # # While deepcopying a model is generally not supported, this is # an easy way to ensure that this simple model is cleanly - # clonable. + # cloneable. ref = """1 Set Declarations INDEX : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members @@ -3213,7 +3239,7 @@ def test_numpy_membership(self): self.assertEqual(numpy.int_(1) in Boolean, True) self.assertEqual(numpy.bool_(True) in Boolean, True) self.assertEqual(numpy.bool_(False) in Boolean, True) - self.assertEqual(numpy.float_(1.1) in Boolean, False) + self.assertEqual(numpy.float64(1.1) in Boolean, False) self.assertEqual(numpy.int_(2) in Boolean, False) self.assertEqual(numpy.int_(0) in Integers, True) @@ -3222,7 +3248,7 @@ def test_numpy_membership(self): # identically to 1 self.assertEqual(numpy.bool_(True) in Integers, True) self.assertEqual(numpy.bool_(False) in Integers, True) - self.assertEqual(numpy.float_(1.1) in Integers, False) + self.assertEqual(numpy.float64(1.1) in Integers, False) self.assertEqual(numpy.int_(2) in Integers, True) self.assertEqual(numpy.int_(0) in Reals, True) @@ -3231,14 +3257,14 @@ def test_numpy_membership(self): # identically to 1 self.assertEqual(numpy.bool_(True) in Reals, True) self.assertEqual(numpy.bool_(False) in Reals, True) - self.assertEqual(numpy.float_(1.1) in Reals, True) + self.assertEqual(numpy.float64(1.1) in Reals, True) self.assertEqual(numpy.int_(2) in Reals, True) self.assertEqual(numpy.int_(0) in Any, True) self.assertEqual(numpy.int_(1) in Any, True) self.assertEqual(numpy.bool_(True) in Any, True) self.assertEqual(numpy.bool_(False) in Any, True) - self.assertEqual(numpy.float_(1.1) in Any, True) + self.assertEqual(numpy.float64(1.1) in Any, True) self.assertEqual(numpy.int_(2) in Any, True) def test_setargs1(self): diff --git a/pyomo/core/tests/unit/test_smap.py b/pyomo/core/tests/unit/test_smap.py index 69448916a04..0394f986602 100644 --- a/pyomo/core/tests/unit/test_smap.py +++ b/pyomo/core/tests/unit/test_smap.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_sos.py b/pyomo/core/tests/unit/test_sos.py index cacfcdf5d42..9b2db8cfd4a 100644 --- a/pyomo/core/tests/unit/test_sos.py +++ b/pyomo/core/tests/unit/test_sos.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_sos_v2.py b/pyomo/core/tests/unit/test_sos_v2.py index 996dd10829d..32e10933fb4 100644 --- a/pyomo/core/tests/unit/test_sos_v2.py +++ b/pyomo/core/tests/unit/test_sos_v2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_suffix.py b/pyomo/core/tests/unit/test_suffix.py index d2e861cceb5..099b8c47c7a 100644 --- a/pyomo/core/tests/unit/test_suffix.py +++ b/pyomo/core/tests/unit/test_suffix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -1795,47 +1795,77 @@ def test_suffix_finder(self): m.b1.b2 = Block() m.b1.b2.v3 = Var([0]) - _suffix_finder = SuffixFinder('suffix') - # Add Suffixes m.suffix = Suffix(direction=Suffix.EXPORT) # No suffix on b1 - make sure we can handle missing suffixes m.b1.b2.suffix = Suffix(direction=Suffix.EXPORT) + _suffix_finder = SuffixFinder('suffix') + _suffix_b1_finder = SuffixFinder('suffix', context=m.b1) + _suffix_b2_finder = SuffixFinder('suffix', context=m.b1.b2) + # Check for no suffix value - assert _suffix_finder.find(m.b1.b2.v3[0]) == None + self.assertEqual(_suffix_finder.find(m.b1.b2.v3[0]), None) + self.assertEqual(_suffix_b1_finder.find(m.b1.b2.v3[0]), None) + self.assertEqual(_suffix_b2_finder.find(m.b1.b2.v3[0]), None) # Check finding default values # Add a default at the top level m.suffix[None] = 1 - assert _suffix_finder.find(m.b1.b2.v3[0]) == 1 + self.assertEqual(_suffix_finder.find(m.b1.b2.v3[0]), 1) + self.assertEqual(_suffix_b1_finder.find(m.b1.b2.v3[0]), None) + self.assertEqual(_suffix_b2_finder.find(m.b1.b2.v3[0]), None) # Add a default suffix at a lower level m.b1.b2.suffix[None] = 2 - assert _suffix_finder.find(m.b1.b2.v3[0]) == 2 + self.assertEqual(_suffix_finder.find(m.b1.b2.v3[0]), 2) + self.assertEqual(_suffix_b1_finder.find(m.b1.b2.v3[0]), 2) + self.assertEqual(_suffix_b2_finder.find(m.b1.b2.v3[0]), 2) # Check for container at lowest level m.b1.b2.suffix[m.b1.b2.v3] = 3 - assert _suffix_finder.find(m.b1.b2.v3[0]) == 3 + self.assertEqual(_suffix_finder.find(m.b1.b2.v3[0]), 3) + self.assertEqual(_suffix_b1_finder.find(m.b1.b2.v3[0]), 3) + self.assertEqual(_suffix_b2_finder.find(m.b1.b2.v3[0]), 3) # Check for container at top level m.suffix[m.b1.b2.v3] = 4 - assert _suffix_finder.find(m.b1.b2.v3[0]) == 4 + self.assertEqual(_suffix_finder.find(m.b1.b2.v3[0]), 4) + self.assertEqual(_suffix_b1_finder.find(m.b1.b2.v3[0]), 3) + self.assertEqual(_suffix_b2_finder.find(m.b1.b2.v3[0]), 3) # Check for specific values at lowest level m.b1.b2.suffix[m.b1.b2.v3[0]] = 5 - assert _suffix_finder.find(m.b1.b2.v3[0]) == 5 + self.assertEqual(_suffix_finder.find(m.b1.b2.v3[0]), 5) + self.assertEqual(_suffix_b1_finder.find(m.b1.b2.v3[0]), 5) + self.assertEqual(_suffix_b2_finder.find(m.b1.b2.v3[0]), 5) # Check for specific values at top level m.suffix[m.b1.b2.v3[0]] = 6 - assert _suffix_finder.find(m.b1.b2.v3[0]) == 6 + self.assertEqual(_suffix_finder.find(m.b1.b2.v3[0]), 6) + self.assertEqual(_suffix_b1_finder.find(m.b1.b2.v3[0]), 5) + self.assertEqual(_suffix_b2_finder.find(m.b1.b2.v3[0]), 5) # Make sure we don't find default suffixes at lower levels - assert _suffix_finder.find(m.b1.v2) == 1 + self.assertEqual(_suffix_finder.find(m.b1.v2), 1) + self.assertEqual(_suffix_b1_finder.find(m.b1.v2), None) + self.assertEqual(_suffix_b2_finder.find(m.b1.v2), None) # Make sure we don't find specific suffixes at lower levels m.b1.b2.suffix[m.v1] = 5 - assert _suffix_finder.find(m.v1) == 1 + self.assertEqual(_suffix_finder.find(m.v1), 1) + self.assertEqual(_suffix_b1_finder.find(m.v1), None) + self.assertEqual(_suffix_b2_finder.find(m.v1), None) + + # Make sure we can look up Blocks and that they will match + # suffixes that they hold + self.assertEqual(_suffix_finder.find(m.b1.b2), 2) + self.assertEqual(_suffix_b1_finder.find(m.b1.b2), 2) + self.assertEqual(_suffix_b2_finder.find(m.b1.b2), 2) + + self.assertEqual(_suffix_finder.find(m.b1), 1) + self.assertEqual(_suffix_b1_finder.find(m.b1), None) + self.assertEqual(_suffix_b2_finder.find(m.b1), None) if __name__ == "__main__": diff --git a/pyomo/core/tests/unit/test_symbol_map.py b/pyomo/core/tests/unit/test_symbol_map.py index 773e6d335f1..07142b1abda 100644 --- a/pyomo/core/tests/unit/test_symbol_map.py +++ b/pyomo/core/tests/unit/test_symbol_map.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_symbolic.py b/pyomo/core/tests/unit/test_symbolic.py index 91887f27bb7..30bd9120882 100644 --- a/pyomo/core/tests/unit/test_symbolic.py +++ b/pyomo/core/tests/unit/test_symbolic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_taylor_series.py b/pyomo/core/tests/unit/test_taylor_series.py index 4b36451d222..f2482aa8965 100644 --- a/pyomo/core/tests/unit/test_taylor_series.py +++ b/pyomo/core/tests/unit/test_taylor_series.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_template_expr.py b/pyomo/core/tests/unit/test_template_expr.py index 4f255e3567a..53d869f5136 100644 --- a/pyomo/core/tests/unit/test_template_expr.py +++ b/pyomo/core/tests/unit/test_template_expr.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -127,7 +127,7 @@ def test_template_scalar_with_set(self): # Note that structural expressions do not implement polynomial_degree with self.assertRaisesRegex( AttributeError, - "'_InsertionOrderSetData' object has " "no attribute 'polynomial_degree'", + "'InsertionOrderSetData' object has " "no attribute 'polynomial_degree'", ): e.polynomial_degree() self.assertEqual(str(e), "s[{I}]") @@ -490,14 +490,14 @@ def c(m): self.assertEqual( str(resolve_template(template)), 'x[1,1,10] + ' - '(x[2,1,10] + x[2,1,20]) + ' - '(x[3,1,10] + x[3,1,20] + x[3,1,30]) + ' - '(x[1,2,10]) + ' - '(x[2,2,10] + x[2,2,20]) + ' - '(x[3,2,10] + x[3,2,20] + x[3,2,30]) + ' - '(x[1,3,10]) + ' - '(x[2,3,10] + x[2,3,20]) + ' - '(x[3,3,10] + x[3,3,20] + x[3,3,30]) <= 0', + 'x[2,1,10] + x[2,1,20] + ' + 'x[3,1,10] + x[3,1,20] + x[3,1,30] + ' + 'x[1,2,10] + ' + 'x[2,2,10] + x[2,2,20] + ' + 'x[3,2,10] + x[3,2,20] + x[3,2,30] + ' + 'x[1,3,10] + ' + 'x[2,3,10] + x[2,3,20] + ' + 'x[3,3,10] + x[3,3,20] + x[3,3,30] <= 0', ) def test_multidim_nested_sum_rule(self): @@ -566,14 +566,14 @@ def c(m): self.assertEqual( str(resolve_template(template)), 'x[1,1,10] + ' - '(x[2,1,10] + x[2,1,20]) + ' - '(x[3,1,10] + x[3,1,20] + x[3,1,30]) + ' - '(x[1,2,10]) + ' - '(x[2,2,10] + x[2,2,20]) + ' - '(x[3,2,10] + x[3,2,20] + x[3,2,30]) + ' - '(x[1,3,10]) + ' - '(x[2,3,10] + x[2,3,20]) + ' - '(x[3,3,10] + x[3,3,20] + x[3,3,30]) <= 0', + 'x[2,1,10] + x[2,1,20] + ' + 'x[3,1,10] + x[3,1,20] + x[3,1,30] + ' + 'x[1,2,10] + ' + 'x[2,2,10] + x[2,2,20] + ' + 'x[3,2,10] + x[3,2,20] + x[3,2,30] + ' + 'x[1,3,10] + ' + 'x[2,3,10] + x[2,3,20] + ' + 'x[3,3,10] + x[3,3,20] + x[3,3,30] <= 0', ) def test_multidim_nested_getattr_sum_rule(self): @@ -609,14 +609,14 @@ def c(m): self.assertEqual( str(resolve_template(template)), 'x[1,1,10] + ' - '(x[2,1,10] + x[2,1,20]) + ' - '(x[3,1,10] + x[3,1,20] + x[3,1,30]) + ' - '(x[1,2,10]) + ' - '(x[2,2,10] + x[2,2,20]) + ' - '(x[3,2,10] + x[3,2,20] + x[3,2,30]) + ' - '(x[1,3,10]) + ' - '(x[2,3,10] + x[2,3,20]) + ' - '(x[3,3,10] + x[3,3,20] + x[3,3,30]) <= 0', + 'x[2,1,10] + x[2,1,20] + ' + 'x[3,1,10] + x[3,1,20] + x[3,1,30] + ' + 'x[1,2,10] + ' + 'x[2,2,10] + x[2,2,20] + ' + 'x[3,2,10] + x[3,2,20] + x[3,2,30] + ' + 'x[1,3,10] + ' + 'x[2,3,10] + x[2,3,20] + ' + 'x[3,3,10] + x[3,3,20] + x[3,3,30] <= 0', ) def test_eval_getattr(self): diff --git a/pyomo/core/tests/unit/test_units.py b/pyomo/core/tests/unit/test_units.py index bda62835711..88d40ff81b7 100644 --- a/pyomo/core/tests/unit/test_units.py +++ b/pyomo/core/tests/unit/test_units.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_var.py b/pyomo/core/tests/unit/test_var.py index 6b2e92be832..581c00fc31e 100644 --- a/pyomo/core/tests/unit/test_var.py +++ b/pyomo/core/tests/unit/test_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/test_var_set_bounds.py b/pyomo/core/tests/unit/test_var_set_bounds.py index bae89556ce3..36141c3546b 100644 --- a/pyomo/core/tests/unit/test_var_set_bounds.py +++ b/pyomo/core/tests/unit/test_var_set_bounds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -36,7 +36,7 @@ # GAH: These tests been temporarily disabled. It is no longer the job of Var # to validate its domain at the time of construction. It only needs to # ensure that whatever object is passed as its domain is suitable for -# interacting with the _VarData interface (e.g., has a bounds method) +# interacting with the VarData interface (e.g., has a bounds method) # The plan is to start adding functionality to the solver interfaces # that will support custom domains. diff --git a/pyomo/core/tests/unit/test_visitor.py b/pyomo/core/tests/unit/test_visitor.py index fada7d6f6b2..07c5fb5c03b 100644 --- a/pyomo/core/tests/unit/test_visitor.py +++ b/pyomo/core/tests/unit/test_visitor.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -60,6 +60,7 @@ from pyomo.core.expr.visitor import ( FixedExpressionError, NonConstantExpressionError, + SimpleExpressionVisitor, StreamBasedExpressionVisitor, ExpressionReplacementVisitor, evaluate_expression, @@ -72,7 +73,7 @@ RECURSION_LIMIT, get_stack_depth, ) -from pyomo.core.base.param import _ParamData, ScalarParam +from pyomo.core.base.param import ParamData, ScalarParam from pyomo.core.expr.template_expr import IndexTemplate from pyomo.common.collections import ComponentSet from pyomo.common.errors import TemplateExpressionError @@ -130,6 +131,72 @@ def test_identify_vars_expr(self): self.assertEqual(list(identify_variables(m.E[0])), [m.a]) self.assertEqual(list(identify_variables(m.E[1])), [m.b]) + def test_identify_vars_expr_cache(self): + # + # Identify variables in named expressions + # + m = ConcreteModel() + m.a = Var(initialize=1) + m.b = Var(initialize=2) + m.c = Var(initialize=3) + m.d = Var(initialize=4) + m.e = Expression(expr=3 * m.a) + + cache = {} + self.assertEqual( + list(identify_variables(m.b + m.e, named_expression_cache=cache)), + [m.b, m.a], + ) + self.assertEqual(cache, {id(m.e): ({id(m.a): m.a}, {id(m.e): (m.e, m.e.expr)})}) + + # Check that the cache is used (to check, we will cause the cache to lie) + s, e = cache[id(m.e)] + s.clear() + s.update({id(m.b): m.b, id(m.c): m.c}) + self.assertEqual( + list(identify_variables(m.b + m.e, named_expression_cache=cache)), + [m.b, m.c], + ) + + # Check that changing the expression invalidates the cache + m.e = 4 * m.d + self.assertEqual( + list(identify_variables(m.b + m.e, named_expression_cache=cache)), + [m.b, m.d], + ) + + # Check that changing a nested expression invalidates the cache + m.f = Expression(expr=5 * m.a * m.e * m.b) + self.assertEqual( + list(identify_variables(m.c + m.f, named_expression_cache=cache)), + [m.c, m.a, m.d, m.b], + ) + self.assertEqual( + cache, + { + id(m.e): ({id(m.d): m.d}, {id(m.e): (m.e, m.e.expr)}), + id(m.f): ( + {id(m.a): m.a, id(m.d): m.d, id(m.b): m.b}, + {id(m.f): (m.f, m.f.expr), id(m.e): (m.e, m.e.expr)}, + ), + }, + ) + m.e = 5 + self.assertEqual( + list(identify_variables(m.c + m.f, named_expression_cache=cache)), + [m.c, m.a, m.b], + ) + self.assertEqual( + cache, + { + id(m.e): ({}, {id(m.e): (m.e, m.e.expr)}), + id(m.f): ( + {id(m.a): m.a, id(m.b): m.b}, + {id(m.f): (m.f, m.f.expr), id(m.e): (m.e, m.e.expr)}, + ), + }, + ) + def test_identify_vars_vars(self): m = ConcreteModel() m.I = RangeSet(3) @@ -145,7 +212,8 @@ def test_identify_vars_vars(self): self.assertEqual(list(identify_variables(m.a + m.b[1])), [m.a, m.b[1]]) self.assertEqual(list(identify_variables(m.a ** m.b[1])), [m.a, m.b[1]]) self.assertEqual( - list(identify_variables(m.a ** m.b[1] + m.b[2])), [m.b[2], m.a, m.b[1]] + ComponentSet(identify_variables(m.a ** m.b[1] + m.b[2])), + ComponentSet([m.b[2], m.a, m.b[1]]), ) self.assertEqual( list(identify_variables(m.a ** m.b[1] + m.b[2] * m.b[3] * m.b[2])), @@ -159,14 +227,20 @@ def test_identify_vars_vars(self): # Identify variables in the arguments to functions # self.assertEqual( - list(identify_variables(m.x(m.a, 'string_param', 1, []) * m.b[1])), - [m.b[1], m.a], + ComponentSet(identify_variables(m.x(m.a, 'string_param', 1, []) * m.b[1])), + ComponentSet([m.b[1], m.a]), ) self.assertEqual( list(identify_variables(m.x(m.p, 'string_param', 1, []) * m.b[1])), [m.b[1]] ) - self.assertEqual(list(identify_variables(tanh(m.a) * m.b[1])), [m.b[1], m.a]) - self.assertEqual(list(identify_variables(abs(m.a) * m.b[1])), [m.b[1], m.a]) + self.assertEqual( + ComponentSet(identify_variables(tanh(m.a) * m.b[1])), + ComponentSet([m.b[1], m.a]), + ) + self.assertEqual( + ComponentSet(identify_variables(abs(m.a) * m.b[1])), + ComponentSet([m.b[1], m.a]), + ) # # Check logic for allowing duplicates # @@ -275,7 +349,7 @@ def test_identify_mutable_parameters_params(self): ) self.assertEqual( list(identify_mutable_parameters(m.a ** m.b[1] + m.b[2])), - [m.b[2], m.a, m.b[1]], + [m.a, m.b[1], m.b[2]], ) self.assertEqual( list(identify_mutable_parameters(m.a ** m.b[1] + m.b[2] * m.b[3] * m.b[2])), @@ -290,17 +364,17 @@ def test_identify_mutable_parameters_params(self): # self.assertEqual( list(identify_mutable_parameters(m.x(m.a, 'string_param', 1, []) * m.b[1])), - [m.b[1], m.a], + [m.a, m.b[1]], ) self.assertEqual( list(identify_mutable_parameters(m.x(m.p, 'string_param', 1, []) * m.b[1])), [m.b[1]], ) self.assertEqual( - list(identify_mutable_parameters(tanh(m.a) * m.b[1])), [m.b[1], m.a] + list(identify_mutable_parameters(tanh(m.a) * m.b[1])), [m.a, m.b[1]] ) self.assertEqual( - list(identify_mutable_parameters(abs(m.a) * m.b[1])), [m.b[1], m.a] + list(identify_mutable_parameters(abs(m.a) * m.b[1])), [m.a, m.b[1]] ) # # Check logic for allowing duplicates @@ -437,9 +511,7 @@ def test_replacement_linear_expression_with_constant(self): sub_map = dict() sub_map[id(m.x)] = 5 e2 = replace_expressions(e, sub_map) - assertExpressionsEqual( - self, e2, LinearExpression([10, MonomialTermExpression((1, m.y))]) - ) + assertExpressionsEqual(self, e2, LinearExpression([10, m.y])) e = LinearExpression(linear_coefs=[2, 3], linear_vars=[m.x, m.y]) sub_map = dict() @@ -687,7 +759,7 @@ def __init__(self, model): self.model = model def visiting_potential_leaf(self, node): - if node.__class__ in (_ParamData, ScalarParam): + if node.__class__ in (ParamData, ScalarParam): if id(node) in self.substitute: return True, self.substitute[id(node)] self.substitute[id(node)] = 2 * self.model.w.add() @@ -886,20 +958,7 @@ def test_replace(self): assertExpressionsEqual( self, SumExpression( - [ - LinearExpression( - [ - MonomialTermExpression((1, m.y[1])), - MonomialTermExpression((1, m.y[2])), - ] - ), - LinearExpression( - [ - MonomialTermExpression((1, m.y[2])), - MonomialTermExpression((1, m.y[3])), - ] - ), - ] + [LinearExpression([m.y[1], m.y[2]]), LinearExpression([m.y[2], m.y[3]])] ) == 0, f, @@ -930,9 +989,7 @@ def test_npv_sum(self): e3 = replace_expressions(e1, {id(m.p1): m.x}) assertExpressionsEqual(self, e2, m.p2 + 2) - assertExpressionsEqual( - self, e3, LinearExpression([MonomialTermExpression((1, m.x)), 2]) - ) + assertExpressionsEqual(self, e3, LinearExpression([m.x, 2])) def test_npv_negation(self): m = ConcreteModel() @@ -1848,6 +1905,58 @@ def test_evaluate_abex(self): return self.run_walker(self.evaluate_abex()) +class TestSimpleExpressionVisitor(unittest.TestCase): + def test_base_class(self): + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.p = Param(mutable=True) + v = SimpleExpressionVisitor() + + e = 5 + self.assertEqual(v.xbfs(e), None) + self.assertEqual(list(v.xbfs_yield_leaves(e)), []) + + e = m.x + self.assertEqual(v.xbfs(e), None) + self.assertEqual(list(v.xbfs_yield_leaves(e)), []) + + e = m.x + 5 * m.y**m.p + self.assertEqual(v.xbfs(e), None) + self.assertEqual(list(v.xbfs_yield_leaves(e)), []) + + def test_derived_visitor(self): + class _Visitor(SimpleExpressionVisitor): + def __init__(self): + super().__init__() + self.nodes = [] + + def visit(self, node): + self.nodes.append(node) + return node + + def finalize(self): + return len(self.nodes) + + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.p = Param(mutable=True) + v = _Visitor() + + e = 5 + self.assertEqual(v.xbfs(e), 1) + self.assertEqual(list(v.xbfs_yield_leaves(e)), [5]) + + e = m.x + self.assertEqual(v.xbfs(e), 3) + self.assertEqual(list(v.xbfs_yield_leaves(e)), [m.x]) + + e = m.x + 5 * m.y**m.p + self.assertEqual(v.xbfs(e), 11) + self.assertEqual(list(v.xbfs_yield_leaves(e)), [m.x, 5, m.y, m.p]) + + class TestEvaluateExpression(unittest.TestCase): def test_constant(self): m = ConcreteModel() diff --git a/pyomo/core/tests/unit/test_xfrm_discrete_vars.py b/pyomo/core/tests/unit/test_xfrm_discrete_vars.py index d0e74c8cae3..07edce21771 100644 --- a/pyomo/core/tests/unit/test_xfrm_discrete_vars.py +++ b/pyomo/core/tests/unit/test_xfrm_discrete_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/uninstantiated_model_linear.py b/pyomo/core/tests/unit/uninstantiated_model_linear.py index 417f7763d87..2f41a1ebd05 100644 --- a/pyomo/core/tests/unit/uninstantiated_model_linear.py +++ b/pyomo/core/tests/unit/uninstantiated_model_linear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/tests/unit/uninstantiated_model_quadratic.py b/pyomo/core/tests/unit/uninstantiated_model_quadratic.py index 350d96a85bb..a9e561d4c7e 100644 --- a/pyomo/core/tests/unit/uninstantiated_model_quadratic.py +++ b/pyomo/core/tests/unit/uninstantiated_model_quadratic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/core/util.py b/pyomo/core/util.py index f337b487cef..00237bd5cf0 100644 --- a/pyomo/core/util.py +++ b/pyomo/core/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,7 +18,7 @@ from pyomo.core.expr.numeric_expr import mutable_expression, NPV_SumExpression from pyomo.core.base.var import Var from pyomo.core.base.expression import Expression -from pyomo.core.base.component import _ComponentBase +from pyomo.core.base.component import ComponentBase import logging logger = logging.getLogger(__name__) @@ -221,10 +221,12 @@ def sequence(*args): Return a generator that containing an arithmetic progression of integers. - sequence(i, j) returns [i, i+1, i+2, ..., j]; - start defaults to 1. - step specifies the increment (or decrement) - For example, sequence(4) returns [1, 2, 3, 4]. + + - ``sequence(i, j)`` returns ``[i, i+1, i+2, ..., j]``; + - start defaults to 1. + - step specifies the increment (or decrement) + + For example, ``sequence(4)`` returns ``[1, 2, 3, 4]``. """ if len(args) == 0: raise ValueError('sequence expected at least 1 arguments, got 0') @@ -238,12 +240,12 @@ def sequence(*args): def target_list(x): - if isinstance(x, _ComponentBase): + if isinstance(x, ComponentBase): return [x] elif hasattr(x, '__iter__'): ans = [] for i in x: - if isinstance(i, _ComponentBase): + if isinstance(i, ComponentBase): ans.append(i) else: raise ValueError( diff --git a/pyomo/dae/__init__.py b/pyomo/dae/__init__.py index 5860a129aa2..bb47fcc43fc 100644 --- a/pyomo/dae/__init__.py +++ b/pyomo/dae/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/contset.py b/pyomo/dae/contset.py index 9b4f11714df..1357e2345d7 100644 --- a/pyomo/dae/contset.py +++ b/pyomo/dae/contset.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/diffvar.py b/pyomo/dae/diffvar.py index b921107957f..b3ee5b7f775 100644 --- a/pyomo/dae/diffvar.py +++ b/pyomo/dae/diffvar.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/flatten.py b/pyomo/dae/flatten.py index febaf7c10c9..587013002ae 100644 --- a/pyomo/dae/flatten.py +++ b/pyomo/dae/flatten.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -259,7 +259,7 @@ def generate_sliced_components( Parameters ---------- - b: _BlockData + b: BlockData Block whose components will be sliced index_stack: list @@ -267,7 +267,7 @@ def generate_sliced_components( component, that have been sliced. This is necessary to return the sets that have been sliced. - slice_: IndexedComponent_slice or _BlockData + slice_: IndexedComponent_slice or BlockData Slice generated so far. This function will yield extensions to this slice at the current level of the block hierarchy. @@ -443,7 +443,7 @@ def flatten_components_along_sets(m, sets, ctype, indices=None, active=None): Parameters ---------- - m: _BlockData + m: BlockData Block whose components (and their sub-components) will be partitioned @@ -546,7 +546,7 @@ def flatten_dae_components(model, time, ctype, indices=None, active=None): Parameters ---------- - model: _BlockData + model: BlockData Block whose components are partitioned time: Set diff --git a/pyomo/dae/initialization.py b/pyomo/dae/initialization.py index 97928026de2..e59ed6d2b39 100644 --- a/pyomo/dae/initialization.py +++ b/pyomo/dae/initialization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/integral.py b/pyomo/dae/integral.py index 41114296a93..26831bef255 100644 --- a/pyomo/dae/integral.py +++ b/pyomo/dae/integral.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ from pyomo.core.base.indexed_component import rule_wrapper from pyomo.core.base.expression import ( Expression, - _GeneralExpressionData, + ExpressionData, ScalarExpression, IndexedExpression, ) @@ -151,7 +151,7 @@ class ScalarIntegral(ScalarExpression, Integral): """ def __init__(self, *args, **kwds): - _GeneralExpressionData.__init__(self, None, component=self) + ExpressionData.__init__(self, None, component=self) Integral.__init__(self, *args, **kwds) def clear(self): diff --git a/pyomo/dae/misc.py b/pyomo/dae/misc.py index 3e09a055577..59d2ae45e4d 100644 --- a/pyomo/dae/misc.py +++ b/pyomo/dae/misc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -263,7 +263,7 @@ def _update_var(v): # Note: This is not required it is handled by the _default method on # Var (which is now a IndexedComponent). However, it # would be much slower to rely on that method to generate new - # _VarData for a large number of new indices. + # VarData for a large number of new indices. new_indices = set(v.index_set()) - set(v._data.keys()) for index in new_indices: v.add(index) diff --git a/pyomo/dae/plugins/__init__.py b/pyomo/dae/plugins/__init__.py index 681112dd970..9eef9027918 100644 --- a/pyomo/dae/plugins/__init__.py +++ b/pyomo/dae/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,5 +11,4 @@ def load(): - import pyomo.dae.plugins.colloc - import pyomo.dae.plugins.finitedifference + from pyomo.dae.plugins import colloc, finitedifference diff --git a/pyomo/dae/plugins/colloc.py b/pyomo/dae/plugins/colloc.py index 81f1e4dd7ea..0c9f3b31510 100644 --- a/pyomo/dae/plugins/colloc.py +++ b/pyomo/dae/plugins/colloc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/plugins/finitedifference.py b/pyomo/dae/plugins/finitedifference.py index 6557a14e562..1a7131a782a 100644 --- a/pyomo/dae/plugins/finitedifference.py +++ b/pyomo/dae/plugins/finitedifference.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/set_utils.py b/pyomo/dae/set_utils.py index d7a1d9517d9..d410fd67b65 100644 --- a/pyomo/dae/set_utils.py +++ b/pyomo/dae/set_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/simulator.py b/pyomo/dae/simulator.py index 72ba0c7331d..68e22bb0462 100644 --- a/pyomo/dae/simulator.py +++ b/pyomo/dae/simulator.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,14 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -# _________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2014 Sandia Corporation. -# Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation, -# the U.S. Government retains certain rights in this software. -# This software is distributed under the BSD License. -# _________________________________________________________________________ import logging from pyomo.core.base import Constraint, Param, value, Suffix, Block diff --git a/pyomo/dae/tests/__init__.py b/pyomo/dae/tests/__init__.py index 4638923595a..64f366bbda8 100644 --- a/pyomo/dae/tests/__init__.py +++ b/pyomo/dae/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_colloc.py b/pyomo/dae/tests/test_colloc.py index e7e6b20d660..5e2ffce6e2c 100644 --- a/pyomo/dae/tests/test_colloc.py +++ b/pyomo/dae/tests/test_colloc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_contset.py b/pyomo/dae/tests/test_contset.py index e5f11b90e27..82863705571 100644 --- a/pyomo/dae/tests/test_contset.py +++ b/pyomo/dae/tests/test_contset.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_diffvar.py b/pyomo/dae/tests/test_diffvar.py index 414e9341e19..585cc912529 100644 --- a/pyomo/dae/tests/test_diffvar.py +++ b/pyomo/dae/tests/test_diffvar.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_finite_diff.py b/pyomo/dae/tests/test_finite_diff.py index a1b842feccf..9aa952ae2f2 100644 --- a/pyomo/dae/tests/test_finite_diff.py +++ b/pyomo/dae/tests/test_finite_diff.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_flatten.py b/pyomo/dae/tests/test_flatten.py index 1fc28f66bdf..3825416c341 100644 --- a/pyomo/dae/tests/test_flatten.py +++ b/pyomo/dae/tests/test_flatten.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_initialization.py b/pyomo/dae/tests/test_initialization.py index 8407ad2b2a4..99728f915cc 100644 --- a/pyomo/dae/tests/test_initialization.py +++ b/pyomo/dae/tests/test_initialization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_integral.py b/pyomo/dae/tests/test_integral.py index 933bd97d7b4..c3b4f04ebbf 100644 --- a/pyomo/dae/tests/test_integral.py +++ b/pyomo/dae/tests/test_integral.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_misc.py b/pyomo/dae/tests/test_misc.py index 48c1e48418d..bfc0ade38da 100644 --- a/pyomo/dae/tests/test_misc.py +++ b/pyomo/dae/tests/test_misc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_set_utils.py b/pyomo/dae/tests/test_set_utils.py index 8877dadf798..53b327c036f 100644 --- a/pyomo/dae/tests/test_set_utils.py +++ b/pyomo/dae/tests/test_set_utils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/tests/test_simulator.py b/pyomo/dae/tests/test_simulator.py index 76316b5571e..2cd651dfcf7 100644 --- a/pyomo/dae/tests/test_simulator.py +++ b/pyomo/dae/tests/test_simulator.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dae/utilities.py b/pyomo/dae/utilities.py index ae4018a122e..0a7192f87ec 100644 --- a/pyomo/dae/utilities.py +++ b/pyomo/dae/utilities.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/DataPortal.py b/pyomo/dataportal/DataPortal.py index 457bb1aacee..5da1ab77835 100644 --- a/pyomo/dataportal/DataPortal.py +++ b/pyomo/dataportal/DataPortal.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/TableData.py b/pyomo/dataportal/TableData.py index f1500d09f9b..b72bdb9f2ac 100644 --- a/pyomo/dataportal/TableData.py +++ b/pyomo/dataportal/TableData.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/__init__.py b/pyomo/dataportal/__init__.py index ece0ac039f6..7ba690de2e8 100644 --- a/pyomo/dataportal/__init__.py +++ b/pyomo/dataportal/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.dataportal.parse_datacmds +from pyomo.dataportal import parse_datacmds from pyomo.dataportal.TableData import TableData from pyomo.dataportal.DataPortal import DataPortal from pyomo.dataportal.factory import DataManagerFactory, UnknownDataManager diff --git a/pyomo/dataportal/factory.py b/pyomo/dataportal/factory.py index 479769137e2..2b8bb1d0d8d 100644 --- a/pyomo/dataportal/factory.py +++ b/pyomo/dataportal/factory.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/parse_datacmds.py b/pyomo/dataportal/parse_datacmds.py index 60e2f2c0acb..80b56eae0ea 100644 --- a/pyomo/dataportal/parse_datacmds.py +++ b/pyomo/dataportal/parse_datacmds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -90,9 +90,11 @@ # Notes on PLY tokenization # - token functions (beginning with "t_") are prioritized in the order # that they are declared in this module +# - use @lex.TOKEN instead of docstrings to avoid errors from the +# Sphinx autosummary # +@lex.TOKEN(r'[\n]+') def t_newline(t): - r'[\n]+' t.lexer.lineno += len(t.value) t.lexer.linepos.extend(t.lexpos + i for i, _ in enumerate(t.value)) @@ -114,14 +116,14 @@ def t_COMMENT(t): t.lexer.linepos.extend(lastpos for i in range(nlines)) +@lex.TOKEN(r':=') def t_COLONEQ(t): - r':=' t.lexer.begin('data') return t +@lex.TOKEN(r';') def t_SEMICOLON(t): - r';' t.lexer.begin('INITIAL') return t @@ -139,27 +141,27 @@ def t_NUM_VAL(t): return t +@lex.TOKEN(r'[a-zA-Z_][a-zA-Z0-9_\.\-]*\[') def t_WORDWITHLBRACKET(t): - r'[a-zA-Z_][a-zA-Z0-9_\.\-]*\[' return t +@lex.TOKEN(r'[a-zA-Z_][a-zA-Z_0-9\.+\-]*') def t_WORD(t): - r'[a-zA-Z_][a-zA-Z_0-9\.+\-]*' if t.value in reserved: t.type = reserved[t.value] # Check for reserved words return t +@lex.TOKEN(r'[a-zA-Z0-9_\.+\-\\\/]+') def t_STRING(t): - r'[a-zA-Z0-9_\.+\-\\\/]+' # Note: RE guarantees the string has no embedded quotation characters t.value = '"' + t.value + '"' return t +@lex.TOKEN(r'[a-zA-Z0-9_\.+\-]*\[[a-zA-Z0-9_\.+\-\*,\s]+\]') def t_data_BRACKETEDSTRING(t): - r'[a-zA-Z0-9_\.+\-]*\[[a-zA-Z0-9_\.+\-\*,\s]+\]' # NO SPACES # a[1,_df,'foo bar'] # [1,*,'foo bar'] diff --git a/pyomo/dataportal/plugins/__init__.py b/pyomo/dataportal/plugins/__init__.py index 3a356ee9da8..61224de1423 100644 --- a/pyomo/dataportal/plugins/__init__.py +++ b/pyomo/dataportal/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,10 +11,12 @@ def load(): - import pyomo.dataportal.plugins.csv_table - import pyomo.dataportal.plugins.datacommands - import pyomo.dataportal.plugins.db_table - import pyomo.dataportal.plugins.json_dict - import pyomo.dataportal.plugins.text - import pyomo.dataportal.plugins.xml_table - import pyomo.dataportal.plugins.sheet + from pyomo.dataportal.plugins import ( + csv_table, + datacommands, + db_table, + json_dict, + text, + xml_table, + sheet, + ) diff --git a/pyomo/dataportal/plugins/csv_table.py b/pyomo/dataportal/plugins/csv_table.py index a52c8227695..447f1c6ecd5 100644 --- a/pyomo/dataportal/plugins/csv_table.py +++ b/pyomo/dataportal/plugins/csv_table.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/plugins/datacommands.py b/pyomo/dataportal/plugins/datacommands.py index 2da0d44f048..b2c020a89a4 100644 --- a/pyomo/dataportal/plugins/datacommands.py +++ b/pyomo/dataportal/plugins/datacommands.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -42,22 +42,18 @@ def close(self): pass def read(self): - """ - This function does nothing, since executing Pyomo data commands - both reads and processes the data all at once. + """This function does nothing, since executing Pyomo data commands both + reads and processes the data all at once. + """ pass def write(self, data): # pragma:nocover - """ - This function does nothing, because we cannot write to a *.dat file. - """ + """This function does nothing, because we cannot write to a ``*.dat`` file.""" pass def process(self, model, data, default): - """ - Read Pyomo data commands and process the data. - """ + """Read Pyomo data commands and process the data.""" _process_include(['include', self.filename], model, data, default, self.options) def clear(self): diff --git a/pyomo/dataportal/plugins/db_table.py b/pyomo/dataportal/plugins/db_table.py index a39705a6058..217e1428e10 100644 --- a/pyomo/dataportal/plugins/db_table.py +++ b/pyomo/dataportal/plugins/db_table.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -385,8 +385,9 @@ def __init__(self, filename=None, data=None): will override that in the file. """ - # ugh hardcoded strings. See following URL for info: - # http://publib.boulder.ibm.com/infocenter/idshelp/v10/index.jsp?topic=/com.ibm.odbc.doc/odbc58.htm + # Hardcoded string required here. + # See documentation: + # https://www.ibm.com/docs/en/informix-servers/12.10?topic=SSGU8G_12.1.0/com.ibm.odbc.doc/ids_odbc_062.html self.ODBC_DS_KEY = 'ODBC Data Sources' self.ODBC_INFO_KEY = 'ODBC' diff --git a/pyomo/dataportal/plugins/json_dict.py b/pyomo/dataportal/plugins/json_dict.py index 8b41e9a1c7b..844a2d7017a 100644 --- a/pyomo/dataportal/plugins/json_dict.py +++ b/pyomo/dataportal/plugins/json_dict.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/plugins/sheet.py b/pyomo/dataportal/plugins/sheet.py index 773cce81116..845fd289c24 100644 --- a/pyomo/dataportal/plugins/sheet.py +++ b/pyomo/dataportal/plugins/sheet.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/plugins/text.py b/pyomo/dataportal/plugins/text.py index 9a86fd4481b..a62e4c54ed4 100644 --- a/pyomo/dataportal/plugins/text.py +++ b/pyomo/dataportal/plugins/text.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/plugins/xml_table.py b/pyomo/dataportal/plugins/xml_table.py index 7e10b96312e..87738b81ae0 100644 --- a/pyomo/dataportal/plugins/xml_table.py +++ b/pyomo/dataportal/plugins/xml_table.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/process_data.py b/pyomo/dataportal/process_data.py index f6f20d69f67..5eda94298d0 100644 --- a/pyomo/dataportal/process_data.py +++ b/pyomo/dataportal/process_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/tests/__init__.py b/pyomo/dataportal/tests/__init__.py index 85ece8d8cd5..8872067faf9 100644 --- a/pyomo/dataportal/tests/__init__.py +++ b/pyomo/dataportal/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/tests/test_dat_parser.py b/pyomo/dataportal/tests/test_dat_parser.py index 43bf216525c..922c243b3ff 100644 --- a/pyomo/dataportal/tests/test_dat_parser.py +++ b/pyomo/dataportal/tests/test_dat_parser.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/dataportal/tests/test_dataportal.py b/pyomo/dataportal/tests/test_dataportal.py index 8496a8fa3f8..ce16c145bd3 100644 --- a/pyomo/dataportal/tests/test_dataportal.py +++ b/pyomo/dataportal/tests/test_dataportal.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/duality/__init__.py b/pyomo/duality/__init__.py index a08ca813ff8..0c1720887a3 100644 --- a/pyomo/duality/__init__.py +++ b/pyomo/duality/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,4 +9,4 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.duality.collect +from pyomo.duality import collect diff --git a/pyomo/duality/collect.py b/pyomo/duality/collect.py index 350ca058f82..52c81b07911 100644 --- a/pyomo/duality/collect.py +++ b/pyomo/duality/collect.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/duality/lagrangian_dual.py b/pyomo/duality/lagrangian_dual.py index 96bc3f4a95e..22d5c0cccdd 100644 --- a/pyomo/duality/lagrangian_dual.py +++ b/pyomo/duality/lagrangian_dual.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -30,18 +30,19 @@ @TransformationFactory.register("core.lagrangian_dual", doc="Create the LP dual model.") class DualTransformation(IsomorphicTransformation): - """ - Creates a standard form Pyomo model that is equivalent to another model + """Creates a standard form Pyomo model that is equivalent to another + model Options - dual_constraint_suffix Defaults to _constraint - dual_variable_prefix Defaults to p_ - slack_names Defaults to auxiliary_slack - excess_names Defaults to auxiliary_excess - lb_names Defaults to _lower_bound - ub_names Defaults to _upper_bound - pos_suffix Defaults to _plus - neg_suffix Defaults to _minus + dual_constraint_suffix Defaults to ``_constraint`` + dual_variable_prefix Defaults to ``p_`` + slack_names Defaults to ``auxiliary_slack`` + excess_names Defaults to ``auxiliary_excess`` + lb_names Defaults to ``_lower_bound`` + ub_names Defaults to ``_upper_bound`` + pos_suffix Defaults to ``_plus`` + neg_suffix Defaults to ``_minus`` + """ @deprecated( diff --git a/pyomo/duality/plugins.py b/pyomo/duality/plugins.py index 0e89857ded1..7be9e9f36c4 100644 --- a/pyomo/duality/plugins.py +++ b/pyomo/duality/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/duality/tests/__init__.py b/pyomo/duality/tests/__init__.py index 761a6e6c44c..4401c90fce4 100644 --- a/pyomo/duality/tests/__init__.py +++ b/pyomo/duality/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/duality/tests/test_linear_dual.py b/pyomo/duality/tests/test_linear_dual.py index da8ba7a370c..7a0d616ee21 100644 --- a/pyomo/duality/tests/test_linear_dual.py +++ b/pyomo/duality/tests/test_linear_dual.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/environ/__init__.py b/pyomo/environ/__init__.py index c1ceb8cb890..7e2e4caaab1 100644 --- a/pyomo/environ/__init__.py +++ b/pyomo/environ/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -39,6 +39,7 @@ def _do_import(pkg_name): 'pyomo.contrib.appsi', 'pyomo.contrib.community_detection', 'pyomo.contrib.cp', + 'pyomo.contrib.cspline_external', 'pyomo.contrib.example', 'pyomo.contrib.fme', 'pyomo.contrib.gdp_bounds', @@ -50,6 +51,7 @@ def _do_import(pkg_name): 'pyomo.contrib.multistart', 'pyomo.contrib.preprocessing', 'pyomo.contrib.pynumero', + 'pyomo.contrib.simplification', 'pyomo.contrib.solver', 'pyomo.contrib.trustregion', ] diff --git a/pyomo/environ/tests/__init__.py b/pyomo/environ/tests/__init__.py index 61e159c169b..bc5e3506cbc 100644 --- a/pyomo/environ/tests/__init__.py +++ b/pyomo/environ/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/environ/tests/standalone_minimal_pyomo_driver.py b/pyomo/environ/tests/standalone_minimal_pyomo_driver.py index 80fb5d15121..5fc32e067a4 100644 --- a/pyomo/environ/tests/standalone_minimal_pyomo_driver.py +++ b/pyomo/environ/tests/standalone_minimal_pyomo_driver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,7 +15,7 @@ from pyomo.common.tee import capture_output from pyomo.repn.tests.lp_diff import lp_diff -_baseline = """\\* Source Pyomo model name=unknown *\\ +_baseline = r"""\* Source Pyomo model name=unknown *\ min x2: diff --git a/pyomo/environ/tests/test_environ.py b/pyomo/environ/tests/test_environ.py index 9c89fd135d5..49ff6da23fb 100644 --- a/pyomo/environ/tests/test_environ.py +++ b/pyomo/environ/tests/test_environ.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -137,9 +137,11 @@ def test_tpl_import_time(self): 'ast', # Imported on Windows 'backports_abc', # Imported by cython on Linux 'base64', # Imported on Windows + 'bisect', # Imported by dae, dataportal, contrib/mpc 'cPickle', 'csv', 'ctypes', # mandatory import in core/base/external.py; TODO: fix this + 'datetime', # imported by contrib.solver 'decimal', 'gc', # Imported on MacOS, Windows; Linux in 3.10 'glob', diff --git a/pyomo/environ/tests/test_package_layout.py b/pyomo/environ/tests/test_package_layout.py index 4e1574ab158..cd6ca156c46 100644 --- a/pyomo/environ/tests/test_package_layout.py +++ b/pyomo/environ/tests/test_package_layout.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -38,6 +38,8 @@ _NON_MODULE_DIRS = { join('contrib', 'ampl_function_demo', 'src'), join('contrib', 'appsi', 'cmodel', 'src'), + join('contrib', 'cspline_external', 'src'), + join('contrib', 'simplification', 'ginac', 'src'), join('contrib', 'pynumero', 'src'), join('core', 'tests', 'data', 'baselines'), join('core', 'tests', 'diet', 'baselines'), diff --git a/pyomo/__future__.py b/pyomo/future.py similarity index 95% rename from pyomo/__future__.py rename to pyomo/future.py index d298e12cab6..2d8718f5f47 100644 --- a/pyomo/__future__.py +++ b/pyomo/future.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -26,8 +26,6 @@ solver_factory -.. autofunction:: solver_factory - """ @@ -62,7 +60,7 @@ def solver_factory(version=None): .. doctest:: >>> solver_factory(3) - + or by importing the "special" name: @@ -77,7 +75,7 @@ def solver_factory(version=None): """ import pyomo.opt.base.solvers as _solvers - import pyomo.contrib.solver.factory as _contrib + import pyomo.contrib.solver.common.factory as _contrib import pyomo.contrib.appsi.base as _appsi versions = { diff --git a/pyomo/gdp/__init__.py b/pyomo/gdp/__init__.py index a18bc03084a..96b50d12780 100644 --- a/pyomo/gdp/__init__.py +++ b/pyomo/gdp/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.gdp.disjunct import GDP_Error, Disjunct, Disjunction +from pyomo.gdp.disjunct import ( + GDP_Error, + Disjunct, + DisjunctData, + Disjunction, + DisjunctionData, +) # Do not import these files: importing them registers the transformation # plugins with the pyomo script so that they get automatically invoked. diff --git a/pyomo/gdp/basic_step.py b/pyomo/gdp/basic_step.py index 56a19e2a0f2..a950a327628 100644 --- a/pyomo/gdp/basic_step.py +++ b/pyomo/gdp/basic_step.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/disjunct.py b/pyomo/gdp/disjunct.py index d6e5fcfec57..16e2e4dca2f 100644 --- a/pyomo/gdp/disjunct.py +++ b/pyomo/gdp/disjunct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -41,7 +41,7 @@ ComponentData, ) from pyomo.core.base.global_set import UnindexedComponent_index -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.core.base.misc import apply_indexed_rule from pyomo.core.base.indexed_component import ActiveIndexedComponent from pyomo.core.expr.expr_common import ExpressionType @@ -412,7 +412,7 @@ def process(arg): return (_Initializer.deferred_value, arg) -class _DisjunctData(_BlockData): +class DisjunctData(BlockData): __autoslot_mappers__ = {'_transformation_block': AutoSlots.weakref_mapper} _Block_reserved_words = set() @@ -424,7 +424,7 @@ def transformation_block(self): ) def __init__(self, component): - _BlockData.__init__(self, component) + BlockData.__init__(self, component) with self._declare_reserved_components(): self.indicator_var = AutoLinkedBooleanVar() self.binary_indicator_var = AutoLinkedBinaryVar(self.indicator_var) @@ -434,23 +434,28 @@ def __init__(self, component): self._transformation_block = None def activate(self): - super(_DisjunctData, self).activate() + super(DisjunctData, self).activate() self.indicator_var.unfix() def deactivate(self): - super(_DisjunctData, self).deactivate() + super(DisjunctData, self).deactivate() self.indicator_var.fix(False) def _deactivate_without_fixing_indicator(self): - super(_DisjunctData, self).deactivate() + super(DisjunctData, self).deactivate() def _activate_without_unfixing_indicator(self): - super(_DisjunctData, self).activate() + super(DisjunctData, self).activate() + + +class _DisjunctData(metaclass=RenamedClass): + __renamed__new_class__ = DisjunctData + __renamed__version__ = '6.7.2' @ModelComponentFactory.register("Disjunctive blocks.") class Disjunct(Block): - _ComponentDataClass = _DisjunctData + _ComponentDataClass = DisjunctData def __new__(cls, *args, **kwds): if cls != Disjunct: @@ -475,7 +480,7 @@ def __init__(self, *args, **kwargs): # def _deactivate_without_fixing_indicator(self): # # Ideally, this would be a super call from this class. However, # # doing that would trigger a call to deactivate() on all the - # # _DisjunctData objects (exactly what we want to avoid!) + # # DisjunctData objects (exactly what we want to avoid!) # # # # For the time being, we will do something bad and directly call # # the base class method from where we would otherwise want to @@ -484,7 +489,7 @@ def __init__(self, *args, **kwargs): def _activate_without_unfixing_indicator(self): # Ideally, this would be a super call from this class. However, # doing that would trigger a call to deactivate() on all the - # _DisjunctData objects (exactly what we want to avoid!) + # DisjunctData objects (exactly what we want to avoid!) # # For the time being, we will do something bad and directly call # the base class method from where we would otherwise want to @@ -495,15 +500,9 @@ def _activate_without_unfixing_indicator(self): component_data._activate_without_unfixing_indicator() -class ScalarDisjunct(_DisjunctData, Disjunct): +class ScalarDisjunct(DisjunctData, Disjunct): def __init__(self, *args, **kwds): - ## FIXME: This is a HACK to get around a chicken-and-egg issue - ## where _BlockData creates the indicator_var *before* - ## Block.__init__ declares the _defer_construction flag. - self._defer_construction = True - self._suppress_ctypes = set() - - _DisjunctData.__init__(self, self) + DisjunctData.__init__(self, self) Disjunct.__init__(self, *args, **kwds) self._data[None] = self self._index = UnindexedComponent_index @@ -524,10 +523,10 @@ def active(self): return any(d.active for d in self._data.values()) -_DisjunctData._Block_reserved_words = set(dir(Disjunct())) +DisjunctData._Block_reserved_words = set(dir(Disjunct())) -class _DisjunctionData(ActiveComponentData): +class DisjunctionData(ActiveComponentData): __slots__ = ('disjuncts', 'xor', '_algebraic_constraint', '_transformation_map') __autoslot_mappers__ = {'_algebraic_constraint': AutoSlots.weakref_mapper} _NoArgument = (0,) @@ -542,7 +541,7 @@ def __init__(self, component=None): # # These lines represent in-lining of the # following constructors: - # - _ConstraintData, + # - ConstraintData, # - ActiveComponentData # - ComponentData self._component = weakref_ref(component) if (component is not None) else None @@ -620,9 +619,14 @@ def set_value(self, expr): self.disjuncts.append(disjunct) +class _DisjunctionData(metaclass=RenamedClass): + __renamed__new_class__ = DisjunctionData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register("Disjunction expressions.") class Disjunction(ActiveIndexedComponent): - _ComponentDataClass = _DisjunctionData + _ComponentDataClass = DisjunctionData def __new__(cls, *args, **kwds): if cls != Disjunction: @@ -763,9 +767,9 @@ def _pprint(self): ) -class ScalarDisjunction(_DisjunctionData, Disjunction): +class ScalarDisjunction(DisjunctionData, Disjunction): def __init__(self, *args, **kwds): - _DisjunctionData.__init__(self, component=self) + DisjunctionData.__init__(self, component=self) Disjunction.__init__(self, *args, **kwds) self._index = UnindexedComponent_index @@ -776,7 +780,7 @@ def __init__(self, *args, **kwds): # currently in place). So during initialization only, we will # treat them as "indexed" objects where things like # Constraint.Skip are managed. But after that they will behave - # like _DisjunctionData objects where set_value does not handle + # like DisjunctionData objects where set_value does not handle # Disjunction.Skip but expects a valid expression or None. # diff --git a/pyomo/gdp/plugins/__init__.py b/pyomo/gdp/plugins/__init__.py index 875e47e6cc1..e7dc3d502bc 100644 --- a/pyomo/gdp/plugins/__init__.py +++ b/pyomo/gdp/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,15 +11,32 @@ def load(): - import pyomo.gdp.plugins.bigm - import pyomo.gdp.plugins.hull - import pyomo.gdp.plugins.bilinear - import pyomo.gdp.plugins.gdp_var_mover - import pyomo.gdp.plugins.cuttingplane - import pyomo.gdp.plugins.fix_disjuncts - import pyomo.gdp.plugins.partition_disjuncts - import pyomo.gdp.plugins.between_steps - import pyomo.gdp.plugins.multiple_bigm - import pyomo.gdp.plugins.transform_current_disjunctive_state - import pyomo.gdp.plugins.bound_pretransformation - import pyomo.gdp.plugins.binary_multiplication + from pyomo.gdp.plugins import ( + bigm, + hull, + bilinear, + gdp_var_mover, + cuttingplane, + fix_disjuncts, + partition_disjuncts, + between_steps, + multiple_bigm, + transform_current_disjunctive_state, + bound_pretransformation, + binary_multiplication, + ) + + +# +# declare deprecation paths for removed modules +# +from pyomo.common.deprecation import moved_module + +moved_module( + 'pyomo.gdp.plugins.chull', + 'pyomo._archive.chull', + msg='The pyomo.gdp.plugins.chull module is deprecated. ' + 'Import the Hull reformulation objects from pyomo.gdp.plugins.hull.', + version='5.7', +) +del moved_module diff --git a/pyomo/gdp/plugins/between_steps.py b/pyomo/gdp/plugins/between_steps.py index 8f57164334e..0d4b0966785 100644 --- a/pyomo/gdp/plugins/between_steps.py +++ b/pyomo/gdp/plugins/between_steps.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/plugins/bigm.py b/pyomo/gdp/plugins/bigm.py index 1f9f561b192..76121e76bde 100644 --- a/pyomo/gdp/plugins/bigm.py +++ b/pyomo/gdp/plugins/bigm.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,6 +13,7 @@ import logging +from pyomo.common.autoslots import AutoSlots from pyomo.common.collections import ComponentMap from pyomo.common.config import ConfigDict, ConfigValue from pyomo.common.gc_manager import PauseGC @@ -58,6 +59,26 @@ logger = logging.getLogger('pyomo.gdp.bigm') +class _BigMData(AutoSlots.Mixin): + __slots__ = ('bigm_src',) + + def __init__(self): + # we will keep a map of constraints (hashable, ha!) to a tuple to + # indicate what their M value is and where it came from, of the form: + # ((lower_value, lower_source, lower_key), (upper_value, upper_source, + # upper_key)), where the first tuple is the information for the lower M, + # the second tuple is the info for the upper M, source is the Suffix or + # argument dictionary and None if the value was calculated, and key is + # the key in the Suffix or argument dictionary, and None if it was + # calculated. (Note that it is possible the lower or upper is + # user-specified and the other is not, hence the need to store + # information for both.) + self.bigm_src = {} + + +Block.register_private_data_initializer(_BigMData) + + @TransformationFactory.register( 'gdp.bigm', doc="Relax disjunctive model using big-M terms." ) @@ -72,18 +93,17 @@ class BigM_Transformation(GDP_to_MIP_Transformation, _BigM_MixIn): targets: the targets to transform [default: the instance] M values are determined as follows: - 1) if the constraint appears in the bigM argument dict - 2) if the constraint parent_component appears in the bigM - argument dict - 3) if any block which is an ancestor to the constraint appears in + 1. if the constraint appears in the bigM argument dict + 2. if the constraint parent_component appears in the bigM argument dict + 3. if any block which is an ancestor to the constraint appears in the bigM argument dict - 3) if 'None' is in the bigM argument dict - 4) if the constraint or the constraint parent_component appear in + 4. if 'None' is in the bigM argument dict + 5. if the constraint or the constraint parent_component appear in a BigM Suffix attached to any parent_block() beginning with the constraint's parent_block and moving up to the root model. - 5) if None appears in a BigM Suffix attached to any + 6. if None appears in a BigM Suffix attached to any parent_block() between the constraint and the root model. - 6) if the constraint is linear, estimate M using the variable bounds + 7. if the constraint is linear, estimate M using the variable bounds M values may be a single value or a 2-tuple specifying the M for the lower bound and the upper bound of the constraint body. @@ -94,15 +114,8 @@ class BigM_Transformation(GDP_to_MIP_Transformation, _BigM_MixIn): name beginning "_pyomo_gdp_bigm_reformulation". That Block will contain an indexed Block named "relaxedDisjuncts", which will hold the relaxed disjuncts. This block is indexed by an integer - indicating the order in which the disjuncts were relaxed. - Each block has a dictionary "_constraintMap": - - 'srcConstraints': ComponentMap(: - ) - 'transformedConstraints': ComponentMap(: - ) - - All transformed Disjuncts will have a pointer to the block their transformed + indicating the order in which the disjuncts were relaxed. All + transformed Disjuncts will have a pointer to the block their transformed constraints are on, and all transformed Disjunctions will have a pointer to the corresponding 'Or' or 'ExactlyOne' constraint. @@ -199,21 +212,15 @@ def _apply_to_impl(self, instance, **kwds): bigM = self._config.bigM for t in preprocessed_targets: if t.ctype is Disjunction: - self._transform_disjunctionData( - t, - t.index(), - bigM, - parent_disjunct=gdp_tree.parent(t), - root_disjunct=gdp_tree.root_disjunct(t), - ) + self._transform_disjunctionData(t, t.index(), bigM, gdp_tree) # issue warnings about anything that was in the bigM args dict that we # didn't use _warn_for_unused_bigM_args(bigM, self.used_args, logger) - def _transform_disjunctionData( - self, obj, index, bigM, parent_disjunct=None, root_disjunct=None - ): + def _transform_disjunctionData(self, obj, index, bigM, gdp_tree): + parent_disjunct = gdp_tree.parent(obj) + root_disjunct = gdp_tree.root_disjunct(obj) (transBlock, xorConstraint) = self._setup_transform_disjunctionData( obj, root_disjunct ) @@ -222,7 +229,7 @@ def _transform_disjunctionData( or_expr = 0 for disjunct in obj.disjuncts: or_expr += disjunct.binary_indicator_var - self._transform_disjunct(disjunct, bigM, transBlock) + self._transform_disjunct(disjunct, bigM, transBlock, gdp_tree) if obj.xor: xorConstraint[index] = or_expr == 1 @@ -235,7 +242,7 @@ def _transform_disjunctionData( # and deactivate for the writers obj.deactivate() - def _transform_disjunct(self, obj, bigM, transBlock): + def _transform_disjunct(self, obj, bigM, transBlock, gdp_tree): # We're not using the preprocessed list here, so this could be # inactive. We've already done the error checking in preprocessing, so # we just skip it here. @@ -247,17 +254,11 @@ def _transform_disjunct(self, obj, bigM, transBlock): relaxationBlock = self._get_disjunct_transformation_block(obj, transBlock) - # we will keep a map of constraints (hashable, ha!) to a tuple to - # indicate what their M value is and where it came from, of the form: - # ((lower_value, lower_source, lower_key), (upper_value, upper_source, - # upper_key)), where the first tuple is the information for the lower M, - # the second tuple is the info for the upper M, source is the Suffix or - # argument dictionary and None if the value was calculated, and key is - # the key in the Suffix or argument dictionary, and None if it was - # calculated. (Note that it is possible the lower or upper is - # user-specified and the other is not, hence the need to store - # information for both.) - relaxationBlock.bigm_src = {} + indicator_expression = 0 + node = obj + while node is not None: + indicator_expression += 1 - node.binary_indicator_var + node = gdp_tree.parent_disjunct(node) # This is crazy, but if the disjunction has been previously # relaxed, the disjunct *could* be deactivated. This is a big @@ -268,18 +269,26 @@ def _transform_disjunct(self, obj, bigM, transBlock): # comparing the two relaxations. # # Transform each component within this disjunct - self._transform_block_components(obj, obj, bigM, arg_list, suffix_list) + self._transform_block_components( + obj, obj, bigM, arg_list, suffix_list, indicator_expression + ) # deactivate disjunct to keep the writers happy obj._deactivate_without_fixing_indicator() def _transform_constraint( - self, obj, disjunct, bigMargs, arg_list, disjunct_suffix_list + self, + obj, + disjunct, + bigMargs, + arg_list, + disjunct_suffix_list, + indicator_expression, ): # add constraint to the transformation block, we'll transform it there. transBlock = disjunct._transformation_block() - bigm_src = transBlock.bigm_src - constraintMap = transBlock._constraintMap + bigm_src = transBlock.private_data().bigm_src + constraint_map = transBlock.private_data('pyomo.gdp') disjunctionRelaxationBlock = transBlock.parent_block() @@ -346,7 +355,13 @@ def _transform_constraint( bigm_src[c] = (lower, upper) self._add_constraint_expressions( - c, i, M, disjunct.binary_indicator_var, newConstraint, constraintMap + c, + i, + M, + disjunct.binary_indicator_var, + newConstraint, + constraint_map, + indicator_expression=indicator_expression, ) # deactivate because we relaxed @@ -409,7 +424,7 @@ def _update_M_from_suffixes(self, constraint, suffix_list, lower, upper): def get_m_value_src(self, constraint): transBlock = _get_constraint_transBlock(constraint) ((lower_val, lower_source, lower_key), (upper_val, upper_source, upper_key)) = ( - transBlock.bigm_src[constraint] + transBlock.private_data().bigm_src[constraint] ) if ( @@ -464,7 +479,7 @@ def get_M_value_src(self, constraint): transBlock = _get_constraint_transBlock(constraint) # This is a KeyError if it fails, but it is also my fault if it # fails... (That is, it's a bug in the mapping.) - return transBlock.bigm_src[constraint] + return transBlock.private_data().bigm_src[constraint] def get_M_value(self, constraint): """Returns the M values used to transform constraint. Return is a tuple: @@ -479,7 +494,7 @@ def get_M_value(self, constraint): transBlock = _get_constraint_transBlock(constraint) # This is a KeyError if it fails, but it is also my fault if it # fails... (That is, it's a bug in the mapping.) - lower, upper = transBlock.bigm_src[constraint] + lower, upper = transBlock.private_data().bigm_src[constraint] return (lower[0], upper[0]) def get_all_M_values_by_constraint(self, model): @@ -499,9 +514,8 @@ def get_all_M_values_by_constraint(self, model): # First check if it was transformed at all. if transBlock is not None: # If it was transformed with BigM, we get the M values. - if hasattr(transBlock, 'bigm_src'): - for cons in transBlock.bigm_src: - m_values[cons] = self.get_M_value(cons) + for cons in transBlock.private_data().bigm_src: + m_values[cons] = self.get_M_value(cons) return m_values def get_largest_M_value(self, model): diff --git a/pyomo/gdp/plugins/bigm_mixin.py b/pyomo/gdp/plugins/bigm_mixin.py index ad6e6dcad86..4899551f918 100644 --- a/pyomo/gdp/plugins/bigm_mixin.py +++ b/pyomo/gdp/plugins/bigm_mixin.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -232,7 +232,14 @@ def _estimate_M(self, expr, constraint): return tuple(M) def _add_constraint_expressions( - self, c, i, M, indicator_var, newConstraint, constraintMap + self, + c, + i, + M, + indicator_var, + newConstraint, + constraint_map, + indicator_expression=None, ): # Since we are both combining components from multiple blocks and using # local names, we need to make sure that the first index for @@ -244,6 +251,8 @@ def _add_constraint_expressions( # over the constraint indices, but I don't think it matters a lot.) unique = len(newConstraint) name = c.local_name + "_%s" % unique + if indicator_expression is None: + indicator_expression = 1 - indicator_var if c.lower is not None: if M[0] is None: @@ -251,25 +260,21 @@ def _add_constraint_expressions( "Cannot relax disjunctive constraint '%s' " "because M is not defined." % name ) - M_expr = M[0] * (1 - indicator_var) + M_expr = M[0] * indicator_expression newConstraint.add((name, i, 'lb'), c.lower <= c.body - M_expr) - constraintMap['transformedConstraints'][c] = [newConstraint[name, i, 'lb']] - constraintMap['srcConstraints'][newConstraint[name, i, 'lb']] = c + constraint_map.transformed_constraints[c].append( + newConstraint[name, i, 'lb'] + ) + constraint_map.src_constraint[newConstraint[name, i, 'lb']] = c if c.upper is not None: if M[1] is None: raise GDP_Error( "Cannot relax disjunctive constraint '%s' " "because M is not defined." % name ) - M_expr = M[1] * (1 - indicator_var) + M_expr = M[1] * indicator_expression newConstraint.add((name, i, 'ub'), c.body - M_expr <= c.upper) - transformed = constraintMap['transformedConstraints'].get(c) - if transformed is not None: - constraintMap['transformedConstraints'][c].append( - newConstraint[name, i, 'ub'] - ) - else: - constraintMap['transformedConstraints'][c] = [ - newConstraint[name, i, 'ub'] - ] - constraintMap['srcConstraints'][newConstraint[name, i, 'ub']] = c + constraint_map.transformed_constraints[c].append( + newConstraint[name, i, 'ub'] + ) + constraint_map.src_constraint[newConstraint[name, i, 'ub']] = c diff --git a/pyomo/gdp/plugins/bilinear.py b/pyomo/gdp/plugins/bilinear.py index 67390801348..c75292a04e7 100644 --- a/pyomo/gdp/plugins/bilinear.py +++ b/pyomo/gdp/plugins/bilinear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -77,9 +77,10 @@ def _transformBlock(self, block, instance): for component in block.component_data_objects( Constraint, active=True, descend_into=False ): - expr = self._transformExpression(component.body, instance) - instance.bilinear_data_.c_body[id(component)] = component.body - component._body = expr + lb, body, ub = component.to_bounded_expression() + expr = self._transformExpression(body, instance) + instance.bilinear_data_.c_body[id(component)] = body + component.set_value((lb, expr, ub)) def _transformExpression(self, expr, instance): if expr.polynomial_degree() > 2: diff --git a/pyomo/gdp/plugins/binary_multiplication.py b/pyomo/gdp/plugins/binary_multiplication.py index d68f7efe76f..3257a455d08 100644 --- a/pyomo/gdp/plugins/binary_multiplication.py +++ b/pyomo/gdp/plugins/binary_multiplication.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -121,7 +121,7 @@ def _transform_disjunct(self, obj, transBlock): def _transform_constraint(self, obj, disjunct): # add constraint to the transformation block, we'll transform it there. transBlock = disjunct._transformation_block() - constraintMap = transBlock._constraintMap + constraint_map = transBlock.private_data('pyomo.gdp') disjunctionRelaxationBlock = transBlock.parent_block() @@ -137,14 +137,14 @@ def _transform_constraint(self, obj, disjunct): continue self._add_constraint_expressions( - c, i, disjunct.binary_indicator_var, newConstraint, constraintMap + c, i, disjunct.binary_indicator_var, newConstraint, constraint_map ) # deactivate because we relaxed c.deactivate() def _add_constraint_expressions( - self, c, i, indicator_var, newConstraint, constraintMap + self, c, i, indicator_var, newConstraint, constraint_map ): # Since we are both combining components from multiple blocks and using # local names, we need to make sure that the first index for @@ -156,21 +156,21 @@ def _add_constraint_expressions( # over the constraint indices, but I don't think it matters a lot.) unique = len(newConstraint) name = c.local_name + "_%s" % unique - transformed = constraintMap['transformedConstraints'][c] = [] + transformed = constraint_map.transformed_constraints[c] lb, ub = c.lower, c.upper if (c.equality or lb is ub) and lb is not None: # equality newConstraint.add((name, i, 'eq'), (c.body - lb) * indicator_var == 0) transformed.append(newConstraint[name, i, 'eq']) - constraintMap['srcConstraints'][newConstraint[name, i, 'eq']] = c + constraint_map.src_constraint[newConstraint[name, i, 'eq']] = c else: # inequality if lb is not None: newConstraint.add((name, i, 'lb'), 0 <= (c.body - lb) * indicator_var) transformed.append(newConstraint[name, i, 'lb']) - constraintMap['srcConstraints'][newConstraint[name, i, 'lb']] = c + constraint_map.src_constraint[newConstraint[name, i, 'lb']] = c if ub is not None: newConstraint.add((name, i, 'ub'), (c.body - ub) * indicator_var <= 0) transformed.append(newConstraint[name, i, 'ub']) - constraintMap['srcConstraints'][newConstraint[name, i, 'ub']] = c + constraint_map.src_constraint[newConstraint[name, i, 'ub']] = c diff --git a/pyomo/gdp/plugins/bound_pretransformation.py b/pyomo/gdp/plugins/bound_pretransformation.py index 7c90c24d869..fe41a4dc4a6 100644 --- a/pyomo/gdp/plugins/bound_pretransformation.py +++ b/pyomo/gdp/plugins/bound_pretransformation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/plugins/chull.py b/pyomo/gdp/plugins/chull.py deleted file mode 100644 index c11d8ea0729..00000000000 --- a/pyomo/gdp/plugins/chull.py +++ /dev/null @@ -1,20 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.common.deprecation import deprecation_warning - -deprecation_warning( - 'The pyomo.gdp.plugins.chull module is deprecated. ' - 'Import the Hull reformulation objects from pyomo.gdp.plugins.hull.', - version='5.7', -) - -from .hull import _Deprecated_Name_Hull as ConvexHull_Transformation diff --git a/pyomo/gdp/plugins/cuttingplane.py b/pyomo/gdp/plugins/cuttingplane.py index 6c77a582987..e318ddd0f1b 100644 --- a/pyomo/gdp/plugins/cuttingplane.py +++ b/pyomo/gdp/plugins/cuttingplane.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -400,7 +400,8 @@ def back_off_constraint_with_calculated_cut_violation( val = value(transBlock_rHull.infeasibility_objective) - TOL if val <= 0: logger.info("\tBacking off cut by %s" % val) - cut._body += abs(val) + lb, body, ub = cut.to_bounded_expression() + cut.set_value((lb, body + abs(val), ub)) # else there is nothing to do: restore the objective transBlock_rHull.del_component(transBlock_rHull.infeasibility_objective) transBlock_rHull.separation_objective.activate() @@ -424,7 +425,8 @@ def back_off_constraint_by_fixed_tolerance( this callback TOL: An absolute tolerance to be added to make cut more conservative. """ - cut._body += TOL + lb, body, ub = cut.to_bounded_expression() + cut.set_value((lb, body + TOL, ub)) @TransformationFactory.register( diff --git a/pyomo/gdp/plugins/fix_disjuncts.py b/pyomo/gdp/plugins/fix_disjuncts.py index 44a9d91d513..6194c6577f7 100644 --- a/pyomo/gdp/plugins/fix_disjuncts.py +++ b/pyomo/gdp/plugins/fix_disjuncts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -52,7 +52,7 @@ class GDP_Disjunct_Fixer(Transformation): This reclassifies all disjuncts in the passed model instance as ctype Block and deactivates the constraints and disjunctions within inactive disjuncts. - In addition, it transforms relvant LogicalConstraints and BooleanVars so + In addition, it transforms relevant LogicalConstraints and BooleanVars so that the resulting model is a (MI)(N)LP (where it is only mixed-integer if the model contains integer-domain Vars or BooleanVars which were not indicator_vars of Disjuncs. diff --git a/pyomo/gdp/plugins/gdp_to_mip_transformation.py b/pyomo/gdp/plugins/gdp_to_mip_transformation.py index 59cb221321a..5f073c4b48a 100644 --- a/pyomo/gdp/plugins/gdp_to_mip_transformation.py +++ b/pyomo/gdp/plugins/gdp_to_mip_transformation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,7 +11,8 @@ from functools import wraps -from pyomo.common.collections import ComponentMap +from pyomo.common.autoslots import AutoSlots +from pyomo.common.collections import ComponentMap, DefaultComponentMap from pyomo.common.log import is_debug_set from pyomo.common.modeling import unique_component_name @@ -48,6 +49,17 @@ from weakref import ref as weakref_ref +class _GDPTransformationData(AutoSlots.Mixin): + __slots__ = ('src_constraint', 'transformed_constraints') + + def __init__(self): + self.src_constraint = ComponentMap() + self.transformed_constraints = DefaultComponentMap(list) + + +Block.register_private_data_initializer(_GDPTransformationData, scope='pyomo.gdp') + + class GDP_to_MIP_Transformation(Transformation): """ Base class for transformations from GDP to MIP @@ -243,14 +255,7 @@ def _get_disjunct_transformation_block(self, disjunct, transBlock): relaxationBlock = relaxedDisjuncts[len(relaxedDisjuncts)] relaxationBlock.transformedConstraints = Constraint(Any) - relaxationBlock.localVarReferences = Block() - # add the map that will link back and forth between transformed - # constraints and their originals. - relaxationBlock._constraintMap = { - 'srcConstraints': ComponentMap(), - 'transformedConstraints': ComponentMap(), - } # add mappings to source disjunct (so we'll know we've relaxed) disjunct._transformation_block = weakref_ref(relaxationBlock) diff --git a/pyomo/gdp/plugins/gdp_var_mover.py b/pyomo/gdp/plugins/gdp_var_mover.py index 5402b576368..f4b2530a0f5 100644 --- a/pyomo/gdp/plugins/gdp_var_mover.py +++ b/pyomo/gdp/plugins/gdp_var_mover.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -115,7 +115,7 @@ def _apply_to(self, instance, **kwds): disjunct_component, Block ) # HACK: activate the block, but do not activate the - # _BlockData objects + # BlockData objects super(ActiveIndexedComponent, disjunct_component).activate() # Deactivate all constraints. Note that we only need to diff --git a/pyomo/gdp/plugins/hull.py b/pyomo/gdp/plugins/hull.py index d1c38bde039..53ff1080940 100644 --- a/pyomo/gdp/plugins/hull.py +++ b/pyomo/gdp/plugins/hull.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,7 +20,7 @@ from pyomo.common.modeling import unique_component_name from pyomo.core.expr.numvalue import ZeroConstant import pyomo.core.expr as EXPR -from pyomo.core.base import TransformationFactory, Reference +from pyomo.core.base import TransformationFactory from pyomo.core import ( Block, BooleanVar, @@ -42,7 +42,7 @@ Binary, ) from pyomo.gdp import Disjunct, Disjunction, GDP_Error -from pyomo.gdp.disjunct import _DisjunctData +from pyomo.gdp.disjunct import DisjunctData from pyomo.gdp.plugins.gdp_to_mip_transformation import GDP_to_MIP_Transformation from pyomo.gdp.transformed_disjunct import _TransformedDisjunct from pyomo.gdp.util import ( @@ -58,12 +58,18 @@ class _HullTransformationData(AutoSlots.Mixin): - __slots__ = ('disaggregated_var_map', 'original_var_map', 'bigm_constraint_map') + __slots__ = ( + 'disaggregated_var_map', + 'original_var_map', + 'bigm_constraint_map', + 'disaggregation_constraint_map', + ) def __init__(self): self.disaggregated_var_map = DefaultComponentMap(ComponentMap) self.original_var_map = ComponentMap() self.bigm_constraint_map = DefaultComponentMap(ComponentMap) + self.disaggregation_constraint_map = DefaultComponentMap(ComponentMap) Block.register_private_data_initializer(_HullTransformationData) @@ -80,6 +86,15 @@ class Hull_Reformulation(GDP_to_MIP_Transformation): This transformation accepts the following keyword arguments: + The transformation will create a new Block with a unique + name beginning "_pyomo_gdp_hull_reformulation". It will contain an + indexed Block named "relaxedDisjuncts" that will hold the relaxed + disjuncts. This block is indexed by an integer indicating the order + in which the disjuncts were relaxed. All transformed Disjuncts will + have a pointer to the block their transformed constraints are on, + and all transformed Disjunctions will have a pointer to the + corresponding OR or XOR constraint. + Parameters ---------- perspective_function : str @@ -88,31 +103,9 @@ class Hull_Reformulation(GDP_to_MIP_Transformation): 'LeeGrossmann', or 'GrossmannLee' EPS : float The value to use for epsilon [default: 1e-4] - targets : (block, disjunction, or list of those types) + targets : block, disjunction, or list of those types The targets to transform. This can be a block, disjunction, or a list of blocks and Disjunctions [default: the instance] - - The transformation will create a new Block with a unique - name beginning "_pyomo_gdp_hull_reformulation". It will contain an - indexed Block named "relaxedDisjuncts" that will hold the relaxed - disjuncts. This block is indexed by an integer indicating the order - in which the disjuncts were relaxed. Each block has a dictionary - "_constraintMap": - - 'srcConstraints': ComponentMap(: - ), - 'transformedConstraints': - ComponentMap( : - , - : []) - - All transformed Disjuncts will have a pointer to the block their transformed - constraints are on, and all transformed Disjunctions will have a - pointer to the corresponding OR or XOR constraint. - - The _pyomo_gdp_hull_reformulation block will have a ComponentMap - "_disaggregationConstraintMap": - :ComponentMap(: ) """ CONFIG = cfg.ConfigDict('gdp.hull') @@ -294,10 +287,6 @@ def _add_transformation_block(self, to_block): # Disjunctions we transform onto this block here. transBlock.disaggregationConstraints = Constraint(NonNegativeIntegers) - # This will map from srcVar to a map of srcDisjunction to the - # disaggregation constraint corresponding to srcDisjunction - transBlock._disaggregationConstraintMap = ComponentMap() - # we are going to store some of the disaggregated vars directly here # when we have vars that don't appear in every disjunct transBlock._disaggregatedVars = Var(NonNegativeIntegers, dense=False) @@ -330,7 +319,9 @@ def _transform_disjunctionData( ) disaggregationConstraint = transBlock.disaggregationConstraints - disaggregationConstraintMap = transBlock._disaggregationConstraintMap + disaggregationConstraintMap = ( + transBlock.private_data().disaggregation_constraint_map + ) disaggregatedVars = transBlock._disaggregatedVars disaggregated_var_bounds = transBlock._boundsConstraints @@ -456,19 +447,11 @@ def _transform_disjunctionData( disaggregatedVar=disaggregated_var, disjunct=obj, bigmConstraint=disaggregated_var_bounds, - lb_idx=(idx, 'lb'), - ub_idx=(idx, 'ub'), var_free_indicator=var_free, + var_idx=idx, ) - # Update mappings: - var_info = var.parent_block().private_data() - disaggregated_var_map = var_info.disaggregated_var_map - dis_var_info = disaggregated_var.parent_block().private_data() - - dis_var_info.bigm_constraint_map[disaggregated_var][obj] = Reference( - disaggregated_var_bounds[idx, :] - ) - dis_var_info.original_var_map[disaggregated_var] = var + original_var_info = var.parent_block().private_data() + disaggregated_var_map = original_var_info.disaggregated_var_map # For every Disjunct the Var does not appear in, we want to map # that this new variable is its disaggreggated variable. @@ -499,13 +482,7 @@ def _transform_disjunctionData( # and update the map so that we can find this later. We index by # variable and the particular disjunction because there is a # different one for each disjunction - if var in disaggregationConstraintMap: - disaggregationConstraintMap[var][obj] = disaggregationConstraint[ - cons_idx - ] - else: - thismap = disaggregationConstraintMap[var] = ComponentMap() - thismap[obj] = disaggregationConstraint[cons_idx] + disaggregationConstraintMap[var][obj] = disaggregationConstraint[cons_idx] # deactivate for the writers obj.deactivate() @@ -559,8 +536,6 @@ def _transform_disjunct( disaggregatedVar=disaggregatedVar, disjunct=obj, bigmConstraint=bigmConstraint, - lb_idx='lb', - ub_idx='ub', var_free_indicator=obj.indicator_var.get_associated_binary(), ) # update the bigm constraint mappings @@ -588,8 +563,6 @@ def _transform_disjunct( disaggregatedVar=var, disjunct=obj, bigmConstraint=bigmConstraint, - lb_idx='lb', - ub_idx='ub', var_free_indicator=obj.indicator_var.get_associated_binary(), ) # update the bigm constraint mappings @@ -622,10 +595,16 @@ def _declare_disaggregated_var_bounds( disaggregatedVar, disjunct, bigmConstraint, - lb_idx, - ub_idx, var_free_indicator, + var_idx=None, ): + # For updating mappings: + original_var_info = original_var.parent_block().private_data() + disaggregated_var_map = original_var_info.disaggregated_var_map + disaggregated_var_info = disaggregatedVar.parent_block().private_data() + + disaggregated_var_info.bigm_constraint_map[disaggregatedVar][disjunct] = {} + lb = original_var.lb ub = original_var.ub if lb is None or ub is None: @@ -639,13 +618,21 @@ def _declare_disaggregated_var_bounds( disaggregatedVar.setub(max(0, ub)) if lb: + lb_idx = 'lb' + if var_idx is not None: + lb_idx = (var_idx, 'lb') bigmConstraint.add(lb_idx, var_free_indicator * lb <= disaggregatedVar) + disaggregated_var_info.bigm_constraint_map[disaggregatedVar][disjunct][ + 'lb' + ] = bigmConstraint[lb_idx] if ub: + ub_idx = 'ub' + if var_idx is not None: + ub_idx = (var_idx, 'ub') bigmConstraint.add(ub_idx, disaggregatedVar <= ub * var_free_indicator) - - original_var_info = original_var.parent_block().private_data() - disaggregated_var_map = original_var_info.disaggregated_var_map - disaggregated_var_info = disaggregatedVar.parent_block().private_data() + disaggregated_var_info.bigm_constraint_map[disaggregatedVar][disjunct][ + 'ub' + ] = bigmConstraint[ub_idx] # store the mappings from variables to their disaggregated selves on # the transformation block @@ -675,7 +662,7 @@ def _transform_constraint( ): # we will put a new transformed constraint on the relaxation block. relaxationBlock = disjunct._transformation_block() - constraintMap = relaxationBlock._constraintMap + constraint_map = relaxationBlock.private_data('pyomo.gdp') # We will make indexes from ({obj.local_name} x obj.index_set() x ['lb', # 'ub']), but don't bother construct that set here, as taking Cartesian @@ -757,32 +744,32 @@ def _transform_constraint( # this variable, so I'm going to return # it. Alternatively we could return an empty list, but I # think I like this better. - constraintMap['transformedConstraints'][c] = [v[0]] + constraint_map.transformed_constraints[c].append(v[0]) # Reverse map also (this is strange) - constraintMap['srcConstraints'][v[0]] = c + constraint_map.src_constraint[v[0]] = c continue newConsExpr = expr - (1 - y) * h_0 == c.lower * y if obj.is_indexed(): newConstraint.add((name, i, 'eq'), newConsExpr) - # map the _ConstraintDatas (we mapped the container above) - constraintMap['transformedConstraints'][c] = [ + # map the ConstraintDatas (we mapped the container above) + constraint_map.transformed_constraints[c].append( newConstraint[name, i, 'eq'] - ] - constraintMap['srcConstraints'][newConstraint[name, i, 'eq']] = c + ) + constraint_map.src_constraint[newConstraint[name, i, 'eq']] = c else: newConstraint.add((name, 'eq'), newConsExpr) - # map to the _ConstraintData (And yes, for + # map to the ConstraintData (And yes, for # ScalarConstraints, this is overwriting the map to the # container we made above, and that is what I want to # happen. ScalarConstraints will map to lists. For # IndexedConstraints, we can map the container to the # container, but more importantly, we are mapping the - # _ConstraintDatas to each other above) - constraintMap['transformedConstraints'][c] = [ + # ConstraintDatas to each other above) + constraint_map.transformed_constraints[c].append( newConstraint[name, 'eq'] - ] - constraintMap['srcConstraints'][newConstraint[name, 'eq']] = c + ) + constraint_map.src_constraint[newConstraint[name, 'eq']] = c continue @@ -797,16 +784,16 @@ def _transform_constraint( if obj.is_indexed(): newConstraint.add((name, i, 'lb'), newConsExpr) - constraintMap['transformedConstraints'][c] = [ + constraint_map.transformed_constraints[c].append( newConstraint[name, i, 'lb'] - ] - constraintMap['srcConstraints'][newConstraint[name, i, 'lb']] = c + ) + constraint_map.src_constraint[newConstraint[name, i, 'lb']] = c else: newConstraint.add((name, 'lb'), newConsExpr) - constraintMap['transformedConstraints'][c] = [ + constraint_map.transformed_constraints[c].append( newConstraint[name, 'lb'] - ] - constraintMap['srcConstraints'][newConstraint[name, 'lb']] = c + ) + constraint_map.src_constraint[newConstraint[name, 'lb']] = c if c.upper is not None: if self._generate_debug_messages: @@ -821,24 +808,16 @@ def _transform_constraint( newConstraint.add((name, i, 'ub'), newConsExpr) # map (have to account for fact we might have created list # above - transformed = constraintMap['transformedConstraints'].get(c) - if transformed is not None: - transformed.append(newConstraint[name, i, 'ub']) - else: - constraintMap['transformedConstraints'][c] = [ - newConstraint[name, i, 'ub'] - ] - constraintMap['srcConstraints'][newConstraint[name, i, 'ub']] = c + constraint_map.transformed_constraints[c].append( + newConstraint[name, i, 'ub'] + ) + constraint_map.src_constraint[newConstraint[name, i, 'ub']] = c else: newConstraint.add((name, 'ub'), newConsExpr) - transformed = constraintMap['transformedConstraints'].get(c) - if transformed is not None: - transformed.append(newConstraint[name, 'ub']) - else: - constraintMap['transformedConstraints'][c] = [ - newConstraint[name, 'ub'] - ] - constraintMap['srcConstraints'][newConstraint[name, 'ub']] = c + constraint_map.transformed_constraints[c].append( + newConstraint[name, 'ub'] + ) + constraint_map.src_constraint[newConstraint[name, 'ub']] = c # deactivate now that we have transformed obj.deactivate() @@ -931,9 +910,11 @@ def get_disaggregation_constraint( ) try: - cons = transBlock.parent_block()._disaggregationConstraintMap[original_var][ - disjunction - ] + cons = ( + transBlock.parent_block() + .private_data() + .disaggregation_constraint_map[original_var][disjunction] + ) except: if raise_exception: logger.error( @@ -949,10 +930,9 @@ def get_disaggregation_constraint( def get_var_bounds_constraint(self, v, disjunct=None): """ - Returns the IndexedConstraint which sets a disaggregated - variable to be within its bounds when its Disjunct is active and to - be 0 otherwise. (It is always an IndexedConstraint because each - bound becomes a separate constraint.) + Returns a dictionary mapping keys 'lb' and/or 'ub' to the Constraints that + set a disaggregated variable to be within its lower and upper bounds + (respectively) when its Disjunct is active and to be 0 otherwise. Parameters ---------- diff --git a/pyomo/gdp/plugins/multiple_bigm.py b/pyomo/gdp/plugins/multiple_bigm.py index 6177de3c037..b1e2581beca 100644 --- a/pyomo/gdp/plugins/multiple_bigm.py +++ b/pyomo/gdp/plugins/multiple_bigm.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ import itertools import logging -from pyomo.common.collections import ComponentMap +from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.config import ConfigDict, ConfigValue from pyomo.common.gc_manager import PauseGC from pyomo.common.modeling import unique_component_name @@ -72,6 +72,14 @@ } +def Solver(val): + if isinstance(val, str): + return SolverFactory(val) + if not hasattr(val, 'solve'): + raise ValueError("Expected a string or solver object (with solve() method)") + return val + + @TransformationFactory.register( 'gdp.mbigm', doc="Relax disjunctive model using big-M terms specific to each disjunct", @@ -127,7 +135,8 @@ class MultipleBigMTransformation(GDP_to_MIP_Transformation, _BigM_MixIn): CONFIG.declare( 'solver', ConfigValue( - default=SolverFactory('gurobi'), + default='gurobi', + domain=Solver, description="A solver to use to solve the continuous subproblems for " "calculating the M values", ), @@ -310,9 +319,12 @@ def _transform_disjunctionData(self, obj, index, parent_disjunct, root_disjunct) arg_Ms = self._config.bigM if self._config.bigM is not None else {} + # ESJ: I am relying on the fact that the ComponentSet is going to be + # ordered here, but using a set because I will remove infeasible + # Disjuncts from it if I encounter them calculating M's. + active_disjuncts = ComponentSet(disj for disj in obj.disjuncts if disj.active) # First handle the bound constraints if we are dealing with them # separately - active_disjuncts = [disj for disj in obj.disjuncts if disj.active] transformed_constraints = set() if self._config.reduce_bound_constraints: transformed_constraints = self._transform_bound_constraints( @@ -359,7 +371,7 @@ def _transform_disjunct(self, obj, transBlock, active_disjuncts, Ms): def _transform_constraint(self, obj, disjunct, active_disjuncts, Ms): # we will put a new transformed constraint on the relaxation block. relaxationBlock = disjunct._transformation_block() - constraintMap = relaxationBlock._constraintMap + constraint_map = relaxationBlock.private_data('pyomo.gdp') transBlock = relaxationBlock.parent_block() # Though rare, it is possible to get naming conflicts here @@ -378,7 +390,7 @@ def _transform_constraint(self, obj, disjunct, active_disjuncts, Ms): continue if not self._config.only_mbigm_bound_constraints: - transformed = [] + transformed = constraint_map.transformed_constraints[c] if c.lower is not None: rhs = sum( Ms[c, disj][0] * disj.indicator_var.get_associated_binary() @@ -397,8 +409,7 @@ def _transform_constraint(self, obj, disjunct, active_disjuncts, Ms): newConstraint.add((i, 'ub'), c.body - c.upper <= rhs) transformed.append(newConstraint[i, 'ub']) for c_new in transformed: - constraintMap['srcConstraints'][c_new] = [c] - constraintMap['transformedConstraints'][c] = transformed + constraint_map.src_constraint[c_new] = [c] else: lower = (None, None, None) upper = (None, None, None) @@ -427,7 +438,7 @@ def _transform_constraint(self, obj, disjunct, active_disjuncts, Ms): M, disjunct.indicator_var.get_associated_binary(), newConstraint, - constraintMap, + constraint_map, ) # deactivate now that we have transformed @@ -496,6 +507,7 @@ def _transform_bound_constraints(self, active_disjuncts, transBlock, Ms): relaxationBlock = self._get_disjunct_transformation_block( disj, transBlock ) + constraint_map = relaxationBlock.private_data('pyomo.gdp') if len(lower_dict) > 0: M = lower_dict.get(disj, None) if M is None: @@ -527,39 +539,24 @@ def _transform_bound_constraints(self, active_disjuncts, transBlock, Ms): idx = i + offset if len(lower_dict) > 0: transformed.add((idx, 'lb'), v >= lower_rhs) - relaxationBlock._constraintMap['srcConstraints'][ - transformed[idx, 'lb'] - ] = [] + constraint_map.src_constraint[transformed[idx, 'lb']] = [] for c, disj in lower_bound_constraints_by_var[v]: - relaxationBlock._constraintMap['srcConstraints'][ - transformed[idx, 'lb'] - ].append(c) - disj.transformation_block._constraintMap['transformedConstraints'][ - c - ] = [transformed[idx, 'lb']] + constraint_map.src_constraint[transformed[idx, 'lb']].append(c) + disj.transformation_block.private_data( + 'pyomo.gdp' + ).transformed_constraints[c].append(transformed[idx, 'lb']) if len(upper_dict) > 0: transformed.add((idx, 'ub'), v <= upper_rhs) - relaxationBlock._constraintMap['srcConstraints'][ - transformed[idx, 'ub'] - ] = [] + constraint_map.src_constraint[transformed[idx, 'ub']] = [] for c, disj in upper_bound_constraints_by_var[v]: - relaxationBlock._constraintMap['srcConstraints'][ - transformed[idx, 'ub'] - ].append(c) + constraint_map.src_constraint[transformed[idx, 'ub']].append(c) # might already be here if it had an upper bound - if ( - c - in disj.transformation_block._constraintMap[ - 'transformedConstraints' - ] - ): - disj.transformation_block._constraintMap[ - 'transformedConstraints' - ][c].append(transformed[idx, 'ub']) - else: - disj.transformation_block._constraintMap[ - 'transformedConstraints' - ][c] = [transformed[idx, 'ub']] + disj_constraint_map = disj.transformation_block.private_data( + 'pyomo.gdp' + ) + disj_constraint_map.transformed_constraints[c].append( + transformed[idx, 'ub'] + ) return transformed_constraints @@ -600,7 +597,7 @@ def _calculate_missing_M_values( ): if disjunct is other_disjunct: continue - if id(other_disjunct) in scratch_blocks: + elif id(other_disjunct) in scratch_blocks: scratch = scratch_blocks[id(other_disjunct)] else: scratch = scratch_blocks[id(other_disjunct)] = Block() @@ -646,7 +643,10 @@ def _calculate_missing_M_values( scratch.obj.expr = constraint.body - constraint.lower scratch.obj.sense = minimize lower_M = self._solve_disjunct_for_M( - other_disjunct, scratch, unsuccessful_solve_msg + other_disjunct, + scratch, + unsuccessful_solve_msg, + active_disjuncts, ) if constraint.upper is not None and upper_M is None: # last resort: calculate @@ -654,7 +654,10 @@ def _calculate_missing_M_values( scratch.obj.expr = constraint.body - constraint.upper scratch.obj.sense = maximize upper_M = self._solve_disjunct_for_M( - other_disjunct, scratch, unsuccessful_solve_msg + other_disjunct, + scratch, + unsuccessful_solve_msg, + active_disjuncts, ) arg_Ms[constraint, other_disjunct] = (lower_M, upper_M) transBlock._mbm_values[constraint, other_disjunct] = (lower_M, upper_M) @@ -666,9 +669,18 @@ def _calculate_missing_M_values( return arg_Ms def _solve_disjunct_for_M( - self, other_disjunct, scratch_block, unsuccessful_solve_msg + self, other_disjunct, scratch_block, unsuccessful_solve_msg, active_disjuncts ): + if not other_disjunct.active: + # If a Disjunct is infeasible, we will discover that and deactivate + # it when we are calculating the M values. We remove that disjunct + # from active_disjuncts inside of the loop in + # _calculate_missing_M_values. So that means that we might have + # deactivated Disjuncts here that we should skip over. + return 0 + solver = self._config.solver + results = solver.solve(other_disjunct, load_solutions=False) if results.solver.termination_condition is TerminationCondition.infeasible: # [2/18/24]: TODO: After the solver rewrite is complete, we will not @@ -684,6 +696,7 @@ def _solve_disjunct_for_M( "Disjunct '%s' is infeasible, deactivating." % other_disjunct.name ) other_disjunct.deactivate() + active_disjuncts.remove(other_disjunct) M = 0 else: # This is a solver that might report diff --git a/pyomo/gdp/plugins/partition_disjuncts.py b/pyomo/gdp/plugins/partition_disjuncts.py index 1a76900047c..b95a96a7b56 100644 --- a/pyomo/gdp/plugins/partition_disjuncts.py +++ b/pyomo/gdp/plugins/partition_disjuncts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,10 +10,8 @@ # ___________________________________________________________________________ """ -Between Steps (P-Split) reformulation for GDPs from: +Between Steps (P-Split) reformulation for GDPs from [KMT21]_. -J. Kronqvist, R. Misener, and C. Tsay, "Between Steps: Intermediate -Relaxations between big-M and Convex Hull Reformulations," 2021. """ @@ -102,17 +100,20 @@ def _generate_additively_separable_repn(nonlinear_part): def arbitrary_partition(disjunction, P): - """ - Returns a valid partition into P sets of the variables that appear in + """Returns a valid partition into P sets of the variables that appear in algebraic additively separable constraints in the Disjuncts in 'disjunction'. Note that this method may return an invalid partition if the constraints are not additively separable! Arguments: ---------- - disjunction : A Disjunction object for which the variable partition will be - created. - P : An int, the number of partitions + disjunction : DisjunctionData + A Disjunction object for which the variable partition will be + created. + + P : int + the number of partitions + """ # collect variables v_set = ComponentSet() @@ -129,20 +130,26 @@ def arbitrary_partition(disjunction, P): def compute_optimal_bounds(expr, global_constraints, opt): - """ - Returns a tuple (LB, UB) where LB and UB are the results of minimizing + """Returns a tuple (LB, UB) where LB and UB are the results of minimizing and maximizing expr over the variable bounds and the constraints on the global_constraints block. Note that if expr is nonlinear, even if one of the min and max problems is convex, the other won't be! Arguments: ---------- - expr : The subexpression whose bounds we will return - global_constraints : A Block which contains the global Constraints and Vars - of the original model - opt : A configured SolverFactory to use to minimize and maximize expr over - the set defined by global_constraints. Note that if expr is nonlinear, - opt will need to be capable of optimizing nonconvex problems. + expr : ExpressionBase + The subexpression whose bounds we will return + + global_constraints : BlockData + A Block which contains the global Constraints and Vars of the + original model + + opt : SolverBase + A configured Solver object to use to minimize and maximize expr + over the set defined by global_constraints. Note that if expr + is nonlinear, opt will need to be capable of optimizing + nonconvex problems. + """ if opt is None: raise GDP_Error( @@ -209,7 +216,7 @@ class PartitionDisjuncts_Transformation(Transformation): """ Transform disjunctive model to equivalent disjunctive model (with potentially tighter hull relaxation) by taking the "P-split" formulation - from Kronqvist et al. 2021 [1]. In each Disjunct, convex and additively + from Kronqvist et al. 2021 [KMT21]_. In each Disjunct, convex and additively separable constraints are split into separate constraints by introducing auxiliary variables that upperbound the subexpressions created by the split. Increasing the number of partitions can result in tighter hull relaxations, @@ -228,8 +235,7 @@ class PartitionDisjuncts_Transformation(Transformation): References ---------- - [1] J. Kronqvist, R. Misener, and C. Tsay, "Between Steps: Intermediate - Relaxations between big-M and Convex Hull Reformulations," 2021. + See [KMT21]_. """ @@ -355,8 +361,10 @@ class PartitionDisjuncts_Transformation(Transformation): the auxiliary variables created by the transformation. Some pre-implemented options include - * compute_fbbt_bounds (the default), and - * compute_optimal_bounds + + * compute_fbbt_bounds (the default), and + * compute_optimal_bounds + or you can write your own callback which accepts an Expression object, a model containing the variables and global constraints of the original instance, and a configured solver and returns a tuple (LB, UB) where diff --git a/pyomo/gdp/plugins/transform_current_disjunctive_state.py b/pyomo/gdp/plugins/transform_current_disjunctive_state.py index 3e20224ec3d..22e89122de8 100644 --- a/pyomo/gdp/plugins/transform_current_disjunctive_state.py +++ b/pyomo/gdp/plugins/transform_current_disjunctive_state.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/__init__.py b/pyomo/gdp/tests/__init__.py index a2a2c61779a..1eda732fbe1 100644 --- a/pyomo/gdp/tests/__init__.py +++ b/pyomo/gdp/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/common_tests.py b/pyomo/gdp/tests/common_tests.py index 28025816262..316eaedb060 100644 --- a/pyomo/gdp/tests/common_tests.py +++ b/pyomo/gdp/tests/common_tests.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -30,7 +30,7 @@ from pyomo.gdp import Disjunct, Disjunction, GDP_Error from pyomo.core.expr.compare import assertExpressionsEqual from pyomo.core.base import constraint, ComponentUID -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.repn import generate_standard_repn import pyomo.core.expr as EXPR import pyomo.gdp.tests.models as models @@ -425,12 +425,7 @@ def check_two_term_disjunction_xor(self, xor, disj1, disj2): assertExpressionsEqual( self, xor.body, - EXPR.LinearExpression( - [ - EXPR.MonomialTermExpression((1, disj1.binary_indicator_var)), - EXPR.MonomialTermExpression((1, disj2.binary_indicator_var)), - ] - ), + EXPR.LinearExpression([disj1.binary_indicator_var, disj2.binary_indicator_var]), ) self.assertEqual(xor.lower, 1) self.assertEqual(xor.upper, 1) @@ -957,9 +952,7 @@ def check_disjunction_data_target(self, transformation): transBlock = m.component("_pyomo_gdp_%s_reformulation" % transformation) self.assertIsInstance(transBlock, Block) self.assertIsInstance(transBlock.component("disjunction_xor"), Constraint) - self.assertIsInstance( - transBlock.disjunction_xor[2], constraint._GeneralConstraintData - ) + self.assertIsInstance(transBlock.disjunction_xor[2], constraint.ConstraintData) self.assertIsInstance(transBlock.component("relaxedDisjuncts"), Block) self.assertEqual(len(transBlock.relaxedDisjuncts), 3) @@ -968,7 +961,7 @@ def check_disjunction_data_target(self, transformation): m, targets=[m.disjunction[1]] ) self.assertIsInstance( - m.disjunction[1].algebraic_constraint, constraint._GeneralConstraintData + m.disjunction[1].algebraic_constraint, constraint.ConstraintData ) transBlock = m.component("_pyomo_gdp_%s_reformulation_4" % transformation) self.assertIsInstance(transBlock, Block) @@ -1709,10 +1702,10 @@ def check_all_components_transformed(self, m): # makeNestedDisjunctions_NestedDisjuncts model. self.assertIsInstance(m.disj.algebraic_constraint, Constraint) self.assertIsInstance(m.d1.disj2.algebraic_constraint, Constraint) - self.assertIsInstance(m.d1.transformation_block, _BlockData) - self.assertIsInstance(m.d2.transformation_block, _BlockData) - self.assertIsInstance(m.d1.d3.transformation_block, _BlockData) - self.assertIsInstance(m.d1.d4.transformation_block, _BlockData) + self.assertIsInstance(m.d1.transformation_block, BlockData) + self.assertIsInstance(m.d2.transformation_block, BlockData) + self.assertIsInstance(m.d1.d3.transformation_block, BlockData) + self.assertIsInstance(m.d1.d4.transformation_block, BlockData) def check_transformation_blocks_nestedDisjunctions(self, m, transformation): diff --git a/pyomo/gdp/tests/jobshop_large_hull.lp b/pyomo/gdp/tests/jobshop_large_hull.lp index ee8ee0a73d2..f0a9d3ccbf0 100644 --- a/pyomo/gdp/tests/jobshop_large_hull.lp +++ b/pyomo/gdp/tests/jobshop_large_hull.lp @@ -66,17 +66,17 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(3)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(4)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(5)_: +1 t(C) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(5)_: ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(6)_: +1 t(D) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(D)_ @@ -114,17 +114,17 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(11)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(12)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(A)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(13)_: +1 t(F) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(F)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(F)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(13)_: ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(A)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(14)_: +1 t(F) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(F)_ @@ -150,29 +150,29 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(17)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(18)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(B)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(19)_: +1 t(C) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(C)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(C)_ = 0 -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(20)_: +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(19)_: +1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(B)_ = 0 -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(21)_: +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(20)_: +1 t(D) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(D)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(D)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(21)_: ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(B)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(22)_: +1 t(D) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(D)_ @@ -186,17 +186,17 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(23)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(24)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(B)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(25)_: +1 t(E) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(E)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(E)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(25)_: ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(B)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(26)_: +1 t(E) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(E)_ @@ -234,17 +234,17 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(31)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(32)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(B)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(33)_: +1 t(G) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(G)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(G)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(33)_: ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(B)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(34)_: +1 t(G) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(34)_disaggregatedVars__t(G)_ @@ -294,17 +294,17 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(41)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(42)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(C)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(43)_: +1 t(F) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(F)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(F)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(43)_: ++1 t(C) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(C)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(44)_: +1 t(F) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(F)_ @@ -342,17 +342,17 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(49)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(50)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(D)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(51)_: +1 t(E) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(E)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(E)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(51)_: ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(D)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(52)_: +1 t(E) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(E)_ @@ -390,17 +390,17 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(57)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(58)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(D)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(59)_: +1 t(G) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(G)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(G)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(59)_: ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(D)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(60)_: +1 t(G) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(G)_ @@ -426,17 +426,17 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(63)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(64)_: -+1 t(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(E)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(65)_: +1 t(G) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(G)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(G)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(65)_: ++1 t(E) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(E)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(66)_: +1 t(G) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(G)_ @@ -701,34 +701,34 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(A)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ +6.0 NoClash(A_C_1_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ -92 NoClash(A_C_1_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ -92 NoClash(A_C_1_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ +3.0 NoClash(A_C_1_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ -92 NoClash(A_C_1_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ -92 NoClash(A_C_1_1)_binary_indicator_var <= 0 @@ -827,34 +827,34 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)__t(A)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(A)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(A)_ +2.0 NoClash(A_F_1_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(A)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(F)_ -92 NoClash(A_F_1_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(F)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(A)_ -92 NoClash(A_F_1_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(A)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(F)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(A)_ +3.0 NoClash(A_F_1_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(A)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(F)_ -92 NoClash(A_F_1_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(F)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(A)_ -92 NoClash(A_F_1_1)_binary_indicator_var <= 0 @@ -923,66 +923,66 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)__t(A)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(B)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(B)_ +9.0 NoClash(B_C_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(C)_ -92 NoClash(B_C_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(B)_ -92 NoClash(B_C_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(B)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(C)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(B)_ -3.0 NoClash(B_C_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(C)_ -92 NoClash(B_C_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(B)_ -92 NoClash(B_C_2_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(B)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(B)_ +8.0 NoClash(B_D_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(D)_ -92 NoClash(B_D_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(D)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(B)_ -92 NoClash(B_D_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(B)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(D)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(B)_ +3.0 NoClash(B_D_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(D)_ -92 NoClash(B_D_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(D)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(B)_ -92 NoClash(B_D_2_1)_binary_indicator_var <= 0 @@ -1019,34 +1019,34 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)__t(B)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(B)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(B)_ +4.0 NoClash(B_E_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(E)_ -92 NoClash(B_E_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(E)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(B)_ -92 NoClash(B_E_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(B)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(E)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(B)_ +3.0 NoClash(B_E_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(E)_ -92 NoClash(B_E_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(E)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(B)_ -92 NoClash(B_E_2_1)_binary_indicator_var <= 0 @@ -1146,34 +1146,34 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)__t(B)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(B)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(B)_ +8.0 NoClash(B_G_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(G)_ -92 NoClash(B_G_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(G)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(B)_ -92 NoClash(B_G_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(B)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(B)_ +3.0 NoClash(B_G_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(G)_ -92 NoClash(B_G_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(G)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(B)_ -92 NoClash(B_G_2_1)_binary_indicator_var <= 0 @@ -1306,34 +1306,34 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)__t(C)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(C)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(C)_ +2.0 NoClash(C_F_1_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(F)_ -92 NoClash(C_F_1_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(F)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(C)_ -92 NoClash(C_F_1_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(C)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(F)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(C)_ +6.0 NoClash(C_F_1_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(F)_ -92 NoClash(C_F_1_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(F)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(C)_ -92 NoClash(C_F_1_1)_binary_indicator_var <= 0 @@ -1434,34 +1434,34 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)__t(C)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(D)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(D)_ +4.0 NoClash(D_E_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(D)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(E)_ -92 NoClash(D_E_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(E)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(D)_ -92 NoClash(D_E_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(D)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(E)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(D)_ +8.0 NoClash(D_E_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(D)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(E)_ -92 NoClash(D_E_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(E)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(D)_ -92 NoClash(D_E_2_1)_binary_indicator_var <= 0 @@ -1562,34 +1562,34 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)__t(D)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(D)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(D)_ +8.0 NoClash(D_G_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(D)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(G)_ -92 NoClash(D_G_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(G)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(D)_ -92 NoClash(D_G_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(D)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(D)_ +8.0 NoClash(D_G_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(D)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(G)_ -92 NoClash(D_G_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(G)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(D)_ -92 NoClash(D_G_2_1)_binary_indicator_var <= 0 @@ -1657,34 +1657,34 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)__t(E)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(E)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(E)_ +8.0 NoClash(E_G_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(E)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(G)_ -92 NoClash(E_G_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(G)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(E)_ -92 NoClash(E_G_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(E)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(E)_ +4.0 NoClash(E_G_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(E)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(G)_ -92 NoClash(E_G_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(G)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(E)_ -92 NoClash(E_G_2_1)_binary_indicator_var <= 0 @@ -1769,10 +1769,10 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(A)_ <= 92 @@ -1785,10 +1785,10 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(A)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(A)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(F)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(A)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(A)_ <= 92 @@ -1797,22 +1797,22 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(A)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(C)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(E)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(B)_ <= 92 @@ -1825,10 +1825,10 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(G)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(34)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(35)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(34)_disaggregatedVars__t(B)_ <= 92 @@ -1845,10 +1845,10 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(C)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(C)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(F)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(C)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(C)_ <= 92 @@ -1861,10 +1861,10 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(C)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(E)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(D)_ <= 92 @@ -1877,10 +1877,10 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(G)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(D)_ <= 92 @@ -1889,10 +1889,10 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(G)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(E)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(G)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(E)_ <= 92 diff --git a/pyomo/gdp/tests/jobshop_small_hull.lp b/pyomo/gdp/tests/jobshop_small_hull.lp index ae2d738d29c..eccaa800600 100644 --- a/pyomo/gdp/tests/jobshop_small_hull.lp +++ b/pyomo/gdp/tests/jobshop_small_hull.lp @@ -34,29 +34,29 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(1)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(2)_: ++1 t(C) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(C)_ += 0 + +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(3)_: +1 t(A) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ = 0 -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(3)_: +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(4)_: +1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ = 0 -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(4)_: +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(5)_: +1 t(B) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(B)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(B)_ = 0 -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(5)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ -= 0 - c_e__pyomo_gdp_hull_reformulation_disj_xor(A_B_3)_: +1 NoClash(A_B_3_0)_binary_indicator_var +1 NoClash(A_B_3_1)_binary_indicator_var @@ -104,66 +104,66 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)__t(A)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ +2.0 NoClash(A_C_1_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(C)_ -19 NoClash(A_C_1_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ -19 NoClash(A_C_1_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(C)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ +5.0 NoClash(A_C_1_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(C)_ -19 NoClash(A_C_1_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ -19 NoClash(A_C_1_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(B)_ +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(B)_ +6.0 NoClash(B_C_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ -19 NoClash(B_C_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(B)_ -19 NoClash(B_C_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(B)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(B)_ +1 NoClash(B_C_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(B)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ -19 NoClash(B_C_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(B)_ -19 NoClash(B_C_2_1)_binary_indicator_var <= 0 @@ -176,14 +176,14 @@ bounds 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ <= 19 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(A)_ <= 19 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(A)_ <= 19 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ <= 19 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ <= 19 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(C)_ <= 19 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(C)_ <= 19 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(B)_ <= 19 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(B)_ <= 19 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ <= 19 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ <= 19 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(C)_ <= 19 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(C)_ <= 19 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(B)_ <= 19 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(B)_ <= 19 0 <= NoClash(A_B_3_0)_binary_indicator_var <= 1 0 <= NoClash(A_B_3_1)_binary_indicator_var <= 1 0 <= NoClash(A_C_1_0)_binary_indicator_var <= 1 diff --git a/pyomo/gdp/tests/models.py b/pyomo/gdp/tests/models.py index 0b84641899c..18e5bf02f17 100644 --- a/pyomo/gdp/tests/models.py +++ b/pyomo/gdp/tests/models.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -840,7 +840,7 @@ def makeAnyIndexedDisjunctionOfDisjunctDatas(): build from DisjunctDatas. Identical mathematically to makeDisjunctionOfDisjunctDatas. - Used to test that the right things happen for a case where soemone + Used to test that the right things happen for a case where someone implements an algorithm which iteratively generates disjuncts and retransforms""" m = ConcreteModel() diff --git a/pyomo/gdp/tests/test_basic_step.py b/pyomo/gdp/tests/test_basic_step.py index 7e21c46da92..f981460a544 100644 --- a/pyomo/gdp/tests/test_basic_step.py +++ b/pyomo/gdp/tests/test_basic_step.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_bigm.py b/pyomo/gdp/tests/test_bigm.py index 00efcb46485..ddf0863ceef 100644 --- a/pyomo/gdp/tests/test_bigm.py +++ b/pyomo/gdp/tests/test_bigm.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,15 +19,18 @@ Set, Constraint, ComponentMap, + LogicalConstraint, + Objective, SolverFactory, Suffix, + TerminationCondition, ConcreteModel, Var, Any, value, ) from pyomo.gdp import Disjunct, Disjunction, GDP_Error -from pyomo.core.base import constraint, _ConstraintData +from pyomo.core.base import constraint, ConstraintData from pyomo.core.expr.compare import ( assertExpressionsEqual, assertExpressionsStructurallyEqual, @@ -155,10 +158,7 @@ def test_or_constraints(self): self, orcons.body, EXPR.LinearExpression( - [ - EXPR.MonomialTermExpression((1, m.d[0].binary_indicator_var)), - EXPR.MonomialTermExpression((1, m.d[1].binary_indicator_var)), - ] + [m.d[0].binary_indicator_var, m.d[1].binary_indicator_var] ), ) self.assertEqual(orcons.lower, 1) @@ -656,14 +656,14 @@ def test_disjunct_and_constraint_maps(self): if src[0]: # equality self.assertEqual(len(transformed), 2) - self.assertIsInstance(transformed[0], _ConstraintData) - self.assertIsInstance(transformed[1], _ConstraintData) + self.assertIsInstance(transformed[0], ConstraintData) + self.assertIsInstance(transformed[1], ConstraintData) self.assertIs(bigm.get_src_constraint(transformed[0]), srcDisjunct.c) self.assertIs(bigm.get_src_constraint(transformed[1]), srcDisjunct.c) else: # >= self.assertEqual(len(transformed), 1) - self.assertIsInstance(transformed[0], _ConstraintData) + self.assertIsInstance(transformed[0], ConstraintData) # check reverse map from the container self.assertIs(bigm.get_src_constraint(transformed[0]), srcDisjunct.c) @@ -1316,26 +1316,18 @@ def test_do_not_transform_deactivated_constraintDatas(self): bigm.apply_to(m) # the real test: This wasn't transformed - log = StringIO() - with LoggingIntercept(log, 'pyomo.gdp', logging.ERROR): - self.assertRaisesRegex( - KeyError, - r".*b.simpledisj1.c\[1\]", - bigm.get_transformed_constraints, - m.b.simpledisj1.c[1], - ) - self.assertRegex( - log.getvalue(), - r".*Constraint 'b.simpledisj1.c\[1\]' has not been transformed.", - ) + with self.assertRaisesRegex( + GDP_Error, r"Constraint 'b.simpledisj1.c\[1\]' has not been transformed." + ): + bigm.get_transformed_constraints(m.b.simpledisj1.c[1]) # and the rest of the container was transformed cons_list = bigm.get_transformed_constraints(m.b.simpledisj1.c[2]) self.assertEqual(len(cons_list), 2) lb = cons_list[0] ub = cons_list[1] - self.assertIsInstance(lb, constraint._GeneralConstraintData) - self.assertIsInstance(ub, constraint._GeneralConstraintData) + self.assertIsInstance(lb, constraint.ConstraintData) + self.assertIsInstance(ub, constraint.ConstraintData) def checkMs( self, m, disj1c1lb, disj1c1ub, disj1c2lb, disj1c2ub, disj2c1ub, disj2c2ub @@ -1890,12 +1882,11 @@ def test_m_value_mappings(self): # many of the transformed constraints look like this, so can call this # function to test them. def check_bigM_constraint(self, cons, variable, M, indicator_var): - repn = generate_standard_repn(cons.body) - self.assertTrue(repn.is_linear()) - self.assertEqual(repn.constant, -M) - self.assertEqual(len(repn.linear_vars), 2) - ct.check_linear_coef(self, repn, variable, 1) - ct.check_linear_coef(self, repn, indicator_var, M) + assertExpressionsEqual( + self, + cons.body, + variable - float(M) * (1 - indicator_var.get_associated_binary()), + ) def check_inner_xor_constraint(self, inner_disjunction, outer_disjunct, bigm): inner_xor = inner_disjunction.algebraic_constraint @@ -1960,6 +1951,10 @@ def test_transformed_constraints(self): .binary_indicator_var, ) ), + 1, + EXPR.MonomialTermExpression( + (-1, m.disjunct[1].binary_indicator_var) + ), ] ), ) @@ -1969,37 +1964,69 @@ def test_transformed_constraints(self): ] ), ) - self.assertIsNone(cons1ub.lower) - self.assertEqual(cons1ub.upper, 0) - self.check_bigM_constraint( - cons1ub, m.z, 10, m.disjunct[1].innerdisjunct[0].indicator_var + assertExpressionsEqual( + self, + cons1ub.expr, + m.z + - 10.0 + * ( + 1 + - m.disjunct[1].innerdisjunct[0].binary_indicator_var + + 1 + - m.disjunct[1].binary_indicator_var + ) + <= 0.0, ) cons2 = bigm.get_transformed_constraints(m.disjunct[1].innerdisjunct[1].c) self.assertEqual(len(cons2), 1) cons2lb = cons2[0] - self.assertEqual(cons2lb.lower, 5) - self.assertIsNone(cons2lb.upper) - self.check_bigM_constraint( - cons2lb, m.z, -5, m.disjunct[1].innerdisjunct[1].indicator_var + assertExpressionsEqual( + self, + cons2lb.expr, + 5.0 + <= m.z + - (-5.0) + * ( + 1 + - m.disjunct[1].innerdisjunct[1].binary_indicator_var + + 1 + - m.disjunct[1].binary_indicator_var + ), ) cons3 = bigm.get_transformed_constraints(m.simpledisjunct.innerdisjunct0.c) self.assertEqual(len(cons3), 1) cons3ub = cons3[0] - self.assertEqual(cons3ub.upper, 2) - self.assertIsNone(cons3ub.lower) - self.check_bigM_constraint( - cons3ub, m.x, 7, m.simpledisjunct.innerdisjunct0.indicator_var + assertExpressionsEqual( + self, + cons3ub.expr, + m.x + - 7.0 + * ( + 1 + - m.simpledisjunct.innerdisjunct0.binary_indicator_var + + 1 + - m.simpledisjunct.binary_indicator_var + ) + <= 2.0, ) cons4 = bigm.get_transformed_constraints(m.simpledisjunct.innerdisjunct1.c) self.assertEqual(len(cons4), 1) cons4lb = cons4[0] - self.assertEqual(cons4lb.lower, 4) - self.assertIsNone(cons4lb.upper) - self.check_bigM_constraint( - cons4lb, m.x, -13, m.simpledisjunct.innerdisjunct1.indicator_var + assertExpressionsEqual( + self, + cons4lb.expr, + m.x + - (-13.0) + * ( + 1 + - m.simpledisjunct.innerdisjunct1.binary_indicator_var + + 1 + - m.simpledisjunct.binary_indicator_var + ) + >= 4.0, ) # Here we check that the xor constraint from @@ -2099,35 +2126,6 @@ def innerIndexed(d, i): m._pyomo_gdp_bigm_reformulation.relaxedDisjuncts, ) - def check_first_disjunct_constraint(self, disj1c, x, ind_var): - self.assertEqual(len(disj1c), 1) - cons = disj1c[0] - self.assertIsNone(cons.lower) - self.assertEqual(cons.upper, 1) - repn = generate_standard_repn(cons.body) - self.assertTrue(repn.is_quadratic()) - self.assertEqual(len(repn.linear_vars), 1) - self.assertEqual(len(repn.quadratic_vars), 4) - ct.check_linear_coef(self, repn, ind_var, 143) - self.assertEqual(repn.constant, -143) - for i in range(1, 5): - ct.check_squared_term_coef(self, repn, x[i], 1) - - def check_second_disjunct_constraint(self, disj2c, x, ind_var): - self.assertEqual(len(disj2c), 1) - cons = disj2c[0] - self.assertIsNone(cons.lower) - self.assertEqual(cons.upper, 1) - repn = generate_standard_repn(cons.body) - self.assertTrue(repn.is_quadratic()) - self.assertEqual(len(repn.linear_vars), 5) - self.assertEqual(len(repn.quadratic_vars), 4) - self.assertEqual(repn.constant, -63) # M = 99, so this is 36 - 99 - ct.check_linear_coef(self, repn, ind_var, 99) - for i in range(1, 5): - ct.check_squared_term_coef(self, repn, x[i], 1) - ct.check_linear_coef(self, repn, x[i], -6) - def simplify_cons(self, cons, leq): visitor = LinearRepnVisitor({}, {}, {}, None) repn = visitor.walk_expression(cons.body) @@ -2153,30 +2151,76 @@ def check_hierarchical_nested_model(self, m, bigm): # outer disjunction constraints disj1c = bigm.get_transformed_constraints(m.disj1.c) - self.check_first_disjunct_constraint(disj1c, m.x, m.disj1.binary_indicator_var) + self.assertEqual(len(disj1c), 1) + cons = disj1c[0] + assertExpressionsEqual( + self, + cons.expr, + m.x[1] ** 2 + + m.x[2] ** 2 + + m.x[3] ** 2 + + m.x[4] ** 2 + - 143.0 * (1 - m.disj1.binary_indicator_var) + <= 1.0, + ) disj2c = bigm.get_transformed_constraints(m.disjunct_block.disj2.c) - self.check_second_disjunct_constraint( - disj2c, m.x, m.disjunct_block.disj2.binary_indicator_var + self.assertEqual(len(disj2c), 1) + cons = disj2c[0] + assertExpressionsEqual( + self, + cons.expr, + (3 - m.x[1]) ** 2 + + (3 - m.x[2]) ** 2 + + (3 - m.x[3]) ** 2 + + (3 - m.x[4]) ** 2 + - 99.0 * (1 - m.disjunct_block.disj2.binary_indicator_var) + <= 1.0, ) # inner disjunction constraints innerd1c = bigm.get_transformed_constraints( m.disjunct_block.disj2.disjunction_disjuncts[0].constraint[1] ) - self.check_first_disjunct_constraint( - innerd1c, - m.x, - m.disjunct_block.disj2.disjunction_disjuncts[0].binary_indicator_var, + self.assertEqual(len(innerd1c), 1) + cons = innerd1c[0] + assertExpressionsEqual( + self, + cons.expr, + m.x[1] ** 2 + + m.x[2] ** 2 + + m.x[3] ** 2 + + m.x[4] ** 2 + - 143.0 + * ( + 1 + - m.disjunct_block.disj2.disjunction_disjuncts[0].binary_indicator_var + + 1 + - m.disjunct_block.disj2.binary_indicator_var + ) + <= 1.0, ) innerd2c = bigm.get_transformed_constraints( m.disjunct_block.disj2.disjunction_disjuncts[1].constraint[1] ) - self.check_second_disjunct_constraint( - innerd2c, - m.x, - m.disjunct_block.disj2.disjunction_disjuncts[1].binary_indicator_var, + self.assertEqual(len(innerd2c), 1) + cons = innerd2c[0] + assertExpressionsEqual( + self, + cons.expr, + (3 - m.x[1]) ** 2 + + (3 - m.x[2]) ** 2 + + (3 - m.x[3]) ** 2 + + (3 - m.x[4]) ** 2 + - 99.0 + * ( + 1 + - m.disjunct_block.disj2.disjunction_disjuncts[1].binary_indicator_var + + 1 + - m.disjunct_block.disj2.binary_indicator_var + ) + <= 1.0, ) def test_hierarchical_badly_ordered_targets(self): @@ -2204,10 +2248,50 @@ def test_decl_order_opposite_instantiation_order(self): def test_do_not_assume_nested_indicators_local(self): ct.check_do_not_assume_nested_indicators_local(self, 'gdp.bigm') + @unittest.skipUnless(gurobi_available, "Gurobi is not available") + def test_constraints_not_enforced_when_an_ancestor_indicator_is_False(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 30)) + + m.left = Disjunct() + m.left.left = Disjunct() + m.left.left.c = Constraint(expr=m.x >= 10) + m.left.right = Disjunct() + m.left.right.c = Constraint(expr=m.x >= 9) + m.left.disjunction = Disjunction(expr=[m.left.left, m.left.right]) + m.right = Disjunct() + m.right.left = Disjunct() + m.right.left.c = Constraint(expr=m.x >= 11) + m.right.right = Disjunct() + m.right.right.c = Constraint(expr=m.x >= 8) + m.right.disjunction = Disjunction(expr=[m.right.left, m.right.right]) + m.disjunction = Disjunction(expr=[m.left, m.right]) + + m.equiv_left = LogicalConstraint( + expr=m.left.left.indicator_var.equivalent_to(m.right.left.indicator_var) + ) + m.equiv_right = LogicalConstraint( + expr=m.left.right.indicator_var.equivalent_to(m.right.right.indicator_var) + ) + + m.obj = Objective(expr=m.x) + + TransformationFactory('gdp.bigm').apply_to(m) + results = SolverFactory('gurobi').solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertTrue(value(m.right.indicator_var)) + self.assertFalse(value(m.left.indicator_var)) + self.assertTrue(value(m.right.right.indicator_var)) + self.assertFalse(value(m.right.left.indicator_var)) + self.assertTrue(value(m.left.right.indicator_var)) + self.assertAlmostEqual(value(m.x), 8) + class IndexedDisjunction(unittest.TestCase): # this tests that if the targets are a subset of the - # _DisjunctDatas in an IndexedDisjunction that the xor constraint + # DisjunctDatas in an IndexedDisjunction that the xor constraint # created on the parent block will still be indexed as expected. def test_xor_constraint(self): ct.check_indexed_xor_constraints_with_targets(self, 'bigm') @@ -2272,18 +2356,12 @@ def check_all_but_evil1_b_anotherblock_constraint_transformed(self, m): self.assertEqual(len(evil1), 2) self.assertIs(evil1[0].parent_block(), disjBlock[1]) self.assertIs(evil1[1].parent_block(), disjBlock[1]) - out = StringIO() - with LoggingIntercept(out, 'pyomo.gdp', logging.ERROR): - self.assertRaisesRegex( - KeyError, - r".*.evil\[1\].b.anotherblock.c", - bigm.get_transformed_constraints, - m.evil[1].b.anotherblock.c, - ) - self.assertRegex( - out.getvalue(), - r".*Constraint 'evil\[1\].b.anotherblock.c' has not been transformed.", - ) + with self.assertRaisesRegex( + GDP_Error, + r"Constraint 'evil\[1\].b.anotherblock.c' has not been transformed.", + ): + bigm.get_transformed_constraints(m.evil[1].b.anotherblock.c) + evil1 = bigm.get_transformed_constraints(m.evil[1].bb[1].c) self.assertEqual(len(evil1), 2) self.assertIs(evil1[0].parent_block(), disjBlock[1]) diff --git a/pyomo/gdp/tests/test_binary_multiplication.py b/pyomo/gdp/tests/test_binary_multiplication.py index aa846c4710a..a89fae44549 100644 --- a/pyomo/gdp/tests/test_binary_multiplication.py +++ b/pyomo/gdp/tests/test_binary_multiplication.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -146,10 +146,7 @@ def test_or_constraints(self): self, orcons.body, EXPR.LinearExpression( - [ - EXPR.MonomialTermExpression((1, m.d[0].binary_indicator_var)), - EXPR.MonomialTermExpression((1, m.d[1].binary_indicator_var)), - ] + [m.d[0].binary_indicator_var, m.d[1].binary_indicator_var] ), ) self.assertEqual(orcons.lower, 1) diff --git a/pyomo/gdp/tests/test_bound_pretransformation.py b/pyomo/gdp/tests/test_bound_pretransformation.py index 68db64ce93b..563bdee1ebb 100644 --- a/pyomo/gdp/tests/test_bound_pretransformation.py +++ b/pyomo/gdp/tests/test_bound_pretransformation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_cuttingplane.py b/pyomo/gdp/tests/test_cuttingplane.py index 153e236942d..6f6d5651bff 100644 --- a/pyomo/gdp/tests/test_cuttingplane.py +++ b/pyomo/gdp/tests/test_cuttingplane.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_disjunct.py b/pyomo/gdp/tests/test_disjunct.py index d969b245ee7..2248531bf53 100644 --- a/pyomo/gdp/tests/test_disjunct.py +++ b/pyomo/gdp/tests/test_disjunct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -632,19 +632,13 @@ def test_cast_to_binary(self): out = StringIO() with LoggingIntercept(out): e = m.iv + 1 - assertExpressionsEqual( - self, e, EXPR.LinearExpression([EXPR.MonomialTermExpression((1, m.biv)), 1]) - ) + assertExpressionsEqual(self, e, EXPR.LinearExpression([m.biv, 1])) self.assertIn(deprecation_msg, out.getvalue()) out = StringIO() with LoggingIntercept(out): e = m.iv - 1 - assertExpressionsEqual( - self, - e, - EXPR.LinearExpression([EXPR.MonomialTermExpression((1, m.biv)), -1]), - ) + assertExpressionsEqual(self, e, EXPR.LinearExpression([m.biv, -1])) self.assertIn(deprecation_msg, out.getvalue()) out = StringIO() @@ -665,9 +659,7 @@ def test_cast_to_binary(self): out = StringIO() with LoggingIntercept(out): e = 1 + m.iv - assertExpressionsEqual( - self, e, EXPR.LinearExpression([1, EXPR.MonomialTermExpression((1, m.biv))]) - ) + assertExpressionsEqual(self, e, EXPR.LinearExpression([1, m.biv])) self.assertIn(deprecation_msg, out.getvalue()) out = StringIO() @@ -699,20 +691,14 @@ def test_cast_to_binary(self): with LoggingIntercept(out): a = m.iv a += 1 - assertExpressionsEqual( - self, a, EXPR.LinearExpression([EXPR.MonomialTermExpression((1, m.biv)), 1]) - ) + assertExpressionsEqual(self, a, EXPR.LinearExpression([m.biv, 1])) self.assertIn(deprecation_msg, out.getvalue()) out = StringIO() with LoggingIntercept(out): a = m.iv a -= 1 - assertExpressionsEqual( - self, - a, - EXPR.LinearExpression([EXPR.MonomialTermExpression((1, m.biv)), -1]), - ) + assertExpressionsEqual(self, a, EXPR.LinearExpression([m.biv, -1])) self.assertIn(deprecation_msg, out.getvalue()) out = StringIO() diff --git a/pyomo/gdp/tests/test_fix_disjuncts.py b/pyomo/gdp/tests/test_fix_disjuncts.py index 6f01e096e9d..2416ffe451f 100644 --- a/pyomo/gdp/tests/test_fix_disjuncts.py +++ b/pyomo/gdp/tests/test_fix_disjuncts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_gdp.py b/pyomo/gdp/tests/test_gdp.py index b22a60bc04a..008d4595594 100644 --- a/pyomo/gdp/tests/test_gdp.py +++ b/pyomo/gdp/tests/test_gdp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_gdp_reclassification_error.py b/pyomo/gdp/tests/test_gdp_reclassification_error.py index 556dc44eead..0e9188df632 100644 --- a/pyomo/gdp/tests/test_gdp_reclassification_error.py +++ b/pyomo/gdp/tests/test_gdp_reclassification_error.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_hull.py b/pyomo/gdp/tests/test_hull.py index 858764759ee..204bcfea4c0 100644 --- a/pyomo/gdp/tests/test_hull.py +++ b/pyomo/gdp/tests/test_hull.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,10 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.dependencies import dill_available +import logging +import sys +import random +from io import StringIO + import pyomo.common.unittest as unittest + +from pyomo.common.dependencies import dill_available from pyomo.common.log import LoggingIntercept -import logging +from pyomo.common.fileutils import this_file_dir from pyomo.environ import ( TransformationFactory, @@ -32,7 +38,6 @@ Param, Objective, TerminationCondition, - Reference, ) from pyomo.core.expr.compare import ( assertExpressionsEqual, @@ -47,14 +52,8 @@ import pyomo.gdp.tests.models as models import pyomo.gdp.tests.common_tests as ct -import random -from io import StringIO -import os -from os.path import abspath, dirname, join - -currdir = dirname(abspath(__file__)) -from filecmp import cmp +currdir = this_file_dir() EPS = TransformationFactory('gdp.hull').CONFIG.EPS linear_solvers = ct.linear_solvers @@ -530,14 +529,18 @@ def test_bigMConstraint_mappings(self): mappings[disjBlock[i].disaggregatedVars.x] = disjBlock[i].x_bounds if i == 1: # this disjunct has x, w, and no y mappings[disjBlock[i].disaggregatedVars.w] = disjBlock[i].w_bounds - mappings[transBlock._disaggregatedVars[0]] = Reference( - transBlock._boundsConstraints[0, ...] - ) + mappings[transBlock._disaggregatedVars[0]] = { + key: val + for key, val in transBlock._boundsConstraints.items() + if key[0] == 0 + } elif i == 0: # this disjunct has x, y, and no w mappings[disjBlock[i].disaggregatedVars.y] = disjBlock[i].y_bounds - mappings[transBlock._disaggregatedVars[1]] = Reference( - transBlock._boundsConstraints[1, ...] - ) + mappings[transBlock._disaggregatedVars[1]] = { + key: val + for key, val in transBlock._boundsConstraints.items() + if key[0] == 1 + } for var, cons in mappings.items(): returned_cons = hull.get_var_bounds_constraint(var) # This sometimes refers a reference to the right part of a @@ -545,6 +548,8 @@ def test_bigMConstraint_mappings(self): # themselves might not be the same object. The ConstraintDatas # are though: for key, constraintData in cons.items(): + if type(key) is tuple: + key = key[1] self.assertIs(returned_cons[key], constraintData) def test_create_using_nonlinear(self): @@ -897,18 +902,10 @@ def test_do_not_transform_deactivated_constraintDatas(self): hull = TransformationFactory('gdp.hull') hull.apply_to(m) # can't ask for simpledisj1.c[1]: it wasn't transformed - log = StringIO() - with LoggingIntercept(log, 'pyomo.gdp', logging.ERROR): - self.assertRaisesRegex( - KeyError, - r".*b.simpledisj1.c\[1\]", - hull.get_transformed_constraints, - m.b.simpledisj1.c[1], - ) - self.assertRegex( - log.getvalue(), - r".*Constraint 'b.simpledisj1.c\[1\]' has not been transformed.", - ) + with self.assertRaisesRegex( + GDP_Error, r"Constraint 'b.simpledisj1.c\[1\]' has not been transformed." + ): + hull.get_transformed_constraints(m.b.simpledisj1.c[1]) # this fixes a[2] to 0, so we should get the disggregated var transformed = hull.get_transformed_constraints(m.b.simpledisj1.c[2]) @@ -1260,12 +1257,10 @@ def check_second_iteration(self, model): orig = model.component("_pyomo_gdp_hull_reformulation") self.assertIsInstance( - model.disjunctionList[1].algebraic_constraint, - constraint._GeneralConstraintData, + model.disjunctionList[1].algebraic_constraint, constraint.ConstraintData ) self.assertIsInstance( - model.disjunctionList[0].algebraic_constraint, - constraint._GeneralConstraintData, + model.disjunctionList[0].algebraic_constraint, constraint.ConstraintData ) self.assertFalse(model.disjunctionList[1].active) self.assertFalse(model.disjunctionList[0].active) @@ -2314,8 +2309,7 @@ def test_mapping_method_errors(self): with LoggingIntercept(log, 'pyomo.gdp.hull', logging.ERROR): self.assertRaisesRegex( KeyError, - r".*_pyomo_gdp_hull_reformulation.relaxedDisjuncts\[1\]." - r"disaggregatedVars.w", + r".*disjunction", hull.get_disaggregation_constraint, m.d[1].transformation_block.disaggregatedVars.w, m.disjunction, @@ -2880,7 +2874,15 @@ def test_pickle(self): @unittest.skipIf(not dill_available, "Dill is not available") def test_dill_pickle(self): - ct.check_transformed_model_pickles_with_dill(self, 'hull') + try: + # As of Nov 2024, this test needs a larger recursion limit + # due to the various references among the modeling objects + # 1385 is sufficient locally, but not always on GHA. + rl = sys.getrecursionlimit() + sys.setrecursionlimit(max(1500, rl)) + ct.check_transformed_model_pickles_with_dill(self, 'hull') + finally: + sys.setrecursionlimit(rl) @unittest.skipUnless(gurobi_available, "Gurobi is not available") diff --git a/pyomo/gdp/tests/test_mbigm.py b/pyomo/gdp/tests/test_mbigm.py index 9e82b1010f9..aaa4750681c 100644 --- a/pyomo/gdp/tests/test_mbigm.py +++ b/pyomo/gdp/tests/test_mbigm.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -1019,39 +1019,34 @@ def test_calculate_Ms_infeasible_Disjunct(self): out.getvalue().strip(), ) - # We just fixed the infeasible by to False + # We just fixed the infeasible disjunct to False self.assertFalse(m.disjunction.disjuncts[0].active) self.assertTrue(m.disjunction.disjuncts[0].indicator_var.fixed) self.assertFalse(value(m.disjunction.disjuncts[0].indicator_var)) + # We didn't actually transform the infeasible disjunct + self.assertIsNone(m.disjunction.disjuncts[0].transformation_block) + # the remaining constraints are transformed correctly. cons = mbm.get_transformed_constraints(m.disjunction.disjuncts[1].constraint[1]) self.assertEqual(len(cons), 1) assertExpressionsEqual( self, cons[0].expr, - 21 + m.x - m.y - <= 0 * m.disjunction.disjuncts[0].binary_indicator_var - + 12.0 * m.disjunction.disjuncts[2].binary_indicator_var, + 21 + m.x - m.y <= 12.0 * m.disjunction.disjuncts[2].binary_indicator_var, ) cons = mbm.get_transformed_constraints(m.disjunction.disjuncts[2].constraint[1]) self.assertEqual(len(cons), 2) - print(cons[0].expr) - print(cons[1].expr) assertExpressionsEqual( self, cons[0].expr, - 0.0 * m.disjunction_disjuncts[0].binary_indicator_var - - 12.0 * m.disjunction_disjuncts[1].binary_indicator_var - <= m.x - (m.y - 9), + -12.0 * m.disjunction_disjuncts[1].binary_indicator_var <= m.x - (m.y - 9), ) assertExpressionsEqual( self, cons[1].expr, - m.x - (m.y - 9) - <= 0.0 * m.disjunction_disjuncts[0].binary_indicator_var - - 12.0 * m.disjunction_disjuncts[1].binary_indicator_var, + m.x - (m.y - 9) <= -12.0 * m.disjunction_disjuncts[1].binary_indicator_var, ) @unittest.skipUnless( diff --git a/pyomo/gdp/tests/test_partition_disjuncts.py b/pyomo/gdp/tests/test_partition_disjuncts.py index dc5ae9f70ce..5ef40f7aad8 100644 --- a/pyomo/gdp/tests/test_partition_disjuncts.py +++ b/pyomo/gdp/tests/test_partition_disjuncts.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_reclassify.py b/pyomo/gdp/tests/test_reclassify.py index 223c28c5c7a..daf01cbd000 100644 --- a/pyomo/gdp/tests/test_reclassify.py +++ b/pyomo/gdp/tests/test_reclassify.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_transform_current_disjunctive_state.py b/pyomo/gdp/tests/test_transform_current_disjunctive_state.py index 54d80c910e5..2d8270dec0e 100644 --- a/pyomo/gdp/tests/test_transform_current_disjunctive_state.py +++ b/pyomo/gdp/tests/test_transform_current_disjunctive_state.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/gdp/tests/test_util.py b/pyomo/gdp/tests/test_util.py index fd555fc2f59..3b8405cc710 100644 --- a/pyomo/gdp/tests/test_util.py +++ b/pyomo/gdp/tests/test_util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,7 +13,7 @@ from pyomo.core import ConcreteModel, Var, Expression, Block, RangeSet, Any import pyomo.core.expr as EXPR -from pyomo.core.base.expression import _ExpressionData +from pyomo.core.base.expression import NamedExpressionData from pyomo.gdp.util import ( clone_without_expression_components, is_child_of, @@ -40,7 +40,7 @@ def test_clone_without_expression_components(self): test = clone_without_expression_components(base, {}) self.assertIsNot(base, test) self.assertEqual(base(), test()) - self.assertIsInstance(base, _ExpressionData) + self.assertIsInstance(base, NamedExpressionData) self.assertIsInstance(test, EXPR.SumExpression) test = clone_without_expression_components(base, {id(m.x): m.y}) self.assertEqual(3**2 + 3 - 1, test()) @@ -51,7 +51,7 @@ def test_clone_without_expression_components(self): self.assertEqual(base(), test()) self.assertIsInstance(base, EXPR.SumExpression) self.assertIsInstance(test, EXPR.SumExpression) - self.assertIsInstance(base.arg(0), _ExpressionData) + self.assertIsInstance(base.arg(0), NamedExpressionData) self.assertIsInstance(test.arg(0), EXPR.SumExpression) test = clone_without_expression_components(base, {id(m.x): m.y}) self.assertEqual(3**2 + 3 - 1 + 3, test()) diff --git a/pyomo/gdp/transformed_disjunct.py b/pyomo/gdp/transformed_disjunct.py index 6cf60abf414..84904d43b80 100644 --- a/pyomo/gdp/transformed_disjunct.py +++ b/pyomo/gdp/transformed_disjunct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,11 +10,11 @@ # ___________________________________________________________________________ from pyomo.common.autoslots import AutoSlots -from pyomo.core.base.block import _BlockData, IndexedBlock +from pyomo.core.base.block import BlockData, IndexedBlock from pyomo.core.base.global_set import UnindexedComponent_index, UnindexedComponent_set -class _TransformedDisjunctData(_BlockData): +class _TransformedDisjunctData(BlockData): __slots__ = ('_src_disjunct',) __autoslot_mappers__ = {'_src_disjunct': AutoSlots.weakref_mapper} @@ -23,7 +23,7 @@ def src_disjunct(self): return None if self._src_disjunct is None else self._src_disjunct() def __init__(self, component): - _BlockData.__init__(self, component) + BlockData.__init__(self, component) # pointer to the Disjunct whose transformation block this is. self._src_disjunct = None diff --git a/pyomo/gdp/util.py b/pyomo/gdp/util.py index 57eef29eded..3bfc0833a73 100644 --- a/pyomo/gdp/util.py +++ b/pyomo/gdp/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,10 +10,9 @@ # ___________________________________________________________________________ from pyomo.gdp import GDP_Error, Disjunction -from pyomo.gdp.disjunct import _DisjunctData, Disjunct +from pyomo.gdp.disjunct import DisjunctData, Disjunct import pyomo.core.expr as EXPR -from pyomo.core.base.component import _ComponentBase from pyomo.core import ( Block, Suffix, @@ -22,7 +21,7 @@ LogicalConstraint, value, ) -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.common.collections import ComponentMap, ComponentSet, OrderedSet from pyomo.opt import TerminationCondition, SolverStatus @@ -144,13 +143,13 @@ def parent(self, u): Arg: u : A node in the tree """ + if u in self._parent: + return self._parent[u] if u not in self._vertices: raise ValueError( "'%s' is not a vertex in the GDP tree. Cannot " "retrieve its parent." % u ) - if u in self._parent: - return self._parent[u] else: return None @@ -330,7 +329,7 @@ def get_gdp_tree(targets, instance, knownBlocks=None): "Target '%s' is not a component on instance " "'%s'!" % (t.name, instance.name) ) - if t.ctype is Block or isinstance(t, _BlockData): + if t.ctype is Block or isinstance(t, BlockData): _blocks = t.values() if t.is_indexed() else (t,) for block in _blocks: if not block.active: @@ -387,7 +386,7 @@ def is_child_of(parent, child, knownBlocks=None): if knownBlocks is None: knownBlocks = {} tmp = set() - node = child if isinstance(child, (Block, _BlockData)) else child.parent_block() + node = child if isinstance(child, (Block, BlockData)) else child.parent_block() while True: known = knownBlocks.get(node) if known: @@ -452,7 +451,7 @@ def get_src_disjunct(transBlock): Parameters ---------- - transBlock: _BlockData which is in the relaxedDisjuncts IndexedBlock + transBlock: BlockData which is in the relaxedDisjuncts IndexedBlock on a transformation block. """ if ( @@ -477,22 +476,23 @@ def get_src_constraint(transformedConstraint): a transformation block """ transBlock = transformedConstraint.parent_block() + src_constraints = transBlock.private_data('pyomo.gdp').src_constraint # This should be our block, so if it's not, the user messed up and gave # us the wrong thing. If they happen to also have a _constraintMap then # the world is really against us. - if not hasattr(transBlock, "_constraintMap"): + if transformedConstraint not in src_constraints: raise GDP_Error( "Constraint '%s' is not a transformed constraint" % transformedConstraint.name ) # if something goes wrong here, it's a bug in the mappings. - return transBlock._constraintMap['srcConstraints'][transformedConstraint] + return src_constraints[transformedConstraint] def _find_parent_disjunct(constraint): # traverse up until we find the disjunct this constraint lives on parent_disjunct = constraint.parent_block() - while not isinstance(parent_disjunct, _DisjunctData): + while not isinstance(parent_disjunct, DisjunctData): if parent_disjunct is None: raise GDP_Error( "Constraint '%s' is not on a disjunct and so was not " @@ -524,24 +524,28 @@ def get_transformed_constraints(srcConstraint): Parameters ---------- - srcConstraint: ScalarConstraint or _ConstraintData, which must be in + srcConstraint: ScalarConstraint or ConstraintData, which must be in the subtree of a transformed Disjunct """ if srcConstraint.is_indexed(): raise GDP_Error( "Argument to get_transformed_constraint should be " - "a ScalarConstraint or _ConstraintData. (If you " + "a ScalarConstraint or ConstraintData. (If you " "want the container for all transformed constraints " "from an IndexedDisjunction, this is the parent " "component of a transformed constraint originating " - "from any of its _ComponentDatas.)" + "from any of its ComponentDatas.)" ) transBlock = _get_constraint_transBlock(srcConstraint) - try: - return transBlock._constraintMap['transformedConstraints'][srcConstraint] - except: - logger.error("Constraint '%s' has not been transformed." % srcConstraint.name) - raise + transformed_constraints = transBlock.private_data( + 'pyomo.gdp' + ).transformed_constraints + if srcConstraint in transformed_constraints: + return transformed_constraints[srcConstraint] + else: + raise GDP_Error( + "Constraint '%s' has not been transformed." % srcConstraint.name + ) def _warn_for_active_disjunct(innerdisjunct, outerdisjunct): diff --git a/pyomo/kernel/__init__.py b/pyomo/kernel/__init__.py index 289fe83f0e4..c98e301e9d5 100644 --- a/pyomo/kernel/__init__.py +++ b/pyomo/kernel/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,7 +15,6 @@ # Load solver functionality # import pyomo.environ -import pyomo.opt from pyomo.opt import SolverFactory, SolverStatus, TerminationCondition # @@ -90,7 +89,6 @@ from pyomo.core.expr.calculus.derivatives import differentiate from pyomo.core.expr.taylor_series import taylor_series_expansion -import pyomo.core.kernel from pyomo.kernel.util import generate_names, preorder_traversal, pprint from pyomo.core.kernel.variable import ( variable, diff --git a/pyomo/kernel/util.py b/pyomo/kernel/util.py index bdfd0939537..48f8cc5a638 100644 --- a/pyomo/kernel/util.py +++ b/pyomo/kernel/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/__init__.py b/pyomo/mpec/__init__.py index a98ab94dc87..ebb15e9a225 100644 --- a/pyomo/mpec/__init__.py +++ b/pyomo/mpec/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/complementarity.py b/pyomo/mpec/complementarity.py index 79f76a9fc34..be1779f37e0 100644 --- a/pyomo/mpec/complementarity.py +++ b/pyomo/mpec/complementarity.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,7 +19,7 @@ from pyomo.core import Constraint, Var, Block, Set from pyomo.core.base.component import ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.core.base.disable_methods import disable_methods from pyomo.core.base.initializer import ( Initializer, @@ -43,7 +43,7 @@ def complements(a, b): return ComplementarityTuple(a, b) -class _ComplementarityData(_BlockData): +class ComplementarityData(BlockData): def _canonical_expression(self, e): # Note: as the complimentarity component maintains references to # the original expression (e), it is NOT safe or valid to bypass @@ -179,9 +179,14 @@ def set_value(self, cc): ) +class _ComplementarityData(metaclass=RenamedClass): + __renamed__new_class__ = ComplementarityData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register("Complementarity conditions.") class Complementarity(Block): - _ComponentDataClass = _ComplementarityData + _ComponentDataClass = ComplementarityData def __new__(cls, *args, **kwds): if cls != Complementarity: @@ -298,9 +303,9 @@ def _conditional_block_printer(ostream, idx, data): ) -class ScalarComplementarity(_ComplementarityData, Complementarity): +class ScalarComplementarity(ComplementarityData, Complementarity): def __init__(self, *args, **kwds): - _ComplementarityData.__init__(self, self) + ComplementarityData.__init__(self, self) Complementarity.__init__(self, *args, **kwds) self._data[None] = self self._index = UnindexedComponent_index diff --git a/pyomo/mpec/plugins/__init__.py b/pyomo/mpec/plugins/__init__.py index 1ff8c316e9b..b7b7b539888 100644 --- a/pyomo/mpec/plugins/__init__.py +++ b/pyomo/mpec/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,10 +11,12 @@ def load(): - import pyomo.mpec.plugins.mpec1 - import pyomo.mpec.plugins.mpec2 - import pyomo.mpec.plugins.mpec3 - import pyomo.mpec.plugins.mpec4 - import pyomo.mpec.plugins.solver1 - import pyomo.mpec.plugins.solver2 - import pyomo.mpec.plugins.pathampl + from pyomo.mpec.plugins import ( + mpec1, + mpec2, + mpec3, + mpec4, + solver1, + solver2, + pathampl, + ) diff --git a/pyomo/mpec/plugins/mpec1.py b/pyomo/mpec/plugins/mpec1.py index 5935569d370..2e83cf6fc24 100644 --- a/pyomo/mpec/plugins/mpec1.py +++ b/pyomo/mpec/plugins/mpec1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/plugins/mpec2.py b/pyomo/mpec/plugins/mpec2.py index 89d6c0814b2..174f3358f97 100644 --- a/pyomo/mpec/plugins/mpec2.py +++ b/pyomo/mpec/plugins/mpec2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/plugins/mpec3.py b/pyomo/mpec/plugins/mpec3.py index 1b7eb58b021..afa885a43fe 100644 --- a/pyomo/mpec/plugins/mpec3.py +++ b/pyomo/mpec/plugins/mpec3.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/plugins/mpec4.py b/pyomo/mpec/plugins/mpec4.py index fa3e37b16fe..582dc6fc510 100644 --- a/pyomo/mpec/plugins/mpec4.py +++ b/pyomo/mpec/plugins/mpec4.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/plugins/pathampl.py b/pyomo/mpec/plugins/pathampl.py index 23b1b393ef3..9c7999dd961 100644 --- a/pyomo/mpec/plugins/pathampl.py +++ b/pyomo/mpec/plugins/pathampl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/plugins/solver1.py b/pyomo/mpec/plugins/solver1.py index 02659844f1c..a118a8c3833 100644 --- a/pyomo/mpec/plugins/solver1.py +++ b/pyomo/mpec/plugins/solver1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/plugins/solver2.py b/pyomo/mpec/plugins/solver2.py index 5f5b6922e6f..3b2e083125e 100644 --- a/pyomo/mpec/plugins/solver2.py +++ b/pyomo/mpec/plugins/solver2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/tests/__init__.py b/pyomo/mpec/tests/__init__.py index a2a2c61779a..1eda732fbe1 100644 --- a/pyomo/mpec/tests/__init__.py +++ b/pyomo/mpec/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/tests/test_complementarity.py b/pyomo/mpec/tests/test_complementarity.py index 545104364cf..7beb5780bbc 100644 --- a/pyomo/mpec/tests/test_complementarity.py +++ b/pyomo/mpec/tests/test_complementarity.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/tests/test_minlp.py b/pyomo/mpec/tests/test_minlp.py index 965906f4235..bdce86a007c 100644 --- a/pyomo/mpec/tests/test_minlp.py +++ b/pyomo/mpec/tests/test_minlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/tests/test_nlp.py b/pyomo/mpec/tests/test_nlp.py index a87d4ad2b09..e5f7c69192d 100644 --- a/pyomo/mpec/tests/test_nlp.py +++ b/pyomo/mpec/tests/test_nlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/mpec/tests/test_path.py b/pyomo/mpec/tests/test_path.py index 0501d19d2ac..6456dc22cdc 100644 --- a/pyomo/mpec/tests/test_path.py +++ b/pyomo/mpec/tests/test_path.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/neos/__init__.py b/pyomo/neos/__init__.py index 7d18535e753..9efbb906290 100644 --- a/pyomo/neos/__init__.py +++ b/pyomo/neos/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,26 +14,27 @@ # Static documentation for NEOS solvers doc = { - 'bonmin': 'Heuristic MINLP solver', + 'baron': 'Deterministic global MINLP solver', + 'bonmin': 'Deterministic local MINLP solver', 'cbc': 'MILP solver', 'conopt': 'Feasible path NLP solver', 'couenne': 'Deterministic global MINLP solver', 'cplex': 'MILP solver', - 'filmint': 'Heuristic MINLP solver', + 'filmint': 'Deterministic local MINLP solver', 'filter': 'SQP NLP solver', 'ipopt': 'Interior point NLP solver', - 'knitro': 'Convex MINLP solver', + 'knitro': 'Convex NLP/MINLP solver', 'l-bfgs-b': 'Bound-constrained NLP solver', 'lancelot': 'Augmented Lagrangian NLP solver', + 'lgo': 'Lipschitz-Continuous Global Optimizer', 'loqo': 'Interior point NLP solver', - 'minlp': 'Heuristic MINLP solver', - 'minos': 'SLC NLP solver', + 'minlp': 'Deterministic local MINLP solver', + 'minos': 'NLP solver', 'minto': 'MILP solver', 'mosek': 'Interior point NLP solver', - 'octeract': 'Deterministic global MINLP solver', + # 'octeract': 'Deterministic global MINLP solver', 'ooqp': 'Convex QP solver', 'path': 'Nonlinear MCP solver', - 'snopt': 'SQP NLP solver', 'raposa': 'A Global Solver for Polynomial Programming Problems', - 'lgo': 'Lipschitz-Continuous Global Optimizer', + 'snopt': 'SQP NLP solver', } diff --git a/pyomo/neos/kestrel.py b/pyomo/neos/kestrel.py index 8959a81bd0f..774ce771589 100644 --- a/pyomo/neos/kestrel.py +++ b/pyomo/neos/kestrel.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -207,20 +207,24 @@ def getJobAndPassword(self): password = m.groups()[0] return (jobNumber, password) + def getAvailableSolvers(self): + """Return a list of all NEOS solvers that this interface supports""" + allKestrelSolvers = self.neos.listSolversInCategory("kestrel") + _ampl = ':AMPL' + return sorted(s[: -len(_ampl)] for s in allKestrelSolvers if s.endswith(_ampl)) + def getSolverName(self): """ Read in the kestrel_options to pick out the solver name. + The tricky parts: - we don't want to be case sensitive, but NEOS is. - we need to read in options variable + + - we don't want to be case sensitive, but NEOS is. + - we need to read in options variable + """ # Get a list of available kestrel solvers from NEOS - allKestrelSolvers = self.neos.listSolversInCategory("kestrel") - kestrelAmplSolvers = [] - for s in allKestrelSolvers: - i = s.find(':AMPL') - if i > 0: - kestrelAmplSolvers.append(s[0:i]) + kestrelAmplSolvers = self.getAvailableSolvers() self.options = None # Read kestrel_options to get solver name if "kestrel_options" in os.environ: diff --git a/pyomo/neos/plugins/NEOS.py b/pyomo/neos/plugins/NEOS.py index 84bc51645c0..08d2e214c08 100644 --- a/pyomo/neos/plugins/NEOS.py +++ b/pyomo/neos/plugins/NEOS.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -34,7 +34,7 @@ def __init__(self, **kwds): def create_command_line(self, executable, problem_files): """ - Create the local *.sol and *.log files, which will be + Create the local ``*.sol`` and ``*.log`` files, which will be populated by NEOS. """ if self._log_file is None: diff --git a/pyomo/neos/plugins/__init__.py b/pyomo/neos/plugins/__init__.py index 75105e87088..943ae429a9c 100644 --- a/pyomo/neos/plugins/__init__.py +++ b/pyomo/neos/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,5 +11,4 @@ def load(): - import pyomo.neos.plugins.NEOS - import pyomo.neos.plugins.kestrel_plugin + from pyomo.neos.plugins import NEOS, kestrel_plugin diff --git a/pyomo/neos/plugins/kestrel_plugin.py b/pyomo/neos/plugins/kestrel_plugin.py index fecb98e0084..26364a9d367 100644 --- a/pyomo/neos/plugins/kestrel_plugin.py +++ b/pyomo/neos/plugins/kestrel_plugin.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/neos/tests/__init__.py b/pyomo/neos/tests/__init__.py index 83603e3d8ba..da76ce7993e 100644 --- a/pyomo/neos/tests/__init__.py +++ b/pyomo/neos/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/neos/tests/model_min_lp.py b/pyomo/neos/tests/model_min_lp.py index eacf0451c94..3cf443228c5 100644 --- a/pyomo/neos/tests/model_min_lp.py +++ b/pyomo/neos/tests/model_min_lp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/neos/tests/test_neos.py b/pyomo/neos/tests/test_neos.py index a4c4e9e6367..3ebc6ebb8f3 100644 --- a/pyomo/neos/tests/test_neos.py +++ b/pyomo/neos/tests/test_neos.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -98,8 +98,19 @@ def test_connection_failed(self): finally: pyomo.neos.kestrel.NEOS.host = orig_host + def test_check_all_ampl_solvers(self): + kestrel = kestrelAMPL() + solvers = kestrel.getAvailableSolvers() + for solver in solvers: + name = solver.lower().replace('-', '') + if not hasattr(RunAllNEOSSolvers, 'test_' + name): + self.fail(f"RunAllNEOSSolvers missing test for '{solver}'") + class RunAllNEOSSolvers(object): + def test_baron(self): + self._run('baron') + def test_bonmin(self): self._run('bonmin') @@ -149,8 +160,14 @@ def test_minto(self): def test_mosek(self): self._run('mosek') - def test_octeract(self): - self._run('octeract') + # [16 Jul 24]: Octeract is erroring. We will disable the interface + # (and testing) until we have time to resolve #3321 + # [20 Sep 24]: and appears to have been removed from NEOS + # [24 Apr 25]: it appears to be there but causes timeouts + # [29 Apr 25]: JK, it has been removed again + # def test_octeract(self): + # pass + # self._run('octeract') def test_ooqp(self): if self.sense == pyo.maximize: @@ -161,10 +178,9 @@ def test_ooqp(self): else: self._run('ooqp') - # The simple tests aren't complementarity - # problems - # def test_path(self): - # self._run('path') + def test_path(self): + # The simple tests aren't complementarity problems + self.skipTest("The simple NEOS test is not a complementarity problem") def test_snopt(self): self._run('snopt') diff --git a/pyomo/network/__init__.py b/pyomo/network/__init__.py index 6ccfb64f79c..5962ca7ea7d 100644 --- a/pyomo/network/__init__.py +++ b/pyomo/network/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/network/arc.py b/pyomo/network/arc.py index 42b7c6ea075..5bea3f75704 100644 --- a/pyomo/network/arc.py +++ b/pyomo/network/arc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -52,7 +52,7 @@ def _iterable_to_dict(vals, directed, name): return vals -class _ArcData(ActiveComponentData): +class ArcData(ActiveComponentData): """ This class defines the data for a single Arc @@ -246,6 +246,11 @@ def _validate_ports(self, source, destination, ports): ) +class _ArcData(metaclass=RenamedClass): + __renamed__new_class__ = ArcData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register("Component used for connecting two Ports.") class Arc(ActiveIndexedComponent): """ @@ -267,7 +272,7 @@ class Arc(ActiveIndexedComponent): or a two-member iterable of ports """ - _ComponentDataClass = _ArcData + _ComponentDataClass = ArcData def __new__(cls, *args, **kwds): if cls != Arc: @@ -373,9 +378,9 @@ def _pprint(self): ) -class ScalarArc(_ArcData, Arc): +class ScalarArc(ArcData, Arc): def __init__(self, *args, **kwds): - _ArcData.__init__(self, self) + ArcData.__init__(self, self) Arc.__init__(self, *args, **kwds) self.index = UnindexedComponent_index diff --git a/pyomo/network/decomposition.py b/pyomo/network/decomposition.py index 1ffb6a710ff..ac8b461fa8c 100644 --- a/pyomo/network/decomposition.py +++ b/pyomo/network/decomposition.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/network/foqus_graph.py b/pyomo/network/foqus_graph.py index e4cf3b92014..6fc9f75d07b 100644 --- a/pyomo/network/foqus_graph.py +++ b/pyomo/network/foqus_graph.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -268,7 +268,7 @@ def scc_collect(self, G, excludeEdges=None): """ This is an algorithm for finding strongly connected components (SCCs) in a graph. It is based on Tarjan. 1972 Depth-First Search and Linear - Graph Algorithms, SIAM J. Comput. v1 no. 2 1972 + Graph Algorithms, SIAM J. Computing 1(2) 1972 Returns ------- @@ -358,9 +358,9 @@ def scc_calculation_order(self, sccNodes, ie, oe): done = False for i in range(len(sccNodes)): for j in range(len(sccNodes)): - for ine in ie[i]: - for oute in oe[j]: - if ine == oute: + for in_e in ie[i]: + for out_e in oe[j]: + if in_e == out_e: adj[j].append(i) adjR[i].append(j) done = True @@ -775,7 +775,7 @@ def all_cycles(self, G): """ This function finds all the cycles in a directed graph. The algorithm is based on Tarjan 1973 Enumeration of the - elementary circuits of a directed graph, SIAM J. Comput. v3 n2 1973. + elementary circuits of a directed graph, SIAM J. Computing 3(2) 1973. Returns ------- diff --git a/pyomo/network/plugins/__init__.py b/pyomo/network/plugins/__init__.py index ab3cde23daa..02eb734e790 100644 --- a/pyomo/network/plugins/__init__.py +++ b/pyomo/network/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,4 +11,4 @@ def load(): - import pyomo.network.plugins.expand_arcs + from pyomo.network.plugins import expand_arcs diff --git a/pyomo/network/plugins/expand_arcs.py b/pyomo/network/plugins/expand_arcs.py index b1f915214eb..7576142e313 100644 --- a/pyomo/network/plugins/expand_arcs.py +++ b/pyomo/network/plugins/expand_arcs.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/network/port.py b/pyomo/network/port.py index 26822d4fee9..882e0bf911b 100644 --- a/pyomo/network/port.py +++ b/pyomo/network/port.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -36,7 +36,7 @@ logger = logging.getLogger('pyomo.network') -class _PortData(ComponentData): +class PortData(ComponentData): """ This class defines the data for a single Port @@ -285,6 +285,11 @@ def get_split_fraction(self, arc): return res +class _PortData(metaclass=RenamedClass): + __renamed__new_class__ = PortData + __renamed__version__ = '6.7.2' + + @ModelComponentFactory.register( "A bundle of variables that can be connected to other ports." ) @@ -339,7 +344,7 @@ def __init__(self, *args, **kwd): # IndexedComponent that support implicit definition def _getitem_when_not_present(self, idx): """Returns the default component data value.""" - tmp = self._data[idx] = _PortData(component=self) + tmp = self._data[idx] = PortData(component=self) tmp._index = idx return tmp @@ -357,7 +362,7 @@ def construct(self, data=None): for _set in self._anonymous_sets: _set.construct() - # Construct _PortData objects for all index values + # Construct PortData objects for all index values if self.is_indexed(): self._initialize_members(self._index_set) else: @@ -763,9 +768,9 @@ def _create_evar(member, name, eblock, index_set): return evar -class ScalarPort(Port, _PortData): +class ScalarPort(Port, PortData): def __init__(self, *args, **kwd): - _PortData.__init__(self, component=self) + PortData.__init__(self, component=self) Port.__init__(self, *args, **kwd) self._index = UnindexedComponent_index diff --git a/pyomo/network/tests/__init__.py b/pyomo/network/tests/__init__.py index 173fdc4e727..bb450b11aea 100644 --- a/pyomo/network/tests/__init__.py +++ b/pyomo/network/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/network/tests/test_arc.py b/pyomo/network/tests/test_arc.py index 8356bcce9d8..58844af73fa 100644 --- a/pyomo/network/tests/test_arc.py +++ b/pyomo/network/tests/test_arc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/network/tests/test_decomposition.py b/pyomo/network/tests/test_decomposition.py index 2db310217d0..1f483c6de9a 100644 --- a/pyomo/network/tests/test_decomposition.py +++ b/pyomo/network/tests/test_decomposition.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/network/tests/test_port.py b/pyomo/network/tests/test_port.py index a417a832015..6020b32f386 100644 --- a/pyomo/network/tests/test_port.py +++ b/pyomo/network/tests/test_port.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/network/util.py b/pyomo/network/util.py index 4865218aca8..a51ea77a574 100644 --- a/pyomo/network/util.py +++ b/pyomo/network/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/__init__.py b/pyomo/opt/__init__.py index c78dd0384d2..f7563d19653 100644 --- a/pyomo/opt/__init__.py +++ b/pyomo/opt/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.opt.base.opt_config -import pyomo.opt.solver from pyomo.opt.base import ( check_available_solvers, @@ -38,9 +36,6 @@ container, problem, solution, - ScalarData, - ScalarType, - default_print_options, ListContainer, MapContainer, UndefinedData, @@ -66,3 +61,12 @@ SolverManagerFactory, AsynchronousSolverManager, ) + +from pyomo.common.deprecation import relocated_module_attribute + +for _attr in ('ScalarData', 'ScalarType', 'default_print_options'): + relocated_module_attribute( + _attr, 'pyomo.opt.results.container.' + _attr, version='6.0' + ) +del _attr +del relocated_module_attribute diff --git a/pyomo/opt/base/__init__.py b/pyomo/opt/base/__init__.py index 8d11114dd09..ba9032bd7aa 100644 --- a/pyomo/opt/base/__init__.py +++ b/pyomo/opt/base/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.opt.base.opt_config from pyomo.opt.base.error import ConverterError from pyomo.opt.base.convert import convert_problem diff --git a/pyomo/opt/base/convert.py b/pyomo/opt/base/convert.py index 28ad6727d3e..3f0d2899f9e 100644 --- a/pyomo/opt/base/convert.py +++ b/pyomo/opt/base/convert.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/base/error.py b/pyomo/opt/base/error.py index b03fafd7037..305915df433 100644 --- a/pyomo/opt/base/error.py +++ b/pyomo/opt/base/error.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/base/formats.py b/pyomo/opt/base/formats.py index 6e9d3958f48..088e959be57 100644 --- a/pyomo/opt/base/formats.py +++ b/pyomo/opt/base/formats.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/base/opt_config.py b/pyomo/opt/base/opt_config.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/opt/base/opt_config.py +++ b/pyomo/opt/base/opt_config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/base/problem.py b/pyomo/opt/base/problem.py index 804a97e2e4c..7b1b9e8f9f6 100644 --- a/pyomo/opt/base/problem.py +++ b/pyomo/opt/base/problem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/base/results.py b/pyomo/opt/base/results.py index ea295a66315..8e11d205032 100644 --- a/pyomo/opt/base/results.py +++ b/pyomo/opt/base/results.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/base/solvers.py b/pyomo/opt/base/solvers.py index f1f9d653a8a..52eee73c319 100644 --- a/pyomo/opt/base/solvers.py +++ b/pyomo/opt/base/solvers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,7 +23,7 @@ from pyomo.opt.base.formats import ResultsFormat import pyomo.opt.base.results -logger = logging.getLogger('pyomo.opt') +logger = logging.getLogger("pyomo.opt") # The version string is first searched for trunk/Trunk, and if @@ -38,16 +38,16 @@ def _extract_version(x, length=4): Attempts to extract solver version information from a string. """ assert (1 <= length) and (length <= 4) - m = re.search('[t,T]runk', x) + m = re.search("[t,T]runk", x) if m is not None: # Since most version checks are comparing if the current # version is greater/less than some other version, it makes # since that a solver advertising trunk should always be greater # than a version check, hence returning a tuple of infinities - return tuple(float('inf') for i in range(length)) - m = re.search(r'[0-9]+(\.[0-9]+){1,3}', x) + return tuple(float("inf") for i in range(length)) + m = re.search(r"[0-9]+(\.[0-9]+){1,3}", x) if not m is None: - version = tuple(int(i) for i in m.group(0).split('.')[:length]) + version = tuple(int(i) for i in m.group(0).split(".")[:length]) while len(version) < length: version += (0,) return version @@ -96,15 +96,15 @@ def warm_start_capable(self): def solve(self, *args, **kwds): """Perform optimization and return an SolverResults object.""" - self._solver_error('solve') + self._solver_error("solve") def reset(self): """Reset the state of an optimizer""" - self._solver_error('reset') + self._solver_error("reset") def set_options(self, istr): """Set the options in the optimizer from a string.""" - self._solver_error('set_options') + self._solver_error("set_options") def __bool__(self): return self.available() @@ -131,29 +131,38 @@ def _solver_error(self, method_name): class SolverFactoryClass(Factory): + def __call__(self, _name=None, **kwds): + logging.debug( + f"{self.__class__} (solvers.SolverFactoryClass) __call__: _name='{_name}', kwds={kwds}" + ) if _name is None: return self _name = str(_name) - if ':' in _name: - _name, subsolver = _name.split(':', 1) - kwds['solver'] = subsolver - elif 'solver' in kwds: - subsolver = kwds['solver'] + if ":" in _name: + _name, subsolver = _name.split(":", 1) + kwds["solver"] = subsolver + elif "solver" in kwds: + subsolver = kwds["solver"] else: subsolver = None opt = None try: if _name in self._cls: + logger.debug(f"'name('{_name}') in self._cls.keys()") opt = self._cls[_name](**kwds) else: - mode = kwds.get('solver_io', 'nl') + mode = kwds.get("solver_io", "nl") + logger.debug(f"mode = {mode}") if mode is None: - mode = 'nl' - _implicit_solvers = {'nl': 'asl'} + mode = "nl" + logger.debug(f"mode = {mode}") + _implicit_solvers = {"nl": "asl"} if "executable" not in kwds: kwds["executable"] = _name + logger.debug(f"kwds['executable'] = {kwds['executable']}") if mode in _implicit_solvers: + logger.debug(f"{mode} in _implicit_solvers") if _implicit_solvers[mode] not in self._cls: raise RuntimeError( " The solver plugin was not registered.\n" @@ -161,7 +170,7 @@ def __call__(self, _name=None, **kwds): ) opt = self._cls[_implicit_solvers[mode]](**kwds) if opt is not None: - opt.set_options('solver=' + _name) + opt.set_options("solver=" + _name) except: err = sys.exc_info() logger.warning( @@ -171,16 +180,16 @@ def __call__(self, _name=None, **kwds): opt = None if opt is not None and _name != "py" and subsolver is not None: # py just creates instance of its subsolver, no need for this option - opt.set_options('solver=' + subsolver) + opt.set_options("solver=" + subsolver) if opt is None: opt = UnknownSolver(type=_name, **kwds) opt.name = _name return opt -LegacySolverFactory = SolverFactoryClass('solver type') +LegacySolverFactory = SolverFactoryClass("solver type") -SolverFactory = SolverFactoryClass('solver type') +SolverFactory = SolverFactoryClass("solver type") SolverFactory._cls = LegacySolverFactory._cls SolverFactory._doc = LegacySolverFactory._doc @@ -212,7 +221,7 @@ def check_available_solvers(*args): if not opt.available(exception_flag=False): continue # not available - if hasattr(opt, 'executable') and opt.executable() is None: + if hasattr(opt, "executable") and opt.executable() is None: continue # not available if not opt.license_is_valid(): @@ -255,67 +264,67 @@ def __exit__(self, t, v, traceback): # @property def tee(self): - _raise_ephemeral_error('tee') + _raise_ephemeral_error("tee") @tee.setter def tee(self, val): - _raise_ephemeral_error('tee') + _raise_ephemeral_error("tee") @property def suffixes(self): - _raise_ephemeral_error('suffixes') + _raise_ephemeral_error("suffixes") @suffixes.setter def suffixes(self, val): - _raise_ephemeral_error('suffixes') + _raise_ephemeral_error("suffixes") @property def keepfiles(self): - _raise_ephemeral_error('keepfiles') + _raise_ephemeral_error("keepfiles") @keepfiles.setter def keepfiles(self, val): - _raise_ephemeral_error('keepfiles') + _raise_ephemeral_error("keepfiles") @property def soln_file(self): - _raise_ephemeral_error('soln_file') + _raise_ephemeral_error("soln_file") @soln_file.setter def soln_file(self, val): - _raise_ephemeral_error('soln_file') + _raise_ephemeral_error("soln_file") @property def log_file(self): - _raise_ephemeral_error('log_file') + _raise_ephemeral_error("log_file") @log_file.setter def log_file(self, val): - _raise_ephemeral_error('log_file') + _raise_ephemeral_error("log_file") @property def symbolic_solver_labels(self): - _raise_ephemeral_error('symbolic_solver_labels') + _raise_ephemeral_error("symbolic_solver_labels") @symbolic_solver_labels.setter def symbolic_solver_labels(self, val): - _raise_ephemeral_error('symbolic_solver_labels') + _raise_ephemeral_error("symbolic_solver_labels") @property def warm_start_solve(self): - _raise_ephemeral_error('warm_start_solve', keyword=" (warmstart)") + _raise_ephemeral_error("warm_start_solve", keyword=" (warmstart)") @warm_start_solve.setter def warm_start_solve(self, val): - _raise_ephemeral_error('warm_start_solve', keyword=" (warmstart)") + _raise_ephemeral_error("warm_start_solve", keyword=" (warmstart)") @property def warm_start_file_name(self): - _raise_ephemeral_error('warm_start_file_name', keyword=" (warmstart_file)") + _raise_ephemeral_error("warm_start_file_name", keyword=" (warmstart_file)") @warm_start_file_name.setter def warm_start_file_name(self, val): - _raise_ephemeral_error('warm_start_file_name', keyword=" (warmstart_file)") + _raise_ephemeral_error("warm_start_file_name", keyword=" (warmstart_file)") def __init__(self, **kwds): """Constructor""" @@ -352,9 +361,9 @@ def __init__(self, **kwds): # presolve # self.options = Bunch() - if 'options' in kwds and not kwds['options'] is None: - for key in kwds['options']: - setattr(self.options, key, kwds['options'][key]) + if "options" in kwds and not kwds["options"] is None: + for key in kwds["options"]: + setattr(self.options, key, kwds["options"][key]) # the symbol map is an attribute of the solver plugin only # because it is generated in presolve and used to tag results @@ -411,7 +420,7 @@ def _options_string_to_dict(istr): istr = eval(istr) tokens = shlex.split(istr) for token in tokens: - index = token.find('=') + index = token.find("=") if index == -1: raise ValueError( "Solver options must have the form option=value: '%s'" % istr @@ -470,8 +479,8 @@ def set_results_format(self, format): Set the current results format (if it's valid for the current problem format). """ - if (self._problem_format in self._valid_results_formats) and ( - format in self._valid_results_formats[self._problem_format] + if (self._problem_format in self._valid_result_formats) and ( + format in self._valid_result_formats[self._problem_format] ): self._results_format = format else: @@ -536,15 +545,15 @@ def solve(self, *args, **kwds): # If the inputs are models, then validate that they have been # constructed! Collect suffix names to try and import from solution. # - from pyomo.core.base.block import _BlockData + from pyomo.core.base.block import BlockData import pyomo.core.base.suffix from pyomo.core.kernel.block import IBlock import pyomo.core.kernel.suffix _model = None for arg in args: - if isinstance(arg, (_BlockData, IBlock)): - if isinstance(arg, _BlockData): + if isinstance(arg, (BlockData, IBlock)): + if isinstance(arg, BlockData): if not arg.is_constructed(): raise RuntimeError( "Attempting to solve model=%s with unconstructed " @@ -553,7 +562,7 @@ def solve(self, *args, **kwds): _model = arg # import suffixes must be on the top-level model - if isinstance(arg, _BlockData): + if isinstance(arg, BlockData): model_suffixes = list( name for ( @@ -571,7 +580,7 @@ def solve(self, *args, **kwds): ) if len(model_suffixes) > 0: - kwds_suffixes = kwds.setdefault('suffixes', []) + kwds_suffixes = kwds.setdefault("suffixes", []) for name in model_suffixes: if name not in kwds_suffixes: kwds_suffixes.append(name) @@ -587,9 +596,9 @@ def solve(self, *args, **kwds): self.options = Bunch() self.options.update(orig_options) - self.options.update(kwds.pop('options', {})) + self.options.update(kwds.pop("options", {})) self.options.update( - self._options_string_to_dict(kwds.pop('options_string', '')) + self._options_string_to_dict(kwds.pop("options_string", "")) ) try: # we're good to go. @@ -608,9 +617,9 @@ def solve(self, *args, **kwds): self._initialize_callbacks(_model) _status = self._apply_solver() - if hasattr(self, '_transformation_data'): + if hasattr(self, "_transformation_data"): del self._transformation_data - if not hasattr(_status, 'rc'): + if not hasattr(_status, "rc"): logger.warning( "Solver (%s) did not return a solver status code.\n" "This is indicative of an internal solver plugin error.\n" @@ -623,7 +632,7 @@ def solve(self, *args, **kwds): ) if self._tee: logger.error("See the solver log above for diagnostic information.") - elif hasattr(_status, 'log') and _status.log: + elif hasattr(_status, "log") and _status.log: logger.error("Solver log:\n" + str(_status.log)) raise ApplicationError("Solver (%s) did not exit normally" % self.name) solve_completion_time = time.time() @@ -775,11 +784,11 @@ def _get_options_string(self, options=None): ans = [] for key in options: val = options[key] - if isinstance(val, str) and ' ' in val: - ans.append("%s=\"%s\"" % (str(key), str(val))) + if isinstance(val, str) and " " in val: + ans.append('%s="%s"' % (str(key), str(val))) else: ans.append("%s=%s" % (str(key), str(val))) - return ' '.join(ans) + return " ".join(ans) def set_options(self, istr): if isinstance(istr, str): diff --git a/pyomo/opt/parallel/__init__.py b/pyomo/opt/parallel/__init__.py index dbfdf2302ca..3dd545a999a 100644 --- a/pyomo/opt/parallel/__init__.py +++ b/pyomo/opt/parallel/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,5 +15,4 @@ SolverManagerFactory, AsynchronousSolverManager, ) -import pyomo.opt.parallel.manager -import pyomo.opt.parallel.local +from pyomo.opt.parallel import manager, local diff --git a/pyomo/opt/parallel/async_solver.py b/pyomo/opt/parallel/async_solver.py index 74e222e2241..c66dfb5e36a 100644 --- a/pyomo/opt/parallel/async_solver.py +++ b/pyomo/opt/parallel/async_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/parallel/local.py b/pyomo/opt/parallel/local.py index e130ea0407f..9771aa3b882 100644 --- a/pyomo/opt/parallel/local.py +++ b/pyomo/opt/parallel/local.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/parallel/manager.py b/pyomo/opt/parallel/manager.py index 203c348e119..add77029e4a 100644 --- a/pyomo/opt/parallel/manager.py +++ b/pyomo/opt/parallel/manager.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/plugins/__init__.py b/pyomo/opt/plugins/__init__.py index 5ea2490b534..3a71ddacc87 100644 --- a/pyomo/opt/plugins/__init__.py +++ b/pyomo/opt/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,6 +11,4 @@ def load(): - import pyomo.opt.plugins.driver - import pyomo.opt.plugins.res - import pyomo.opt.plugins.sol + from pyomo.opt.plugins import driver, res, sol diff --git a/pyomo/opt/plugins/driver.py b/pyomo/opt/plugins/driver.py index c7c7103835c..8cc0adb0f74 100644 --- a/pyomo/opt/plugins/driver.py +++ b/pyomo/opt/plugins/driver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -50,7 +50,7 @@ def setup_test_parser(parser): def test_exec(options): import pyomo.solvers.tests.testcases - pyomo.solvers.tests.testcases.run_test_scenarios(options) + pyomo.solvers.tests.testcases.run_scenarios(options) # diff --git a/pyomo/opt/plugins/res.py b/pyomo/opt/plugins/res.py index 31971ee7d25..c0c6e747db7 100644 --- a/pyomo/opt/plugins/res.py +++ b/pyomo/opt/plugins/res.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -22,7 +22,7 @@ @results.ReaderFactory.register(str(ResultsFormat.yaml)) class ResultsReader_yaml(results.AbstractResultsReader): """ - Class that reads in a *.yml file and generates a + Class that reads in a ``*.yml`` file and generates a SolverResults object. """ @@ -43,7 +43,7 @@ def __call__(self, filename, res=None, soln=None, suffixes=[]): @results.ReaderFactory.register(str(ResultsFormat.json)) class ResultsReader_json(results.AbstractResultsReader): """ - Class that reads in a *.jsn file and generates a + Class that reads in a ``*.jsn`` file and generates a SolverResults object. """ @@ -51,9 +51,7 @@ def __init__(self): results.AbstractResultsReader.__init__(self, ResultsFormat.json) def __call__(self, filename, res=None, soln=None, suffixes=[]): - """ - Parse a *.results file - """ + """Parse a ``*.results`` file""" if res is None: res = SolverResults() # diff --git a/pyomo/opt/plugins/sol.py b/pyomo/opt/plugins/sol.py index 10da469f186..be5e7f294dd 100644 --- a/pyomo/opt/plugins/sol.py +++ b/pyomo/opt/plugins/sol.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,7 +23,7 @@ @results.ReaderFactory.register(str(ResultsFormat.sol)) class ResultsReader_sol(results.AbstractResultsReader): """ - Class that reads in a *.sol results file and generates a + Class that reads in a ``*.sol`` results file and generates a SolverResults object. """ @@ -34,7 +34,7 @@ def __init__(self, name=None): def __call__(self, filename, res=None, soln=None, suffixes=[]): """ - Parse a *.sol file + Parse a ``*.sol`` file """ try: with open(filename, "r") as f: diff --git a/pyomo/opt/problem/__init__.py b/pyomo/opt/problem/__init__.py index 8199553247d..eb7f6e97078 100644 --- a/pyomo/opt/problem/__init__.py +++ b/pyomo/opt/problem/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/problem/ampl.py b/pyomo/opt/problem/ampl.py index ed107cace60..a7bfcade2d3 100644 --- a/pyomo/opt/problem/ampl.py +++ b/pyomo/opt/problem/ampl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/results/__init__.py b/pyomo/opt/results/__init__.py index 64a1b42ac86..d5a6c335683 100644 --- a/pyomo/opt/results/__init__.py +++ b/pyomo/opt/results/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,17 +10,13 @@ # ___________________________________________________________________________ from pyomo.opt.results.container import ( - ScalarData, - ScalarType, - default_print_options, - strict, ListContainer, MapContainer, UndefinedData, undefined, ignore, ) -import pyomo.opt.results.problem + from pyomo.opt.results.solver import ( SolverStatus, TerminationCondition, @@ -30,3 +26,12 @@ from pyomo.opt.results.problem import ProblemSense from pyomo.opt.results.solution import SolutionStatus, Solution from pyomo.opt.results.results_ import SolverResults + +from pyomo.common.deprecation import relocated_module_attribute + +for _attr in ('ScalarData', 'ScalarType', 'default_print_options', 'strict'): + relocated_module_attribute( + _attr, 'pyomo.opt.results.container.' + _attr, version='6.8.1' + ) +del _attr +del relocated_module_attribute diff --git a/pyomo/opt/results/container.py b/pyomo/opt/results/container.py index 4bbaf44edf7..541f56d3692 100644 --- a/pyomo/opt/results/container.py +++ b/pyomo/opt/results/container.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/results/problem.py b/pyomo/opt/results/problem.py index 98f749f3aeb..b53ea67da24 100644 --- a/pyomo/opt/results/problem.py +++ b/pyomo/opt/results/problem.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,22 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import enum +from pyomo.common.enums import ExtendedEnumType, IntEnum, ObjectiveSense from pyomo.opt.results.container import MapContainer -class ProblemSense(str, enum.Enum): - unknown = 'unknown' - minimize = 'minimize' - maximize = 'maximize' +class ProblemSense(IntEnum, metaclass=ExtendedEnumType): + __base_enum__ = ObjectiveSense + + unknown = 0 - # Overloading __str__ is needed to match the behavior of the old - # pyutilib.enum class (removed June 2020). There are spots in the - # code base that expect the string representation for items in the - # enum to not include the class name. New uses of enum shouldn't - # need to do this. def __str__(self): - return self.value + return self.name class ProblemInformation(MapContainer): diff --git a/pyomo/opt/results/results_.py b/pyomo/opt/results/results_.py index 0a045550517..b542deb9f79 100644 --- a/pyomo/opt/results/results_.py +++ b/pyomo/opt/results/results_.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/results/solution.py b/pyomo/opt/results/solution.py index 6dcd348ea72..bf6c419ea20 100644 --- a/pyomo/opt/results/solution.py +++ b/pyomo/opt/results/solution.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -89,7 +89,7 @@ def pprint(self, ostream, option, from_list=False, prefix="", repn=None): # # the following is specialized logic for handling variable and # constraint maps - which are dictionaries of dictionaries, with - # at a minimum an "id" element per sub-directionary. + # at a minimum an "id" element per sub-dictionary. # first = True for key in self._order: diff --git a/pyomo/opt/results/solver.py b/pyomo/opt/results/solver.py index d4cf46c38a9..996a07b8282 100644 --- a/pyomo/opt/results/solver.py +++ b/pyomo/opt/results/solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -139,22 +139,33 @@ def to_solver_status(tc): def check_optimal_termination(results): """ This function returns True if the termination condition for the solver - is 'optimal', 'locallyOptimal', or 'globallyOptimal', and the status is 'ok' + is 'optimal'. This is backwards and forwards compatible. Parameters ---------- - results : Pyomo results object returned from solver.solve + results : Pyomo Results object returned from solver.solve Returns ------- `bool` """ - if results.solver.status == SolverStatus.ok and ( - results.solver.termination_condition == TerminationCondition.optimal - or results.solver.termination_condition == TerminationCondition.locallyOptimal - or results.solver.termination_condition == TerminationCondition.globallyOptimal - ): - return True + from pyomo.contrib.solver.common.results import ( + SolutionStatus as FutureSolutionStatus, + ) + + if hasattr(results, 'solution_status'): + if results.solution_status == FutureSolutionStatus.optimal: + return True + else: + if results.solver.status == SolverStatus.ok and ( + results.solver.termination_condition + in ( + TerminationCondition.optimal, + TerminationCondition.locallyOptimal, + TerminationCondition.globallyOptimal, + ) + ): + return True return False @@ -162,19 +173,26 @@ def assert_optimal_termination(results): """ This function checks if the termination condition for the solver is 'optimal', 'locallyOptimal', or 'globallyOptimal', and the status is 'ok' - and it raises a RuntimeError exception if this is not true. + and it raises a RuntimeError exception if this is not true. This is backwards + and forwards compatible. Parameters ---------- - results : Pyomo results object returned from solver.solve + results : Pyomo Results object returned from solver.solve """ if not check_optimal_termination(results): - msg = ( - 'Solver failed to return an optimal solution. ' - 'Solver status: {}, Termination condition: {}'.format( - results.solver.status, results.solver.termination_condition + if hasattr(results, 'solution_status'): + msg = ( + 'Solver failed to return an optimal solution. ' + f'Solution status: {results.solution_status}, ' + f'Termination condition: {results.termination_condition}' + ) + else: + msg = ( + 'Solver failed to return an optimal solution. ' + f'Solution status: {results.solver.status}, ' + f'Termination condition: {results.solver.termination_condition}' ) - ) raise RuntimeError(msg) diff --git a/pyomo/opt/solver/__init__.py b/pyomo/opt/solver/__init__.py index 6da73d408fa..bb43fd92de4 100644 --- a/pyomo/opt/solver/__init__.py +++ b/pyomo/opt/solver/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/solver/ilmcmd.py b/pyomo/opt/solver/ilmcmd.py index c956b2ed42f..91e440213e1 100644 --- a/pyomo/opt/solver/ilmcmd.py +++ b/pyomo/opt/solver/ilmcmd.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/solver/shellcmd.py b/pyomo/opt/solver/shellcmd.py index 94117779237..e7319513cde 100644 --- a/pyomo/opt/solver/shellcmd.py +++ b/pyomo/opt/solver/shellcmd.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -60,6 +60,7 @@ def __init__(self, **kwargs): # a solver plugin may not report execution time. self._last_solve_time = None self._define_signal_handlers = None + self._version_timeout = 2 if executable is not None: self.set_executable(name=executable, validate=validate) diff --git a/pyomo/opt/testing/__init__.py b/pyomo/opt/testing/__init__.py index 37ed419fbe3..515520123ac 100644 --- a/pyomo/opt/testing/__init__.py +++ b/pyomo/opt/testing/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/testing/pyunit.py b/pyomo/opt/testing/pyunit.py index bb96806d520..98c96ee20a5 100644 --- a/pyomo/opt/testing/pyunit.py +++ b/pyomo/opt/testing/pyunit.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/__init__.py b/pyomo/opt/tests/__init__.py index b333eb78878..199f852279f 100644 --- a/pyomo/opt/tests/__init__.py +++ b/pyomo/opt/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/base/__init__.py b/pyomo/opt/tests/base/__init__.py index cde23945b56..d82e2fc8e0f 100644 --- a/pyomo/opt/tests/base/__init__.py +++ b/pyomo/opt/tests/base/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/base/test_ampl.py b/pyomo/opt/tests/base/test_ampl.py index d37befcac57..f83f6f889a1 100644 --- a/pyomo/opt/tests/base/test_ampl.py +++ b/pyomo/opt/tests/base/test_ampl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/base/test_convert.py b/pyomo/opt/tests/base/test_convert.py index 30a8fb0d1fc..fd23ac3719e 100644 --- a/pyomo/opt/tests/base/test_convert.py +++ b/pyomo/opt/tests/base/test_convert.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/base/test_factory.py b/pyomo/opt/tests/base/test_factory.py index 441ba245c5e..25fc76bdf47 100644 --- a/pyomo/opt/tests/base/test_factory.py +++ b/pyomo/opt/tests/base/test_factory.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/base/test_sol.py b/pyomo/opt/tests/base/test_sol.py index fada795b925..5cad39b37cf 100644 --- a/pyomo/opt/tests/base/test_sol.py +++ b/pyomo/opt/tests/base/test_sol.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/base/test_soln.py b/pyomo/opt/tests/base/test_soln.py index d39baeab15f..3d45ddfec0d 100644 --- a/pyomo/opt/tests/base/test_soln.py +++ b/pyomo/opt/tests/base/test_soln.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/base/test_solver.py b/pyomo/opt/tests/base/test_solver.py index 8ffc647804d..90c7c5b1e44 100644 --- a/pyomo/opt/tests/base/test_solver.py +++ b/pyomo/opt/tests/base/test_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -109,7 +109,7 @@ def test_set_problem_format(self): def test_set_results_format(self): opt = pyomo.opt.SolverFactory("stest1") opt._valid_problem_formats = ['a'] - opt._valid_results_formats = {'a': 'b'} + opt._valid_result_formats = {'a': 'b'} self.assertEqual(opt.problem_format(), None) try: opt.set_results_format('b') diff --git a/pyomo/opt/tests/solver/__init__.py b/pyomo/opt/tests/solver/__init__.py index d27a8ab41d6..cf7cd8ddf4f 100644 --- a/pyomo/opt/tests/solver/__init__.py +++ b/pyomo/opt/tests/solver/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/opt/tests/solver/test_shellcmd.py b/pyomo/opt/tests/solver/test_shellcmd.py index b6cc264b8f7..71ebd94d0ee 100644 --- a/pyomo/opt/tests/solver/test_shellcmd.py +++ b/pyomo/opt/tests/solver/test_shellcmd.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/pysp/__init__.py b/pyomo/pysp/__init__.py deleted file mode 100644 index bb8a401e45e..00000000000 --- a/pyomo/pysp/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -import logging -import sys -from pyomo.common.deprecation import deprecation_warning, in_testing_environment - -try: - # Warn the user - deprecation_warning( - "PySP has been removed from the pyomo.pysp namespace. " - "Please import PySP directly from the pysp namespace.", - version='6.0', - ) - from pysp import * - - # Redirect all (imported) pysp modules into the pyomo.pysp namespace - for mod in list(sys.modules): - if mod.startswith('pysp.'): - sys.modules['pyomo.' + mod] = sys.modules[mod] -except ImportError: - # Only raise the exception if nose/pytest/sphinx are NOT running - # (otherwise test discovery can result in exceptions) - if not in_testing_environment(): - raise ImportError( - "No module named 'pyomo.pysp'. " - "Beginning in Pyomo 6.0, PySP is distributed as a separate " - "package. Please see https://github.com/Pyomo/pysp for " - "information on downloading and installing PySP" - ) diff --git a/pyomo/repn/__init__.py b/pyomo/repn/__init__.py index 842f4750127..026cbfb68fa 100644 --- a/pyomo/repn/__init__.py +++ b/pyomo/repn/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/ampl.py b/pyomo/repn/ampl.py new file mode 100644 index 00000000000..d626654b76c --- /dev/null +++ b/pyomo/repn/ampl.py @@ -0,0 +1,1329 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import ctypes +import math +import operator + +from collections import deque +from operator import itemgetter + +from pyomo.common.deprecation import deprecation_warning +from pyomo.common.errors import DeveloperError, InfeasibleConstraintException, MouseTrap +from pyomo.common.numeric_types import ( + native_complex_types, + native_numeric_types, + native_types, + value, +) + + +from pyomo.core.base import Expression +from pyomo.core.expr import ( + NegationExpression, + ProductExpression, + DivisionExpression, + PowExpression, + AbsExpression, + UnaryFunctionExpression, + MonomialTermExpression, + LinearExpression, + SumExpression, + EqualityExpression, + InequalityExpression, + RangedExpression, + Expr_ifExpression, + ExternalFunctionExpression, +) +from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, _EvaluationVisitor +from pyomo.repn.util import ( + BeforeChildDispatcher, + ExitNodeDispatcher, + ExprType, + InvalidNumber, + apply_node_operation, + complex_number_error, + nan, + sum_like_expression_types, +) + + +_CONSTANT = ExprType.CONSTANT +_MONOMIAL = ExprType.MONOMIAL +_GENERAL = ExprType.GENERAL + +# Feasibility tolerance for trivial (fixed) constraints +TOL = 1e-8 + + +def _create_strict_inequality_map(vars_): + vars_['strict_inequality_map'] = { + True: vars_['less_than'], + False: vars_['less_equal'], + (True, True): (vars_['less_than'], vars_['less_than']), + (True, False): (vars_['less_than'], vars_['less_equal']), + (False, True): (vars_['less_equal'], vars_['less_than']), + (False, False): (vars_['less_equal'], vars_['less_equal']), + } + + +class TextNLDebugTemplate(object): + unary = { + 'log': 'o43\t#log\n', + 'log10': 'o42\t#log10\n', + 'sin': 'o41\t#sin\n', + 'cos': 'o46\t#cos\n', + 'tan': 'o38\t#tan\n', + 'sinh': 'o40\t#sinh\n', + 'cosh': 'o45\t#cosh\n', + 'tanh': 'o37\t#tanh\n', + 'asin': 'o51\t#asin\n', + 'acos': 'o53\t#acos\n', + 'atan': 'o49\t#atan\n', + 'exp': 'o44\t#exp\n', + 'sqrt': 'o39\t#sqrt\n', + 'asinh': 'o50\t#asinh\n', + 'acosh': 'o52\t#acosh\n', + 'atanh': 'o47\t#atanh\n', + 'ceil': 'o14\t#ceil\n', + 'floor': 'o13\t#floor\n', + } + + binary_sum = 'o0\t#+\n' + product = 'o2\t#*\n' + division = 'o3\t# /\n' + pow = 'o5\t#^\n' + abs = 'o15\t# abs\n' + negation = 'o16\t#-\n' + nary_sum = 'o54\t# sumlist\n%d\t# (n)\n' + exprif = 'o35\t# if\n' + and_expr = 'o21\t# and\n' + less_than = 'o22\t# lt\n' + less_equal = 'o23\t# le\n' + equality = 'o24\t# eq\n' + external_fcn = 'f%d %d%s\n' + # NOTE: to support scaling and substitutions, we do NOT include the + # 'v' or the EOL here: + var = '%s' + const = 'n%s\n' + string = 'h%d:%s\n' + monomial = product + const + var.replace('%', '%%') + multiplier = product + const + + _create_strict_inequality_map(vars()) + + +nl_operators = { + 0: (2, operator.add), + 2: (2, operator.mul), + 3: (2, operator.truediv), + 5: (2, operator.pow), + 15: (1, operator.abs), + 16: (1, operator.neg), + 54: (None, lambda *x: sum(x)), + 35: (3, lambda a, b, c: b if a else c), + 21: (2, operator.and_), + 22: (2, operator.lt), + 23: (2, operator.le), + 24: (2, operator.eq), + 43: (1, math.log), + 42: (1, math.log10), + 41: (1, math.sin), + 46: (1, math.cos), + 38: (1, math.tan), + 40: (1, math.sinh), + 45: (1, math.cosh), + 37: (1, math.tanh), + 51: (1, math.asin), + 53: (1, math.acos), + 49: (1, math.atan), + 44: (1, math.exp), + 39: (1, math.sqrt), + 50: (1, math.asinh), + 52: (1, math.acosh), + 47: (1, math.atanh), + 14: (1, math.ceil), + 13: (1, math.floor), +} + + +def _strip_template_comments(vars_, base_): + vars_['unary'] = { + k: v[: v.find('\t#')] + '\n' if v[-1] == '\n' else '' + for k, v in base_.unary.items() + } + for k, v in base_.__dict__.items(): + if type(v) is str and '\t#' in v: + v_lines = v.split('\n') + for i, l in enumerate(v_lines): + comment_start = l.find('\t#') + if comment_start >= 0: + v_lines[i] = l[:comment_start] + vars_[k] = '\n'.join(v_lines) + + +def _inv2str(val): + return f"{val._str() if hasattr(val, '_str') else val}" + + +# The "standard" text mode template is the debugging template with the +# comments removed +class TextNLTemplate(TextNLDebugTemplate): + _strip_template_comments(vars(), TextNLDebugTemplate) + _create_strict_inequality_map(vars()) + + +class NLFragment(object): + """This is a mock "component" for the nl portion of a named Expression. + + It is used internally in the writer when requesting symbolic solver + labels so that we can generate meaningful names for the nonlinear + portion of an Expression component. + + """ + + __slots__ = ('_repn', '_node') + + def __init__(self, repn, node): + self._repn = repn + self._node = node + + @property + def name(self): + return 'nl(' + self._node.name + ')' + + +class AMPLRepn(object): + """The "compiled" representation of an expression in AMPL NL format. + + This stores a compiled form of an expression in the AMPL "NL" + format. The data structure contains 6 fields: + + Attributes + ---------- + mult : float + + A constant multiplier applied to this expression. The + :py:class`AMPLRepn` returned by the :py:class`AMPLRepnVisitor` + should always have `mult` == 1. + + const : float + + The constant portion of this expression + + linear : Dict[int, float] or None + + Mapping of `id(VarData)` to linear coefficient + + nonlinear : Tuple[str, List[int]] or List[Tuple[str, List[int]]] or None + + The general nonlinear portion of the compiled expression as a + tuple of two parts: + + - the nl template string: this is the NL string with + placeholders (``%s``) for all the variables that appear in + the expression. + + - an iterable if the :class:`VarData` IDs that correspond to the + placeholders in the nl template string + + This is `None` if there is no general nonlinear part of the + expression. Note that this can be a list of tuple fragments + within AMPLRepnVisitor, but that list is concatenated to a + single tuple when exiting the `AMPLRepnVisitor`. + + named_exprs : Set[int] + + A set of IDs point to named expressions (:py:class:`Expression`) + objects appearing in this expression. + + nl : Tuple[str, Iterable[int]] + + This holds the complete compiled representation of this + expression (including multiplier, constant, linear terms, and + nonlinear fragment) using the same format as the `nonlinear` + attribute. This field (if not None) should be considered + authoritative, as there are NL fragments that are not + representable by {mult, const, linear, nonlinear} (e.g., string + arguments). + + """ + + __slots__ = ('nl', 'mult', 'const', 'linear', 'nonlinear', 'named_exprs') + + template = TextNLTemplate + + def __init__(self, const, linear, nonlinear): + self.nl = None + self.mult = 1 + self.const = const + self.linear = linear + if nonlinear is None: + self.nonlinear = self.named_exprs = None + else: + nl, nl_args, self.named_exprs = nonlinear + self.nonlinear = nl, nl_args + + def __str__(self): + return ( + f'AMPLRepn(mult={self.mult}, const={self.const}, ' + f'linear={self.linear}, nonlinear={self.nonlinear}, ' + f'nl={self.nl}, named_exprs={self.named_exprs})' + ) + + def __repr__(self): + return str(self) + + def __eq__(self, other): + return isinstance(other.__class__, AMPLRepn) and ( + self.nl == other.nl + and self.mult == other.mult + and self.const == other.const + and self.linear == other.linear + and self.nonlinear == other.nonlinear + and self.named_exprs == other.named_exprs + ) + + def __hash__(self): + # Approximation of the Python default object hash + # (4 LSB are rolled to the MSB to reduce hash collisions) + return id(self) // 16 + ( + (id(self) & 15) << 8 * ctypes.sizeof(ctypes.c_void_p) - 4 + ) + + def duplicate(self): + ans = self.__class__.__new__(self.__class__) + ans.nl = self.nl + ans.mult = self.mult + ans.const = self.const + ans.linear = None if self.linear is None else dict(self.linear) + ans.nonlinear = self.nonlinear + ans.named_exprs = None if self.named_exprs is None else set(self.named_exprs) + return ans + + def compile_repn(self, prefix='', args=None, named_exprs=None): + template = self.template + if self.mult != 1: + if self.mult == -1: + prefix += template.negation + else: + prefix += template.multiplier % self.mult + self.mult = 1 + if self.named_exprs is not None: + if named_exprs is None: + named_exprs = set(self.named_exprs) + else: + named_exprs.update(self.named_exprs) + if self.nl is not None: + # This handles both named subexpressions and embedded + # non-numeric (e.g., string) arguments. + nl, nl_args = self.nl + if prefix: + nl = prefix + nl + if args is not None: + assert args is not nl_args + args.extend(nl_args) + else: + args = list(nl_args) + if nl_args: + # For string arguments, nl_args is an empty tuple and + # self.named_exprs is None. For named subexpressions, + # we are guaranteed that named_exprs is NOT None. We + # need to ensure that the named subexpression that we + # are returning is added to the named_exprs set. + named_exprs.update(nl_args) + return nl, args, named_exprs + + if args is None: + args = [] + if self.linear: + nterms = -len(args) + _v_template = template.var + _m_template = template.monomial + # Because we are compiling this expression (into a NL + # expression), we will go ahead and filter the 0*x terms + # from the expression. Note that the args are accumulated + # by side-effect, which prevents iterating over the linear + # terms twice. + nl_sum = ''.join( + args.append(v) or (_v_template if c == 1 else _m_template % c) + for v, c in self.linear.items() + if c + ) + nterms += len(args) + else: + nterms = 0 + nl_sum = '' + if self.nonlinear: + if self.nonlinear.__class__ is list: + nterms += len(self.nonlinear) + nl_sum += ''.join(map(itemgetter(0), self.nonlinear)) + deque(map(args.extend, map(itemgetter(1), self.nonlinear)), maxlen=0) + else: + nterms += 1 + nl_sum += self.nonlinear[0] + args.extend(self.nonlinear[1]) + if self.const: + nterms += 1 + nl_sum += template.const % self.const + + if nterms > 2: + return (prefix + (template.nary_sum % nterms) + nl_sum, args, named_exprs) + elif nterms == 2: + return prefix + template.binary_sum + nl_sum, args, named_exprs + elif nterms == 1: + return prefix + nl_sum, args, named_exprs + else: # nterms == 0 + return prefix + (template.const % 0), args, named_exprs + + def compile_nonlinear_fragment(self): + if not self.nonlinear: + self.nonlinear = None + return + args = [] + nterms = len(self.nonlinear) + nl_sum = ''.join(map(itemgetter(0), self.nonlinear)) + deque(map(args.extend, map(itemgetter(1), self.nonlinear)), maxlen=0) + + if nterms > 2: + self.nonlinear = (self.template.nary_sum % nterms) + nl_sum, args + elif nterms == 2: + self.nonlinear = self.template.binary_sum + nl_sum, args + else: # nterms == 1: + self.nonlinear = nl_sum, args + + def append(self, other): + """Append a child result from acceptChildResult + + Notes + ----- + This method assumes that the operator was "+". It is implemented + so that we can directly use an AMPLRepn() as a data object in + the expression walker (thereby avoiding the function call for a + custom callback) + + """ + # Note that self.mult will always be 1 (we only call append() + # within a sum, so there is no opportunity for self.mult to + # change). Omitting the assertion for efficiency. + # assert self.mult == 1 + _type = other[0] + if _type is _MONOMIAL: + _, v, c = other + if v in self.linear: + self.linear[v] += c + else: + self.linear[v] = c + elif _type is _GENERAL: + _, other = other + if other.nl is not None and other.nl[1]: + if other.linear: + # This is a named expression with both a linear and + # nonlinear component. We want to merge it with + # this AMPLRepn, preserving the named expression for + # only the nonlinear component (merging the linear + # component with this AMPLRepn). + pass + else: + # This is a nonlinear-only named expression, + # possibly with a multiplier that is not 1. Compile + # it and append it (this both resolves the + # multiplier, and marks the named expression as + # having been used) + other = other.compile_repn('', None, self.named_exprs) + nl, nl_args, self.named_exprs = other + self.nonlinear.append((nl, nl_args)) + return + if other.named_exprs is not None: + if self.named_exprs is None: + self.named_exprs = set(other.named_exprs) + else: + self.named_exprs.update(other.named_exprs) + if other.mult != 1: + mult = other.mult + self.const += mult * other.const + if other.linear: + linear = self.linear + for v, c in other.linear.items(): + if v in linear: + linear[v] += c * mult + else: + linear[v] = c * mult + if other.nonlinear: + if other.nonlinear.__class__ is list: + other.compile_nonlinear_fragment() + if mult == -1: + prefix = self.template.negation + else: + prefix = self.template.multiplier % mult + self.nonlinear.append( + (prefix + other.nonlinear[0], other.nonlinear[1]) + ) + else: + self.const += other.const + if other.linear: + linear = self.linear + for v, c in other.linear.items(): + if v in linear: + linear[v] += c + else: + linear[v] = c + if other.nonlinear: + if other.nonlinear.__class__ is list: + self.nonlinear.extend(other.nonlinear) + else: + self.nonlinear.append(other.nonlinear) + elif _type is _CONSTANT: + self.const += other[1] + + def to_expr(self, var_map): + if self.nl is not None or self.nonlinear is not None: + # TODO: support converting general nonlinear expressions + # back to Pyomo expressions. This will require an AMPL + # parser. + raise MouseTrap("Cannot convert nonlinear AMPLRepn to Pyomo Expression") + if self.linear: + # Explicitly generate the LinearExpression. At time of + # writing, this is about 40% faster than standard operator + # overloading for O(1000) element sums + ans = LinearExpression( + [coef * var_map[vid] for vid, coef in self.linear.items()] + ) + ans += self.const + else: + ans = self.const + return ans * self.mult + + +class DebugAMPLRepn(AMPLRepn): + """An `AMPLRepn` that uses the "debug" (annotated) NL format + + This is identical to the :py:class:`AMPLRepn` class, except it is + built using the `TextNLDebugTemplate` formatting template. This + format includes descriptions of the operators and variable / + expression names in the NL text. + + """ + + __slots__ = () + template = TextNLDebugTemplate + + +def handle_negation_node(visitor, node, arg1): + if arg1[0] is _MONOMIAL: + return (_MONOMIAL, arg1[1], -1 * arg1[2]) + elif arg1[0] is _GENERAL: + arg1[1].mult *= -1 + return arg1 + elif arg1[0] is _CONSTANT: + return (_CONSTANT, -1 * arg1[1]) + else: + raise RuntimeError("%s: %s" % (type(arg1[0]), arg1)) + + +def handle_product_node(visitor, node, arg1, arg2): + if arg2[0] is _CONSTANT: + arg2, arg1 = arg1, arg2 + if arg1[0] is _CONSTANT: + mult = arg1[1] + if not mult: + # simplify multiplication by 0 (if arg2 is zero, the + # simplification happens when we evaluate the constant + # below). Note that this is not IEEE-754 compliant, and + # will map 0*inf and 0*nan to 0 (and not to nan). We are + # including this for backwards compatibility with the NLv1 + # writer, but arguably we should deprecate/remove this + # "feature" in the future. + if arg2[0] is _CONSTANT: + _prod = mult * arg2[1] + if _prod: + deprecation_warning( + f"Encountered {mult}*{_inv2str(arg2[1])} in expression tree. " + "Mapping the NaN result to 0 for compatibility " + "with the nl_v1 writer. In the future, this NaN " + "will be preserved/emitted to comply with IEEE-754.", + version='6.4.3', + ) + _prod = 0 + return (_CONSTANT, _prod) + return arg1 + if mult == 1: + return arg2 + elif arg2[0] is _MONOMIAL: + if mult != mult: + # This catches mult (i.e., arg1) == nan + return arg1 + return (_MONOMIAL, arg2[1], mult * arg2[2]) + elif arg2[0] is _GENERAL: + if mult != mult: + # This catches mult (i.e., arg1) == nan + return arg1 + arg2[1].mult *= mult + return arg2 + elif arg2[0] is _CONSTANT: + if not arg2[1]: + # Simplify multiplication by 0; see note above about + # IEEE-754 incompatibility. + _prod = mult * arg2[1] + if _prod: + deprecation_warning( + f"Encountered {_inv2str(mult)}*{arg2[1]} in expression tree. " + "Mapping the NaN result to 0 for compatibility " + "with the nl_v1 writer. In the future, this NaN " + "will be preserved/emitted to comply with IEEE-754.", + version='6.4.3', + ) + _prod = 0 + return (_CONSTANT, _prod) + return (_CONSTANT, mult * arg2[1]) + nonlin = visitor.node_result_to_amplrepn(arg1).compile_repn( + visitor.template.product + ) + nonlin = visitor.node_result_to_amplrepn(arg2).compile_repn(*nonlin) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_division_node(visitor, node, arg1, arg2): + if arg2[0] is _CONSTANT: + div = arg2[1] + if div == 1: + return arg1 + if arg1[0] is _MONOMIAL: + tmp = apply_node_operation(node, (arg1[2], div)) + if tmp != tmp: + # This catches if the coefficient division results in nan + return _CONSTANT, tmp + return (_MONOMIAL, arg1[1], tmp) + elif arg1[0] is _GENERAL: + tmp = apply_node_operation(node, (arg1[1].mult, div)) + if tmp != tmp: + # This catches if the multiplier division results in nan + return _CONSTANT, tmp + arg1[1].mult = tmp + return arg1 + elif arg1[0] is _CONSTANT: + return _CONSTANT, apply_node_operation(node, (arg1[1], div)) + elif arg1[0] is _CONSTANT and not arg1[1]: + return _CONSTANT, 0 + nonlin = visitor.node_result_to_amplrepn(arg1).compile_repn( + visitor.template.division + ) + nonlin = visitor.node_result_to_amplrepn(arg2).compile_repn(*nonlin) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_pow_node(visitor, node, arg1, arg2): + if arg2[0] is _CONSTANT: + if arg1[0] is _CONSTANT: + ans = apply_node_operation(node, (arg1[1], arg2[1])) + if ans.__class__ in native_complex_types: + ans = complex_number_error(ans, visitor, node) + return _CONSTANT, ans + elif not arg2[1]: + return _CONSTANT, 1 + elif arg2[1] == 1: + return arg1 + nonlin = visitor.node_result_to_amplrepn(arg1).compile_repn(visitor.template.pow) + nonlin = visitor.node_result_to_amplrepn(arg2).compile_repn(*nonlin) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_abs_node(visitor, node, arg1): + if arg1[0] is _CONSTANT: + return (_CONSTANT, abs(arg1[1])) + nonlin = visitor.node_result_to_amplrepn(arg1).compile_repn(visitor.template.abs) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_unary_node(visitor, node, arg1): + if arg1[0] is _CONSTANT: + return _CONSTANT, apply_node_operation(node, (arg1[1],)) + nonlin = visitor.node_result_to_amplrepn(arg1).compile_repn( + visitor.template.unary[node.name] + ) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_exprif_node(visitor, node, arg1, arg2, arg3): + if arg1[0] is _CONSTANT: + if arg1[1]: + return arg2 + else: + return arg3 + nonlin = visitor.node_result_to_amplrepn(arg1).compile_repn(visitor.template.exprif) + nonlin = visitor.node_result_to_amplrepn(arg2).compile_repn(*nonlin) + nonlin = visitor.node_result_to_amplrepn(arg3).compile_repn(*nonlin) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_equality_node(visitor, node, arg1, arg2): + if arg1[0] is _CONSTANT and arg2[0] is _CONSTANT: + return (_CONSTANT, arg1[1] == arg2[1]) + nonlin = visitor.node_result_to_amplrepn(arg1).compile_repn( + visitor.template.equality + ) + nonlin = visitor.node_result_to_amplrepn(arg2).compile_repn(*nonlin) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_inequality_node(visitor, node, arg1, arg2): + if arg1[0] is _CONSTANT and arg2[0] is _CONSTANT: + return (_CONSTANT, node._apply_operation((arg1[1], arg2[1]))) + nonlin = visitor.node_result_to_amplrepn(arg1).compile_repn( + visitor.template.strict_inequality_map[node.strict] + ) + nonlin = visitor.node_result_to_amplrepn(arg2).compile_repn(*nonlin) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_ranged_inequality_node(visitor, node, arg1, arg2, arg3): + if arg1[0] is _CONSTANT and arg2[0] is _CONSTANT and arg3[0] is _CONSTANT: + return (_CONSTANT, node._apply_operation((arg1[1], arg2[1], arg3[1]))) + op = visitor.template.strict_inequality_map[node.strict] + nl, args, named = visitor.node_result_to_amplrepn(arg1).compile_repn( + visitor.template.and_expr + op[0] + ) + nl2, args2, named = visitor.node_result_to_amplrepn(arg2).compile_repn( + '', None, named + ) + nl += nl2 + op[1] + nl2 + args.extend(args2) + args.extend(args2) + nonlin = visitor.node_result_to_amplrepn(arg3).compile_repn(nl, args, named) + return (_GENERAL, visitor.Result(0, None, nonlin)) + + +def handle_named_expression_node(visitor, node, arg1): + _id = id(node) + # Note that while named subexpressions ('defined variables' in the + # ASL NL file vernacular) look like variables, they are not allowed + # to appear in the 'linear' portion of a constraint / objective + # definition. We will return this as a "var" template, but + # wrapped in the nonlinear portion of the expression tree. + repn = visitor.node_result_to_amplrepn(arg1) + + # A local copy of the expression source list. This will be updated + # later if the same Expression node is encountered in another + # expression tree. + # + # This is a 3-tuple [con_id, obj_id, substitute_expression]. If the + # expression is used by more than 1 constraint / objective, then the + # id is set to 0. If it is not used by any, then it is None. + # substitute_expression is a bool indicating if this named + # subexpression tree should be directly substituted into any + # expression tree that references this node (i.e., do NOT emit the V + # line). + expression_source = [None, None, False] + # Record this common expression + visitor.subexpression_cache[_id] = ( + # 0: the "component" that generated this expression ID + node, + # 1: the common subexpression (to be written out) + repn, + # 2: the source usage information for this subexpression: + # [(con_id, obj_id, substitute); see above] + expression_source, + ) + + # As we will eventually need the compiled form of any nonlinear + # expression, we will go ahead and compile it here. We do not + # do the same for the linear component as we will only need the + # linear component compiled to a dict if we are emitting the + # original (linear + nonlinear) V line (which will not happen if + # the V line is part of a larger linear operator). + if repn.nonlinear.__class__ is list: + repn.compile_nonlinear_fragment() + + if not visitor.use_named_exprs: + return _GENERAL, repn.duplicate() + + mult, repn.mult = repn.mult, 1 + if repn.named_exprs is None: + repn.named_exprs = set() + + # When converting this shared subexpression to a (nonlinear) + # node, we want to just reference this subexpression: + repn.nl = (visitor.template.var, (_id,)) + + if repn.nonlinear: + if repn.linear: + # If this expression has both linear and nonlinear + # components, we will follow the ASL convention and break + # the named subexpression into two named subexpressions: one + # that is only the nonlinear component and one that has the + # const/linear component (and references the first). This + # will allow us to propagate linear coefficients up from + # named subexpressions when appropriate. + sub_node = NLFragment(repn, node) + sub_id = id(sub_node) + sub_repn = visitor.Result(0, None, None) + sub_repn.nonlinear = repn.nonlinear + sub_repn.nl = (visitor.template.var, (sub_id,)) + sub_repn.named_exprs = set(repn.named_exprs) + + repn.named_exprs.add(sub_id) + repn.nonlinear = sub_repn.nl + + # See above for the meaning of this source information + nl_info = list(expression_source) + visitor.subexpression_cache[sub_id] = (sub_node, sub_repn, nl_info) + # It is important that the NL subexpression comes before the + # main named expression: re-insert the original named + # expression (so that the nonlinear sub_node comes first + # when iterating over subexpression_cache) + visitor.subexpression_cache[_id] = visitor.subexpression_cache.pop(_id) + else: + nl_info = expression_source + else: + repn.nonlinear = None + if repn.linear: + if ( + not repn.const + and len(repn.linear) == 1 + and next(iter(repn.linear.values())) == 1 + ): + # This Expression holds only a variable (multiplied by + # 1). Do not emit this as a named variable and instead + # just inject the variable where this expression is + # used. + repn.nl = None + expression_source[2] = True + else: + # This Expression holds only a constant. Do not emit this + # as a named variable and instead just inject the constant + # where this expression is used. + repn.nl = None + expression_source[2] = True + + if mult != 1: + repn.const *= mult + if repn.linear: + _lin = repn.linear + for v in repn.linear: + _lin[v] *= mult + if repn.nonlinear: + if mult == -1: + prefix = visitor.template.negation + else: + prefix = visitor.template.multiplier % mult + repn.nonlinear = prefix + repn.nonlinear[0], repn.nonlinear[1] + + if expression_source[2]: + if repn.linear: + assert len(repn.linear) == 1 and not repn.const + return (_MONOMIAL,) + next(iter(repn.linear.items())) + else: + return (_CONSTANT, repn.const) + + return (_GENERAL, repn.duplicate()) + + +def handle_external_function_node(visitor, node, *args): + func = node._fcn._function + # There is a special case for external functions: these are the only + # expressions that can accept string arguments. As we currently pass + # these as 'precompiled' GENERAL AMPLRepns, the normal trap for + # constant subexpressions will miss string arguments. We will catch + # that case here by looking for NL fragments with no variable + # references. Note that the NL fragment is NOT the raw string + # argument that we want to evaluate: the raw string is in the + # `const` field. + if all( + arg[0] is _CONSTANT or (arg[0] is _GENERAL and arg[1].nl and not arg[1].nl[1]) + for arg in args + ): + arg_list = [arg[1] if arg[0] is _CONSTANT else arg[1].const for arg in args] + return _CONSTANT, apply_node_operation(node, arg_list) + if func in visitor.external_functions: + if node._fcn._library != visitor.external_functions[func][1]._library: + raise RuntimeError( + "The same external function name (%s) is associated " + "with two different libraries (%s through %s, and %s " + "through %s). The ASL solver will fail to link " + "correctly." + % ( + func, + visitor.external_functions[func]._library, + visitor.external_functions[func]._library.name, + node._fcn._library, + node._fcn.name, + ) + ) + else: + visitor.external_functions[func] = (len(visitor.external_functions), node._fcn) + comment = f'\t#{node.local_name}' if visitor.symbolic_solver_labels else '' + nl = visitor.template.external_fcn % ( + visitor.external_functions[func][0], + len(args), + comment, + ) + arg_ids = [] + named_exprs = set() + for arg in args: + _id = id(arg) + arg_ids.append(_id) + visitor.subexpression_cache[_id] = ( + arg, + visitor.Result( + 0, + None, + visitor.node_result_to_amplrepn(arg).compile_repn( + named_exprs=named_exprs + ), + ), + (None, None, True), + ) + if not named_exprs: + named_exprs = None + return ( + _GENERAL, + visitor.Result(0, None, (nl + '%s' * len(arg_ids), arg_ids, named_exprs)), + ) + + +_operator_handles = ExitNodeDispatcher( + { + NegationExpression: handle_negation_node, + ProductExpression: handle_product_node, + DivisionExpression: handle_division_node, + PowExpression: handle_pow_node, + AbsExpression: handle_abs_node, + UnaryFunctionExpression: handle_unary_node, + Expr_ifExpression: handle_exprif_node, + EqualityExpression: handle_equality_node, + InequalityExpression: handle_inequality_node, + RangedExpression: handle_ranged_inequality_node, + Expression: handle_named_expression_node, + ExternalFunctionExpression: handle_external_function_node, + # These are handled explicitly in beforeChild(): + # LinearExpression: handle_linear_expression, + # SumExpression: handle_sum_expression, + # + # Note: MonomialTermExpression is only hit when processing NPV + # subexpressions that raise errors (e.g., log(0) * m.x), so no + # special processing is needed [it is just a product expression] + MonomialTermExpression: handle_product_node, + } +) + + +class AMPLBeforeChildDispatcher(BeforeChildDispatcher): + __slots__ = () + + def __init__(self): + # Special linear / summation expressions + self[MonomialTermExpression] = self._before_monomial + self[LinearExpression] = self._before_linear + self[SumExpression] = self._before_general_expression + + @staticmethod + def _record_var(visitor, var): + # We always add all indices to the var_map at once so that + # we can honor deterministic ordering of unordered sets + # (because the user could have iterated over an unordered + # set when constructing an expression, thereby altering the + # order in which we would see the variables) + vm = visitor.var_map + try: + _iter = var.parent_component().values(visitor.sorter) + except AttributeError: + # Note that this only works for the AML, as kernel does not + # provide a parent_component() + _iter = (var,) + for v in _iter: + if v.fixed: + continue + vm[id(v)] = v + + @staticmethod + def _before_string(visitor, child): + visitor.encountered_string_arguments = True + ans = visitor.Result(child, None, None) + ans.nl = (visitor.template.string % (len(child), child), ()) + return False, (_GENERAL, ans) + + @staticmethod + def _before_var(visitor, child): + _id = id(child) + if _id not in visitor.var_map: + if child.fixed: + if _id not in visitor.fixed_vars: + visitor.cache_fixed_var(_id, child) + return False, (_CONSTANT, visitor.fixed_vars[_id]) + _before_child_handlers._record_var(visitor, child) + return False, (_MONOMIAL, _id, 1) + + @staticmethod + def _before_monomial(visitor, child): + # + # The following are performance optimizations for common + # situations (Monomial terms and Linear expressions) + # + arg1, arg2 = child._args_ + if arg1.__class__ not in native_types: + try: + arg1 = visitor.check_constant(visitor.evaluate(arg1), arg1) + except (ValueError, ArithmeticError): + return True, None + + # Trap multiplication by 0 and nan. + if not arg1: + if arg2.fixed: + _id = id(arg2) + if _id not in visitor.fixed_vars: + visitor.cache_fixed_var(id(arg2), arg2) + arg2 = visitor.fixed_vars[_id] + if arg2 != arg2: + deprecation_warning( + f"Encountered {arg1}*{_inv2str(arg2)} in expression tree. " + "Mapping the NaN result to 0 for compatibility " + "with the nl_v1 writer. In the future, this NaN " + "will be preserved/emitted to comply with IEEE-754.", + version='6.4.3', + ) + return False, (_CONSTANT, arg1) + + _id = id(arg2) + if _id not in visitor.var_map: + if arg2.fixed: + if _id not in visitor.fixed_vars: + visitor.cache_fixed_var(_id, arg2) + return False, (_CONSTANT, arg1 * visitor.fixed_vars[_id]) + _before_child_handlers._record_var(visitor, arg2) + return False, (_MONOMIAL, _id, arg1) + + @staticmethod + def _before_linear(visitor, child): + # Because we are going to modify the LinearExpression in this + # walker, we need to make a copy of the arg list from the original + # expression tree. + var_map = visitor.var_map + const = 0 + linear = {} + for arg in child.args: + if arg.__class__ is MonomialTermExpression: + arg1, arg2 = arg._args_ + if arg1.__class__ not in native_types: + try: + arg1 = visitor.check_constant(visitor.evaluate(arg1), arg1) + except (ValueError, ArithmeticError): + return True, None + + # Trap multiplication by 0 and nan. + if not arg1: + if arg2.fixed: + arg2 = visitor.check_constant(arg2.value, arg2) + if arg2 != arg2: + deprecation_warning( + f"Encountered {arg1}*{_inv2str(arg2)} in expression " + "tree. Mapping the NaN result to 0 for compatibility " + "with the nl_v1 writer. In the future, this NaN " + "will be preserved/emitted to comply with IEEE-754.", + version='6.4.3', + ) + continue + + _id = id(arg2) + if _id not in var_map: + if arg2.fixed: + if _id not in visitor.fixed_vars: + visitor.cache_fixed_var(_id, arg2) + const += arg1 * visitor.fixed_vars[_id] + continue + _before_child_handlers._record_var(visitor, arg2) + linear[_id] = arg1 + elif _id in linear: + linear[_id] += arg1 + else: + linear[_id] = arg1 + elif arg.__class__ in native_types: + const += arg + elif arg.is_variable_type(): + _id = id(arg) + if _id not in var_map: + if arg.fixed: + if _id not in visitor.fixed_vars: + visitor.cache_fixed_var(_id, arg) + const += visitor.fixed_vars[_id] + continue + _before_child_handlers._record_var(visitor, arg) + linear[_id] = 1 + elif _id in linear: + linear[_id] += 1 + else: + linear[_id] = 1 + else: + try: + const += visitor.check_constant(visitor.evaluate(arg), arg) + except (ValueError, ArithmeticError): + return True, None + + if linear: + return False, (_GENERAL, visitor.Result(const, linear, None)) + else: + return False, (_CONSTANT, const) + + @staticmethod + def _before_named_expression(visitor, child): + _id = id(child) + if _id in visitor.subexpression_cache: + obj, repn, info = visitor.subexpression_cache[_id] + if info[2]: + if repn.linear: + return False, (_MONOMIAL, next(iter(repn.linear)), 1) + else: + return False, (_CONSTANT, repn.const) + return False, (_GENERAL, repn.duplicate()) + else: + return True, None + + +_before_child_handlers = AMPLBeforeChildDispatcher() + + +class AMPLRepnVisitor(StreamBasedExpressionVisitor): + def __init__( + self, + subexpression_cache, + external_functions, + var_map, + used_named_expressions, + symbolic_solver_labels, + use_named_exprs, + sorter, + ): + super().__init__() + self.subexpression_cache = subexpression_cache + self.external_functions = external_functions + self.active_expression_source = None + self.var_map = var_map + self.used_named_expressions = used_named_expressions + self.symbolic_solver_labels = symbolic_solver_labels + self.use_named_exprs = use_named_exprs + self.encountered_string_arguments = False + self.fixed_vars = {} + self._eval_expr_visitor = _EvaluationVisitor(True) + self.evaluate = self._eval_expr_visitor.dfs_postorder_stack + self.sorter = sorter + + if symbolic_solver_labels: + self.Result = DebugAMPLRepn + else: + self.Result = AMPLRepn + self.template = self.Result.template + + def check_constant(self, ans, obj): + if ans.__class__ not in native_numeric_types: + # None can be returned from uninitialized Var/Param objects + if ans is None: + return InvalidNumber( + None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + if ans.__class__ is InvalidNumber: + return ans + elif ans.__class__ in native_complex_types: + return complex_number_error(ans, self, obj) + else: + # It is possible to get other non-numeric types. Most + # common are bool and 1-element numpy.array(). We will + # attempt to convert the value to a float before + # proceeding. + # + # TODO: we should check bool and warn/error (while bool is + # convertible to float in Python, they have very + # different semantic meanings in Pyomo). + try: + ans = float(ans) + except: + return InvalidNumber( + ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + if ans != ans: + return InvalidNumber( + nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + return ans + + def cache_fixed_var(self, _id, child): + val = self.check_constant(child.value, child) + lb, ub = child.bounds + if (lb is not None and lb - val > TOL) or (ub is not None and ub - val < -TOL): + raise InfeasibleConstraintException( + "model contains a trivially infeasible " + f"variable '{child.name}' (fixed value " + f"{val} outside bounds [{lb}, {ub}])." + ) + self.fixed_vars[_id] = self.check_constant(child.value, child) + + def node_result_to_amplrepn(self, data): + if data[0] is _GENERAL: + return data[1] + elif data[0] is _MONOMIAL: + _, v, c = data + if c: + return self.Result(0, {v: c}, None) + else: + return self.Result(0, None, None) + elif data[0] is _CONSTANT: + return self.Result(data[1], None, None) + else: + raise DeveloperError("unknown result type") + + def initializeWalker(self, expr): + expr, src, src_idx, self.expression_scaling_factor = expr + self.active_expression_source = (src_idx, id(src)) + walk, result = self.beforeChild(None, expr, 0) + if not walk: + return False, self.finalizeResult(result) + return True, expr + + def beforeChild(self, node, child, child_idx): + return _before_child_handlers[child.__class__](self, child) + + def enterNode(self, node): + # SumExpression are potentially large nary operators. Directly + # populate the result + if node.__class__ in sum_like_expression_types: + data = self.Result(0, {}, None) + data.nonlinear = [] + return node.args, data + else: + return node.args, [] + + def exitNode(self, node, data): + if data.__class__ is self.Result: + # If the summation resulted in a constant, return the constant + if data.linear or data.nonlinear or data.nl: + return (_GENERAL, data) + else: + return (_CONSTANT, data.const) + # + # General expressions... + # + return _operator_handles[node.__class__](self, node, *data) + + def finalizeResult(self, result): + ans = self.node_result_to_amplrepn(result) + + # Multiply the expression by the scaling factor provided by the caller + ans.mult *= self.expression_scaling_factor + + # If this was a nonlinear named expression, and that expression + # has no linear portion, then we will directly use this as a + # named expression. We need to mark that the expression was + # used and return it as a simple nonlinear expression pointing + # to this named expression. In all other cases, we will return + # the processed representation (which will reference the + # nonlinear-only named subexpression - if it exists - but not + # this outer named expression). This prevents accidentally + # recharacterizing variables that only appear linearly as + # nonlinear variables. + if ans.nl is not None: + if not ans.nl[1]: + raise ValueError("Numeric expression resolved to a string constant") + # This *is* a named subexpression. If there is no linear + # component, then replace this expression with the named + # expression. The mult will be handled later. We know that + # the const is built into the nonlinear expression, because + # it cannot be changed "in place" (only through addition, + # which would have "cleared" the nl attribute) + if not ans.linear: + ans.named_exprs.update(ans.nl[1]) + ans.nonlinear = ans.nl + ans.const = 0 + else: + # This named expression has both a linear and a + # nonlinear component, and possibly a multiplier and + # constant. We will not include this named expression + # and instead will expose the components so that linear + # variables are not accidentally re-characterized as + # nonlinear. + pass + ans.nl = None + + if ans.nonlinear.__class__ is list: + ans.compile_nonlinear_fragment() + + if not ans.linear: + ans.linear = {} + if ans.mult != 1: + linear = ans.linear + mult, ans.mult = ans.mult, 1 + ans.const *= mult + if linear: + for k in linear: + linear[k] *= mult + if ans.nonlinear: + if mult == -1: + prefix = self.template.negation + else: + prefix = self.template.multiplier % mult + ans.nonlinear = prefix + ans.nonlinear[0], ans.nonlinear[1] + # + self.active_expression_source = None + return ans + + +def evaluate_ampl_nl_expression(nl, external_functions): + expr = nl.splitlines() + stack = [] + while expr: + line = expr.pop() + tokens = line.split() + # remove tokens after the first comment + for i, t in enumerate(tokens): + if t.startswith('#'): + tokens = tokens[:i] + break + if len(tokens) != 1: + # skip blank lines + if not tokens: + continue + if tokens[0][0] == 'f': + # external function + fid, nargs = tokens + fid = int(fid[1:]) + nargs = int(nargs) + fcn_id, ef = external_functions[fid] + assert fid == fcn_id + stack.append(ef.evaluate(tuple(stack.pop() for i in range(nargs)))) + continue + raise DeveloperError( + f"Unsupported line format _evaluate_constant_nl() " + f"(we expect each line to contain a single token): '{line}'" + ) + term = tokens[0] + # the "command" can be determined by the first character on the line + cmd = term[0] + # Note that we will unpack the line into the expected number of + # explicit arguments as a form of error checking + if cmd == 'n': + # numeric constant + stack.append(float(term[1:])) + elif cmd == 'o': + # operator + nargs, fcn = nl_operators[int(term[1:])] + if nargs is None: + nargs = int(stack.pop()) + stack.append(fcn(*(stack.pop() for i in range(nargs)))) + elif cmd in '1234567890': + # this is either a single int (e.g., the nargs in a nary + # sum) or a string argument. Preserve it as-is until later + # when we know which we are expecting. + stack.append(term) + elif cmd == 'h': + stack.append(term.split(':', 1)[1]) + else: + raise DeveloperError( + f"Unsupported NL operator in _evaluate_constant_nl(): '{line}'" + ) + assert len(stack) == 1 + return stack[0] diff --git a/pyomo/repn/beta/__init__.py b/pyomo/repn/beta/__init__.py index a75a75ec760..ebf48f9fe34 100644 --- a/pyomo/repn/beta/__init__.py +++ b/pyomo/repn/beta/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,4 +9,4 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.repn.beta.matrix +from pyomo.repn.beta import matrix diff --git a/pyomo/repn/beta/matrix.py b/pyomo/repn/beta/matrix.py index 916b0daf755..1c4e8ec2813 100644 --- a/pyomo/repn/beta/matrix.py +++ b/pyomo/repn/beta/matrix.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,6 +16,8 @@ from pyomo.common.log import is_debug_set from pyomo.common.numeric_types import value +from pyomo.common.modeling import NOTSET +from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import is_fixed, ZeroConstant from pyomo.core.base.set_types import Any from pyomo.core.base import SortComponents, Var @@ -24,7 +26,7 @@ Constraint, IndexedConstraint, ScalarConstraint, - _ConstraintData, + ConstraintData, ) from pyomo.core.expr.numvalue import native_numeric_types from pyomo.repn import generate_standard_repn @@ -247,7 +249,7 @@ def _get_bound(exp): constraint_containers_removed += 1 for constraint, index in constraint_data_to_remove: # Note that this del is not needed: assigning Constraint.Skip - # above removes the _ConstraintData from the _data dict. + # above removes the ConstraintData from the _data dict. # del constraint[index] constraints_removed += 1 for block, constraint in constraint_containers_to_remove: @@ -348,12 +350,12 @@ def _get_bound(exp): ) -# class _LinearConstraintData(_ConstraintData,LinearCanonicalRepn): +# class _LinearConstraintData(ConstraintData,LinearCanonicalRepn): # # This change breaks this class, but it's unclear whether this # is being used... # -class _LinearConstraintData(_ConstraintData): +class _LinearConstraintData(ConstraintData): """ This class defines the data for a single linear constraint in canonical form. @@ -393,7 +395,7 @@ def __init__(self, index, component=None): # # These lines represent in-lining of the # following constructors: - # - _ConstraintData, + # - ConstraintData, # - ActiveComponentData # - ComponentData self._component = weakref_ref(component) if (component is not None) else None @@ -442,7 +444,7 @@ def __init__(self, index, component=None): # These lines represent in-lining of the # following constructors: # - _LinearConstraintData - # - _ConstraintData, + # - ConstraintData, # - ActiveComponentData # - ComponentData self._component = weakref_ref(component) if (component is not None) else None @@ -468,10 +470,11 @@ def __getstate__(self): # possible # - def __call__(self, exception=True): + def __call__(self, exception=NOTSET): """ Compute the value of the body of this constraint. """ + exception = _type_check_exception_arg(self, exception) comp = self.parent_component() index = self.index() prows = comp._prows @@ -584,9 +587,14 @@ def constant(self): return sum(terms) # - # Abstract Interface (_ConstraintData) + # Abstract Interface (ConstraintData) # + def to_bounded_expression(self, evaluate_bounds=False): + """Access this constraint as a single expression.""" + # Note that the bounds are always going to be floats... + return self.lower, self.body, self.upper + @property def body(self): """Access the body of a constraint expression.""" diff --git a/pyomo/repn/linear.py b/pyomo/repn/linear.py index 6ab4abfdaf5..12f6ef5cee8 100644 --- a/pyomo/repn/linear.py +++ b/pyomo/repn/linear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -31,8 +31,8 @@ MonomialTermExpression, LinearExpression, SumExpression, - NPV_SumExpression, ExternalFunctionExpression, + mutable_expression, ) from pyomo.core.expr.relational_expr import ( EqualityExpression, @@ -47,9 +47,14 @@ BeforeChildDispatcher, ExitNodeDispatcher, ExprType, + FileDeterminism, + FileDeterminism_to_SortComponents, InvalidNumber, + OrderedVarRecorder, + VarRecorder, apply_node_operation, complex_number_error, + initialize_exit_node_dispatcher, nan, sum_like_expression_types, ) @@ -61,6 +66,10 @@ _GENERAL = ExprType.GENERAL +def _inv2str(val): + return f"{val._str() if hasattr(val, '_str') else val}" + + def _merge_dict(dest_dict, mult, src_dict): if mult == 1: for vid, coef in src_dict.items(): @@ -80,6 +89,7 @@ class LinearRepn(object): __slots__ = ("multiplier", "constant", "linear", "nonlinear") def __init__(self): + logger.debug(f"(LinearRepn) {self.__class__.__qualname__}: __init__") self.multiplier = 1 self.constant = 0 self.linear = {} @@ -120,22 +130,18 @@ def to_expression(self, visitor): ans = 0 if self.linear: var_map = visitor.var_map - if len(self.linear) == 1: - vid, coef = next(iter(self.linear.items())) - if coef == 1: - ans += var_map[vid] - elif coef: - ans += MonomialTermExpression((coef, var_map[vid])) - else: - pass - else: - ans += LinearExpression( - [ - MonomialTermExpression((coef, var_map[vid])) - for vid, coef in self.linear.items() - if coef - ] - ) + with mutable_expression() as e: + for vid, coef in self.linear.items(): + try: + if coef: + e += coef * var_map[vid] + except Exception: + logger.exception(f"vid={vid}, coef={coef}, var_map={var_map}") + raise + if e.nargs() > 1: + ans += e + elif e.nargs() == 1: + ans += e.arg(0) if self.constant: ans += self.constant if self.multiplier != 1: @@ -191,8 +197,6 @@ def to_expression(visitor, arg): return arg[1].to_expression(visitor) -_exit_node_handlers = {} - # # NEGATION handlers # @@ -207,32 +211,26 @@ def _handle_negation_ANY(visitor, node, arg): return arg -_exit_node_handlers[NegationExpression] = { - (_CONSTANT,): _handle_negation_constant, - (_LINEAR,): _handle_negation_ANY, - (_GENERAL,): _handle_negation_ANY, -} - # # PRODUCT handlers # def _handle_product_constant_constant(visitor, node, arg1, arg2): - _, arg1 = arg1 - _, arg2 = arg2 - ans = arg1 * arg2 + ans = arg1[1] * arg2[1] if ans != ans: - if not arg1 or not arg2: + if not arg1[1] or not arg2[1]: + a = _inv2str(arg1[1]) + b = _inv2str(arg2[1]) deprecation_warning( - f"Encountered {str(arg1)}*{str(arg2)} in expression tree. " + f"Encountered {a}*{b} in expression tree. " "Mapping the NaN result to 0 for compatibility " "with the lp_v1 writer. In the future, this NaN " "will be preserved/emitted to comply with IEEE-754.", - version='6.6.0', + version="6.6.0", ) - return _, 0 - return _, arg1 * arg2 + return _CONSTANT, 0 + return _CONSTANT, ans def _handle_product_constant_ANY(visitor, node, arg1, arg2): @@ -283,19 +281,6 @@ def _handle_product_nonlinear(visitor, node, arg1, arg2): return _GENERAL, ans -_exit_node_handlers[ProductExpression] = { - (_CONSTANT, _CONSTANT): _handle_product_constant_constant, - (_CONSTANT, _LINEAR): _handle_product_constant_ANY, - (_CONSTANT, _GENERAL): _handle_product_constant_ANY, - (_LINEAR, _CONSTANT): _handle_product_ANY_constant, - (_LINEAR, _LINEAR): _handle_product_nonlinear, - (_LINEAR, _GENERAL): _handle_product_nonlinear, - (_GENERAL, _CONSTANT): _handle_product_ANY_constant, - (_GENERAL, _LINEAR): _handle_product_nonlinear, - (_GENERAL, _GENERAL): _handle_product_nonlinear, -} -_exit_node_handlers[MonomialTermExpression] = _exit_node_handlers[ProductExpression] - # # DIVISION handlers # @@ -306,7 +291,7 @@ def _handle_division_constant_constant(visitor, node, arg1, arg2): def _handle_division_ANY_constant(visitor, node, arg1, arg2): - arg1[1].multiplier /= arg2[1] + arg1[1].multiplier = apply_node_operation(node, (arg1[1].multiplier, arg2[1])) return arg1 @@ -316,25 +301,12 @@ def _handle_division_nonlinear(visitor, node, arg1, arg2): return _GENERAL, ans -_exit_node_handlers[DivisionExpression] = { - (_CONSTANT, _CONSTANT): _handle_division_constant_constant, - (_CONSTANT, _LINEAR): _handle_division_nonlinear, - (_CONSTANT, _GENERAL): _handle_division_nonlinear, - (_LINEAR, _CONSTANT): _handle_division_ANY_constant, - (_LINEAR, _LINEAR): _handle_division_nonlinear, - (_LINEAR, _GENERAL): _handle_division_nonlinear, - (_GENERAL, _CONSTANT): _handle_division_ANY_constant, - (_GENERAL, _LINEAR): _handle_division_nonlinear, - (_GENERAL, _GENERAL): _handle_division_nonlinear, -} - # # EXPONENTIATION handlers # -def _handle_pow_constant_constant(visitor, node, *args): - arg1, arg2 = args +def _handle_pow_constant_constant(visitor, node, arg1, arg2): ans = apply_node_operation(node, (arg1[1], arg2[1])) if ans.__class__ in native_complex_types: ans = complex_number_error(ans, visitor, node) @@ -343,7 +315,9 @@ def _handle_pow_constant_constant(visitor, node, *args): def _handle_pow_ANY_constant(visitor, node, arg1, arg2): _, exp = arg2 + logger.debug(f"exp={exp}") if exp == 1: + logger.debug(f"returning {arg1}") return arg1 elif exp > 1 and exp <= visitor.max_exponential_expansion and int(exp) == exp: _type, _arg = arg1 @@ -365,18 +339,6 @@ def _handle_pow_nonlinear(visitor, node, arg1, arg2): return _GENERAL, ans -_exit_node_handlers[PowExpression] = { - (_CONSTANT, _CONSTANT): _handle_pow_constant_constant, - (_CONSTANT, _LINEAR): _handle_pow_nonlinear, - (_CONSTANT, _GENERAL): _handle_pow_nonlinear, - (_LINEAR, _CONSTANT): _handle_pow_ANY_constant, - (_LINEAR, _LINEAR): _handle_pow_nonlinear, - (_LINEAR, _GENERAL): _handle_pow_nonlinear, - (_GENERAL, _CONSTANT): _handle_pow_ANY_constant, - (_GENERAL, _LINEAR): _handle_pow_nonlinear, - (_GENERAL, _GENERAL): _handle_pow_nonlinear, -} - # # ABS and UNARY handlers # @@ -396,13 +358,6 @@ def _handle_unary_nonlinear(visitor, node, arg): return _GENERAL, ans -_exit_node_handlers[UnaryFunctionExpression] = { - (_CONSTANT,): _handle_unary_constant, - (_LINEAR,): _handle_unary_nonlinear, - (_GENERAL,): _handle_unary_nonlinear, -} -_exit_node_handlers[AbsExpression] = _exit_node_handlers[UnaryFunctionExpression] - # # NAMED EXPRESSION handlers # @@ -421,12 +376,6 @@ def _handle_named_ANY(visitor, node, arg1): return _type, arg1.duplicate() -_exit_node_handlers[Expression] = { - (_CONSTANT,): _handle_named_constant, - (_LINEAR,): _handle_named_ANY, - (_GENERAL,): _handle_named_ANY, -} - # # EXPR_IF handlers # @@ -457,16 +406,6 @@ def _handle_expr_if_nonlinear(visitor, node, arg1, arg2, arg3): return _GENERAL, ans -_exit_node_handlers[Expr_ifExpression] = { - (i, j, k): _handle_expr_if_nonlinear - for i in (_LINEAR, _GENERAL) - for j in (_CONSTANT, _LINEAR, _GENERAL) - for k in (_CONSTANT, _LINEAR, _GENERAL) -} -for j in (_CONSTANT, _LINEAR, _GENERAL): - for k in (_CONSTANT, _LINEAR, _GENERAL): - _exit_node_handlers[Expr_ifExpression][_CONSTANT, j, k] = _handle_expr_if_const - # # Relational expression handlers # @@ -494,14 +433,6 @@ def _handle_equality_general(visitor, node, arg1, arg2): return _GENERAL, ans -_exit_node_handlers[EqualityExpression] = { - (i, j): _handle_equality_general - for i in (_CONSTANT, _LINEAR, _GENERAL) - for j in (_CONSTANT, _LINEAR, _GENERAL) -} -_exit_node_handlers[EqualityExpression][_CONSTANT, _CONSTANT] = _handle_equality_const - - def _handle_inequality_const(visitor, node, arg1, arg2): # It is exceptionally likely that if we get here, one of the # arguments is an InvalidNumber @@ -524,16 +455,6 @@ def _handle_inequality_general(visitor, node, arg1, arg2): return _GENERAL, ans -_exit_node_handlers[InequalityExpression] = { - (i, j): _handle_inequality_general - for i in (_CONSTANT, _LINEAR, _GENERAL) - for j in (_CONSTANT, _LINEAR, _GENERAL) -} -_exit_node_handlers[InequalityExpression][ - _CONSTANT, _CONSTANT -] = _handle_inequality_const - - def _handle_ranged_const(visitor, node, arg1, arg2, arg3): # It is exceptionally likely that if we get here, one of the # arguments is an InvalidNumber @@ -561,15 +482,62 @@ def _handle_ranged_general(visitor, node, arg1, arg2, arg3): return _GENERAL, ans -_exit_node_handlers[RangedExpression] = { - (i, j, k): _handle_ranged_general - for i in (_CONSTANT, _LINEAR, _GENERAL) - for j in (_CONSTANT, _LINEAR, _GENERAL) - for k in (_CONSTANT, _LINEAR, _GENERAL) -} -_exit_node_handlers[RangedExpression][ - _CONSTANT, _CONSTANT, _CONSTANT -] = _handle_ranged_const +def define_exit_node_handlers(_exit_node_handlers=None): + if _exit_node_handlers is None: + _exit_node_handlers = {} + _exit_node_handlers[NegationExpression] = { + None: _handle_negation_ANY, + (_CONSTANT,): _handle_negation_constant, + } + _exit_node_handlers[ProductExpression] = { + None: _handle_product_nonlinear, + (_CONSTANT, _CONSTANT): _handle_product_constant_constant, + (_CONSTANT, _LINEAR): _handle_product_constant_ANY, + (_CONSTANT, _GENERAL): _handle_product_constant_ANY, + (_LINEAR, _CONSTANT): _handle_product_ANY_constant, + (_GENERAL, _CONSTANT): _handle_product_ANY_constant, + } + _exit_node_handlers[MonomialTermExpression] = _exit_node_handlers[ProductExpression] + _exit_node_handlers[DivisionExpression] = { + None: _handle_division_nonlinear, + (_CONSTANT, _CONSTANT): _handle_division_constant_constant, + (_LINEAR, _CONSTANT): _handle_division_ANY_constant, + (_GENERAL, _CONSTANT): _handle_division_ANY_constant, + } + _exit_node_handlers[PowExpression] = { + None: _handle_pow_nonlinear, + (_CONSTANT, _CONSTANT): _handle_pow_constant_constant, + (_LINEAR, _CONSTANT): _handle_pow_ANY_constant, + (_GENERAL, _CONSTANT): _handle_pow_ANY_constant, + } + _exit_node_handlers[UnaryFunctionExpression] = { + None: _handle_unary_nonlinear, + (_CONSTANT,): _handle_unary_constant, + } + _exit_node_handlers[AbsExpression] = _exit_node_handlers[UnaryFunctionExpression] + _exit_node_handlers[Expression] = { + None: _handle_named_ANY, + (_CONSTANT,): _handle_named_constant, + } + _exit_node_handlers[Expr_ifExpression] = {None: _handle_expr_if_nonlinear} + for j in (_CONSTANT, _LINEAR, _GENERAL): + for k in (_CONSTANT, _LINEAR, _GENERAL): + _exit_node_handlers[Expr_ifExpression][ + _CONSTANT, j, k + ] = _handle_expr_if_const + _exit_node_handlers[EqualityExpression] = { + None: _handle_equality_general, + (_CONSTANT, _CONSTANT): _handle_equality_const, + } + _exit_node_handlers[InequalityExpression] = { + None: _handle_inequality_general, + (_CONSTANT, _CONSTANT): _handle_inequality_const, + } + _exit_node_handlers[RangedExpression] = { + None: _handle_ranged_general, + (_CONSTANT, _CONSTANT, _CONSTANT): _handle_ranged_const, + } + return _exit_node_handlers class LinearBeforeChildDispatcher(BeforeChildDispatcher): @@ -582,38 +550,15 @@ def __init__(self): self[LinearExpression] = self._before_linear self[SumExpression] = self._before_general_expression - @staticmethod - def _record_var(visitor, var): - # We always add all indices to the var_map at once so that - # we can honor deterministic ordering of unordered sets - # (because the user could have iterated over an unordered - # set when constructing an expression, thereby altering the - # order in which we would see the variables) - vm = visitor.var_map - vo = visitor.var_order - l = len(vo) - try: - _iter = var.parent_component().values(visitor.sorter) - except AttributeError: - # Note that this only works for the AML, as kernel does not - # provide a parent_component() - _iter = (var,) - for v in _iter: - if v.fixed: - continue - vid = id(v) - vm[vid] = v - vo[vid] = l - l += 1 - @staticmethod def _before_var(visitor, child): _id = id(child) if _id not in visitor.var_map: if child.fixed: return False, (_CONSTANT, visitor.check_constant(child.value, child)) - LinearBeforeChildDispatcher._record_var(visitor, child) + visitor.var_recorder.add(child) ans = visitor.Result() + logger.debug(f"setting linear[{child}] linear[{_id}] = 1") ans.linear[_id] = 1 return False, (_LINEAR, ans) @@ -641,7 +586,7 @@ def _before_monomial(visitor, child): _CONSTANT, arg1 * visitor.check_constant(arg2.value, arg2), ) - LinearBeforeChildDispatcher._record_var(visitor, arg2) + visitor.var_recorder.add(arg2) # Trap multiplication by 0 and nan. if not arg1: @@ -649,11 +594,11 @@ def _before_monomial(visitor, child): arg2 = visitor.check_constant(arg2.value, arg2) if arg2 != arg2: deprecation_warning( - f"Encountered {arg1}*{str(arg2.value)} in expression " + f"Encountered {arg1}*{_inv2str(arg2)} in expression " "tree. Mapping the NaN result to 0 for compatibility " "with the lp_v1 writer. In the future, this NaN " "will be preserved/emitted to comply with IEEE-754.", - version='6.6.0', + version="6.6.0", ) return False, (_CONSTANT, arg1) @@ -663,8 +608,8 @@ def _before_monomial(visitor, child): @staticmethod def _before_linear(visitor, child): + var_map = visitor.var_map - var_order = visitor.var_order ans = visitor.Result() const = 0 linear = ans.linear @@ -683,11 +628,11 @@ def _before_linear(visitor, child): arg2 = visitor.check_constant(arg2.value, arg2) if arg2 != arg2: deprecation_warning( - f"Encountered {arg1}*{str(arg2.value)} in expression " + f"Encountered {arg1}*{_inv2str(arg2)} in expression " "tree. Mapping the NaN result to 0 for compatibility " "with the lp_v1 writer. In the future, this NaN " "will be preserved/emitted to comply with IEEE-754.", - version='6.6.0', + version="6.6.0", ) continue @@ -696,7 +641,7 @@ def _before_linear(visitor, child): if arg2.fixed: const += arg1 * visitor.check_constant(arg2.value, arg2) continue - LinearBeforeChildDispatcher._record_var(visitor, arg2) + visitor.var_recorder.add(arg2) linear[_id] = arg1 elif _id in linear: linear[_id] += arg1 @@ -704,6 +649,18 @@ def _before_linear(visitor, child): linear[_id] = arg1 elif arg.__class__ in native_numeric_types: const += arg + elif arg.is_variable_type(): + _id = id(arg) + if _id not in var_map: + if arg.fixed: + const += visitor.check_constant(arg.value, arg) + continue + visitor.var_recorder.add(arg) + linear[_id] = 1 + elif _id in linear: + linear[_id] += 1 + else: + linear[_id] = 1 else: try: const += visitor.check_constant(visitor.evaluate(arg), arg) @@ -740,35 +697,44 @@ def _before_external(visitor, child): return False, (_GENERAL, ans) -_before_child_dispatcher = LinearBeforeChildDispatcher() - - -# -# Initialize the _exit_node_dispatcher -# -def _initialize_exit_node_dispatcher(exit_handlers): - exit_dispatcher = {} - for cls, handlers in exit_handlers.items(): - for args, fcn in handlers.items(): - exit_dispatcher[(cls, *args)] = fcn - return exit_dispatcher - - class LinearRepnVisitor(StreamBasedExpressionVisitor): Result = LinearRepn - exit_node_handlers = _exit_node_handlers + before_child_dispatcher = LinearBeforeChildDispatcher() exit_node_dispatcher = ExitNodeDispatcher( - _initialize_exit_node_dispatcher(_exit_node_handlers) + initialize_exit_node_dispatcher(define_exit_node_handlers()) ) expand_nonlinear_products = False max_exponential_expansion = 1 - def __init__(self, subexpression_cache, var_map, var_order, sorter): + def __init__( + self, + subexpression_cache, + var_map=None, + var_order=None, + sorter=None, + var_recorder=None, + ): + logger.debug(f"(LinearRepnVisitor) {self.__class__}: __init__") super().__init__() self.subexpression_cache = subexpression_cache - self.var_map = var_map - self.var_order = var_order - self.sorter = sorter + if any(_ is not None for _ in (var_map, var_order, sorter)): + if var_recorder is not None: + raise ValueError( + "LinearRepnVisitor: cannot specify any of var_map, " + "var_order, or sorter with var_recorder" + ) + deprecation_warning( + "var_map, var_order, and sorter are deprecated arguments to " + "LinearRepnVisitor(). Please pass the VarRecorder object directly.", + version="6.8.1", + ) + var_recorder = OrderedVarRecorder(var_map, var_order, sorter) + if var_recorder is None: + var_recorder = VarRecorder( + {}, FileDeterminism_to_SortComponents(FileDeterminism.ORDERED) + ) + self.var_recorder = var_recorder + self.var_map = var_recorder.var_map self._eval_expr_visitor = _EvaluationVisitor(True) self.evaluate = self._eval_expr_visitor.dfs_postorder_stack @@ -805,13 +771,16 @@ def check_constant(self, ans, obj): return ans def initializeWalker(self, expr): + logger.debug(f"{self.__class__.__name__}.initializeWalker({expr})") walk, result = self.beforeChild(None, expr, 0) + logger.debug(f" walk, result = {walk, result}") if not walk: return False, self.finalizeResult(result) return True, expr def beforeChild(self, node, child, child_idx): - return _before_child_dispatcher[child.__class__](self, child) + logger.debug(f"before_child: {child}:{child_idx} {type(child)}") + return self.before_child_dispatcher[child.__class__](self, child) def enterNode(self, node): # SumExpression are potentially large nary operators. Directly @@ -822,16 +791,22 @@ def enterNode(self, node): return node.args, [] def exitNode(self, node, data): + if data.__class__ is self.Result: return data.walker_exitNode() # # General expressions... # + logger.debug( + f"exit lookup: {node.__class__.__name__}, {[i.name for i in map(itemgetter(0), data)]}" + ) + return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( self, node, *data ) def finalizeResult(self, result): + ans = result[1] if ans.__class__ is self.Result: mult = ans.multiplier @@ -849,11 +824,11 @@ def finalizeResult(self, result): c != c for c in ans.linear.values() ): deprecation_warning( - f"Encountered {str(mult)}*nan in expression tree. " + f"Encountered {mult}*nan in expression tree. " "Mapping the NaN result to 0 for compatibility " "with the lp_v1 writer. In the future, this NaN " "will be preserved/emitted to comply with IEEE-754.", - version='6.6.0', + version="6.6.0", ) return self.Result() else: diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py new file mode 100644 index 00000000000..bd15bbe384a --- /dev/null +++ b/pyomo/repn/linear_template.py @@ -0,0 +1,393 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +from copy import deepcopy +from itertools import chain + +from pyomo.common.collections import ComponentSet +from pyomo.common.errors import MouseTrap +from pyomo.common.numeric_types import native_types + +import pyomo.core.expr as expr +import pyomo.repn.linear as linear +import pyomo.repn.util as util + +from pyomo.core.expr import ExpressionType +from pyomo.repn.linear import LinearRepn + +_CONSTANT = util.ExprType.CONSTANT +_VARIABLE = util.ExprType.VARIABLE +_LINEAR = util.ExprType.LINEAR +_GENERAL = util.ExprType.GENERAL + +code_type = deepcopy.__class__ + + +import logging + +logger = logging.getLogger(__name__) + + +class LinearTemplateRepn(LinearRepn): + __slots__ = ("linear_sum",) + + def __init__(self): + logger.debug(f"(LinearTemplateRepn) {self.__class__.__name__}: __init__") + super().__init__() + self.linear_sum = [] + + def __str__(self): + return ( + f"LinearTemplateRepn(mult={self.multiplier}, const={self.constant}, " + f"linear={self.linear}, linear_sum={self.linear_sum}, " + f"nonlinear={self.nonlinear})" + ) + + def walker_exitNode(self): + if self.nonlinear is not None: + return _GENERAL, self + elif self.linear or self.linear_sum: + return _LINEAR, self + else: + return _CONSTANT, self.multiplier * self.constant + + def duplicate(self): + ans = super().duplicate() + ans.linear_sum = [(r[0].duplicate(),) + r[1:] for r in self.linear_sum] + return ans + + def append(self, other): + """Append a child result from StreamBasedExpressionVisitor.acceptChildResult() + + Notes + ----- + This method assumes that the operator was "+". It is implemented + so that we can directly use a QuadraticRepn() as a data object in + the expression walker (thereby avoiding the function call for a + custom callback) + + """ + super().append(other) + _type, other = other + if getattr(other, "linear_sum", None): + mult = other.multiplier + if not mult: + return + if mult != 1: + for term in other.linear_sum: + term[0].multiplier *= mult + self.linear_sum.extend(other.linear_sum) + + def _build_evaluator( + self, + smap, + expr_cache, + multiplier, + repetitions, + remove_fixed_vars, + check_duplicates, + ): + logger.debug( + f"smap:{smap}, expr_cache:{expr_cache}, multiplier:{multiplier}, repetitions:{repetitions}, " + f"remove_fixed_vars:{remove_fixed_vars}, check_duplicates:{check_duplicates}" + ) + ans = [] + multiplier *= self.multiplier + constant = self.constant + if constant.__class__ not in native_types or constant: + constant *= multiplier + if not repetitions or ( + constant.__class__ not in native_types and constant.is_expression_type() + ): + ans.append("const += " + constant.to_string(smap=smap)) + constant = 0 + else: + constant *= repetitions + for k, coef in list(self.linear.items()): + coef *= multiplier + if coef.__class__ not in native_types and coef.is_expression_type(): + coef = coef.to_string(smap=smap) + elif coef: + coef = repr(coef) + else: + continue + + indent = "" + if k in expr_cache: + k = expr_cache[k] + if k.__class__ not in native_types and k.is_expression_type(): + ans.append("v = " + k.to_string(smap=smap)) + k = "v" + if remove_fixed_vars: + ans.append("if v.__class__ is tuple:") + ans.append(" const += v[0] * {coef}") + ans.append(" v = None") + ans.append("else:") + indent = " " + elif not check_duplicates: + # Directly substitute the expression into the + # 'linear[vid] = coef below + # + # Remove the 'v = ' from the beginning of the last line: + k = ans.pop()[4:] + if check_duplicates: + ans.append(indent + f"if {k} in linear:") + ans.append(indent + f" linear[{k}] += {coef}") + ans.append(indent + "else:") + ans.append(indent + f" linear[{k}] = {coef}") + else: + ans.append(indent + f"linear_indices.append({k})") + ans.append(indent + f"linear_data.append({coef})") + + for subrepn, subindices, subsets in getattr(self, "linear_sum", []): + ans.extend( + " " * i + + f"for {','.join(smap.getSymbol(i) for i in _idx)} in " + + ( + _set.to_string(smap=smap) + if _set.is_expression_type() + else smap.getSymbol(_set) + ) + + ":" + for i, (_idx, _set) in enumerate(zip(subindices, subsets)) + ) + try: + subrep = 1 + for _set in subsets: + subrep *= len(_set) + except: + subrep = 0 + subans, subconst = subrepn._build_evaluator( + smap, + expr_cache, + multiplier, + repetitions * subrep, + remove_fixed_vars, + check_duplicates, + ) + indent = " " * (len(subsets)) + ans.extend(indent + line for line in subans) + constant += subconst + return ans, constant + + def compile( + self, + env, + smap, + expr_cache, + args, + remove_fixed_vars=False, + check_duplicates=False, + ): + ans, constant = self._build_evaluator( + smap, expr_cache, 1, 1, remove_fixed_vars, check_duplicates + ) + if not ans: + return constant + indent = "\n " + if not constant and ans and ans[0].startswith("const +="): + # Convert initial "const +=" to "const =" + ans[0] = "".join(ans[0].split("+", 1)) + else: + ans.insert(0, "const = " + repr(constant)) + fcn_body = indent.join(ans[1:]) + if "const" not in fcn_body: + # No constants in the expression. Move the initial const + # term to the return value and avoid declaring the local + # variable + ans = ["return " + ans[0].split("=", 1)[1]] + if fcn_body: + ans.insert(0, fcn_body) + else: + ans = [ans[0], fcn_body, "return const"] + if check_duplicates: + ans.insert(0, f"def build_expr(linear, {', '.join(args)}):") + else: + ans.insert( + 0, f"def build_expr(linear_indices, linear_data, {', '.join(args)}):" + ) + ans = indent.join(ans) + + import textwrap + + logger.debug( + f"EXECUTING:\n\n{textwrap.indent(ans, ' ')}\n* compile RETURNING: build_expr\n" + ) + + # build the function in the env namespace, then remove and + # return the compiled function. The function's globals will + # still be bound to env + exec(ans, env) + return env.pop("build_expr") + + +class LinearTemplateBeforeChildDispatcher(linear.LinearBeforeChildDispatcher): + + def _before_indexed_var(self, visitor, child): + if child not in visitor.indexed_vars: + visitor.var_recorder.add(child) + visitor.indexed_vars.add(child) + return False, (_VARIABLE, child) + + def _before_indexed_param(self, visitor, child): + if child not in visitor.indexed_params: + visitor.indexed_params.add(child) + name = visitor.symbolmap.getSymbol(child) + visitor.env[name] = child.extract_values() + return False, (_CONSTANT, child) + + def _before_indexed_component(self, visitor, child): + visitor.env[visitor.symbolmap.getSymbol(child)] = child + return False, (_CONSTANT, child) + + def _before_index_template(self, visitor, child): + symb = visitor.symbolmap.getSymbol(child) + visitor.env[symb] = 0 + visitor.expr_cache[id(child)] = child + return False, (_CONSTANT, child) + + def _before_component(self, visitor, child): + visitor.env[visitor.symbolmap.getSymbol(child)] = child + return False, (_CONSTANT, child) + + def _before_named_expression(self, visitor, child): + raise MouseTrap("We do not yet support Expression components") + + +def _handle_getitem(visitor, node, comp, *args): + + expr = comp[1][tuple(arg[1] for arg in args)] + if comp[0] is _CONSTANT: + return (_CONSTANT, expr) + elif comp[0] is _VARIABLE: + # Because we are passing up an id() and not the expression + # itself, we need to cache the expression that we just created + # to preserve a reference to it and prevent deallocation / GC + visitor.expr_cache[id(expr)] = expr + ans = visitor.Result() + ans.linear[id(expr)] = 1 + return (_LINEAR, ans) + + +def _handle_templatesum(visitor, node, comp, *args): + ans = visitor.Result() + if comp[0] is _LINEAR: + ans.linear_sum.append((comp[1], node.template_iters(), [a[1] for a in args])) + return _LINEAR, ans + else: + raise DeveloperError() + + +def define_exit_node_handlers(_exit_node_handlers=None): + if _exit_node_handlers is None: + _exit_node_handlers = {} + linear.define_exit_node_handlers(_exit_node_handlers) + + _exit_node_handlers[expr.GetItemExpression] = {None: _handle_getitem} + _exit_node_handlers[expr.TemplateSumExpression] = {None: _handle_templatesum} + + return _exit_node_handlers + + +class LinearTemplateRepnVisitor(linear.LinearRepnVisitor): + Result = LinearTemplateRepn + before_child_dispatcher = LinearTemplateBeforeChildDispatcher() + exit_node_dispatcher = linear.ExitNodeDispatcher( + util.initialize_exit_node_dispatcher(define_exit_node_handlers()) + ) + # expand_nonlinear_products = True + + def __init__(self, subexpression_cache, var_recorder, remove_fixed_vars=False): + super().__init__(subexpression_cache, var_recorder=var_recorder) + self.indexed_vars = set() + self.indexed_params = set() + self.expr_cache = {} + self.env = var_recorder.env + self.symbolmap = var_recorder.symbolmap + self.expanded_templates = {} + self.remove_fixed_vars = remove_fixed_vars + + def enterNode(self, node): + # SumExpression are potentially large nary operators. Directly + # populate the result + logger.debug(f"node = {type(node).__name__} {node}") + if node.__class__ is expr.TemplateSumExpression: + return node.template_args(), [] + if node.__class__ in linear.sum_like_expression_types: + return node.args, self.Result() + else: + return node.args, [] + + def expand_expression(self, obj, template_info): + env = self.env + logger.debug(f"obj={type(obj)}") + logger.debug( + f"template_info={type(template_info)}, {[type(ti) for ti in template_info]}" + ) + logger.debug(f"id(template_info)={id(template_info)}") + try: + # attempt to look up already-constructed template + body, lb, ub = self.expanded_templates[id(template_info)] + except KeyError: + # create a new expanded template + smap = self.symbolmap + expr, indices = template_info + args = [smap.getSymbol(i) for i in indices] + if expr.is_expression_type(ExpressionType.RELATIONAL): + lb, body, ub = obj.to_bounded_expression() + if body is not None: + body = self.walk_expression(body).compile( + env, smap, self.expr_cache, args, False + ) + if lb is not None: + lb = self.walk_expression(lb).compile( + env, smap, self.expr_cache, args, True + ) + if ub is not None: + ub = self.walk_expression(ub).compile( + env, smap, self.expr_cache, args, True + ) + + elif expr is not None: + lb = ub = None + body = self.walk_expression(expr).compile( + env, smap, self.expr_cache, args, False + ) + else: + body = lb = ub = None + self.expanded_templates[id(template_info)] = body, lb, ub + logger.debug( + f"SET: {template_info} self.expanded_templates[{id(template_info)}] = {body}, {lb}, {ub}" + ) + + linear_indices = [] + linear_data = [] + + index = obj.index() + if index.__class__ is not tuple: + if index is None and not obj.parent_component().is_indexed(): + index = () + else: + index = (index,) + if lb.__class__ is code_type: + lb = lb(linear_indices, linear_data, *index) + if linear_indices: + raise RuntimeError(f"Constraint {obj} has non-fixed lower bound") + if ub.__class__ is code_type: + ub = ub(linear_indices, linear_data, *index) + if linear_indices: + raise RuntimeError(f"Constraint {obj} has non-fixed upper bound") + return ( + body(linear_indices, linear_data, *index), + linear_indices, + linear_data, + lb, + ub, + ) diff --git a/pyomo/repn/parameterized_linear.py b/pyomo/repn/parameterized_linear.py new file mode 100644 index 00000000000..399594b5a66 --- /dev/null +++ b/pyomo/repn/parameterized_linear.py @@ -0,0 +1,405 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import copy + +from pyomo.common.collections import ComponentSet +from pyomo.common.numeric_types import native_numeric_types +from pyomo.core import Var +from pyomo.core.expr.logical_expr import _flattened +from pyomo.core.expr.numeric_expr import ( + AbsExpression, + DivisionExpression, + LinearExpression, + MonomialTermExpression, + NegationExpression, + mutable_expression, + PowExpression, + ProductExpression, + SumExpression, + UnaryFunctionExpression, +) +from pyomo.repn.linear import ( + ExitNodeDispatcher, + initialize_exit_node_dispatcher, + LinearBeforeChildDispatcher, + LinearRepn, + LinearRepnVisitor, +) +from pyomo.repn.util import ExprType +import pyomo.repn.linear as linear + + +_FIXED = ExprType.FIXED +_CONSTANT = ExprType.CONSTANT +_LINEAR = ExprType.LINEAR +_GENERAL = ExprType.GENERAL + + +def _merge_dict(dest_dict, mult, src_dict): + if mult.__class__ not in native_numeric_types or mult != 1: + for vid, coef in src_dict.items(): + if vid in dest_dict: + dest_dict[vid] += mult * coef + else: + dest_dict[vid] = mult * coef + else: + for vid, coef in src_dict.items(): + if vid in dest_dict: + dest_dict[vid] += coef + else: + dest_dict[vid] = coef + + +def to_expression(visitor, arg): + if arg[0] in (_CONSTANT, _FIXED): + return arg[1] + else: + return arg[1].to_expression(visitor) + + +class ParameterizedLinearRepn(LinearRepn): + def __str__(self): + return ( + f"ParameterizedLinearRepn(mult={self.multiplier}, const={self.constant}, " + f"linear={self.linear}, nonlinear={self.nonlinear})" + ) + + def walker_exitNode(self): + if self.nonlinear is not None: + return _GENERAL, self + elif self.linear: + return _LINEAR, self + elif self.constant.__class__ in native_numeric_types: + return _CONSTANT, self.multiplier * self.constant + else: + return _FIXED, self.multiplier * self.constant + + def to_expression(self, visitor): + if self.nonlinear is not None: + # We want to start with the nonlinear term (and use + # assignment) in case the term is a non-numeric node (like a + # relational expression) + ans = self.nonlinear + else: + ans = 0 + if self.linear: + var_map = visitor.var_map + with mutable_expression() as e: + for vid, coef in self.linear.items(): + if coef.__class__ not in native_numeric_types or coef: + e += coef * var_map[vid] + if e.nargs() > 1: + ans += e + elif e.nargs() == 1: + ans += e.arg(0) + if self.constant.__class__ not in native_numeric_types or self.constant: + ans += self.constant + if ( + self.multiplier.__class__ not in native_numeric_types + or self.multiplier != 1 + ): + ans *= self.multiplier + return ans + + def append(self, other): + """Append a child result from acceptChildResult + + Notes + ----- + This method assumes that the operator was "+". It is implemented + so that we can directly use a ParameterizedLinearRepn() as a `data` object in + the expression walker (thereby allowing us to use the default + implementation of acceptChildResult [which calls + `data.append()`] and avoid the function call for a custom + callback). + + """ + _type, other = other + if _type is _CONSTANT or _type is _FIXED: + self.constant += other + return + + mult = other.multiplier + try: + _mult = bool(mult) + if not _mult: + return + if mult == 1: + _mult = False + except: + _mult = True + + const = other.constant + try: + _const = bool(const) + except: + _const = True + + if _mult: + if _const: + self.constant += mult * const + if other.linear: + _merge_dict(self.linear, mult, other.linear) + if other.nonlinear is not None: + nl = mult * other.nonlinear + if self.nonlinear is None: + self.nonlinear = nl + else: + self.nonlinear += nl + else: + if _const: + self.constant += const + if other.linear: + _merge_dict(self.linear, 1, other.linear) + if other.nonlinear is not None: + nl = other.nonlinear + if self.nonlinear is None: + self.nonlinear = nl + else: + self.nonlinear += nl + + +class ParameterizedLinearBeforeChildDispatcher(LinearBeforeChildDispatcher): + def __init__(self): + super().__init__() + self[Var] = self._before_var + self[MonomialTermExpression] = self._before_monomial + self[LinearExpression] = self._before_linear + self[SumExpression] = self._before_general_expression + + @staticmethod + def _before_linear(visitor, child): + return True, None + + @staticmethod + def _before_monomial(visitor, child): + return True, None + + @staticmethod + def _before_general_expression(visitor, child): + return True, None + + @staticmethod + def _before_var(visitor, child): + _id = id(child) + if _id not in visitor.var_map: + if child.fixed: + return False, (_CONSTANT, visitor.check_constant(child.value, child)) + if child in visitor.wrt: + # pseudo-constant + # We aren't treating this Var as a Var for the purposes of this walker + return False, (_FIXED, child) + # This is a normal situation + visitor.var_recorder.add(child) + ans = visitor.Result() + ans.linear[_id] = 1 + return False, (ExprType.LINEAR, ans) + + +_before_child_dispatcher = ParameterizedLinearBeforeChildDispatcher() + +# +# NEGATION handlers +# + + +def _handle_negation_pseudo_constant(visitor, node, arg): + return (_FIXED, -1 * arg[1]) + + +# +# PRODUCT handlers +# + + +def _handle_product_constant_constant(visitor, node, arg1, arg2): + # [ESJ 5/22/24]: Overriding this handler to exclude the deprecation path for + # 0 * nan. It doesn't need overridden when that deprecation path goes away. + return _CONSTANT, arg1[1] * arg2[1] + + +def _handle_product_pseudo_constant_constant(visitor, node, arg1, arg2): + return _FIXED, arg1[1] * arg2[1] + + +# +# DIVISION handlers +# + + +def _handle_division_pseudo_constant_constant(visitor, node, arg1, arg2): + return _FIXED, arg1[1] / arg2[1] + + +def _handle_division_ANY_pseudo_constant(visitor, node, arg1, arg2): + arg1[1].multiplier = arg1[1].multiplier / arg2[1] + return arg1 + + +# +# EXPONENTIATION handlers +# + + +def _handle_pow_pseudo_constant_constant(visitor, node, arg1, arg2): + return _FIXED, to_expression(visitor, arg1) ** to_expression(visitor, arg2) + + +def _handle_pow_nonlinear(visitor, node, arg1, arg2): + # ESJ: We override this because we need our own to_expression implementation + # if pseudo constants are involved. + ans = visitor.Result() + ans.nonlinear = to_expression(visitor, arg1) ** to_expression(visitor, arg2) + return _GENERAL, ans + + +# +# ABS and UNARY handlers +# + + +def _handle_unary_pseudo_constant(visitor, node, arg): + # We override this because we can't blindly use apply_node_operation in this case + return _FIXED, node.create_node_with_local_data((to_expression(visitor, arg),)) + + +def define_exit_node_handlers(exit_node_handlers=None): + if exit_node_handlers is None: + exit_node_handlers = {} + linear.define_exit_node_handlers(exit_node_handlers) + + exit_node_handlers[NegationExpression].update( + {(_FIXED,): _handle_negation_pseudo_constant} + ) + + exit_node_handlers[ProductExpression].update( + { + (_CONSTANT, _CONSTANT): _handle_product_constant_constant, + (_FIXED, _FIXED): _handle_product_pseudo_constant_constant, + (_FIXED, _CONSTANT): _handle_product_pseudo_constant_constant, + (_CONSTANT, _FIXED): _handle_product_pseudo_constant_constant, + (_FIXED, _LINEAR): linear._handle_product_constant_ANY, + (_LINEAR, _FIXED): linear._handle_product_ANY_constant, + (_FIXED, _GENERAL): linear._handle_product_constant_ANY, + (_GENERAL, _FIXED): linear._handle_product_ANY_constant, + } + ) + + exit_node_handlers[MonomialTermExpression].update( + exit_node_handlers[ProductExpression] + ) + + exit_node_handlers[DivisionExpression].update( + { + (_FIXED, _FIXED): _handle_division_pseudo_constant_constant, + (_FIXED, _CONSTANT): _handle_division_pseudo_constant_constant, + (_CONSTANT, _FIXED): _handle_division_pseudo_constant_constant, + (_LINEAR, _FIXED): _handle_division_ANY_pseudo_constant, + (_GENERAL, _FIXED): _handle_division_ANY_pseudo_constant, + } + ) + + exit_node_handlers[PowExpression].update( + { + (_FIXED, _FIXED): _handle_pow_pseudo_constant_constant, + (_FIXED, _CONSTANT): _handle_pow_pseudo_constant_constant, + (_CONSTANT, _FIXED): _handle_pow_pseudo_constant_constant, + (_LINEAR, _FIXED): _handle_pow_nonlinear, + (_FIXED, _LINEAR): _handle_pow_nonlinear, + (_GENERAL, _FIXED): _handle_pow_nonlinear, + (_FIXED, _GENERAL): _handle_pow_nonlinear, + } + ) + exit_node_handlers[UnaryFunctionExpression].update( + {(_FIXED,): _handle_unary_pseudo_constant} + ) + exit_node_handlers[AbsExpression] = exit_node_handlers[UnaryFunctionExpression] + + return exit_node_handlers + + +class ParameterizedLinearRepnVisitor(LinearRepnVisitor): + Result = ParameterizedLinearRepn + exit_node_dispatcher = ExitNodeDispatcher( + initialize_exit_node_dispatcher(define_exit_node_handlers()) + ) + + def __init__( + self, + subexpression_cache, + var_map=None, + var_order=None, + sorter=None, + wrt=None, + var_recorder=None, + ): + super().__init__(subexpression_cache, var_map, var_order, sorter, var_recorder) + if wrt is None: + raise ValueError("ParameterizedLinearRepn: wrt not specified") + self.wrt = ComponentSet(_flattened(wrt)) + + def beforeChild(self, node, child, child_idx): + return _before_child_dispatcher[child.__class__](self, child) + + def _factor_multiplier_into_linear_terms(self, ans, mult): + linear = ans.linear + zeros = [] + for vid, coef in linear.items(): + if coef.__class__ not in native_numeric_types or coef: + linear[vid] = mult * coef + else: + zeros.append(vid) + for vid in zeros: + del linear[vid] + if ans.nonlinear is not None: + ans.nonlinear *= mult + if ans.constant.__class__ not in native_numeric_types or ans.constant: + ans.constant *= mult + ans.multiplier = 1 + + def finalizeResult(self, result): + ans = result[1] + if ans.__class__ is self.Result: + mult = ans.multiplier + if mult.__class__ not in native_numeric_types: + # mult is an expression--we should push it back into the other terms + self._factor_multiplier_into_linear_terms(ans, mult) + return ans + if mult == 1: + zeros = [ + (vid, coef) + for vid, coef in ans.linear.items() + if coef.__class__ in native_numeric_types and not coef + ] + for vid, coef in zeros: + del ans.linear[vid] + elif not mult: + # the multiplier has cleared out the entire expression. Check + # if this is suppressing a NaN because we can't clear everything + # out if it is + if ans.constant != ans.constant or any( + c != c for c in ans.linear.values() + ): + # There's a nan in here, so we distribute the 0 + self._factor_multiplier_into_linear_terms(ans, mult) + return ans + return self.Result() + else: + # mult not in {0, 1}: factor it into the constant, + # linear coefficients, and nonlinear term + self._factor_multiplier_into_linear_terms(ans, mult) + return ans + + ans = self.Result() + assert result[0] in (_CONSTANT, _FIXED) + ans.constant = result[1] + return ans diff --git a/pyomo/repn/parameterized_quadratic.py b/pyomo/repn/parameterized_quadratic.py new file mode 100644 index 00000000000..2f67fe86526 --- /dev/null +++ b/pyomo/repn/parameterized_quadratic.py @@ -0,0 +1,419 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.numeric_types import native_numeric_types +from pyomo.core.expr.numeric_expr import ( + DivisionExpression, + Expr_ifExpression, + mutable_expression, + PowExpression, + ProductExpression, +) +from pyomo.repn.linear import ( + ExitNodeDispatcher, + initialize_exit_node_dispatcher, + _handle_division_ANY_constant, + _handle_expr_if_const, + _handle_pow_ANY_constant, + _handle_product_ANY_constant, + _handle_product_constant_ANY, +) +from pyomo.repn.parameterized_linear import ( + define_exit_node_handlers as _param_linear_def_exit_node_handlers, + ParameterizedLinearRepnVisitor, + to_expression, + _handle_division_ANY_pseudo_constant, + _merge_dict, +) +from pyomo.repn.quadratic import QuadraticRepn, _mul_linear_linear +from pyomo.repn.util import ExprType + + +_FIXED = ExprType.FIXED +_CONSTANT = ExprType.CONSTANT +_LINEAR = ExprType.LINEAR +_GENERAL = ExprType.GENERAL +_QUADRATIC = ExprType.QUADRATIC + + +class ParameterizedQuadraticRepn(QuadraticRepn): + def __str__(self): + return ( + "ParameterizedQuadraticRepn(" + f"mult={self.multiplier}, " + f"const={self.constant}, " + f"linear={self.linear}, " + f"quadratic={self.quadratic}, " + f"nonlinear={self.nonlinear})" + ) + + def __repr__(self): + return str(self) + + def walker_exitNode(self): + if self.nonlinear is not None: + return _GENERAL, self + elif self.quadratic: + return _QUADRATIC, self + elif self.linear: + return _LINEAR, self + elif self.constant.__class__ in native_numeric_types: + return _CONSTANT, self.multiplier * self.constant + else: + return _FIXED, self.multiplier * self.constant + + def to_expression(self, visitor): + var_map = visitor.var_map + if self.nonlinear is not None: + # We want to start with the nonlinear term (and use + # assignment) in case the term is a non-numeric node (like a + # relational expression) + ans = self.nonlinear + else: + ans = 0 + if self.quadratic: + with mutable_expression() as e: + for (x1, x2), coef in self.quadratic.items(): + if x1 == x2: + e += coef * var_map[x1] ** 2 + else: + e += coef * (var_map[x1] * var_map[x2]) + ans += e + if self.linear: + var_map = visitor.var_map + with mutable_expression() as e: + for vid, coef in self.linear.items(): + if not is_zero(coef): + e += coef * var_map[vid] + if e.nargs() > 1: + ans += e + elif e.nargs() == 1: + ans += e.arg(0) + if not is_zero(self.constant): + ans += self.constant + if not is_equal_to(self.multiplier, 1): + ans *= self.multiplier + return ans + + def append(self, other): + """Append a child result from acceptChildResult + + Notes + ----- + This method assumes that the operator was "+". It is implemented + so that we can directly use a ParameterizedLinearRepn() as a `data` object in + the expression walker (thereby allowing us to use the default + implementation of acceptChildResult [which calls + `data.append()`] and avoid the function call for a custom + callback). + + """ + _type, other = other + if _type is _CONSTANT or _type is _FIXED: + self.constant += other + return + + mult = other.multiplier + try: + _mult = bool(mult) + if not _mult: + return + if mult == 1: + _mult = False + except: + _mult = True + + const = other.constant + try: + _const = bool(const) + except: + _const = True + + if _mult: + if _const: + self.constant += mult * const + if other.linear: + _merge_dict(self.linear, mult, other.linear) + if other.quadratic: + if not self.quadratic: + self.quadratic = {} + _merge_dict(self.quadratic, mult, other.quadratic) + if other.nonlinear is not None: + nl = mult * other.nonlinear + if self.nonlinear is None: + self.nonlinear = nl + else: + self.nonlinear += nl + else: + if _const: + self.constant += const + if other.linear: + _merge_dict(self.linear, 1, other.linear) + if other.quadratic: + if not self.quadratic: + self.quadratic = {} + _merge_dict(self.quadratic, 1, other.quadratic) + if other.nonlinear is not None: + nl = other.nonlinear + if self.nonlinear is None: + self.nonlinear = nl + else: + self.nonlinear += nl + + +def is_zero(obj): + """Return true if expression/constant is zero, False otherwise.""" + return obj.__class__ in native_numeric_types and not obj + + +def is_zero_product(e1, e2): + """ + Return True if e1 is zero and e2 is not known to be an indeterminate + (e.g., NaN, inf), or vice versa, False otherwise. + """ + return (is_zero(e1) and e2 == e2) or (e1 == e1 and is_zero(e2)) + + +def is_equal_to(obj, val): + return obj.__class__ in native_numeric_types and obj == val + + +def _handle_product_linear_linear(visitor, node, arg1, arg2): + _, arg1 = arg1 + _, arg2 = arg2 + # Quadratic first, because we will update linear in a minute + arg1.quadratic = _mul_linear_linear(visitor, arg1.linear, arg2.linear) + # Linear second, as this relies on knowing the original constants + if is_zero(arg2.constant): + arg1.linear = {} + elif not is_equal_to(arg2.constant, 1): + c = arg2.constant + for vid, coef in arg1.linear.items(): + arg1.linear[vid] = c * coef + if not is_zero(arg1.constant): + # TODO: what if a linear coefficient is indeterminate (nan/inf)? + # might that also affect nonlinear product handler? + _merge_dict(arg1.linear, arg1.constant, arg2.linear) + + # Finally, the constant and multipliers + if is_zero_product(arg1.constant, arg2.constant): + arg1.constant = 0 + else: + arg1.constant *= arg2.constant + + arg1.multiplier *= arg2.multiplier + return _QUADRATIC, arg1 + + +def _handle_product_nonlinear(visitor, node, arg1, arg2): + ans = visitor.Result() + if not visitor.expand_nonlinear_products: + ans.nonlinear = to_expression(visitor, arg1) * to_expression(visitor, arg2) + return _GENERAL, ans + + # multiplying (A1 + B1x + C1x^2 + D1(x)) * (A2 + B2x + C2x^2 + D2x)) + _, x1 = arg1 + _, x2 = arg2 + ans.multiplier = x1.multiplier * x2.multiplier + x1.multiplier = x2.multiplier = 1 + + # constant term [A1A2] + if is_zero_product(x1.constant, x2.constant): + ans.constant = 0 + else: + ans.constant = x1.constant * x2.constant + + # linear & quadratic terms + if not is_zero(x2.constant): + # [B1A2], [C1A2] + x2_c = x2.constant + if is_equal_to(x2_c, 1): + ans.linear = dict(x1.linear) + if x1.quadratic: + ans.quadratic = dict(x1.quadratic) + else: + ans.linear = {vid: x2_c * coef for vid, coef in x1.linear.items()} + if x1.quadratic: + ans.quadratic = {k: x2_c * coef for k, coef in x1.quadratic.items()} + if not is_zero(x1.constant): + # [A1B2] + _merge_dict(ans.linear, x1.constant, x2.linear) + # [A1C2] + if x2.quadratic: + if ans.quadratic: + _merge_dict(ans.quadratic, x1.constant, x2.quadratic) + elif is_equal_to(x1.constant, 1): + ans.quadratic = dict(x2.quadratic) + else: + c = x1.constant + ans.quadratic = {k: c * coef for k, coef in x2.quadratic.items()} + # [B1B2] + if x1.linear and x2.linear: + quad = _mul_linear_linear(visitor, x1.linear, x2.linear) + if ans.quadratic: + _merge_dict(ans.quadratic, 1, quad) + else: + ans.quadratic = quad + + # nonlinear portion + # [D1A2] + [D1B2] + [D1C2] + [D1D2] + ans.nonlinear = 0 + if x1.nonlinear is not None: + ans.nonlinear += x1.nonlinear * x2.to_expression(visitor) + x1.nonlinear = None + x2.constant = 0 + x1_c = x1.constant + x1.constant = 0 + x1_lin = x1.linear + x1.linear = {} + # [C1B2] + [C1C2] + [C1D2] + if x1.quadratic: + ans.nonlinear += x1.to_expression(visitor) * x2.to_expression(visitor) + x1.quadratic = None + x2.linear = {} + # [B1C2] + [B1D2] + if x1_lin and (x2.nonlinear is not None or x2.quadratic): + x1.linear = x1_lin + ans.nonlinear += x1.to_expression(visitor) * x2.to_expression(visitor) + # [A1D2] + if not is_zero(x1_c) and x2.nonlinear is not None: + # TODO: what if nonlinear contains nan? + ans.nonlinear += x1_c * x2.nonlinear + return _GENERAL, ans + + +def define_exit_node_handlers(exit_node_handlers=None): + if exit_node_handlers is None: + exit_node_handlers = {} + _param_linear_def_exit_node_handlers(exit_node_handlers) + + exit_node_handlers[ProductExpression].update( + { + None: _handle_product_nonlinear, + (_CONSTANT, _QUADRATIC): _handle_product_constant_ANY, + (_QUADRATIC, _CONSTANT): _handle_product_ANY_constant, + # Replace handler from the linear walker + (_LINEAR, _LINEAR): _handle_product_linear_linear, + (_QUADRATIC, _FIXED): _handle_product_ANY_constant, + (_FIXED, _QUADRATIC): _handle_product_constant_ANY, + } + ) + exit_node_handlers[DivisionExpression].update( + { + (_QUADRATIC, _CONSTANT): _handle_division_ANY_constant, + (_QUADRATIC, _FIXED): _handle_division_ANY_pseudo_constant, + } + ) + exit_node_handlers[PowExpression].update( + {(_QUADRATIC, _CONSTANT): _handle_pow_ANY_constant} + ) + exit_node_handlers[Expr_ifExpression].update( + { + (_CONSTANT, i, _QUADRATIC): _handle_expr_if_const + for i in (_CONSTANT, _LINEAR, _QUADRATIC, _GENERAL) + } + ) + exit_node_handlers[Expr_ifExpression].update( + { + (_CONSTANT, _QUADRATIC, i): _handle_expr_if_const + for i in (_CONSTANT, _LINEAR, _GENERAL) + } + ) + return exit_node_handlers + + +class ParameterizedQuadraticRepnVisitor(ParameterizedLinearRepnVisitor): + Result = ParameterizedQuadraticRepn + exit_node_dispatcher = ExitNodeDispatcher( + initialize_exit_node_dispatcher(define_exit_node_handlers()) + ) + max_exponential_expansion = 2 + expand_nonlinear_products = True + + def _factor_multiplier_into_quadratic_terms(self, ans, mult): + linear = ans.linear + zeros = [] + for vid, coef in linear.items(): + if not is_zero(coef): + linear[vid] = mult * coef + else: + zeros.append(vid) + for vid in zeros: + del linear[vid] + + quadratic = ans.quadratic + if quadratic is not None: + quad_zeros = [] + for vid_pair, coef in ans.quadratic.items(): + if not is_zero(coef): + ans.quadratic[vid_pair] = mult * coef + else: + quad_zeros.append(vid_pair) + for vid_pair in quad_zeros: + del quadratic[vid_pair] + + if ans.nonlinear is not None: + ans.nonlinear *= mult + if not is_zero(ans.constant): + ans.constant *= mult + ans.multiplier = 1 + + def finalizeResult(self, result): + ans = result[1] + if ans.__class__ is self.Result: + mult = ans.multiplier + if mult.__class__ not in native_numeric_types: + # mult is an expression--we should push it back into the other terms + self._factor_multiplier_into_quadratic_terms(ans, mult) + return ans + if mult == 1: + linear_zeros = [ + (vid, coef) for vid, coef in ans.linear.items() if is_zero(coef) + ] + for vid, coef in linear_zeros: + del ans.linear[vid] + + if ans.quadratic: + quadratic_zeros = [ + (vidpair, coef) + for vidpair, coef in ans.quadratic.items() + if is_zero(coef) + ] + for vidpair, coef in quadratic_zeros: + del ans.quadratic[vidpair] + elif not mult: + # the multiplier has cleared out the entire expression. + # check if this is suppressing a NaN because we can't + # clear everything out if it is + has_nan_coefficient = ( + ans.constant != ans.constant + or any(lcoeff != lcoeff for lcoeff in ans.linear.values()) + or ( + ans.quadratic is not None + and any(qcoeff != qcoeff for qcoeff in ans.quadratic.values()) + ) + ) + if has_nan_coefficient: + # There's a nan in here, so we distribute the 0 + self._factor_multiplier_into_quadratic_terms(ans, mult) + return ans + return self.Result() + else: + # mult not in {0, 1}: factor it into the constant, + # linear coefficients, quadratic coefficients, + # and nonlinear term + self._factor_multiplier_into_quadratic_terms(ans, mult) + return ans + + ans = self.Result() + assert result[0] in (_CONSTANT, _FIXED) + ans.constant = result[1] + return ans diff --git a/pyomo/repn/plugins/__init__.py b/pyomo/repn/plugins/__init__.py index d3804c55106..6402095df7e 100644 --- a/pyomo/repn/plugins/__init__.py +++ b/pyomo/repn/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,15 +11,17 @@ def load(): - import pyomo.repn.plugins.cpxlp - import pyomo.repn.plugins.ampl - import pyomo.repn.plugins.baron_writer - import pyomo.repn.plugins.mps - import pyomo.repn.plugins.gams_writer - import pyomo.repn.plugins.lp_writer - import pyomo.repn.plugins.nl_writer - import pyomo.repn.plugins.standard_form - + from pyomo.repn.plugins import ( + cpxlp, + ampl, + baron_writer, + mps, + gams_writer, + lp_writer, + nl_writer, + standard_form, + parameterized_standard_form, + ) from pyomo.opt import WriterFactory # Register the "default" versions of writers that have more than one @@ -37,6 +39,23 @@ def load(): def activate_writer_version(name, ver): """DEBUGGING TOOL to switch the "default" writer implementation""" + from pyomo.opt import WriterFactory + doc = WriterFactory.doc(name) WriterFactory.unregister(name) WriterFactory.register(name, doc)(WriterFactory.get_class(f'{name}_v{ver}')) + + +def active_writer_version(name): + """DEBUGGING TOOL to switch the "default" writer implementation""" + from pyomo.opt import WriterFactory + + ref = WriterFactory.get_class(name) + ver = 1 + try: + while 1: + if WriterFactory.get_class(f'{name}_v{ver}') is ref: + return ver + ver += 1 + except KeyError: + return None diff --git a/pyomo/repn/plugins/ampl/__init__.py b/pyomo/repn/plugins/ampl/__init__.py index d935056c90b..7aa8fb6b4a9 100644 --- a/pyomo/repn/plugins/ampl/__init__.py +++ b/pyomo/repn/plugins/ampl/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/plugins/ampl/ampl_.py b/pyomo/repn/plugins/ampl/ampl_.py index f422a085a3c..adb9294b2a7 100644 --- a/pyomo/repn/plugins/ampl/ampl_.py +++ b/pyomo/repn/plugins/ampl/ampl_.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -33,7 +33,7 @@ from pyomo.core.base import ( SymbolMap, NameLabeler, - _ExpressionData, + NamedExpressionData, SortComponents, var, param, @@ -168,11 +168,11 @@ def _build_op_template(): _op_template[EXPR.EqualityExpression] = "o24{C}\n" _op_comment[EXPR.EqualityExpression] = "\t#eq" - _op_template[var._VarData] = "v%d{C}\n" - _op_comment[var._VarData] = "\t#%s" + _op_template[var.VarData] = "v%d{C}\n" + _op_comment[var.VarData] = "\t#%s" - _op_template[param._ParamData] = "n%r{C}\n" - _op_comment[param._ParamData] = "" + _op_template[param.ParamData] = "n%r{C}\n" + _op_comment[param.ParamData] = "" _op_template[NumericConstant] = "n%r{C}\n" _op_comment[NumericConstant] = "" @@ -724,7 +724,7 @@ def _print_nonlinear_terms_NL(self, exp): self._print_nonlinear_terms_NL(exp.arg(0)) self._print_nonlinear_terms_NL(exp.arg(1)) - elif isinstance(exp, (_ExpressionData, IIdentityExpression)): + elif isinstance(exp, (NamedExpressionData, IIdentityExpression)): self._print_nonlinear_terms_NL(exp.expr) else: @@ -733,24 +733,24 @@ def _print_nonlinear_terms_NL(self, exp): % (exp_type) ) - elif isinstance(exp, (var._VarData, IVariable)) and (not exp.is_fixed()): + elif isinstance(exp, (var.VarData, IVariable)) and (not exp.is_fixed()): # (self._output_fixed_variable_bounds or if not self._symbolic_solver_labels: OUTPUT.write( - self._op_string[var._VarData] + self._op_string[var.VarData] % (self.ampl_var_id[self._varID_map[id(exp)]]) ) else: OUTPUT.write( - self._op_string[var._VarData] + self._op_string[var.VarData] % ( self.ampl_var_id[self._varID_map[id(exp)]], self._name_labeler(exp), ) ) - elif isinstance(exp, param._ParamData): - OUTPUT.write(self._op_string[param._ParamData] % (value(exp))) + elif isinstance(exp, param.ParamData): + OUTPUT.write(self._op_string[param.ParamData] % (value(exp))) elif isinstance(exp, NumericConstant) or exp.is_fixed(): OUTPUT.write(self._op_string[NumericConstant] % (value(exp))) diff --git a/pyomo/repn/plugins/baron_writer.py b/pyomo/repn/plugins/baron_writer.py index de19b5aad73..65f642b3adf 100644 --- a/pyomo/repn/plugins/baron_writer.py +++ b/pyomo/repn/plugins/baron_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -174,15 +174,26 @@ def _monomial_to_string(self, node): return self.smap.getSymbol(var) return ftoa(const, True) + '*' + self.smap.getSymbol(var) + def _var_to_string(self, node): + if node.is_fixed(): + return ftoa(node.value, True) + self.variables.add(id(node)) + return self.smap.getSymbol(node) + def _linear_to_string(self, node): values = [ ( self._monomial_to_string(arg) - if ( - arg.__class__ is EXPR.MonomialTermExpression - and not arg.arg(1).is_fixed() + if arg.__class__ is EXPR.MonomialTermExpression + else ( + ftoa(arg) + if arg.__class__ in native_numeric_types + else ( + self._var_to_string(arg) + if arg.is_variable_type() + else ftoa(value(arg), True) + ) ) - else ftoa(value(arg)) ) for arg in node.args ] @@ -245,9 +256,9 @@ def _skip_trivial(constraint_data): suffix_gen = ( lambda b: pyomo.core.base.suffix.active_export_suffix_generator(b) ) - r_o_eqns = [] - c_eqns = [] - l_eqns = [] + r_o_eqns = {} + c_eqns = {} + l_eqns = {} branching_priorities_suffixes = [] for block in all_blocks_list: for name, suffix in suffix_gen(block): @@ -255,13 +266,14 @@ def _skip_trivial(constraint_data): branching_priorities_suffixes.append(suffix) elif name == 'constraint_types': for constraint_data, constraint_type in suffix.items(): + info = constraint_data.to_bounded_expression(True) if not _skip_trivial(constraint_data): if constraint_type.lower() == 'relaxationonly': - r_o_eqns.append(constraint_data) + r_o_eqns[constraint_data] = info elif constraint_type.lower() == 'convex': - c_eqns.append(constraint_data) + c_eqns[constraint_data] = info elif constraint_type.lower() == 'local': - l_eqns.append(constraint_data) + l_eqns[constraint_data] = info else: raise ValueError( "A suffix '%s' contained an invalid value: %s\n" @@ -283,7 +295,10 @@ def _skip_trivial(constraint_data): % (name, _location) ) - non_standard_eqns = r_o_eqns + c_eqns + l_eqns + non_standard_eqns = set() + non_standard_eqns.update(r_o_eqns) + non_standard_eqns.update(c_eqns) + non_standard_eqns.update(l_eqns) # # EQUATIONS @@ -293,7 +308,7 @@ def _skip_trivial(constraint_data): n_roeqns = len(r_o_eqns) n_ceqns = len(c_eqns) n_leqns = len(l_eqns) - eqns = [] + eqns = {} # Alias the constraints by declaration order since Baron does not # include the constraint names in the solution file. It is important @@ -310,14 +325,15 @@ def _skip_trivial(constraint_data): for constraint_data in block.component_data_objects( Constraint, active=True, sort=sorter, descend_into=False ): - if (not constraint_data.has_lb()) and (not constraint_data.has_ub()): + lb, body, ub = constraint_data.to_bounded_expression(True) + if lb is None and ub is None: assert not constraint_data.equality continue # non-binding, so skip if (not _skip_trivial(constraint_data)) and ( constraint_data not in non_standard_eqns ): - eqns.append(constraint_data) + eqns[constraint_data] = lb, body, ub con_symbol = symbol_map.createSymbol(constraint_data, c_labeler) assert not con_symbol.startswith('.') @@ -396,12 +412,12 @@ def mutable_param_gen(b): # Equation Definition output_file.write('c_e_FIX_ONE_VAR_CONST__: ONE_VAR_CONST__ == 1;\n') - for constraint_data in itertools.chain(eqns, r_o_eqns, c_eqns, l_eqns): + for constraint_data, (lb, body, ub) in itertools.chain( + eqns.items(), r_o_eqns.items(), c_eqns.items(), l_eqns.items() + ): variables = OrderedSet() # print(symbol_map.byObject.keys()) - eqn_body = expression_to_string( - constraint_data.body, variables, smap=symbol_map - ) + eqn_body = expression_to_string(body, variables, smap=symbol_map) # print(symbol_map.byObject.keys()) referenced_variable_ids.update(variables) @@ -428,22 +444,22 @@ def mutable_param_gen(b): # Equality constraint if constraint_data.equality: eqn_lhs = '' - eqn_rhs = ' == ' + ftoa(constraint_data.upper) + eqn_rhs = ' == ' + ftoa(ub) # Greater than constraint - elif not constraint_data.has_ub(): - eqn_rhs = ' >= ' + ftoa(constraint_data.lower) + elif ub is None: + eqn_rhs = ' >= ' + ftoa(lb) eqn_lhs = '' # Less than constraint - elif not constraint_data.has_lb(): - eqn_rhs = ' <= ' + ftoa(constraint_data.upper) + elif lb is None: + eqn_rhs = ' <= ' + ftoa(ub) eqn_lhs = '' # Double-sided constraint - elif constraint_data.has_lb() and constraint_data.has_ub(): - eqn_lhs = ftoa(constraint_data.lower) + ' <= ' - eqn_rhs = ' <= ' + ftoa(constraint_data.upper) + elif lb is not None and ub is not None: + eqn_lhs = ftoa(lb) + ' <= ' + eqn_rhs = ' <= ' + ftoa(ub) eqn_string = eqn_lhs + eqn_body + eqn_rhs + ';\n' output_file.write(eqn_string) diff --git a/pyomo/repn/plugins/cpxlp.py b/pyomo/repn/plugins/cpxlp.py index 46e6b6d5265..f8d73ff5765 100644 --- a/pyomo/repn/plugins/cpxlp.py +++ b/pyomo/repn/plugins/cpxlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -60,7 +60,7 @@ def __init__(self): # The LP writer tracks which variables are # referenced in constraints, so that a user does not end up with a # zillion "unreferenced variables" warning messages. - # This dictionary maps id(_VarData) -> _VarData. + # This dictionary maps id(VarData) -> VarData. self._referenced_variable_ids = {} # Per ticket #4319, we are using %.17g, which mocks the @@ -374,7 +374,7 @@ def _print_expr_canonical( def printSOS(self, symbol_map, labeler, variable_symbol_map, soscondata, output): """ - Prints the SOS constraint associated with the _SOSConstraintData object + Prints the SOS constraint associated with the SOSConstraintData object """ sos_template_string = self.sos_template_string diff --git a/pyomo/repn/plugins/gams_writer.py b/pyomo/repn/plugins/gams_writer.py index 5f94f176762..3da385fb7aa 100644 --- a/pyomo/repn/plugins/gams_writer.py +++ b/pyomo/repn/plugins/gams_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -183,7 +183,16 @@ def _linear_to_string(self, node): ( self._monomial_to_string(arg) if arg.__class__ is EXPR.MonomialTermExpression - else ftoa(arg, True) + else ( + ftoa(arg, True) + if arg.__class__ in native_numeric_types + else ( + self.smap.getSymbol(arg) + if arg.is_variable_type() + and (not arg.fixed or self.output_fixed_variables) + else ftoa(value(arg), True) + ) + ) ) for arg in node.args ] @@ -610,11 +619,12 @@ def _write_model( # encountered will be added to the var_list due to the labeler # defined above. for con in model.component_data_objects(Constraint, active=True, sort=sort): - if not con.has_lb() and not con.has_ub(): + lb, body, ub = con.to_bounded_expression(True) + if lb is None and ub is None: assert not con.equality continue # non-binding, so skip - con_body = as_numeric(con.body) + con_body = as_numeric(body) if skip_trivial_constraints and con_body.is_fixed(): continue if linear: @@ -633,20 +643,20 @@ def _write_model( constraint_names.append('%s' % cName) ConstraintIO.write( '%s.. %s =e= %s ;\n' - % (constraint_names[-1], con_body_str, ftoa(con.upper, False)) + % (constraint_names[-1], con_body_str, ftoa(ub, False)) ) else: - if con.has_lb(): + if lb is not None: constraint_names.append('%s_lo' % cName) ConstraintIO.write( '%s.. %s =l= %s ;\n' - % (constraint_names[-1], ftoa(con.lower, False), con_body_str) + % (constraint_names[-1], ftoa(lb, False), con_body_str) ) - if con.has_ub(): + if ub is not None: constraint_names.append('%s_hi' % cName) ConstraintIO.write( '%s.. %s =l= %s ;\n' - % (constraint_names[-1], con_body_str, ftoa(con.upper, False)) + % (constraint_names[-1], con_body_str, ftoa(ub, False)) ) obj = list(model.component_data_objects(Objective, active=True, sort=sort)) diff --git a/pyomo/repn/plugins/lp_writer.py b/pyomo/repn/plugins/lp_writer.py index 627a54e3f68..1bfbbd6a2be 100644 --- a/pyomo/repn/plugins/lp_writer.py +++ b/pyomo/repn/plugins/lp_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -43,6 +43,7 @@ from pyomo.repn.util import ( FileDeterminism, FileDeterminism_to_SortComponents, + OrderedVarRecorder, categorize_valid_components, initialize_var_map_from_column_order, int_float, @@ -107,10 +108,12 @@ class LPWriter(object): doc=""" How much effort do we want to put into ensuring the LP file is written deterministically for a Pyomo model: - NONE (0) : None - ORDERED (10): rely on underlying component ordering (default) - SORT_INDICES (20) : sort keys of indexed components - SORT_SYMBOLS (30) : sort keys AND sort names (not declaration order) + + - NONE (0) : None + - ORDERED (10): rely on underlying component ordering (default) + - SORT_INDICES (20) : sort keys of indexed components + - SORT_SYMBOLS (30) : sort keys AND sort names (not declaration order) + """, ), ) @@ -142,8 +145,6 @@ class LPWriter(object): default=None, description='Preferred variable ordering', doc=""" - - List of variables in the order that they should appear in the LP file. Note that this is only a suggestion, as the LP file format is row-major and the columns are inferred from @@ -267,7 +268,9 @@ def write(self, model): aliasSymbol = self.symbol_map.alias getSymbol = self.symbol_map.getSymbol - sorter = FileDeterminism_to_SortComponents(self.config.file_determinism) + self.sorter = sorter = FileDeterminism_to_SortComponents( + self.config.file_determinism + ) component_map, unknown = categorize_valid_components( model, active=True, @@ -303,20 +306,19 @@ def write(self, model): ONE_VAR_CONSTANT = Var(name='ONE_VAR_CONSTANT', bounds=(1, 1)) ONE_VAR_CONSTANT.construct() - self.var_map = var_map = {id(ONE_VAR_CONSTANT): ONE_VAR_CONSTANT} - initialize_var_map_from_column_order(model, self.config, var_map) - self.var_order = {_id: i for i, _id in enumerate(var_map)} + self.var_map = {id(ONE_VAR_CONSTANT): ONE_VAR_CONSTANT} + initialize_var_map_from_column_order(model, self.config, self.var_map) + self.var_order = {_id: i for i, _id in enumerate(self.var_map)} + self.var_recorder = OrderedVarRecorder(self.var_map, self.var_order, sorter) _qp = self.config.allow_quadratic_objective _qc = self.config.allow_quadratic_constraint objective_visitor = (QuadraticRepnVisitor if _qp else LinearRepnVisitor)( - {}, var_map, self.var_order, sorter + {}, var_recorder=self.var_recorder ) constraint_visitor = (QuadraticRepnVisitor if _qc else LinearRepnVisitor)( objective_visitor.subexpression_cache if _qp == _qc else {}, - var_map, - self.var_order, - sorter, + var_recorder=self.var_recorder, ) timer.toc('Initialized column order', level=logging.DEBUG) @@ -408,10 +410,10 @@ def write(self, model): if with_debug_timing and con.parent_component() is not last_parent: timer.toc('Constraint %s', last_parent, level=logging.DEBUG) last_parent = con.parent_component() - # Note: Constraint.lb/ub guarantee a return value that is - # either a (finite) native_numeric_type, or None - lb = con.lb - ub = con.ub + # Note: Constraint.to_bounded_expression(evaluate_bounds=True) + # guarantee a return value that is either a (finite) + # native_numeric_type, or None + lb, body, ub = con.to_bounded_expression(True) if lb is None and ub is None: # Note: you *cannot* output trivial (unbounded) @@ -419,7 +421,7 @@ def write(self, model): # slack variable if skip_trivial_constraints is False, # but that seems rather silly. continue - repn = constraint_visitor.walk_expression(con.body) + repn = constraint_visitor.walk_expression(body) if repn.nonlinear is not None: raise ValueError( f"Model constraint ({con.name}) contains nonlinear terms that " @@ -458,13 +460,13 @@ def write(self, model): addSymbol(con, label) ostream.write(f'\n{label}:\n') self.write_expression(ostream, repn, False) - ostream.write(f'>= {(lb - offset)!r}\n') + ostream.write(f'>= {(lb - offset)!s}\n') elif lb == ub: label = f'c_e_{symbol}_' addSymbol(con, label) ostream.write(f'\n{label}:\n') self.write_expression(ostream, repn, False) - ostream.write(f'= {(lb - offset)!r}\n') + ostream.write(f'= {(lb - offset)!s}\n') else: # We will need the constraint body twice. Generate # in a buffer so we only have to do that once. @@ -476,18 +478,18 @@ def write(self, model): addSymbol(con, label) ostream.write(f'\n{label}:\n') ostream.write(buf) - ostream.write(f'>= {(lb - offset)!r}\n') + ostream.write(f'>= {(lb - offset)!s}\n') label = f'r_u_{symbol}_' aliasSymbol(con, label) ostream.write(f'\n{label}:\n') ostream.write(buf) - ostream.write(f'<= {(ub - offset)!r}\n') + ostream.write(f'<= {(ub - offset)!s}\n') elif ub is not None: label = f'c_u_{symbol}_' addSymbol(con, label) ostream.write(f'\n{label}:\n') self.write_expression(ostream, repn, False) - ostream.write(f'<= {(ub - offset)!r}\n') + ostream.write(f'<= {(ub - offset)!s}\n') if with_debug_timing: # report the last constraint @@ -511,7 +513,7 @@ def write(self, model): integer_vars = [] binary_vars = [] getSymbolByObjectID = self.symbol_map.byObject.get - for vid, v in var_map.items(): + for vid, v in self.var_map.items(): # Some variables in the var_map may not actually have been # written out to the LP file (e.g., added from col_order, or # multiplied by 0 in the expressions). Check to see that @@ -527,8 +529,8 @@ def write(self, model): # Note: Var.bounds guarantees the values are either (finite) # native_numeric_types or None lb, ub = v.bounds - lb = '-inf' if lb is None else repr(lb) - ub = '+inf' if ub is None else repr(ub) + lb = '-inf' if lb is None else str(lb) + ub = '+inf' if ub is None else str(ub) ostream.write(f"\n {lb} <= {v_symbol} <= {ub}") if integer_vars: @@ -565,7 +567,7 @@ def write(self, model): for v, w in getattr(soscon, 'get_items', soscon.items)(): if w.__class__ not in int_float: w = float(f) - ostream.write(f" {getSymbol(v)}:{w!r}\n") + ostream.write(f" {getSymbol(v)}:{w!s}\n") ostream.write("\nend\n") @@ -584,9 +586,9 @@ def write_expression(self, ostream, expr, is_objective): expr.linear.items(), key=lambda x: getVarOrder(x[0]) ): if coef < 0: - ostream.write(f'{coef!r} {getSymbol(getVar(vid))}\n') + ostream.write(f'{coef!s} {getSymbol(getVar(vid))}\n') else: - ostream.write(f'+{coef!r} {getSymbol(getVar(vid))}\n') + ostream.write(f'+{coef!s} {getSymbol(getVar(vid))}\n') quadratic = getattr(expr, 'quadratic', None) if quadratic: @@ -605,9 +607,9 @@ def _normalize_constraint(data): col = c1, c2 sym = f' {getSymbol(getVar(vid1))} * {getSymbol(getVar(vid2))}\n' if coef < 0: - return col, repr(coef) + sym + return col, str(coef) + sym else: - return col, '+' + repr(coef) + sym + return col, f'+{coef!s}{sym}' if is_objective: # diff --git a/pyomo/repn/plugins/mps.py b/pyomo/repn/plugins/mps.py index ba26783eea1..c8e01753d90 100644 --- a/pyomo/repn/plugins/mps.py +++ b/pyomo/repn/plugins/mps.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -62,7 +62,7 @@ def __init__(self, int_marker=False): # referenced in constraints, so that one doesn't end up with a # zillion "unreferenced variables" warning messages. stored at # the object level to avoid additional method arguments. - # dictionary of id(_VarData)->_VarData. + # dictionary of id(VarData)->VarData. self._referenced_variable_ids = {} # Keven Hunter made a nice point about using %.16g in his attachment diff --git a/pyomo/repn/plugins/nl_writer.py b/pyomo/repn/plugins/nl_writer.py index f3ff94ea8c9..b4c201238ae 100644 --- a/pyomo/repn/plugins/nl_writer.py +++ b/pyomo/repn/plugins/nl_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,50 +9,26 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import ctypes import logging import os -from collections import deque, defaultdict, namedtuple +from collections import defaultdict, namedtuple from contextlib import nullcontext from itertools import filterfalse, product from math import log10 as _log10 -from operator import itemgetter, attrgetter, setitem +from operator import itemgetter, attrgetter from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.config import ( - ConfigBlock, + ConfigDict, ConfigValue, InEnum, document_kwargs_from_configdict, ) -from pyomo.common.deprecation import deprecation_warning -from pyomo.common.errors import DeveloperError, InfeasibleConstraintException, MouseTrap +from pyomo.common.deprecation import relocated_module_attribute +from pyomo.common.errors import DeveloperError, InfeasibleConstraintException from pyomo.common.gc_manager import PauseGC -from pyomo.common.numeric_types import ( - native_complex_types, - native_numeric_types, - native_types, - value, -) from pyomo.common.timing import TicTocTimer -from pyomo.core.expr import ( - NegationExpression, - ProductExpression, - DivisionExpression, - PowExpression, - AbsExpression, - UnaryFunctionExpression, - MonomialTermExpression, - LinearExpression, - SumExpression, - EqualityExpression, - InequalityExpression, - RangedExpression, - Expr_ifExpression, - ExternalFunctionExpression, -) -from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, _EvaluationVisitor from pyomo.core.base import ( Block, Objective, @@ -69,34 +45,23 @@ minimize, ) from pyomo.core.base.component import ActiveComponent -from pyomo.core.base.constraint import _ConstraintData -from pyomo.core.base.expression import ScalarExpression, _GeneralExpressionData -from pyomo.core.base.objective import ( - ScalarObjective, - _GeneralObjectiveData, - _ObjectiveData, -) +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.expression import ScalarExpression, ExpressionData +from pyomo.core.base.objective import ScalarObjective, ObjectiveData from pyomo.core.base.suffix import SuffixFinder -from pyomo.core.base.var import _VarData +from pyomo.core.base.var import VarData import pyomo.core.kernel as kernel from pyomo.core.pyomoobject import PyomoObject from pyomo.opt import WriterFactory +from pyomo.repn.ampl import AMPLRepnVisitor, evaluate_ampl_nl_expression, TOL from pyomo.repn.util import ( - BeforeChildDispatcher, - ExitNodeDispatcher, - ExprType, FileDeterminism, FileDeterminism_to_SortComponents, - InvalidNumber, - apply_node_operation, categorize_valid_components, - complex_number_error, initialize_var_map_from_column_order, int_float, ordered_active_constraints, - nan, - sum_like_expression_types, ) from pyomo.repn.plugins.ampl.ampl_ import set_pyomo_amplfunc_env @@ -109,14 +74,11 @@ logger = logging.getLogger(__name__) -# Feasibility tolerance for trivial (fixed) constraints -TOL = 1e-8 +relocated_module_attribute('AMPLRepn', 'pyomo.repn.ampl.AMPLRepn', version='6.8.0') + inf = float('inf') minus_inf = -inf - -_CONSTANT = ExprType.CONSTANT -_MONOMIAL = ExprType.MONOMIAL -_GENERAL = ExprType.GENERAL +allowable_binary_var_bounds = {(0, 0), (0, 1), (1, 1)} ScalingFactors = namedtuple( 'ScalingFactors', ['variables', 'constraints', 'objectives'] @@ -129,17 +91,17 @@ class NLWriterInfo(object): Attributes ---------- - variables: List[_VarData] + variables: List[VarData] The list of (unfixed) Pyomo model variables in the order written to the NL file - constraints: List[_ConstraintData] + constraints: List[ConstraintData] The list of (active) Pyomo model constraints in the order written to the NL file - objectives: List[_ObjectiveData] + objectives: List[ObjectiveData] The list of (active) Pyomo model objectives in the order written to the NL file @@ -162,10 +124,10 @@ class NLWriterInfo(object): file in the same order as the :py:attr:`variables` and generated .col file. - eliminated_vars: List[Tuple[_VarData, NumericExpression]] + eliminated_vars: List[Tuple[VarData, NumericExpression]] The list of variables in the model that were eliminated by the - presolve. Each entry is a 2-tuple of (:py:class:`_VarData`, + presolve. Each entry is a 2-tuple of (:py:class:`VarData`, :py:class`NumericExpression`|`float`). The list is in the necessary order for correct evaluation (i.e., all variables appearing in the expression must either have been sent to the @@ -202,7 +164,7 @@ def __init__( @WriterFactory.register('nl_v2', 'Generate the corresponding AMPL NL file (version 2).') class NLWriter(object): - CONFIG = ConfigBlock('nlwriter') + CONFIG = ConfigDict('nlwriter') CONFIG.declare( 'show_section_timing', ConfigValue( @@ -228,10 +190,12 @@ class NLWriter(object): doc=""" How much effort do we want to put into ensuring the NL file is written deterministically for a Pyomo model: - NONE (0) : None - ORDERED (10): rely on underlying component ordering (default) - SORT_INDICES (20) : sort keys of indexed components - SORT_SYMBOLS (30) : sort keys AND sort names (not declaration order) + + - NONE (0) : None + - ORDERED (10): rely on underlying component ordering (default) + - SORT_INDICES (20) : sort keys of indexed components + - SORT_SYMBOLS (30) : sort keys AND sort names (not declaration order) + """, ), ) @@ -441,6 +405,7 @@ def store(self, obj, val): self.values[obj] = val def compile(self, column_order, row_order, obj_order, model_id): + var_con_obj = {Var, Constraint, Objective} missing_component_data = ComponentSet() unknown_data = ComponentSet() queue = [self.values.items()] @@ -466,18 +431,20 @@ def compile(self, column_order, row_order, obj_order, model_id): self.obj[obj_order[_id]] = val elif _id == model_id: self.prob[0] = val - elif isinstance(obj, (_VarData, _ConstraintData, _ObjectiveData)): - missing_component_data.add(obj) - elif isinstance(obj, (Var, Constraint, Objective)): - # Expand this indexed component to store the - # individual ComponentDatas, but ONLY if the - # component data is not in the original dictionary - # of values that we extracted from the Suffixes - queue.append( - product( - filterfalse(self.values.__contains__, obj.values()), (val,) + elif getattr(obj, 'ctype', None) in var_con_obj: + if obj.is_indexed(): + # Expand this indexed component to store the + # individual ComponentDatas, but ONLY if the + # component data is not in the original dictionary + # of values that we extracted from the Suffixes + queue.append( + product( + filterfalse(self.values.__contains__, obj.values()), + (val,), + ) ) - ) + else: + missing_component_data.add(obj) else: unknown_data.add(obj) if missing_component_data: @@ -508,8 +475,8 @@ def compile(self, column_order, row_order, obj_order, model_id): class CachingNumericSuffixFinder(SuffixFinder): scale = True - def __init__(self, name, default=None): - super().__init__(name, default) + def __init__(self, name, default=None, context=None): + super().__init__(name, default, context) self.suffix_cache = {} def __call__(self, obj): @@ -537,20 +504,15 @@ def __init__(self, ostream, rowstream, colstream, config): self.colstream = colstream self.config = config self.symbolic_solver_labels = config.symbolic_solver_labels - if self.symbolic_solver_labels: - self.template = text_nl_debug_template - else: - self.template = text_nl_template self.subexpression_cache = {} - self.subexpression_order = [] + self.subexpression_order = None # set to [] later self.external_functions = {} self.used_named_expressions = set() self.var_map = {} + self.var_id_to_nl_map = {} self.sorter = FileDeterminism_to_SortComponents(config.file_determinism) self.visitor = AMPLRepnVisitor( - self.template, self.subexpression_cache, - self.subexpression_order, self.external_functions, self.var_map, self.used_named_expressions, @@ -560,18 +522,15 @@ def __init__(self, ostream, rowstream, colstream, config): ) self.next_V_line_id = 0 self.pause_gc = None + self.template = self.visitor.Result.template def __enter__(self): - assert AMPLRepn.ActiveVisitor is None - AMPLRepn.ActiveVisitor = self.visitor self.pause_gc = PauseGC() self.pause_gc.__enter__() return self def __exit__(self, exc_type, exc_value, tb): self.pause_gc.__exit__(exc_type, exc_value, tb) - assert AMPLRepn.ActiveVisitor is self.visitor - AMPLRepn.ActiveVisitor = None def write(self, model): timing_logger = logging.getLogger('pyomo.common.timing.writer') @@ -620,6 +579,7 @@ def write(self, model): ostream = self.ostream linear_presolve = self.config.linear_presolve + nl_map = self.var_id_to_nl_map var_map = self.var_map initialize_var_map_from_column_order(model, self.config, var_map) timer.toc('Initialized column order', level=logging.DEBUG) @@ -643,7 +603,7 @@ def write(self, model): # Data structures to support variable/constraint scaling # if self.config.scale_model and 'scaling_factor' in suffix_data: - scaling_factor = CachingNumericSuffixFinder('scaling_factor', 1) + scaling_factor = CachingNumericSuffixFinder('scaling_factor', 1, model) scaling_cache = scaling_factor.suffix_cache del suffix_data['scaling_factor'] else: @@ -700,8 +660,7 @@ def write(self, model): objectives.extend(linear_objs) n_objs = len(objectives) - constraints = [] - linear_cons = [] + all_constraints = [] n_ranges = 0 n_equality = 0 n_complementarity_nonlin = 0 @@ -721,14 +680,14 @@ def write(self, model): timer.toc('Constraint %s', last_parent, level=logging.DEBUG) last_parent = con.parent_component() scale = scaling_factor(con) - expr_info = visitor.walk_expression((con.body, con, 0, scale)) + # Note: Constraint.to_bounded_expression(evaluate_bounds=True) + # guarantee a return value that is either a (finite) + # native_numeric_type, or None + lb, body, ub = con.to_bounded_expression(True) + expr_info = visitor.walk_expression((body, con, 0, scale)) if expr_info.named_exprs: self._record_named_expression_usage(expr_info.named_exprs, con, 0) - # Note: Constraint.lb/ub guarantee a return value that is - # either a (finite) native_numeric_type, or None - lb = con.lb - ub = con.ub if lb is None and ub is None: # and self.config.skip_trivial_constraints: continue if scale != 1: @@ -738,22 +697,7 @@ def write(self, model): ub = ub * scale if scale < 0: lb, ub = ub, lb - if expr_info.nonlinear: - constraints.append((con, expr_info, lb, ub)) - elif expr_info.linear: - linear_cons.append((con, expr_info, lb, ub)) - elif not self.config.skip_trivial_constraints: - linear_cons.append((con, expr_info, lb, ub)) - else: # constant constraint and skip_trivial_constraints - c = expr_info.const - if (lb is not None and lb - c > TOL) or ( - ub is not None and ub - c < -TOL - ): - raise InfeasibleConstraintException( - "model contains a trivially infeasible " - f"constraint '{con.name}' (fixed body value " - f"{c} outside bounds [{lb}, {ub}])." - ) + all_constraints.append((con, expr_info, lb, ub)) if linear_presolve: con_id = id(con) if not expr_info.nonlinear and lb == ub and lb is not None: @@ -764,28 +708,68 @@ def write(self, model): # report the last constraint timer.toc('Constraint %s', last_parent, level=logging.DEBUG) else: - timer.toc('Processed %s constraints', len(constraints)) + timer.toc('Processed %s constraints', len(all_constraints)) + + # We have identified all the external functions (resolving them + # by name). Now we may need to resolve the function by the + # (local) FID, which we know is indexed by integers starting at + # 0. We will convert the dict to a list for efficient lookup. + self.external_functions = list(self.external_functions.values()) # This may fetch more bounds than needed, but only in the cases # where variables were completely eliminated while walking the # expressions, or when users provide superfluous variables in # the column ordering. var_bounds = {_id: v.bounds for _id, v in var_map.items()} + var_values = {_id: v.value for _id, v in var_map.items()} eliminated_cons, eliminated_vars = self._linear_presolve( - comp_by_linear_var, lcon_by_linear_nnz, var_bounds + comp_by_linear_var, lcon_by_linear_nnz, var_bounds, var_values ) del comp_by_linear_var del lcon_by_linear_nnz - # Order the constraints, moving all nonlinear constraints to - # the beginning - n_nonlinear_cons = len(constraints) + # Note: defer categorizing constraints until after presolve, as + # the presolver could result in nonlinear constraints becoming + # linear (or trivial) + constraints = [] + linear_cons = [] if eliminated_cons: _removed = eliminated_cons.__contains__ - constraints.extend(filterfalse(lambda c: _removed(id(c[0])), linear_cons)) + _constraints = filterfalse(lambda c: _removed(id(c[0])), all_constraints) else: - constraints.extend(linear_cons) + _constraints = all_constraints + for info in _constraints: + expr_info = info[1] + if expr_info.nonlinear: + nl, args = expr_info.nonlinear + if any(vid not in nl_map for vid in args): + constraints.append(info) + continue + expr_info.const += evaluate_ampl_nl_expression( + nl % tuple(nl_map[i] for i in args), self.external_functions + ) + expr_info.nonlinear = None + if expr_info.linear: + linear_cons.append(info) + elif not self.config.skip_trivial_constraints: + linear_cons.append(info) + else: # constant constraint and skip_trivial_constraints + c = expr_info.const + con, expr_info, lb, ub = info + if (lb is not None and lb - c > TOL) or ( + ub is not None and ub - c < -TOL + ): + raise InfeasibleConstraintException( + "model contains a trivially infeasible " + f"constraint '{con.name}' (fixed body value " + f"{c} outside bounds [{lb}, {ub}])." + ) + + # Order the constraints, moving all nonlinear constraints to + # the beginning + n_nonlinear_cons = len(constraints) + constraints.extend(linear_cons) n_cons = len(constraints) # @@ -798,7 +782,7 @@ def write(self, model): # Filter out any unused named expressions self.subexpression_order = list( - filter(self.used_named_expressions.__contains__, self.subexpression_order) + filter(self.used_named_expressions.__contains__, self.subexpression_cache) ) # linear contribution by (constraint, objective, variable) component. @@ -820,10 +804,7 @@ def write(self, model): # We need to categorize the named subexpressions first so that # we know their linear / nonlinear vars when we encounter them # in constraints / objectives - self._categorize_vars( - map(self.subexpression_cache.__getitem__, self.subexpression_order), - linear_by_comp, - ) + self._categorize_vars(self.subexpression_cache.values(), linear_by_comp) n_subexpressions = self._count_subexpression_occurrences() obj_vars_linear, obj_vars_nonlinear, obj_nnz_by_var = self._categorize_vars( objectives, linear_by_comp @@ -847,6 +828,7 @@ def write(self, model): if _id not in var_map: var_map[_id] = _v var_bounds[_id] = _v.bounds + var_values[_id] = _v.value con_vars_nonlinear.add(_id) con_nnz = sum(con_nnz_by_var.values()) @@ -882,7 +864,12 @@ def write(self, model): elif v.is_binary(): binary_vars.add(_id) elif v.is_integer(): - integer_vars.add(_id) + # Note: integer variables whose bounds are in {0, 1} + # should be classified as binary + if var_bounds[_id] in allowable_binary_var_bounds: + binary_vars.add(_id) + else: + integer_vars.add(_id) else: raise ValueError( f"Variable '{v.name}' has a domain that is not Real, " @@ -1036,8 +1023,8 @@ def write(self, model): row_comments = [f'\t#{lbl}' for lbl in row_labels] col_labels = [labeler(var_map[_id]) for _id in variables] col_comments = [f'\t#{lbl}' for lbl in col_labels] - self.var_id_to_nl = { - _id: f'v{var_idx}{col_comments[var_idx]}' + id2nl = { + _id: f'v{var_idx}{col_comments[var_idx]}\n' for var_idx, _id in enumerate(variables) } # Write out the .row and .col data @@ -1050,11 +1037,12 @@ def write(self, model): else: row_labels = row_comments = [''] * (n_cons + n_objs) col_labels = col_comments = [''] * len(variables) - self.var_id_to_nl = { - _id: f"v{var_idx}" for var_idx, _id in enumerate(variables) - } + id2nl = {_id: f"v{var_idx}\n" for var_idx, _id in enumerate(variables)} - _vmap = self.var_id_to_nl + if nl_map: + nl_map.update(id2nl) + else: + self.var_id_to_nl_map = nl_map = id2nl if scale_model: template = self.template objective_scaling = [scaling_cache[id(info[0])] for info in objectives] @@ -1074,16 +1062,42 @@ def write(self, model): if ub is not None: ub *= scale var_bounds[_id] = lb, ub - # Update _vmap to output scaled variables in NL expressions - _vmap[_id] = ( - template.division + _vmap[_id] + '\n' + template.const % scale - ).rstrip() + # Update nl_map to output scaled variables in NL expressions + nl_map[_id] = template.division + nl_map[_id] + template.const % scale # Update any eliminated variables to point to the (potentially # scaled) substituted variables - for _id, expr_info in eliminated_vars.items(): - nl, args, _ = expr_info.compile_repn(visitor) - _vmap[_id] = nl.rstrip() % tuple(_vmap[_id] for _id in args) + for _id, expr_info in list(eliminated_vars.items()): + nl, args, _ = expr_info.compile_repn() + for _i in args: + # It is possible that the eliminated variable could + # reference another variable that is no longer part of + # the model and therefore does not have a nl_map entry. + # This can happen when there is an underdetermined + # independent linear subsystem and the presolve removed + # all the constraints from the subsystem. Because the + # free variables in the subsystem are not referenced + # anywhere else in the model, they are not part of the + # `variables` list. Implicitly "fix" it to an arbitrary + # valid value from the presolved domain (see #3192). + if _i not in nl_map: + lb, ub = var_bounds[_i] + if lb is None: + lb = -inf + if ub is None: + ub = inf + if lb <= 0 <= ub: + val = 0 + else: + val = lb if abs(lb) < abs(ub) else ub + eliminated_vars[_i] = visitor.Result(val, {}, None) + nl_map[_i] = expr_info.compile_repn()[0] + logger.warning( + "presolve identified an underdetermined independent " + "linear subsystem that was removed from the model. " + f"Setting '{var_map[_i]}' == {val}" + ) + nl_map[_id] = nl % tuple(nl_map[_i] for _i in args) r_lines = [None] * n_cons for idx, (con, expr_info, lb, ub) in enumerate(constraints): @@ -1093,17 +1107,17 @@ def write(self, model): r_lines[idx] = "3" else: # _type = 4 # L == c == U - r_lines[idx] = f"4 {lb - expr_info.const!r}" + r_lines[idx] = f"4 {lb - expr_info.const!s}" n_equality += 1 elif lb is None: # _type = 1 # c <= U - r_lines[idx] = f"1 {ub - expr_info.const!r}" + r_lines[idx] = f"1 {ub - expr_info.const!s}" elif ub is None: # _type = 2 # L <= c - r_lines[idx] = f"2 {lb - expr_info.const!r}" + r_lines[idx] = f"2 {lb - expr_info.const!s}" else: # _type = 0 # L <= c <= U - r_lines[idx] = f"0 {lb - expr_info.const!r} {ub - expr_info.const!r}" + r_lines[idx] = f"0 {lb - expr_info.const!s} {ub - expr_info.const!s}" n_ranges += 1 expr_info.const = 0 # FIXME: this is a HACK to be compatible with the NLv1 @@ -1249,8 +1263,8 @@ def write(self, model): len(linear_binary_vars), len(linear_integer_vars), len(both_vars_nonlinear.intersection(discrete_vars)), - len(con_vars_nonlinear.intersection(discrete_vars)), - len(obj_vars_nonlinear.intersection(discrete_vars)), + len(con_only_nonlinear_vars.intersection(discrete_vars)), + len(obj_only_nonlinear_vars.intersection(discrete_vars)), ) ) # @@ -1282,7 +1296,7 @@ def write(self, model): # "F" lines (external function definitions) # amplfunc_libraries = set() - for fid, fcn in sorted(self.external_functions.values()): + for fid, fcn in self.external_functions: amplfunc_libraries.add(fcn._library) ostream.write("F%d 1 -1 %s\n" % (fid, fcn._function)) @@ -1318,7 +1332,7 @@ def write(self, model): ostream.write(f"S{_field|_float} {len(_vals)} {name}\n") # Note: _SuffixData.compile() guarantees the value is int/float ostream.write( - ''.join(f"{_id} {_vals[_id]!r}\n" for _id in sorted(_vals)) + ''.join(f"{_id} {_vals[_id]!s}\n" for _id in sorted(_vals)) ) # @@ -1428,7 +1442,7 @@ def write(self, model): ostream.write(f"d{len(data.con)}\n") # Note: _SuffixData.compile() guarantees the value is int/float ostream.write( - ''.join(f"{_id} {data.con[_id]!r}\n" for _id in sorted(data.con)) + ''.join(f"{_id} {data.con[_id]!s}\n" for _id in sorted(data.con)) ) # @@ -1436,7 +1450,7 @@ def write(self, model): # _init_lines = [ (var_idx, val if val.__class__ in int_float else float(val)) - for var_idx, val in enumerate(var_map[_id].value for _id in variables) + for var_idx, val in enumerate(map(var_values.__getitem__, variables)) if val is not None ] if scale_model: @@ -1450,7 +1464,7 @@ def write(self, model): ) ostream.write( ''.join( - f'{var_idx} {val!r}{col_comments[var_idx]}\n' + f'{var_idx} {val!s}{col_comments[var_idx]}\n' for var_idx, val in _init_lines ) ) @@ -1491,13 +1505,13 @@ def write(self, model): if lb is None: # unbounded ostream.write(f"3{col_comments[var_idx]}\n") else: # == - ostream.write(f"4 {lb!r}{col_comments[var_idx]}\n") + ostream.write(f"4 {lb!s}{col_comments[var_idx]}\n") elif lb is None: # var <= ub - ostream.write(f"1 {ub!r}{col_comments[var_idx]}\n") + ostream.write(f"1 {ub!s}{col_comments[var_idx]}\n") elif ub is None: # lb <= body - ostream.write(f"2 {lb!r}{col_comments[var_idx]}\n") + ostream.write(f"2 {lb!s}{col_comments[var_idx]}\n") else: # lb <= body <= ub - ostream.write(f"0 {lb!r} {ub!r}{col_comments[var_idx]}\n") + ostream.write(f"0 {lb!s} {ub!s}{col_comments[var_idx]}\n") # # "k" lines (column offsets in Jacobian NNZ) @@ -1532,7 +1546,7 @@ def write(self, model): linear[_id] /= scaling_cache[_id] ostream.write(f'J{row_idx} {len(linear)}{row_comments[row_idx]}\n') for _id in sorted(linear, key=column_order.__getitem__): - ostream.write(f'{column_order[_id]} {linear[_id]!r}\n') + ostream.write(f'{column_order[_id]} {linear[_id]!s}\n') # # "G" lines (non-empty terms in the Objective) @@ -1548,7 +1562,7 @@ def write(self, model): linear[_id] /= scaling_cache[_id] ostream.write(f'G{obj_idx} {len(linear)}{row_comments[obj_idx + n_cons]}\n') for _id in sorted(linear, key=column_order.__getitem__): - ostream.write(f'{column_order[_id]} {linear[_id]!r}\n') + ostream.write(f'{column_order[_id]} {linear[_id]!s}\n') # Generate the return information eliminated_vars = [ @@ -1657,12 +1671,13 @@ def _categorize_vars(self, comp_list, linear_by_comp): expr_info.linear = dict.fromkeys(nonlinear_vars, 0) all_nonlinear_vars.update(nonlinear_vars) - # Update the count of components that each variable appears in - for v in expr_info.linear: - if v in nnz_by_var: - nnz_by_var[v] += 1 - else: - nnz_by_var[v] = 1 + if expr_info.linear: + # Update the count of components that each variable appears in + for v in expr_info.linear: + if v in nnz_by_var: + nnz_by_var[v] += 1 + else: + nnz_by_var[v] = 1 # Record all nonzero variable ids for this component linear_by_comp[id(comp_info[0])] = expr_info.linear # Linear models (or objectives) are common. Avoid the set @@ -1702,7 +1717,9 @@ def _count_subexpression_occurrences(self): n_subexpressions[0] += 1 return n_subexpressions - def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): + def _linear_presolve( + self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds, var_values + ): eliminated_vars = {} eliminated_cons = set() if not self.config.linear_presolve: @@ -1723,6 +1740,7 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): var_map = self.var_map substitutions_by_linear_var = defaultdict(set) template = self.template + nl_map = self.var_id_to_nl_map one_var = lcon_by_linear_nnz[1] two_var = lcon_by_linear_nnz[2] while 1: @@ -1731,7 +1749,8 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): a = x = None b, _ = var_bounds[_id] logger.debug("NL presolve: bounds fixed %s := %s", var_map[_id], b) - eliminated_vars[_id] = AMPLRepn(b, {}, None) + eliminated_vars[_id] = self.visitor.Result(b, {}, None) + nl_map[_id] = template.const % b elif one_var: con_id, info = one_var.popitem() expr_info, lb = info @@ -1741,6 +1760,7 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): b = expr_info.const = (lb - expr_info.const) / coef logger.debug("NL presolve: substituting %s := %s", var_map[_id], b) eliminated_vars[_id] = expr_info + nl_map[_id] = template.const % b lb, ub = var_bounds[_id] if (lb is not None and lb - b > TOL) or ( ub is not None and ub - b < -TOL @@ -1760,7 +1780,7 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): id2_isdiscrete = var_map[id2].domain.isdiscrete() if var_map[_id].domain.isdiscrete() ^ id2_isdiscrete: # if only one variable is discrete, then we need to - # substiitute out the other + # substitute out the other if id2_isdiscrete: _id, id2 = id2, _id coef, coef2 = coef2, coef @@ -1775,7 +1795,7 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): ): _id, id2 = id2, _id coef, coef2 = coef2, coef - # substituting _id with a*x + b + # eliminating _id and replacing it with a*x + b a = -coef2 / coef x = id2 b = expr_info.const = (lb - expr_info.const) / coef @@ -1805,9 +1825,28 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): var_bounds[x] = x_lb, x_ub if x_lb == x_ub and x_lb is not None: fixed_vars.append(x) + # Given that we are eliminating a variable, we want to + # attempt to sanely resolve the initial variable values. + y_init = var_values[_id] + if y_init is not None: + # Y has a value + x_init = var_values[x] + if x_init is None: + # X does not; just use the one calculated from Y + x_init = (y_init - b) / a + else: + # X does too, use the average of the two values + x_init = (x_init + (y_init - b) / a) / 2.0 + # Ensure that the initial value respects the + # tightened bounds + if x_ub is not None and x_init > x_ub: + x_init = x_ub + if x_lb is not None and x_init < x_lb: + x_init = x_lb + var_values[x] = x_init eliminated_cons.add(con_id) else: - return eliminated_cons, eliminated_vars + break for con_id, expr_info in comp_by_linear_var[_id]: # Note that if we were aggregating (i.e., _id was # from two_var), then one of these info's will be @@ -1820,10 +1859,15 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): # appropriately (that expr_info is persisting in the # eliminated_vars dict - and we will use that to # update other linear expressions later.) + old_nnz = len(expr_info.linear) c = expr_info.linear.pop(_id, 0) + nnz = old_nnz - 1 expr_info.const += c * b if x in expr_info.linear: expr_info.linear[x] += c * a + if expr_info.linear[x] == 0: + nnz -= 1 + coef = expr_info.linear.pop(x) elif a: expr_info.linear[x] = c * a # replacing _id with x... NNZ is not changing, @@ -1831,10 +1875,17 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): # this constraint comp_by_linear_var[x].append((con_id, expr_info)) continue - # NNZ has been reduced by 1 - nnz = len(expr_info.linear) - _old = lcon_by_linear_nnz[nnz + 1] + _old = lcon_by_linear_nnz[old_nnz] if con_id in _old: + if not nnz: + if abs(expr_info.const) > TOL: + # constraint is trivially infeasible + raise InfeasibleConstraintException( + "model contains a trivially infeasible constraint " + f"{expr_info.const} == {coef}*{var_map[x]}" + ) + # constraint is trivially feasible + eliminated_cons.add(con_id) lcon_by_linear_nnz[nnz][con_id] = _old.pop(con_id) # If variables were replaced by the variable that # we are currently eliminating, then we need to update @@ -1847,6 +1898,42 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds): expr_info.linear[x] += c * a elif a: expr_info.linear[x] = c * a + elif not expr_info.linear: + nl_map[resubst] = template.const % expr_info.const + + # Note: the ASL will (silently) produce incorrect answers if the + # nonlinear portion of a defined variable is a constant + # expression. This may not be the case if all the variables in + # the original nonlinear expression have been fixed. + for _id, (expr, info, sub) in self.subexpression_cache.items(): + if info.nonlinear: + nl, args = info.nonlinear + # Note: 'not args' skips string arguments + # Note: 'vid in nl_map' skips eliminated + # variables and defined variables reduced to constants + if not args or any(vid not in nl_map for vid in args): + continue + # Ideally, we would just evaluate the named expression. + # However, there might be a linear portion of the named + # expression that still has free variables, and there is no + # guarantee that the user actually initialized the + # variables. So, we will fall back on parsing the (now + # constant) nonlinear fragment and evaluating it. + info.nonlinear = None + info.const += evaluate_ampl_nl_expression( + nl % tuple(nl_map[i] for i in args), self.external_functions + ) + if not info.linear: + # This has resolved to a constant: the ASL will fail for + # defined variables containing ONLY a constant. We + # need to substitute the constant directly into the + # original constraint/objective expression(s) + info.linear = {} + self.used_named_expressions.discard(_id) + nl_map[_id] = template.const % info.const + self.subexpression_cache[_id] = (expr, info, [None, None, True]) + + return eliminated_cons, eliminated_vars def _record_named_expression_usage(self, named_exprs, src, comp_type): self.used_named_expressions.update(named_exprs) @@ -1858,6 +1945,16 @@ def _record_named_expression_usage(self, named_exprs, src, comp_type): elif info[comp_type] != src: info[comp_type] = 0 + def _resolve_subexpression_args(self, nl, args): + final_args = [] + for arg in args: + if arg in self.var_id_to_nl_map: + final_args.append(self.var_id_to_nl_map[arg]) + else: + _nl, _ids, _ = self.subexpression_cache[arg][1].compile_repn() + final_args.append(self._resolve_subexpression_args(_nl, _ids)) + return nl % tuple(final_args) + def _write_nl_expression(self, repn, include_const): # Note that repn.mult should always be 1 (the AMPLRepn was # compiled before this point). Omitting the assertion for @@ -1872,7 +1969,13 @@ def _write_nl_expression(self, repn, include_const): # Add the constant to the NL expression. AMPL adds the # constant as the second argument, so we will too. nl = self.template.binary_sum + nl + self.template.const % repn.const - self.ostream.write(nl % tuple(map(self.var_id_to_nl.__getitem__, args))) + try: + self.ostream.write( + nl % tuple(map(self.var_id_to_nl_map.__getitem__, args)) + ) + except KeyError: + self.ostream.write(self._resolve_subexpression_args(nl, args)) + elif include_const: self.ostream.write(self.template.const % repn.const) else: @@ -1886,7 +1989,7 @@ def _write_v_line(self, expr_id, k): lbl = '\t#%s' % info[0].name else: lbl = '' - self.var_id_to_nl[expr_id] = f"v{self.next_V_line_id}{lbl}" + self.var_id_to_nl_map[expr_id] = f"v{self.next_V_line_id}{lbl}\n" # Do NOT write out 0 coefficients here: doing so fouls up the # ASL's logic for calculating derivatives, leading to 'nan' in # the Hessian results. @@ -1894,1071 +1997,6 @@ def _write_v_line(self, expr_id, k): # ostream.write(f'V{self.next_V_line_id} {len(linear)} {k}{lbl}\n') for _id in sorted(linear, key=column_order.__getitem__): - ostream.write(f'{column_order[_id]} {linear[_id]!r}\n') + ostream.write(f'{column_order[_id]} {linear[_id]!s}\n') self._write_nl_expression(info[1], True) self.next_V_line_id += 1 - - -class NLFragment(object): - """This is a mock "component" for the nl portion of a named Expression. - - It is used internally in the writer when requesting symbolic solver - labels so that we can generate meaningful names for the nonlinear - portion of an Expression component. - - """ - - __slots__ = ('_repn', '_node') - - def __init__(self, repn, node): - self._repn = repn - self._node = node - - @property - def name(self): - return 'nl(' + self._node.name + ')' - - -class AMPLRepn(object): - __slots__ = ('nl', 'mult', 'const', 'linear', 'nonlinear', 'named_exprs') - - ActiveVisitor = None - - def __init__(self, const, linear, nonlinear): - self.nl = None - self.mult = 1 - self.const = const - self.linear = linear - if nonlinear is None: - self.nonlinear = self.named_exprs = None - else: - nl, nl_args, self.named_exprs = nonlinear - self.nonlinear = nl, nl_args - - def __str__(self): - return ( - f'AMPLRepn(mult={self.mult}, const={self.const}, ' - f'linear={self.linear}, nonlinear={self.nonlinear}, ' - f'nl={self.nl}, named_exprs={self.named_exprs})' - ) - - def __repr__(self): - return str(self) - - def __eq__(self, other): - return other.__class__ is AMPLRepn and ( - self.nl == other.nl - and self.mult == other.mult - and self.const == other.const - and self.linear == other.linear - and self.nonlinear == other.nonlinear - and self.named_exprs == other.named_exprs - ) - - def __hash__(self): - # Approximation of the Python default object hash - # (4 LSB are rolled to the MSB to reduce hash collisions) - return id(self) // 16 + ( - (id(self) & 15) << 8 * ctypes.sizeof(ctypes.c_void_p) - 4 - ) - - def duplicate(self): - ans = self.__class__.__new__(self.__class__) - ans.nl = self.nl - ans.mult = self.mult - ans.const = self.const - ans.linear = None if self.linear is None else dict(self.linear) - ans.nonlinear = self.nonlinear - ans.named_exprs = self.named_exprs - return ans - - def compile_repn(self, visitor, prefix='', args=None, named_exprs=None): - template = visitor.template - if self.mult != 1: - if self.mult == -1: - prefix += template.negation - else: - prefix += template.multiplier % self.mult - self.mult = 1 - if self.named_exprs is not None: - if named_exprs is None: - named_exprs = set(self.named_exprs) - else: - named_exprs.update(self.named_exprs) - if self.nl is not None: - # This handles both named subexpressions and embedded - # non-numeric (e.g., string) arguments. - nl, nl_args = self.nl - if prefix: - nl = prefix + nl - if args is not None: - assert args is not nl_args - args.extend(nl_args) - else: - args = list(nl_args) - if nl_args: - # For string arguments, nl_args is an empty tuple and - # self.named_exprs is None. For named subexpressions, - # we are guaranteed that named_exprs is NOT None. We - # need to ensure that the named subexpression that we - # are returning is added to the named_exprs set. - named_exprs.update(nl_args) - return nl, args, named_exprs - - if args is None: - args = [] - if self.linear: - nterms = -len(args) - _v_template = template.var - _m_template = template.monomial - # Because we are compiling this expression (into a NL - # expression), we will go ahead and filter the 0*x terms - # from the expression. Note that the args are accumulated - # by side-effect, which prevents iterating over the linear - # terms twice. - nl_sum = ''.join( - args.append(v) or (_v_template if c == 1 else _m_template % c) - for v, c in self.linear.items() - if c - ) - nterms += len(args) - else: - nterms = 0 - nl_sum = '' - if self.nonlinear: - if self.nonlinear.__class__ is list: - nterms += len(self.nonlinear) - nl_sum += ''.join(map(itemgetter(0), self.nonlinear)) - deque(map(args.extend, map(itemgetter(1), self.nonlinear)), maxlen=0) - else: - nterms += 1 - nl_sum += self.nonlinear[0] - args.extend(self.nonlinear[1]) - if self.const: - nterms += 1 - nl_sum += template.const % self.const - - if nterms > 2: - return (prefix + (template.nary_sum % nterms) + nl_sum, args, named_exprs) - elif nterms == 2: - return prefix + template.binary_sum + nl_sum, args, named_exprs - elif nterms == 1: - return prefix + nl_sum, args, named_exprs - else: # nterms == 0 - return prefix + (template.const % 0), args, named_exprs - - def compile_nonlinear_fragment(self, visitor): - if not self.nonlinear: - self.nonlinear = None - return - args = [] - nterms = len(self.nonlinear) - nl_sum = ''.join(map(itemgetter(0), self.nonlinear)) - deque(map(args.extend, map(itemgetter(1), self.nonlinear)), maxlen=0) - - if nterms > 2: - self.nonlinear = (visitor.template.nary_sum % nterms) + nl_sum, args - elif nterms == 2: - self.nonlinear = visitor.template.binary_sum + nl_sum, args - else: # nterms == 1: - self.nonlinear = nl_sum, args - - def append(self, other): - """Append a child result from acceptChildResult - - Notes - ----- - This method assumes that the operator was "+". It is implemented - so that we can directly use an AMPLRepn() as a data object in - the expression walker (thereby avoiding the function call for a - custom callback) - - """ - # Note that self.mult will always be 1 (we only call append() - # within a sum, so there is no opportunity for self.mult to - # change). Omitting the assertion for efficiency. - # assert self.mult == 1 - _type = other[0] - if _type is _MONOMIAL: - _, v, c = other - if v in self.linear: - self.linear[v] += c - else: - self.linear[v] = c - elif _type is _GENERAL: - _, other = other - if other.nl is not None and other.nl[1]: - if other.linear: - # This is a named expression with both a linear and - # nonlinear component. We want to merge it with - # this AMPLRepn, preserving the named expression for - # only the nonlinear component (merging the linear - # component with this AMPLRepn). - pass - else: - # This is a nonlinear-only named expression, - # possibly with a multiplier that is not 1. Compile - # it and append it (this both resolves the - # multiplier, and marks the named expression as - # having been used) - other = other.compile_repn( - self.ActiveVisitor, '', None, self.named_exprs - ) - nl, nl_args, self.named_exprs = other - self.nonlinear.append((nl, nl_args)) - return - if other.named_exprs is not None: - if self.named_exprs is None: - self.named_exprs = set(other.named_exprs) - else: - self.named_exprs.update(other.named_exprs) - if other.mult != 1: - mult = other.mult - self.const += mult * other.const - if other.linear: - linear = self.linear - for v, c in other.linear.items(): - if v in linear: - linear[v] += c * mult - else: - linear[v] = c * mult - if other.nonlinear: - if other.nonlinear.__class__ is list: - other.compile_nonlinear_fragment(self.ActiveVisitor) - if mult == -1: - prefix = self.ActiveVisitor.template.negation - else: - prefix = self.ActiveVisitor.template.multiplier % mult - self.nonlinear.append( - (prefix + other.nonlinear[0], other.nonlinear[1]) - ) - else: - self.const += other.const - if other.linear: - linear = self.linear - for v, c in other.linear.items(): - if v in linear: - linear[v] += c - else: - linear[v] = c - if other.nonlinear: - if other.nonlinear.__class__ is list: - self.nonlinear.extend(other.nonlinear) - else: - self.nonlinear.append(other.nonlinear) - elif _type is _CONSTANT: - self.const += other[1] - - def to_expr(self, var_map): - if self.nl is not None or self.nonlinear is not None: - # TODO: support converting general nonlinear expressiosn - # back to Pyomo expressions. This will require an AMPL - # parser. - raise MouseTrap("Cannot convert nonlinear AMPLRepn to Pyomo Expression") - if self.linear: - # Explicitly generate the LinearExpression. At time of - # writing, this is about 40% faster than standard operator - # overloading for O(1000) element sums - ans = LinearExpression( - [coef * var_map[vid] for vid, coef in self.linear.items()] - ) - ans += self.const - else: - ans = self.const - return ans * self.mult - - -def _create_strict_inequality_map(vars_): - vars_['strict_inequality_map'] = { - True: vars_['less_than'], - False: vars_['less_equal'], - (True, True): (vars_['less_than'], vars_['less_than']), - (True, False): (vars_['less_than'], vars_['less_equal']), - (False, True): (vars_['less_equal'], vars_['less_than']), - (False, False): (vars_['less_equal'], vars_['less_equal']), - } - - -class text_nl_debug_template(object): - unary = { - 'log': 'o43\t#log\n', - 'log10': 'o42\t#log10\n', - 'sin': 'o41\t#sin\n', - 'cos': 'o46\t#cos\n', - 'tan': 'o38\t#tan\n', - 'sinh': 'o40\t#sinh\n', - 'cosh': 'o45\t#cosh\n', - 'tanh': 'o37\t#tanh\n', - 'asin': 'o51\t#asin\n', - 'acos': 'o53\t#acos\n', - 'atan': 'o49\t#atan\n', - 'exp': 'o44\t#exp\n', - 'sqrt': 'o39\t#sqrt\n', - 'asinh': 'o50\t#asinh\n', - 'acosh': 'o52\t#acosh\n', - 'atanh': 'o47\t#atanh\n', - 'ceil': 'o14\t#ceil\n', - 'floor': 'o13\t#floor\n', - } - - binary_sum = 'o0\t#+\n' - product = 'o2\t#*\n' - division = 'o3\t# /\n' - pow = 'o5\t#^\n' - abs = 'o15\t# abs\n' - negation = 'o16\t#-\n' - nary_sum = 'o54\t# sumlist\n%d\t# (n)\n' - exprif = 'o35\t# if\n' - and_expr = 'o21\t# and\n' - less_than = 'o22\t# lt\n' - less_equal = 'o23\t# le\n' - equality = 'o24\t# eq\n' - external_fcn = 'f%d %d%s\n' - var = '%s\n' # NOTE: to support scaling, we do NOT include the 'v' here - const = 'n%r\n' - string = 'h%d:%s\n' - monomial = product + const + var.replace('%', '%%') - multiplier = product + const - - _create_strict_inequality_map(vars()) - - -def _strip_template_comments(vars_, base_): - vars_['unary'] = {k: v[: v.find('\t#')] + '\n' for k, v in base_.unary.items()} - for k, v in base_.__dict__.items(): - if type(v) is str and '\t#' in v: - v_lines = v.split('\n') - for i, l in enumerate(v_lines): - comment_start = l.find('\t#') - if comment_start >= 0: - v_lines[i] = l[:comment_start] - vars_[k] = '\n'.join(v_lines) - - -# The "standard" text mode template is the debugging template with the -# comments removed -class text_nl_template(text_nl_debug_template): - _strip_template_comments(vars(), text_nl_debug_template) - _create_strict_inequality_map(vars()) - - -def node_result_to_amplrepn(data): - if data[0] is _GENERAL: - return data[1] - elif data[0] is _MONOMIAL: - _, v, c = data - if c: - return AMPLRepn(0, {v: c}, None) - else: - return AMPLRepn(0, None, None) - elif data[0] is _CONSTANT: - return AMPLRepn(data[1], None, None) - else: - raise DeveloperError("unknown result type") - - -def handle_negation_node(visitor, node, arg1): - if arg1[0] is _MONOMIAL: - return (_MONOMIAL, arg1[1], -1 * arg1[2]) - elif arg1[0] is _GENERAL: - arg1[1].mult *= -1 - return arg1 - elif arg1[0] is _CONSTANT: - return (_CONSTANT, -1 * arg1[1]) - else: - raise RuntimeError("%s: %s" % (type(arg1[0]), arg1)) - - -def handle_product_node(visitor, node, arg1, arg2): - if arg2[0] is _CONSTANT: - arg2, arg1 = arg1, arg2 - if arg1[0] is _CONSTANT: - mult = arg1[1] - if not mult: - # simplify multiplication by 0 (if arg2 is zero, the - # simplification happens when we evaluate the constant - # below). Note that this is not IEEE-754 compliant, and - # will map 0*inf and 0*nan to 0 (and not to nan). We are - # including this for backwards compatibility with the NLv1 - # writer, but arguably we should deprecate/remove this - # "feature" in the future. - if arg2[0] is _CONSTANT: - _prod = mult * arg2[1] - if _prod: - deprecation_warning( - f"Encountered {mult}*{str(arg2[1])} in expression tree. " - "Mapping the NaN result to 0 for compatibility " - "with the nl_v1 writer. In the future, this NaN " - "will be preserved/emitted to comply with IEEE-754.", - version='6.4.3', - ) - _prod = 0 - return (_CONSTANT, _prod) - return arg1 - if mult == 1: - return arg2 - elif arg2[0] is _MONOMIAL: - if mult != mult: - # This catches mult (i.e., arg1) == nan - return arg1 - return (_MONOMIAL, arg2[1], mult * arg2[2]) - elif arg2[0] is _GENERAL: - if mult != mult: - # This catches mult (i.e., arg1) == nan - return arg1 - arg2[1].mult *= mult - return arg2 - elif arg2[0] is _CONSTANT: - if not arg2[1]: - # Simplify multiplication by 0; see note above about - # IEEE-754 incompatibility. - _prod = mult * arg2[1] - if _prod: - deprecation_warning( - f"Encountered {str(mult)}*{arg2[1]} in expression tree. " - "Mapping the NaN result to 0 for compatibility " - "with the nl_v1 writer. In the future, this NaN " - "will be preserved/emitted to comply with IEEE-754.", - version='6.4.3', - ) - _prod = 0 - return (_CONSTANT, _prod) - return (_CONSTANT, mult * arg2[1]) - nonlin = node_result_to_amplrepn(arg1).compile_repn( - visitor, visitor.template.product - ) - nonlin = node_result_to_amplrepn(arg2).compile_repn(visitor, *nonlin) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_division_node(visitor, node, arg1, arg2): - if arg2[0] is _CONSTANT: - div = arg2[1] - if div == 1: - return arg1 - if arg1[0] is _MONOMIAL: - tmp = apply_node_operation(node, (arg1[2], div)) - if tmp != tmp: - # This catches if the coefficient division results in nan - return _CONSTANT, tmp - return (_MONOMIAL, arg1[1], tmp) - elif arg1[0] is _GENERAL: - tmp = apply_node_operation(node, (arg1[1].mult, div)) - if tmp != tmp: - # This catches if the multiplier division results in nan - return _CONSTANT, tmp - arg1[1].mult = tmp - return arg1 - elif arg1[0] is _CONSTANT: - return _CONSTANT, apply_node_operation(node, (arg1[1], div)) - elif arg1[0] is _CONSTANT and not arg1[1]: - return _CONSTANT, 0 - nonlin = node_result_to_amplrepn(arg1).compile_repn( - visitor, visitor.template.division - ) - nonlin = node_result_to_amplrepn(arg2).compile_repn(visitor, *nonlin) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_pow_node(visitor, node, arg1, arg2): - if arg2[0] is _CONSTANT: - if arg1[0] is _CONSTANT: - ans = apply_node_operation(node, (arg1[1], arg2[1])) - if ans.__class__ in native_complex_types: - ans = complex_number_error(ans, visitor, node) - return _CONSTANT, ans - elif not arg2[1]: - return _CONSTANT, 1 - elif arg2[1] == 1: - return arg1 - nonlin = node_result_to_amplrepn(arg1).compile_repn(visitor, visitor.template.pow) - nonlin = node_result_to_amplrepn(arg2).compile_repn(visitor, *nonlin) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_abs_node(visitor, node, arg1): - if arg1[0] is _CONSTANT: - return (_CONSTANT, abs(arg1[1])) - nonlin = node_result_to_amplrepn(arg1).compile_repn(visitor, visitor.template.abs) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_unary_node(visitor, node, arg1): - if arg1[0] is _CONSTANT: - return _CONSTANT, apply_node_operation(node, (arg1[1],)) - nonlin = node_result_to_amplrepn(arg1).compile_repn( - visitor, visitor.template.unary[node.name] - ) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_exprif_node(visitor, node, arg1, arg2, arg3): - if arg1[0] is _CONSTANT: - if arg1[1]: - return arg2 - else: - return arg3 - nonlin = node_result_to_amplrepn(arg1).compile_repn( - visitor, visitor.template.exprif - ) - nonlin = node_result_to_amplrepn(arg2).compile_repn(visitor, *nonlin) - nonlin = node_result_to_amplrepn(arg3).compile_repn(visitor, *nonlin) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_equality_node(visitor, node, arg1, arg2): - if arg1[0] is _CONSTANT and arg2[0] is _CONSTANT: - return (_CONSTANT, arg1[1] == arg2[1]) - nonlin = node_result_to_amplrepn(arg1).compile_repn( - visitor, visitor.template.equality - ) - nonlin = node_result_to_amplrepn(arg2).compile_repn(visitor, *nonlin) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_inequality_node(visitor, node, arg1, arg2): - if arg1[0] is _CONSTANT and arg2[0] is _CONSTANT: - return (_CONSTANT, node._apply_operation((arg1[1], arg2[1]))) - nonlin = node_result_to_amplrepn(arg1).compile_repn( - visitor, visitor.template.strict_inequality_map[node.strict] - ) - nonlin = node_result_to_amplrepn(arg2).compile_repn(visitor, *nonlin) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_ranged_inequality_node(visitor, node, arg1, arg2, arg3): - if arg1[0] is _CONSTANT and arg2[0] is _CONSTANT and arg3[0] is _CONSTANT: - return (_CONSTANT, node._apply_operation((arg1[1], arg2[1], arg3[1]))) - op = visitor.template.strict_inequality_map[node.strict] - nl, args, named = node_result_to_amplrepn(arg1).compile_repn( - visitor, visitor.template.and_expr + op[0] - ) - nl2, args2, named = node_result_to_amplrepn(arg2).compile_repn( - visitor, '', None, named - ) - nl += nl2 + op[1] + nl2 - args.extend(args2) - args.extend(args2) - nonlin = node_result_to_amplrepn(arg3).compile_repn(visitor, nl, args, named) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -def handle_named_expression_node(visitor, node, arg1): - _id = id(node) - # Note that while named subexpressions ('defined variables' in the - # ASL NL file vernacular) look like variables, they are not allowed - # to appear in the 'linear' portion of a constraint / objective - # definition. We will return this as a "var" template, but - # wrapped in the nonlinear portion of the expression tree. - repn = node_result_to_amplrepn(arg1) - - # A local copy of the expression source list. This will be updated - # later if the same Expression node is encountered in another - # expression tree. - # - # This is a 3-tuple [con_id, obj_id, substitute_expression]. If the - # expression is used by more than 1 constraint / objective, then the - # id is set to 0. If it is not used by any, then it is None. - # substitute_expression is a bool indicating if this named - # subexpression tree should be directly substituted into any - # expression tree that references this node (i.e., do NOT emit the V - # line). - expression_source = [None, None, False] - # Record this common expression - visitor.subexpression_cache[_id] = ( - # 0: the "component" that generated this expression ID - node, - # 1: the common subexpression (to be written out) - repn, - # 2: the source usage information for this subexpression: - # [(con_id, obj_id, substitute); see above] - expression_source, - ) - - if not visitor.use_named_exprs: - return _GENERAL, repn.duplicate() - - mult, repn.mult = repn.mult, 1 - if repn.named_exprs is None: - repn.named_exprs = set() - - # When converting this shared subexpression to a (nonlinear) - # node, we want to just reference this subexpression: - repn.nl = (visitor.template.var, (_id,)) - - if repn.nonlinear: - # As we will eventually need the compiled form of any nonlinear - # expression, we will go ahead and compile it here. We do not - # do the same for the linear component as we will only need the - # linear component compiled to a dict if we are emitting the - # original (linear + nonlinear) V line (which will not happen if - # the V line is part of a larger linear operator). - if repn.nonlinear.__class__ is list: - repn.compile_nonlinear_fragment(visitor) - - if repn.linear: - # If this expression has both linear and nonlinear - # components, we will follow the ASL convention and break - # the named subexpression into two named subexpressions: one - # that is only the nonlinear component and one that has the - # const/linear component (and references the first). This - # will allow us to propagate linear coefficients up from - # named subexpressions when appropriate. - sub_node = NLFragment(repn, node) - sub_id = id(sub_node) - sub_repn = AMPLRepn(0, None, None) - sub_repn.nonlinear = repn.nonlinear - sub_repn.nl = (visitor.template.var, (sub_id,)) - sub_repn.named_exprs = set(repn.named_exprs) - - repn.named_exprs.add(sub_id) - repn.nonlinear = sub_repn.nl - - # See above for the meaning of this source information - nl_info = list(expression_source) - visitor.subexpression_cache[sub_id] = (sub_node, sub_repn, nl_info) - # It is important that the NL subexpression comes before the - # main named expression: - visitor.subexpression_order.append(sub_id) - else: - nl_info = expression_source - else: - repn.nonlinear = None - if repn.linear: - if ( - not repn.const - and len(repn.linear) == 1 - and next(iter(repn.linear.values())) == 1 - ): - # This Expression holds only a variable (multiplied by - # 1). Do not emit this as a named variable and instead - # just inject the variable where this expression is - # used. - repn.nl = None - expression_source[2] = True - else: - # This Expression holds only a constant. Do not emit this - # as a named variable and instead just inject the constant - # where this expression is used. - repn.nl = None - expression_source[2] = True - - if mult != 1: - repn.const *= mult - if repn.linear: - _lin = repn.linear - for v in repn.linear: - _lin[v] *= mult - if repn.nonlinear: - if mult == -1: - prefix = visitor.template.negation - else: - prefix = visitor.template.multiplier % mult - repn.nonlinear = prefix + repn.nonlinear[0], repn.nonlinear[1] - - if expression_source[2]: - if repn.linear: - return (_MONOMIAL, next(iter(repn.linear)), 1) - else: - return (_CONSTANT, repn.const) - - # Defer recording this _id until after we know that this repn will - # not be directly substituted (and to ensure that the NL fragment is - # added to the order first). - visitor.subexpression_order.append(_id) - - return (_GENERAL, repn.duplicate()) - - -def handle_external_function_node(visitor, node, *args): - func = node._fcn._function - # There is a special case for external functions: these are the only - # expressions that can accept string arguments. As we currently pass - # these as 'precompiled' general NL fragments, the normal trap for - # constant subexpressions will miss constant external function calls - # that contain strings. We will catch that case here. - if all( - arg[0] is _CONSTANT or (arg[0] is _GENERAL and arg[1].nl and not arg[1].nl[1]) - for arg in args - ): - arg_list = [arg[1] if arg[0] is _CONSTANT else arg[1].const for arg in args] - return _CONSTANT, apply_node_operation(node, arg_list) - if func in visitor.external_functions: - if node._fcn._library != visitor.external_functions[func][1]._library: - raise RuntimeError( - "The same external function name (%s) is associated " - "with two different libraries (%s through %s, and %s " - "through %s). The ASL solver will fail to link " - "correctly." - % ( - func, - visitor.external_byFcn[func]._library, - visitor.external_byFcn[func]._library.name, - node._fcn._library, - node._fcn.name, - ) - ) - else: - visitor.external_functions[func] = (len(visitor.external_functions), node._fcn) - comment = f'\t#{node.local_name}' if visitor.symbolic_solver_labels else '' - nonlin = node_result_to_amplrepn(args[0]).compile_repn( - visitor, - visitor.template.external_fcn - % (visitor.external_functions[func][0], len(args), comment), - ) - for arg in args[1:]: - nonlin = node_result_to_amplrepn(arg).compile_repn(visitor, *nonlin) - return (_GENERAL, AMPLRepn(0, None, nonlin)) - - -_operator_handles = ExitNodeDispatcher( - { - NegationExpression: handle_negation_node, - ProductExpression: handle_product_node, - DivisionExpression: handle_division_node, - PowExpression: handle_pow_node, - AbsExpression: handle_abs_node, - UnaryFunctionExpression: handle_unary_node, - Expr_ifExpression: handle_exprif_node, - EqualityExpression: handle_equality_node, - InequalityExpression: handle_inequality_node, - RangedExpression: handle_ranged_inequality_node, - Expression: handle_named_expression_node, - ExternalFunctionExpression: handle_external_function_node, - # These are handled explicitly in beforeChild(): - # LinearExpression: handle_linear_expression, - # SumExpression: handle_sum_expression, - # - # Note: MonomialTermExpression is only hit when processing NPV - # subexpressions that raise errors (e.g., log(0) * m.x), so no - # special processing is needed [it is just a product expression] - MonomialTermExpression: handle_product_node, - } -) - - -class AMPLBeforeChildDispatcher(BeforeChildDispatcher): - __slots__ = () - - def __init__(self): - # Special linear / summation expressions - self[MonomialTermExpression] = self._before_monomial - self[LinearExpression] = self._before_linear - self[SumExpression] = self._before_general_expression - - @staticmethod - def _record_var(visitor, var): - # We always add all indices to the var_map at once so that - # we can honor deterministic ordering of unordered sets - # (because the user could have iterated over an unordered - # set when constructing an expression, thereby altering the - # order in which we would see the variables) - vm = visitor.var_map - try: - _iter = var.parent_component().values(visitor.sorter) - except AttributeError: - # Note that this only works for the AML, as kernel does not - # provide a parent_component() - _iter = (var,) - for v in _iter: - if v.fixed: - continue - vm[id(v)] = v - - @staticmethod - def _before_string(visitor, child): - visitor.encountered_string_arguments = True - ans = AMPLRepn(child, None, None) - ans.nl = (visitor.template.string % (len(child), child), ()) - return False, (_GENERAL, ans) - - @staticmethod - def _before_var(visitor, child): - _id = id(child) - if _id not in visitor.var_map: - if child.fixed: - if _id not in visitor.fixed_vars: - visitor.cache_fixed_var(_id, child) - return False, (_CONSTANT, visitor.fixed_vars[_id]) - _before_child_handlers._record_var(visitor, child) - return False, (_MONOMIAL, _id, 1) - - @staticmethod - def _before_monomial(visitor, child): - # - # The following are performance optimizations for common - # situations (Monomial terms and Linear expressions) - # - arg1, arg2 = child._args_ - if arg1.__class__ not in native_types: - try: - arg1 = visitor.check_constant(visitor.evaluate(arg1), arg1) - except (ValueError, ArithmeticError): - return True, None - - # Trap multiplication by 0 and nan. - if not arg1: - if arg2.fixed: - _id = id(arg2) - if _id not in visitor.fixed_vars: - visitor.cache_fixed_var(id(arg2), arg2) - arg2 = visitor.fixed_vars[_id] - if arg2 != arg2: - deprecation_warning( - f"Encountered {arg1}*{arg2} in expression tree. " - "Mapping the NaN result to 0 for compatibility " - "with the nl_v1 writer. In the future, this NaN " - "will be preserved/emitted to comply with IEEE-754.", - version='6.4.3', - ) - return False, (_CONSTANT, arg1) - - _id = id(arg2) - if _id not in visitor.var_map: - if arg2.fixed: - if _id not in visitor.fixed_vars: - visitor.cache_fixed_var(_id, arg2) - return False, (_CONSTANT, arg1 * visitor.fixed_vars[_id]) - _before_child_handlers._record_var(visitor, arg2) - return False, (_MONOMIAL, _id, arg1) - - @staticmethod - def _before_linear(visitor, child): - # Because we are going to modify the LinearExpression in this - # walker, we need to make a copy of the arg list from the original - # expression tree. - var_map = visitor.var_map - const = 0 - linear = {} - for arg in child.args: - if arg.__class__ is MonomialTermExpression: - arg1, arg2 = arg._args_ - if arg1.__class__ not in native_types: - try: - arg1 = visitor.check_constant(visitor.evaluate(arg1), arg1) - except (ValueError, ArithmeticError): - return True, None - - # Trap multiplication by 0 and nan. - if not arg1: - if arg2.fixed: - arg2 = visitor.check_constant(arg2.value, arg2) - if arg2 != arg2: - deprecation_warning( - f"Encountered {arg1}*{str(arg2.value)} in expression " - "tree. Mapping the NaN result to 0 for compatibility " - "with the nl_v1 writer. In the future, this NaN " - "will be preserved/emitted to comply with IEEE-754.", - version='6.4.3', - ) - continue - - _id = id(arg2) - if _id not in var_map: - if arg2.fixed: - if _id not in visitor.fixed_vars: - visitor.cache_fixed_var(_id, arg2) - const += arg1 * visitor.fixed_vars[_id] - continue - _before_child_handlers._record_var(visitor, arg2) - linear[_id] = arg1 - elif _id in linear: - linear[_id] += arg1 - else: - linear[_id] = arg1 - elif arg.__class__ in native_types: - const += arg - else: - try: - const += visitor.check_constant(visitor.evaluate(arg), arg) - except (ValueError, ArithmeticError): - return True, None - - if linear: - return False, (_GENERAL, AMPLRepn(const, linear, None)) - else: - return False, (_CONSTANT, const) - - @staticmethod - def _before_named_expression(visitor, child): - _id = id(child) - if _id in visitor.subexpression_cache: - obj, repn, info = visitor.subexpression_cache[_id] - if info[2]: - if repn.linear: - return False, (_MONOMIAL, next(iter(repn.linear)), 1) - else: - return False, (_CONSTANT, repn.const) - return False, (_GENERAL, repn.duplicate()) - else: - return True, None - - -_before_child_handlers = AMPLBeforeChildDispatcher() - - -class AMPLRepnVisitor(StreamBasedExpressionVisitor): - def __init__( - self, - template, - subexpression_cache, - subexpression_order, - external_functions, - var_map, - used_named_expressions, - symbolic_solver_labels, - use_named_exprs, - sorter, - ): - super().__init__() - self.template = template - self.subexpression_cache = subexpression_cache - self.subexpression_order = subexpression_order - self.external_functions = external_functions - self.active_expression_source = None - self.var_map = var_map - self.used_named_expressions = used_named_expressions - self.symbolic_solver_labels = symbolic_solver_labels - self.use_named_exprs = use_named_exprs - self.encountered_string_arguments = False - self.fixed_vars = {} - self._eval_expr_visitor = _EvaluationVisitor(True) - self.evaluate = self._eval_expr_visitor.dfs_postorder_stack - self.sorter = sorter - - def check_constant(self, ans, obj): - if ans.__class__ not in native_numeric_types: - # None can be returned from uninitialized Var/Param objects - if ans is None: - return InvalidNumber( - None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans.__class__ is InvalidNumber: - return ans - elif ans.__class__ in native_complex_types: - return complex_number_error(ans, self, obj) - else: - # It is possible to get other non-numeric types. Most - # common are bool and 1-element numpy.array(). We will - # attempt to convert the value to a float before - # proceeding. - # - # TODO: we should check bool and warn/error (while bool is - # convertible to float in Python, they have very - # different semantic meanings in Pyomo). - try: - ans = float(ans) - except: - return InvalidNumber( - ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans != ans: - return InvalidNumber( - nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - return ans - - def cache_fixed_var(self, _id, child): - val = self.check_constant(child.value, child) - lb, ub = child.bounds - if (lb is not None and lb - val > TOL) or (ub is not None and ub - val < -TOL): - raise InfeasibleConstraintException( - "model contains a trivially infeasible " - f"variable '{child.name}' (fixed value " - f"{val} outside bounds [{lb}, {ub}])." - ) - self.fixed_vars[_id] = self.check_constant(child.value, child) - - def initializeWalker(self, expr): - expr, src, src_idx, self.expression_scaling_factor = expr - self.active_expression_source = (src_idx, id(src)) - walk, result = self.beforeChild(None, expr, 0) - if not walk: - return False, self.finalizeResult(result) - return True, expr - - def beforeChild(self, node, child, child_idx): - return _before_child_handlers[child.__class__](self, child) - - def enterNode(self, node): - # SumExpression are potentially large nary operators. Directly - # populate the result - if node.__class__ in sum_like_expression_types: - data = AMPLRepn(0, {}, None) - data.nonlinear = [] - return node.args, data - else: - return node.args, [] - - def exitNode(self, node, data): - if data.__class__ is AMPLRepn: - # If the summation resulted in a constant, return the constant - if data.linear or data.nonlinear or data.nl: - return (_GENERAL, data) - else: - return (_CONSTANT, data.const) - # - # General expressions... - # - return _operator_handles[node.__class__](self, node, *data) - - def finalizeResult(self, result): - ans = node_result_to_amplrepn(result) - - # Multiply the expression by the scaling factor provided by the caller - ans.mult *= self.expression_scaling_factor - - # If this was a nonlinear named expression, and that expression - # has no linear portion, then we will directly use this as a - # named expression. We need to mark that the expression was - # used and return it as a simple nonlinear expression pointing - # to this named expression. In all other cases, we will return - # the processed representation (which will reference the - # nonlinear-only named subexpression - if it exists - but not - # this outer named expression). This prevents accidentally - # recharacterizing variables that only appear linearly as - # nonlinear variables. - if ans.nl is not None: - if not ans.nl[1]: - raise ValueError("Numeric expression resolved to a string constant") - # This *is* a named subexpression. If there is no linear - # component, then replace this expression with the named - # expression. The mult will be handled later. We know that - # the const is built into the nonlinear expression, because - # it cannot be changed "in place" (only through addition, - # which would have "cleared" the nl attribute) - if not ans.linear: - ans.named_exprs.update(ans.nl[1]) - ans.nonlinear = ans.nl - ans.const = 0 - else: - # This named expression has both a linear and a - # nonlinear component, and possibly a multiplier and - # constant. We will not include this named expression - # and instead will expose the components so that linear - # variables are not accidentally re-characterized as - # nonlinear. - pass - ans.nl = None - - if ans.nonlinear.__class__ is list: - ans.compile_nonlinear_fragment(self) - - if not ans.linear: - ans.linear = {} - if ans.mult != 1: - linear = ans.linear - mult, ans.mult = ans.mult, 1 - ans.const *= mult - if linear: - for k in linear: - linear[k] *= mult - if ans.nonlinear: - if mult == -1: - prefix = self.template.negation - else: - prefix = self.template.multiplier % mult - ans.nonlinear = prefix + ans.nonlinear[0], ans.nonlinear[1] - # - self.active_expression_source = None - return ans diff --git a/pyomo/repn/plugins/parameterized_standard_form.py b/pyomo/repn/plugins/parameterized_standard_form.py new file mode 100644 index 00000000000..94474172b16 --- /dev/null +++ b/pyomo/repn/plugins/parameterized_standard_form.py @@ -0,0 +1,302 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.config import ConfigValue, document_kwargs_from_configdict +from pyomo.common.dependencies import numpy as np +from pyomo.common.gc_manager import PauseGC +from pyomo.common.numeric_types import native_numeric_types +from pyomo.core import Var + +from pyomo.opt import WriterFactory +from pyomo.repn.parameterized_linear import ParameterizedLinearRepnVisitor +from pyomo.repn.plugins.standard_form import ( + LinearStandardFormInfo, + LinearStandardFormCompiler, + _LinearStandardFormCompiler_impl, +) +from pyomo.util.config_domains import ComponentDataSet + + +@WriterFactory.register( + 'compile_parameterized_standard_form', + 'Compile an LP to standard form (`min cTx s.t. Ax <= b`) treating some ' + 'variables as data (e.g., variables decided by the outer problem in a ' + 'bilevel optimization problem).', +) +class ParameterizedLinearStandardFormCompiler(LinearStandardFormCompiler): + r"""Compiler to convert a "Parameterized" LP to the matrix representation + of the standard form: + + .. math:: + + \min\ & c^Tx \\ + s.t.\ & Ax \le b + + by treating the variables specified in the ``wrt`` list as data + (constants). The resulting compiled representation is returned as + NumPy arrays and SciPy sparse matrices in a + :py:class:`LinearStandardFormInfo` . + + """ + + CONFIG = LinearStandardFormCompiler.CONFIG() + CONFIG.declare( + 'wrt', + ConfigValue( + default=None, + domain=ComponentDataSet(Var), + description="Vars to treat as data for the purposes of compiling " + "the standard form", + doc=""" + Optional list of Vars to be treated as data while compiling the + standard form. + + For example, if this is the standard form of an inner problem in a + multilevel optimization problem, then the outer problem's Vars would + be specified in this list since they are not variables from the + perspective of the inner problem. + """, + ), + ) + + @document_kwargs_from_configdict(CONFIG) + def write(self, model, ostream=None, **options): + r"""Convert a model to standard form treating the Vars specified in + ``wrt`` as data. + + Returns + ------- + LinearStandardFormInfo + + Parameters + ---------- + model: ConcreteModel + The concrete Pyomo model to write out. + + ostream: None + This is provided for API compatibility with other writers + and is ignored here. + + """ + config = self.config(options) + + # Pause the GC, as the walker that generates the compiled LP + # representation generates (and disposes of) a large number of + # small objects. + with PauseGC(): + return _ParameterizedLinearStandardFormCompiler_impl(config).write(model) + + +class _SparseMatrixBase(object): + def __init__(self, matrix_data, shape): + (data, indices, indptr) = matrix_data + (nrows, ncols) = shape + + self.data = np.array(data) + self.indices = np.array(indices, dtype=int) + self.indptr = np.array(indptr, dtype=int) + self.shape = (nrows, ncols) + + def __eq__(self, other): + return self.todense() == other + + +class _CSRMatrix(_SparseMatrixBase): + def __init__(self, matrix_data, shape): + super().__init__(matrix_data, shape) + if len(self.indptr) != self.shape[0] + 1: + raise ValueError( + "Shape specifies the number of rows as %s but the index " + "pointer has length %s. The index pointer must have length " + "nrows + 1: Check the 'shape' and 'matrix_data' arguments." + % (self.shape[0], len(self.indptr)) + ) + + def tocsc(self): + """Implements the same algorithm as scipy's csr_tocsc function from + sparsetools. + """ + csr_data = self.data + col_index = self.indices + row_index_ptr = self.indptr + nrows = self.shape[0] + + num_nonzeros = len(csr_data) + csc_data = np.empty(csr_data.shape[0], dtype=object) + row_index = np.empty(num_nonzeros, dtype=int) + # tally the nonzeros in each column + col_index_ptr = np.zeros(self.shape[1], dtype=int) + for i in col_index: + col_index_ptr[int(i)] += 1 + + # cumulative sum the tally to get the column index pointer + cum_sum = 0 + for i, tally in enumerate(col_index_ptr): + col_index_ptr[i] = cum_sum + cum_sum += tally + # We have now initialized the col_index_ptr to the *starting* position + # of each column in the data vector. Note that col_index_ptr is only + # num_cols long: we have ignored the last entry in the standard CSC + # col_index_ptr (the total number of nonzeros). This will get resolved + # below when we shift this vector by one position. + + # Now we are actually going to mess up what we just did while we + # construct the row index: We can imagine that col_index_ptr holds the + # position of the *next* nonzero in each column, so each time we move a + # data element into a column we will increment that col_index_ptr by + # one. This is beautiful because by "messing up" the col_index_pointer, + # we are just transforming the vector of *starting* indices for each + # column to a vector of *ending* indices (actually 1 past the last + # index) of each column. Thank you, scipy. + for row in range(nrows): + for j in range(row_index_ptr[row], row_index_ptr[row + 1]): + col = col_index[j] + dest = col_index_ptr[col] + row_index[dest] = row + # Note that the data changes order because now we are looking + # for nonzeros through the columns rather than through the rows. + csc_data[dest] = csr_data[j] + + col_index_ptr[col] += 1 + + # Fix the column index pointer by inserting 0 at the beginning. The + # col_index_ptr currently holds pointers to 1 past the last element of + # each column, which is really the starting index for the next + # column. Inserting the 0 (the starting index for the firsst column) + # shifts everything by one column, "converting" the vector to the + # starting indices of each column, and extending the vector length to + # num_cols + 1 (as is expected by the CSC matrix). + col_index_ptr = np.insert(col_index_ptr, 0, 0) + + return _CSCMatrix((csc_data, row_index, col_index_ptr), self.shape) + + def todense(self): + """Implements the algorithm from scipy's csr_todense function + in sparsetools. + """ + nrows = self.shape[0] + col_index = self.indices + row_index_ptr = self.indptr + data = self.data + + dense = np.zeros(self.shape, dtype=object) + + for row in range(nrows): + for j in range(row_index_ptr[row], row_index_ptr[row + 1]): + dense[row, col_index[j]] = data[j] + + return dense + + +class _CSCMatrix(_SparseMatrixBase): + def __init__(self, matrix_data, shape): + super().__init__(matrix_data, shape) + if len(self.indptr) != self.shape[1] + 1: + raise ValueError( + "Shape specifies the number of columns as %s but the index " + "pointer has length %s. The index pointer must have length " + "ncols + 1: Check the 'shape' and 'matrix_data' arguments." + % (self.shape[1], len(self.indptr)) + ) + + def todense(self): + """Implements the algorithm from scipy's csr_todense function + in sparsetools. + """ + ncols = self.shape[1] + row_index = self.indices + col_index_ptr = self.indptr + data = self.data + + dense = np.zeros(self.shape, dtype=object) + + for col in range(ncols): + for j in range(col_index_ptr[col], col_index_ptr[col + 1]): + dense[row_index[j], col] = data[j] + + return dense + + def sum_duplicates(self): + """Implements the algorithm from scipy's csr_sum_duplicates function + in sparsetools. + + Note that this only removes duplicates that are adjacent, so it will remove + all duplicates if the incoming CSC matrix has sorted indices. (In particular + this will be true if it was just converted from CSR). + """ + ncols = self.shape[1] + row_index = self.indices + col_index_ptr = self.indptr + data = self.data + + num_non_zeros = 0 + col_end = 0 + for i in range(ncols): + jj = col_end + col_end = col_index_ptr[i + 1] + while jj < col_end: + j = row_index[jj] + x = data[jj] + jj += 1 + while jj < col_end and row_index[jj] == j: + x += data[jj] + jj += 1 + row_index[num_non_zeros] = j + data[num_non_zeros] = x + num_non_zeros += 1 + col_index_ptr[i + 1] = num_non_zeros + + # [ESJ 11/11/24]: I'm not 100% sure how scipy handles this, but we need + # to remove the "extra" entries from the data and row_index arrays. + self.data = data[:num_non_zeros] + self.row_index = row_index[:num_non_zeros] + + def eliminate_zeros(self): + """Implements the algorithm from scipy's csr_eliminate_zeros function + in sparsetools. + """ + ncols = self.shape[1] + row_index = self.indices + col_index_ptr = self.indptr + data = self.data + + num_non_zeros = 0 + col_end = 0 + for i in range(ncols): + jj = col_end + col_end = col_index_ptr[i + 1] + while jj < col_end: + j = row_index[jj] + x = data[jj] + if x.__class__ not in native_numeric_types or x != 0: + row_index[num_non_zeros] = j + data[num_non_zeros] = x + num_non_zeros += 1 + jj += 1 + col_index_ptr[i + 1] = num_non_zeros + + +class _ParameterizedLinearStandardFormCompiler_impl(_LinearStandardFormCompiler_impl): + _csc_matrix = _CSCMatrix + _csr_matrix = _CSRMatrix + + def _get_visitor(self, subexpression_cache, var_recorder): + wrt = self.config.wrt + if wrt is None: + wrt = [] + return ParameterizedLinearRepnVisitor( + subexpression_cache, wrt=wrt, var_recorder=var_recorder + ) + + def _to_vector(self, data, N, vector_type): + # override this to not attempt conversion to float since that will fail + # on the Pyomo expressions + return np.array([v for v in data]) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 239cd845930..0cc40e6093b 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,8 +10,9 @@ # ___________________________________________________________________________ import collections +import itertools +import operator import logging -from operator import attrgetter from pyomo.common.config import ( ConfigBlock, @@ -20,7 +21,9 @@ document_kwargs_from_configdict, ) from pyomo.common.dependencies import scipy, numpy as np +from pyomo.common.enums import ObjectiveSense from pyomo.common.gc_manager import PauseGC +from pyomo.common.numeric_types import native_types, value from pyomo.common.timing import TicTocTimer from pyomo.core.base import ( @@ -33,13 +36,15 @@ SortComponents, Suffix, SymbolMap, - maximize, ) from pyomo.opt import WriterFactory from pyomo.repn.linear import LinearRepnVisitor +from pyomo.repn.linear_template import LinearTemplateRepnVisitor +from pyomo.repn.quadratic import QuadraticRepnVisitor, QuadraticTemplateRepnVisitor from pyomo.repn.util import ( FileDeterminism, FileDeterminism_to_SortComponents, + TemplateVarRecorder, categorize_valid_components, initialize_var_map_from_column_order, ordered_active_constraints, @@ -52,7 +57,7 @@ logger = logging.getLogger(__name__) -RowEntry = collections.namedtuple('RowEntry', ['constraint', 'bound_type']) +RowEntry = collections.namedtuple("RowEntry", ["constraint", "bound_type"]) # TODO: make a proper base class @@ -61,124 +66,200 @@ class LinearStandardFormInfo(object): Attributes ---------- - c : scipy.sparse.csr_array + c : scipy.sparse.csc_array The objective coefficients. Note that this is a sparse array and may contain multiple rows (for multiobjective problems). The - objectives may be calculated by "c @ x" + objectives may be calculated by ``c @ x`` + + c_offset : numpy.ndarray + + The list of objective constant offsets A : scipy.sparse.csc_array The constraint coefficients. The constraint bodies may be - calculated by "A @ x" + calculated by ``A @ x`` rhs : numpy.ndarray The constraint right-hand sides. - rows : List[Tuple[_ConstraintData, int]] + rows : List[Tuple[ConstraintData, int]] The list of Pyomo constraint objects corresponding to the rows in `A`. Each element in the list is a 2-tuple of - (_ConstraintData, row_multiplier). The `row_multiplier` will be + (ConstraintData, row_multiplier). The `row_multiplier` will be +/- 1 indicating if the row was multiplied by -1 (corresponding to a constraint lower bound) or +1 (upper bound). - columns : List[_VarData] + columns : List[VarData] The list of Pyomo variable objects corresponding to columns in the `A` and `c` matrices. - eliminated_vars: List[Tuple[_VarData, NumericExpression]] + objectives : List[ObjectiveData] + + The list of Pyomo objective objects corresponding to the active objectives + + eliminated_vars: List[Tuple[VarData, NumericExpression]] The list of variables from the original model that do not appear in the standard form (usually because they were replaced by nonnegative variables). Each entry is a 2-tuple of - (:py:class:`_VarData`, :py:class`NumericExpression`|`float`). + (:py:class:`VarData`, :py:class`NumericExpression`|`float`). The list is in the necessary order for correct evaluation (i.e., all variables appearing in the expression must either have appeared in the standard form, or appear *earlier* in this list. """ - def __init__(self, c, A, rhs, rows, columns, eliminated_vars): + is_quadratic = False + + def __init__(self, c, c_offset, A, rhs, rows, columns, objectives, eliminated_vars): self.c = c + self.c_offset = c_offset self.A = A self.rhs = rhs self.rows = rows self.columns = columns + self.objectives = objectives self.eliminated_vars = eliminated_vars + logger.debug(f" c: {c.toarray()}") + logger.debug(f" c_offset: {c_offset}") + logger.debug(f" A: {A.toarray()}") + logger.debug(f" rhs: {rhs}") + logger.debug(f" rows: {rows}") + logger.debug(f" columns: {columns}") + logger.debug(f" objectives: {objectives}") + logger.debug(f" eliminated_vars: {eliminated_vars}") @property def x(self): + "Alias for :attr:`columns`" return self.columns @property def b(self): + "Alias for :attr:`rhs`" return self.rhs +class QuadraticStandardFormInfo(LinearStandardFormInfo): + """ + Extension of LinearStandardFormInfo with list of Q (quadratic) matrices + """ + + def __init__(self, *args, Q_list: list = None, Q_obj: list = None, **kwargs): + super().__init__(*args, **kwargs) + self.Q_list = Q_list + self.Q_obj = Q_obj + for i, q in enumerate(Q_obj): + logger.debug(f" Q objective[{i}]: {None if q is None else q.toarray()}") + for i, q in enumerate(Q_list): + logger.debug(f" Q constraint[{i}]: {None if q is None else q.toarray()}") + for Q in [Q_list, Q_obj]: + if isinstance(Q, list) and any([q is not None and q is not [] for q in Q]): + self.is_quadratic = True + + @WriterFactory.register( - 'compile_standard_form', 'Compile an LP to standard form (`min cTx s.t. Ax <= b`)' + "compile_standard_form", + r"Compile an LP to standard form (:math:`\min c^Tx s.t. Ax \le b)`", ) class LinearStandardFormCompiler(object): - CONFIG = ConfigBlock('compile_standard_form') + r"""Compiler to convert an LP to the matrix representation of the + standard form: + + .. math:: + + \min\ & c^Tx \\ + s.t.\ & Ax \le b + + and return the compiled representation as NumPy arrays and SciPy + sparse matrices. + + """ + + CONFIG = ConfigBlock("compile_standard_form") + + CONFIG.declare( + "nonnegative_vars", + ConfigValue( + default=False, + domain=bool, + description="Convert all variables to be nonnegative variables", + ), + ) CONFIG.declare( - 'nonnegative_vars', + "slack_form", ConfigValue( default=False, domain=bool, - description='Convert all variables to be nonnegative variables', + description="Add slack variables and return " + r":math:`\min c^Tx; s.t. Ax = b`", ), ) CONFIG.declare( - 'slack_form', + "mixed_form", ConfigValue( default=False, domain=bool, - description='Add slack variables and return `min cTx s.t. Ax == b`', + description="Return A in mixed form (the comparison operator is a " + "mix of <=, ==, and >=)", ), ) CONFIG.declare( - 'show_section_timing', + "set_sense", + ConfigValue( + default=ObjectiveSense.minimize, + domain=InEnum(ObjectiveSense), + description="If not None, map all objectives to the specified sense.", + ), + ) + CONFIG.declare( + "show_section_timing", ConfigValue( default=False, domain=bool, - description='Print timing after each stage of the compilation process', + description="Print timing after each stage of the compilation process", ), ) CONFIG.declare( - 'file_determinism', + "file_determinism", ConfigValue( default=FileDeterminism.ORDERED, domain=InEnum(FileDeterminism), - description='How much effort to ensure result is deterministic', + description="How much effort to ensure result is deterministic", doc=""" How much effort do we want to put into ensuring the resulting matrices are produced deterministically: - NONE (0) : None - ORDERED (10): rely on underlying component ordering (default) - SORT_INDICES (20) : sort keys of indexed components - SORT_SYMBOLS (30) : sort keys AND sort names (not declaration order) + + - ``NONE`` (0): None + - ``ORDERED`` (10): rely on underlying component ordering (default) + - ``SORT_INDICES`` (20) : sort keys of indexed components + - ``SORT_SYMBOLS`` (30) : sort keys AND sort names (not + declaration order) + """, ), ) CONFIG.declare( - 'row_order', + "row_order", ConfigValue( default=None, - description='Preferred constraint ordering', + description="Preferred constraint ordering", doc=""" List of constraints in the order that they should appear in - the resulting `A` matrix. Unspecified constraints will + the resulting ``A`` matrix. Unspecified constraints will appear at the end.""", ), ) CONFIG.declare( - 'column_order', + "column_order", ConfigValue( default=None, - description='Preferred variable ordering', + description="Preferred variable ordering", doc=""" List of variables in the order that they should appear in the compiled representation. Unspecified variables will be @@ -191,7 +272,7 @@ def __init__(self): @document_kwargs_from_configdict(CONFIG) def write(self, model, ostream=None, **options): - """Convert a model to standard form (`min cTx s.t. Ax <= b`) + """Convert a model to standard form Returns ------- @@ -202,7 +283,7 @@ def write(self, model, ostream=None, **options): model: ConcreteModel The concrete Pyomo model to write out. - ostream: None + ostream: This is provided for API compatibility with other writers and is ignored here. @@ -217,11 +298,25 @@ def write(self, model, ostream=None, **options): class _LinearStandardFormCompiler_impl(object): + # Making these methods class attributes so that others can change the hooks + # _get_visitor = LinearRepnVisitor + _get_visitor = QuadraticRepnVisitor + _to_vector = None + _csc_matrix = None + _csr_matrix = None + def __init__(self, config): self.config = config + # We defer the first instantiation of these attributes so we do + # not trigger the numpy / scipy imports when the module is + # imported + if _LinearStandardFormCompiler_impl._to_vector is None: + _LinearStandardFormCompiler_impl._to_vector = np.fromiter + _LinearStandardFormCompiler_impl._csc_matrix = scipy.sparse.csc_array + _LinearStandardFormCompiler_impl._csr_matrix = scipy.sparse.csr_array def write(self, model): - timing_logger = logging.getLogger('pyomo.common.timing.writer') + timing_logger = logging.getLogger("pyomo.common.timing.writer") timer = TicTocTimer(logger=timing_logger) with_debug_timing = ( timing_logger.isEnabledFor(logging.DEBUG) and timing_logger.hasHandlers() @@ -255,19 +350,28 @@ def write(self, model): % ( model.name, "\n\t".join( - "%s:\n\t\t%s" % (k, "\n\t\t".join(map(attrgetter('name'), v))) + "%s:\n\t\t%s" + % (k, "\n\t\t".join(map(operator.attrgetter("name"), v))) for k, v in unknown.items() ), ) ) + logger.debug(f"component_map: {component_map}") + self.var_map = var_map = {} initialize_var_map_from_column_order(model, self.config, var_map) - var_order = {_id: i for i, _id in enumerate(var_map)} + logger.debug(f"var_map: {var_map}") - visitor = LinearRepnVisitor({}, var_map, var_order, sorter) + var_recorder = TemplateVarRecorder(var_map, None, sorter) + visitor = self._get_visitor({}, var_recorder=var_recorder) + logger.debug(f"visitor = {type(visitor)}") - timer.toc('Initialized column order', level=logging.DEBUG) + # template_visitor = LinearTemplateRepnVisitor({}, var_recorder=var_recorder) + template_visitor = QuadraticTemplateRepnVisitor({}, var_recorder=var_recorder) + logger.debug(f"template_visitor = {type(template_visitor)}") + + timer.toc("Initialized column order", level=logging.DEBUG) # We don't export any suffix information to the Standard Form # @@ -286,7 +390,7 @@ def write(self, model): suffixesByName[name] = [suffix] for name, suffixes in suffixesByName.items(): n = len(suffixes) - plural = 's' if n > 1 else '' + plural = "s" if n > 1 else "" logger.warning( f"EXPORT Suffix '{name}' found on {n} block{plural}:\n " + "\n ".join(s.name for s in suffixes) @@ -296,90 +400,227 @@ def write(self, model): # # Process objective # - if not component_map[Objective]: - objectives = [Objective(expr=1)] - objectives[0].construct() - else: - objectives = [] - for blk in component_map[Objective]: - objectives.extend( - blk.component_data_objects( - Objective, active=True, descend_into=False, sort=sorter - ) + set_sense = self.config.set_sense + + objectives = [] + for blk in component_map[Objective]: + objectives.extend( + blk.component_data_objects( + Objective, active=True, descend_into=False, sort=sorter ) + ) + obj_nnz = 0 + obj_offset = [] obj_data = [] obj_index = [] obj_index_ptr = [0] - for i, obj in enumerate(objectives): - repn = visitor.walk_expression(obj.expr) - if repn.nonlinear is not None: - raise ValueError( - f"Model objective ({obj.name}) contains nonlinear terms that " - "cannot be compiled to standard (linear) form." + + obj_quadratic_nnz = 0 + obj_quadratic_data = [] + obj_quadratic_index = [] + obj_quadratic_index_ptr = [0] + + for _i, obj in enumerate(objectives): + logger.debug(f"OBJECTIVE: {_i}:{obj} {obj.sense} ({type(obj)})") + if hasattr(obj, "template_expr"): + expanded_expression_values = template_visitor.expand_expression( + obj, obj.template_expr() ) - N = len(repn.linear) - obj_data.append(np.fromiter(repn.linear.values(), float, N)) - if obj.sense == maximize: - obj_data[-1] *= -1 - obj_index.append( - np.fromiter(map(var_order.__getitem__, repn.linear), float, N) - ) + offset, linear_index, linear_data, _, _ = expanded_expression_values[:5] + + N = len(linear_index) + obj_index.append(linear_index) + obj_data.append(linear_data) + obj_offset.append(offset) + + if len(expanded_expression_values) > 5: + quadratic_index, quadratic_data = expanded_expression_values[5:] + else: + quadratic_index, quadratic_data = [], [] + N_quadratic = len(quadratic_data) + obj_quadratic_nnz += N_quadratic + obj_quadratic_data.append(quadratic_data) + if N_quadratic > 0: + obj_quadratic_index.append(quadratic_index) + obj_quadratic_index_ptr.append(obj_quadratic_nnz) + + else: + repn = visitor.walk_expression(obj.expr) + N = len(repn.linear) + obj_index.append(map(var_recorder.var_order.__getitem__, repn.linear)) + obj_data.append(repn.linear.values()) + obj_offset.append(repn.constant) + + if repn.nonlinear is not None: + raise ValueError( + f"Model objective ({obj.name}) contains nonlinear terms that " + "cannot be compiled to standard (linear) form." + ) + + obj_nnz += N + if set_sense is not None and set_sense != obj.sense: + obj_data[-1] = -self._to_vector(obj_data[-1], float, N) + obj_offset[-1] *= -1 obj_index_ptr.append(obj_index_ptr[-1] + N) if with_debug_timing: - timer.toc('Objective %s', obj, level=logging.DEBUG) + timer.toc("Objective %s", obj, level=logging.DEBUG) + + logger.debug("processed objectives\n") # # Tabulate constraints # slack_form = self.config.slack_form + mixed_form = self.config.mixed_form + + if slack_form and mixed_form: + raise ValueError("cannot specify both slack_form and mixed_form") rows = [] rhs = [] + con_nnz = 0 con_data = [] con_index = [] con_index_ptr = [0] + + con_quadratic_nnz = 0 + con_quadratic_data = [] + con_quadratic_index = [] + con_quadratic_index_ptr = [0] last_parent = None - for con in ordered_active_constraints(model, self.config): - if with_debug_timing and con.parent_component() is not last_parent: + for _i, con in enumerate(ordered_active_constraints(model, self.config)): + logger.debug(f"CONSTRAINT: {_i}:{con} ({type(con)})") + if with_debug_timing and con._component is not last_parent: if last_parent is not None: - timer.toc('Constraint %s', last_parent, level=logging.DEBUG) - last_parent = con.parent_component() - # Note: Constraint.lb/ub guarantee a return value that is - # either a (finite) native_numeric_type, or None - lb = con.lb - ub = con.ub + timer.toc("Constraint %s", last_parent(), level=logging.DEBUG) + last_parent = con._component + + if hasattr(con, "template_expr"): + expanded_expression_values = template_visitor.expand_expression( + con, con.template_expr() + ) + offset, linear_index, linear_data, lb, ub = expanded_expression_values[ + :5 + ] + + N = len(linear_data) + logger.debug( + f" N: {N}, offset: {offset}, linear_index: {linear_index}, linear_data: {linear_data}, " + f"lb: {lb}, ub: {ub}" + ) + if len(expanded_expression_values) > 5: + quadratic_index, quadratic_data = expanded_expression_values[5:] + else: + quadratic_index, quadratic_data = [], [] + N_quadratic = len(quadratic_data) + logger.debug( + f" N_quadratic: {N_quadratic}, quadratic_index: {quadratic_index}, quadratic_data: {quadratic_data}" + ) + con_quadratic_nnz += N_quadratic + con_quadratic_data.append(quadratic_data) + if N_quadratic > 0: + con_quadratic_index.append(quadratic_index) + con_quadratic_index_ptr.append(con_quadratic_nnz) - repn = visitor.walk_expression(con.body) + else: + # Note: lb and ub could be a number, expression, or None + lb, body, ub = con.to_bounded_expression() + if lb.__class__ not in native_types: + lb = value(lb) + if ub.__class__ not in native_types: + ub = value(ub) + repn = visitor.walk_expression(body) + + if repn.nonlinear is not None: + + raise ValueError( + f"Model constraint ({con.name}) contains nonlinear terms that " + "cannot be compiled to standard (linear) form." + ) + + N = len(repn.linear) + # Pull out the constant: we will move it to the bounds + offset = repn.constant + + linear_index = map(var_recorder.var_order.__getitem__, repn.linear) + linear_data = repn.linear.values() + logger.debug( + f" N: {N}, offset: {offset}, linear_index: {linear_index}, linear_data: {linear_data}" + ) + if getattr(repn, "quadratic", None): + N_quadratic = len(repn.quadratic) + con_quadratic_nnz += N_quadratic + quadratic_index = map( + lambda k: ( + var_recorder.var_order[k[0]], + var_recorder.var_order[k[1]], + ), + repn.quadratic, + ) + + quadratic_data = repn.quadratic.values() + logger.debug( + f" N_quadratic: {N_quadratic}, quadratic_index: {quadratic_index}, quadratic_data: {quadratic_data}" + ) + con_quadratic_data.append(quadratic_data) + con_quadratic_index.append(quadratic_index) + con_quadratic_index_ptr.append(con_quadratic_nnz) + else: + con_quadratic_data.append([]) + + N_quadratic = 0 if lb is None and ub is None: # Note: you *cannot* output trivial (unbounded) # constraints in matrix format. I suppose we could add a # slack variable, but that seems rather silly. continue - if repn.nonlinear is not None: - raise ValueError( - f"Model constraint ({con.name}) contains nonlinear terms that " - "cannot be compiled to standard (linear) form." - ) - # Pull out the constant: we will move it to the bounds - offset = repn.constant - repn.constant = 0 - - if not repn.linear: + if not N and not N_quadratic: + # This is a constant constraint + # TODO: add a (configurable) feasibility tolerance if (lb is None or lb <= offset) and (ub is None or ub >= offset): continue raise InfeasibleError( f"model contains a trivially infeasible constraint, '{con.name}'" ) - if slack_form: - _data = list(repn.linear.values()) - _index = list(map(var_order.__getitem__, repn.linear)) + if mixed_form: + logger.debug("mixed_form") + if lb == ub: + con_nnz += N + logger.debug(f"rows.append(RowEntry({con}, 0))\n") + rows.append(RowEntry(con, 0)) + rhs.append(ub - offset) + con_data.append(linear_data) + con_index.append(linear_index) + con_index_ptr.append(con_nnz) + else: + if ub is not None: + if lb is not None: + linear_index = list(linear_index) + con_nnz += N + logger.debug(f"rows.append(RowEntry({con}, +1))\n") + rows.append(RowEntry(con, 1)) + rhs.append(ub - offset) + con_data.append(linear_data) + con_index.append(linear_index) + con_index_ptr.append(con_nnz) + if lb is not None: + con_nnz += N + logger.debug(f"rows.append(RowEntry({con}, -1))\n") + rows.append(RowEntry(con, -1)) + rhs.append(lb - offset) + con_data.append(linear_data) + con_index.append(linear_index) + con_index_ptr.append(con_nnz) + elif slack_form: + logger.debug("slack_form") if lb == ub: # TODO: add tolerance? rhs.append(ub - offset) else: # add slack variable - v = Var(name=f'_slack_{len(rhs)}', bounds=(None, None)) + con_nnz += 1 + v = Var(name=f"_slack_{len(rhs)}", bounds=(None, None)) v.construct() if lb is None: rhs.append(ub - offset) @@ -390,119 +631,290 @@ def write(self, model): if ub is not None: v.lb = lb - ub var_map[id(v)] = v - var_order[id(v)] = slack_col = len(var_order) - _data.append(1) - _index.append(slack_col) + if var_recorder.var_order is not None: + var_recorder.var_order[id(v)] = slack_col = len( + var_recorder.var_order + ) + linear_data = list(linear_data) + linear_data.append(1) + linear_index = list(linear_index) + linear_index.append(slack_col) + con_nnz += N rows.append(RowEntry(con, 1)) - con_data.append(np.array(_data)) - con_index.append(np.array(_index)) - con_index_ptr.append(con_index_ptr[-1] + len(_index)) + con_data.append(linear_data) + con_index.append(linear_index) + con_index_ptr.append(con_nnz) else: - N = len(repn.linear) - _data = np.fromiter(repn.linear.values(), float, N) - _index = np.fromiter(map(var_order.__getitem__, repn.linear), float, N) + logger.info("not mixed or slack form") if ub is not None: + if lb is not None: + linear_index = list(linear_index) + con_nnz += N rows.append(RowEntry(con, 1)) rhs.append(ub - offset) - con_data.append(_data) - con_index.append(_index) - con_index_ptr.append(con_index_ptr[-1] + N) + con_data.append(linear_data) + con_index.append(linear_index) + con_index_ptr.append(con_nnz) if lb is not None: + con_nnz += N rows.append(RowEntry(con, -1)) rhs.append(offset - lb) - con_data.append(-_data) - con_index.append(_index) - con_index_ptr.append(con_index_ptr[-1] + N) + con_data.append(-np.array(list(linear_data))) + con_index.append(linear_index) + con_index_ptr.append(con_nnz) if with_debug_timing: # report the last constraint - timer.toc('Constraint %s', last_parent, level=logging.DEBUG) + timer.toc("Constraint %s", last_parent(), level=logging.DEBUG) + + logger.debug("completed constraints\n") # Get the variable list columns = list(var_map.values()) + n_cols = len(columns) + # Convert the compiled data to scipy sparse matrices - c = scipy.sparse.csr_array( - (np.concatenate(obj_data), np.concatenate(obj_index), obj_index_ptr), - [len(obj_index_ptr) - 1, len(columns)], - ).tocsc() - A = scipy.sparse.csr_array( - (np.concatenate(con_data), np.concatenate(con_index), con_index_ptr), - [len(rows), len(columns)], - ).tocsc() - - # Some variables in the var_map may not actually appear in the - # objective or constraints (e.g., added from col_order, or - # multiplied by 0 in the expressions). The easiest way to check - # for empty columns is to convert from CSR to CSC and then look - # at the index pointer list (an O(num_var) operation). - c_ip = c.indptr - A_ip = A.indptr - active_var_idx = list( - filter( - lambda i: A_ip[i] != A_ip[i + 1] or c_ip[i] != c_ip[i + 1], - range(len(columns)), - ) + logger.debug(f"converting objective: {obj_index}") + logger.debug( + f"objective.is_quadratic: {any([qd for qd in obj_quadratic_data])}" + ) + + c, Q_obj = self._create_csc_quadratic( + obj_data, + obj_index, + obj_index_ptr, + obj_nnz, + n_cols, + obj_quadratic_data, + obj_quadratic_index, + ) + + logger.debug( + f"Objective:\n c: {c.toarray()}\n " + f"Q: {[q if not hasattr(q, 'toarray') else q.toarray() for q in Q_obj]}\n " + f"offset: {obj_offset}" + ) + + logger.debug(f"converting constraints: {con_index}") + is_quadratic = any([qd for qd in con_quadratic_data]) or any( + [qd for qd in obj_quadratic_data] ) - nCol = len(active_var_idx) - if nCol != len(columns): - # Note that the indptr can't just use range() because a var - # may only appear in the objectives or the constraints. - columns = list(map(columns.__getitem__, active_var_idx)) - active_var_idx.append(c.indptr[-1]) - c = scipy.sparse.csc_array( - (c.data, c.indices, c.indptr.take(active_var_idx)), [c.shape[0], nCol] + + # if quadratic, create A (linear) and quadratic Q matrices + if is_quadratic: + A, Q_list = self._create_csc_quadratic( + con_data, + con_index, + con_index_ptr, + con_nnz, + n_cols, + con_quadratic_data, + con_quadratic_index, ) - active_var_idx[-1] = A.indptr[-1] - A = scipy.sparse.csc_array( - (A.data, A.indices, A.indptr.take(active_var_idx)), [A.shape[0], nCol] + if with_debug_timing: + timer.toc("Formed matrices", level=logging.DEBUG) + info = QuadraticStandardFormInfo( + c, + np.array(obj_offset), + A, + rhs, + rows, + columns, + objectives, + [], + Q_list=Q_list, + Q_obj=Q_obj, ) - if self.config.nonnegative_vars: - c, A, columns, eliminated_vars = _csc_to_nonnegative_vars(c, A, columns) - else: - eliminated_vars = [] + else: # linear: single A matrix + A = self._create_csc(con_data, con_index, con_index_ptr, con_nnz, n_cols) - info = LinearStandardFormInfo(c, A, rhs, rows, columns, eliminated_vars) - timer.toc("Generated linear standard form representation", delta=False) - return info + if with_debug_timing: + timer.toc("Formed matrices", level=logging.DEBUG) + + # Some variables in the var_map may not actually appear in the + # objective or constraints (e.g., added from col_order, or + # multiplied by 0 in the expressions). The easiest way to check + # for empty columns is to convert from CSR to CSC and then look + # at the index pointer list (an O(num_var) operation). + c_ip = c.indptr + A_ip = A.indptr + active_var_mask = (A_ip[1:] > A_ip[:-1]) | (c_ip[1:] > c_ip[:-1]) + + # Masks on NumPy arrays are very fast. Build the reduced A + # indptr and then check if we actually have to manipulate the + # columns + augmented_mask = np.concatenate((active_var_mask, [True])) + reduced_A_indptr = A.indptr[augmented_mask] + n_cols -= len(reduced_A_indptr) - 1 + if n_cols > 0: + columns = [v for k, v in zip(active_var_mask, columns) if k] + c = self._csc_matrix( + (c.data, c.indices, c.indptr[augmented_mask]), + [c.shape[0], len(columns)], + ) + # active_var_idx[-1] = len(columns) + A = self._csc_matrix( + (A.data, A.indices, reduced_A_indptr), [A.shape[0], len(columns)] + ) + if with_debug_timing: + timer.toc("Eliminated %s unused columns", n_cols, level=logging.DEBUG) -def _csc_to_nonnegative_vars(c, A, columns): - eliminated_vars = [] - new_columns = [] - new_c_data = [] - new_c_indices = [] - new_c_indptr = [0] - new_A_data = [] - new_A_indices = [] - new_A_indptr = [0] - for i, v in enumerate(columns): - lb, ub = v.bounds - if lb is None or lb < 0: - name = v.name - new_columns.append( - Var( - name=f'_neg_{i}', - domain=v.domain, - bounds=(0, None if lb is None else -lb), + if self.config.nonnegative_vars: + c, A, columns, eliminated_vars = self._csc_to_nonnegative_vars( + c, A, columns ) + else: + eliminated_vars = [] + + info = LinearStandardFormInfo( + c, + np.array(obj_offset), + A, + rhs, + rows, + columns, + objectives, + eliminated_vars, ) - new_columns[-1].construct() - s, e = A.indptr[i : i + 2] - new_A_data.append(-A.data[s:e]) - new_A_indices.append(A.indices[s:e]) - new_A_indptr.append(new_A_indptr[-1] + e - s) - s, e = c.indptr[i : i + 2] - new_c_data.append(-c.data[s:e]) - new_c_indices.append(c.indices[s:e]) - new_c_indptr.append(new_c_indptr[-1] + e - s) - if ub is None or ub > 0: - # Crosses 0; split into 2 vars + + timer.toc("Generated standard form representation", delta=False) + logger.debug(f"RETURNING: {type(info)} ") + return info + + def _create_csc( + self, + data, + index, + index_ptr, + nnz, + n_cols, + *, + sum_duplicates=True, + eliminate_zeros=True, + ): + logger.debug( + f"data={data}, index={[i for i in index]}, " + f"index_ptr={index_ptr}, nnz={nnz}, n_cols={n_cols}" + ) + data = self._to_vector(itertools.chain.from_iterable(data), np.float64, nnz) + index = self._to_vector(itertools.chain.from_iterable(index), np.int32, nnz) + if not nnz: + # The empty CSC has no (or few) rows and a large number of + # columns and no nonzeros: it is faster / easier to create + # the empty CSR on the python side and convert it to CSC on + # the C (numpy) side, as opposed to creating the large [0] * + # (n_cols + 1) array on the Python side and transfer it to C + # (numpy) + return self._csr_matrix( + (data, index, index_ptr), [len(index_ptr) - 1, n_cols] + ).tocsc() + + index_ptr = np.array(index_ptr, dtype=np.int32) + A = self._csr_matrix((data, index, index_ptr), [len(index_ptr) - 1, n_cols]) + A = A.tocsc() + if sum_duplicates: + A.sum_duplicates() + if eliminate_zeros: + A.eliminate_zeros() + return A + + # For quadratic programs, create A (linear) matrix and list of Q (quadratic) matrices + def _create_csc_quadratic( + self, + linear_data, + linear_index, + linear_indez_ptr, + linear_nnz, + n_cols, + quadratic_data, + quadratic_index, + ) -> tuple: + + # Create A (linear) matrix, but do not reduce; need to keep rows in order to correspond with + # Q matrices + A = self._create_csc( + linear_data, + linear_index, + linear_indez_ptr, + linear_nnz, + n_cols, + sum_duplicates=False, + eliminate_zeros=False, + ) + + qi = iter(quadratic_index) + Q_list = [] + for i, coefficients in enumerate(quadratic_data): + + if coefficients: + index = list(next(qi)) + row_ind, col_ind = zip(*index) + + Q = self._csr_matrix( + (np.array(list(coefficients)), (row_ind, col_ind)), + shape=[n_cols, n_cols], + ) + Q = Q.tocsc() + + else: + Q = None + + Q_list.append(Q) + + return A, Q_list + + def _csc_to_nonnegative_vars(self, c, A, columns): + eliminated_vars = [] + new_columns = [] + new_c_data = [] + new_c_indices = [] + new_c_indptr = [0] + new_A_data = [] + new_A_indices = [] + new_A_indptr = [0] + for i, v in enumerate(columns): + lb, ub = v.bounds + if lb is None or lb < 0: + name = v.name new_columns.append( - Var(name=f'_pos_{i}', domain=v.domain, bounds=(0, ub)) + Var( + name=f"_neg_{i}", + domain=v.domain, + bounds=(0, None if lb is None else -lb), + ) ) new_columns[-1].construct() s, e = A.indptr[i : i + 2] + new_A_data.append(-A.data[s:e]) + new_A_indices.append(A.indices[s:e]) + new_A_indptr.append(new_A_indptr[-1] + e - s) + s, e = c.indptr[i : i + 2] + new_c_data.append(-c.data[s:e]) + new_c_indices.append(c.indices[s:e]) + new_c_indptr.append(new_c_indptr[-1] + e - s) + if ub is None or ub > 0: + # Crosses 0; split into 2 vars + new_columns.append( + Var(name=f"_pos_{i}", domain=v.domain, bounds=(0, ub)) + ) + new_columns[-1].construct() + s, e = A.indptr[i : i + 2] + new_A_data.append(A.data[s:e]) + new_A_indices.append(A.indices[s:e]) + new_A_indptr.append(new_A_indptr[-1] + e - s) + s, e = c.indptr[i : i + 2] + new_c_data.append(c.data[s:e]) + new_c_indices.append(c.indices[s:e]) + new_c_indptr.append(new_c_indptr[-1] + e - s) + eliminated_vars.append((v, new_columns[-1] - new_columns[-2])) + else: + new_columns[-1].lb = -ub + eliminated_vars.append((v, -new_columns[-1])) + else: # lb >= 0 + new_columns.append(v) + s, e = A.indptr[i : i + 2] new_A_data.append(A.data[s:e]) new_A_indices.append(A.indices[s:e]) new_A_indptr.append(new_A_indptr[-1] + e - s) @@ -510,28 +922,14 @@ def _csc_to_nonnegative_vars(c, A, columns): new_c_data.append(c.data[s:e]) new_c_indices.append(c.indices[s:e]) new_c_indptr.append(new_c_indptr[-1] + e - s) - eliminated_vars.append((v, new_columns[-1] - new_columns[-2])) - else: - new_columns[-1].lb = -ub - eliminated_vars.append((v, -new_columns[-1])) - else: # lb >= 0 - new_columns.append(v) - s, e = A.indptr[i : i + 2] - new_A_data.append(A.data[s:e]) - new_A_indices.append(A.indices[s:e]) - new_A_indptr.append(new_A_indptr[-1] + e - s) - s, e = c.indptr[i : i + 2] - new_c_data.append(c.data[s:e]) - new_c_indices.append(c.indices[s:e]) - new_c_indptr.append(new_c_indptr[-1] + e - s) - - nCol = len(new_columns) - c = scipy.sparse.csc_array( - (np.concatenate(new_c_data), np.concatenate(new_c_indices), new_c_indptr), - [c.shape[0], nCol], - ) - A = scipy.sparse.csc_array( - (np.concatenate(new_A_data), np.concatenate(new_A_indices), new_A_indptr), - [A.shape[0], nCol], - ) - return c, A, new_columns, eliminated_vars + + n_cols = len(new_columns) + c = self._csc_matrix( + (np.concatenate(new_c_data), np.concatenate(new_c_indices), new_c_indptr), + [c.shape[0], n_cols], + ) + A = self._csc_matrix( + (np.concatenate(new_A_data), np.concatenate(new_A_indices), new_A_indptr), + [A.shape[0], n_cols], + ) + return c, A, new_columns, eliminated_vars diff --git a/pyomo/repn/quadratic.py b/pyomo/repn/quadratic.py index c538d1efc7f..d2f42930dfd 100644 --- a/pyomo/repn/quadratic.py +++ b/pyomo/repn/quadratic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -30,7 +30,18 @@ ) from pyomo.core.base.expression import Expression from . import linear +from . import linear_template +from . import util from .linear import _merge_dict, to_expression +import pyomo.core.expr as expr +from pyomo.core.expr import ExpressionType +from pyomo.common.numeric_types import native_types + +import logging + +code_type = copy.deepcopy.__class__ + +logger = logging.getLogger(__name__) _CONSTANT = linear.ExprType.CONSTANT _LINEAR = linear.ExprType.LINEAR @@ -45,7 +56,7 @@ def __init__(self): self.multiplier = 1 self.constant = 0 self.linear = {} - self.quadratic = None + self.quadratic = {} self.nonlinear = None def __str__(self): @@ -69,6 +80,7 @@ def walker_exitNode(self): return _CONSTANT, self.multiplier * self.constant def duplicate(self): + logger.debug(f"DUPLICATE: {self}") ans = self.__class__.__new__(self.__class__) ans.multiplier = self.multiplier ans.constant = self.constant @@ -82,6 +94,11 @@ def duplicate(self): def to_expression(self, visitor): var_map = visitor.var_map + logger.debug(f"var_map: {var_map}") + logger.debug( + f"self: linear={self.linear}, quadratic={self.quadratic}, nonlinear={self.nonlinear}" + ) + logger.debug(f"constant: {self.constant}, multiplier: {self.multiplier}") if self.nonlinear is not None: # We want to start with the nonlinear term (and use # assignment) in case the term is a non-numeric node (like a @@ -98,22 +115,15 @@ def to_expression(self, visitor): e += coef * (var_map[x1] * var_map[x2]) ans += e if self.linear: - if len(self.linear) == 1: - vid, coef = next(iter(self.linear.items())) - if coef == 1: - ans += var_map[vid] - elif coef: - ans += MonomialTermExpression((coef, var_map[vid])) - else: - pass - else: - ans += LinearExpression( - [ - MonomialTermExpression((coef, var_map[vid])) - for vid, coef in self.linear.items() - if coef - ] - ) + var_map = visitor.var_map + with mutable_expression() as e: + for vid, coef in self.linear.items(): + if coef: + e += coef * var_map[vid] + if e.nargs() > 1: + ans += e + elif e.nargs() == 1: + ans += e.arg(0) if self.constant: ans += self.constant if self.multiplier != 1: @@ -164,39 +174,23 @@ def append(self, other): self.nonlinear += nl -_exit_node_handlers = copy.deepcopy(linear._exit_node_handlers) - -# -# NEGATION -# -_exit_node_handlers[NegationExpression][(_QUADRATIC,)] = linear._handle_negation_ANY - - -# -# PRODUCT -# -def _mul_linear_linear(varOrder, linear1, linear2): +def _mul_linear_linear(visitor, linear1, linear2): + logger.debug(f"linear1: {linear1}, linear2: {linear2}") quadratic = {} for vid1, coef1 in linear1.items(): for vid2, coef2 in linear2.items(): - if varOrder(vid1) < varOrder(vid2): - key = vid1, vid2 - else: - key = vid2, vid1 - if key in quadratic: - quadratic[key] += coef1 * coef2 - else: - quadratic[key] = coef1 * coef2 + key = (min(vid1, vid2), max(vid2, vid1)) + quadratic[key] = quadratic.get(key, 0) + (coef1 * coef2) + return quadratic def _handle_product_linear_linear(visitor, node, arg1, arg2): + logger.debug(f"{node}, {arg1}, {arg2}") _, arg1 = arg1 _, arg2 = arg2 # Quadratic first, because we will update linear in a minute - arg1.quadratic = _mul_linear_linear( - visitor.var_order.__getitem__, arg1.linear, arg2.linear - ) + arg1.quadratic = _mul_linear_linear(visitor, arg1.linear, arg2.linear) # Linear second, as this relies on knowing the original constants if not arg2.constant: arg1.linear = {} @@ -252,7 +246,7 @@ def _handle_product_nonlinear(visitor, node, arg1, arg2): ans.quadratic = {k: c * coef for k, coef in x2.quadratic.items()} # [BB] if x1.linear and x2.linear: - quad = _mul_linear_linear(visitor.var_order.__getitem__, x1.linear, x2.linear) + quad = _mul_linear_linear(visitor, x1.linear, x2.linear) if ans.quadratic: _merge_dict(ans.quadratic, 1, quad) else: @@ -282,126 +276,416 @@ def _handle_product_nonlinear(visitor, node, arg1, arg2): return _GENERAL, ans -_exit_node_handlers[ProductExpression].update( - { - (_CONSTANT, _QUADRATIC): linear._handle_product_constant_ANY, - (_LINEAR, _QUADRATIC): _handle_product_nonlinear, - (_QUADRATIC, _QUADRATIC): _handle_product_nonlinear, - (_GENERAL, _QUADRATIC): _handle_product_nonlinear, - (_QUADRATIC, _CONSTANT): linear._handle_product_ANY_constant, - (_QUADRATIC, _LINEAR): _handle_product_nonlinear, - (_QUADRATIC, _GENERAL): _handle_product_nonlinear, - # Replace handler from the linear walker - (_LINEAR, _LINEAR): _handle_product_linear_linear, - (_GENERAL, _GENERAL): _handle_product_nonlinear, - (_GENERAL, _LINEAR): _handle_product_nonlinear, - (_LINEAR, _GENERAL): _handle_product_nonlinear, - } -) +def define_exit_node_handlers(_exit_node_handlers=None): + if _exit_node_handlers is None: + _exit_node_handlers = {} + linear.define_exit_node_handlers(_exit_node_handlers) + # + # NEGATION + # + _exit_node_handlers[NegationExpression][(_QUADRATIC,)] = linear._handle_negation_ANY + # + # PRODUCT + # + _exit_node_handlers[ProductExpression].update( + { + None: _handle_product_nonlinear, + (_CONSTANT, _QUADRATIC): linear._handle_product_constant_ANY, + (_QUADRATIC, _CONSTANT): linear._handle_product_ANY_constant, + # Replace handler from the linear walker + (_LINEAR, _LINEAR): _handle_product_linear_linear, + } + ) + # + # DIVISION + # + _exit_node_handlers[DivisionExpression].update( + {(_QUADRATIC, _CONSTANT): linear._handle_division_ANY_constant} + ) + # + # EXPONENTIATION + # + _exit_node_handlers[PowExpression].update( + {(_QUADRATIC, _CONSTANT): linear._handle_pow_ANY_constant} + ) + # + # ABS and UNARY handlers + # + # (no changes needed) + # + # NAMED EXPRESSION handlers + # + # (no changes needed) + # + # EXPR_IF handlers + # + # Note: it is easier to just recreate the entire data structure, rather + # than update it + _exit_node_handlers[Expr_ifExpression].update( + { + (_CONSTANT, i, _QUADRATIC): linear._handle_expr_if_const + for i in (_CONSTANT, _LINEAR, _QUADRATIC, _GENERAL) + } + ) + _exit_node_handlers[Expr_ifExpression].update( + { + (_CONSTANT, _QUADRATIC, i): linear._handle_expr_if_const + for i in (_CONSTANT, _LINEAR, _GENERAL) + } + ) + # + # RELATIONAL handlers + # + # (no changes needed) -# -# DIVISION -# -_exit_node_handlers[DivisionExpression].update( - { - (_CONSTANT, _QUADRATIC): linear._handle_division_nonlinear, - (_LINEAR, _QUADRATIC): linear._handle_division_nonlinear, - (_QUADRATIC, _QUADRATIC): linear._handle_division_nonlinear, - (_GENERAL, _QUADRATIC): linear._handle_division_nonlinear, - (_QUADRATIC, _CONSTANT): linear._handle_division_ANY_constant, - (_QUADRATIC, _LINEAR): linear._handle_division_nonlinear, - (_QUADRATIC, _GENERAL): linear._handle_division_nonlinear, + _exit_node_handlers[expr.GetItemExpression] = { + None: linear_template._handle_getitem + } + _exit_node_handlers[expr.TemplateSumExpression] = { + None: linear_template._handle_templatesum } -) + return _exit_node_handlers -# -# EXPONENTIATION -# -_exit_node_handlers[PowExpression].update( - { - (_CONSTANT, _QUADRATIC): linear._handle_pow_nonlinear, - (_LINEAR, _QUADRATIC): linear._handle_pow_nonlinear, - (_QUADRATIC, _QUADRATIC): linear._handle_pow_nonlinear, - (_GENERAL, _QUADRATIC): linear._handle_pow_nonlinear, - (_QUADRATIC, _CONSTANT): linear._handle_pow_ANY_constant, - (_QUADRATIC, _LINEAR): linear._handle_pow_nonlinear, - (_QUADRATIC, _GENERAL): linear._handle_pow_nonlinear, - } -) -# -# ABS and UNARY handlers -# -_exit_node_handlers[AbsExpression][(_QUADRATIC,)] = linear._handle_unary_nonlinear -_exit_node_handlers[UnaryFunctionExpression][ - (_QUADRATIC,) -] = linear._handle_unary_nonlinear +class QuadraticRepnVisitor(linear.LinearRepnVisitor): + Result = QuadraticRepn + exit_node_dispatcher = linear.ExitNodeDispatcher( + util.initialize_exit_node_dispatcher(define_exit_node_handlers()) + ) + max_exponential_expansion = 2 -# -# NAMED EXPRESSION handlers -# -_exit_node_handlers[Expression][(_QUADRATIC,)] = linear._handle_named_ANY + ## handle quadratics, then let LinearRepnVisitor handle the rest + def finalizeResult(self, result): + ans = result[1] + if ( + ans.__class__ is self.Result + and ans.multiplier + and ans.multiplier != 1 + and ans.quadratic + ): + mult = ans.multiplier + quadratic = ans.quadratic + zeros = [] + for vid, coef in quadratic.items(): + if coef: + quadratic[vid] = coef * mult + else: + zeros.append(vid) + for vid in zeros: + del quadratic[vid] -# -# EXPR_IF handlers -# -# Note: it is easier to just recreate the entire data structure, rather -# than update it -_exit_node_handlers[Expr_ifExpression] = { - (i, j, k): linear._handle_expr_if_nonlinear - for i in (_LINEAR, _QUADRATIC, _GENERAL) - for j in (_CONSTANT, _LINEAR, _QUADRATIC, _GENERAL) - for k in (_CONSTANT, _LINEAR, _QUADRATIC, _GENERAL) -} -for j in (_CONSTANT, _LINEAR, _QUADRATIC, _GENERAL): - for k in (_CONSTANT, _LINEAR, _QUADRATIC, _GENERAL): - _exit_node_handlers[Expr_ifExpression][ - _CONSTANT, j, k - ] = linear._handle_expr_if_const + return super().finalizeResult(result) -# -# RELATIONAL handlers -# -_exit_node_handlers[EqualityExpression].update( - { - (_CONSTANT, _QUADRATIC): linear._handle_equality_general, - (_LINEAR, _QUADRATIC): linear._handle_equality_general, - (_QUADRATIC, _QUADRATIC): linear._handle_equality_general, - (_GENERAL, _QUADRATIC): linear._handle_equality_general, - (_QUADRATIC, _CONSTANT): linear._handle_equality_general, - (_QUADRATIC, _LINEAR): linear._handle_equality_general, - (_QUADRATIC, _GENERAL): linear._handle_equality_general, - } -) -_exit_node_handlers[InequalityExpression].update( - { - (_CONSTANT, _QUADRATIC): linear._handle_inequality_general, - (_LINEAR, _QUADRATIC): linear._handle_inequality_general, - (_QUADRATIC, _QUADRATIC): linear._handle_inequality_general, - (_GENERAL, _QUADRATIC): linear._handle_inequality_general, - (_QUADRATIC, _CONSTANT): linear._handle_inequality_general, - (_QUADRATIC, _LINEAR): linear._handle_inequality_general, - (_QUADRATIC, _GENERAL): linear._handle_inequality_general, - } -) -_exit_node_handlers[RangedExpression].update( - { - (_CONSTANT, _QUADRATIC): linear._handle_ranged_general, - (_LINEAR, _QUADRATIC): linear._handle_ranged_general, - (_QUADRATIC, _QUADRATIC): linear._handle_ranged_general, - (_GENERAL, _QUADRATIC): linear._handle_ranged_general, - (_QUADRATIC, _CONSTANT): linear._handle_ranged_general, - (_QUADRATIC, _LINEAR): linear._handle_ranged_general, - (_QUADRATIC, _GENERAL): linear._handle_ranged_general, - } -) +class QuadraticTemplateRepn(QuadraticRepn): + # __slots__ = ("linear_sum",) -class QuadraticRepnVisitor(linear.LinearRepnVisitor): - Result = QuadraticRepn - exit_node_handlers = _exit_node_handlers + def __init__(self): + super().__init__() + self.linear_sum = [] + + @classmethod + def _resolve_symbols( + cls, k, ans, expr_cache, smap, remove_fixed_vars, check_duplicates, var_map + ): + + if isinstance(k, tuple): + return ( + "(" + + ",".join( + [ + cls._resolve_symbols( + _k, + ans, + expr_cache, + smap, + remove_fixed_vars, + check_duplicates, + var_map, + ) + for _k in k + ] + ) + + ")" + ) + + logger.debug(f"k={k} ({type(k)})") + logger.debug( + f"expr_cache: {expr_cache}, smap: {smap.bySymbol} {smap.byObject}, " + f"var_map: {[(k,v._index,v._value,v._component()) for k, v in var_map.items()]}" + ) + + if k in expr_cache or k in var_map: + if k in expr_cache: + k = expr_cache[k] + else: + symbol_obj_id = id(var_map[k]._component()) + if symbol_obj_id in smap.byObject: + k = f"{smap.byObject[symbol_obj_id]}[{var_map[k]._index}]" + + logger.debug(f"k={k} ({type(k)})") + + if k.__class__ not in native_types and k.is_expression_type(): + ans.append("v = " + k.to_string(smap=smap)) + k = "v" + if remove_fixed_vars: + ans.append("if v.__class__ is tuple:") + ans.append(" const += v[0] * {coef}") + ans.append(" v = None") + ans.append("else:") + indent = " " # FIX + elif not check_duplicates: + k = ans.pop()[4:] + + return k + + def compile( + self, + env, + smap, + expr_cache, + args, + remove_fixed_vars=False, + check_duplicates=False, + *, + var_map={}, + ): + ans, constant = self._build_evaluator( + smap, expr_cache, 1, 1, remove_fixed_vars, check_duplicates, var_map=var_map + ) + if not ans: + return constant + indent = "\n " + if not constant and ans and ans[0].startswith("const +="): + # Convert initial "const +=" to "const =" + ans[0] = "".join(ans[0].split("+", 1)) + else: + ans.insert(0, "const = " + repr(constant)) + fcn_body = indent.join(ans[1:]) + if "const" not in fcn_body: + # No constants in the expression. Move the initial const + # term to the return value and avoid declaring the local + # variable + ans = ["return " + ans[0].split("=", 1)[1]] + if fcn_body: + ans.insert(0, fcn_body) + else: + ans = [ans[0], fcn_body, "return const"] + if check_duplicates: + ans.insert(0, f"def build_expr(linear, quadratic, {', '.join(args)}):") + else: + ans.insert( + 0, + f"def build_expr(linear_indices, linear_data, quadratic_indices, quadratic_data, {', '.join(args)}):", + ) + ans = indent.join(ans) + import textwrap + + logger.debug( + f"EXECUTING:\n\n{textwrap.indent(ans, ' ')}\n* compile RETURNING: build_expr\n" + ) + # build the function in the env namespace, then remove and + # return the compiled function. The function's globals will + # still be bound to env + exec(ans, env) + return env.pop("build_expr") + + def _build_evaluator( + self, + smap, + expr_cache, + multiplier, + repetitions, + remove_fixed_vars, + check_duplicates, + *, + var_map=None, + ): + logger.debug( + f"smap:{smap}, expr_cache:{expr_cache}, multiplier:{multiplier}, repetitions:{repetitions}, " + f"remove_fixed_vars:{remove_fixed_vars}, check_duplicates:{check_duplicates}\n\n" + ) + ans = [] + multiplier *= self.multiplier + constant = self.constant + if constant.__class__ not in native_types or constant: + constant *= multiplier + if not repetitions or ( + constant.__class__ not in native_types and constant.is_expression_type() + ): + ans.append("const += " + constant.to_string(smap=smap)) + constant = 0 + else: + constant *= repetitions + + for term_type in ["linear", "quadratic"]: + for k, coef in list(getattr(self, term_type).items()): + logger.debug(f"{term_type}: ({k} ({k.__class__.__name__}): {coef})") + coef *= multiplier + if coef.__class__ not in native_types and coef.is_expression_type(): + coef = coef.to_string(smap=smap) + elif coef: + coef = repr(coef) + else: + continue + + indent = "" + + k = self.__class__._resolve_symbols( + k, + ans, + expr_cache, + smap, + remove_fixed_vars, + check_duplicates, + var_map, + ) + + if check_duplicates: + ans.append(indent + f"if {k} in {term_type}:") + ans.append(indent + f" {term_type}[{k}] += {coef}") + ans.append(indent + "else:") + ans.append(indent + f" {term_type}[{k}] = {coef}") + else: + ans.append(indent + f"{term_type}_indices.append({k})") + ans.append(indent + f"{term_type}_data.append({coef})") + + for subrepn, subindices, subsets in getattr(self, "linear_sum", []): + ans.extend( + " " * i + + f"for {','.join(smap.getSymbol(i) for i in _idx)} in " + + ( + _set.to_string(smap=smap) + if _set.is_expression_type() + else smap.getSymbol(_set) + ) + + ":" + for i, (_idx, _set) in enumerate(zip(subindices, subsets)) + ) + try: + subrep = 1 + for _set in subsets: + subrep *= len(_set) + except: + subrep = 0 + subans, subconst = subrepn._build_evaluator( + smap, + expr_cache, + multiplier, + repetitions * subrep, + remove_fixed_vars, + check_duplicates, + ) + indent = " " * (len(subsets)) + ans.extend(indent + line for line in subans) + constant += subconst + return ans, constant + + +class QuadraticTemplateRepnVisitor(linear_template.LinearTemplateRepnVisitor): + Result = QuadraticTemplateRepn + max_exponential_expansion = 2 exit_node_dispatcher = linear.ExitNodeDispatcher( - linear._initialize_exit_node_dispatcher(_exit_node_handlers) + util.initialize_exit_node_dispatcher(define_exit_node_handlers()) ) - max_exponential_expansion = 2 + + ## handle quadratics, then let LinearRepnVisitor handle the rest. + ## duplicate of QuadraticRepnVisitor, but not directly inheritable because of ambiguous + ## multi-class inheritance (QuadaraticRepnVisitor vs. LinearTemplateRepnVisitor) + def finalizeResult(self, result): + ans = result[1] + if ( + ans.__class__ is self.Result + and ans.multiplier + and ans.multiplier != 1 + and ans.quadratic + ): + mult = ans.multiplier + quadratic = ans.quadratic + zeros = [] + for vid, coef in quadratic.items(): + if coef: + quadratic[vid] = coef * mult + else: + zeros.append(vid) + for vid in zeros: + del quadratic[vid] + + return super().finalizeResult(result) + + def expand_expression(self, obj, template_info): + env = self.env + logger.debug(f"obj={type(obj)}") + logger.debug( + f"template_info={type(template_info)}, {[type(ti) for ti in template_info]}" + ) + logger.debug(f"id(template_info)={id(template_info)}") + try: + # attempt to look up already-constructed template + body, lb, ub = self.expanded_templates[id(template_info)] + except KeyError: + # create a new expanded template + logger.debug(f"create new expanded template") + smap = self.symbolmap + expr, indices = template_info + args = [smap.getSymbol(i) for i in indices] + if expr.is_expression_type(ExpressionType.RELATIONAL): + logger.debug("expression_type = RELATIONAL") + + lb, body, ub = obj.to_bounded_expression() + if body is not None: + body = self.walk_expression(body).compile( + env, smap, self.expr_cache, args, False, var_map=self.var_map + ) + if lb is not None: + lb = self.walk_expression(lb).compile( + env, smap, self.expr_cache, args, True, var_map=self.var_map + ) + if ub is not None: + ub = self.walk_expression(ub).compile( + env, smap, self.expr_cache, args, True, var_map=self.var_map + ) + + elif expr is not None: + lb = ub = None + body = self.walk_expression(expr).compile( + env, smap, self.expr_cache, args, False, var_map=self.var_map + ) + else: + body = lb = ub = None + self.expanded_templates[id(template_info)] = body, lb, ub + logger.debug( + f"SET: {template_info} self.expanded_templates[{id(template_info)}] = {body}, {lb}, {ub}" + ) + + linear_indices = [] + linear_data = [] + quadratic_indices = [] + quadratic_data = [] + call_args = (linear_indices, linear_data, quadratic_indices, quadratic_data) + + index = obj.index() + if index.__class__ is not tuple: + if index is None and not obj.parent_component().is_indexed(): + index = () + else: + index = (index,) + if lb.__class__ is code_type: + lb = lb(*call_args, *index) + if linear_indices: + raise RuntimeError(f"Constraint {obj} has non-fixed lower bound") + if ub.__class__ is code_type: + ub = ub(*call_args, *index) + if linear_indices: + raise RuntimeError(f"Constraint {obj} has non-fixed upper bound") + + return ( + body(*call_args, *index), + linear_indices, + linear_data, + lb, + ub, + quadratic_indices, + quadratic_data, + ) diff --git a/pyomo/repn/standard_aux.py b/pyomo/repn/standard_aux.py index 403320c462c..21461a0688e 100644 --- a/pyomo/repn/standard_aux.py +++ b/pyomo/repn/standard_aux.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/standard_repn.py b/pyomo/repn/standard_repn.py index 8700872f04f..3f7abfde8e8 100644 --- a/pyomo/repn/standard_repn.py +++ b/pyomo/repn/standard_repn.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,11 +19,15 @@ import pyomo.core.expr as EXPR from pyomo.core.expr.numvalue import NumericConstant -from pyomo.core.base.objective import _GeneralObjectiveData, ScalarObjective -from pyomo.core.base import _ExpressionData, Expression -from pyomo.core.base.expression import ScalarExpression, _GeneralExpressionData -from pyomo.core.base.var import ScalarVar, Var, _GeneralVarData, value -from pyomo.core.base.param import ScalarParam, _ParamData +from pyomo.core.base.objective import ObjectiveData, ScalarObjective +from pyomo.core.base import Expression +from pyomo.core.base.expression import ( + ScalarExpression, + NamedExpressionData, + ExpressionData, +) +from pyomo.core.base.var import ScalarVar, Var, VarData, value +from pyomo.core.base.param import ScalarParam, ParamData from pyomo.core.kernel.expression import expression, noclone from pyomo.core.kernel.variable import IVariable, variable from pyomo.core.kernel.objective import objective @@ -321,6 +325,16 @@ def generate_standard_repn( linear_vars[id_] = v elif arg.__class__ in native_numeric_types: C_ += arg + elif arg.is_variable_type(): + if arg.fixed: + C_ += arg.value + continue + id_ = id(arg) + if id_ in linear_coefs: + linear_coefs[id_] += 1 + else: + linear_coefs[id_] = 1 + linear_vars[id_] = arg else: C_ += EXPR.evaluate_expression(arg) else: # compute_values == False @@ -336,6 +350,18 @@ def generate_standard_repn( else: linear_coefs[id_] = c linear_vars[id_] = v + elif arg.__class__ in native_numeric_types: + C_ += arg + elif arg.is_variable_type(): + if arg.fixed: + C_ += arg + continue + id_ = id(arg) + if id_ in linear_coefs: + linear_coefs[id_] += 1 + else: + linear_coefs[id_] = 1 + linear_vars[id_] = arg else: C_ += arg @@ -1114,25 +1140,25 @@ def _collect_external_fn(exp, multiplier, idMap, compute_values, verbose, quadra EXPR.RangedExpression: _collect_comparison, EXPR.EqualityExpression: _collect_comparison, EXPR.ExternalFunctionExpression: _collect_external_fn, - # _ConnectorData : _collect_linear_connector, + # ConnectorData : _collect_linear_connector, # ScalarConnector : _collect_linear_connector, - _ParamData: _collect_const, + ParamData: _collect_const, ScalarParam: _collect_const, # param.Param : _collect_linear_const, # parameter : _collect_linear_const, NumericConstant: _collect_const, - _GeneralVarData: _collect_var, + VarData: _collect_var, ScalarVar: _collect_var, Var: _collect_var, variable: _collect_var, IVariable: _collect_var, - _GeneralExpressionData: _collect_identity, + ExpressionData: _collect_identity, ScalarExpression: _collect_identity, expression: _collect_identity, noclone: _collect_identity, - _ExpressionData: _collect_identity, + NamedExpressionData: _collect_identity, Expression: _collect_identity, - _GeneralObjectiveData: _collect_identity, + ObjectiveData: _collect_identity, ScalarObjective: _collect_identity, objective: _collect_identity, } @@ -1514,24 +1540,24 @@ def _linear_collect_pow(exp, multiplier, idMap, compute_values, verbose, coef): #EXPR.EqualityExpression : _linear_collect_comparison, #EXPR.ExternalFunctionExpression : _linear_collect_external_fn, ##EXPR.LinearSumExpression : _collect_linear_sum, - ##_ConnectorData : _collect_linear_connector, + ##ConnectorData : _collect_linear_connector, ##ScalarConnector : _collect_linear_connector, - ##param._ParamData : _collect_linear_const, + ##param.ParamData : _collect_linear_const, ##param.ScalarParam : _collect_linear_const, ##param.Param : _collect_linear_const, ##parameter : _collect_linear_const, - _GeneralVarData : _linear_collect_var, + VarData : _linear_collect_var, ScalarVar : _linear_collect_var, Var : _linear_collect_var, variable : _linear_collect_var, IVariable : _linear_collect_var, - _GeneralExpressionData : _linear_collect_identity, + ExpressionData : _linear_collect_identity, ScalarExpression : _linear_collect_identity, expression : _linear_collect_identity, noclone : _linear_collect_identity, - _ExpressionData : _linear_collect_identity, + NamedExpressionData : _linear_collect_identity, Expression : _linear_collect_identity, - _GeneralObjectiveData : _linear_collect_identity, + ObjectiveData : _linear_collect_identity, ScalarObjective : _linear_collect_identity, objective : _linear_collect_identity, } diff --git a/pyomo/repn/tests/__init__.py b/pyomo/repn/tests/__init__.py index a9e1a5bea47..6dc7244f5cf 100644 --- a/pyomo/repn/tests/__init__.py +++ b/pyomo/repn/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/__init__.py b/pyomo/repn/tests/ampl/__init__.py index a4a626013c4..d7fbdad79ea 100644 --- a/pyomo/repn/tests/ampl/__init__.py +++ b/pyomo/repn/tests/ampl/__init__.py @@ -1,10 +1,23 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ + +# +# declare deprecation paths for removed modules +# +from pyomo.common.deprecation import moved_module + +moved_module( + 'pyomo.repn.tests.ampl.nl_diff', + 'pyomo.repn.tests.nl_diff', + version='6.6.0', + remove_in='6.6.1', +) +del moved_module diff --git a/pyomo/repn/tests/ampl/helper.py b/pyomo/repn/tests/ampl/helper.py index 2bf2198d20f..c832abab3a6 100644 --- a/pyomo/repn/tests/ampl/helper.py +++ b/pyomo/repn/tests/ampl/helper.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small10_testCase.py b/pyomo/repn/tests/ampl/small10_testCase.py index deb56f92a88..730b5160973 100644 --- a/pyomo/repn/tests/ampl/small10_testCase.py +++ b/pyomo/repn/tests/ampl/small10_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small11_testCase.py b/pyomo/repn/tests/ampl/small11_testCase.py index 11b61805d5e..9187d07a61c 100644 --- a/pyomo/repn/tests/ampl/small11_testCase.py +++ b/pyomo/repn/tests/ampl/small11_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small12_testCase.py b/pyomo/repn/tests/ampl/small12_testCase.py index b73a8f528f2..13e3cddc671 100644 --- a/pyomo/repn/tests/ampl/small12_testCase.py +++ b/pyomo/repn/tests/ampl/small12_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small13_testCase.py b/pyomo/repn/tests/ampl/small13_testCase.py index c24185bf8d7..39045ba675d 100644 --- a/pyomo/repn/tests/ampl/small13_testCase.py +++ b/pyomo/repn/tests/ampl/small13_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small14_testCase.py b/pyomo/repn/tests/ampl/small14_testCase.py index fb2c2bc6c5e..d622ad05e81 100644 --- a/pyomo/repn/tests/ampl/small14_testCase.py +++ b/pyomo/repn/tests/ampl/small14_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small15_testCase.py b/pyomo/repn/tests/ampl/small15_testCase.py index d4d5796aaa5..ccf4f253610 100644 --- a/pyomo/repn/tests/ampl/small15_testCase.py +++ b/pyomo/repn/tests/ampl/small15_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small1_testCase.py b/pyomo/repn/tests/ampl/small1_testCase.py index 06f5ad122d9..75e7ec5b446 100644 --- a/pyomo/repn/tests/ampl/small1_testCase.py +++ b/pyomo/repn/tests/ampl/small1_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small2_testCase.py b/pyomo/repn/tests/ampl/small2_testCase.py index 8a65779f55e..5dff254db0c 100644 --- a/pyomo/repn/tests/ampl/small2_testCase.py +++ b/pyomo/repn/tests/ampl/small2_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small3_testCase.py b/pyomo/repn/tests/ampl/small3_testCase.py index 999143d9a0c..fec439c52d9 100644 --- a/pyomo/repn/tests/ampl/small3_testCase.py +++ b/pyomo/repn/tests/ampl/small3_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small4_testCase.py b/pyomo/repn/tests/ampl/small4_testCase.py index 9736dd9bf3b..ccba47ad165 100644 --- a/pyomo/repn/tests/ampl/small4_testCase.py +++ b/pyomo/repn/tests/ampl/small4_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small5_testCase.py b/pyomo/repn/tests/ampl/small5_testCase.py index 1f254b7f04d..f50314eb207 100644 --- a/pyomo/repn/tests/ampl/small5_testCase.py +++ b/pyomo/repn/tests/ampl/small5_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small6_testCase.py b/pyomo/repn/tests/ampl/small6_testCase.py index 9d309c09fef..5f1c746442b 100644 --- a/pyomo/repn/tests/ampl/small6_testCase.py +++ b/pyomo/repn/tests/ampl/small6_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small7_testCase.py b/pyomo/repn/tests/ampl/small7_testCase.py index 485962dd211..3644a777a62 100644 --- a/pyomo/repn/tests/ampl/small7_testCase.py +++ b/pyomo/repn/tests/ampl/small7_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small8_testCase.py b/pyomo/repn/tests/ampl/small8_testCase.py index 61a3e3ccce7..8ea0338c56a 100644 --- a/pyomo/repn/tests/ampl/small8_testCase.py +++ b/pyomo/repn/tests/ampl/small8_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/small9_testCase.py b/pyomo/repn/tests/ampl/small9_testCase.py index 7cb0913a762..08a9a583d1b 100644 --- a/pyomo/repn/tests/ampl/small9_testCase.py +++ b/pyomo/repn/tests/ampl/small9_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/test_ampl_comparison.py b/pyomo/repn/tests/ampl/test_ampl_comparison.py index 8210bbdd173..3fe078b7e1d 100644 --- a/pyomo/repn/tests/ampl/test_ampl_comparison.py +++ b/pyomo/repn/tests/ampl/test_ampl_comparison.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/test_ampl_nl.py b/pyomo/repn/tests/ampl/test_ampl_nl.py index 53a2d3cda82..4c680cd6c15 100644 --- a/pyomo/repn/tests/ampl/test_ampl_nl.py +++ b/pyomo/repn/tests/ampl/test_ampl_nl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -31,11 +31,9 @@ from ..nl_diff import load_and_compare_nl_baseline import pyomo.repn.plugins.ampl.ampl_ as ampl_ -import pyomo.repn.plugins.nl_writer as nl_writer +from pyomo.repn.ampl import TextNLDebugTemplate as template gsr = ampl_.generate_standard_repn -template = nl_writer.text_nl_debug_template - thisdir = this_file_dir() diff --git a/pyomo/repn/tests/ampl/test_ampl_repn.py b/pyomo/repn/tests/ampl/test_ampl_repn.py index 9c911540eb0..477f3bcf738 100644 --- a/pyomo/repn/tests/ampl/test_ampl_repn.py +++ b/pyomo/repn/tests/ampl/test_ampl_repn.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/ampl/test_nlv2.py b/pyomo/repn/tests/ampl/test_nlv2.py index 8b95fc03bdb..932615be119 100644 --- a/pyomo/repn/tests/ampl/test_nlv2.py +++ b/pyomo/repn/tests/ampl/test_nlv2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,6 +25,7 @@ from pyomo.common.dependencies import numpy, numpy_available from pyomo.common.errors import MouseTrap +from pyomo.common.gsl import find_GSL from pyomo.common.log import LoggingIntercept from pyomo.common.tee import capture_output from pyomo.common.tempfiles import TempfileManager @@ -42,29 +43,24 @@ Suffix, Constraint, Expression, + Binary, + Integers, ) import pyomo.environ as pyo -_invalid_1j = r'InvalidNumber\((\([-+0-9.e]+\+)?1j\)?\)' +nan = float('nan') class INFO(object): def __init__(self, symbolic=False): - if symbolic: - self.template = nl_writer.text_nl_debug_template - else: - self.template = nl_writer.text_nl_template self.subexpression_cache = {} - self.subexpression_order = [] self.external_functions = {} self.var_map = {} self.used_named_expressions = set() self.symbolic_solver_labels = symbolic self.visitor = nl_writer.AMPLRepnVisitor( - self.template, self.subexpression_cache, - self.subexpression_order, self.external_functions, self.var_map, self.used_named_expressions, @@ -72,15 +68,13 @@ def __init__(self, symbolic=False): True, None, ) + self.template = self.visitor.template def __enter__(self): - assert nl_writer.AMPLRepn.ActiveVisitor is None - nl_writer.AMPLRepn.ActiveVisitor = self.visitor return self def __exit__(self, exc_type, exc_value, tb): - assert nl_writer.AMPLRepn.ActiveVisitor is self.visitor - nl_writer.AMPLRepn.ActiveVisitor = None + pass class Test_AMPLRepnVisitor(unittest.TestCase): @@ -97,7 +91,7 @@ def test_divide(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, ('o5\n%s\nn2\n', [id(m.x)])) + self.assertEqual(repn.nonlinear, ('o5\n%sn2\n', [id(m.x)])) m.p = 2 @@ -149,7 +143,7 @@ def test_divide(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, ('o2\nn0.5\no5\n%s\nn2\n', [id(m.x)])) + self.assertEqual(repn.nonlinear, ('o2\nn0.5\no5\n%sn2\n', [id(m.x)])) info = INFO() with LoggingIntercept() as LOG: @@ -159,7 +153,7 @@ def test_divide(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, ('o3\no43\n%s\n%s\n', [id(m.x), id(m.x)])) + self.assertEqual(repn.nonlinear, ('o3\no43\n%s%s', [id(m.x), id(m.x)])) def test_errors_divide_by_0(self): m = ConcreteModel() @@ -177,7 +171,7 @@ def test_errors_divide_by_0(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertEqual(str(repn.const), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -192,7 +186,7 @@ def test_errors_divide_by_0(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertEqual(str(repn.const), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -207,7 +201,7 @@ def test_errors_divide_by_0(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertEqual(str(repn.const), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -222,7 +216,7 @@ def test_errors_divide_by_0(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertEqual(str(repn.const), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -237,7 +231,7 @@ def test_errors_divide_by_0(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertEqual(str(repn.const), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -254,7 +248,7 @@ def test_pow(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, ('o5\n%s\nn2\n', [id(m.x)])) + self.assertEqual(repn.nonlinear, ('o5\n%sn2\n', [id(m.x)])) m.p = 1 info = INFO() @@ -430,7 +424,7 @@ def test_errors_negative_frac_pow(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertRegex(str(repn.const), _invalid_1j) + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(1j)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -446,7 +440,7 @@ def test_errors_negative_frac_pow(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertRegex(str(repn.const), _invalid_1j) + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(1j)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -466,7 +460,7 @@ def test_errors_unary_func(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertEqual(str(repn.const), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -490,7 +484,7 @@ def test_errors_propagate_nan(self): ) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertEqual(str(repn.const), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -500,7 +494,7 @@ def test_errors_propagate_nan(self): repn = info.visitor.walk_expression((expr, None, None, 1)) self.assertEqual(repn.nl, None) self.assertEqual(repn.mult, 1) - self.assertEqual(str(repn.const), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.const, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -541,7 +535,7 @@ def test_errors_propagate_nan(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, InvalidNumber(None)) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear[0], 'o16\no2\no2\n%s\n%s\n%s\n') + self.assertEqual(repn.nonlinear[0], 'o16\no2\no2\n%s%s%s') self.assertEqual(repn.nonlinear[1], [id(m.z[2]), id(m.z[3]), id(m.z[4])]) m.z[3].fix(float('nan')) @@ -591,7 +585,7 @@ def test_eval_pow(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, ('o5\n%s\nn0.5\n', [id(m.x)])) + self.assertEqual(repn.nonlinear, ('o5\n%sn0.5\n', [id(m.x)])) m.x.fix() info = INFO() @@ -616,7 +610,7 @@ def test_eval_abs(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, ('o15\n%s\n', [id(m.x)])) + self.assertEqual(repn.nonlinear, ('o15\n%s', [id(m.x)])) m.x.fix() info = INFO() @@ -641,7 +635,7 @@ def test_eval_unary_func(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, ('o43\n%s\n', [id(m.x)])) + self.assertEqual(repn.nonlinear, ('o43\n%s', [id(m.x)])) m.x.fix() info = INFO() @@ -670,7 +664,7 @@ def test_eval_expr_if_lessEq(self): self.assertEqual(repn.linear, {}) self.assertEqual( repn.nonlinear, - ('o35\no23\n%s\nn4\no5\n%s\nn2\n%s\n', [id(m.x), id(m.x), id(m.y)]), + ('o35\no23\n%sn4\no5\n%sn2\n%s', [id(m.x), id(m.x), id(m.y)]), ) m.x.fix() @@ -711,7 +705,7 @@ def test_eval_expr_if_Eq(self): self.assertEqual(repn.linear, {}) self.assertEqual( repn.nonlinear, - ('o35\no24\n%s\nn4\no5\n%s\nn2\n%s\n', [id(m.x), id(m.x), id(m.y)]), + ('o35\no24\n%sn4\no5\n%sn2\n%s', [id(m.x), id(m.x), id(m.y)]), ) m.x.fix() @@ -753,7 +747,7 @@ def test_eval_expr_if_ranged(self): self.assertEqual( repn.nonlinear, ( - 'o35\no21\no23\nn1\n%s\no23\n%s\nn4\no5\n%s\nn2\n%s\n', + 'o35\no21\no23\nn1\n%so23\n%sn4\no5\n%sn2\n%s', [id(m.x), id(m.x), id(m.x), id(m.y)], ), ) @@ -814,7 +808,7 @@ class CustomExpression(ScalarExpression): self.assertEqual(len(info.subexpression_cache), 1) obj, repn, info = info.subexpression_cache[id(m.e)] self.assertIs(obj, m.e) - self.assertEqual(repn.nl, ('%s\n', (id(m.e),))) + self.assertEqual(repn.nl, ('%s', (id(m.e),))) self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 3) self.assertEqual(repn.linear, {id(m.x): 1}) @@ -841,7 +835,7 @@ def test_nested_operator_zero_arg(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, ('o24\no3\nn1\n%s\nn0\n', [id(m.x)])) + self.assertEqual(repn.nonlinear, ('o24\no3\nn1\n%sn0\n', [id(m.x)])) def test_duplicate_shared_linear_expressions(self): # This tests an issue where AMPLRepn.duplicate() was not copying @@ -928,7 +922,7 @@ def test_AMPLRepn_to_expr(self): self.assertEqual(repn.mult, 1) self.assertEqual(repn.const, 0) self.assertEqual(repn.linear, {id(m.x[2]): 4, id(m.x[3]): 9, id(m.x[4]): 16}) - self.assertEqual(repn.nonlinear, ('o5\n%s\nn2\n', [id(m.x[2])])) + self.assertEqual(repn.nonlinear, ('o5\n%sn2\n', [id(m.x[2])])) with self.assertRaisesRegex( MouseTrap, "Cannot convert nonlinear AMPLRepn to Pyomo Expression" ): @@ -1096,7 +1090,6 @@ def test_log_timing(self): m.c1 = Constraint([1, 2], rule=lambda m, i: sum(m.x.values()) == 1) m.c2 = Constraint(expr=m.p * m.x[1] ** 2 + m.x[2] ** 3 <= 100) - self.maxDiff = None OUT = io.StringIO() with capture_output() as LOG: with report_timing(level=logging.DEBUG): @@ -1267,7 +1260,7 @@ def test_nonfloat_constants(self): 0 0 #network constraints: nonlinear, linear 0 0 0 #nonlinear vars in constraints, objectives, both 0 0 0 1 #linear network variables; functions; arith, flags - 0 4 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 4 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) 4 4 #nonzeros in Jacobian, obj. gradient 6 4 #max name lengths: constraints, variables 0 0 0 0 0 #common exprs: b,c,o,c1,o1 @@ -1688,6 +1681,257 @@ def test_presolve_named_expressions(self): ) ) + def test_presolve_zero_coef(self): + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.z = Var() + m.obj = Objective(expr=m.x**2 + m.y**2 + m.z**2) + m.c1 = Constraint(expr=m.x == m.y + m.z + 1.5) + m.c2 = Constraint(expr=m.z == -m.y) + + OUT = io.StringIO() + with LoggingIntercept() as LOG: + nlinfo = nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=True + ) + self.assertEqual(LOG.getvalue(), "") + + self.assertEqual(nlinfo.eliminated_vars[0], (m.x, 1.5)) + self.assertIs(nlinfo.eliminated_vars[1][0], m.y) + self.assertExpressionsEqual( + nlinfo.eliminated_vars[1][1], LinearExpression([-1.0 * m.z]) + ) + + self.assertEqual( + *nl_diff( + """g3 1 1 0 # problem unknown + 1 0 1 0 0 #vars, constraints, objectives, ranges, eqns + 0 1 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 1 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 0 1 #nonzeros in Jacobian, obj. gradient + 3 1 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +O0 0 #obj +o54 #sumlist +3 #(n) +o5 #^ +n1.5 +n2 +o5 #^ +o16 #- +v0 #z +n2 +o5 #^ +v0 #z +n2 +x0 #initial guess +r #0 ranges (rhs's) +b #1 bounds (on variables) +3 #z +k0 #intermediate Jacobian column lengths +G0 1 #obj +0 0 +""", + OUT.getvalue(), + ) + ) + + m.c3 = Constraint(expr=m.x == 2) + OUT = io.StringIO() + with LoggingIntercept() as LOG: + with self.assertRaisesRegex( + nl_writer.InfeasibleConstraintException, + r"model contains a trivially infeasible constraint 0.5 == 0.0\*y", + ): + nlinfo = nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=True + ) + self.assertEqual(LOG.getvalue(), "") + + m.c1.set_value(m.x >= m.y + m.z + 1.5) + OUT = io.StringIO() + with LoggingIntercept() as LOG: + nlinfo = nl_writer.NLWriter().write( + m, + OUT, + symbolic_solver_labels=True, + linear_presolve=True, + skip_trivial_constraints=False, + ) + self.assertEqual(LOG.getvalue(), "") + + self.assertIs(nlinfo.eliminated_vars[0][0], m.y) + self.assertExpressionsEqual( + nlinfo.eliminated_vars[0][1], LinearExpression([-1.0 * m.z]) + ) + self.assertEqual(nlinfo.eliminated_vars[1], (m.x, 2)) + + self.assertEqual( + *nl_diff( + """g3 1 1 0 # problem unknown + 1 1 1 0 0 #vars, constraints, objectives, ranges, eqns + 0 1 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 1 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 0 1 #nonzeros in Jacobian, obj. gradient + 3 1 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +C0 #c1 +n0 +O0 0 #obj +o54 #sumlist +3 #(n) +o5 #^ +n2 +n2 +o5 #^ +o16 #- +v0 #z +n2 +o5 #^ +v0 #z +n2 +x0 #initial guess +r #1 ranges (rhs's) +1 0.5 #c1 +b #1 bounds (on variables) +3 #z +k0 #intermediate Jacobian column lengths +G0 1 #obj +0 0 +""", + OUT.getvalue(), + ) + ) + + OUT = io.StringIO() + with LoggingIntercept() as LOG: + nlinfo = nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=True + ) + self.assertEqual(LOG.getvalue(), "") + + self.assertIs(nlinfo.eliminated_vars[0][0], m.y) + self.assertExpressionsEqual( + nlinfo.eliminated_vars[0][1], LinearExpression([-1.0 * m.z]) + ) + self.assertEqual(nlinfo.eliminated_vars[1], (m.x, 2)) + + self.assertEqual( + *nl_diff( + """g3 1 1 0 # problem unknown + 1 0 1 0 0 #vars, constraints, objectives, ranges, eqns + 0 1 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 1 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 0 1 #nonzeros in Jacobian, obj. gradient + 3 1 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +O0 0 #obj +o54 #sumlist +3 #(n) +o5 #^ +n2 +n2 +o5 #^ +o16 #- +v0 #z +n2 +o5 #^ +v0 #z +n2 +x0 #initial guess +r #1 ranges (rhs's) +b #1 bounds (on variables) +3 #z +k0 #intermediate Jacobian column lengths +G0 1 #obj +0 0 +""", + OUT.getvalue(), + ) + ) + + def test_presolve_independent_subsystem(self): + # This is derived from the example in #3192 + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.z = Var() + m.d = Constraint(expr=m.z == m.y) + m.c = Constraint(expr=m.y == m.x) + m.o = Objective(expr=0) + + ref = """g3 1 1 0 #problem unknown + 0 0 1 0 0 #vars, constraints, objectives, ranges, eqns + 0 0 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 0 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 0 0 #nonzeros in Jacobian, obj. gradient + 1 0 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +O0 0 #o +n0 +x0 #initial guess +r #0 ranges (rhs's) +b #0 bounds (on variables) +k-1 #intermediate Jacobian column lengths +""" + + OUT = io.StringIO() + with LoggingIntercept() as LOG: + nlinfo = nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=True + ) + self.assertEqual( + LOG.getvalue(), + "presolve identified an underdetermined independent linear subsystem " + "that was removed from the model. Setting 'z' == 0\n", + ) + + self.assertEqual(*nl_diff(ref, OUT.getvalue())) + + m.x.lb = 5.0 + + OUT = io.StringIO() + with LoggingIntercept() as LOG: + nlinfo = nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=True + ) + self.assertEqual( + LOG.getvalue(), + "presolve identified an underdetermined independent linear subsystem " + "that was removed from the model. Setting 'z' == 5.0\n", + ) + + self.assertEqual(*nl_diff(ref, OUT.getvalue())) + + m.x.lb = -5.0 + m.z.ub = -2.0 + + OUT = io.StringIO() + with LoggingIntercept() as LOG: + nlinfo = nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=True + ) + self.assertEqual( + LOG.getvalue(), + "presolve identified an underdetermined independent linear subsystem " + "that was removed from the model. Setting 'z' == -2.0\n", + ) + + self.assertEqual(*nl_diff(ref, OUT.getvalue())) + def test_scaling(self): m = pyo.ConcreteModel() m.x = pyo.Var(initialize=0) @@ -1969,6 +2213,586 @@ def test_named_expressions(self): 0 0 1 0 2 0 +""", + OUT.getvalue(), + ) + ) + + def test_discrete_var_tabulation(self): + # This tests an error reported in #3235 + # + # Among other issues, this verifies that nonlinear discrete + # variables are tabulated correctly (header line 7), and that + # integer variables with bounds in {0, 1} are mapped to binary + # variables. + m = ConcreteModel() + m.p1 = Var(bounds=(0.85, 1.15)) + m.p2 = Var(bounds=(0.68, 0.92)) + m.c1 = Var(bounds=(-0.0, 0.7)) + m.c2 = Var(bounds=(-0.0, 0.7)) + m.t1 = Var(within=Binary, bounds=(0, 1)) + m.t2 = Var(within=Binary, bounds=(0, 1)) + m.t3 = Var(within=Binary, bounds=(0, 1)) + m.t4 = Var(within=Binary, bounds=(0, 1)) + m.t5 = Var(within=Integers, bounds=(0, None)) + m.t6 = Var(within=Integers, bounds=(0, None)) + m.x1 = Var(within=Binary) + m.x2 = Var(within=Integers, bounds=(0, 1)) + m.x3 = Var(within=Integers, bounds=(0, None)) + m.const = Constraint( + expr=( + (0.7 - (m.c1 * m.t1 + m.c2 * m.t2)) + <= (m.p1 * m.t1 + m.p2 * m.t2 + m.p1 * m.t4 + m.t6 * m.t5) + ) + ) + m.OBJ = Objective( + expr=(m.p1 * m.t1 + m.p2 * m.t2 + m.p2 * m.t3 + m.x1 + m.x2 + m.x3) + ) + + OUT = io.StringIO() + nl_writer.NLWriter().write(m, OUT, symbolic_solver_labels=True) + + self.assertEqual( + *nl_diff( + """g3 1 1 0 # problem unknown + 13 1 1 0 0 #vars, constraints, objectives, ranges, eqns + 1 1 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 9 10 4 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 2 1 2 3 1 #discrete variables: binary, integer, nonlinear (b,c,o) + 9 8 #nonzeros in Jacobian, obj. gradient + 5 2 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +C0 #const +o0 #+ +o16 #- +o0 #+ +o2 #* +v4 #c1 +v2 #t1 +o2 #* +v5 #c2 +v3 #t2 +o16 #- +o54 #sumlist +4 #(n) +o2 #* +v0 #p1 +v2 #t1 +o2 #* +v1 #p2 +v3 #t2 +o2 #* +v0 #p1 +v6 #t4 +o2 #* +v7 #t6 +v8 #t5 +O0 0 #OBJ +o54 #sumlist +3 #(n) +o2 #* +v0 #p1 +v2 #t1 +o2 #* +v1 #p2 +v3 #t2 +o2 #* +v1 #p2 +v9 #t3 +x0 #initial guess +r #1 ranges (rhs's) +1 -0.7 #const +b #13 bounds (on variables) +0 0.85 1.15 #p1 +0 0.68 0.92 #p2 +0 0 1 #t1 +0 0 1 #t2 +0 -0.0 0.7 #c1 +0 -0.0 0.7 #c2 +0 0 1 #t4 +2 0 #t6 +2 0 #t5 +0 0 1 #t3 +0 0 1 #x1 +0 0 1 #x2 +2 0 #x3 +k12 #intermediate Jacobian column lengths +1 +2 +3 +4 +5 +6 +7 +8 +9 +9 +9 +9 +J0 9 #const +0 0 +1 0 +2 0 +3 0 +4 0 +5 0 +6 0 +7 0 +8 0 +G0 8 #OBJ +0 0 +1 0 +2 0 +3 0 +9 0 +10 1 +11 1 +12 1 +""", + OUT.getvalue(), + ) + ) + + def test_presolve_fixes_nl_defined_variables(self): + # This tests a workaround for a bug in the ASL where defined + # variables with constant expressions in the NL portion are not + # evaluated correctly. + m = ConcreteModel() + m.x = Var() + m.y = Var(bounds=(3, None)) + m.z = Var(bounds=(None, 3)) + m.e = Expression(expr=m.x + m.y * m.z + m.y**2 + 3 / m.z) + m.c1 = Constraint(expr=m.y * m.e + m.x >= 0) + m.c2 = Constraint(expr=m.y == m.z) + + OUT = io.StringIO() + nl_writer.NLWriter().write( + m, + OUT, + symbolic_solver_labels=True, + linear_presolve=True, + export_defined_variables=True, + ) + self.assertEqual( + *nl_diff( + """g3 1 1 0 # problem unknown + 1 1 0 0 0 #vars, constraints, objectives, ranges, eqns + 1 0 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 1 0 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 1 0 #nonzeros in Jacobian, obj. gradient + 2 1 #max name lengths: constraints, variables + 0 0 0 1 0 #common exprs: b,c,o,c1,o1 +V1 1 1 #e +0 1 +n19 +C0 #c1 +o2 #* +n3 +v1 #e +x0 #initial guess +r #1 ranges (rhs's) +2 0 #c1 +b #1 bounds (on variables) +3 #x +k0 #intermediate Jacobian column lengths +J0 1 #c1 +0 1 +""", + OUT.getvalue(), + ) + ) + + OUT = io.StringIO() + nl_writer.NLWriter().write( + m, + OUT, + symbolic_solver_labels=True, + linear_presolve=True, + export_defined_variables=False, + ) + self.assertEqual( + *nl_diff( + """g3 1 1 0 # problem unknown + 1 1 0 0 0 #vars, constraints, objectives, ranges, eqns + 1 0 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 1 0 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 1 0 #nonzeros in Jacobian, obj. gradient + 2 1 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +C0 #c1 +o2 #* +n3 +o0 #+ +v0 #x +o54 #sumlist +3 #(n) +o2 #* +n3 +n3 +o5 #^ +n3 +n2 +o3 #/ +n3 +n3 +x0 #initial guess +r #1 ranges (rhs's) +2 0 #c1 +b #1 bounds (on variables) +3 #x +k0 #intermediate Jacobian column lengths +J0 1 #c1 +0 1 +""", + OUT.getvalue(), + ) + ) + + OUT = io.StringIO() + nl_writer.NLWriter().write( + m, + OUT, + symbolic_solver_labels=True, + linear_presolve=False, + export_defined_variables=True, + ) + self.assertEqual( + *nl_diff( + """g3 1 1 0 # problem unknown + 3 2 0 0 1 #vars, constraints, objectives, ranges, eqns + 1 0 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 3 0 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 5 0 #nonzeros in Jacobian, obj. gradient + 2 1 #max name lengths: constraints, variables + 0 0 0 2 0 #common exprs: b,c,o,c1,o1 +V3 0 1 #nl(e) +o54 #sumlist +3 #(n) +o2 #* +v0 #y +v2 #z +o5 #^ +v0 #y +n2 +o3 #/ +n3 +v2 #z +V4 1 1 #e +1 1 +v3 #nl(e) +C0 #c1 +o2 #* +v0 #y +v4 #e +C1 #c2 +n0 +x0 #initial guess +r #2 ranges (rhs's) +2 0 #c1 +4 0 #c2 +b #3 bounds (on variables) +2 3 #y +3 #x +1 3 #z +k2 #intermediate Jacobian column lengths +2 +3 +J0 3 #c1 +0 0 +1 1 +2 0 +J1 2 #c2 +0 1 +2 -1 +""", + OUT.getvalue(), + ) + ) + + def test_presolve_fixes_nl_external_function(self): + # This tests a workaround for a bug in the ASL where external + # functions with constant argument expressions are not + # evaluated correctly. + DLL = find_GSL() + if not DLL: + self.skipTest("Could not find the amplgsl.dll library") + + m = ConcreteModel() + m.hypot = ExternalFunction(library=DLL, function="gsl_hypot") + m.p = Param(initialize=1, mutable=True) + m.x = Var(bounds=(None, 3)) + m.y = Var(bounds=(3, None)) + m.z = Var(initialize=1) + m.o = Objective(expr=m.z**2 * m.hypot(m.p * m.x, m.p + m.y) ** 2) + m.c = Constraint(expr=m.x == m.y) + + OUT = io.StringIO() + nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=False + ) + self.assertEqual( + *nl_diff( + """g3 1 1 0 #problem unknown + 3 1 1 0 1 #vars, constraints, objectives, ranges, eqns + 0 1 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 3 0 #nonlinear vars in constraints, objectives, both + 0 1 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 2 3 #nonzeros in Jacobian, obj. gradient + 1 1 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +F0 1 -1 gsl_hypot +C0 #c +n0 +O0 0 #o +o2 #* +o5 #^ +v0 #z +n2 +o5 #^ +f0 2 #hypot +v1 #x +o0 #+ +v2 #y +n1 +n2 +x1 #initial guess +0 1 #z +r #1 ranges (rhs's) +4 0 #c +b #3 bounds (on variables) +3 #z +1 3 #x +2 3 #y +k2 #intermediate Jacobian column lengths +0 +1 +J0 2 #c +1 1 +2 -1 +G0 3 #o +0 0 +1 0 +2 0 +""", + OUT.getvalue(), + ) + ) + + OUT = io.StringIO() + nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=True + ) + self.assertEqual( + *nl_diff( + """g3 1 1 0 #problem unknown + 1 0 1 0 0 #vars, constraints, objectives, ranges, eqns + 0 1 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 1 0 #nonlinear vars in constraints, objectives, both + 0 1 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 0 1 #nonzeros in Jacobian, obj. gradient + 1 1 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +F0 1 -1 gsl_hypot +O0 0 #o +o2 #* +o5 #^ +v0 #z +n2 +o5 #^ +f0 2 #hypot +n3 +n4 +n2 +x1 #initial guess +0 1 #z +r #0 ranges (rhs's) +b #1 bounds (on variables) +3 #z +k0 #intermediate Jacobian column lengths +G0 1 #o +0 0 +""", + OUT.getvalue(), + ) + ) + + def test_presolve_defined_var_to_const(self): + # This test is derived from a step in an IDAES initialization + # where the presolver is able to fix enough variables to cause + # the defined variable to be reduced to a constant. We must not + # emit the defined variable (because doing so generates an error + # in the ASL) + m = ConcreteModel() + m.eq = Var(initialize=100) + m.co2 = Var() + m.n2 = Var() + m.E = Expression(expr=60 / (3 * m.co2 - 4 * m.n2 - 5)) + m.con1 = Constraint(expr=m.co2 == 6) + m.con2 = Constraint(expr=m.n2 == 7) + m.con3 = Constraint(expr=8 / m.E == m.eq) + + OUT = io.StringIO() + nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=True + ) + # Note that the presolve will end up recognizing con3 as a + # linear constraint; however, it does not do so until processing + # the constraints after presolve (so the constraint is not + # actually removed and the eq variable still appears in the model) + self.assertEqual( + *nl_diff( + """g3 1 1 0 #problem unknown + 1 1 0 0 1 #vars, constraints, objectives, ranges, eqns + 0 0 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 0 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 1 0 #nonzeros in Jacobian, obj. gradient + 4 2 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +C0 #con3 +n0 +x1 #initial guess +0 100 #eq +r #1 ranges (rhs's) +4 2.0 #con3 +b #1 bounds (on variables) +3 #eq +k0 #intermediate Jacobian column lengths +J0 1 #con3 +0 -1 +""", + OUT.getvalue(), + ) + ) + + def test_presolve_check_invalid_monomial_constraints(self): + # This checks issue #3272 + m = ConcreteModel() + m.x = Var() + m.c = Constraint(expr=m.x == 5) + m.d = Constraint(expr=m.x >= 10) + + OUT = io.StringIO() + with self.assertRaisesRegex( + nl_writer.InfeasibleConstraintException, + r"model contains a trivially infeasible constraint 'd' " + r"\(fixed body value 5.0 outside bounds \[10, None\]\)\.", + ): + nl_writer.NLWriter().write(m, OUT, linear_presolve=True) + + def test_nested_external_expressions(self): + # This tests nested external functions in a single expression + DLL = find_GSL() + if not DLL: + self.skipTest("Could not find the amplgsl.dll library") + + m = ConcreteModel() + m.hypot = ExternalFunction(library=DLL, function="gsl_hypot") + m.p = Param(initialize=1, mutable=True) + m.x = Var(bounds=(None, 3)) + m.y = Var(bounds=(3, None)) + m.z = Var(initialize=1) + m.o = Objective(expr=m.z**2 * m.hypot(m.z, m.hypot(m.x, m.y)) ** 2) + m.c = Constraint(expr=m.x == m.y) + + OUT = io.StringIO() + nl_writer.NLWriter().write( + m, OUT, symbolic_solver_labels=True, linear_presolve=False + ) + self.assertEqual( + *nl_diff( + """g3 1 1 0 #problem unknown + 3 1 1 0 1 #vars, constraints, objectives, ranges, eqns + 0 1 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 3 0 #nonlinear vars in constraints, objectives, both + 0 1 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 2 3 #nonzeros in Jacobian, obj. gradient + 1 1 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +F0 1 -1 gsl_hypot +C0 #c +n0 +O0 0 #o +o2 #* +o5 #^ +v0 #z +n2 +o5 #^ +f0 2 #hypot +v0 #z +f0 2 #hypot +v1 #x +v2 #y +n2 +x1 #initial guess +0 1 #z +r #1 ranges (rhs's) +4 0 #c +b #3 bounds (on variables) +3 #z +1 3 #x +2 3 #y +k2 #intermediate Jacobian column lengths +0 +1 +J0 2 #c +1 1 +2 -1 +G0 3 #o +0 0 +1 0 +2 0 +""", + OUT.getvalue(), + ) + ) + + @unittest.skipUnless(numpy_available, "test requires numpy") + def test_objective_numpy_const(self): + # This tests issue #3352 + m = ConcreteModel() + m.e = Expression(expr=numpy.float64(0)) + m.obj = Objective(expr=m.e) + + OUT = io.StringIO() + nl_writer.NLWriter().write(m, OUT, linear_presolve=False, scale_model=True) + self.assertEqual( + *nl_diff( + """g3 1 1 0 #problem unknown + 0 0 1 0 0 #vars, constraints, objectives, ranges, eqns + 0 0 0 0 0 0 #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb + 0 0 #network constraints: nonlinear, linear + 0 0 0 #nonlinear vars in constraints, objectives, both + 0 0 0 1 #linear network variables; functions; arith, flags + 0 0 0 0 0 #discrete variables: binary, integer, nonlinear (b,c,o) + 0 0 #nonzeros in Jacobian, obj. gradient + 0 0 #max name lengths: constraints, variables + 0 0 0 0 0 #common exprs: b,c,o,c1,o1 +O0 0 +n0 +x0 +r +b +k-1 """, OUT.getvalue(), ) diff --git a/pyomo/repn/tests/ampl/test_suffixes.py b/pyomo/repn/tests/ampl/test_suffixes.py index 1372da68bdc..9e6d395ebfb 100644 --- a/pyomo/repn/tests/ampl/test_suffixes.py +++ b/pyomo/repn/tests/ampl/test_suffixes.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/baron/__init__.py b/pyomo/repn/tests/baron/__init__.py index c693bb8accd..d8fa0760df5 100644 --- a/pyomo/repn/tests/baron/__init__.py +++ b/pyomo/repn/tests/baron/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/baron/small14a_testCase.py b/pyomo/repn/tests/baron/small14a_testCase.py index b2cf5afcb72..9299bff3a83 100644 --- a/pyomo/repn/tests/baron/small14a_testCase.py +++ b/pyomo/repn/tests/baron/small14a_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/baron/test_baron.py b/pyomo/repn/tests/baron/test_baron.py index 6f22f26cd38..a11c0c528a1 100644 --- a/pyomo/repn/tests/baron/test_baron.py +++ b/pyomo/repn/tests/baron/test_baron.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/baron/test_baron_comparison.py b/pyomo/repn/tests/baron/test_baron_comparison.py index 1b394f6a5b1..a7378814e5a 100644 --- a/pyomo/repn/tests/baron/test_baron_comparison.py +++ b/pyomo/repn/tests/baron/test_baron_comparison.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/cpxlp/__init__.py b/pyomo/repn/tests/cpxlp/__init__.py index f216a76f48b..6988af2de8b 100644 --- a/pyomo/repn/tests/cpxlp/__init__.py +++ b/pyomo/repn/tests/cpxlp/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/cpxlp/test_cpxlp.py b/pyomo/repn/tests/cpxlp/test_cpxlp.py index 567c5184517..0d66e750823 100644 --- a/pyomo/repn/tests/cpxlp/test_cpxlp.py +++ b/pyomo/repn/tests/cpxlp/test_cpxlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/cpxlp/test_lpv2.py b/pyomo/repn/tests/cpxlp/test_lpv2.py index fbef24c77c3..825fbea0de2 100644 --- a/pyomo/repn/tests/cpxlp/test_lpv2.py +++ b/pyomo/repn/tests/cpxlp/test_lpv2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -76,7 +76,7 @@ def test_warn_export_suffixes(self): ) def test_deterministic_unordered_sets(self): - ref = """\\* Source Pyomo model name=unknown *\\ + ref = r"""\* Source Pyomo model name=unknown *\ min o: diff --git a/pyomo/repn/tests/diffutils.py b/pyomo/repn/tests/diffutils.py index c346f8c48b2..5e025464c61 100644 --- a/pyomo/repn/tests/diffutils.py +++ b/pyomo/repn/tests/diffutils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/gams/__init__.py b/pyomo/repn/tests/gams/__init__.py index e548666fd72..aa499abe22a 100644 --- a/pyomo/repn/tests/gams/__init__.py +++ b/pyomo/repn/tests/gams/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/gams/small14a_testCase.py b/pyomo/repn/tests/gams/small14a_testCase.py index 1efdd1baa25..b174b4bdb1f 100644 --- a/pyomo/repn/tests/gams/small14a_testCase.py +++ b/pyomo/repn/tests/gams/small14a_testCase.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/gams/test_gams.py b/pyomo/repn/tests/gams/test_gams.py index e3304e18491..947551afae0 100644 --- a/pyomo/repn/tests/gams/test_gams.py +++ b/pyomo/repn/tests/gams/test_gams.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/gams/test_gams_comparison.py b/pyomo/repn/tests/gams/test_gams_comparison.py index 42fa9f71dda..66afc5e0225 100644 --- a/pyomo/repn/tests/gams/test_gams_comparison.py +++ b/pyomo/repn/tests/gams/test_gams_comparison.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/lp_diff.py b/pyomo/repn/tests/lp_diff.py index 2c119d72c6f..89b531ab698 100644 --- a/pyomo/repn/tests/lp_diff.py +++ b/pyomo/repn/tests/lp_diff.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/mps/__init__.py b/pyomo/repn/tests/mps/__init__.py index effc182aa1c..7be7c158d9a 100644 --- a/pyomo/repn/tests/mps/__init__.py +++ b/pyomo/repn/tests/mps/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/mps/test_mps.py b/pyomo/repn/tests/mps/test_mps.py index ff7981b391c..2e33b59e6e3 100644 --- a/pyomo/repn/tests/mps/test_mps.py +++ b/pyomo/repn/tests/mps/test_mps.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/nl_diff.py b/pyomo/repn/tests/nl_diff.py index aa2b4519db3..cae7fc35b6e 100644 --- a/pyomo/repn/tests/nl_diff.py +++ b/pyomo/repn/tests/nl_diff.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,9 +15,7 @@ from difflib import SequenceMatcher, unified_diff from pyomo.repn.tests.diffutils import compare_floats, load_baseline -import pyomo.repn.plugins.nl_writer as nl_writer - -template = nl_writer.text_nl_debug_template +from pyomo.repn.ampl import TextNLDebugTemplate as template _norm_whitespace = re.compile(r'[^\S\n]+') _norm_integers = re.compile(r'(?m)\.0+$') diff --git a/pyomo/repn/tests/test_linear.py b/pyomo/repn/tests/test_linear.py index 6843650d0c2..86498a2b757 100644 --- a/pyomo/repn/tests/test_linear.py +++ b/pyomo/repn/tests/test_linear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,7 +19,7 @@ from pyomo.core.expr import Expr_if, inequality, LinearExpression, NPV_SumExpression import pyomo.repn.linear as linear from pyomo.repn.linear import LinearRepn, LinearRepnVisitor -from pyomo.repn.util import InvalidNumber +from pyomo.repn.util import InvalidNumber, OrderedVarRecorder from pyomo.environ import ( Any, @@ -35,15 +35,28 @@ nan = float('nan') -class VisitorConfig(object): +class VisitorConfig(dict): def __init__(self): self.subexpr = {} self.var_map = {} self.var_order = {} self.sorter = None + self.var_recorder = OrderedVarRecorder( + self.var_map, self.var_order, self.sorter + ) + super().__init__( + subexpression_cache=self.subexpr, var_recorder=self.var_recorder + ) - def __iter__(self): - return iter((self.subexpr, self.var_map, self.var_order, self.sorter)) + def order_quadratic(self, quad): + return { + ( + (vid1, vid2) + if self.var_order[vid1] <= self.var_order[vid2] + else (vid2, vid1) + ): val + for (vid1, vid2), val in quad.items() + } def sum_sq(args, fixed, fgh): @@ -63,7 +76,7 @@ def test_finalize(self): e = m.x + 2 * m.y - m.x - m.z cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y, id(m.z): m.z}) self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1, id(m.z): 2}) @@ -75,7 +88,7 @@ def test_finalize(self): e *= 5 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y, id(m.z): m.z}) self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1, id(m.z): 2}) @@ -87,7 +100,7 @@ def test_finalize(self): e = 5 * (m.y + m.z**2 + 3 * m.y**3) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.y): m.y, id(m.z): m.z}) self.assertEqual(cfg.var_order, {id(m.y): 0, id(m.z): 1}) @@ -102,7 +115,7 @@ def test_scalars(self): m.p = Param(mutable=True, initialize=2) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(3) + repn = LinearRepnVisitor(**cfg).walk_expression(3) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -112,7 +125,7 @@ def test_scalars(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression((-1) ** 0.5) + repn = LinearRepnVisitor(**cfg).walk_expression((-1) ** 0.5) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -122,7 +135,7 @@ def test_scalars(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.p) + repn = LinearRepnVisitor(**cfg).walk_expression(m.p) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -133,7 +146,7 @@ def test_scalars(self): m.p.set_value(None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.p) + repn = LinearRepnVisitor(**cfg).walk_expression(m.p) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -144,18 +157,18 @@ def test_scalars(self): m.p.set_value(nan) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.p) + repn = LinearRepnVisitor(**cfg).walk_expression(m.p) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) m.p.set_value(1j) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.p) + repn = LinearRepnVisitor(**cfg).walk_expression(m.p) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -165,7 +178,7 @@ def test_scalars(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.x) + repn = LinearRepnVisitor(**cfg).walk_expression(m.x) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -176,7 +189,7 @@ def test_scalars(self): m.x.fix(1) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.x) + repn = LinearRepnVisitor(**cfg).walk_expression(m.x) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -187,7 +200,7 @@ def test_scalars(self): m.x.fix(None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.x) + repn = LinearRepnVisitor(**cfg).walk_expression(m.x) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -198,23 +211,23 @@ def test_scalars(self): m.x.fix(nan) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.x) + repn = LinearRepnVisitor(**cfg).walk_expression(m.x) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) m.x.fix(1j) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.x) + repn = LinearRepnVisitor(**cfg).walk_expression(m.x) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(1j)') + self.assertEqual(repn.constant, InvalidNumber(1j)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -226,7 +239,7 @@ def test_npv(self): pow_expr = m.p ** (0.5) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -236,7 +249,7 @@ def test_npv(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -248,17 +261,17 @@ def test_npv(self): m.p = 0 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -270,7 +283,7 @@ def test_npv(self): m.p = -1 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -280,7 +293,7 @@ def test_npv(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -292,7 +305,7 @@ def test_npv(self): m.p = None cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -302,7 +315,7 @@ def test_npv(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -322,7 +335,7 @@ def test_monomial(self): pow_expr = (m.p ** (0.5)) * m.x cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(const_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(const_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -332,7 +345,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -342,7 +355,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -352,7 +365,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -364,7 +377,7 @@ def test_monomial(self): m.p = -1.0 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -374,7 +387,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -384,7 +397,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -396,7 +409,7 @@ def test_monomial(self): m.p = float('nan') cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -406,7 +419,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -416,7 +429,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -428,7 +441,7 @@ def test_monomial(self): m.p.set_value(None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -441,7 +454,7 @@ def test_monomial(self): m.x.fix(10) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(const_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(const_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -451,7 +464,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -461,7 +474,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -471,7 +484,7 @@ def test_monomial(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -483,39 +496,39 @@ def test_monomial(self): m.p = float('nan') cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(nested_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(nested_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(pow_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(pow_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) m.p.set_value(None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -529,7 +542,7 @@ def test_monomial(self): cfg = VisitorConfig() with LoggingIntercept() as LOG: - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(LOG.getvalue(), "") self.assertEqual(cfg.subexpr, {}) @@ -544,14 +557,14 @@ def test_monomial(self): cfg = VisitorConfig() with LoggingIntercept() as LOG: - repn = LinearRepnVisitor(*cfg).walk_expression(param_expr) + repn = LinearRepnVisitor(**cfg).walk_expression(param_expr) self.assertEqual(LOG.getvalue(), "") self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -563,7 +576,7 @@ def test_linear(self): e = LinearExpression() cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -575,7 +588,7 @@ def test_linear(self): e += m.x[0] cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, {id(m.x[0]): m.x[0], id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]} @@ -589,7 +602,7 @@ def test_linear(self): e += 2 * m.x[0] cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, {id(m.x[0]): m.x[0], id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]} @@ -603,7 +616,7 @@ def test_linear(self): e += m.p * m.x[1] cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, {id(m.x[0]): m.x[0], id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]} @@ -617,7 +630,7 @@ def test_linear(self): e += (m.p**0.5) * m.x[1] cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, {id(m.x[0]): m.x[0], id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]} @@ -631,7 +644,7 @@ def test_linear(self): e += 10 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, {id(m.x[0]): m.x[0], id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]} @@ -645,7 +658,7 @@ def test_linear(self): e += 10 * m.p cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, {id(m.x[0]): m.x[0], id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]} @@ -659,7 +672,7 @@ def test_linear(self): m.p = -1 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, {id(m.x[0]): m.x[0], id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]} @@ -676,7 +689,7 @@ def test_linear(self): e += (1 / m.p) * m.x[1] cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, {id(m.x[0]): m.x[0], id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]} @@ -692,10 +705,10 @@ def test_linear(self): m.x[0].fix(10) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x[1]): m.x[1], id(m.x[2]): m.x[2]}) - self.assertEqual(cfg.var_order, {id(m.x[1]): 0, id(m.x[2]): 1}) + self.assertEqual(cfg.var_order, {id(m.x[0]): 0, id(m.x[1]): 1, id(m.x[2]): 2}) self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, 40) self.assertStructuredAlmostEqual(repn.linear, {id(m.x[1]): InvalidNumber(nan)}) @@ -704,7 +717,7 @@ def test_linear(self): m.x[1].fix(10) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -720,10 +733,10 @@ def test_linear(self): e += m.x[2] + (1 / m.p) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x[2]): m.x[2]}) - self.assertEqual(cfg.var_order, {id(m.x[2]): 0}) + self.assertEqual(cfg.var_order, {id(m.x[0]): 0, id(m.x[1]): 1, id(m.x[2]): 2}) self.assertEqual(repn.multiplier, 1) self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {id(m.x[2]): 1}) @@ -734,7 +747,7 @@ def test_linear(self): cfg.var_map[id(m.x[0])] = m.x[0] cfg.var_order[id(m.x[2])] = 0 cfg.var_order[id(m.x[0])] = 1 - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x[2]): m.x[2], id(m.x[0]): m.x[0]}) self.assertEqual(cfg.var_order, {id(m.x[2]): 0, id(m.x[0]): 1}) @@ -748,7 +761,7 @@ def test_linear(self): cfg = VisitorConfig() with LoggingIntercept() as LOG: - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(LOG.getvalue(), "") self.assertEqual(cfg.subexpr, {}) @@ -763,9 +776,10 @@ def test_linear(self): cfg = VisitorConfig() with LoggingIntercept() as LOG: - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertIn( - "DEPRECATED: Encountered 0*nan in expression tree.", LOG.getvalue() + "DEPRECATED: Encountered 0*InvalidNumber(nan) in expression tree.", + LOG.getvalue(), ) self.assertEqual(cfg.subexpr, {}) @@ -783,7 +797,7 @@ def test_trig(self): e = cos(m.x) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -795,7 +809,7 @@ def test_trig(self): m.x.fix(0) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -812,7 +826,7 @@ def test_named_expr(self): e = m.e * 2 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(len(cfg.subexpr), 1) self.assertEqual(cfg.subexpr[id(m.e)][1].multiplier, 1) self.assertEqual(cfg.subexpr[id(m.e)][1].constant, 0) @@ -834,7 +848,7 @@ def test_named_expr(self): e = m.e * 2 + 3 * m.e cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(len(cfg.subexpr), 1) self.assertEqual(cfg.subexpr[id(m.e)][1].multiplier, 1) self.assertEqual(cfg.subexpr[id(m.e)][1].constant, 0) @@ -859,7 +873,7 @@ def test_named_expr(self): e = m.e * 2 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(len(cfg.subexpr), 1) self.assertEqual(cfg.subexpr[id(m.e)][1], 10) @@ -873,7 +887,7 @@ def test_named_expr(self): e = m.e * 2 + 3 * m.e cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(len(cfg.subexpr), 1) self.assertEqual(cfg.subexpr[id(m.e)][1], 10) @@ -887,7 +901,7 @@ def test_named_expr(self): m.e = None cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.e) + repn = LinearRepnVisitor(**cfg).walk_expression(m.e) self.assertEqual( cfg.subexpr, {id(m.e): (linear._CONSTANT, InvalidNumber(None))} ) @@ -899,7 +913,7 @@ def test_named_expr(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(2 * m.e) + repn = LinearRepnVisitor(**cfg).walk_expression(2 * m.e) self.assertEqual( cfg.subexpr, {id(m.e): (linear._CONSTANT, InvalidNumber(None))} ) @@ -918,7 +932,7 @@ def test_pow_expr(self): e = m.x**m.p cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -930,7 +944,7 @@ def test_pow_expr(self): m.p = 0 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -942,7 +956,7 @@ def test_pow_expr(self): m.p = 2 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -954,7 +968,7 @@ def test_pow_expr(self): m.x.fix(2) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -967,7 +981,7 @@ def test_pow_expr(self): m.x = -1 cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -980,7 +994,7 @@ def test_pow_expr(self): e = (1 + m.x) ** 2 cfg = VisitorConfig() - visitor = LinearRepnVisitor(*cfg) + visitor = LinearRepnVisitor(**cfg) visitor.max_exponential_expansion = 2 repn = visitor.walk_expression(e) @@ -993,7 +1007,7 @@ def test_pow_expr(self): assertExpressionsEqual(self, repn.nonlinear, (m.x + 1) * (m.x + 1)) cfg = VisitorConfig() - visitor = LinearRepnVisitor(*cfg) + visitor = LinearRepnVisitor(**cfg) visitor.max_exponential_expansion = 2 visitor.expand_nonlinear_products = True repn = visitor.walk_expression(e) @@ -1015,7 +1029,7 @@ def test_product(self): e = (2 + 3 * m.x + 4 * m.x**2) * (5 + 6 * m.x + 7 * m.x**2) cfg = VisitorConfig() - visitor = LinearRepnVisitor(*cfg) + visitor = LinearRepnVisitor(**cfg) visitor.expand_nonlinear_products = True repn = visitor.walk_expression(e) @@ -1040,7 +1054,7 @@ def test_product(self): e = m.x * m.y cfg = VisitorConfig() - visitor = LinearRepnVisitor(*cfg) + visitor = LinearRepnVisitor(**cfg) visitor.expand_nonlinear_products = True with LoggingIntercept() as LOG: repn = visitor.walk_expression(e) @@ -1059,7 +1073,7 @@ def test_product(self): e = m.x * (m.y + 2 + m.z) cfg = VisitorConfig() - visitor = LinearRepnVisitor(*cfg) + visitor = LinearRepnVisitor(**cfg) visitor.expand_nonlinear_products = True with LoggingIntercept() as LOG: repn = visitor.walk_expression(e) @@ -1085,7 +1099,7 @@ def test_expr_if(self): m.y.fix(2) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1095,7 +1109,7 @@ def test_expr_if(self): assertExpressionsEqual(self, repn.nonlinear, m.x**2) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(f) + repn = LinearRepnVisitor(**cfg).walk_expression(f) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1105,7 +1119,7 @@ def test_expr_if(self): assertExpressionsEqual(self, repn.nonlinear, m.x**2) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(g) + repn = LinearRepnVisitor(**cfg).walk_expression(g) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1117,7 +1131,7 @@ def test_expr_if(self): m.y.fix(5) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1127,7 +1141,7 @@ def test_expr_if(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(f) + repn = LinearRepnVisitor(**cfg).walk_expression(f) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1137,7 +1151,7 @@ def test_expr_if(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(g) + repn = LinearRepnVisitor(**cfg).walk_expression(g) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1150,7 +1164,7 @@ def test_expr_if(self): m.x.fix(3) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1160,7 +1174,7 @@ def test_expr_if(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(f) + repn = LinearRepnVisitor(**cfg).walk_expression(f) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1170,7 +1184,7 @@ def test_expr_if(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(g) + repn = LinearRepnVisitor(**cfg).walk_expression(g) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1183,7 +1197,7 @@ def test_expr_if(self): m.x.fix(6) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1193,7 +1207,7 @@ def test_expr_if(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(f) + repn = LinearRepnVisitor(**cfg).walk_expression(f) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1203,7 +1217,7 @@ def test_expr_if(self): self.assertEqual(repn.nonlinear, None) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(g) + repn = LinearRepnVisitor(**cfg).walk_expression(g) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1216,7 +1230,7 @@ def test_expr_if(self): m.x.unfix() cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1230,7 +1244,7 @@ def test_expr_if(self): ) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(f) + repn = LinearRepnVisitor(**cfg).walk_expression(f) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1244,7 +1258,7 @@ def test_expr_if(self): ) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(g) + repn = LinearRepnVisitor(**cfg).walk_expression(g) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1260,7 +1274,7 @@ def test_expr_if(self): m.y.unfix() cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.y): m.y, id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.y): 0, id(m.x): 1}) @@ -1272,7 +1286,7 @@ def test_expr_if(self): ) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(f) + repn = LinearRepnVisitor(**cfg).walk_expression(f) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.y): m.y, id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.y): 0, id(m.x): 1}) @@ -1284,7 +1298,7 @@ def test_expr_if(self): ) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(g) + repn = LinearRepnVisitor(**cfg).walk_expression(g) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.y): m.y, id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.y): 0, id(m.x): 1}) @@ -1300,7 +1314,7 @@ def test_expr_if(self): h = Expr_if(1 / m.y >= 1, m.x, m.x**2) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(h) + repn = LinearRepnVisitor(**cfg).walk_expression(h) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) self.assertEqual(cfg.var_order, {id(m.y): 0, id(m.x): 1}) @@ -1313,7 +1327,7 @@ def test_expr_if(self): m.y.fix(0) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(h) + repn = LinearRepnVisitor(**cfg).walk_expression(h) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1335,7 +1349,7 @@ def test_division(self): m.y.fix(2) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1347,7 +1361,7 @@ def test_division(self): e = m.y / (m.x + 1) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1364,7 +1378,7 @@ def test_negation(self): e = -(m.x + 2) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -1376,7 +1390,7 @@ def test_negation(self): m.x.fix(3) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1394,7 +1408,7 @@ def test_external(self): e = m.sq(2 / m.x, 2 * m.y) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1407,7 +1421,7 @@ def test_external(self): m.y.fix(3) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1419,7 +1433,7 @@ def test_external(self): m.x.fix(0) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1436,10 +1450,25 @@ def test_errors_propagate_nan(self): m.z = Var() m.y.fix(1) + expr = (m.x + 1) / m.p + cfg = VisitorConfig() + with LoggingIntercept() as LOG: + repn = LinearRepnVisitor(**cfg).walk_expression(expr) + self.assertEqual( + LOG.getvalue(), + "Exception encountered evaluating expression 'div(1, 0)'\n" + "\tmessage: division by zero\n" + "\texpression: (x + 1)/p\n", + ) + self.assertEqual(repn.multiplier, 1) + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) + self.assertStructuredAlmostEqual(repn.linear, {id(m.x): InvalidNumber(nan)}) + self.assertEqual(repn.nonlinear, None) + expr = m.y + m.x + m.z + ((3 * m.x) / m.p) / m.y cfg = VisitorConfig() with LoggingIntercept() as LOG: - repn = LinearRepnVisitor(*cfg).walk_expression(expr) + repn = LinearRepnVisitor(**cfg).walk_expression(expr) self.assertEqual( LOG.getvalue(), "Exception encountered evaluating expression 'div(3, 0)'\n" @@ -1448,21 +1477,21 @@ def test_errors_propagate_nan(self): ) self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, 1) - self.assertEqual(len(repn.linear), 2) - self.assertEqual(repn.linear[id(m.z)], 1) - self.assertEqual(str(repn.linear[id(m.x)]), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual( + repn.linear, {id(m.z): 1, id(m.x): InvalidNumber(nan)} + ) self.assertEqual(repn.nonlinear, None) m.y.fix(None) expr = log(m.y) + 3 - repn = LinearRepnVisitor(*cfg).walk_expression(expr) + repn = LinearRepnVisitor(**cfg).walk_expression(expr) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(nan)') + self.assertStructuredAlmostEqual(repn.constant, InvalidNumber(nan)) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) expr = 3 * m.y - repn = LinearRepnVisitor(*cfg).walk_expression(expr) + repn = LinearRepnVisitor(**cfg).walk_expression(expr) self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, InvalidNumber(None)) self.assertEqual(repn.linear, {}) @@ -1470,7 +1499,7 @@ def test_errors_propagate_nan(self): m.p.value = None expr = 5 * (m.p * m.x + 2 * m.z) - repn = LinearRepnVisitor(*cfg).walk_expression(expr) + repn = LinearRepnVisitor(**cfg).walk_expression(expr) self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, 0) self.assertEqual(len(repn.linear), 2) @@ -1479,7 +1508,7 @@ def test_errors_propagate_nan(self): self.assertEqual(repn.nonlinear, None) expr = m.y * m.x - repn = LinearRepnVisitor(*cfg).walk_expression(expr) + repn = LinearRepnVisitor(**cfg).walk_expression(expr) self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, 0) self.assertEqual(len(repn.linear), 1) @@ -1489,14 +1518,14 @@ def test_errors_propagate_nan(self): m.z = Var([1, 2, 3, 4], initialize=lambda m, i: i - 1) m.z[1].fix(None) expr = m.z[1] - ((m.z[2] * m.z[3]) * m.z[4]) - repn = LinearRepnVisitor(*cfg).walk_expression(expr) + repn = LinearRepnVisitor(**cfg).walk_expression(expr) self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, InvalidNumber(None)) self.assertEqual(repn.linear, {}) self.assertIsNotNone(repn.nonlinear) m.z[3].fix(float('nan')) - repn = LinearRepnVisitor(*cfg).walk_expression(expr) + repn = LinearRepnVisitor(**cfg).walk_expression(expr) self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, InvalidNumber(None)) self.assertEqual(repn.linear, {}) @@ -1506,9 +1535,9 @@ def test_type_registrations(self): m = ConcreteModel() cfg = VisitorConfig() - visitor = LinearRepnVisitor(*cfg) + visitor = LinearRepnVisitor(**cfg) - _orig_dispatcher = linear._before_child_dispatcher + _orig_dispatcher = visitor.before_child_dispatcher linear._before_child_dispatcher = bcd = _orig_dispatcher.__class__() bcd.clear() try: @@ -1561,7 +1590,7 @@ def test_to_expression(self): m.y = Var() cfg = VisitorConfig() - visitor = LinearRepnVisitor(*cfg) + visitor = LinearRepnVisitor(**cfg) # prepopulate the visitor's var_map visitor.walk_expression(m.x + m.y) @@ -1589,7 +1618,7 @@ def test_to_expression(self): expr.constant = 0 expr.linear[id(m.x)] = 0 expr.linear[id(m.y)] = 0 - assertExpressionsEqual(self, expr.to_expression(visitor), LinearExpression()) + assertExpressionsEqual(self, expr.to_expression(visitor), 0) @unittest.skipUnless(numpy_available, "Test requires numpy") def test_nonnumeric(self): @@ -1598,7 +1627,7 @@ def test_nonnumeric(self): m.e = Expression() cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.p) + repn = LinearRepnVisitor(**cfg).walk_expression(m.p) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -1610,12 +1639,14 @@ def test_nonnumeric(self): m.p = numpy.array([3, 4]) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(m.p) + repn = LinearRepnVisitor(**cfg).walk_expression(m.p) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) self.assertEqual(repn.multiplier, 1) - self.assertEqual(str(repn.constant), 'InvalidNumber(array([3, 4]))') + self.assertStructuredAlmostEqual( + repn.constant, InvalidNumber(numpy.array([3, 4])) + ) self.assertEqual(repn.linear, {}) self.assertEqual(repn.nonlinear, None) @@ -1626,7 +1657,7 @@ def test_zero_elimination(self): e = 0 * m.x[0] + 0 * m.x[1] * m.x[2] + 0 * log(m.x[3]) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, @@ -1643,13 +1674,13 @@ def test_zero_elimination(self): self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.nonlinear, None) + self.assertIsNone(repn.nonlinear) m.p = Param(mutable=True, within=Any, initialize=None) e = m.p * m.x[0] + m.p * m.x[1] * m.x[2] + m.p * log(m.x[3]) cfg = VisitorConfig() - repn = LinearRepnVisitor(*cfg).walk_expression(e) + repn = LinearRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, diff --git a/pyomo/repn/tests/test_parameterized_linear.py b/pyomo/repn/tests/test_parameterized_linear.py new file mode 100644 index 00000000000..58ecefc167b --- /dev/null +++ b/pyomo/repn/tests/test_parameterized_linear.py @@ -0,0 +1,552 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.log import LoggingIntercept +import pyomo.common.unittest as unittest +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.environ import Any, Binary, ConcreteModel, log, Param, Var +from pyomo.repn.parameterized_linear import ParameterizedLinearRepnVisitor +from pyomo.repn.tests.test_linear import VisitorConfig +from pyomo.repn.util import InvalidNumber + + +class TestParameterizedLinearRepnVisitor(unittest.TestCase): + def make_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 45)) + m.y = Var(domain=Binary) + m.z = Var() + + return m + + def test_walk_sum(self): + m = self.make_model() + e = m.x + m.y + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.x), repn.linear) + self.assertEqual(repn.linear[id(m.x)], 1) + self.assertIs(repn.constant, m.y) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), m.x + m.y) + + def test_walk_triple_sum(self): + m = self.make_model() + e = m.x + m.z * m.y + m.z + + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.z]) + + repn = visitor.walk_expression(e) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 2) + self.assertIn(id(m.x), repn.linear) + self.assertIn(id(m.y), repn.linear) + self.assertEqual(repn.linear[id(m.x)], 1) + self.assertIs(repn.linear[id(m.y)], m.z) + self.assertIs(repn.constant, m.z) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), m.x + m.z * m.y + m.z) + + def test_sum_two_of_the_same(self): + # This hits the mult == 1 and vid in dest_dict case in _merge_dict + m = self.make_model() + e = m.x + m.x + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.z]) + + repn = visitor.walk_expression(e) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.x), repn.linear) + self.assertEqual(repn.linear[id(m.x)], 2) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), 2 * m.x) + + def test_sum_with_mult_0(self): + m = self.make_model() + e = 0 * m.x + m.x - m.y + + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.x), repn.linear) + self.assertEqual(repn.linear[id(m.x)], 1) + assertExpressionsEqual(self, repn.constant, -m.y) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), m.x - m.y) + + def test_sum_nonlinear_to_linear(self): + m = self.make_model() + e = m.y * m.x**2 + m.y * m.x - 3 + + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + assertExpressionsEqual(self, repn.nonlinear, m.y * m.x**2) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.x), repn.linear) + self.assertIs(repn.linear[id(m.x)], m.y) + self.assertEqual(repn.constant, -3) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual( + self, repn.to_expression(visitor), m.y * m.x**2 + m.y * m.x - 3 + ) + + def test_sum_nonlinear_to_nonlinear(self): + m = self.make_model() + e = m.x**3 + 3 + m.x**2 + + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + assertExpressionsEqual(self, repn.nonlinear, m.x**3 + m.x**2) + self.assertEqual(repn.constant, 3) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), m.x**3 + m.x**2 + 3) + + def test_sum_to_linear_expr(self): + m = self.make_model() + e = m.x + m.y * (m.x + 5) + + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.x), repn.linear) + assertExpressionsEqual(self, repn.linear[id(m.x)], 1 + m.y) + assertExpressionsEqual(self, repn.constant, m.y * 5) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual( + self, repn.to_expression(visitor), (1 + m.y) * m.x + m.y * 5 + ) + + def test_bilinear_term(self): + m = self.make_model() + e = m.x * m.y + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.x), repn.linear) + self.assertIs(repn.linear[id(m.x)], m.y) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), m.y * m.x) + + def test_distributed_bilinear_term(self): + m = self.make_model() + e = m.y * (m.x + 7) + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.x), repn.linear) + self.assertIs(repn.linear[id(m.x)], m.y) + assertExpressionsEqual(self, repn.constant, m.y * 7) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), m.y * m.x + m.y * 7) + + def test_monomial(self): + m = self.make_model() + e = 45 * m.y + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.x, m.z]) + + repn = visitor.walk_expression(e) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.y), repn.linear) + self.assertEqual(repn.linear[id(m.y)], 45) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), 45 * m.y) + + def test_constant(self): + m = self.make_model() + e = 45 * m.y + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 0) + assertExpressionsEqual(self, repn.constant, 45 * m.y) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), 45 * m.y) + + def test_fixed_var(self): + m = self.make_model() + m.x.fix(42) + e = (m.y**2) * (m.x + m.x**2) + + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(len(repn.linear), 0) + assertExpressionsEqual(self, repn.constant, (m.y**2) * 1806) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.to_expression(visitor), (m.y**2) * 1806) + + def test_nonlinear(self): + m = self.make_model() + e = (m.y * log(m.x)) * (m.y + 2) / m.x + + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]) + + repn = visitor.walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.nonlinear, log(m.x) * (m.y * (m.y + 2)) / m.x) + assertExpressionsEqual( + self, repn.to_expression(visitor), log(m.x) * (m.y * (m.y + 2)) / m.x + ) + + def test_finalize(self): + m = self.make_model() + m.w = Var() + + e = m.x + 2 * m.w**2 * m.y - m.x - m.w * m.z + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.w]).walk_expression(e) + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y, id(m.z): m.z}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1, id(m.z): 2}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(len(repn.linear), 2) + self.assertIn(id(m.y), repn.linear) + assertExpressionsEqual(self, repn.linear[id(m.y)], 2 * m.w**2) + self.assertIn(id(m.z), repn.linear) + assertExpressionsEqual(self, repn.linear[id(m.z)], -m.w) + self.assertEqual(repn.nonlinear, None) + + e *= 5 + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.w]).walk_expression(e) + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y, id(m.z): m.z}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1, id(m.z): 2}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(len(repn.linear), 2) + self.assertIn(id(m.y), repn.linear) + assertExpressionsEqual(self, repn.linear[id(m.y)], 5 * (2 * m.w**2)) + self.assertIn(id(m.z), repn.linear) + assertExpressionsEqual(self, repn.linear[id(m.z)], -5 * m.w) + self.assertEqual(repn.nonlinear, None) + + e = 5 * (m.w * m.y + m.z**2 + 3 * m.w * m.y**3) + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.w]).walk_expression(e) + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.y): m.y, id(m.z): m.z}) + self.assertEqual(cfg.var_order, {id(m.y): 0, id(m.z): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.y), repn.linear) + assertExpressionsEqual(self, repn.linear[id(m.y)], 5 * m.w) + assertExpressionsEqual(self, repn.nonlinear, (m.z**2 + 3 * m.w * m.y**3) * 5) + + def test_ANY_over_constant_division(self): + m = ConcreteModel() + m.p = Param(mutable=True, initialize=2, domain=Any) + m.x = Var() + m.z = Var() + m.y = Var() + # We will use the fixed value regardless of the fact that we aren't + # treating this as a Var. + m.y.fix(1) + + expr = m.y + m.x + m.z + ((3 * m.z * m.x) / m.p) / m.y + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]).walk_expression( + expr + ) + + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 1 + m.z) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual(self, repn.linear[id(m.x)], 1 + 1.5 * m.z) + self.assertEqual(repn.nonlinear, None) + + def test_errors_propagate_nan(self): + m = ConcreteModel() + m.p = Param(mutable=True, initialize=0, domain=Any) + m.x = Var() + m.z = Var() + m.y = Var() + m.y.fix(1) + + expr = m.y + m.x + m.z + ((3 * m.z * m.x) / m.p) / m.y + cfg = VisitorConfig() + with LoggingIntercept() as LOG: + repn = ParameterizedLinearRepnVisitor( + **cfg, wrt=[m.y, m.z] + ).walk_expression(expr) + self.assertEqual( + LOG.getvalue(), + "Exception encountered evaluating expression 'div(3*z, 0)'\n" + "\tmessage: division by zero\n" + "\texpression: 3*z*x/p\n", + ) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 1 + m.z) + self.assertEqual(len(repn.linear), 1) + self.assertIsInstance(repn.linear[id(m.x)], InvalidNumber) + assertExpressionsEqual(self, repn.linear[id(m.x)].value, 1 + float('nan')) + self.assertEqual(repn.nonlinear, None) + + m.y.fix(None) + expr = m.z * log(m.y) + 3 + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]).walk_expression( + expr + ) + self.assertEqual(repn.multiplier, 1) + self.assertIsInstance(repn.constant, InvalidNumber) + assertExpressionsEqual(self, repn.constant.value, float('nan') * m.z + 3) + self.assertEqual(repn.linear, {}) + self.assertEqual(repn.nonlinear, None) + + def test_negation_constant(self): + m = self.make_model() + e = -(m.y * m.z + 17) + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, -1 * (m.y * m.z + 17)) + self.assertIsNone(repn.nonlinear) + + def test_product_nonlinear(self): + m = self.make_model() + e = (m.x**2) * (log(m.y) * m.z**4) * m.y + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + assertExpressionsEqual( + self, repn.nonlinear, (m.x**2) * (m.z**4 * log(m.y)) * m.y + ) + + def test_division_pseudo_constant_constant(self): + m = self.make_model() + e = m.x / 4 + m.y + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.x]).walk_expression(e) + + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.y), repn.linear) + self.assertEqual(repn.linear[id(m.y)], 1) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, m.x / 4) + self.assertIsNone(repn.nonlinear) + + e = 4 / m.x + m.y + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.x]).walk_expression(e) + + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.y), repn.linear) + self.assertEqual(repn.linear[id(m.y)], 1) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 4 / m.x) + self.assertIsNone(repn.nonlinear) + + e = m.z / m.x + m.y + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.x, m.z]).walk_expression(e) + + self.assertEqual(len(repn.linear), 1) + self.assertIn(id(m.y), repn.linear) + self.assertEqual(repn.linear[id(m.y)], 1) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, m.z / m.x) + self.assertIsNone(repn.nonlinear) + + def test_division_ANY_pseudo_constant(self): + m = self.make_model() + e = (m.x + 3 * m.z) / m.y + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]).walk_expression(e) + + self.assertEqual(len(repn.linear), 2) + self.assertIn(id(m.x), repn.linear) + assertExpressionsEqual(self, repn.linear[id(m.x)], 1 / m.y) + self.assertIn(id(m.z), repn.linear) + assertExpressionsEqual(self, repn.linear[id(m.z)], (1 / m.y) * 3) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertIsNone(repn.nonlinear) + + def test_duplicate(self): + m = self.make_model() + e = (1 + m.x) ** 2 + m.y + + cfg = VisitorConfig() + visitor = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]) + visitor.max_exponential_expansion = 2 + repn = visitor.walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertIs(repn.constant, m.y) + assertExpressionsEqual(self, repn.nonlinear, (m.x + 1) * (m.x + 1)) + + def test_pow_ANY_pseudo_constant(self): + m = self.make_model() + e = (m.x**2 + 3 * m.z) ** m.y + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + assertExpressionsEqual(self, repn.nonlinear, (m.x**2 + 3 * m.z) ** m.y) + + def test_pow_pseudo_constant_ANY(self): + m = self.make_model() + e = m.y ** (m.x**2 + 3 * m.z) + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + assertExpressionsEqual(self, repn.nonlinear, m.y ** (m.x**2 + 3 * m.z)) + + def test_pow_linear_pseudo_constant(self): + m = self.make_model() + e = (m.x + 3 * m.z) ** m.y + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + assertExpressionsEqual(self, repn.nonlinear, (m.x + 3 * m.z) ** m.y) + + def test_pow_pseudo_constant_linear(self): + m = self.make_model() + e = m.y ** (m.x + 3 * m.z) + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + assertExpressionsEqual(self, repn.nonlinear, m.y ** (m.x + 3 * m.z)) + + def test_0_mult(self): + m = self.make_model() + m.p = Var() + m.p.fix(0) + e = m.p * (m.y**2 + m.z) + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.z]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertIsNone(repn.nonlinear) + self.assertEqual(repn.constant, 0) + + def test_0_mult_nan(self): + m = self.make_model() + m.p = Param(initialize=0, mutable=True) + m.y.domain = Any + m.y.fix(float('nan')) + e = m.p * (m.y**2 + m.x) + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.x]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertIsNone(repn.nonlinear) + self.assertIsInstance(repn.constant, InvalidNumber) + assertExpressionsEqual(self, repn.constant.value, 0 * (float('nan') + m.x)) + + def test_0_mult_nan_param(self): + m = self.make_model() + m.p = Param(initialize=0, mutable=True) + m.y.fix(float('nan')) + e = m.p * (m.y**2) + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]).walk_expression(e) + + self.assertEqual(len(repn.linear), 0) + self.assertEqual(repn.multiplier, 1) + self.assertIsNone(repn.nonlinear) + self.assertIsInstance(repn.constant, InvalidNumber) + assertExpressionsEqual(self, repn.constant.value, 0 * float('nan')) + + def test_0_mult_linear_with_nan(self): + m = self.make_model() + m.p = Param(initialize=0, mutable=True) + m.x.domain = Any + m.x.fix(float('nan')) + e = m.p * (3 * m.x * m.y + m.z) + + cfg = VisitorConfig() + repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.x]).walk_expression(e) + + self.assertEqual(len(repn.linear), 2) + self.assertIn(id(m.y), repn.linear) + self.assertIsInstance(repn.linear[id(m.y)], InvalidNumber) + assertExpressionsEqual(self, repn.linear[id(m.y)].value, 0 * 3 * float('nan')) + self.assertIn(id(m.z), repn.linear) + self.assertEqual(repn.linear[id(m.z)], 0) + self.assertEqual(repn.multiplier, 1) + self.assertIsNone(repn.nonlinear) + self.assertEqual(repn.constant, 0) diff --git a/pyomo/repn/tests/test_parameterized_quadratic.py b/pyomo/repn/tests/test_parameterized_quadratic.py new file mode 100644 index 00000000000..daf8616a36f --- /dev/null +++ b/pyomo/repn/tests/test_parameterized_quadratic.py @@ -0,0 +1,1463 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +from math import isnan +import unittest + +from pyomo.core.expr import SumExpression, MonomialTermExpression +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.environ import Any, ConcreteModel, log, Param, Var +from pyomo.repn.parameterized_quadratic import ParameterizedQuadraticRepnVisitor +from pyomo.repn.tests.test_linear import VisitorConfig +from pyomo.repn.util import InvalidNumber + + +def build_test_model(): + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.z = Var() + m.p = Param(initialize=1, mutable=True) + + return m + + +class TestParameterizedQuadratic(unittest.TestCase): + def test_constant_literal(self): + """ + Ensure ParameterizedQuadraticRepnVisitor(*args, wrt=[]) works + like QuadraticRepnVisitor. + """ + expr = 2 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {}) + self.assertEqual(cfg.var_order, {}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 2) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + self.assertEqual(repn.to_expression(visitor), 2) + + def test_constant_param(self): + m = build_test_model() + m.p.set_value(2) + expr = 2 + m.p + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {}) + self.assertEqual(cfg.var_order, {}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 4) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), 4) + + def test_binary_sum_identical_terms(self): + m = build_test_model() + expr = m.x + m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {id(m.x): 2}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), 2 * m.x) + + def test_binary_sum_identical_terms_wrt_x(self): + m = build_test_model() + expr = m.x + m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.x]) + # note: covers walker_exitNode for case where + # constant is a fixed expression + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {}) + self.assertEqual(cfg.var_order, {}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, m.x + m.x) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), m.x + m.x) + + def test_binary_sum_nonidentical_terms(self): + m = build_test_model() + expr = m.x + m.y + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {id(m.x): 1, id(m.y): 1}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), m.x + m.y) + + def test_binary_sum_nonidentical_terms_wrt_x(self): + m = build_test_model() + expr = m.x + m.y + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.x]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.y): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, m.x) + self.assertEqual(repn.linear, {id(m.y): 1}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), m.y + m.x) + + def test_ternary_sum_with_product(self): + m = build_test_model() + e = m.x + m.z * m.y + m.z + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.z): m.z, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.z): 1, id(m.y): 2}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(len(repn.linear), 2) + self.assertEqual(repn.linear[id(m.x)], 1) + self.assertEqual(repn.linear[id(m.z)], 1) + self.assertEqual(len(repn.quadratic), 1) + self.assertEqual(repn.quadratic[(id(m.z), id(m.y))], 1) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, repn.to_expression(visitor), m.z * m.y + (m.x + m.z) + ) + + def test_ternary_sum_with_product_wrt_z(self): + m = build_test_model() + e = m.x + m.z * m.y + m.z + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.z]) + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertIs(repn.constant, m.z) + self.assertEqual(len(repn.linear), 2) + self.assertEqual(repn.linear[id(m.x)], 1) + self.assertIs(repn.linear[id(m.y)], m.z) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), m.x + m.z * m.y + m.z) + + def test_nonlinear_wrt_x(self): + m = build_test_model() + expr = log(m.x) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.x]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {}) + self.assertEqual(cfg.var_order, {}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, log(m.x)) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), log(m.x)) + + def test_linear_constant_coeffs(self): + m = build_test_model() + e = 2 + 3 * m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + visitor.expand_nonlinear_products = True + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 2) + self.assertEqual(repn.linear, {id(m.x): 3}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), 3 * m.x + 2) + + def test_linear_constant_coeffs_wrt_x(self): + m = build_test_model() + e = 2 + 3 * m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.x]) + visitor.expand_nonlinear_products = True + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {}) + self.assertEqual(cfg.var_order, {}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 2 + 3 * m.x) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), 2 + 3 * m.x) + + def test_quadratic(self): + m = build_test_model() + e = 2 + 3 * m.x + 4 * m.x**2 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + visitor.expand_nonlinear_products = True + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 2) + self.assertEqual(repn.linear, {id(m.x): 3}) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 4}) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, repn.to_expression(visitor), 4 * m.x**2 + 3 * m.x + 2 + ) + + def test_product_quadratic_quadratic(self): + m = build_test_model() + e = (2 + 3 * m.x + 4 * m.x**2) * (5 + 6 * m.x + 7 * m.x**2) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + visitor.expand_nonlinear_products = True + repn = visitor.walk_expression(e) + + QE4 = SumExpression([4 * m.x**2]) + QE7 = SumExpression([7 * m.x**2]) + LE3 = MonomialTermExpression((3, m.x)) + LE6 = MonomialTermExpression((6, m.x)) + NL = +QE4 * (QE7 + LE6) + (LE3) * (QE7) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 10) + self.assertEqual(repn.linear, {id(m.x): 27}) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 52}) + assertExpressionsEqual(self, repn.nonlinear, NL) + assertExpressionsEqual( + self, repn.to_expression(visitor), NL + 52 * m.x**2 + 27 * m.x + 10 + ) + + def test_product_quadratic_quadratic_2(self): + m = build_test_model() + e = (2 + 3 * m.x + 4 * m.x**2) * (5 + 6 * m.x + 7 * m.x**2) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + visitor.expand_nonlinear_products = False + repn = visitor.walk_expression(e) + + NL = (4 * m.x**2 + 3 * m.x + 2) * (7 * m.x**2 + 6 * m.x + 5) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + assertExpressionsEqual(self, repn.nonlinear, NL) + assertExpressionsEqual(self, repn.to_expression(visitor), NL) + + def test_product_linear_linear(self): + m = build_test_model() + e = (1 + 2 * m.x + 3 * m.y) * (4 + 5 * m.x + 6 * m.y) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 4) + self.assertEqual(repn.linear, {id(m.x): 13, id(m.y): 18}) + self.assertEqual( + repn.quadratic, + {(id(m.x), id(m.x)): 10, (id(m.y), id(m.y)): 18, (id(m.x), id(m.y)): 27}, + ) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + (10 * m.x**2 + 27 * (m.x * m.y) + 18 * m.y**2 + (13 * m.x + 18 * m.y) + 4), + ) + + def test_product_linear_linear_wrt_y(self): + m = build_test_model() + e = (1 + 2 * m.x + 3 * m.y) * (4 + 5 * m.x + 6 * m.y) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, (1 + 3 * m.y) * (4 + 6 * m.y)) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual( + self, repn.linear[id(m.x)], (4 + 6 * m.y) * 2 + (1 + 3 * m.y) * 5 + ) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 10}) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + ( + 10 * m.x**2 + + ((4 + 6 * m.y) * 2 + (1 + 3 * m.y) * 5) * m.x + + (1 + 3 * m.y) * (4 + 6 * m.y) + ), + ) + + def test_product_linear_linear_const_0(self): + m = build_test_model() + expr = (0 + 3 * m.x + 4 * m.y) * (5 + 3 * m.x + 7 * m.y) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {id(m.x): 15, id(m.y): 20}) + self.assertEqual( + repn.quadratic, + {(id(m.x), id(m.x)): 9, (id(m.x), id(m.y)): 33, (id(m.y), id(m.y)): 28}, + ) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + 9 * m.x**2 + 33 * (m.x * m.y) + 28 * m.y**2 + (15 * m.x + 20 * m.y), + ) + + def test_product_linear_quadratic(self): + m = build_test_model() + expr = (5 + 3 * m.x + 7 * m.y) * (1 + 3 * m.x + 4 * m.y + 8 * m.y * m.x) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 5) + self.assertEqual(repn.linear, {id(m.x): 18, id(m.y): 27}) + self.assertEqual( + repn.quadratic, + {(id(m.x), id(m.y)): 73, (id(m.x), id(m.x)): 9, (id(m.y), id(m.y)): 28}, + ) + assertExpressionsEqual( + self, repn.nonlinear, (3 * m.x + 7 * m.y) * SumExpression([8 * (m.x * m.y)]) + ) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + ( + 73 * (m.x * m.y) + + 9 * m.x**2 + + 28 * m.y**2 + + (3 * m.x + 7 * m.y) * SumExpression([8 * (m.x * m.y)]) + + (18 * m.x + 27 * m.y) + + 5 + ), + ) + + def test_product_linear_quadratic_wrt_x(self): + m = build_test_model() + expr = (0 + 3 * m.x + 4 * m.y + 8 * m.y * m.x) * (5 + 3 * m.x + 7 * m.y) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.x]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.y): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 3 * m.x * (5 + 3 * m.x)) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual( + self, repn.linear[id(m.y)], (5 + 3 * m.x) * (4 + 8 * m.x) + 21 * m.x + ) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual( + self, repn.quadratic[id(m.y), id(m.y)], (4 + 8 * m.x) * 7 + ) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + (4 + 8 * m.x) * 7 * m.y**2 + + ((5 + 3 * m.x) * (4 + 8 * m.x) + 21 * m.x) * m.y + + 3 * m.x * (5 + 3 * m.x), + ) + + def test_product_nonlinear_var_expand_false(self): + m = build_test_model() + e = (m.x + m.y + log(m.x)) * m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + visitor.expand_nonlinear_products = False + repn = visitor.walk_expression(e) + + NL = (log(m.x) + (m.x + m.y)) * m.x + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + assertExpressionsEqual(self, repn.nonlinear, NL) + assertExpressionsEqual(self, repn.to_expression(visitor), NL) + + def test_product_nonlinear_var_expand_true(self): + m = build_test_model() + e = (m.x + m.y + log(m.x)) * m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + visitor.expand_nonlinear_products = True + repn = visitor.walk_expression(e) + + NL = log(m.x) * m.x + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 1, (id(m.x), id(m.y)): 1}) + assertExpressionsEqual(self, repn.nonlinear, NL) + + def test_product_nonlinear_var_2_expand_false(self): + m = build_test_model() + e = m.x * (m.x + m.y + log(m.x) + 2) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + visitor.expand_nonlinear_products = False + repn = visitor.walk_expression(e) + + NL = m.x * (log(m.x) + (m.x + m.y) + 2) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + assertExpressionsEqual(self, repn.nonlinear, NL) + assertExpressionsEqual(self, repn.to_expression(visitor), NL) + + def test_product_nonlinear_var_2_expand_true(self): + m = build_test_model() + e = m.x * (m.x + m.y + log(m.x) + 2) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + visitor.expand_nonlinear_products = True + repn = visitor.walk_expression(e) + + NL = m.x * log(m.x) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {id(m.x): 2}) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 1, (id(m.x), id(m.y)): 1}) + assertExpressionsEqual(self, repn.nonlinear, NL) + assertExpressionsEqual( + self, repn.to_expression(visitor), m.x**2 + m.x * m.y + NL + 2 * m.x + ) + + def test_zero_elimination(self): + m = ConcreteModel() + m.x = Var(range(4)) + e = 0 * m.x[0] + 0 * m.x[1] * m.x[2] + 0 * log(m.x[3]) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual( + cfg.var_map, + { + id(m.x[0]): m.x[0], + id(m.x[1]): m.x[1], + id(m.x[2]): m.x[2], + id(m.x[3]): m.x[3], + }, + ) + self.assertEqual( + cfg.var_order, {id(m.x[0]): 0, id(m.x[1]): 1, id(m.x[2]): 2, id(m.x[3]): 3} + ) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), 0) + + def test_uninitialized_param_expansion(self): + m = ConcreteModel() + m.x = Var(range(4)) + m.p = Param(mutable=True, within=Any, initialize=None) + e = m.p * m.x[0] + m.p * m.x[1] * m.x[2] + m.p * log(m.x[3]) + + cfg = VisitorConfig() + repn = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]).walk_expression(e) + self.assertEqual(cfg.subexpr, {}) + self.assertEqual( + cfg.var_map, + { + id(m.x[0]): m.x[0], + id(m.x[1]): m.x[1], + id(m.x[2]): m.x[2], + id(m.x[3]): m.x[3], + }, + ) + self.assertEqual( + cfg.var_order, {id(m.x[0]): 0, id(m.x[1]): 1, id(m.x[2]): 2, id(m.x[3]): 3} + ) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {id(m.x[0]): InvalidNumber(None)}) + self.assertEqual( + repn.quadratic, {(id(m.x[1]), id(m.x[2])): InvalidNumber(None)} + ) + self.assertEqual(repn.nonlinear, InvalidNumber(None)) + + def test_zero_times_var(self): + m = build_test_model() + e = 0 * m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(e) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), 0) + + def test_square_linear(self): + m = build_test_model() + expr = (1 + 3 * m.x + 4 * m.y) ** 2 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 1) + self.assertEqual(repn.linear, {id(m.x): 6, id(m.y): 8}) + self.assertEqual( + repn.quadratic, + {(id(m.x), id(m.x)): 9, (id(m.y), id(m.y)): 16, (id(m.x), id(m.y)): 24}, + ) + self.assertEqual(repn.nonlinear, None) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + 9 * m.x**2 + 24 * (m.x * m.y) + 16 * m.y**2 + (6 * m.x + 8 * m.y) + 1, + ) + + def test_square_linear_wrt_y(self): + m = build_test_model() + expr = (1 + 3 * m.x + 4 * m.y) ** 2 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, (1 + 4 * m.y) * (1 + 4 * m.y)) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual( + self, repn.linear[id(m.x)], (1 + 4 * m.y) * 3 + (1 + 4 * m.y) * 3 + ) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 9}) + self.assertEqual(repn.nonlinear, None) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + ( + 9 * m.x**2 + + ((1 + 4 * m.y) * 3 + (1 + 4 * m.y) * 3) * m.x + + ((1 + 4 * m.y) * (1 + 4 * m.y)) + ), + ) + + def test_square_linear_float(self): + m = build_test_model() + expr = (1 + 3 * m.x + 4 * m.y) ** 2.0 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 1) + self.assertEqual(repn.linear, {id(m.x): 6, id(m.y): 8}) + self.assertEqual( + repn.quadratic, + {(id(m.x), id(m.x)): 9, (id(m.y), id(m.y)): 16, (id(m.x), id(m.y)): 24}, + ) + self.assertEqual(repn.nonlinear, None) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + 9 * m.x**2 + 24 * (m.x * m.y) + 16 * m.y**2 + (6 * m.x + 8 * m.y) + 1, + ) + + def test_division_quadratic_nonlinear(self): + m = build_test_model() + expr = (1 + 3 * m.x + 4 * log(m.x) * m.y + 4 * m.y**2) / (2 * m.x) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertIsNone(repn.quadratic) + assertExpressionsEqual( + self, + repn.nonlinear, + (4 * m.y**2 + 4 * (log(m.x) * m.y) + 3 * m.x + 1) / (2 * m.x), + ) + assertExpressionsEqual(self, repn.to_expression(visitor), repn.nonlinear) + + def test_division_quadratic_nonlinear_wrt_x(self): + m = build_test_model() + expr = (1 + 3 * m.x + 4 * log(m.x) * m.y + 4 * m.y**2) / (2 * m.x) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.x]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.y): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, (1 + 3 * m.x) * (1 / (2 * m.x))) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual( + self, repn.linear[id(m.y)], (1 / (2 * m.x)) * (4 * log(m.x)) + ) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual( + self, repn.quadratic[id(m.y), id(m.y)], (1 / (2 * m.x)) * 4 + ) + self.assertEqual(repn.nonlinear, None) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + ((1 / (2 * m.x)) * 4) * m.y**2 + + ((1 / (2 * m.x)) * (4 * log(m.x))) * m.y + + (1 + 3 * m.x) * (1 / (2 * m.x)), + ) + + def test_constant_expr_multiplier(self): + m = build_test_model() + expr = 5 * (2 * m.x + m.x**2) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {id(m.x): 10}) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 5}) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), 5 * m.x**2 + 10 * m.x) + + def test_0_mult_nan_linear_coeff(self): + m = build_test_model() + expr = 0 * (float("nan") * m.x + m.y + log(m.x) + m.y * m.x**2 + 2 * m.x) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 0 * m.y) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual(self, repn.linear[id(m.x)], float("nan")) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual(self, repn.quadratic[id(m.x), id(m.x)], 0 * m.y) + assertExpressionsEqual(self, repn.nonlinear, (log(m.x)) * 0) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + 0 * m.y * m.x**2 + (log(m.x)) * 0 + float("nan") * m.x + 0 * m.y, + ) + + def test_0_mult_nan_quadratic_coeff(self): + m = build_test_model() + expr = 0 * (m.x + m.y + log(m.x) + float("nan") * m.x**2 + 2 * m.x) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 0 * m.y) + self.assertEqual(repn.linear, {id(m.x): 0}) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual(self, repn.quadratic[id(m.x), id(m.x)], float("nan")) + assertExpressionsEqual(self, repn.nonlinear, (log(m.x)) * 0) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + float("nan") * m.x**2 + (log(m.x)) * 0 + 0 * m.y, + ) + + def test_square_quadratic(self): + m = build_test_model() + expr = (1 + m.x + m.y + m.x**2 + m.x * m.y) ** 2.0 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + NL = (m.x**2 + m.x * m.y) * (m.x**2 + m.x * m.y + (m.x + m.y)) + ( + m.x + m.y + ) * (m.x**2 + m.x * m.y) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 1) + self.assertEqual(repn.linear, {id(m.x): 2, id(m.y): 2}) + self.assertEqual( + repn.quadratic, + {(id(m.x), id(m.x)): 3, (id(m.x), id(m.y)): 4, (id(m.y), id(m.y)): 1}, + ) + assertExpressionsEqual(self, repn.nonlinear, NL) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + NL + 3 * m.x**2 + 4 * (m.x * m.y) + m.y**2 + (2 * m.x + 2 * m.y) + 1, + ) + + def test_square_quadratic_wrt_y(self): + m = build_test_model() + expr = (1 + m.x + m.y + m.x**2 + m.x * m.y) ** 2.0 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y]) + repn = visitor.walk_expression(expr) + + NL = SumExpression([m.x**2]) * (m.x**2 + (1 + m.y) * m.x) + ( + (1 + m.y) * m.x + ) * SumExpression([m.x**2]) + QC = 1 + m.y + 1 + m.y + (1 + m.y) * (1 + m.y) + LC = (1 + m.y) * (1 + m.y) + (1 + m.y) * (1 + m.y) + CON = (1 + m.y) * (1 + m.y) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, (1 + m.y) * (1 + m.y)) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual( + self, repn.linear[id(m.x)], (1 + m.y) * (1 + m.y) + (1 + m.y) * (1 + m.y) + ) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual( + self, + repn.quadratic[id(m.x), id(m.x)], + 1 + m.y + 1 + m.y + (1 + m.y) * (1 + m.y), + ) + assertExpressionsEqual(self, repn.nonlinear, NL) + assertExpressionsEqual( + self, repn.to_expression(visitor), NL + QC * m.x**2 + LC * m.x + CON + ) + + def test_cube_linear(self): + m = build_test_model() + expr = (1 + m.x + m.y) ** 3 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + # cubic expansion not supported + assertExpressionsEqual(self, repn.nonlinear, (m.x + m.y + 1) ** 3) + assertExpressionsEqual(self, repn.to_expression(visitor), (m.x + m.y + 1) ** 3) + + def test_nonlinear_product_with_constant_terms(self): + m = build_test_model() + # test product of nonlinear expressions where one + # multiplicand has constant of value 1 + expr = (1 + log(m.x)) * (log(m.x) + m.y**2) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertEqual(repn.quadratic, {(id(m.y), id(m.y)): 1}) + assertExpressionsEqual( + self, repn.nonlinear, log(m.x) * (m.y**2 + log(m.x)) + log(m.x) + ) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + log(m.x) * (m.y**2 + log(m.x)) + log(m.x) + m.y**2, + ) + + def test_finalize_simplify_coefficients(self): + m = build_test_model() + expr = m.x + m.p * m.x**2 + 2 * m.y**2 - m.x - m.p * m.x**2 - m.p * m.z + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.z): m.z}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.z): 1}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 2 * m.y**2) + self.assertEqual(repn.linear, {id(m.z): -1}) + self.assertEqual(repn.quadratic, {}) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual(self, repn.to_expression(visitor), -1 * m.z + 2 * m.y**2) + + def test_factor_multiplier_simplify_coefficients(self): + m = build_test_model() + expr = 2 * (m.x + m.x**2 + 2 * m.y**2 - m.x - m.x**2 - m.p * m.z) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y]) + # this tests case where there are zeros in the `linear` + # and `quadratic` dicts of the unfinalized repn + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.z): m.z}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.z): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertIsNone(repn.nonlinear) + self.assertEqual(repn.quadratic, {}) + self.assertEqual(repn.linear, {id(m.z): -2}) + assertExpressionsEqual(self, repn.constant, (2 * m.y**2) * 2) + assertExpressionsEqual( + self, repn.to_expression(visitor), -2 * m.z + (2 * m.y**2) * 2 + ) + + def test_sum_nonlinear_custom_multiplier(self): + m = build_test_model() + expr = 2 * (1 + log(m.x)) + (2 * (m.y + m.y**2 + log(m.x))) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 2 + 2 * (m.y + m.y**2)) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + assertExpressionsEqual(self, repn.nonlinear, 2 * log(m.x) + 2 * log(m.x)) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + 2 * log(m.x) + 2 * log(m.x) + 2 + 2 * (m.y + m.y**2), + ) + + def test_negation_linear(self): + m = build_test_model() + expr = -(2 + 3 * m.x + 5 * m.x * m.y) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, -2) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual(self, repn.linear[id(m.x)], -1 * (3 + 5 * m.y)) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, repn.to_expression(visitor), -1 * (3 + 5 * m.y) * m.x - 2 + ) + + def test_negation_nonlinear_wrt_y_fix_z(self): + m = build_test_model() + m.z.fix(2) + expr = -( + 2 + + 3 * m.x + + 4 * m.y * m.z + + 5 * m.x**2 * m.y + + 6 * m.x * (m.z - 2) + + m.z**2 + + m.z * log(m.x) + ) + + cfg = VisitorConfig() + # note: variable fixing takes precedence over inclusion in + # the `wrt` list; that is tested here + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, (2 + 8 * m.y + 4) * -1) + self.assertEqual(repn.linear, {id(m.x): -3}) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual(self, repn.quadratic[(id(m.x), id(m.x))], -5 * m.y) + assertExpressionsEqual(self, repn.nonlinear, 2 * log(m.x) * -1) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + +(-5 * m.y) * (m.x**2) + + 2 * log(m.x) * -1 + + (-3) * m.x + + (2 + 8 * m.y + 4) * (-1), + ) + + def test_negation_product_linear_linear(self): + m = build_test_model() + expr = -(1 + 2 * m.x + 3 * m.y) * (4 + 5 * m.x + 6 * m.y * 7 * m.z) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual( + self, repn.constant, (1 + 3 * m.y) * (4 + 42 * m.y * m.z) * (-1) + ) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual( + self, + repn.linear[id(m.x)], + (-1) * ((4 + 42 * m.y * m.z) * 2 + (1 + 3 * m.y) * 5), + ) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual(self, repn.quadratic[id(m.x), id(m.x)], -10) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + ( + -10 * m.x**2 + + (-1) * ((4 + 42 * m.y * m.z) * 2 + (1 + 3 * m.y) * 5) * m.x + + (1 + 3 * m.y) * (4 + 42 * m.y * m.z) * (-1) + ), + ) + + def test_expanded_monomial_square_term(self): + m = build_test_model() + expr = m.x * m.x * m.p + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.z]) + # ensure overcomplication issues with standard repn + # are not repeated by quadratic repn + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 1}) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, repn.to_expression(visitor), SumExpression([m.x**2]) + ) + + def test_sum_bilinear_terms_commute_product(self): + m = build_test_model() + expr = m.x * m.y + m.y * m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.y)): 2}) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, repn.to_expression(visitor), SumExpression([2 * (m.x * m.y)]) + ) + + def test_sum_nonlinear(self): + m = build_test_model() + expr = (1 + log(m.x)) + (m.x + m.y + m.y**2 + log(m.x)) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + # tests special case of `repn.append` where multiplier + # is 1 and both summands have a nonlinear term + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 1 + m.y + m.y**2) + self.assertEqual(repn.linear, {id(m.x): 1}) + self.assertIsNone(repn.quadratic) + assertExpressionsEqual(self, repn.nonlinear, log(m.x) + log(m.x)) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + log(m.x) + log(m.x) + m.x + (1 + m.y) + m.y**2, + ) + + def test_product_linear_linear_0_nan(self): + m = build_test_model() + m.p.set_value(0) + expr = (m.p + 0 * m.x) * (float("nan") + float("nan") * m.x) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertTrue(isnan(repn.constant)) + self.assertEqual(len(repn.linear), 1) + self.assertTrue(isnan(repn.linear[id(m.x)])) + self.assertIsNone(repn.quadratic) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, repn.to_expression(visitor), float("nan") * m.x + float("nan") + ) + + def test_product_quadratic_quadratic_nan_0(self): + m = build_test_model() + m.p.set_value(0) + expr = (float("nan") + float("nan") * m.x + float("nan") * m.x**2) * ( + m.p + 0 * m.x + 0 * m.x**2 + ) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertTrue(isnan(repn.constant)) + self.assertEqual(len(repn.linear), 1) + self.assertTrue(isnan(repn.linear[id(m.x)])) + self.assertEqual(len(repn.quadratic), 1) + self.assertTrue(isnan(repn.quadratic[id(m.x), id(m.x)])) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + float("nan") * m.x**2 + float("nan") * m.x + float("nan"), + ) + + def test_product_quadratic_quadratic_0_nan(self): + m = build_test_model() + m.p.set_value(0) + expr = (m.p + 0 * m.x + 0 * m.x**2) * ( + float("nan") + float("nan") * m.x + float("nan") * m.x**2 + ) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertTrue(isnan(repn.constant)) + self.assertEqual(len(repn.linear), 1) + self.assertTrue(isnan(repn.linear[id(m.x)])) + self.assertEqual(len(repn.quadratic), 1) + self.assertTrue(isnan(repn.quadratic[id(m.x), id(m.x)])) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + float("nan") * m.x**2 + float("nan") * m.x + float("nan"), + ) + + def test_nary_sum_products(self): + m = build_test_model() + expr = ( + m.x**2 * (m.z - 1) + + m.x * (m.y**4 + 0.8) + - 5 * m.x * m.y * m.z + + m.x * (m.y + 2) + ) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual( + self, repn.linear[id(m.x)], m.y**4 + 0.8 + 5 * m.y * m.z * (-1) + (m.y + 2) + ) + assertExpressionsEqual(self, repn.quadratic[id(m.x), id(m.x)], m.z - 1) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + (m.z - 1) * m.x**2 + + (m.y**4 + 0.8 + 5 * m.y * m.z * (-1) + (m.y + 2)) * m.x, + ) + + def test_ternary_product_linear(self): + m = build_test_model() + expr = (1 + 2 * m.x) * (3 + 4 * m.y) * (5 + 6 * m.z) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.z): m.z}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.z): 1}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 5 * (3 + 4 * m.y)) + self.assertEqual(len(repn.linear), 2) + assertExpressionsEqual(self, repn.linear[id(m.x)], (3 + 4 * m.y) * 10) + assertExpressionsEqual(self, repn.linear[id(m.z)], (3 + 4 * m.y) * 6) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual( + self, repn.quadratic[id(m.x), id(m.z)], (3 + 4 * m.y) * 12 + ) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + ( + (3 + 4 * m.y) * 12 * (m.x * m.z) + + (3 + 4 * m.y) * 10 * m.x + + (3 + 4 * m.y) * 6 * m.z + + 5 * (3 + 4 * m.y) + ), + ) + + def test_noninteger_pow_linear(self): + m = build_test_model() + expr = (1 + 2 * m.x + 3 * m.y) ** 1.5 + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + assertExpressionsEqual(self, repn.nonlinear, (1 + 3 * m.y + 2 * m.x) ** 1.5) + assertExpressionsEqual( + self, repn.to_expression(visitor), (1 + 3 * m.y + 2 * m.x) ** 1.5 + ) + + def test_variable_pow_linear(self): + m = build_test_model() + expr = (1 + 2 * m.x + 3 * m.y) ** (m.y) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(repn.linear, {}) + self.assertIsNone(repn.quadratic) + assertExpressionsEqual(self, repn.nonlinear, (1 + 3 * m.y + 2 * m.x) ** m.y) + assertExpressionsEqual( + self, repn.to_expression(visitor), (1 + 3 * m.y + 2 * m.x) ** m.y + ) + + def test_pow_integer_fixed_var(self): + m = build_test_model() + m.z.fix(2) + expr = (1 + 2 * m.x + 3 * m.y) ** (m.z) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, (1 + 3 * m.y) * (1 + 3 * m.y)) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual( + self, repn.linear[id(m.x)], (1 + 3 * m.y) * 2 + (1 + 3 * m.y) * 2 + ) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 4}) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + ( + 4 * m.x**2 + + ((1 + 3 * m.y) * 2 + (1 + 3 * m.y) * 2) * m.x + + (1 + 3 * m.y) * (1 + 3 * m.y) + ), + ) + + def test_repr_parameterized_quadratic_repn(self): + m = build_test_model() + expr = 2 + m.x + m.x**2 + log(m.x) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + linear_dict = {id(m.x): 1} + quad_dict = {(id(m.x), id(m.x)): 1} + expected_repn_str = ( + "ParameterizedQuadraticRepn(" + "mult=1, " + "const=2, " + f"linear={linear_dict}, " + f"quadratic={quad_dict}, " + "nonlinear=log(x))" + ) + self.assertEqual(repr(repn), expected_repn_str) + self.assertEqual(str(repn), expected_repn_str) + + def test_product_var_linear_wrt_yz(self): + """ + Test product of Var and quadratic expression. + + Aimed at testing what happens when one multiplicand + of a product + has a constant term of 0, and the other has a + constant term that is an expression. + """ + m = build_test_model() + expr = m.x * (m.y + m.x * m.y + m.z) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 0) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual(self, repn.linear[id(m.x)], m.y + m.z) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual(self, repn.quadratic[id(m.x), id(m.x)], m.y) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, repn.to_expression(visitor), m.y * m.x**2 + (m.y + m.z) * m.x + ) + + def test_product_linear_var_wrt_yz(self): + """ + Test product of Var and quadratic expression. + + Checks what happens when multiplicands of + `test_product_var_linear` are swapped/commuted. + """ + m = build_test_model() + expr = (m.y + m.x * m.y + m.z) * m.x + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x}) + self.assertEqual(cfg.var_order, {id(m.x): 0}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 0) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual(self, repn.linear[id(m.x)], m.y + m.z) + self.assertEqual(len(repn.quadratic), 1) + assertExpressionsEqual(self, repn.quadratic[id(m.x), id(m.x)], m.y) + self.assertIsNone(repn.nonlinear) + assertExpressionsEqual( + self, repn.to_expression(visitor), m.y * m.x**2 + (m.y + m.z) * m.x + ) + + def test_product_var_quadratic(self): + """ + Test product of Var and quadratic expression. + + Aimed at testing what happens when one multiplicand + of a product + has a constant term of 0, and the other has a + constant term that is an expression. + """ + m = build_test_model() + expr = m.x * (m.y + m.x * m.y + m.z) + + cfg = VisitorConfig() + visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.z]) + repn = visitor.walk_expression(expr) + + self.assertEqual(cfg.subexpr, {}) + self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) + self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) + self.assertEqual(repn.multiplier, 1) + assertExpressionsEqual(self, repn.constant, 0) + self.assertEqual(len(repn.linear), 1) + assertExpressionsEqual(self, repn.linear[id(m.x)], m.z) + self.assertEqual(len(repn.quadratic), 1) + self.assertEqual(repn.quadratic, {(id(m.x), id(m.y)): 1}) + assertExpressionsEqual(self, repn.nonlinear, m.x * SumExpression([m.x * m.y])) + assertExpressionsEqual( + self, + repn.to_expression(visitor), + m.x * m.y + m.x * SumExpression([m.x * m.y]) + m.z * m.x, + ) diff --git a/pyomo/repn/tests/test_parameterized_standard_form.py b/pyomo/repn/tests/test_parameterized_standard_form.py new file mode 100644 index 00000000000..0f98006e674 --- /dev/null +++ b/pyomo/repn/tests/test_parameterized_standard_form.py @@ -0,0 +1,501 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +# + +from pyomo.common.dependencies import numpy as np, scipy_available, numpy_available +import pyomo.common.unittest as unittest + +from pyomo.environ import ( + ConcreteModel, + Constraint, + inequality, + Objective, + maximize, + Var, +) +from pyomo.core.expr import ( + MonomialTermExpression, + NegationExpression, + ProductExpression, +) +from pyomo.core.expr.compare import ( + assertExpressionsEqual, + assertExpressionsStructurallyEqual, +) + +from pyomo.repn.plugins.parameterized_standard_form import ( + ParameterizedLinearStandardFormCompiler, + _CSRMatrix, + _CSCMatrix, +) + + +@unittest.skipUnless( + numpy_available & scipy_available, + "CSC and CSR representations require scipy and numpy", +) +class TestSparseMatrixRepresentations(unittest.TestCase): + def test_csr_to_csc_only_data(self): + A = _CSRMatrix(([5, 8, 3, 6], [0, 1, 2, 1], [0, 1, 2, 3, 4]), [4, 4]) + thing = A.tocsc() + + self.assertTrue(np.all(thing.data == np.array([5, 8, 6, 3]))) + self.assertTrue(np.all(thing.indices == np.array([0, 1, 3, 2]))) + self.assertTrue(np.all(thing.indptr == np.array([0, 1, 3, 4, 4]))) + + def test_csr_to_csc_pyomo_exprs(self): + m = ConcreteModel() + m.x = Var() + m.y = Var() + + A = _CSRMatrix( + ([5, 8 * m.x, 3 * m.x * m.y**2, 6], [0, 1, 2, 1], [0, 1, 2, 3, 4]), [4, 4] + ) + thing = A.tocsc() + + self.assertEqual(thing.data[0], 5) + assertExpressionsEqual(self, thing.data[1], 8 * m.x) + self.assertEqual(thing.data[2], 6) + assertExpressionsEqual(self, thing.data[3], 3 * m.x * m.y**2) + self.assertEqual(thing.data.shape, (4,)) + + self.assertTrue(np.all(thing.indices == np.array([0, 1, 3, 2]))) + self.assertTrue(np.all(thing.indptr == np.array([0, 1, 3, 4, 4]))) + + def test_csr_to_csc_empty_matrix(self): + A = _CSRMatrix(([], [], [0]), [0, 4]) + thing = A.tocsc() + + self.assertEqual(thing.data.size, 0) + self.assertEqual(thing.indices.size, 0) + self.assertEqual(thing.shape, (0, 4)) + self.assertTrue(np.all(thing.indptr == np.zeros(5))) + + def test_todense(self): + A = _CSRMatrix(([5, 8, 3, 6], [0, 1, 2, 1], [0, 1, 2, 3, 4]), [4, 4]) + dense = np.array([[5, 0, 0, 0], [0, 8, 0, 0], [0, 0, 3, 0], [0, 6, 0, 0]]) + + self.assertTrue(np.all(A.todense() == dense)) + self.assertTrue(np.all(A.tocsc().todense() == dense)) + + A = _CSRMatrix( + ([5, 6, 7, 2, 1, 1.5], [0, 1, 1, 2, 3, 1], [0, 2, 4, 5, 6]), [4, 4] + ) + dense = np.array([[5, 6, 0, 0], [0, 7, 2, 0], [0, 0, 0, 1], [0, 1.5, 0, 0]]) + self.assertTrue(np.all(A.todense() == dense)) + self.assertTrue(np.all(A.tocsc().todense() == dense)) + + def test_sum_duplicates(self): + A = _CSCMatrix(([4, 5], [1, 1], [0, 0, 2, 2]), [3, 3]) + self.assertTrue(np.all(A.data == [4, 5])) + self.assertTrue(np.all(A.indptr == [0, 0, 2, 2])) + self.assertTrue(np.all(A.indices == [1, 1])) + + A.sum_duplicates() + + self.assertTrue(np.all(A.data == [9])) + self.assertTrue(np.all(A.indptr == [0, 0, 1, 1])) + self.assertTrue(np.all(A.indices == [1])) + + dense = np.array([[0, 0, 0], [0, 9, 0], [0, 0, 0]]) + self.assertTrue(np.all(A.todense() == dense)) + + def test_invalid_sparse_matrix_input(self): + with self.assertRaisesRegex( + ValueError, + r"Shape specifies the number of rows as 3 but the index " + r"pointer has length 2. The index pointer must have length " + r"nrows \+ 1: Check the 'shape' and 'matrix_data' arguments.", + ): + A = _CSRMatrix(([4, 5], [1, 1], [1, 1]), shape=(3, 3)) + + with self.assertRaisesRegex( + ValueError, + r"Shape specifies the number of columns as 3 but the index " + r"pointer has length 2. The index pointer must have length " + r"ncols \+ 1: Check the 'shape' and 'matrix_data' arguments.", + ): + A = _CSCMatrix(([4, 5], [1, 1], [1, 1]), shape=(3, 3)) + + +def assertExpressionArraysEqual(self, A, B): + self.assertEqual(A.shape, B.shape) + for i in range(A.shape[0]): + for j in range(A.shape[1]): + assertExpressionsStructurallyEqual(self, A[i, j], B[i, j]) + + +def assertExpressionListsEqual(self, A, B): + self.assertEqual(len(A), len(B)) + for i, a in enumerate(A): + assertExpressionsEqual(self, a, B[i]) + + +@unittest.skipUnless( + numpy_available & scipy_available, + "Parameterized standard form requires scipy and numpy", +) +class TestParameterizedStandardFormCompiler(unittest.TestCase): + def test_linear_model(self): + m = ConcreteModel() + m.x = Var() + m.y = Var([1, 2, 3]) + m.c = Constraint(expr=m.x + 2 * m.y[1] >= 3) + m.d = Constraint(expr=m.y[1] + 4 * m.y[3] <= 5) + + repn = ParameterizedLinearStandardFormCompiler().write(m) + + self.assertTrue(np.all(repn.c == np.array([0, 0, 0]))) + self.assertTrue(np.all(repn.A == np.array([[-1, -2, 0], [0, 1, 4]]))) + self.assertTrue(np.all(repn.rhs == np.array([-3, 5]))) + self.assertEqual(repn.rows, [(m.c, -1), (m.d, 1)]) + self.assertEqual(repn.columns, [m.x, m.y[1], m.y[3]]) + + def test_parameterized_linear_model(self): + m = ConcreteModel() + m.x = Var() + m.y = Var([1, 2, 3]) + m.data = Var([1, 2]) + m.more_data = Var() + m.c = Constraint(expr=m.x + 2 * m.data[1] * m.data[2] * m.y[1] >= 3) + m.d = Constraint(expr=m.y[1] + 4 * m.y[3] <= 5 * m.more_data) + + repn = ParameterizedLinearStandardFormCompiler().write( + m, wrt=[m.data, m.more_data] + ) + + self.assertTrue(np.all(repn.c == np.array([0, 0, 0]))) + assertExpressionArraysEqual( + self, + repn.A.todense(), + np.array( + [ + [ + -1, + NegationExpression( + ( + ProductExpression( + [MonomialTermExpression([2, m.data[1]]), m.data[2]] + ), + ) + ), + 0, + ], + [0, 1, 4], + ] + ), + ) + assertExpressionListsEqual(self, repn.rhs, [-3, 5 * m.more_data]) + self.assertEqual(repn.rows, [(m.c, -1), (m.d, 1)]) + self.assertEqual(repn.columns, [m.x, m.y[1], m.y[3]]) + + def test_parameterized_almost_dense_linear_model(self): + m = ConcreteModel() + m.x = Var() + m.y = Var([1, 2, 3]) + m.data = Var([1, 2]) + m.more_data = Var() + m.c = Constraint( + expr=m.x + 2 * m.y[1] + 4 * m.y[3] + m.more_data >= 10 * m.data[1] ** 2 + ) + m.d = Constraint(expr=5 * m.x + 6 * m.y[1] + 8 * m.data[2] * m.y[3] <= 20) + + repn = ParameterizedLinearStandardFormCompiler().write( + m, wrt=[m.data, m.more_data] + ) + + self.assertTrue(np.all(repn.c == np.array([0, 0, 0]))) + # m.c gets interpreted as a <= Constraint, and you can't really blame + # pyomo for that because it's not parameterized yet. So that's why this + # differs from the test in test_standard_form.py + assertExpressionArraysEqual( + self, repn.A.todense(), np.array([[-1, -2, -4], [5, 6, 8 * m.data[2]]]) + ) + assertExpressionListsEqual( + self, repn.rhs, [-(10 * m.data[1] ** 2 - m.more_data), 20] + ) + self.assertEqual(repn.rows, [(m.c, 1), (m.d, 1)]) + self.assertEqual(repn.columns, [m.x, m.y[1], m.y[3]]) + + def test_parameterized_linear_model_row_col_order(self): + m = ConcreteModel() + m.x = Var() + m.y = Var([1, 2, 3]) + m.data = Var([1, 2]) + m.more_data = Var() + m.c = Constraint(expr=m.x + 2 * m.data[1] * m.data[2] * m.y[1] >= 3) + m.d = Constraint(expr=m.y[1] + 4 * m.y[3] <= 5 * m.more_data) + + repn = ParameterizedLinearStandardFormCompiler().write( + m, + wrt=[m.data, m.more_data], + column_order=[m.y[3], m.y[2], m.x, m.y[1]], + row_order=[m.d, m.c], + ) + + self.assertTrue(np.all(repn.c == np.array([0, 0, 0]))) + assertExpressionArraysEqual( + self, + repn.A.todense(), + np.array( + [ + [4, 0, 1], + [ + 0, + -1, + NegationExpression( + ( + ProductExpression( + [MonomialTermExpression([2, m.data[1]]), m.data[2]] + ), + ) + ), + ], + ] + ), + ) + assertExpressionListsEqual(self, repn.rhs, np.array([5 * m.more_data, -3])) + self.assertEqual(repn.rows, [(m.d, 1), (m.c, -1)]) + self.assertEqual(repn.columns, [m.y[3], m.x, m.y[1]]) + + def make_model(self, do_not_flip_c=False): + m = ConcreteModel() + m.x = Var() + m.y = Var([0, 1, 3], bounds=lambda m, i: (-1 * (i % 2) * 5, 10 - 12 * (i // 2))) + m.data = Var([1, 2]) + m.more_data = Var() + if do_not_flip_c: + # [ESJ: 06/24]: I should have done this sooner, but if you write c + # this way, it gets interpreted as a >= constraint, which matches + # the standard_form tests and makes life much easier. Unforuntately + # I wrote a lot of tests before I thought of this, so I'm leaving in + # both variations for the moment. + m.c = Constraint( + expr=m.data[1] ** 2 * m.x + 2 * m.y[1] - 3 * m.more_data >= 0 + ) + else: + m.c = Constraint(expr=m.data[1] ** 2 * m.x + 2 * m.y[1] >= 3 * m.more_data) + m.d = Constraint(expr=m.y[1] + 4 * m.y[3] <= 5 + m.data[2]) + m.e = Constraint(expr=inequality(-2, m.y[0] + 1 + 6 * m.y[1], 7)) + m.f = Constraint(expr=m.x + (m.data[2] + m.data[1] ** 3) * m.y[0] + 2 == 10) + m.o = Objective([1, 3], rule=lambda m, i: m.x + i * 5 * m.more_data * m.y[i]) + m.o[1].sense = maximize + + return m + + def test_nonnegative_vars(self): + m = self.make_model() + col_order = [m.x, m.y[0], m.y[1], m.y[3]] + repn = ParameterizedLinearStandardFormCompiler().write( + m, wrt=[m.data, m.more_data], nonnegative_vars=True, column_order=col_order + ) + + # m.c comes back opposite how it does in test_standard_form, but that's + # not unexpected. + self.assertEqual( + repn.rows, [(m.c, 1), (m.d, 1), (m.e, 1), (m.e, -1), (m.f, 1), (m.f, -1)] + ) + self.assertEqual( + list(map(str, repn.x)), + ['_neg_0', '_pos_0', 'y[0]', '_neg_2', '_pos_2', '_neg_3'], + ) + ref = np.array( + [ + [ + NegationExpression((ProductExpression((-1, m.data[1] ** 2)),)), + ProductExpression((-1, m.data[1] ** 2)), + 0, + 2, + -2, + 0, + ], + [0, 0, 0, -1, 1, -4], + [0, 0, 1, -6, 6, 0], + [0, 0, -1, 6, -6, 0], + [-1, 1, m.data[2] + m.data[1] ** 3, 0, 0, 0], + [1, -1, -(m.data[2] + m.data[1] ** 3), 0, 0, 0], + ] + ) + assertExpressionArraysEqual(self, repn.A.todense(), ref) + assertExpressionListsEqual( + self, + repn.b, + [ + -3 * m.more_data, + NegationExpression((ProductExpression((-1, 5 + m.data[2])),)), + 6, + 3, + 8, + -8, + ], + ) + + c_ref = np.array( + [ + [1, -1, 0, 5 * m.more_data, -5 * m.more_data, 0], + [-1, 1, 0, 0, 0, -15 * m.more_data], + ] + ) + assertExpressionArraysEqual(self, repn.c.todense(), c_ref) + + def test_slack_form(self): + m = self.make_model() + col_order = [m.x, m.y[0], m.y[1], m.y[3]] + repn = ParameterizedLinearStandardFormCompiler().write( + m, wrt=[m.data, m.more_data], slack_form=True, column_order=col_order + ) + + self.assertEqual(repn.rows, [(m.c, 1), (m.d, 1), (m.e, 1), (m.f, 1)]) + self.assertEqual( + list(map(str, repn.x)), + ['x', 'y[0]', 'y[1]', 'y[3]', '_slack_0', '_slack_1', '_slack_2'], + ) + # m.c is flipped again, so the bounds on _slack_0 are flipped + self.assertEqual( + list(v.bounds for v in repn.x), + [(None, None), (0, 10), (-5, 10), (-5, -2), (0, None), (0, None), (-9, 0)], + ) + ref = np.array( + [ + [ProductExpression((-1, m.data[1] ** 2)), 0, -2, 0, 1, 0, 0], + [0, 0, 1, 4, 0, 1, 0], + [0, 1, 6, 0, 0, 0, 1], + [1, m.data[2] + m.data[1] ** 3, 0, 0, 0, 0, 0], + ] + ) + assertExpressionArraysEqual(self, repn.A.todense(), ref) + assertExpressionListsEqual( + self, + repn.b, + np.array( + [ + -3 * m.more_data, + NegationExpression((ProductExpression((-1, 5 + m.data[2])),)), + -3, + 8, + ] + ), + ) + c_ref = np.array( + [ + [-1, 0, -5 * m.more_data, 0, 0, 0, 0], + [1, 0, 0, 15 * m.more_data, 0, 0, 0], + ] + ) + assertExpressionArraysEqual(self, repn.c.todense(), c_ref) + + def test_mixed_form(self): + m = self.make_model() + col_order = [m.x, m.y[0], m.y[1], m.y[3]] + repn = ParameterizedLinearStandardFormCompiler().write( + m, wrt=[m.data, m.more_data], mixed_form=True, column_order=col_order + ) + + # m.c gets is opposite again + self.assertEqual(repn.rows, [(m.c, 1), (m.d, 1), (m.e, 1), (m.e, -1), (m.f, 0)]) + self.assertEqual(list(map(str, repn.x)), ['x', 'y[0]', 'y[1]', 'y[3]']) + self.assertEqual( + list(v.bounds for v in repn.x), [(None, None), (0, 10), (-5, 10), (-5, -2)] + ) + ref = np.array( + [ + [ProductExpression((-1, m.data[1] ** 2)), 0, -2, 0], + [0, 0, 1, 4], + [0, 1, 6, 0], + [0, 1, 6, 0], + [1, m.data[2] + m.data[1] ** 3, 0, 0], + ] + ) + assertExpressionArraysEqual(self, repn.A.todense(), ref) + assertExpressionListsEqual( + self, + repn.b, + np.array( + [ + -3 * m.more_data, + NegationExpression((ProductExpression((-1, 5 + m.data[2])),)), + 6, + -3, + 8, + ] + ), + ) + ref_c = np.array([[-1, 0, -5 * m.more_data, 0], [1, 0, 0, 15 * m.more_data]]) + assertExpressionArraysEqual(self, repn.c.todense(), ref_c) + + def test_slack_form_nonnegative_vars(self): + m = self.make_model(do_not_flip_c=True) + col_order = [m.x, m.y[0], m.y[1], m.y[3]] + repn = ParameterizedLinearStandardFormCompiler().write( + m, + wrt=[m.data, m.more_data], + slack_form=True, + nonnegative_vars=True, + column_order=col_order, + ) + + self.assertEqual(repn.rows, [(m.c, 1), (m.d, 1), (m.e, 1), (m.f, 1)]) + self.assertEqual( + list(map(str, repn.x)), + [ + '_neg_0', + '_pos_0', + 'y[0]', + '_neg_2', + '_pos_2', + '_neg_3', + '_neg_4', + '_slack_1', + '_neg_6', + ], + ) + self.assertEqual( + list(v.bounds for v in repn.x), + [ + (0, None), + (0, None), + (0, 10), + (0, 5), + (0, 10), + (2, 5), + (0, None), + (0, None), + (0, 9), + ], + ) + ref = np.array( + [ + [-m.data[1] ** 2, m.data[1] ** 2, 0, -2, 2, 0, -1, 0, 0], + [0, 0, 0, -1, 1, -4, 0, 1, 0], + [0, 0, 1, -6, 6, 0, 0, 0, -1], + [-1, 1, m.data[2] + m.data[1] ** 3, 0, 0, 0, 0, 0, 0], + ] + ) + assertExpressionArraysEqual(self, repn.A.todense(), ref) + assertExpressionListsEqual( + self, + repn.b, + np.array( + [ + 3 * m.more_data, + NegationExpression((ProductExpression((-1, 5 + m.data[2])),)), + -3, + 8, + ] + ), + ) + c_ref = np.array( + [ + [1, -1, 0, 5 * m.more_data, -5 * m.more_data, 0, 0, 0, 0], + [-1, 1, 0, 0, 0, -15 * m.more_data, 0, 0, 0], + ] + ) + assertExpressionArraysEqual(self, repn.c.todense(), c_ref) diff --git a/pyomo/repn/tests/test_plugins.py b/pyomo/repn/tests/test_plugins.py new file mode 100644 index 00000000000..76805630f5c --- /dev/null +++ b/pyomo/repn/tests/test_plugins.py @@ -0,0 +1,50 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common import unittest + +from pyomo.opt import WriterFactory +from pyomo.repn.plugins import activate_writer_version, active_writer_version + +import pyomo.environ + + +class TestPlugins(unittest.TestCase): + def test_active(self): + with self.assertRaises(KeyError): + active_writer_version('nonexistent_writer') + ver = active_writer_version('lp') + self.assertIs( + WriterFactory.get_class('lp'), WriterFactory.get_class(f'lp_v{ver}') + ) + + class TMP(object): + pass + + WriterFactory.register('test_writer')(TMP) + try: + self.assertIsNone(active_writer_version('test_writer')) + finally: + WriterFactory.unregister('test_writer') + + def test_activate(self): + ver = active_writer_version('lp') + try: + activate_writer_version('lp', 2) + self.assertIs( + WriterFactory.get_class('lp'), WriterFactory.get_class(f'lp_v2') + ) + activate_writer_version('lp', 1) + self.assertIs( + WriterFactory.get_class('lp'), WriterFactory.get_class(f'lp_v1') + ) + finally: + activate_writer_version('lp', ver) diff --git a/pyomo/repn/tests/test_quadratic.py b/pyomo/repn/tests/test_quadratic.py index 2d2e4022037..921e412596a 100644 --- a/pyomo/repn/tests/test_quadratic.py +++ b/pyomo/repn/tests/test_quadratic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,22 +19,12 @@ SumExpression, ) from pyomo.repn.quadratic import QuadraticRepnVisitor +from pyomo.repn.tests.test_linear import VisitorConfig from pyomo.repn.util import InvalidNumber from pyomo.environ import ConcreteModel, Var, Param, Any, log -class VisitorConfig(object): - def __init__(self): - self.subexpr = {} - self.var_map = {} - self.var_order = {} - self.sorter = None - - def __iter__(self): - return iter((self.subexpr, self.var_map, self.var_order, self.sorter)) - - class TestQuadratic(unittest.TestCase): def test_product(self): m = ConcreteModel() @@ -44,7 +34,7 @@ def test_product(self): e = 2 cfg = VisitorConfig() - visitor = QuadraticRepnVisitor(*cfg) + visitor = QuadraticRepnVisitor(**cfg) visitor.expand_nonlinear_products = True repn = visitor.walk_expression(e) @@ -60,7 +50,7 @@ def test_product(self): e = 2 + 3 * m.x cfg = VisitorConfig() - visitor = QuadraticRepnVisitor(*cfg) + visitor = QuadraticRepnVisitor(**cfg) visitor.expand_nonlinear_products = True repn = visitor.walk_expression(e) @@ -76,7 +66,7 @@ def test_product(self): e = 2 + 3 * m.x + 4 * m.x**2 cfg = VisitorConfig() - visitor = QuadraticRepnVisitor(*cfg) + visitor = QuadraticRepnVisitor(**cfg) visitor.expand_nonlinear_products = True repn = visitor.walk_expression(e) @@ -92,7 +82,7 @@ def test_product(self): e = (2 + 3 * m.x + 4 * m.x**2) * (5 + 6 * m.x + 7 * m.x**2) cfg = VisitorConfig() - visitor = QuadraticRepnVisitor(*cfg) + visitor = QuadraticRepnVisitor(**cfg) visitor.expand_nonlinear_products = True repn = visitor.walk_expression(e) @@ -114,7 +104,7 @@ def test_product(self): e = (2 + 3 * m.x + 4 * m.x**2) * (5 + 6 * m.x + 7 * m.x**2) cfg = VisitorConfig() - visitor = QuadraticRepnVisitor(*cfg) + visitor = QuadraticRepnVisitor(**cfg) visitor.expand_nonlinear_products = False repn = visitor.walk_expression(e) @@ -132,7 +122,7 @@ def test_product(self): e = (1 + 2 * m.x + 3 * m.y) * (4 + 5 * m.x + 6 * m.y) cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) @@ -141,7 +131,7 @@ def test_product(self): self.assertEqual(repn.constant, 4) self.assertEqual(repn.linear, {id(m.x): 13, id(m.y): 18}) self.assertEqual( - repn.quadratic, + cfg.order_quadratic(repn.quadratic), {(id(m.x), id(m.x)): 10, (id(m.y), id(m.y)): 18, (id(m.x), id(m.y)): 27}, ) assertExpressionsEqual(self, repn.nonlinear, None) @@ -149,7 +139,7 @@ def test_product(self): e = (m.x + m.y + log(m.x)) * m.x cfg = VisitorConfig() - visitor = QuadraticRepnVisitor(*cfg) + visitor = QuadraticRepnVisitor(**cfg) visitor.expand_nonlinear_products = False repn = visitor.walk_expression(e) @@ -175,13 +165,16 @@ def test_product(self): self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, 0) self.assertEqual(repn.linear, {}) - self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 1, (id(m.x), id(m.y)): 1}) + self.assertEqual( + cfg.order_quadratic(repn.quadratic), + {(id(m.x), id(m.x)): 1, (id(m.x), id(m.y)): 1}, + ) assertExpressionsEqual(self, repn.nonlinear, NL) e = m.x * (m.x + m.y + log(m.x) + 2) cfg = VisitorConfig() - visitor = QuadraticRepnVisitor(*cfg) + visitor = QuadraticRepnVisitor(**cfg) visitor.expand_nonlinear_products = False repn = visitor.walk_expression(e) @@ -207,7 +200,10 @@ def test_product(self): self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, 0) self.assertEqual(repn.linear, {id(m.x): 2}) - self.assertEqual(repn.quadratic, {(id(m.x), id(m.x)): 1, (id(m.x), id(m.y)): 1}) + self.assertEqual( + cfg.order_quadratic(repn.quadratic), + {(id(m.x), id(m.x)): 1, (id(m.x), id(m.y)): 1}, + ) assertExpressionsEqual(self, repn.nonlinear, NL) def test_sum(self): @@ -218,7 +214,7 @@ def test_sum(self): e = SumExpression([]) cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -231,7 +227,7 @@ def test_sum(self): e += 5 cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {}) self.assertEqual(cfg.var_order, {}) @@ -244,7 +240,7 @@ def test_sum(self): e += m.x cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x}) self.assertEqual(cfg.var_order, {id(m.x): 0}) @@ -257,7 +253,7 @@ def test_sum(self): e += m.y**2 cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) @@ -270,7 +266,7 @@ def test_sum(self): e += m.y**3 cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) @@ -283,7 +279,7 @@ def test_sum(self): e += 2 * m.x**4 cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) @@ -296,7 +292,7 @@ def test_sum(self): e += 2 * m.y cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) @@ -309,14 +305,17 @@ def test_sum(self): e += 3 * m.x * m.y cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) self.assertEqual(repn.multiplier, 1) self.assertEqual(repn.constant, 5) self.assertEqual(repn.linear, {id(m.x): 1, id(m.y): 2}) - self.assertEqual(repn.quadratic, {(id(m.y), id(m.y)): 1, (id(m.x), id(m.y)): 3}) + self.assertEqual( + cfg.order_quadratic(repn.quadratic), + {(id(m.y), id(m.y)): 1, (id(m.x), id(m.y)): 3}, + ) assertExpressionsEqual(self, repn.nonlinear, m.y**3 + 2 * m.x**4) def test_pow(self): @@ -326,7 +325,7 @@ def test_pow(self): # Check **{int} cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression((1 + 3 * m.x + 4 * m.y) ** 2) + repn = QuadraticRepnVisitor(**cfg).walk_expression((1 + 3 * m.x + 4 * m.y) ** 2) self.assertEqual(cfg.subexpr, {}) self.assertEqual(cfg.var_map, {id(m.x): m.x, id(m.y): m.y}) self.assertEqual(cfg.var_order, {id(m.x): 0, id(m.y): 1}) @@ -334,14 +333,14 @@ def test_pow(self): self.assertEqual(repn.constant, 1) self.assertEqual(repn.linear, {id(m.x): 6, id(m.y): 8}) self.assertEqual( - repn.quadratic, + cfg.order_quadratic(repn.quadratic), {(id(m.x), id(m.x)): 9, (id(m.y), id(m.y)): 16, (id(m.x), id(m.y)): 24}, ) self.assertEqual(repn.nonlinear, None) # Check **{int} cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression( + repn = QuadraticRepnVisitor(**cfg).walk_expression( (1 + 3 * m.x + 4 * m.y) ** 2.0 ) self.assertEqual(cfg.subexpr, {}) @@ -351,7 +350,7 @@ def test_pow(self): self.assertEqual(repn.constant, 1) self.assertEqual(repn.linear, {id(m.x): 6, id(m.y): 8}) self.assertEqual( - repn.quadratic, + cfg.order_quadratic(repn.quadratic), {(id(m.x), id(m.x)): 9, (id(m.y), id(m.y)): 16, (id(m.x), id(m.y)): 24}, ) self.assertEqual(repn.nonlinear, None) @@ -363,7 +362,7 @@ def test_zero_elimination(self): e = 0 * m.x[0] + 0 * m.x[1] * m.x[2] + 0 * log(m.x[3]) cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, @@ -387,7 +386,7 @@ def test_zero_elimination(self): e = m.p * m.x[0] + m.p * m.x[1] * m.x[2] + m.p * log(m.x[3]) cfg = VisitorConfig() - repn = QuadraticRepnVisitor(*cfg).walk_expression(e) + repn = QuadraticRepnVisitor(**cfg).walk_expression(e) self.assertEqual(cfg.subexpr, {}) self.assertEqual( cfg.var_map, @@ -405,6 +404,7 @@ def test_zero_elimination(self): self.assertEqual(repn.constant, 0) self.assertEqual(repn.linear, {id(m.x[0]): InvalidNumber(None)}) self.assertEqual( - repn.quadratic, {(id(m.x[1]), id(m.x[2])): InvalidNumber(None)} + cfg.order_quadratic(repn.quadratic), + {(id(m.x[1]), id(m.x[2])): InvalidNumber(None)}, ) self.assertEqual(repn.nonlinear, InvalidNumber(None)) diff --git a/pyomo/repn/tests/test_standard.py b/pyomo/repn/tests/test_standard.py index 6c5a6e3e033..c1912b463e1 100644 --- a/pyomo/repn/tests/test_standard.py +++ b/pyomo/repn/tests/test_standard.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index e24195edfde..774a6191de9 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -42,6 +42,23 @@ def test_linear_model(self): self.assertTrue(np.all(repn.c == np.array([0, 0, 0]))) self.assertTrue(np.all(repn.A == np.array([[-1, -2, 0], [0, 1, 4]]))) self.assertTrue(np.all(repn.rhs == np.array([-3, 5]))) + self.assertEqual(repn.rows, [(m.c, -1), (m.d, 1)]) + self.assertEqual(repn.columns, [m.x, m.y[1], m.y[3]]) + + def test_almost_dense_linear_model(self): + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var([1, 2, 3]) + m.c = pyo.Constraint(expr=m.x + 2 * m.y[1] + 4 * m.y[3] >= 10) + m.d = pyo.Constraint(expr=5 * m.x + 6 * m.y[1] + 8 * m.y[3] <= 20) + + repn = LinearStandardFormCompiler().write(m) + + self.assertTrue(np.all(repn.c == np.array([0, 0, 0]))) + self.assertTrue(np.all(repn.A == np.array([[-1, -2, -4], [5, 6, 8]]))) + self.assertTrue(np.all(repn.rhs == np.array([-10, 20]))) + self.assertEqual(repn.rows, [(m.c, -1), (m.d, 1)]) + self.assertEqual(repn.columns, [m.x, m.y[1], m.y[3]]) def test_linear_model_row_col_order(self): m = pyo.ConcreteModel() @@ -57,6 +74,8 @@ def test_linear_model_row_col_order(self): self.assertTrue(np.all(repn.c == np.array([0, 0, 0]))) self.assertTrue(np.all(repn.A == np.array([[4, 0, 1], [0, -1, -2]]))) self.assertTrue(np.all(repn.rhs == np.array([5, -3]))) + self.assertEqual(repn.rows, [(m.d, 1), (m.c, -1)]) + self.assertEqual(repn.columns, [m.y[3], m.x, m.y[1]]) def test_suffix_warning(self): m = pyo.ConcreteModel() @@ -222,6 +241,28 @@ def test_alternative_forms(self): ) self._verify_solution(soln, repn, True) + repn = LinearStandardFormCompiler().write( + m, mixed_form=True, column_order=col_order + ) + + self.assertEqual( + repn.rows, [(m.c, -1), (m.d, 1), (m.e, 1), (m.e, -1), (m.f, 0)] + ) + self.assertEqual(list(map(str, repn.x)), ['x', 'y[0]', 'y[1]', 'y[3]']) + self.assertEqual( + list(v.bounds for v in repn.x), [(None, None), (0, 10), (-5, 10), (-5, -2)] + ) + ref = np.array( + [[1, 0, 2, 0], [0, 0, 1, 4], [0, 1, 6, 0], [0, 1, 6, 0], [1, 1, 0, 0]] + ) + self.assertTrue(np.all(repn.A == ref)) + self.assertTrue(np.all(repn.b == np.array([3, 5, 6, -3, 8]))) + self.assertTrue(np.all(repn.c == np.array([[-1, 0, -5, 0], [1, 0, 0, 15]]))) + # Note that the mixed_form solution is a mix of inequality and + # equality constraints, so we cannot (easily) reuse the + # _verify_solutions helper (as in the above cases): + # self._verify_solution(soln, repn, False) + repn = LinearStandardFormCompiler().write( m, slack_form=True, nonnegative_vars=True, column_order=col_order ) diff --git a/pyomo/repn/tests/test_util.py b/pyomo/repn/tests/test_util.py index b5e4cc4facf..724e52e2899 100644 --- a/pyomo/repn/tests/test_util.py +++ b/pyomo/repn/tests/test_util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -242,7 +242,7 @@ def test_apply_operation(self): pyomo.repn.util.HALT_ON_EVALUATION_ERROR = False with LoggingIntercept() as LOG: val = apply_node_operation(div, [1, 0]) - self.assertEqual(str(val), "InvalidNumber(nan)") + self.assertStructuredAlmostEqual(val, InvalidNumber(float('nan'))) self.assertEqual( LOG.getvalue(), "Exception encountered evaluating expression 'div(1, 0)'\n" @@ -293,7 +293,7 @@ class Visitor(object): pyomo.repn.util.HALT_ON_EVALUATION_ERROR = False with LoggingIntercept() as LOG: val = complex_number_error(1j, visitor, exp) - self.assertEqual(str(val), "InvalidNumber(1j)") + self.assertEqual(val, InvalidNumber(1j)) self.assertEqual( LOG.getvalue(), "Complex number returned from expression\n" @@ -718,16 +718,14 @@ class UnknownExpression(NumericExpression): DeveloperError, r".*Unexpected expression node type 'UnknownExpression'" ): end[node.__class__](None, node, *node.args) - self.assertEqual(len(end), 9) - self.assertIn(UnknownExpression, end) + self.assertEqual(len(end), 8) node = UnknownExpression((6, 7)) with self.assertRaisesRegex( DeveloperError, r".*Unexpected expression node type 'UnknownExpression'" ): end[node.__class__, 6, 7](None, node, *node.args) - self.assertEqual(len(end), 10) - self.assertIn((UnknownExpression, 6, 7), end) + self.assertEqual(len(end), 8) def test_BeforeChildDispatcher_registration(self): class BeforeChildDispatcherTester(BeforeChildDispatcher): diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index 49cca32eaf9..4a51f87fc78 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,13 +10,13 @@ # ___________________________________________________________________________ import collections -import enum import functools import itertools import logging import operator import sys +from pyomo.common import enums from pyomo.common.collections import Sequence, ComponentMap, ComponentSet from pyomo.common.deprecation import deprecation_warning from pyomo.common.errors import DeveloperError, InvalidValueError @@ -36,11 +36,12 @@ Block, Constraint, Expression, + NumericLabeler, Suffix, SortComponents, ) from pyomo.core.base.component import ActiveComponent -from pyomo.core.base.expression import _ExpressionData +from pyomo.core.base.expression import NamedExpressionData from pyomo.core.expr.numvalue import is_fixed, value import pyomo.core.expr as EXPR import pyomo.core.kernel as kernel @@ -55,7 +56,7 @@ EXPR.NPV_SumExpression, } _named_subexpression_types = ( - _ExpressionData, + NamedExpressionData, kernel.expression.expression, kernel.objective.objective, ) @@ -65,8 +66,10 @@ int_float = {int, float} -class ExprType(enum.IntEnum): +class ExprType(enums.IntEnum): CONSTANT = 0 + FIXED = 3 + VARIABLE = 5 MONOMIAL = 10 LINEAR = 20 QUADRATIC = 30 @@ -81,7 +84,7 @@ class ExprType(enum.IntEnum): } -class FileDeterminism(enum.IntEnum): +class FileDeterminism(enums.IntEnum): NONE = 0 # DEPRECATED_KEYS = 1 # DEPRECATED_KEYS_AND_NAMES = 2 @@ -93,11 +96,11 @@ class FileDeterminism(enum.IntEnum): # 3.11 is consistent with 3.7 - 3.10. def __str__(self): - return enum.Enum.__str__(self) + return enums.Enum.__str__(self) def __format__(self, spec): # Removal of Python 3.7 support allows us to use Enum.__format__ - return enum.Enum.__format__(self, spec) + return enums.Enum.__format__(self, spec) @classmethod def _missing_(cls, value): @@ -156,19 +159,37 @@ def _op(self, op, *args): return InvalidNumber(self.value, causes) def __eq__(self, other): - return self._cmp(operator.eq, other) + ans = self._cmp(operator.eq, other) + try: + return bool(ans) + except ValueError: + # ValueError can be raised by numpy.ndarray when two arrays + # are returned. In that case, ndarray returns a new ndarray + # of bool values. We will fall back on using `all` to + # reduce it to a single bool. + try: + return all(ans) + except: + pass + raise def __lt__(self, other): - return self._cmp(operator.lt, other) + # Note that as < is ambiguous for arrays, we will attempt to + # cast the result to bool and if it was an array, allow the + # exception to propagate + return bool(self._cmp(operator.lt, other)) def __gt__(self, other): - return self._cmp(operator.gt, other) + # See the comment in __lt__() on the use of bool() + return bool(self._cmp(operator.gt, other)) def __le__(self, other): - return self._cmp(operator.le, other) + # See the comment in __lt__() on the use of bool() + return bool(self._cmp(operator.le, other)) def __ge__(self, other): - return self._cmp(operator.ge, other) + # See the comment in __lt__() on the use of bool() + return bool(self._cmp(operator.ge, other)) def _error(self, msg): causes = list(filter(None, self.causes)) @@ -178,14 +199,21 @@ def _error(self, msg): raise InvalidValueError(msg) def __str__(self): - # We will support simple conversion of InvalidNumber to strings - # (for reporting purposes) + # We want attempts to convert InvalidNumber to a string + # representation to raise a InvalidValueError, unless we are in + # the middle of processing an exception. In that case, it is + # very likely that an exception handler is generating an error + # message. We will play nice and return a reasonable string. + if sys.exc_info()[1] is None: + self._error(f'Cannot emit {self._str()} in compiled representation') + else: + return self._str() + + def _str(self): return f'InvalidNumber({self.value!r})' def __repr__(self): - # We want attempts to convert InvalidNumber to a string - # representation to raise a InvalidValueError. - self._error(f'Cannot emit {str(self)} in compiled representation') + return str(self) def __format__(self, format_spec): # FIXME: We want to move to where converting InvalidNumber to @@ -193,12 +221,12 @@ def __format__(self, format_spec): # InvalidValueError. However, at the moment, this breaks some # tests in PyROS. # return self.value.__format__(format_spec) - self._error(f'Cannot emit {str(self)} in compiled representation') + return self._error(f'Cannot emit {str(self)} in compiled representation') def __float__(self): # We want attempts to convert InvalidNumber to a float # representation to raise a InvalidValueError. - self._error(f'Cannot convert {str(self)} to float') + return self._error(f'Cannot convert {str(self)} to float') def __neg__(self): return self._op(operator.neg, self) @@ -241,12 +269,12 @@ def __rpow__(self, other): class BeforeChildDispatcher(collections.defaultdict): - """Dispatcher for handling the :py:class:`StreamBasedExpressionVisitor` + """Dispatcher for handling the :class:`StreamBasedExpressionVisitor` `beforeChild` callback - This dispatcher implements a specialization of :py:`defaultdict` + This dispatcher implements a specialization of :class:`defaultdict` that supports automatic type registration. Any missing types will - return the :py:meth:`register_dispatcher` method, which (when called + return the :meth:`register_dispatcher` method, which (when called as a callback) will interrogate the type, identify the appropriate callback, add the callback to the dict, and return the result of calling the callback. As the callback is added to the dict, no type @@ -282,10 +310,26 @@ def register_dispatcher(self, visitor, child): else: self[child_type] = self._before_invalid elif not child.is_expression_type(): - if child.is_potentially_variable(): - self[child_type] = self._before_var - else: - self[child_type] = self._before_param + if child.is_indexed(): + cdata = child._ComponentDataClass(child) + if cdata.is_expression_type(): + self[child_type] = self._before_indexed_expr + elif cdata.is_numeric_type() or child.is_logical_type(): + if cdata.is_potentially_variable(): + self[child_type] = self._before_indexed_var + else: + self[child_type] = self._before_indexed_param + elif child.is_component_type(): + self[child_type] = self._before_indexed_component + elif child.is_numeric_type() or child.is_logical_type(): + if child.is_potentially_variable(): + self[child_type] = self._before_var + elif isinstance(child, EXPR.IndexTemplate): + self[child_type] = self._before_index_template + else: + self[child_type] = self._before_param + elif child.is_component_type(): + self[child_type] = self._before_component elif not child.is_potentially_variable(): self[child_type] = self._before_npv pv_base_type = child.potentially_variable_base_class() @@ -357,6 +401,41 @@ def _before_npv(visitor, child): def _before_param(visitor, child): return False, (_CONSTANT, visitor.check_constant(child.value, child)) + @staticmethod + def _before_index_template(visitor, child): + raise NotImplementedError( + f"{visitor.__class__.__name__} can not handle expressions " + f"containing {child.__class__} nodes" + ) + + @staticmethod + def _before_indexed_expr(visitor, child): + raise NotImplementedError( + f"{visitor.__class__.__name__} can not handle expressions " + f"containing {child.__class__} nodes" + ) + + @staticmethod + def _before_indexed_param(visitor, child): + raise NotImplementedError( + f"{visitor.__class__.__name__} can not handle expressions " + f"containing {child.__class__} nodes" + ) + + @staticmethod + def _before_indexed_var(visitor, child): + raise NotImplementedError( + f"{visitor.__class__.__name__} can not handle expressions " + f"containing {child.__class__} nodes" + ) + + @staticmethod + def _before_component(visitor, child): + raise NotImplementedError( + f"{visitor.__class__.__name__} can not handle expressions " + f"containing {child.__class__} nodes" + ) + # # The following methods must be defined by derivative classes (along # with any other special-case handling they want to implement; @@ -374,22 +453,20 @@ def _before_param(visitor, child): class ExitNodeDispatcher(collections.defaultdict): - """Dispatcher for handling the :py:class:`StreamBasedExpressionVisitor` + """Dispatcher for handling the :class:`StreamBasedExpressionVisitor` `exitNode` callback - This dispatcher implements a specialization of :py:`defaultdict` - that supports automatic type registration. Any missing types will - return the :py:meth:`register_dispatcher` method, which (when called - as a callback) will interrogate the type, identify the appropriate - callback, add the callback to the dict, and return the result of - calling the callback. As the callback is added to the dict, no type - will incur the overhead of `register_dispatcher` more than once. + This dispatcher implements a specialization of :class:`defaultdict` + that supports automatic type registration. As the identified + callback is added to the dict, no type will incur the overhead of + `register_dispatcher` more than once. Note that in this case, the client is expected to register all non-NPV expression types. The auto-registration is designed to only handle two cases: - Auto-detection of user-defined Named Expression types - Automatic mappimg of NPV expressions to their equivalent non-NPV handlers + - Automatic registration of derived expression types """ @@ -400,7 +477,15 @@ def __init__(self, *args, **kwargs): def __missing__(self, key): if type(key) is tuple: - node_class = key[0] + # Only lookup/cache argument-specific handlers for unary or + # binary operators + if len(key) <= 3: + node_class = key[0] + node_args = key[1:] + else: + node_class = key = key[0] + if node_class in self: + return self[node_class] else: node_class = key bases = node_class.__mro__ @@ -412,36 +497,48 @@ def __missing__(self, key): bases = [Expression] fcn = None for base_type in bases: - if isinstance(key, tuple): - base_key = (base_type,) + key[1:] - # Only cache handlers for unary, binary and ternary operators - cache = len(key) <= 4 - else: - base_key = base_type - cache = True - if base_key in self: - fcn = self[base_key] - elif base_type in self: + if key is not node_class: + if (base_type,) + node_args in self: + fcn = self[(base_type,) + node_args] + break + if base_type in self: fcn = self[base_type] - elif any((k[0] if type(k) is tuple else k) is base_type for k in self): - raise DeveloperError( - f"Base expression key '{base_key}' not found when inserting " - f"dispatcher for node '{node_class.__name__}' while walking " - "expression tree." - ) + break if fcn is None: - fcn = self.unexpected_expression_type - if cache: - self[key] = fcn + partial_matches = set( + k[0] for k in self if type(k) is tuple and issubclass(node_class, k[0]) + ) + for base_type in node_class.__mro__: + if node_class is not key: + key = (base_type,) + node_args + if base_type in partial_matches: + raise DeveloperError( + f"Base expression key '{key}' not found when inserting " + f"dispatcher for node '{node_class.__name__}' while walking " + "expression tree." + ) + return self.unexpected_expression_type + self[key] = fcn return fcn - def unexpected_expression_type(self, visitor, node, *arg): + def unexpected_expression_type(self, visitor, node, *args): raise DeveloperError( f"Unexpected expression node type '{type(node).__name__}' " f"found while walking expression tree in {type(visitor).__name__}." ) +def initialize_exit_node_dispatcher(exit_handlers): + exit_dispatcher = {} + for cls, handlers in exit_handlers.items(): + for args, fcn in handlers.items(): + if args is None: + exit_dispatcher[cls] = fcn + else: + exit_dispatcher[(cls, *args)] = fcn + return exit_dispatcher + + def apply_node_operation(node, args): try: ans = node._apply_operation(args) @@ -486,7 +583,7 @@ def categorize_valid_components( Parameters ---------- - model: _BlockData + model: BlockData The model tree to walk active: True or None @@ -507,7 +604,7 @@ def categorize_valid_components( Returns ------- - component_map: Dict[type, List[_BlockData]] + component_map: Dict[type, List[BlockData]] A dict mapping component type to a list of block data objects that contain declared component of that type. @@ -661,6 +758,107 @@ def ordered_active_constraints(model, config): return sorted(constraints, key=lambda x: _row_getter(id(x), _n)) +class VarRecorder(object): + def __init__(self, var_map, sorter): + self.var_map = var_map + self.sorter = sorter + + def add(self, var): + # We always add all indices to the var_map at once so that + # we can honor deterministic ordering of unordered sets + # (because the user could have iterated over an unordered + # set when constructing an expression, thereby altering the + # order in which we would see the variables) + vm = self.var_map + try: + _iter = var.parent_component().values(self.sorter) + except AttributeError: + # Note that this only works for the AML, as kernel does not + # provide a parent_component() + _iter = (var,) + for v in _iter: + if not v.fixed: + vm[id(v)] = v + + +class OrderedVarRecorder(object): + def __init__(self, var_map, var_order, sorter): + self.var_map = var_map + self.var_order = var_order + self.sorter = sorter + + def add(self, var): + # We always add all indices to the var_map at once so that + # we can honor deterministic ordering of unordered sets + # (because the user could have iterated over an unordered + # set when constructing an expression, thereby altering the + # order in which we would see the variables) + vm = self.var_map + vo = self.var_order + try: + _iter = var.parent_component().values(self.sorter) + except AttributeError: + # Note that this only works for the AML, as kernel does not + # provide a parent_component() + _iter = (var,) + for i, v in enumerate(_iter, start=len(vo)): + vid = id(v) + vo[vid] = i + if not v.fixed: + vm[vid] = v + + +class TemplateVarRecorder(object): + def __init__(self, var_map, var_order, sorter): + self.var_map = var_map + self._var_order = var_order + self.sorter = sorter + self.env = {None: 0} + self.symbolmap = EXPR.SymbolMap(NumericLabeler('x')) + + @property + def var_order(self): + if self._var_order is None: + self._var_order = {vid: i for i, vid in enumerate(self.var_map)} + return self._var_order + + def add(self, var): + # Note: the following is mostly a copy of + # LinearBeforeChildDispatcher.record_var, but with extra + # handling to update the env in the same loop + var_comp = var.parent_component() + # Double-check that the component has not already been processed + # (through an individual var data) + name = self.symbolmap.getSymbol(var_comp) + if name in self.env: + return + + # We always add all indices to the var_map at once so that + # we can honor deterministic ordering of unordered sets + # (because the user could have iterated over an unordered + # set when constructing an expression, thereby altering the + # order in which we would see the variables) + vm = self.var_map + ve = self.env[name] = {} + vo = self._var_order + try: + _iter = var_comp.items(self.sorter) + except AttributeError: + # Note that this only works for the AML, as kernel does not + # provide a parent_component() + _iter = (var,) + if vo is None: + for i, (idx, v) in enumerate(_iter, start=len(vm)): + vm[id(v)] = v + ve[idx] = i + else: + for i, (idx, v) in enumerate(_iter, start=len(vm)): + vid = id(v) + vm[vid] = v + ve[idx] = i + vo[vid] = i + + # Copied from cpxlp.py: # Keven Hunter made a nice point about using %.16g in his attachment # to ticket #4319. I am adjusting this to %.17g as this mocks the diff --git a/pyomo/scripting/__init__.py b/pyomo/scripting/__init__.py index 7cb5ac652fc..e77d4f5beea 100644 --- a/pyomo/scripting/__init__.py +++ b/pyomo/scripting/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,5 +9,4 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.scripting.pyomo_command -import pyomo.scripting.util +from pyomo.scripting import pyomo_command, util diff --git a/pyomo/scripting/commands.py b/pyomo/scripting/commands.py index ef59d64b542..adac28b8612 100644 --- a/pyomo/scripting/commands.py +++ b/pyomo/scripting/commands.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/convert.py b/pyomo/scripting/convert.py index 20f9ef6d382..fc95851725c 100644 --- a/pyomo/scripting/convert.py +++ b/pyomo/scripting/convert.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/driver_help.py b/pyomo/scripting/driver_help.py index 38d1a4c16bf..62430836b94 100644 --- a/pyomo/scripting/driver_help.py +++ b/pyomo/scripting/driver_help.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,6 +20,7 @@ import pyomo.common from pyomo.common.collections import Bunch +from pyomo.common.tee import capture_output import pyomo.scripting.pyomo_parser logger = logging.getLogger('pyomo.solvers') @@ -188,7 +189,7 @@ def help_transformations(): # The next best thing is to ensure that the deprecation status # is indicated here. _init_doc = TransformationFactory.get_class(xform).__init__.__doc__ or "" - if _init_doc.strip().startswith('DEPRECATED') and 'DEPRECAT' not in _doc: + if _init_doc.strip().startswith('DEPRECATED') and 'DEPRECATE' not in _doc: _doc = ' '.join(('[DEPRECATED]', _doc)) if _doc: print(wrapper.fill(_doc)) @@ -235,33 +236,44 @@ def help_solvers(): try: # Disable warnings logging.disable(logging.WARNING) - for s in solver_list: - # Create a solver, and see if it is available - with pyomo.opt.SolverFactory(s) as opt: - ver = '' - if opt.available(False): - avail = '-' - if opt.license_is_valid(): - avail = '+' - try: - ver = opt.version() - if ver: - while len(ver) > 2 and ver[-1] == 0: - ver = ver[:-1] - ver = '.'.join(str(v) for v in ver) - else: - ver = '' - except (AttributeError, NameError): - pass - elif s == 'py' or (hasattr(opt, "_metasolver") and opt._metasolver): - # py is a metasolver, but since we don't specify a subsolver - # for this test, opt is actually an UnknownSolver, so we - # can't try to get the _metasolver attribute from it. - # Also, default to False if the attribute isn't implemented - avail = '*' - else: - avail = '' - _data.append((avail, s, ver, pyomo.opt.SolverFactory.doc(s))) + # suppress ALL output + with capture_output(capture_fd=True): + for s in solver_list: + # Create a solver, and see if it is available + with pyomo.opt.SolverFactory(s) as opt: + ver = '' + if opt.available(False): + avail = '-' + if opt.license_is_valid(): + avail = '+' + try: + ver = opt.version() + if isinstance(ver, str): + pass + elif ver: + while len(ver) > 2 and ver[-1] == 0: + ver = ver[:-1] + ver = '.'.join(str(v) for v in ver) + else: + ver = '' + except (AttributeError, NameError): + pass + elif s == 'py': + # py is a metasolver, but since we don't specify a subsolver + # for this test, opt is actually an UnknownSolver, so we + # can't try to get the _metasolver attribute from it. + avail = '*' + elif isinstance(s, pyomo.opt.solvers.UnknownSolver): + # We can get here if creating a registered + # solver failed (i.e., an exception was raised + # in __init__) + avail = '' + elif getattr(opt, "_metasolver", False): + # Note: default to False if the attribute isn't implemented + avail = '*' + else: + avail = '' + _data.append((avail, s, ver, pyomo.opt.SolverFactory.doc(s))) finally: # Reset logging level logging.disable(logging.NOTSET) diff --git a/pyomo/scripting/interface.py b/pyomo/scripting/interface.py index fca485b279b..697a3616d11 100644 --- a/pyomo/scripting/interface.py +++ b/pyomo/scripting/interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -29,9 +29,12 @@ def pyomo_callback(name): Example: - @pyomo_callback('cut-callback') - def my_cut_generator(solver, model): - ... + .. code:: + + @pyomo_callback('cut-callback') + def my_cut_generator(solver, model): + ... + """ def fn(f): diff --git a/pyomo/scripting/plugins/__init__.py b/pyomo/scripting/plugins/__init__.py index 86a3100e077..c26e43a76e0 100644 --- a/pyomo/scripting/plugins/__init__.py +++ b/pyomo/scripting/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,8 +11,4 @@ def load(): - import pyomo.scripting.plugins.convert - import pyomo.scripting.plugins.solve - import pyomo.scripting.plugins.download - import pyomo.scripting.plugins.build_ext - import pyomo.scripting.plugins.extras + from pyomo.scripting.plugins import convert, solve, download, build_ext, extras diff --git a/pyomo/scripting/plugins/build_ext.py b/pyomo/scripting/plugins/build_ext.py index 5b4ac836a00..cdf70a3254b 100644 --- a/pyomo/scripting/plugins/build_ext.py +++ b/pyomo/scripting/plugins/build_ext.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/plugins/convert.py b/pyomo/scripting/plugins/convert.py index ea6742cec56..a9d4029d094 100644 --- a/pyomo/scripting/plugins/convert.py +++ b/pyomo/scripting/plugins/convert.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/plugins/download.py b/pyomo/scripting/plugins/download.py index eea858a737f..80d5d643fa3 100644 --- a/pyomo/scripting/plugins/download.py +++ b/pyomo/scripting/plugins/download.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -38,9 +38,9 @@ def _call_impl(self, args, unparsed, logger): self.downloader.cacert = args.cacert self.downloader.insecure = args.insecure logger.info( - "As of February 9, 2023, AMPL GSL can no longer be downloaded\ - through download-extensions. Visit https://portal.ampl.com/\ - to download the AMPL GSL binaries." + "As of February 9, 2023, AMPL GSL can no longer be downloaded \ + through download-extensions. Visit https://portal.ampl.com/ \ + to download the AMPL GSL binaries." ) for target in DownloadFactory: try: diff --git a/pyomo/scripting/plugins/extras.py b/pyomo/scripting/plugins/extras.py index 2bd1c4a0803..f5cf480b261 100644 --- a/pyomo/scripting/plugins/extras.py +++ b/pyomo/scripting/plugins/extras.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/plugins/solve.py b/pyomo/scripting/plugins/solve.py index b2a849e995b..a0224e96208 100644 --- a/pyomo/scripting/plugins/solve.py +++ b/pyomo/scripting/plugins/solve.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/pyomo_command.py b/pyomo/scripting/pyomo_command.py index 8beec41a8b1..1bd1a11593e 100644 --- a/pyomo/scripting/pyomo_command.py +++ b/pyomo/scripting/pyomo_command.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/pyomo_main.py b/pyomo/scripting/pyomo_main.py index 6497206fdda..3f7214ba5b6 100644 --- a/pyomo/scripting/pyomo_main.py +++ b/pyomo/scripting/pyomo_main.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/pyomo_parser.py b/pyomo/scripting/pyomo_parser.py index 9294d46f85e..0ec71a0b3af 100644 --- a/pyomo/scripting/pyomo_parser.py +++ b/pyomo/scripting/pyomo_parser.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/solve_config.py b/pyomo/scripting/solve_config.py index 7ce3505d045..35f0fbb8dbf 100644 --- a/pyomo/scripting/solve_config.py +++ b/pyomo/scripting/solve_config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -195,7 +195,7 @@ def minlp_config_block(init=False): ), ).declare_as_argument('-c', '--catch-errors', dest="catch") runtime.declare( - 'disable gc', ConfigValue(False, bool, 'Disable the garbage collecter.', None) + 'disable gc', ConfigValue(False, bool, 'Disable the garbage collector.', None) ).declare_as_argument('--disable-gc', dest='disable_gc') runtime.declare( 'interactive', diff --git a/pyomo/scripting/tests/__init__.py b/pyomo/scripting/tests/__init__.py index d9146f7eee4..3fa793229a7 100644 --- a/pyomo/scripting/tests/__init__.py +++ b/pyomo/scripting/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/tests/test_cmds.py b/pyomo/scripting/tests/test_cmds.py index 9a120c8c175..e3a25d32ea0 100644 --- a/pyomo/scripting/tests/test_cmds.py +++ b/pyomo/scripting/tests/test_cmds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/scripting/util.py b/pyomo/scripting/util.py index b2a30ebaecd..35b5c090bd5 100644 --- a/pyomo/scripting/util.py +++ b/pyomo/scripting/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -971,30 +971,45 @@ def __exit__(self, et, ev, tb): def run_command( command=None, parser=None, args=None, name='unknown', data=None, options=None ): - """ - Execute a function that processes command-line arguments and + """Execute a function that processes command-line arguments and then calls a command-line driver. This function provides a generic facility for executing a command function is rather generic. This function is segregated from the driver to enable profiling of the command-line execution. - Required: - command: The name of a function that will be executed to perform process the command-line - options with a parser object. - parser: The parser object that is used by the command-line function. + Parameters + ---------- + command: - Optional: - options: If this is not None, then ignore the args option and use - this to specify command options. - args: Command-line arguments that are parsed. If this value is `None`, then the - arguments in `sys.argv` are used to parse the command-line. - name: Specifying the name of the command-line (for error messages). - data: A container of labeled data. + The name of a function that will be executed to perform process + the command-line options with a parser object. + + parser: + The parser object that is used by the command-line function. + + options: + If this is not None, then ignore the args option and use this to + specify command options. + + args: + Command-line arguments that are parsed. If this value is + `None`, then the arguments in `sys.argv` are used to parse the + command-line. + + name: + Specifying the name of the command-line (for error messages). + + data: + A container of labeled data. + + Returns + ------- + retval: + Return values from the command-line execution. + errorcode: + 0 if Pyomo ran successfully - Returned: - retval: Return values from the command-line execution. - errorcode: 0 if Pyomo ran successfully """ # # diff --git a/pyomo/solvers/__init__.py b/pyomo/solvers/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/solvers/__init__.py +++ b/pyomo/solvers/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/amplfunc_merge.py b/pyomo/solvers/amplfunc_merge.py new file mode 100644 index 00000000000..2d065609f19 --- /dev/null +++ b/pyomo/solvers/amplfunc_merge.py @@ -0,0 +1,62 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.deprecation import relocated_module_attribute + +relocated_module_attribute( + 'amplfunc_string_merge', + 'pyomo.solvers.amplfunc_merge.unique_paths', + version='6.9.2', + f_globals=globals(), +) + + +def unique_paths(*paths): + """Merge paths, eliminating duplicates. + + Parameters + ---------- + *path : str + + Each argument is a string containing one or more paths. + Multiple libraries are separated by newlines. + + Returns + ------- + str : merged list of newline-separated paths. + + """ + funcs = {} + for src in paths: + for line in src.splitlines(): + if not line: + continue + funcs[line] = None + return "\n".join(funcs) + + +def amplfunc_merge(env, *funcs): + """Merge AMPLFUNC and PYOMO_AMPLFUNC environment variables with provided values + + Paths are returned with entries from AMPLFUNC first, PYOMO_AMPLFUNC + second, and the user-provided arguments last. Duplicates and empty + paths are filtered out. + + Parameters + ---------- + env : Dict[str, str] + Environment dictionary mapping environment variable names to values. + + *funcs : str + Additional paths to combine. + + """ + return unique_paths(env.get("AMPLFUNC", ""), env.get("PYOMO_AMPLFUNC", ""), *funcs) diff --git a/pyomo/solvers/mockmip.py b/pyomo/solvers/mockmip.py index 2c28b7a9be0..626c289baf3 100644 --- a/pyomo/solvers/mockmip.py +++ b/pyomo/solvers/mockmip.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/plugins/__init__.py b/pyomo/solvers/plugins/__init__.py index 2a7bf2fea04..f5b9747f030 100644 --- a/pyomo/solvers/plugins/__init__.py +++ b/pyomo/solvers/plugins/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,5 +11,4 @@ def load(): - import pyomo.solvers.plugins.converter - import pyomo.solvers.plugins.solvers + from pyomo.solvers.plugins import converter, solvers diff --git a/pyomo/solvers/plugins/converter/__init__.py b/pyomo/solvers/plugins/converter/__init__.py index 56c32f1c8c1..fa9ef757760 100644 --- a/pyomo/solvers/plugins/converter/__init__.py +++ b/pyomo/solvers/plugins/converter/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,4 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.solvers.plugins.converter.ampl -import pyomo.solvers.plugins.converter.glpsol -import pyomo.solvers.plugins.converter.model +from pyomo.solvers.plugins.converter import ampl, glpsol, model diff --git a/pyomo/solvers/plugins/converter/ampl.py b/pyomo/solvers/plugins/converter/ampl.py index 0798115a448..69a0eea382f 100644 --- a/pyomo/solvers/plugins/converter/ampl.py +++ b/pyomo/solvers/plugins/converter/ampl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/plugins/converter/glpsol.py b/pyomo/solvers/plugins/converter/glpsol.py index 9b404567c4d..b0da85e0e13 100644 --- a/pyomo/solvers/plugins/converter/glpsol.py +++ b/pyomo/solvers/plugins/converter/glpsol.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/plugins/converter/model.py b/pyomo/solvers/plugins/converter/model.py index 817df157bf5..5d3f00ee9e4 100644 --- a/pyomo/solvers/plugins/converter/model.py +++ b/pyomo/solvers/plugins/converter/model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/plugins/converter/pico.py b/pyomo/solvers/plugins/converter/pico.py index e5d008da347..bb5a8955693 100644 --- a/pyomo/solvers/plugins/converter/pico.py +++ b/pyomo/solvers/plugins/converter/pico.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/plugins/solvers/ASL.py b/pyomo/solvers/plugins/solvers/ASL.py index ae7ad82c870..1ef288657a9 100644 --- a/pyomo/solvers/plugins/solvers/ASL.py +++ b/pyomo/solvers/plugins/solvers/ASL.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -23,6 +23,7 @@ from pyomo.opt.solver import SystemCallSolver from pyomo.core.kernel.block import IBlock from pyomo.solvers.mockmip import MockMIP +from pyomo.solvers.amplfunc_merge import amplfunc_merge from pyomo.core import TransformationFactory import logging @@ -100,13 +101,14 @@ def _get_version(self): timeout=5, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - universal_newlines=True, + text=True, + errors='ignore', ) ver = _extract_version(results.stdout) if ver is None: # Some ASL solvers do not export a version number if results.stdout.strip().split()[-1].startswith('ASL('): - return '0.0.0' + return (0, 0, 0) return ver except OSError: pass @@ -158,11 +160,9 @@ def create_command_line(self, executable, problem_files): # Pyomo/Pyomo) with any user-specified external function # libraries # - if 'PYOMO_AMPLFUNC' in env: - if 'AMPLFUNC' in env: - env['AMPLFUNC'] += "\n" + env['PYOMO_AMPLFUNC'] - else: - env['AMPLFUNC'] = env['PYOMO_AMPLFUNC'] + amplfunc = amplfunc_merge(env) + if amplfunc: + env['AMPLFUNC'] = amplfunc cmd = [executable, problem_files[0], '-AMPL'] if self._timer: diff --git a/pyomo/solvers/plugins/solvers/BARON.py b/pyomo/solvers/plugins/solvers/BARON.py index 044cab27b86..fb67c53c079 100644 --- a/pyomo/solvers/plugins/solvers/BARON.py +++ b/pyomo/solvers/plugins/solvers/BARON.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/plugins/solvers/CBCplugin.py b/pyomo/solvers/plugins/solvers/CBCplugin.py index eb6c2c2e1bd..f9e1d628d4f 100644 --- a/pyomo/solvers/plugins/solvers/CBCplugin.py +++ b/pyomo/solvers/plugins/solvers/CBCplugin.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -16,6 +16,7 @@ import subprocess from pyomo.common import Executable +from pyomo.common.enums import maximize, minimize from pyomo.common.errors import ApplicationError from pyomo.common.collections import Bunch from pyomo.common.tempfiles import TempfileManager @@ -29,7 +30,6 @@ SolverStatus, TerminationCondition, SolutionStatus, - ProblemSense, Solution, ) from pyomo.opt.solver import SystemCallSolver @@ -443,7 +443,7 @@ def process_logfile(self): # # Parse logfile lines # - results.problem.sense = ProblemSense.minimize + results.problem.sense = minimize results.problem.name = None optim_value = float('inf') lower_bound = None @@ -455,7 +455,7 @@ def process_logfile(self): tokens = tuple(re.split('[ \t]+', line.strip())) n_tokens = len(tokens) if n_tokens > 1: - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L3769 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L3769 if n_tokens > 4 and tokens[:4] == ( 'Continuous', 'objective', @@ -539,7 +539,7 @@ def process_logfile(self): results.problem.name = results.problem.name.split('/')[-1] if '\\' in results.problem.name: results.problem.name = results.problem.name.split('\\')[-1] - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L10840 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L10840 elif tokens[0] == 'Presolve': if n_tokens > 9 and tokens[3] == 'rows,' and tokens[6] == 'columns': results.problem.number_of_variables = int(tokens[4]) - int( @@ -551,7 +551,7 @@ def process_logfile(self): results.problem.number_of_objectives = 1 elif n_tokens > 6 and tokens[6] == 'infeasible': soln.status = SolutionStatus.infeasible - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L11105 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L11105 elif ( n_tokens > 11 and tokens[:2] == ('Problem', 'has') @@ -563,7 +563,7 @@ def process_logfile(self): results.problem.number_of_constraints = int(tokens[2]) results.problem.number_of_nonzeros = int(tokens[6][1:]) results.problem.number_of_objectives = 1 - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L10814 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L10814 elif ( n_tokens > 8 and tokens[:3] == ('Original', 'problem', 'has') @@ -578,8 +578,8 @@ def process_logfile(self): 'CoinLpIO::readLp(): Maximization problem reformulated as minimization' in ' '.join(tokens) ): - results.problem.sense = ProblemSense.maximize - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L3047 + results.problem.sense = maximize + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L3047 elif n_tokens > 3 and tokens[:2] == ('Result', '-'): if tokens[2:4] in [('Run', 'abandoned'), ('User', 'ctrl-c')]: results.solver.termination_condition = ( @@ -609,15 +609,15 @@ def process_logfile(self): 'solution': TerminationCondition.other, 'iterations': TerminationCondition.maxIterations, }.get(tokens[4], TerminationCondition.other) - # perhaps from https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L12318 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L12318 elif n_tokens > 3 and tokens[2] == "Finished": soln.status = SolutionStatus.optimal optim_value = _float(tokens[4]) - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L7904 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L7904 elif n_tokens >= 3 and tokens[:2] == ('Objective', 'value:'): # parser for log file generetated with discrete variable optim_value = _float(tokens[2]) - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L7904 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L7904 elif n_tokens >= 4 and tokens[:4] == ( 'No', 'feasible', @@ -630,25 +630,25 @@ def process_logfile(self): lower_bound is None ): # Only use if not already found since this is to less decimal places results.problem.lower_bound = _float(tokens[2]) - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L7918 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L7918 elif tokens[0] == 'Gap:': # This is relative and only to 2 decimal places - could calculate explicitly using lower bound gap = _float(tokens[1]) - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L7923 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L7923 elif n_tokens > 2 and tokens[:2] == ('Enumerated', 'nodes:'): nodes = int(tokens[2]) - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L7926 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L7926 elif n_tokens > 2 and tokens[:2] == ('Total', 'iterations:'): results.solver.statistics.black_box.number_of_iterations = int( tokens[2] ) - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L7930 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L7930 elif n_tokens > 3 and tokens[:3] == ('Time', '(CPU', 'seconds):'): results.solver.system_time = _float(tokens[3]) - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L7933 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L7933 elif n_tokens > 3 and tokens[:3] == ('Time', '(Wallclock', 'Seconds):'): results.solver.wallclock_time = _float(tokens[3]) - # https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp?rev=2497#L10477 + # https://github.com/coin-or/Cbc/blob/cb6bf98/Cbc/src/CbcSolver.cpp#L10477 elif n_tokens > 4 and tokens[:4] == ( 'Total', 'time', @@ -752,9 +752,9 @@ def process_logfile(self): "maxIterations parameter." ) soln.gap = gap - if results.problem.sense == ProblemSense.minimize: + if results.problem.sense == minimize: upper_bound = optim_value - elif results.problem.sense == ProblemSense.maximize: + elif results.problem.sense == maximize: _ver = self.version() if _ver and _ver[:3] < (2, 10, 2): optim_value *= -1 @@ -824,7 +824,7 @@ def process_soln_file(self, results): INPUT = [] _ver = self.version() - invert_objective_sense = results.problem.sense == ProblemSense.maximize and ( + invert_objective_sense = results.problem.sense == maximize and ( _ver and _ver[:3] < (2, 10, 2) ) @@ -832,11 +832,15 @@ def process_soln_file(self, results): tokens = tuple(re.split('[ \t]+', line.strip())) n_tokens = len(tokens) # - # These are the only header entries CBC will generate (identified via browsing CbcSolver.cpp) - # See https://projects.coin-or.org/Cbc/browser/trunk/Cbc/src/CbcSolver.cpp - # Search for (no integer solution - continuous used) Currently line 9912 as of rev2497 - # Note that since this possibly also covers old CBC versions, we shall not be removing any functionality, - # even if it is not seen in the current revision + # These are the only header entries CBC will generate + # (identified via browsing CbcSolver.cpp). See + # https://github.com/coin-or/Cbc/tree/master/src/CbcSolver.cpp + # Search for "(no integer solution - continuous used)" + # (L10796 as of cb855c7) + # + # Note that since this possibly also supports old CBC + # versions, we shall not be removing any functionality, even + # if it is not seen in the current revision # if not header_processed: if tokens[0] == 'Optimal': diff --git a/pyomo/solvers/plugins/solvers/CONOPT.py b/pyomo/solvers/plugins/solvers/CONOPT.py index 89ee3848805..c196137dde6 100644 --- a/pyomo/solvers/plugins/solvers/CONOPT.py +++ b/pyomo/solvers/plugins/solvers/CONOPT.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -79,7 +79,7 @@ def _get_version(self): return _extract_version('') results = subprocess.run( [solver_exec], - timeout=1, + timeout=self._version_timeout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, diff --git a/pyomo/solvers/plugins/solvers/CPLEX.py b/pyomo/solvers/plugins/solvers/CPLEX.py index b2b8c5e988d..f684525bac9 100644 --- a/pyomo/solvers/plugins/solvers/CPLEX.py +++ b/pyomo/solvers/plugins/solvers/CPLEX.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,6 +17,7 @@ import subprocess from pyomo.common import Executable +from pyomo.common.enums import maximize, minimize from pyomo.common.errors import ApplicationError from pyomo.common.tempfiles import TempfileManager @@ -28,7 +29,6 @@ SolverStatus, TerminationCondition, SolutionStatus, - ProblemSense, Solution, ) from pyomo.opt.solver import ILMLicensedSystemCallSolver @@ -42,7 +42,7 @@ def _validate_file_name(cplex, filename, description): - """Validate filenames against the set of allowable chaacters in CPLEX. + """Validate filenames against the set of allowable characters in CPLEX. Returns the filename, possibly enclosed in double-quotes, or raises a ValueError is unallowable characters are found. @@ -404,7 +404,7 @@ def _get_version(self): return _extract_version('') results = subprocess.run( [solver_exec, '-c', 'quit'], - timeout=1, + timeout=self._version_timeout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, @@ -547,9 +547,9 @@ def process_logfile(self): ): # CPLEX 11.2 and subsequent has two Nonzeros sections. results.problem.number_of_nonzeros = int(tokens[2]) elif len(tokens) >= 5 and tokens[4] == "MINIMIZE": - results.problem.sense = ProblemSense.minimize + results.problem.sense = minimize elif len(tokens) >= 5 and tokens[4] == "MAXIMIZE": - results.problem.sense = ProblemSense.maximize + results.problem.sense = maximize elif ( len(tokens) >= 4 and tokens[0] == "Solution" @@ -859,9 +859,9 @@ def process_soln_file(self, results): else: sense = tokens[0].lower() if sense in ['max', 'maximize']: - results.problem.sense = ProblemSense.maximize + results.problem.sense = maximize if sense in ['min', 'minimize']: - results.problem.sense = ProblemSense.minimize + results.problem.sense = minimize break tINPUT.close() @@ -952,7 +952,7 @@ def process_soln_file(self, results): ) if primal_feasible == 1: soln.status = SolutionStatus.feasible - if results.problem.sense == ProblemSense.minimize: + if results.problem.sense == minimize: results.problem.upper_bound = soln.objective[ '__default_objective__' ]['Value'] @@ -964,7 +964,7 @@ def process_soln_file(self, results): soln.status = SolutionStatus.infeasible if self._best_bound is not None: - if results.problem.sense == ProblemSense.minimize: + if results.problem.sense == minimize: results.problem.lower_bound = self._best_bound else: results.problem.upper_bound = self._best_bound diff --git a/pyomo/solvers/plugins/solvers/GAMS.py b/pyomo/solvers/plugins/solvers/GAMS.py index e84cbdb441d..40c40921d72 100644 --- a/pyomo/solvers/plugins/solvers/GAMS.py +++ b/pyomo/solvers/plugins/solvers/GAMS.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -18,6 +18,7 @@ from pyomo.opt import ProblemFormat, SolverFactory import pyomo.common +from pyomo.common.dependencies import pathlib from pyomo.common.collections import Bunch from pyomo.common.tee import TeeStream @@ -36,12 +37,11 @@ Solution, SolutionStatus, TerminationCondition, - ProblemSense, ) from pyomo.common.dependencies import attempt_import -gdxcc, gdxcc_available = attempt_import('gdxcc', defer_check=True) +gdxcc, gdxcc_available = attempt_import('gdxcc') logger = logging.getLogger('pyomo.solvers') @@ -198,8 +198,8 @@ def _get_version(self): return _extract_version('') from gams import GamsWorkspace - ws = GamsWorkspace() - version = tuple(int(i) for i in ws._version.split('.')[:4]) + workspace = GamsWorkspace() + version = tuple(int(i) for i in workspace._version.split('.')[:4]) while len(version) < 4: version += (0,) return version @@ -209,8 +209,8 @@ def _run_simple_model(self, n): try: from gams import GamsWorkspace, DebugLevel - ws = GamsWorkspace(debug=DebugLevel.Off, working_directory=tmpdir) - t1 = ws.add_job_from_string(self._simple_model(n)) + workspace = GamsWorkspace(debug=DebugLevel.Off, working_directory=tmpdir) + t1 = workspace.add_job_from_string(self._simple_model(n)) t1.run() return True except: @@ -301,7 +301,7 @@ def solve(self, *args, **kwds): filename=output_file, format=ProblemFormat.gams, _called_by_solver=True, - **io_options + **io_options, ) symbolMap = getattr(model, "._symbol_maps")[smap_id] else: @@ -330,12 +330,12 @@ def solve(self, *args, **kwds): if tmpdir is not None and os.path.exists(tmpdir): newdir = False - ws = GamsWorkspace( + workspace = GamsWorkspace( debug=DebugLevel.KeepFiles if keepfiles else DebugLevel.Off, working_directory=tmpdir, ) - t1 = ws.add_job_from_string(output_file.getvalue()) + t1 = workspace.add_job_from_string(output_file.getvalue()) try: with OutputStream(tee=tee, logfile=logfile) as output_stream: @@ -349,7 +349,9 @@ def solve(self, *args, **kwds): # Always name working directory or delete files, # regardless of any errors. if keepfiles: - print("\nGAMS WORKING DIRECTORY: %s\n" % ws.working_directory) + print( + "\nGAMS WORKING DIRECTORY: %s\n" % workspace.working_directory + ) elif tmpdir is not None: # Garbage collect all references to t1.out_db # So that .gdx file can be deleted @@ -359,7 +361,7 @@ def solve(self, *args, **kwds): except: # Catch other errors and remove files first if keepfiles: - print("\nGAMS WORKING DIRECTORY: %s\n" % ws.working_directory) + print("\nGAMS WORKING DIRECTORY: %s\n" % workspace.working_directory) elif tmpdir is not None: # Garbage collect all references to t1.out_db # So that .gdx file can be deleted @@ -398,7 +400,9 @@ def solve(self, *args, **kwds): extract_rc = 'rc' in model_suffixes results = SolverResults() - results.problem.name = os.path.join(ws.working_directory, t1.name + '.gms') + results.problem.name = os.path.join( + workspace.working_directory, t1.name + '.gms' + ) results.problem.lower_bound = t1.out_db["OBJEST"].find_record().value results.problem.upper_bound = t1.out_db["OBJEST"].find_record().value results.problem.number_of_variables = t1.out_db["NUMVAR"].find_record().value @@ -418,11 +422,10 @@ def solve(self, *args, **kwds): assert len(obj) == 1, 'Only one objective is allowed.' obj = obj[0] objctvval = t1.out_db["OBJVAL"].find_record().value + results.problem.sense = obj.sense if obj.is_minimizing(): - results.problem.sense = ProblemSense.minimize results.problem.upper_bound = objctvval else: - results.problem.sense = ProblemSense.maximize results.problem.lower_bound = objctvval results.solver.name = "GAMS " + str(self.version()) @@ -587,7 +590,7 @@ def solve(self, *args, **kwds): results.solution.insert(soln) if keepfiles: - print("\nGAMS WORKING DIRECTORY: %s\n" % ws.working_directory) + print("\nGAMS WORKING DIRECTORY: %s\n" % workspace.working_directory) elif tmpdir is not None: # Garbage collect all references to t1.out_db # So that .gdx file can be deleted @@ -737,6 +740,32 @@ def _parse_special_values(value): return sys.float_info.epsilon return value + def _rewrite_path_win8p3(self, path): + """ + Return the 8.3 short path on Windows; unchanged elsewhere. + + This change is in response to Pyomo/pyomo#3579 which reported + that GAMS (direct) fails on Windows if there is a space in + the path. This utility converts paths to their 8.3 short-path version + (which never have spaces). + """ + if not sys.platform.startswith("win"): + return str(path) + + import ctypes, ctypes.wintypes as wt + + GetShortPathNameW = ctypes.windll.kernel32.GetShortPathNameW + GetShortPathNameW.argtypes = [wt.LPCWSTR, wt.LPWSTR, wt.DWORD] + + # the file must exist, or Windows will not create a short name + pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True) + pathlib.Path(path).touch(exist_ok=True) + + buf = ctypes.create_unicode_buffer(260) + if GetShortPathNameW(str(path), buf, 260): + return buf.value + return str(path) + def solve(self, *args, **kwds): """ Solve a model via the GAMS executable. @@ -843,7 +872,7 @@ def solve(self, *args, **kwds): filename=output_filename, format=ProblemFormat.gams, _called_by_solver=True, - **io_options + **io_options, ) symbolMap = getattr(model, "._symbol_maps")[smap_id] else: @@ -879,7 +908,7 @@ def solve(self, *args, **kwds): elif tee and logfile: command.append("lo=4") if logfile: - command.append("lf=" + str(logfile)) + command.append(f"lf={self._rewrite_path_win8p3(logfile)}") try: ostreams = [StringIO()] @@ -980,11 +1009,10 @@ def solve(self, *args, **kwds): assert len(obj) == 1, 'Only one objective is allowed.' obj = obj[0] objctvval = stat_vars["OBJVAL"] + results.problem.sense = obj.sense if obj.is_minimizing(): - results.problem.sense = ProblemSense.minimize results.problem.upper_bound = objctvval else: - results.problem.sense = ProblemSense.maximize results.problem.lower_bound = objctvval results.solver.name = "GAMS " + str(self.version()) diff --git a/pyomo/solvers/plugins/solvers/GLPK.py b/pyomo/solvers/plugins/solvers/GLPK.py index 39948d465f4..a46c6ec4f6f 100644 --- a/pyomo/solvers/plugins/solvers/GLPK.py +++ b/pyomo/solvers/plugins/solvers/GLPK.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -19,6 +19,8 @@ from pyomo.common import Executable from pyomo.common.collections import Bunch +from pyomo.common.enums import maximize, minimize +from pyomo.common.errors import ApplicationError from pyomo.opt import ( SolverFactory, OptSolver, @@ -27,7 +29,6 @@ SolverResults, TerminationCondition, SolutionStatus, - ProblemSense, ) from pyomo.opt.base.solvers import _extract_version from pyomo.opt.solver import SystemCallSolver @@ -137,7 +138,7 @@ def _get_version(self, executable=None): [executable, "--version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - timeout=1, + timeout=self._version_timeout, universal_newlines=True, ) return _extract_version(result.stdout) @@ -307,10 +308,8 @@ def process_soln_file(self, results): ): raise ValueError - self.is_integer = 'mip' == ptype and True or False - prob.sense = ( - 'min' == psense and ProblemSense.minimize or ProblemSense.maximize - ) + self.is_integer = 'mip' == ptype + prob.sense = minimize if 'min' == psense else maximize prob.number_of_constraints = prows prob.number_of_nonzeros = pnonz prob.number_of_variables = pcols diff --git a/pyomo/solvers/plugins/solvers/GUROBI.py b/pyomo/solvers/plugins/solvers/GUROBI.py index c8b0912970e..692012bbb8e 100644 --- a/pyomo/solvers/plugins/solvers/GUROBI.py +++ b/pyomo/solvers/plugins/solvers/GUROBI.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,6 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import io import os import sys import re @@ -18,8 +19,12 @@ from pyomo.common import Executable from pyomo.common.collections import Bunch +from pyomo.common.dependencies import attempt_import +from pyomo.common.enums import maximize, minimize +from pyomo.common.errors import ApplicationError from pyomo.common.fileutils import this_file_dir -from pyomo.common.tee import capture_output +from pyomo.common.log import is_debug_set +from pyomo.common.tee import capture_output, TeeStream from pyomo.common.tempfiles import TempfileManager from pyomo.opt.base import ProblemFormat, ResultsFormat, OptSolver @@ -28,17 +33,17 @@ SolverStatus, TerminationCondition, SolutionStatus, - ProblemSense, Solution, ) from pyomo.opt.solver import ILMLicensedSystemCallSolver from pyomo.core.kernel.block import IBlock from pyomo.core import ConcreteModel, Var, Objective -from .gurobi_direct import gurobipy_available -from .ASL import ASL +from pyomo.solvers.plugins.solvers.gurobi_direct import gurobipy, gurobipy_available +from pyomo.solvers.plugins.solvers.ASL import ASL logger = logging.getLogger('pyomo.solvers') +GUROBI_RUN = attempt_import('pyomo.solvers.plugins.solvers.GUROBI_RUN')[0] @SolverFactory.register('gurobi', doc='The GUROBI LP/MIP solver') @@ -51,9 +56,15 @@ def __new__(cls, *args, **kwds): mode = 'lp' # if mode == 'lp': - return SolverFactory('_gurobi_shell', **kwds) + if gurobipy_available: + return SolverFactory('_gurobi_file', **kwds) + else: + return SolverFactory('_gurobi_shell', **kwds) if mode == 'mps': - opt = SolverFactory('_gurobi_shell', **kwds) + if gurobipy_available: + opt = SolverFactory('_gurobi_file', **kwds) + else: + opt = SolverFactory('_gurobi_shell', **kwds) opt.set_problem_format(ProblemFormat.mps) return opt if mode in ['python', 'direct']: @@ -76,7 +87,28 @@ def __new__(cls, *args, **kwds): else: logger.error('Unknown IO type: %s' % mode) return - opt.set_options('solver=gurobi_ampl') + # The Gurobi ASL solver was 'gurobi_ampl' through Gurobi 11, + # then was renamed to 'gurobi'. Check 'gurobi' first, then + # 'gurobi_ampl'. + for exe_name in ('gurobi', 'gurobi_ampl'): + exe = Executable(exe_name) + if ( + exe.available() + and b'[-AMPL]' + in subprocess.run( + exe.executable, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=1, + ).stdout + ): + opt.set_options(f'solver={exe_name}') + break + else: + # Fall back on 'gurobi' (matching the current Gurobi + # release) if neither appears to be available. + opt.set_options('solver=gurobi') return opt @@ -192,8 +224,7 @@ def _warm_start(self, instance): # for each variable in the symbol_map, add a child to the # variables element. Both continuous and discrete are accepted - # (and required, depending on other options), according to the - # CPLEX manual. + # (and required, depending on other options). # # **Note**: This assumes that the symbol_map is "clean", i.e., # contains only references to the variables encountered in @@ -318,8 +349,6 @@ def _get_version(self): def create_command_line(self, executable, problem_files): # # Define log file - # The log file in CPLEX contains the solution trace, but the - # solver status can be found in the solution file. # if self._log_file is None: self._log_file = TempfileManager.create_tempfile(suffix='.gurobi.log') @@ -341,11 +370,10 @@ def create_command_line(self, executable, problem_files): warmstart_filename = self._warm_start_file_name # translate the options into a normal python dictionary, from a - # pyutilib SectionWrapper - the gurobi_run function doesn't know - # about pyomo, so the translation is necessary. - options_dict = {} - for key in self.options: - options_dict[key] = self.options[key] + # pyomo.common.collections.Bunch - the gurobi_run function + # doesn't know about pyomo, so the translation is necessary + # (`repr(options)` doesn't produce executable python code) + options_dict = dict(self.options) # NOTE: the gurobi shell is independent of Pyomo python # virtualized environment, so any imports - specifically @@ -354,21 +382,20 @@ def create_command_line(self, executable, problem_files): # NOTE: The gurobi plugin (GUROBI.py) and GUROBI_RUN.py live in # the same directory. script = "import sys\n" - script += "from gurobipy import *\n" script += "sys.path.append(%r)\n" % (this_file_dir(),) - script += "from GUROBI_RUN import *\n" - script += "gurobi_run(" + script += "import GUROBI_RUN\n" + script += "soln = GUROBI_RUN.gurobi_run(" mipgap = float(self.options.mipgap) if self.options.mipgap is not None else None for x in ( problem_filename, warmstart_filename, - solution_filename, None, options_dict, self._suffixes, ): script += "%r," % x script += ")\n" + script += "GUROBI_RUN.write_result(soln, %r)\n" % solution_filename script += "quit()\n" # dump the script and warm-start file names for the @@ -392,11 +419,10 @@ def create_command_line(self, executable, problem_files): return Bunch(cmd=cmd, script=script, log_file=self._log_file, env=None) def process_soln_file(self, results): - # the only suffixes that we extract from CPLEX are - # constraint duals, constraint slacks, and variable - # reduced-costs. scan through the solver suffix list - # and throw an exception if the user has specified - # any others. + # the only suffixes that we extract are constraint duals, + # constraint slacks, and variable reduced-costs. scan through + # the solver suffix list and throw an exception if the user has + # specified any others. extract_duals = False extract_slacks = False extract_rc = False @@ -472,7 +498,7 @@ def process_soln_file(self, results): soln.objective['__default_objective__'] = { 'Value': float(tokens[1]) } - if results.problem.sense == ProblemSense.minimize: + if results.problem.sense == minimize: results.problem.upper_bound = float(tokens[1]) else: results.problem.lower_bound = float(tokens[1]) @@ -514,9 +540,9 @@ def process_soln_file(self, results): elif section == 1: if tokens[0] == 'sense': if tokens[1] == 'minimize': - results.problem.sense = ProblemSense.minimize + results.problem.sense = minimize elif tokens[1] == 'maximize': - results.problem.sense = ProblemSense.maximize + results.problem.sense = maximize else: try: val = eval(tokens[1]) @@ -588,3 +614,228 @@ def _postsolve(self): TempfileManager.pop(remove=not self._keepfiles) return results + + +@SolverFactory.register( + '_gurobi_file', doc='LP/MPS file-based direct interface to the GUROBI LP/MIP solver' +) +class GUROBIFILE(GUROBISHELL): + """Direct LP/MPS file-based interface to the GUROBI LP/MIP solver""" + + def available(self, exception_flag=False): + if not gurobipy_available: # this triggers the deferred import + if exception_flag: + raise ApplicationError("gurobipy module not importable") + return False + if getattr(self, '_available', None) is None: + self._check_license() + ans = self._available[0] + if exception_flag and not ans: + raise ApplicationError(msg % self.name) + return ans + + def license_is_valid(self): + return self.available(False) and self._available[1] + + def _check_license(self): + licensed = False + try: + # Gurobipy writes out license file information when creating + # the environment + with capture_output(capture_fd=True): + m = gurobipy.Model() + licensed = True + except gurobipy.GurobiError: + licensed = False + + self._available = (True, licensed) + + def _get_version(self): + return ( + gurobipy.GRB.VERSION_MAJOR, + gurobipy.GRB.VERSION_MINOR, + gurobipy.GRB.VERSION_TECHNICAL, + ) + + def _default_executable(self): + # Bogus, but not None (because the test infrastructure disables + # solvers where the executable() is None) + return "" + + def create_command_line(self, executable, problem_files): + # + # Define log file + # + if self._log_file is None: + self._log_file = TempfileManager.create_tempfile(suffix='.gurobi.log') + + # + # Define command line + # + return Bunch(cmd=[], script="", log_file=self._log_file, env=None) + + def _apply_solver(self): + # + # Execute the command + # + if is_debug_set(logger): + logger.debug("Running %s", self._command.cmd) + + problem_filename = self._problem_files[0] + warmstart_filename = self._warm_start_file_name + + # translate the options into a normal python dictionary, from a + # pyutilib SectionWrapper - because the gurobi_run function was + # originally designed to run in the Python environment + # distributed in the Gurobi installation (which doesn't know + # about pyomo) the translation is necessary. + options_dict = {} + for key in self.options: + options_dict[key] = self.options[key] + + # display the log/solver file names prior to execution. this is useful + # in case something crashes unexpectedly, which is not without precedent. + if self._keepfiles: + if self._log_file is not None: + print("Solver log file: '%s'" % self._log_file) + if self._problem_files != []: + print("Solver problem files: %s" % str(self._problem_files)) + + sys.stdout.flush() + ostreams = [io.StringIO()] + if self._tee: + ostreams.append(sys.stdout) + with capture_output(output=TeeStream(*ostreams), capture_fd=False): + self._soln = GUROBI_RUN.gurobi_run( + problem_filename, warmstart_filename, None, options_dict, self._suffixes + ) + self._log = ostreams[0].getvalue() + self._rc = 0 + sys.stdout.flush() + return Bunch(rc=self._rc, log=self._log) + + def process_soln_file(self, results): + # the only suffixes that we extract are constraint duals, + # constraint slacks, and variable reduced-costs. Scan through + # the solver suffix list and throw an exception if the user has + # specified any others. + extract_duals = False + extract_slacks = False + extract_rc = False + for suffix in self._suffixes: + flag = False + if re.match(suffix, "dual"): + extract_duals = True + flag = True + if re.match(suffix, "slack"): + extract_slacks = True + flag = True + if re.match(suffix, "rc"): + extract_rc = True + flag = True + if not flag: + raise RuntimeError( + "***The GUROBI solver plugin cannot extract solution suffix=" + + suffix + ) + + soln = Solution() + + # caching for efficiency + soln_variables = soln.variable + soln_constraints = soln.constraint + + num_variables_read = 0 + + # string compares are too expensive, so simply introduce some + # section IDs. + # 0 - unknown + # 1 - problem + # 2 - solution + # 3 - solver + + section = 0 # unknown + + solution_seen = False + + range_duals = {} + range_slacks = {} + + # Copy over the problem info + for key, val in self._soln['problem'].items(): + setattr(results.problem, key, val) + if results.problem.sense == 'minimize': + results.problem.sense = minimize + elif results.problem.sense == 'maximize': + results.problem.sense = maximize + + # Copy over the solver info + for key, val in self._soln['solver'].items(): + setattr(results.solver, key, val) + results.solver.status = getattr(SolverStatus, results.solver.status) + try: + results.solver.termination_condition = getattr( + TerminationCondition, results.solver.termination_condition + ) + except AttributeError: + results.solver.termination_condition = TerminationCondition.unknown + + # Copy over the solution information + sol = self._soln.get('solution', None) + if sol: + if 'status' in sol: + soln.status = sol['status'] + if 'gap' in sol: + soln.gap = sol['gap'] + obj = sol.get('objective', None) + if obj is not None: + soln.objective['__default_objective__'] = {'Value': obj} + if results.problem.sense == minimize: + results.problem.upper_bound = obj + else: + results.problem.lower_bound = obj + for name, val in sol.get('var', {}).items(): + if name == "ONE_VAR_CONSTANT": + continue + soln_variables[name] = {"Value": val} + num_variables_read += 1 + for name, val in sol.get('varrc', {}).items(): + if name == "ONE_VAR_CONSTANT": + continue + soln_variables[name]["Rc"] = val + for name, val in sol.get('constraintdual', {}).items(): + if name == "c_e_ONE_VAR_CONSTANT": + continue + if name.startswith('c_'): + soln_constraints.setdefault(name, {})["Dual"] = val + elif name.startswith('r_l_'): + range_duals.setdefault(name[4:], [0, 0])[0] = val + elif name.startswith('r_u_'): + range_duals.setdefault(name[4:], [0, 0])[1] = val + for name, val in sol.get('constraintslack', {}).items(): + if name == "c_e_ONE_VAR_CONSTANT": + continue + if name.startswith('c_'): + soln_constraints.setdefault(name, {})["Slack"] = val + elif name.startswith('r_l_'): + range_slacks.setdefault(name[4:], [0, 0])[0] = val + elif name.startswith('r_u_'): + range_slacks.setdefault(name[4:], [0, 0])[1] = val + + results.solution.insert(soln) + + # For the range constraints, supply only the dual with the largest + # magnitude (at least one should always be numerically zero) + for key, (ld, ud) in range_duals.items(): + if abs(ld) > abs(ud): + soln_constraints['r_l_' + key] = {"Dual": ld} + else: + # Use the same key + soln_constraints['r_l_' + key] = {"Dual": ud} + # slacks + for key, (ls, us) in range_slacks.items(): + if abs(ls) > abs(us): + soln_constraints.setdefault('r_l_' + key, {})["Slack"] = ls + else: + # Use the same key + soln_constraints.setdefault('r_l_' + key, {})["Slack"] = us diff --git a/pyomo/solvers/plugins/solvers/GUROBI_RUN.py b/pyomo/solvers/plugins/solvers/GUROBI_RUN.py index 88f953e18ae..2cba4c852be 100644 --- a/pyomo/solvers/plugins/solvers/GUROBI_RUN.py +++ b/pyomo/solvers/plugins/solvers/GUROBI_RUN.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -13,9 +13,9 @@ import re -""" -This script is run using the Gurobi/system python. Do not assume any third party packages -are available! +"""This script is run using the Gurobi/system python. Do not assume any +third party packages are available! + """ from gurobipy import gurobi, read, GRB import sys @@ -40,7 +40,7 @@ def _is_numeric(x): return True -def gurobi_run(model_file, warmstart_file, soln_file, mipgap, options, suffixes): +def gurobi_run(model_file, warmstart_file, mipgap, options, suffixes): # figure out what suffixes we need to extract. extract_duals = False extract_slacks = False @@ -77,7 +77,7 @@ def gurobi_run(model_file, warmstart_file, soln_file, mipgap, options, suffixes) if model is None: print( - "***The GUROBI solver plugin failed to load the input LP file=" + soln_file + "***The GUROBI solver plugin failed to load the input LP file=" + model_file ) return @@ -107,6 +107,7 @@ def gurobi_run(model_file, warmstart_file, soln_file, mipgap, options, suffixes) # because the latter does not preserve the # Gurobi stack trace if not _is_numeric(value): + model.close() raise model.setParam(key, float(value)) @@ -239,13 +240,9 @@ def gurobi_run(model_file, warmstart_file, soln_file, mipgap, options, suffixes) # minimize obj_value = float('inf') - # write the solution file - solnfile = open(soln_file, "w+") - - # write the information required by results.problem - solnfile.write("section:problem\n") - name = model.getAttr(GRB.Attr.ModelName) - solnfile.write("name: " + name + '\n') + result = {} + problem = result['problem'] = {} + problem['name'] = model.getAttr(GRB.Attr.ModelName) # TODO: find out about bounds and fix this with error checking # this line fails for some reason so set the value to unknown @@ -258,97 +255,103 @@ def gurobi_run(model_file, warmstart_file, soln_file, mipgap, options, suffixes) bound = None if sense < 0: - solnfile.write("sense:maximize\n") + problem["sense"] = "maximize" if bound is None: - solnfile.write("upper_bound: %f\n" % float('inf')) - else: - solnfile.write("upper_bound: %s\n" % str(bound)) + bound = float('inf') + problem["upper_bound"] = bound else: - solnfile.write("sense:minimize\n") + problem["sense"] = "minimize" if bound is None: - solnfile.write("lower_bound: %f\n" % float('-inf')) - else: - solnfile.write("lower_bound: %s\n" % str(bound)) + bound = float('-inf') + problem["lower_bound"] = bound # TODO: Get the number of objective functions from GUROBI n_objs = 1 - solnfile.write("number_of_objectives: %d\n" % n_objs) + problem["number_of_objectives"] = n_objs cons = model.getConstrs() qcons = [] if GUROBI_VERSION[0] >= 5: qcons = model.getQConstrs() - solnfile.write( - "number_of_constraints: %d\n" % (len(cons) + len(qcons) + model.NumSOS,) - ) + problem["number_of_constraints"] = len(cons) + len(qcons) + model.NumSOS vars = model.getVars() - solnfile.write("number_of_variables: %d\n" % len(vars)) + problem["number_of_variables"] = len(vars) n_binvars = model.getAttr(GRB.Attr.NumBinVars) - solnfile.write("number_of_binary_variables: %d\n" % n_binvars) + problem["number_of_binary_variables"] = n_binvars n_intvars = model.getAttr(GRB.Attr.NumIntVars) - solnfile.write("number_of_integer_variables: %d\n" % n_intvars) - - solnfile.write("number_of_continuous_variables: %d\n" % (len(vars) - n_intvars,)) - - solnfile.write("number_of_nonzeros: %d\n" % model.getAttr(GRB.Attr.NumNZs)) + problem["number_of_integer_variables"] = n_intvars + problem["number_of_continuous_variables"] = len(vars) - n_intvars + problem["number_of_nonzeros"] = model.getAttr(GRB.Attr.NumNZs) # write out the information required by results.solver - solnfile.write("section:solver\n") + solver = result['solver'] = {} - solnfile.write('status: %s\n' % status) - solnfile.write('return_code: %s\n' % return_code) - solnfile.write('message: %s\n' % message) - solnfile.write('wall_time: %s\n' % str(wall_time)) - solnfile.write('termination_condition: %s\n' % term_cond) - solnfile.write('termination_message: %s\n' % message) + solver['status'] = status + solver['return_code'] = return_code + solver['message'] = message + solver['wall_time'] = wall_time + solver['termination_condition'] = term_cond + solver['termination_message'] = message is_discrete = False if model.getAttr(GRB.Attr.IsMIP): is_discrete = True if (term_cond == 'optimal') or (model.getAttr(GRB.Attr.SolCount) >= 1): - solnfile.write('section:solution\n') - solnfile.write('status: %s\n' % (solution_status)) - solnfile.write('message: %s\n' % message) - solnfile.write('objective: %s\n' % str(obj_value)) - solnfile.write('gap: 0.0\n') + solution = result['solution'] = {} + solution['status'] = solution_status + solution['message'] = message + solution['objective'] = obj_value + solution['gap'] = 0.0 vals = model.getAttr("X", vars) names = model.getAttr("VarName", vars) - for val, name in zip(vals, names): - solnfile.write('var: %s : %s\n' % (str(name), str(val))) + solution['var'] = {name: val for name, val in zip(names, vals)} - if (is_discrete is False) and (extract_reduced_costs is True): + if extract_reduced_costs and not is_discrete: vals = model.getAttr("Rc", vars) - for val, name in zip(vals, names): - solnfile.write('varrc: %s : %s\n' % (str(name), str(val))) + solution['varrc'] = {name: val for name, val in zip(names, vals)} if extract_duals or extract_slacks: con_names = model.getAttr("ConstrName", cons) if GUROBI_VERSION[0] >= 5: qcon_names = model.getAttr("QCName", qcons) - if (is_discrete is False) and (extract_duals is True): + if extract_duals and not is_discrete: + # Pi attributes in Gurobi are the constraint duals vals = model.getAttr("Pi", cons) - for val, name in zip(vals, con_names): - # Pi attributes in Gurobi are the constraint duals - solnfile.write("constraintdual: %s : %s\n" % (str(name), str(val))) + solution['constraintdual'] = { + name: val for name, val in zip(con_names, vals) + } if GUROBI_VERSION[0] >= 5: + # QCPI attributes in Gurobi are the constraint duals vals = model.getAttr("QCPi", qcons) - for val, name in zip(vals, qcon_names): - # QCPI attributes in Gurobi are the constraint duals - solnfile.write("constraintdual: %s : %s\n" % (str(name), str(val))) + solution['constraintdual'].update(zip(qcon_names, vals)) - if extract_slacks is True: + if extract_slacks: vals = model.getAttr("Slack", cons) - for val, name in zip(vals, con_names): - solnfile.write("constraintslack: %s : %s\n" % (str(name), str(val))) + solution['constraintslack'] = { + name: val for name, val in zip(con_names, vals) + } if GUROBI_VERSION[0] >= 5: vals = model.getAttr("QCSlack", qcons) - for val, name in zip(vals, qcon_names): - solnfile.write("constraintslack: %s : %s\n" % (str(name), str(val))) - - solnfile.close() + solution['constraintslack'].update(zip(qcon_names, vals)) + + model.close() + model = None + return result + + +def write_result(result, soln_file): + with open(soln_file, "w+") as FILE: + for section, data in result.items(): + FILE.write(f'section:{section}\n') + for key, val in data.items(): + if val.__class__ is dict: + for name, v in val.items(): + FILE.write(f'{key}:{name}:{v}\n') + else: + FILE.write(f'{key}:{val}\n') diff --git a/pyomo/solvers/plugins/solvers/IPOPT.py b/pyomo/solvers/plugins/solvers/IPOPT.py index deda4314a52..28295502b12 100644 --- a/pyomo/solvers/plugins/solvers/IPOPT.py +++ b/pyomo/solvers/plugins/solvers/IPOPT.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,6 +14,8 @@ from pyomo.common import Executable from pyomo.common.collections import Bunch +from pyomo.common.errors import ApplicationError +from pyomo.common.tee import capture_output from pyomo.common.tempfiles import TempfileManager from pyomo.opt.base import ProblemFormat, ResultsFormat @@ -21,6 +23,8 @@ from pyomo.opt.results import SolverStatus, SolverResults, TerminationCondition from pyomo.opt.solver import SystemCallSolver +from pyomo.solvers.amplfunc_merge import amplfunc_merge + import logging logger = logging.getLogger('pyomo.solvers') @@ -79,7 +83,7 @@ def _get_version(self): return _extract_version('') results = subprocess.run( [solver_exec, "-v"], - timeout=1, + timeout=self._version_timeout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, @@ -119,11 +123,9 @@ def create_command_line(self, executable, problem_files): # Pyomo/Pyomo) with any user-specified external function # libraries # - if 'PYOMO_AMPLFUNC' in env: - if 'AMPLFUNC' in env: - env['AMPLFUNC'] += "\n" + env['PYOMO_AMPLFUNC'] - else: - env['AMPLFUNC'] = env['PYOMO_AMPLFUNC'] + amplfunc = amplfunc_merge(env) + if amplfunc: + env['AMPLFUNC'] = amplfunc cmd = [executable, problem_files[0], '-AMPL'] if self._timer: @@ -207,3 +209,16 @@ def process_output(self, rc): res.solver.message = line.split(':')[2].strip() assert "degrees of freedom" in res.solver.message return res + + def has_linear_solver(self, linear_solver): + import pyomo.core as AML + + m = AML.ConcreteModel() + m.x = AML.Var() + m.o = AML.Objective(expr=(m.x - 2) ** 2) + try: + with capture_output() as OUT: + self.solve(m, tee=True, options={'linear_solver': linear_solver}) + except ApplicationError: + return False + return 'running with linear solver' in OUT.getvalue() diff --git a/pyomo/solvers/plugins/solvers/KNITROAMPL.py b/pyomo/solvers/plugins/solvers/KNITROAMPL.py new file mode 100644 index 00000000000..9952cfa4c6d --- /dev/null +++ b/pyomo/solvers/plugins/solvers/KNITROAMPL.py @@ -0,0 +1,89 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +import logging + +from pyomo.common import Executable +from pyomo.common.collections import Bunch +from pyomo.common.dependencies import pathlib +from pyomo.opt.base.formats import ProblemFormat, ResultsFormat +from pyomo.opt.base.solvers import SolverFactory, OptSolver +from pyomo.solvers.plugins.solvers.ASL import ASL + +logger = logging.getLogger('pyomo.solvers') + + +@SolverFactory.register( + 'knitroampl', doc='The Knitro solver for NLP/MINLP and their subclasses' +) +class KNITROAMPL(ASL): + """An interface to the Knitro optimizer that uses the AMPL Solver Library.""" + + def __init__(self, **kwds): + """Constructor""" + executable = kwds.pop('executable', None) + validate = kwds.pop('validate', True) + kwds["type"] = "knitroampl" + kwds["options"] = {'solver': "knitroampl"} + OptSolver.__init__(self, **kwds) + self._keepfiles = False + self._results_file = None + self._timer = '' + self._user_executable = None + # broadly useful for reporting, and in cases where + # a solver plugin may not report execution time. + self._last_solve_time = None + self._define_signal_handlers = None + self._version_timeout = 2 + + if executable == 'knitroampl': + self.set_executable(name=None, validate=validate) + elif executable is not None: + self.set_executable(name=executable, validate=validate) + # + # Setup valid problem formats, and valid results for each problem format. + # Also set the default problem and results formats. + # + self._valid_problem_formats = [ProblemFormat.nl] + self._valid_result_formats = {ProblemFormat.nl: [ResultsFormat.sol]} + self.set_problem_format(ProblemFormat.nl) + # + # Note: Undefined capabilities default to 'None' + # + self._capabilities = Bunch() + self._capabilities.linear = True + self._capabilities.integer = True + self._capabilities.quadratic_objective = True + self._capabilities.quadratic_constraint = True + self._capabilities.sos1 = False + self._capabilities.sos2 = False + + def _default_executable(self): + try: + # If knitro Python package is available, use the executable it contains + import knitro + + package_knitroampl_path = ( + pathlib.Path(knitro.__file__).resolve().parent + / 'knitroampl' + / 'knitroampl' + ) + executable = Executable(str(package_knitroampl_path)) + except ModuleNotFoundError: + # Otherwise, search usual path list + executable = Executable('knitroampl') + if not executable: + logger.warning( + "Could not locate the 'knitroampl' executable, " + "which is required for solver %s" % self.name + ) + self.enable = False + return None + return executable.path() diff --git a/pyomo/solvers/plugins/solvers/SAS.py b/pyomo/solvers/plugins/solvers/SAS.py new file mode 100644 index 00000000000..9c8e85adb36 --- /dev/null +++ b/pyomo/solvers/plugins/solvers/SAS.py @@ -0,0 +1,815 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging +import sys +from os import stat +from abc import ABC, abstractmethod +from io import StringIO + +from pyomo.opt.base import ProblemFormat, ResultsFormat, OptSolver +from pyomo.opt.base.solvers import SolverFactory +from pyomo.common.collections import Bunch +from pyomo.common.dependencies import attempt_import +from pyomo.opt.results import ( + SolverResults, + SolverStatus, + TerminationCondition, + SolutionStatus, + ProblemSense, +) +from pyomo.common.tempfiles import TempfileManager +from pyomo.core.base import Var +from pyomo.core.base.block import BlockData +from pyomo.core.kernel.block import IBlock +from pyomo.common.log import LogStream +from pyomo.common.tee import capture_output, TeeStream + + +uuid, uuid_available = attempt_import('uuid') +logger = logging.getLogger("pyomo.solvers") + + +STATUS_TO_SOLVERSTATUS = { + "OK": SolverStatus.ok, + "SYNTAX_ERROR": SolverStatus.error, + "DATA_ERROR": SolverStatus.error, + "OUT_OF_MEMORY": SolverStatus.aborted, + "IO_ERROR": SolverStatus.error, + "ERROR": SolverStatus.error, +} + +# This combines all status codes from OPTLP/solvelp and OPTMILP/solvemilp +SOLSTATUS_TO_TERMINATIONCOND = { + "OPTIMAL": TerminationCondition.optimal, + "OPTIMAL_AGAP": TerminationCondition.optimal, + "OPTIMAL_RGAP": TerminationCondition.optimal, + "OPTIMAL_COND": TerminationCondition.optimal, + "TARGET": TerminationCondition.optimal, + "CONDITIONAL_OPTIMAL": TerminationCondition.optimal, + "FEASIBLE": TerminationCondition.feasible, + "INFEASIBLE": TerminationCondition.infeasible, + "UNBOUNDED": TerminationCondition.unbounded, + "INFEASIBLE_OR_UNBOUNDED": TerminationCondition.infeasibleOrUnbounded, + "SOLUTION_LIM": TerminationCondition.maxEvaluations, + "NODE_LIM_SOL": TerminationCondition.maxEvaluations, + "NODE_LIM_NOSOL": TerminationCondition.maxEvaluations, + "ITERATION_LIMIT_REACHED": TerminationCondition.maxIterations, + "TIME_LIM_SOL": TerminationCondition.maxTimeLimit, + "TIME_LIM_NOSOL": TerminationCondition.maxTimeLimit, + "TIME_LIMIT_REACHED": TerminationCondition.maxTimeLimit, + "ABORTED": TerminationCondition.userInterrupt, + "ABORT_SOL": TerminationCondition.userInterrupt, + "ABORT_NOSOL": TerminationCondition.userInterrupt, + "OUTMEM_SOL": TerminationCondition.solverFailure, + "OUTMEM_NOSOL": TerminationCondition.solverFailure, + "FAILED": TerminationCondition.solverFailure, + "FAIL_SOL": TerminationCondition.solverFailure, + "FAIL_NOSOL": TerminationCondition.solverFailure, +} + + +SOLSTATUS_TO_MESSAGE = { + "OPTIMAL": "The solution is optimal.", + "OPTIMAL_AGAP": "The solution is optimal within the absolute gap specified by the ABSOBJGAP= option.", + "OPTIMAL_RGAP": "The solution is optimal within the relative gap specified by the RELOBJGAP= option.", + "OPTIMAL_COND": "The solution is optimal, but some infeasibilities (primal, bound, or integer) exceed tolerances due to scaling or choice of a small INTTOL= value.", + "TARGET": "The solution is not worse than the target specified by the TARGET= option.", + "CONDITIONAL_OPTIMAL": "The solution is optimal, but some infeasibilities (primal, dual or bound) exceed tolerances due to scaling or preprocessing.", + "FEASIBLE": "The problem is feasible. This status is displayed when the IIS=TRUE option is specified and the problem is feasible.", + "INFEASIBLE": "The problem is infeasible.", + "UNBOUNDED": "The problem is unbounded.", + "INFEASIBLE_OR_UNBOUNDED": "The problem is infeasible or unbounded.", + "SOLUTION_LIM": "The solver reached the maximum number of solutions specified by the MAXSOLS= option.", + "NODE_LIM_SOL": "The solver reached the maximum number of nodes specified by the MAXNODES= option and found a solution.", + "NODE_LIM_NOSOL": "The solver reached the maximum number of nodes specified by the MAXNODES= option and did not find a solution.", + "ITERATION_LIMIT_REACHED": "The maximum allowable number of iterations was reached.", + "TIME_LIM_SOL": "The solver reached the execution time limit specified by the MAXTIME= option and found a solution.", + "TIME_LIM_NOSOL": "The solver reached the execution time limit specified by the MAXTIME= option and did not find a solution.", + "TIME_LIMIT_REACHED": "The solver reached its execution time limit.", + "ABORTED": "The solver was interrupted externally.", + "ABORT_SOL": "The solver was stopped by the user but still found a solution.", + "ABORT_NOSOL": "The solver was stopped by the user and did not find a solution.", + "OUTMEM_SOL": "The solver ran out of memory but still found a solution.", + "OUTMEM_NOSOL": "The solver ran out of memory and either did not find a solution or failed to output the solution due to insufficient memory.", + "FAILED": "The solver failed to converge, possibly due to numerical issues.", + "FAIL_SOL": "The solver stopped due to errors but still found a solution.", + "FAIL_NOSOL": "The solver stopped due to errors and did not find a solution.", +} + + +@SolverFactory.register("sas", doc="The SAS LP/MIP solver") +class SAS(OptSolver): + """The SAS optimization solver""" + + def __new__(cls, *args, **kwds): + mode = kwds.pop("solver_io", None) + if mode != None: + return SolverFactory(mode, **kwds) + else: + # Choose solver factory automatically + # based on what can be loaded. + s = SolverFactory("_sas94", **kwds) + if not s.available(): + s = SolverFactory("_sascas", **kwds) + return s + + +class SASAbc(ABC, OptSolver): + """Abstract base class for the SAS solver interfaces. Simply to avoid code duplication.""" + + def __init__(self, **kwds): + """Initialize the SAS solver interfaces.""" + kwds["type"] = "sas" + super(SASAbc, self).__init__(**kwds) + + # + # Set up valid problem formats and valid results for each + # problem format + # + self._valid_problem_formats = [ProblemFormat.mps] + self._valid_result_formats = {ProblemFormat.mps: [ResultsFormat.soln]} + + self._keepfiles = False + self._capabilities.linear = True + self._capabilities.integer = True + + super(SASAbc, self).set_problem_format(ProblemFormat.mps) + + def _presolve(self, *args, **kwds): + """Set things up for the actual solve.""" + # create a context in the temporary file manager for + # this plugin - is "pop"ed in the _postsolve method. + TempfileManager.push() + + # Get the warmstart flag + self.warmstart_flag = kwds.pop("warmstart", False) + + # Call parent presolve function + super(SASAbc, self)._presolve(*args, **kwds) + + # Store the model, too bad this is not done in the base class + for arg in args: + if isinstance(arg, (BlockData, IBlock)): + # Store the instance + self._instance = arg + self._vars = [] + for block in self._instance.block_data_objects(active=True): + for vardata in block.component_data_objects( + Var, active=True, descend_into=False + ): + self._vars.append(vardata) + # Store the symbol map, we need this for example when writing the warmstart file + if isinstance(self._instance, IBlock): + self._smap = getattr(self._instance, "._symbol_maps")[self._smap_id] + else: + self._smap = self._instance.solutions.symbol_map[self._smap_id] + + # Create the primalin data + if self.warmstart_flag: + filename = self._warm_start_file_name = TempfileManager.create_tempfile( + ".sol", text=True + ) + smap = self._smap + numWritten = 0 + with open(filename, "w") as file: + file.write("_VAR_,_VALUE_\n") + for var in self._vars: + if (var.value is not None) and (id(var) in smap.byObject): + name = smap.byObject[id(var)] + file.write( + "{name},{value}\n".format(name=name, value=var.value) + ) + numWritten += 1 + if numWritten == 0: + # No solution available, disable warmstart + self.warmstart_flag = False + + def available(self, exception_flag=False): + """True if the solver is available""" + if not self._python_api_exists: + return False + return self.start_sas_session() is not None + + def _has_integer_variables(self): + """True if the problem has integer variables.""" + for vardata in self._vars: + if vardata.is_binary() or vardata.is_integer(): + return True + return False + + def _create_results_from_status(self, status, solution_status): + """Create a results object and set the status code and messages.""" + results = SolverResults() + results.solver.name = "SAS" + results.solver.status = STATUS_TO_SOLVERSTATUS[status] + results.solver.hasSolution = False + if results.solver.status == SolverStatus.ok: + results.solver.termination_condition = SOLSTATUS_TO_TERMINATIONCOND[ + solution_status + ] + results.solver.message = results.solver.termination_message = ( + SOLSTATUS_TO_MESSAGE[solution_status] + ) + results.solver.status = TerminationCondition.to_solver_status( + results.solver.termination_condition + ) + if "OPTIMAL" in solution_status or "_SOL" in solution_status: + results.solver.hasSolution = True + elif results.solver.status == SolverStatus.aborted: + results.solver.termination_condition = TerminationCondition.userInterrupt + if solution_status != "ERROR": + results.solver.message = results.solver.termination_message = ( + SOLSTATUS_TO_MESSAGE[solution_status] + ) + else: + results.solver.termination_condition = TerminationCondition.error + results.solver.message = results.solver.termination_message = ( + SOLSTATUS_TO_MESSAGE["FAILED"] + ) + return results + + @abstractmethod + def _apply_solver(self): + pass + + def _postsolve(self): + """Clean up at the end, especially the temp files.""" + # Let the base class deal with returning results. + results = super(SASAbc, self)._postsolve() + + # Finally, clean any temporary files registered with the temp file + # manager, created populated *directly* by this plugin. does not + # include, for example, the execution script. but does include + # the warm-start file. + TempfileManager.pop(remove=not self._keepfiles) + + return results + + def warm_start_capable(self): + """True if the solver interface supports MILP warmstarting.""" + return True + + +@SolverFactory.register("_sas94", doc="SAS 9.4 interface") +class SAS94(SASAbc): + """ + Solver interface for SAS 9.4 using saspy. See the saspy documentation about + how to create a connection. + The swat connection options can be specified on the SolverFactory call. + """ + + def __init__(self, **kwds): + """Initialize the solver interface and see if the saspy package is available.""" + super(SAS94, self).__init__(**kwds) + + try: + import saspy + + self._sas = saspy + except ImportError: + self._python_api_exists = False + except Exception as e: + self._python_api_exists = False + # For other exceptions, raise it so that it does not get lost + raise e + else: + self._python_api_exists = True + self._sas.logger.setLevel(logger.level) + + # Store other options for the SAS session + self._session_options = kwds + self._sas_session = None + + def __del__(self): + # Close the session, if we created one + if self._sas_session: + self._sas_session.endsas() + del self._sas_session + + def _create_statement_str(self, statement): + """Helper function to create the strings for the statements of the proc OPTLP/OPTMILP code.""" + stmt = self.options.pop(statement, None) + if stmt: + return ( + statement.strip() + + " " + + " ".join(option + "=" + str(value) for option, value in stmt.items()) + + ";" + ) + else: + return "" + + def sas_version(self): + return self._sasver + + def start_sas_session(self): + if self._sas_session is None: + # Create (and cache) the session + try: + self._sas_session = self._sas.SASsession(**self._session_options) + except: + pass + return self._sas_session + + def _apply_solver(self): + """ "Prepare the options and run the solver. Then store the data to be returned.""" + logger.debug("Running SAS") + + # Set return code to issue an error if we get interrupted + self._rc = -1 + + # Figure out if the problem has integer variables + with_opt = self.options.pop("with", None) + if with_opt == "lp": + proc = "OPTLP" + elif with_opt == "milp": + proc = "OPTMILP" + else: + # Check if there are integer variables, this might be slow + proc = "OPTMILP" if self._has_integer_variables() else "OPTLP" + + # Get the rootnode options + decomp_str = self._create_statement_str("decomp") + decompmaster_str = self._create_statement_str("decompmaster") + decompmasterip_str = self._create_statement_str("decompmasterip") + decompsubprob_str = self._create_statement_str("decompsubprob") + rootnode_str = self._create_statement_str("rootnode") + + # Get a unique identifier, always use the same with different prefixes + unique = uuid.uuid4().hex[:16] + + # Create unique filename for output datasets + primalout_dataset_name = "pout" + unique + dualout_dataset_name = "dout" + unique + primalin_dataset_name = None + + # Handle warmstart + warmstart_str = "" + if self.warmstart_flag: + # Set the warmstart basis option + primalin_dataset_name = "pin" + unique + if proc != "OPTLP": + warmstart_str = """ + proc import datafile='{primalin}' + out={primalin_dataset_name} + dbms=csv + replace; + getnames=yes; + run; + """.format( + primalin=self._warm_start_file_name, + primalin_dataset_name=primalin_dataset_name, + ) + self.options["primalin"] = primalin_dataset_name + + # Convert options to string + opt_str = " ".join( + option + "=" + str(value) for option, value in self.options.items() + ) + + # Set some SAS options to make the log more clean + sas_options = "option notes nonumber nodate nosource pagesize=max;" + + # Get the current SAS session, submit the code and return the results + sas = self.start_sas_session() + + # Find the version of 9.4 we are using + self._sasver = sas.sasver + + # Upload files, only if not accessible locally + upload_mps = False + if not sas.file_info(self._problem_files[0], quiet=True): + sas.upload(self._problem_files[0], self._problem_files[0], overwrite=True) + upload_mps = True + + upload_pin = False + if self.warmstart_flag and not sas.file_info( + self._warm_start_file_name, quiet=True + ): + sas.upload( + self._warm_start_file_name, self._warm_start_file_name, overwrite=True + ) + upload_pin = True + + # Using a function call to make it easier to mock the version check + major_version = self.sas_version()[0] + minor_version = self.sas_version().split("M", 1)[1][0] + if major_version == "9" and int(minor_version) < 5: + raise NotImplementedError( + "Support for SAS 9.4 M4 and earlier is not implemented." + ) + elif major_version == "9" and int(minor_version) == 5: + # In 9.4M5 we have to create an MPS data set from an MPS file first + # Earlier versions will not work because the MPS format in incompatible + mps_dataset_name = "mps" + unique + res = sas.submit( + """ + {sas_options} + {warmstart} + %MPS2SASD(MPSFILE="{mpsfile}", OUTDATA={mps_dataset_name}, MAXLEN=256, FORMAT=FREE); + proc {proc} data={mps_dataset_name} {options} primalout={primalout_dataset_name} dualout={dualout_dataset_name}; + {decomp} + {decompmaster} + {decompmasterip} + {decompsubprob} + {rootnode} + run; + """.format( + sas_options=sas_options, + warmstart=warmstart_str, + proc=proc, + mpsfile=self._problem_files[0], + mps_dataset_name=mps_dataset_name, + options=opt_str, + primalout_dataset_name=primalout_dataset_name, + dualout_dataset_name=dualout_dataset_name, + decomp=decomp_str, + decompmaster=decompmaster_str, + decompmasterip=decompmasterip_str, + decompsubprob=decompsubprob_str, + rootnode=rootnode_str, + ), + results="TEXT", + ) + sas.sasdata(mps_dataset_name).delete(quiet=True) + else: + # Since 9.4M6+ optlp/optmilp can read mps files directly (this includes Viya-based local installs) + res = sas.submit( + """ + {sas_options} + {warmstart} + proc {proc} mpsfile=\"{mpsfile}\" {options} primalout={primalout_dataset_name} dualout={dualout_dataset_name}; + {decomp} + {decompmaster} + {decompmasterip} + {decompsubprob} + {rootnode} + run; + """.format( + sas_options=sas_options, + warmstart=warmstart_str, + proc=proc, + mpsfile=self._problem_files[0], + options=opt_str, + primalout_dataset_name=primalout_dataset_name, + dualout_dataset_name=dualout_dataset_name, + decomp=decomp_str, + decompmaster=decompmaster_str, + decompmasterip=decompmasterip_str, + decompsubprob=decompsubprob_str, + rootnode=rootnode_str, + ), + results="TEXT", + ) + + # Delete uploaded file + if upload_mps: + sas.file_delete(self._problem_files[0], quiet=True) + if self.warmstart_flag and upload_pin: + sas.file_delete(self._warm_start_file_name, quiet=True) + + # Store log and ODS output + self._log = res["LOG"] + self._lst = res["LST"] + if "ERROR 22-322: Syntax error" in self._log: + raise ValueError( + "An option passed to the SAS solver caused a syntax error: {log}".format( + log=self._log + ) + ) + else: + # Print log if requested by the user, only if we did not already print it + if self._tee: + print(self._log) + self._macro = dict( + (key.strip(), value.strip()) + for key, value in ( + pair.split("=") for pair in sas.symget("_OR" + proc + "_").split() + ) + ) + if self._macro.get("STATUS", "ERROR") == "OK": + primal_out = sas.sd2df(primalout_dataset_name) + dual_out = sas.sd2df(dualout_dataset_name) + + # Delete data sets, they will go away automatically, but does not hurt to delete them + if primalin_dataset_name: + sas.sasdata(primalin_dataset_name).delete(quiet=True) + sas.sasdata(primalout_dataset_name).delete(quiet=True) + sas.sasdata(dualout_dataset_name).delete(quiet=True) + + # Prepare the solver results + results = self.results = self._create_results_from_status( + self._macro.get("STATUS", "ERROR"), + self._macro.get("SOLUTION_STATUS", "ERROR"), + ) + + if "Objective Sense Maximization" in self._lst: + results.problem.sense = ProblemSense.maximize + else: + results.problem.sense = ProblemSense.minimize + + # Prepare the solution information + if results.solver.hasSolution: + sol = results.solution.add() + + # Store status in solution + sol.status = SolutionStatus.feasible + sol.termination_condition = SOLSTATUS_TO_TERMINATIONCOND[ + self._macro.get("SOLUTION_STATUS", "ERROR") + ] + + # Store objective value in solution + sol.objective["__default_objective__"] = {"Value": self._macro["OBJECTIVE"]} + + if proc == "OPTLP": + # Convert primal out data set to variable dictionary + # Use pandas functions for efficiency + primal_out = primal_out[["_VAR_", "_VALUE_", "_STATUS_", "_R_COST_"]] + primal_out = primal_out.set_index("_VAR_", drop=True) + primal_out = primal_out.rename( + {"_VALUE_": "Value", "_STATUS_": "Status", "_R_COST_": "rc"}, + axis="columns", + ) + sol.variable = primal_out.to_dict("index") + + # Convert dual out data set to constraint dictionary + # Use pandas functions for efficiency + dual_out = dual_out[["_ROW_", "_VALUE_", "_STATUS_", "_ACTIVITY_"]] + dual_out = dual_out.set_index("_ROW_", drop=True) + dual_out = dual_out.rename( + {"_VALUE_": "dual", "_STATUS_": "Status", "_ACTIVITY_": "slack"}, + axis="columns", + ) + sol.constraint = dual_out.to_dict("index") + else: + # Convert primal out data set to variable dictionary + # Use pandas functions for efficiency + primal_out = primal_out[["_VAR_", "_VALUE_"]] + primal_out = primal_out.set_index("_VAR_", drop=True) + primal_out = primal_out.rename({"_VALUE_": "Value"}, axis="columns") + sol.variable = primal_out.to_dict("index") + + self._rc = 0 + return Bunch(rc=self._rc, log=self._log) + + +@SolverFactory.register("_sascas", doc="SAS Viya CAS Server interface") +class SASCAS(SASAbc): + """ + Solver interface connection to a SAS Viya CAS server using swat. + See the documentation for the swat package about how to create a connection. + The swat connection options can be specified on the SolverFactory call. + """ + + def __init__(self, **kwds): + """Initialize and try to load the swat package.""" + super(SASCAS, self).__init__(**kwds) + + try: + import swat + + self._sas = swat + except ImportError: + self._python_api_exists = False + except Exception as e: + self._python_api_exists = False + # For other exceptions, raise it so that it does not get lost + raise e + else: + self._python_api_exists = True + + self._session_options = kwds + self._sas_session = None + + def __del__(self): + # Close the session, if we created one + if self._sas_session: + self._sas_session.close() + del self._sas_session + + def start_sas_session(self): + if self._sas_session is None: + # Create (and cache) the session + try: + self._sas_session = self._sas.CAS(**self._session_options) + except: + pass + return self._sas_session + + def _uploadMpsFile(self, s, unique): + # Declare a unique table name for the mps table + mpsdata_table_name = "mps" + unique + + # Upload mps file to CAS, if the file is larger than 2 GB, we need to use convertMps instead of loadMps + # Note that technically it is 2 Gibibytes file size that trigger the issue, but 2 GB is the safer threshold + if stat(self._problem_files[0]).st_size > 2e9: + # For files larger than 2 GB (this is a limitation of the loadMps action used in the else part). + # Use convertMPS, first create file for upload. + mpsWithIdFileName = TempfileManager.create_tempfile(".mps.csv", text=True) + with open(mpsWithIdFileName, "w") as mpsWithId: + mpsWithId.write("_ID_\tText\n") + with open(self._problem_files[0], "r") as f: + id = 0 + for line in f: + id += 1 + mpsWithId.write(str(id) + "\t" + line.rstrip() + "\n") + + # Upload .mps.csv file + mpscsv_table_name = "csv" + unique + s.upload_file( + mpsWithIdFileName, + casout={"name": mpscsv_table_name, "replace": True}, + importoptions={"filetype": "CSV", "delimiter": "\t"}, + ) + + # Convert .mps.csv file to .mps + s.optimization.convertMps( + data=mpscsv_table_name, + casOut={"name": mpsdata_table_name, "replace": True}, + format="FREE", + maxLength=256, + ) + + # Delete the table we don't need anymore + if mpscsv_table_name: + s.dropTable(name=mpscsv_table_name, quiet=True) + else: + # For small files (less than 2 GB), use loadMps + with open(self._problem_files[0], "r") as mps_file: + s.optimization.loadMps( + mpsFileString=mps_file.read(), + casout={"name": mpsdata_table_name, "replace": True}, + format="FREE", + maxLength=256, + ) + return mpsdata_table_name + + def _uploadPrimalin(self, s, unique): + # Upload warmstart file to CAS with a unique name + primalin_table_name = "pin" + unique + s.upload_file( + self._warm_start_file_name, + casout={"name": primalin_table_name, "replace": True}, + importoptions={"filetype": "CSV"}, + ) + self.options["primalin"] = primalin_table_name + return primalin_table_name + + def _retrieveSolution( + self, s, r, results, action, primalout_table_name, dualout_table_name + ): + # Create solution + sol = results.solution.add() + + # Store status in solution + sol.status = SolutionStatus.feasible + sol.termination_condition = SOLSTATUS_TO_TERMINATIONCOND[ + r.get("solutionStatus", "ERROR") + ] + + # Store objective value in solution + sol.objective["__default_objective__"] = {"Value": r["objective"]} + + if action == "solveMilp": + primal_out = s.CASTable(name=primalout_table_name) + # Use pandas functions for efficiency + primal_out = primal_out[["_VAR_", "_VALUE_"]] + sol.variable = {} + for row in primal_out.itertuples(index=False): + sol.variable[row[0]] = {"Value": row[1]} + else: + # Convert primal out data set to variable dictionary + # Use panda functions for efficiency + primal_out = s.CASTable(name=primalout_table_name) + primal_out = primal_out[["_VAR_", "_VALUE_", "_STATUS_", "_R_COST_"]] + sol.variable = {} + for row in primal_out.itertuples(index=False): + sol.variable[row[0]] = {"Value": row[1], "Status": row[2], "rc": row[3]} + + # Convert dual out data set to constraint dictionary + # Use pandas functions for efficiency + dual_out = s.CASTable(name=dualout_table_name) + dual_out = dual_out[["_ROW_", "_VALUE_", "_STATUS_", "_ACTIVITY_"]] + sol.constraint = {} + for row in dual_out.itertuples(index=False): + sol.constraint[row[0]] = { + "dual": row[1], + "Status": row[2], + "slack": row[3], + } + + def _apply_solver(self): + """ "Prepare the options and run the solver. Then store the data to be returned.""" + logger.debug("Running SAS Viya") + + # Set return code to issue an error if we get interrupted + self._rc = -1 + + # Figure out if the problem has integer variables + with_opt = self.options.pop("with", None) + if with_opt == "lp": + action = "solveLp" + elif with_opt == "milp": + action = "solveMilp" + else: + # Check if there are integer variables, this might be slow + action = "solveMilp" if self._has_integer_variables() else "solveLp" + + # Get a unique identifier, always use the same with different prefixes + unique = uuid.uuid4().hex[:16] + + # Creat the output stream, we want to print to a log string as well as to the console + self._log = StringIO() + ostreams = [LogStream(level=logging.INFO, logger=logger)] + ostreams.append(self._log) + if self._tee: + ostreams.append(sys.stdout) + + # Connect to CAS server + with capture_output(output=TeeStream(*ostreams), capture_fd=False): + s = self.start_sas_session() + try: + # Load the optimization action set + s.loadactionset("optimization") + + mpsdata_table_name = self._uploadMpsFile(s, unique) + + primalin_table_name = None + if self.warmstart_flag: + primalin_table_name = self._uploadPrimalin(s, unique) + + # Define output table names + primalout_table_name = "pout" + unique + dualout_table_name = None + + # Solve the problem in CAS + if action == "solveMilp": + r = s.optimization.solveMilp( + data={"name": mpsdata_table_name}, + primalOut={"name": primalout_table_name, "replace": True}, + **self.options + ) + else: + dualout_table_name = "dout" + unique + r = s.optimization.solveLp( + data={"name": mpsdata_table_name}, + primalOut={"name": primalout_table_name, "replace": True}, + dualOut={"name": dualout_table_name, "replace": True}, + **self.options + ) + + # Prepare the solver results + if r: + # Get back the primal and dual solution data sets + results = self.results = self._create_results_from_status( + r.get("status", "ERROR"), r.get("solutionStatus", "ERROR") + ) + + if results.solver.status != SolverStatus.error: + if r.ProblemSummary["cValue1"][1] == "Maximization": + results.problem.sense = ProblemSense.maximize + else: + results.problem.sense = ProblemSense.minimize + + # Prepare the solution information + if results.solver.hasSolution: + self._retrieveSolution( + s, + r, + results, + action, + primalout_table_name, + dualout_table_name, + ) + else: + raise ValueError("The SAS solver returned an error status.") + else: + results = self.results = SolverResults() + results.solver.name = "SAS" + results.solver.status = SolverStatus.error + raise ValueError( + "An option passed to the SAS solver caused a syntax error." + ) + + finally: + if mpsdata_table_name: + s.dropTable(name=mpsdata_table_name, quiet=True) + if primalin_table_name: + s.dropTable(name=primalin_table_name, quiet=True) + if primalout_table_name: + s.dropTable(name=primalout_table_name, quiet=True) + if dualout_table_name: + s.dropTable(name=dualout_table_name, quiet=True) + + self._log = self._log.getvalue() + self._rc = 0 + return Bunch(rc=self._rc, log=self._log) diff --git a/pyomo/solvers/plugins/solvers/SCIPAMPL.py b/pyomo/solvers/plugins/solvers/SCIPAMPL.py index be7415a19ef..0db53a7f953 100644 --- a/pyomo/solvers/plugins/solvers/SCIPAMPL.py +++ b/pyomo/solvers/plugins/solvers/SCIPAMPL.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -20,12 +20,7 @@ from pyomo.opt.base import ProblemFormat, ResultsFormat from pyomo.opt.base.solvers import _extract_version, SolverFactory -from pyomo.opt.results import ( - SolverStatus, - TerminationCondition, - SolutionStatus, - ProblemSense, -) +from pyomo.opt.results import SolverStatus, TerminationCondition, SolutionStatus from pyomo.opt.solver import SystemCallSolver import logging @@ -103,7 +98,7 @@ def _get_version(self, solver_exec=None): return _extract_version('') results = subprocess.run( [solver_exec, "--version"], - timeout=1, + timeout=self._version_timeout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, @@ -374,9 +369,11 @@ def _postsolve(self): if len(results.solution) > 0: results.solution(0).status = SolutionStatus.optimal try: - if results.problem.sense == ProblemSense.minimize: + if results.solver.primal_bound < results.solver.dual_bound: results.problem.lower_bound = results.solver.primal_bound + results.problem.upper_bound = results.solver.dual_bound else: + results.problem.lower_bound = results.solver.dual_bound results.problem.upper_bound = results.solver.primal_bound except AttributeError: """ @@ -455,7 +452,7 @@ def read_scip_log(filename: str): solver_status = scip_lines[0][colon_position + 2 : scip_lines[0].index('\n')] solving_time = float( - scip_lines[1][colon_position + 2 : scip_lines[1].index('\n')] + scip_lines[1][colon_position + 2 : scip_lines[1].index('\n')].split(' ')[0] ) try: diff --git a/pyomo/solvers/plugins/solvers/XPRESS.py b/pyomo/solvers/plugins/solvers/XPRESS.py index 2c16d971144..014064ba765 100644 --- a/pyomo/solvers/plugins/solvers/XPRESS.py +++ b/pyomo/solvers/plugins/solvers/XPRESS.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/plugins/solvers/__init__.py b/pyomo/solvers/plugins/solvers/__init__.py index 9b2507d876c..cf10af15186 100644 --- a/pyomo/solvers/plugins/solvers/__init__.py +++ b/pyomo/solvers/plugins/solvers/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,23 +10,27 @@ # ___________________________________________________________________________ # TODO: Disabled until we can confirm application to Pyomo models -import pyomo.solvers.plugins.solvers.CBCplugin -import pyomo.solvers.plugins.solvers.GLPK -import pyomo.solvers.plugins.solvers.CPLEX -import pyomo.solvers.plugins.solvers.GUROBI -import pyomo.solvers.plugins.solvers.BARON -import pyomo.solvers.plugins.solvers.ASL -import pyomo.solvers.plugins.solvers.pywrapper -import pyomo.solvers.plugins.solvers.SCIPAMPL -import pyomo.solvers.plugins.solvers.CONOPT -import pyomo.solvers.plugins.solvers.XPRESS -import pyomo.solvers.plugins.solvers.IPOPT -import pyomo.solvers.plugins.solvers.gurobi_direct -import pyomo.solvers.plugins.solvers.gurobi_persistent -import pyomo.solvers.plugins.solvers.cplex_direct -import pyomo.solvers.plugins.solvers.cplex_persistent -import pyomo.solvers.plugins.solvers.GAMS -import pyomo.solvers.plugins.solvers.mosek_direct -import pyomo.solvers.plugins.solvers.mosek_persistent -import pyomo.solvers.plugins.solvers.xpress_direct -import pyomo.solvers.plugins.solvers.xpress_persistent +from pyomo.solvers.plugins.solvers import ( + CBCplugin, + GLPK, + CPLEX, + GUROBI, + BARON, + ASL, + pywrapper, + SCIPAMPL, + CONOPT, + XPRESS, + IPOPT, + gurobi_direct, + gurobi_persistent, + cplex_direct, + cplex_persistent, + GAMS, + mosek_direct, + mosek_persistent, + xpress_direct, + xpress_persistent, + SAS, + KNITROAMPL, +) diff --git a/pyomo/solvers/plugins/solvers/cplex_direct.py b/pyomo/solvers/plugins/solvers/cplex_direct.py index 93d8015514e..31ad292676c 100644 --- a/pyomo/solvers/plugins/solvers/cplex_direct.py +++ b/pyomo/solvers/plugins/solvers/cplex_direct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -846,7 +846,9 @@ def _postsolve(self): if extract_slacks: linear_slacks = self._solver_model.solution.get_linear_slacks() - qudratic_slacks = self._solver_model.solution.get_quadratic_slacks() + quadratic_slacks = ( + self._solver_model.solution.get_quadratic_slacks() + ) for i, con_name in enumerate( self._solver_model.linear_constraints.get_names() ): @@ -869,7 +871,7 @@ def _postsolve(self): for i, con_name in enumerate( self._solver_model.quadratic_constraints.get_names() ): - soln_constraints[con_name]["Slack"] = qudratic_slacks[i] + soln_constraints[con_name]["Slack"] = quadratic_slacks[i] elif self._load_solutions: if cpxprob.solution.get_solution_type() > 0: self.load_vars() diff --git a/pyomo/solvers/plugins/solvers/cplex_persistent.py b/pyomo/solvers/plugins/solvers/cplex_persistent.py index fd396a8c87f..a02df8222b6 100644 --- a/pyomo/solvers/plugins/solvers/cplex_persistent.py +++ b/pyomo/solvers/plugins/solvers/cplex_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -82,7 +82,7 @@ def update_var(self, var): Parameters ---------- - var: Var (scalar Var or single _VarData) + var: Var (scalar Var or single VarData) """ # see PR #366 for discussion about handling indexed @@ -130,7 +130,7 @@ def _add_column(self, var, obj_coef, constraints, coefficients): Parameters ---------- - var: Var (scalar Var or single _VarData) + var: Var (scalar Var or single VarData) obj_coef: float constraints: list of solver constraints coefficients: list of coefficients to put on var in the associated constraint diff --git a/pyomo/solvers/plugins/solvers/direct_or_persistent_solver.py b/pyomo/solvers/plugins/solvers/direct_or_persistent_solver.py index c131b8ad10a..54fadfa06d7 100644 --- a/pyomo/solvers/plugins/solvers/direct_or_persistent_solver.py +++ b/pyomo/solvers/plugins/solvers/direct_or_persistent_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # ___________________________________________________________________________ from pyomo.core.base.PyomoModel import Model -from pyomo.core.base.block import Block, _BlockData +from pyomo.core.base.block import Block, BlockData from pyomo.core.kernel.block import IBlock from pyomo.opt.base.solvers import OptSolver from pyomo.core.base import SymbolMap, NumericLabeler, TextLabeler @@ -177,7 +177,7 @@ def _postsolve(self): """ This method should be implemented by subclasses.""" def _set_instance(self, model, kwds={}): - if not isinstance(model, (Model, IBlock, Block, _BlockData)): + if not isinstance(model, (Model, IBlock, Block, BlockData)): msg = ( "The problem instance supplied to the {0} plugin " "'_presolve' method must be a Model or a Block".format(type(self)) diff --git a/pyomo/solvers/plugins/solvers/direct_solver.py b/pyomo/solvers/plugins/solvers/direct_solver.py index 3eab658391c..4c380107a9d 100644 --- a/pyomo/solvers/plugins/solvers/direct_solver.py +++ b/pyomo/solvers/plugins/solvers/direct_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,14 +15,14 @@ from pyomo.solvers.plugins.solvers.direct_or_persistent_solver import ( DirectOrPersistentSolver, ) -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.core.kernel.block import IBlock from pyomo.core.base.suffix import active_import_suffix_generator from pyomo.core.kernel.suffix import import_suffix_generator from pyomo.common.errors import ApplicationError from pyomo.common.collections import Bunch -logger = logging.getLogger('pyomo.solvers') +logger = logging.getLogger("pyomo.solvers") class DirectSolver(DirectOrPersistentSolver): @@ -71,6 +71,7 @@ def _presolve(self, *args, **kwds): def solve(self, *args, **kwds): """Solve the problem""" + # Note this does not get called with LegacySolver -- it does its own solve self.available(exception_flag=True) # @@ -79,8 +80,8 @@ def solve(self, *args, **kwds): # _model = None for arg in args: - if isinstance(arg, (_BlockData, IBlock)): - if isinstance(arg, _BlockData): + if isinstance(arg, (BlockData, IBlock)): + if isinstance(arg, BlockData): if not arg.is_constructed(): raise RuntimeError( "Attempting to solve model=%s with unconstructed " @@ -89,7 +90,7 @@ def solve(self, *args, **kwds): _model = arg # import suffixes must be on the top-level model - if isinstance(arg, _BlockData): + if isinstance(arg, BlockData): model_suffixes = list( name for (name, comp) in active_import_suffix_generator(arg) ) @@ -103,7 +104,7 @@ def solve(self, *args, **kwds): ) if len(model_suffixes) > 0: - kwds_suffixes = kwds.setdefault('suffixes', []) + kwds_suffixes = kwds.setdefault("suffixes", []) for name in model_suffixes: if name not in kwds_suffixes: kwds_suffixes.append(name) @@ -119,9 +120,9 @@ def solve(self, *args, **kwds): self.options = Bunch() self.options.update(orig_options) - self.options.update(kwds.pop('options', {})) + self.options.update(kwds.pop("options", {})) self.options.update( - self._options_string_to_dict(kwds.pop('options_string', '')) + self._options_string_to_dict(kwds.pop("options_string", "")) ) try: # we're good to go. @@ -140,9 +141,9 @@ def solve(self, *args, **kwds): self._initialize_callbacks(_model) _status = self._apply_solver() - if hasattr(self, '_transformation_data'): + if hasattr(self, "_transformation_data"): del self._transformation_data - if not hasattr(_status, 'rc'): + if not hasattr(_status, "rc"): logger.warning( "Solver (%s) did not return a solver status code.\n" "This is indicative of an internal solver plugin error.\n" @@ -155,7 +156,7 @@ def solve(self, *args, **kwds): ) if self._tee: logger.error("See the solver log above for diagnostic information.") - elif hasattr(_status, 'log') and _status.log: + elif hasattr(_status, "log") and _status.log: logger.error("Solver log:\n" + str(_status.log)) raise ApplicationError("Solver (%s) did not exit normally" % self.name) solve_completion_time = time.time() diff --git a/pyomo/solvers/plugins/solvers/gurobi_direct.py b/pyomo/solvers/plugins/solvers/gurobi_direct.py index 1d88eced629..6d8a9137aa2 100644 --- a/pyomo/solvers/plugins/solvers/gurobi_direct.py +++ b/pyomo/solvers/plugins/solvers/gurobi_direct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -32,7 +32,6 @@ from pyomo.opt.results.solver import TerminationCondition, SolverStatus from pyomo.opt.base import SolverFactory from pyomo.core.base.suffix import Suffix -import pyomo.core.base.var logger = logging.getLogger('pyomo.solvers') @@ -71,6 +70,7 @@ def _parse_gurobi_version(gurobipy, avail): # exception! catch_exceptions=(Exception,), callback=_parse_gurobi_version, + defer_import=True, ) @@ -308,7 +308,7 @@ def _get_expr_from_pyomo_repn(self, repn, max_degree=2): new_expr += repn.constant - return new_expr, referenced_vars + return new_expr, referenced_vars, degree def _get_expr_from_pyomo_expr(self, expr, max_degree=2): if max_degree == 2: @@ -317,7 +317,7 @@ def _get_expr_from_pyomo_expr(self, expr, max_degree=2): repn = generate_standard_repn(expr, quadratic=False) try: - gurobi_expr, referenced_vars = self._get_expr_from_pyomo_repn( + gurobi_expr, referenced_vars, degree = self._get_expr_from_pyomo_repn( repn, max_degree ) except DegreeError as e: @@ -325,7 +325,7 @@ def _get_expr_from_pyomo_expr(self, expr, max_degree=2): msg += '\nexpr: {0}'.format(expr) raise DegreeError(msg) - return gurobi_expr, referenced_vars + return gurobi_expr, referenced_vars, degree def _gurobi_lb_ub_from_var(self, var): if var.is_fixed(): @@ -404,10 +404,12 @@ def _create_model(self, model): self._init_env() if self._solver_model is not None: self._solver_model.close() - if model.name is not None: - self._solver_model = gurobipy.Model(model.name, env=self._env) - else: - self._solver_model = gurobipy.Model(env=self._env) + + self._solver_model = ( + gurobipy.Model(model.name, env=self._env) + if model.name is not None + else gurobipy.Model(env=self._env) + ) def close(self): """Frees local Gurobi resources used by this solver instance. @@ -489,26 +491,28 @@ def _set_instance(self, model, kwds={}): def _add_block(self, block): DirectOrPersistentSolver._add_block(self, block) + def _addConstr(self, degree, lhs, sense=None, rhs=None, name=""): + if degree == 2: + con = self._solver_model.addQConstr(lhs, sense, rhs, name) + else: + con = self._solver_model.addLConstr(lhs, sense, rhs, name) + return con + def _add_constraint(self, con): if not con.active: return None - if is_fixed(con.body): - if self._skip_trivial_constraints: - return None + if self._skip_trivial_constraints and is_fixed(con.body): + return None conname = self._symbol_map.getSymbol(con, self._labeler) if con._linear_canonical_form: - gurobi_expr, referenced_vars = self._get_expr_from_pyomo_repn( + gurobi_expr, referenced_vars, degree = self._get_expr_from_pyomo_repn( con.canonical_form(), self._max_constraint_degree ) - # elif isinstance(con, LinearCanonicalRepn): - # gurobi_expr, referenced_vars = self._get_expr_from_pyomo_repn( - # con, - # self._max_constraint_degree) else: - gurobi_expr, referenced_vars = self._get_expr_from_pyomo_expr( + gurobi_expr, referenced_vars, degree = self._get_expr_from_pyomo_expr( con.body, self._max_constraint_degree ) @@ -524,7 +528,8 @@ def _add_constraint(self, con): ) if con.equality: - gurobipy_con = self._solver_model.addConstr( + gurobipy_con = self._addConstr( + degree=degree, lhs=gurobi_expr, sense=gurobipy.GRB.EQUAL, rhs=value(con.lower), @@ -536,14 +541,16 @@ def _add_constraint(self, con): ) self._range_constraints.add(con) elif con.has_lb(): - gurobipy_con = self._solver_model.addConstr( + gurobipy_con = self._addConstr( + degree=degree, lhs=gurobi_expr, sense=gurobipy.GRB.GREATER_EQUAL, rhs=value(con.lower), name=conname, ) elif con.has_ub(): - gurobipy_con = self._solver_model.addConstr( + gurobipy_con = self._addConstr( + degree=degree, lhs=gurobi_expr, sense=gurobipy.GRB.LESS_EQUAL, rhs=value(con.upper), @@ -637,7 +644,7 @@ def _set_objective(self, obj): else: raise ValueError('Objective sense is not recognized: {0}'.format(obj.sense)) - gurobi_expr, referenced_vars = self._get_expr_from_pyomo_expr( + gurobi_expr, referenced_vars, degree = self._get_expr_from_pyomo_expr( obj.expr, self._max_obj_degree ) diff --git a/pyomo/solvers/plugins/solvers/gurobi_persistent.py b/pyomo/solvers/plugins/solvers/gurobi_persistent.py index 4522a2151c3..2160d28a77f 100644 --- a/pyomo/solvers/plugins/solvers/gurobi_persistent.py +++ b/pyomo/solvers/plugins/solvers/gurobi_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -111,7 +111,7 @@ def update_var(self, var): Parameters ---------- - var: Var (scalar Var or single _VarData) + var: Var (scalar Var or single VarData) """ # see PR #366 for discussion about handling indexed @@ -157,7 +157,7 @@ def set_linear_constraint_attr(self, con, attr, val): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be modified. attr: str @@ -192,7 +192,7 @@ def set_var_attr(self, var, attr, val): Parameters ---------- - con: pyomo.core.base.var._GeneralVarData + con: pyomo.core.base.var.VarData The pyomo var for which the corresponding gurobi var attribute should be modified. attr: str @@ -342,7 +342,7 @@ def get_var_attr(self, var, attr): Parameters ---------- - var: pyomo.core.base.var._GeneralVarData + var: pyomo.core.base.var.VarData The pyomo var for which the corresponding gurobi var attribute should be retrieved. attr: str @@ -384,7 +384,7 @@ def get_linear_constraint_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be retrieved. attr: str @@ -413,7 +413,7 @@ def get_sos_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.sos._SOSConstraintData + con: pyomo.core.base.sos.SOSConstraintData The pyomo SOS constraint for which the corresponding gurobi SOS constraint attribute should be retrieved. attr: str @@ -431,7 +431,7 @@ def get_quadratic_constraint_attr(self, con, attr): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The pyomo constraint for which the corresponding gurobi constraint attribute should be retrieved. attr: str @@ -500,13 +500,13 @@ def set_callback(self, func=None): .. math:: :nowrap: - \begin{array}{ll} - \min & 2x + y \\ - \mathrm{s.t.} & y \geq (x-2)^2 \\ - & 0 \leq x \leq 4 \\ - & y \geq 0 \\ - & y \in \mathbb{Z} - \end{array} + \[\begin{array}{ll} + \min & 2x + y \\ + \mathrm{s.t.} & y \geq (x-2)^2 \\ + & 0 \leq x \leq 4 \\ + & y \geq 0 \\ + & y \in \mathbb{Z} + \end{array}\] as an MILP using extended cutting planes in callbacks. @@ -514,14 +514,14 @@ def set_callback(self, func=None): :skipif: not gurobipy_available from gurobipy import GRB - import pyomo.environ as pe + import pyomo.environ as pyo from pyomo.core.expr.taylor_series import taylor_series_expansion - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(0, 4)) - m.y = pe.Var(within=pe.Integers, bounds=(0, None)) - m.obj = pe.Objective(expr=2*m.x + m.y) - m.cons = pe.ConstraintList() # for the cutting planes + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 4)) + m.y = pyo.Var(within=pyo.Integers, bounds=(0, None)) + m.obj = pyo.Objective(expr=2*m.x + m.y) + m.cons = pyo.ConstraintList() # for the cutting planes def _add_cut(xval): # a function to generate the cut @@ -531,7 +531,7 @@ def _add_cut(xval): _add_cut(0) # start with 2 cuts at the bounds of x _add_cut(4) # this is an arbitrary choice - opt = pe.SolverFactory('gurobi_persistent') + opt = pyo.SolverFactory('gurobi_persistent') opt.set_instance(m) opt.set_gurobi_param('PreCrush', 1) opt.set_gurobi_param('LazyConstraints', 1) @@ -569,7 +569,7 @@ def cbCut(self, con): Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The cut to add """ if not con.active: @@ -578,7 +578,7 @@ def cbCut(self, con): if is_fixed(con.body): raise ValueError('cbCut expected a non-trivial constraint') - gurobi_expr, referenced_vars = self._get_expr_from_pyomo_expr( + gurobi_expr, referenced_vars, degree = self._get_expr_from_pyomo_expr( con.body, self._max_constraint_degree ) @@ -647,7 +647,7 @@ def cbLazy(self, con): """ Parameters ---------- - con: pyomo.core.base.constraint._GeneralConstraintData + con: pyomo.core.base.constraint.ConstraintData The lazy constraint to add """ if not con.active: @@ -656,7 +656,7 @@ def cbLazy(self, con): if is_fixed(con.body): raise ValueError('cbLazy expected a non-trivial constraint') - gurobi_expr, referenced_vars = self._get_expr_from_pyomo_expr( + gurobi_expr, referenced_vars, degree = self._get_expr_from_pyomo_expr( con.body, self._max_constraint_degree ) @@ -710,7 +710,7 @@ def _add_column(self, var, obj_coef, constraints, coefficients): Parameters ---------- - var: Var (scalar Var or single _VarData) + var: Var (scalar Var or single VarData) obj_coef: float constraints: list of solver constraints coefficients: list of coefficients to put on var in the associated constraint diff --git a/pyomo/solvers/plugins/solvers/mosek_direct.py b/pyomo/solvers/plugins/solvers/mosek_direct.py index 5000a2f35c4..32ea0c99879 100644 --- a/pyomo/solvers/plugins/solvers/mosek_direct.py +++ b/pyomo/solvers/plugins/solvers/mosek_direct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -163,21 +163,28 @@ def _process_stream(msg): for key, option in self.options.items(): try: param = key.split('.') - if param[0] == 'mosek': - param.pop(0) - param = getattr(mosek, param[0])(param[1]) - if 'sparam' in key.split('.'): - self._solver_model.putstrparam(param, option) - elif 'dparam' in key.split('.'): - self._solver_model.putdouparam(param, option) - elif 'iparam' in key.split('.'): - if isinstance(option, str): - option = option.split('.') - if option[0] == 'mosek': - option.pop('mosek') - option = getattr(mosek, option[0])(option[1]) - else: + if key == param[0]: + self._solver_model.putparam(key, option) + else: + if param[0] == 'mosek': + param.pop(0) + assert ( + len(param) == 2 + ), "unrecognized MOSEK parameter name '{}'".format(key) + param = getattr(mosek, param[0])(param[1]) + if 'sparam.' in key: + self._solver_model.putstrparam(param, option) + elif 'dparam.' in key: + self._solver_model.putdouparam(param, option) + elif 'iparam.' in key: + if isinstance(option, str): + option = option.split('.') + if option[0] == 'mosek': + option.pop(0) + option = getattr(mosek, option[0])(option[1]) self._solver_model.putintparam(param, option) + else: + raise ValueError(f"unrecognized MOSEK parameter name '{key}'") except (TypeError, AttributeError): raise try: @@ -492,13 +499,10 @@ def _add_constraints(self, con_seq): ptrb = (0,) + ptre[:-1] asubs = tuple(itertools.chain.from_iterable(l_ids)) avals = tuple(itertools.chain.from_iterable(l_coefs)) - qcsubi = tuple(itertools.chain.from_iterable(q_is)) - qcsubj = tuple(itertools.chain.from_iterable(q_js)) - qcval = tuple(itertools.chain.from_iterable(q_vals)) - qcsubk = tuple(i for i in sub for j in range(len(q_is[i - con_num]))) self._solver_model.appendcons(num_lq) self._solver_model.putarowlist(sub, ptrb, ptre, asubs, avals) - self._solver_model.putqcon(qcsubk, qcsubi, qcsubj, qcval) + for k, i, j, v in zip(sub, q_is, q_js, q_vals): + self._solver_model.putqconk(k, i, j, v) self._solver_model.putconboundlist(sub, bound_types, lbs, ubs) for i, s_n in enumerate(sub_names): self._solver_model.putconname(sub[i], s_n) @@ -558,7 +562,7 @@ def _add_block(self, block): Parameters ---------- - block: Block (scalar Block or single _BlockData) + block: Block (scalar Block or single BlockData) """ var_seq = tuple( block.component_data_objects( @@ -1071,7 +1075,7 @@ def _warm_start(self): for pyomo_var, mosek_var in self._pyomo_var_to_solver_var_map.items(): if pyomo_var.value is not None: self._solver_model.putxxslice( - self._whichsol, mosek_var, mosek_var + 1, [(pyomo_var.value)] + self._whichsol, mosek_var, mosek_var + 1, [pyomo_var.value] ) if (self._version[0] > 9) & (self._whichsol == mosek.soltype.itg): diff --git a/pyomo/solvers/plugins/solvers/mosek_persistent.py b/pyomo/solvers/plugins/solvers/mosek_persistent.py index 97f88e0cb9a..774ea699f0e 100644 --- a/pyomo/solvers/plugins/solvers/mosek_persistent.py +++ b/pyomo/solvers/plugins/solvers/mosek_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -85,7 +85,7 @@ def add_constraints(self, con_seq): Parameters ---------- - con_seq: tuple/list of Constraint (scalar Constraint or single _ConstraintData) + con_seq: tuple/list of Constraint (scalar Constraint or single ConstraintData) """ self._add_constraints(con_seq) @@ -95,7 +95,7 @@ def remove_var(self, solver_var): This will keep any other model components intact. Parameters ---------- - solver_var: Var (scalar Var or single _VarData) + solver_var: Var (scalar Var or single VarData) """ self.remove_vars(solver_var) @@ -106,7 +106,7 @@ def remove_vars(self, *solver_vars): This will keep any other model components intact. Parameters ---------- - *solver_var: Var (scalar Var or single _VarData) + *solver_var: Var (scalar Var or single VarData) """ try: var_ids = [] @@ -137,7 +137,7 @@ def remove_constraint(self, solver_con): To remove a conic-domain, you should use the remove_block method. Parameters ---------- - solver_con: Constraint (scalar Constraint or single _ConstraintData) + solver_con: Constraint (scalar Constraint or single ConstraintData) """ self.remove_constraints(solver_con) @@ -151,7 +151,7 @@ def remove_constraints(self, *solver_cons): Parameters ---------- - *solver_cons: Constraint (scalar Constraint or single _ConstraintData) + *solver_cons: Constraint (scalar Constraint or single ConstraintData) """ lq_cons = tuple( itertools.filterfalse(lambda x: isinstance(x, _ConicBase), solver_cons) @@ -205,7 +205,7 @@ def update_vars(self, *solver_vars): changing variable types and bounds. Parameters ---------- - *solver_var: Constraint (scalar Constraint or single _ConstraintData) + *solver_var: Constraint (scalar Constraint or single ConstraintData) """ try: var_ids = [] diff --git a/pyomo/solvers/plugins/solvers/persistent_solver.py b/pyomo/solvers/plugins/solvers/persistent_solver.py index 29aa3f2bbf5..85ed04eafc4 100644 --- a/pyomo/solvers/plugins/solvers/persistent_solver.py +++ b/pyomo/solvers/plugins/solvers/persistent_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ from pyomo.solvers.plugins.solvers.direct_or_persistent_solver import ( DirectOrPersistentSolver, ) -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.core.kernel.block import IBlock from pyomo.core.base.suffix import active_import_suffix_generator from pyomo.core.kernel.suffix import import_suffix_generator @@ -96,7 +96,7 @@ def add_block(self, block): Parameters ---------- - block: Block (scalar Block or single _BlockData) + block: Block (scalar Block or single BlockData) """ if self._pyomo_model is None: @@ -132,7 +132,7 @@ def add_constraint(self, con): Parameters ---------- - con: Constraint (scalar Constraint or single _ConstraintData) + con: Constraint (scalar Constraint or single ConstraintData) """ if self._pyomo_model is None: @@ -206,9 +206,9 @@ def add_column(self, model, var, obj_coef, constraints, coefficients): Parameters ---------- model: pyomo ConcreteModel to which the column will be added - var: Var (scalar Var or single _VarData) + var: Var (scalar Var or single VarData) obj_coef: float, pyo.Param - constraints: list of scalar Constraints of single _ConstraintDatas + constraints: list of scalar Constraints of single ConstraintDatas coefficients: list of the coefficient to put on var in the associated constraint """ @@ -262,7 +262,9 @@ def _add_and_collect_column_data(self, var, obj_coef, constraints, coefficients) coeff_list = list() constr_list = list() for val, c in zip(coefficients, constraints): - c._body += val * var + lb, body, ub = c.to_bounded_expression() + body += val * var + c.set_value((lb, body, ub)) self._vars_referenced_by_con[c].add(var) cval = _convert_to_const(val) @@ -295,7 +297,7 @@ def remove_block(self, block): Parameters ---------- - block: Block (scalar Block or a single _BlockData) + block: Block (scalar Block or a single BlockData) """ # see PR #366 for discussion about handling indexed @@ -328,7 +330,7 @@ def remove_constraint(self, con): Parameters ---------- - con: Constraint (scalar Constraint or single _ConstraintData) + con: Constraint (scalar Constraint or single ConstraintData) """ # see PR #366 for discussion about handling indexed @@ -380,7 +382,7 @@ def remove_var(self, var): Parameters ---------- - var: Var (scalar Var or single _VarData) + var: Var (scalar Var or single VarData) """ # see PR #366 for discussion about handling indexed @@ -455,7 +457,7 @@ def solve(self, *args, **kwds): self.available(exception_flag=True) # Collect suffix names to try and import from solution. - if isinstance(self._pyomo_model, _BlockData): + if isinstance(self._pyomo_model, BlockData): model_suffixes = list( name for (name, comp) in active_import_suffix_generator(self._pyomo_model) diff --git a/pyomo/solvers/plugins/solvers/pywrapper.py b/pyomo/solvers/plugins/solvers/pywrapper.py index c3ec2eaf709..64ebcc098a1 100644 --- a/pyomo/solvers/plugins/solvers/pywrapper.py +++ b/pyomo/solvers/plugins/solvers/pywrapper.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 75cf8f921df..a7ce2b438a6 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -75,6 +75,100 @@ def _finalize_xpress_import(xpress, avail): if not hasattr(xpress, 'rng'): xpress.rng = xpress.range + # + # Xpress 9.5 (44.1.1) changed the Python API fairly significantly. + # We will map between the two APIs based on the version. + # + if XpressDirect._version < (44,): + + def _addConstraint( + self, + prob, + constraint=None, + body=None, + lb=None, + ub=None, + type=None, + rhs=None, + name='', + ): + # It's unclear what the acceptable "default" values are for + # lb, ub, etc. (putting in the values from the documentation + # generates errors). We will instead use None and filter + # out any non-None fields. + args = {'sense': type, 'name': name} + for field in ('constraint', 'body', 'lb', 'ub', 'rhs'): + if locals()[field] is not None: + args[field] = locals()[field] + con = xpress.constraint(**args) + prob.addConstraint(con) + return con + + def _addVariable(self, prob, name, lb, ub, vartype): + var = xpress.var(name=name, lb=lb, ub=ub, vartype=vartype) + prob.addVariable(var) + return var + + def _addSOS(self, prob, indices, weights, type, name): + con = xpress.sos(indices, weights, type, name) + prob.addSOS(con) + return con + + XpressDirect._addConstraint = _addConstraint + XpressDirect._addVariable = _addVariable + XpressDirect._addSOS = _addSOS + XpressDirect._getSlacks = lambda self, prob, con: prob.getSlack(con) + XpressDirect._getDuals = lambda self, prob, con: prob.getDual(con) + XpressDirect._getRedCosts = lambda self, prob, con: prob.getRCost(con) + else: + # Note that rhsrange (the last argument) was not added until + # 9.5. We will not include it here in the compatibility + # wrapper. + def _addConstraint( + self, + prob, + constraint=None, + body=None, + lb=None, + ub=None, + type=None, + rhs=None, + name='', + ): + con = xpress.constraint( + constraint=constraint, + body=body, + lb=lb, + ub=ub, + type=type, + rhs=rhs, + name=name, + ) + prob.addConstraint(con) + return con + + XpressDirect._addConstraint = _addConstraint + XpressDirect._addVariable = ( + lambda self, prob, name, lb, ub, vartype: prob.addVariable( + name=name, lb=lb, ub=ub, vartype=vartype + ) + ) + XpressDirect._addSOS = ( + lambda self, prob, indices, weights, type, name: prob.addSOS( + indices, weights, type, name + ) + ) + XpressDirect._getSlacks = lambda self, prob, con: prob.getSlacks(con) + XpressDirect._getDuals = lambda self, prob, con: prob.getDuals(con) + XpressDirect._getRedCosts = lambda self, prob, con: prob.getRedCosts(con) + + # Note that as of 9.5, xpress.var raises an exception when + # compared using '==' after it has been removed from the model. + # This can foul up ComponentMaps in the persistent interface, + # so we will hard-code the `var` as not being hashable (so the + # ComponentMap will use the id() as the key) + ComponentMap.hasher.hashable(xpress.var, False) + class _xpress_importer_class(object): # We want to be able to *update* the message that the deferred @@ -103,21 +197,6 @@ def __call__(self): return xpress -_xpress_importer = _xpress_importer_class() -xpress, xpress_available = attempt_import( - 'xpress', - error_message=_xpress_importer, - # Other forms of exceptions can be thrown by the xpress python - # import. For example, an xpress.InterfaceError exception is thrown - # if the Xpress license is not valid. Unfortunately, you can't - # import without a license, which means we can't test for that - # explicit exception! - catch_exceptions=(Exception,), - importer=_xpress_importer, - callback=_finalize_xpress_import, -) - - @SolverFactory.register('xpress_direct', doc='Direct python interface to XPRESS') class XpressDirect(DirectSolver): _name = None @@ -163,12 +242,24 @@ def __init__(self, **kwds): def available(self, exception_flag=True): """True if the solver is available.""" - if exception_flag and not xpress_available: - xpress.log_import_warning(logger=__name__) - raise ApplicationError( - "No Python bindings available for %s solver plugin" % (type(self),) - ) - return bool(xpress_available) + if not xpress_available: + if exception_flag: + xpress.log_import_warning(logger=__name__) + raise ApplicationError( + "No Python bindings available for %s solver plugin" % (type(self),) + ) + return False + + # Check that there is a valid license + try: + xpress.init() + return True + except: + if exception_flag: + raise + return False + finally: + xpress.free() def _apply_solver(self): StaleFlagManager.mark_all_as_stale() @@ -226,7 +317,8 @@ def _apply_solver(self): if self._tee and XpressDirect._version[0] < 36: self._solver_model.removecbmessage(_print_message, None) - # FIXME: can we get a return code indicating if XPRESS had a significant failure? + # FIXME: can we get a return code indicating if XPRESS had a + # significant failure? return Bunch(rc=None, log=None) def _get_mip_results(self, results, soln): @@ -246,7 +338,8 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.unknown - # no MIP solution, first LP did not solve, second LP did, third search started but incomplete + # no MIP solution, first LP did not solve, second LP did, + # third search started but incomplete elif ( status == xp.mip_lp_not_optimal or status == xp.mip_lp_optimal @@ -621,8 +714,9 @@ def _add_var(self, var): vartype = self._xpress_vartype_from_var(var) lb, ub = self._xpress_lb_ub_from_var(var) - xpress_var = xpress.var(name=varname, lb=lb, ub=ub, vartype=vartype) - self._solver_model.addVariable(xpress_var) + xpress_var = self._addVariable( + self._solver_model, name=varname, lb=lb, ub=ub, vartype=vartype + ) ## bounds on binary variables don't seem to be set correctly ## by the method above @@ -667,9 +761,8 @@ def _add_constraint(self, con): if not con.active: return None - if is_fixed(con.body): - if self._skip_trivial_constraints: - return None + if self._skip_trivial_constraints and is_fixed(con.body): + return None conname = self._symbol_map.getSymbol(con, self._labeler) @@ -694,25 +787,38 @@ def _add_constraint(self, con): ) if con.equality: - xpress_con = xpress.constraint( - body=xpress_expr, sense=xpress.eq, rhs=value(con.lower), name=conname + xpress_con = self._addConstraint( + self._solver_model, + body=xpress_expr, + type=xpress.eq, + rhs=value(con.lower), + name=conname, ) elif con.has_lb() and con.has_ub(): - xpress_con = xpress.constraint( + xpress_con = self._addConstraint( + self._solver_model, body=xpress_expr, - sense=xpress.rng, + type=xpress.rng, lb=value(con.lower), ub=value(con.upper), name=conname, ) self._range_constraints.add(xpress_con) elif con.has_lb(): - xpress_con = xpress.constraint( - body=xpress_expr, sense=xpress.geq, rhs=value(con.lower), name=conname + xpress_con = self._addConstraint( + self._solver_model, + body=xpress_expr, + type=xpress.geq, + rhs=value(con.lower), + name=conname, ) elif con.has_ub(): - xpress_con = xpress.constraint( - body=xpress_expr, sense=xpress.leq, rhs=value(con.upper), name=conname + xpress_con = self._addConstraint( + self._solver_model, + body=xpress_expr, + type=xpress.leq, + rhs=value(con.upper), + name=conname, ) else: raise ValueError( @@ -720,8 +826,6 @@ def _add_constraint(self, con): "or an upper bound: {0} \n".format(con) ) - self._solver_model.addConstraint(xpress_con) - for var in referenced_vars: self._referenced_variables[var] += 1 self._vars_referenced_by_con[con] = referenced_vars @@ -757,16 +861,19 @@ def _add_sos_constraint(self, con): self._referenced_variables[v] += 1 weights.append(w) - xpress_con = xpress.sos(xpress_vars, weights, level, conname) - self._solver_model.addSOS(xpress_con) + xpress_con = self._addSOS( + self._solver_model, xpress_vars, weights, level, conname + ) self._pyomo_con_to_solver_con_map[con] = xpress_con self._solver_con_to_pyomo_con_map[xpress_con] = con def _xpress_vartype_from_var(self, var): - """ - This function takes a pyomo variable and returns the appropriate xpress variable type + """This function takes a pyomo variable and returns the appropriate + xpress variable type + :param var: pyomo.core.base.var.Var :return: xpress.continuous or xpress.binary or xpress.integer + """ if var.is_binary(): vartype = xpress.binary @@ -895,41 +1002,60 @@ def _postsolve(self): # see if there is a solution available - this may not always # be the case, both in LP and MIP contexts. if self._save_results: - """ - This code in this if statement is only needed for backwards compatibility. It is more efficient to set - _save_results to False and use load_vars, load_duals, etc. - """ + # This code in this if statement is only needed for backwards + # compatibility. It is more efficient to set _save_results to + # False and use load_vars, load_duals, etc. if have_soln: soln_variables = soln.variable soln_constraints = soln.constraint + if extract_duals or extract_slacks: + xpress_cons = list(self._solver_con_to_pyomo_con_map.keys()) + for con in xpress_cons: + soln_constraints[con.name] = {} + xpress_vars = list(self._solver_var_to_pyomo_var_map.keys()) - var_vals = xprob.getSolution(xpress_vars) + try: + var_vals = xprob.getSolution(xpress_vars) + if extract_slacks: + slacks = self._getSlacks(xprob, xpress_cons) + except xpress.ModelError: + # Xpress 9.5.0 has new behavior for unbounded + # problems that have mipsols > 0. Previously + # getSolution() would return a solution, but now + # raises a ModelError (even though the deprecated + # getmipsol() will return a solution). We will try + # to fall back on the [deprecated] getmipsol(), but + # if it fails, we will raise the original exception. + try: + var_vals = [] + slacks = [] if extract_slacks else None + xprob.getmipsol(var_vals, slacks) + fail = 0 + except: + fail = 1 + if fail: + raise + for xpress_var, val in zip(xpress_vars, var_vals): pyomo_var = self._solver_var_to_pyomo_var_map[xpress_var] if self._referenced_variables[pyomo_var] > 0: soln_variables[xpress_var.name] = {"Value": val} if extract_reduced_costs: - vals = xprob.getRCost(xpress_vars) + vals = self._getRedCosts(xprob, xpress_vars) for xpress_var, val in zip(xpress_vars, vals): pyomo_var = self._solver_var_to_pyomo_var_map[xpress_var] if self._referenced_variables[pyomo_var] > 0: soln_variables[xpress_var.name]["Rc"] = val - if extract_duals or extract_slacks: - xpress_cons = list(self._solver_con_to_pyomo_con_map.keys()) - for con in xpress_cons: - soln_constraints[con.name] = {} - if extract_duals: - vals = xprob.getDual(xpress_cons) + vals = self._getDuals(xprob, xpress_cons) for val, con in zip(vals, xpress_cons): soln_constraints[con.name]["Dual"] = val if extract_slacks: - vals = xprob.getSlack(xpress_cons) - for con, val in zip(xpress_cons, vals): + for con, val in zip(xpress_cons, slacks): if con in self._range_constraints: ## for xpress, the slack on a range constraint ## is based on the upper bound @@ -1000,7 +1126,7 @@ def _load_rc(self, vars_to_load=None): vars_to_load = var_map.keys() xpress_vars_to_load = [var_map[pyomo_var] for pyomo_var in vars_to_load] - vals = self._solver_model.getRCost(xpress_vars_to_load) + vals = self._getRedCosts(self._solver_model, xpress_vars_to_load) for var, val in zip(vars_to_load, vals): if ref_vars[var] > 0: @@ -1016,7 +1142,7 @@ def _load_duals(self, cons_to_load=None): cons_to_load = con_map.keys() xpress_cons_to_load = [con_map[pyomo_con] for pyomo_con in cons_to_load] - vals = self._solver_model.getDual(xpress_cons_to_load) + vals = self._getDuals(self._solver_model, xpress_cons_to_load) for pyomo_con, val in zip(cons_to_load, vals): dual[pyomo_con] = val @@ -1031,16 +1157,14 @@ def _load_slacks(self, cons_to_load=None): cons_to_load = con_map.keys() xpress_cons_to_load = [con_map[pyomo_con] for pyomo_con in cons_to_load] - vals = self._solver_model.getSlack(xpress_cons_to_load) + vals = self._getSlacks(self._solver_model, xpress_cons_to_load) for pyomo_con, xpress_con, val in zip(cons_to_load, xpress_cons_to_load, vals): if xpress_con in self._range_constraints: ## for xpress, the slack on a range constraint ## is based on the upper bound - ## FIXME: This looks like a bug - there is no variable named - ## `con` - there is, however, `xpress_con` and `pyomo_con` - lb = con.lb - ub = con.ub + lb = xpress_con.lb + ub = xpress_con.ub ub_s = val expr_val = ub - ub_s lb_s = lb - expr_val @@ -1052,32 +1176,52 @@ def _load_slacks(self, cons_to_load=None): slack[pyomo_con] = val def load_duals(self, cons_to_load=None): - """ - Load the duals into the 'dual' suffix. The 'dual' suffix must live on the parent model. + """Load the duals into the 'dual' suffix. The 'dual' suffix must live + on the parent model. Parameters ---------- cons_to_load: list of Constraint + """ self._load_duals(cons_to_load) def load_rc(self, vars_to_load=None): - """ - Load the reduced costs into the 'rc' suffix. The 'rc' suffix must live on the parent model. + """Load the reduced costs into the 'rc' suffix. The 'rc' suffix must + live on the parent model. Parameters ---------- vars_to_load: list of Var + """ self._load_rc(vars_to_load) def load_slacks(self, cons_to_load=None): - """ - Load the values of the slack variables into the 'slack' suffix. The 'slack' suffix must live on the parent - model. + """Load the values of the slack variables into the 'slack' suffix. The + 'slack' suffix must live on the parent model. Parameters ---------- cons_to_load: list of Constraint + """ self._load_slacks(cons_to_load) + + +# Note: because _finalize_xpress_import references XpressDirect, we need +# to make sure to not attempt the xpress import until after the +# XpressDirect class is fully declared. +_xpress_importer = _xpress_importer_class() +xpress, xpress_available = attempt_import( + 'xpress', + error_message=_xpress_importer, + # Other forms of exceptions can be thrown by the xpress python + # import. For example, an xpress.InterfaceError exception is thrown + # if the Xpress license is not valid. Unfortunately, you can't + # import without a license, which means we can't test for that + # explicit exception! + catch_exceptions=(Exception,), + importer=_xpress_importer, + callback=_finalize_xpress_import, +) diff --git a/pyomo/solvers/plugins/solvers/xpress_persistent.py b/pyomo/solvers/plugins/solvers/xpress_persistent.py index 513a7fbc257..f374fd5e1a4 100644 --- a/pyomo/solvers/plugins/solvers/xpress_persistent.py +++ b/pyomo/solvers/plugins/solvers/xpress_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -90,7 +90,7 @@ def update_var(self, var): Parameters ---------- - var: Var (scalar Var or single _VarData) + var: Var (scalar Var or single VarData) """ # see PR #366 for discussion about handling indexed @@ -110,8 +110,8 @@ def update_var(self, var): qctype = self._xpress_chgcoltype_from_var(var) lb, ub = self._xpress_lb_ub_from_var(var) - self._solver_model.chgbounds([xpress_var, xpress_var], ['L', 'U'], [lb, ub]) self._solver_model.chgcoltype([xpress_var], [qctype]) + self._solver_model.chgbounds([xpress_var, xpress_var], ['L', 'U'], [lb, ub]) def _add_column(self, var, obj_coef, constraints, coefficients): """Add a column to the solver's model @@ -124,7 +124,7 @@ def _add_column(self, var, obj_coef, constraints, coefficients): Parameters ---------- - var: Var (scalar Var or single _VarData) + var: Var (scalar Var or single VarData) obj_coef: float constraints: list of solver constraints coefficients: list of coefficients to put on var in the associated constraint diff --git a/pyomo/solvers/tests/__init__.py b/pyomo/solvers/tests/__init__.py index 4d8d45da724..81f127a54f8 100644 --- a/pyomo/solvers/tests/__init__.py +++ b/pyomo/solvers/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/__init__.py b/pyomo/solvers/tests/checks/__init__.py index ccd3a0f98a4..2afcc2b2775 100644 --- a/pyomo/solvers/tests/checks/__init__.py +++ b/pyomo/solvers/tests/checks/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_BARON.py b/pyomo/solvers/tests/checks/test_BARON.py index 29c7ffb0148..a55b60bf835 100644 --- a/pyomo/solvers/tests/checks/test_BARON.py +++ b/pyomo/solvers/tests/checks/test_BARON.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_CBCplugin.py b/pyomo/solvers/tests/checks/test_CBCplugin.py index 2ea0e55c5f4..45db7e5020b 100644 --- a/pyomo/solvers/tests/checks/test_CBCplugin.py +++ b/pyomo/solvers/tests/checks/test_CBCplugin.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -29,7 +29,7 @@ maximize, minimize, ) -from pyomo.opt import SolverFactory, ProblemSense, TerminationCondition, SolverStatus +from pyomo.opt import SolverFactory, TerminationCondition, SolverStatus from pyomo.solvers.plugins.solvers.CBCplugin import CBCSHELL cbc_available = SolverFactory('cbc', solver_io='lp').available(exception_flag=False) @@ -62,7 +62,7 @@ def test_infeasible_lp(self): results = self.opt.solve(self.model) - self.assertEqual(ProblemSense.minimize, results.problem.sense) + self.assertEqual(minimize, results.problem.sense) self.assertEqual( TerminationCondition.infeasible, results.solver.termination_condition ) @@ -81,7 +81,7 @@ def test_unbounded_lp(self): results = self.opt.solve(self.model) - self.assertEqual(ProblemSense.maximize, results.problem.sense) + self.assertEqual(maximize, results.problem.sense) self.assertEqual( TerminationCondition.unbounded, results.solver.termination_condition ) @@ -99,7 +99,7 @@ def test_optimal_lp(self): self.assertEqual(0.0, results.problem.lower_bound) self.assertEqual(0.0, results.problem.upper_bound) - self.assertEqual(ProblemSense.minimize, results.problem.sense) + self.assertEqual(minimize, results.problem.sense) self.assertEqual( TerminationCondition.optimal, results.solver.termination_condition ) @@ -118,7 +118,7 @@ def test_infeasible_mip(self): results = self.opt.solve(self.model) - self.assertEqual(ProblemSense.minimize, results.problem.sense) + self.assertEqual(minimize, results.problem.sense) self.assertEqual( TerminationCondition.infeasible, results.solver.termination_condition ) @@ -134,7 +134,7 @@ def test_unbounded_mip(self): results = self.opt.solve(self.model) - self.assertEqual(ProblemSense.minimize, results.problem.sense) + self.assertEqual(minimize, results.problem.sense) self.assertEqual( TerminationCondition.unbounded, results.solver.termination_condition ) @@ -159,7 +159,7 @@ def test_optimal_mip(self): self.assertEqual(1.0, results.problem.upper_bound) self.assertEqual(results.problem.number_of_binary_variables, 2) self.assertEqual(results.problem.number_of_integer_variables, 4) - self.assertEqual(ProblemSense.maximize, results.problem.sense) + self.assertEqual(maximize, results.problem.sense) self.assertEqual( TerminationCondition.optimal, results.solver.termination_condition ) diff --git a/pyomo/solvers/tests/checks/test_CPLEXDirect.py b/pyomo/solvers/tests/checks/test_CPLEXDirect.py index 400d7ee5f75..cf3757199c8 100644 --- a/pyomo/solvers/tests/checks/test_CPLEXDirect.py +++ b/pyomo/solvers/tests/checks/test_CPLEXDirect.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_CPLEXPersistent.py b/pyomo/solvers/tests/checks/test_CPLEXPersistent.py index 91a60eee9dd..77d9b06e55e 100644 --- a/pyomo/solvers/tests/checks/test_CPLEXPersistent.py +++ b/pyomo/solvers/tests/checks/test_CPLEXPersistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -101,7 +101,7 @@ def test_add_column_exceptions(self): # add indexed constraint self.assertRaises(AttributeError, opt.add_column, m, m.y, -2, [m.ci], [1]) - # add something not a _ConstraintData + # add something not a ConstraintData self.assertRaises(AttributeError, opt.add_column, m, m.y, -2, [m.x], [1]) # constraint not on solver model diff --git a/pyomo/solvers/tests/checks/test_GAMS.py b/pyomo/solvers/tests/checks/test_GAMS.py index 1eef09819f7..3eb260ef6ae 100644 --- a/pyomo/solvers/tests/checks/test_GAMS.py +++ b/pyomo/solvers/tests/checks/test_GAMS.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -355,7 +355,7 @@ def tearDown(self): """Clean up temporary directory after tests are over.""" shutil.rmtree(self.tmpdir) - def _check_logfile(self, exists=True): + def _check_logfile(self, exists=True, logfile=None): """Check for logfiles existence and contents. exists=True: @@ -364,12 +364,14 @@ def _check_logfile(self, exists=True): Optionally check that the logfiles contents is equal to this value. """ + if not logfile: + logfile = self.logfile if not exists: - self.assertFalse(os.path.exists(self.logfile)) + self.assertFalse(os.path.exists(logfile)) return - self.assertTrue(os.path.exists(self.logfile)) - with open(self.logfile) as f: + self.assertTrue(os.path.exists(logfile)) + with open(logfile) as f: logfile_contents = f.read() self.assertIn(self.characteristic_output_string, logfile_contents) @@ -416,6 +418,15 @@ def test_logfile(self): self._check_stdout(output.getvalue(), exists=False) self._check_logfile(exists=True) + def test_logfile_with_spaces(self): + # In response to Issue 3579 + logfile_with_spaces = os.path.join(self.tmpdir, "My File.log") + with SolverFactory("gams", solver_io="gms") as opt: + with capture_output() as output: + opt.solve(self.m, logfile=logfile_with_spaces) + self._check_stdout(output.getvalue(), exists=False) + self._check_logfile(exists=True, logfile=logfile_with_spaces) + def test_logfile_relative(self): cwd = os.getcwd() with TempfileManager: @@ -470,6 +481,15 @@ def test_logfile(self): self._check_stdout(output.getvalue(), exists=False) self._check_logfile(exists=True) + def test_logfile_with_spaces(self): + # In response to Issue 3579 + logfile_with_spaces = os.path.join(self.tmpdir, "My File.log") + with SolverFactory("gams", solver_io="gms") as opt: + with capture_output() as output: + opt.solve(self.m, logfile=logfile_with_spaces) + self._check_stdout(output.getvalue(), exists=False) + self._check_logfile(exists=True, logfile=logfile_with_spaces) + def test_logfile_relative(self): cwd = os.getcwd() with TempfileManager: diff --git a/pyomo/solvers/tests/checks/test_KNITROAMPL.py b/pyomo/solvers/tests/checks/test_KNITROAMPL.py new file mode 100644 index 00000000000..bfb9013d37f --- /dev/null +++ b/pyomo/solvers/tests/checks/test_KNITROAMPL.py @@ -0,0 +1,140 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common import unittest +from pyomo.environ import ( + ConcreteModel, + Var, + Objective, + Constraint, + Suffix, + NonNegativeIntegers, + NonNegativeReals, + value, +) +from pyomo.opt import SolverFactory, TerminationCondition + +knitroampl_available = SolverFactory('knitroampl').available(False) + + +class TestKNITROAMPLInterface(unittest.TestCase): + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_infeasible_lp(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.C1 = Constraint(expr=model.X == 1) + model.C2 = Constraint(expr=model.X == 2) + model.Obj = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_unbounded_lp(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var() + model.Obj = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertIn( + results.solver.termination_condition, + ( + TerminationCondition.unbounded, + TerminationCondition.infeasibleOrUnbounded, + ), + ) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_optimal_lp(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.C1 = Constraint(expr=model.X >= 2.5) + model.Obj = Objective(expr=model.X) + + results = opt.solve(model, load_solutions=True) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertAlmostEqual(value(model.X), 2.5) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_get_duals_lp(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.Y = Var(within=NonNegativeReals) + + model.C1 = Constraint(expr=2 * model.X + model.Y >= 8) + model.C2 = Constraint(expr=model.X + 3 * model.Y >= 6) + + model.Obj = Objective(expr=model.X + model.Y) + + results = opt.solve(model, suffixes=['dual'], load_solutions=False) + + model.dual = Suffix(direction=Suffix.IMPORT) + model.solutions.load_from(results) + + self.assertAlmostEqual(model.dual[model.C1], 0.4) + self.assertAlmostEqual(model.dual[model.C2], 0.2) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_infeasible_mip(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeIntegers) + model.C1 = Constraint(expr=model.X == 1) + model.C2 = Constraint(expr=model.X == 2) + model.Obj = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_optimal_mip(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeIntegers) + model.C1 = Constraint(expr=model.X >= 2.5) + model.Obj = Objective(expr=model.X) + + results = opt.solve(model, load_solutions=True) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertAlmostEqual(value(model.X), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/solvers/tests/checks/test_MOSEKDirect.py b/pyomo/solvers/tests/checks/test_MOSEKDirect.py index 2cf7034b80a..79888effb2e 100644 --- a/pyomo/solvers/tests/checks/test_MOSEKDirect.py +++ b/pyomo/solvers/tests/checks/test_MOSEKDirect.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -313,6 +313,62 @@ def test_conic_duals(self): results.Solution.constraint['x11']['Dual'][i], check[i], 5 ) + def test_solver_parameters(self): + import mosek + + solver = pyo.SolverFactory('mosek_direct') + model = self._test_model() + solver.solve( + model, + options={ + 'dparam.optimizer_max_time': 1.0, + 'iparam.intpnt_solve_form': mosek.solveform.dual, + 'mosek.iparam.intpnt_max_iterations': 10, + 'MSK_DPAR_INTPNT_CO_TOL_REL_GAP': '1.0e-7', + 'MSK_IPAR_PRESOLVE_USE': '0', + 'sparam.param_comment_sign': '##', + }, + ) + # Check if iparams were set correctly + self.assertEqual( + solver._solver_model.getintparam(mosek.iparam.intpnt_solve_form), + mosek.solveform.dual, + ) + self.assertEqual( + solver._solver_model.getintparam(mosek.iparam.intpnt_max_iterations), 10 + ) + self.assertEqual( + solver._solver_model.getintparam(mosek.iparam.presolve_use), + mosek.presolvemode.off, + ) + # Check if dparams were set correctly + self.assertEqual( + solver._solver_model.getdouparam(mosek.dparam.optimizer_max_time), 1.0 + ) + self.assertEqual( + solver._solver_model.getdouparam(mosek.dparam.intpnt_co_tol_rel_gap), 1.0e-7 + ) + # Check if sparam is set correctly + self.assertEqual( + solver._solver_model.getstrparam(mosek.sparam.param_comment_sign)[1], '##' + ) + # Check for TypeErrors + with self.assertRaises(TypeError) as typeCheck: + solver.solve(model, options={'mosek.dparam.intpnt_co_tol_rel_gap': '1.4'}) + with self.assertRaises(TypeError) as typeCheck: + solver.solve(model, options={'iparam.log': 1.2}) + # Check for AttributeError + with self.assertRaises(AttributeError) as assertCheck: + solver.solve(model, options={'wrong.name': '1'}) + with self.assertRaises(AttributeError) as typeCheck: + solver.solve(model, options={'mosek.iparam.log': 'mosek.wrong.input'}) + # Check for wrong parameter name (but valid MOSEK attribute) + with self.assertRaises(ValueError) as typeCheck: + solver.solve(model, options={'mosek.mark.up': 'wrong.val'}) + # Check for parameter names with wrong length + with self.assertRaises(AssertionError) as typeCheck: + solver.solve(model, options={'mosek.iparam.log.level': 10}) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/solvers/tests/checks/test_MOSEKPersistent.py b/pyomo/solvers/tests/checks/test_MOSEKPersistent.py index a4c0aa21666..455e7e895d9 100644 --- a/pyomo/solvers/tests/checks/test_MOSEKPersistent.py +++ b/pyomo/solvers/tests/checks/test_MOSEKPersistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_SAS.py b/pyomo/solvers/tests/checks/test_SAS.py new file mode 100644 index 00000000000..f08ff83a06d --- /dev/null +++ b/pyomo/solvers/tests/checks/test_SAS.py @@ -0,0 +1,546 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import os +import pyomo.common.unittest as unittest +from unittest import mock +from pyomo.environ import ( + ConcreteModel, + Var, + Objective, + Constraint, + NonNegativeIntegers, + NonNegativeReals, + Reals, + Integers, + maximize, + minimize, + Suffix, +) +from pyomo.opt.results import SolverStatus, TerminationCondition, ProblemSense +from pyomo.opt import SolverFactory +import warnings + +CFGFILE = os.environ.get("SAS_CFG_FILE_PATH", None) + +CAS_OPTIONS = { + "hostname": os.environ.get("CASHOST", None), + "port": os.environ.get("CASPORT", None), + "authinfo": os.environ.get("CASAUTHINFO", None), +} + + +try: + sas94_available = SolverFactory('_sas94').available() +except: + sas94_available = False + + +class SASTestAbc: + solver_io = "_sas94" + session_options = {} + cfgfile = CFGFILE + + @classmethod + def setUpClass(cls): + cls.opt_sas = SolverFactory( + "sas", solver_io=cls.solver_io, cfgfile=cls.cfgfile, **cls.session_options + ) + + @classmethod + def tearDownClass(cls): + del cls.opt_sas + + def setObj(self): + X = self.instance.X + self.instance.Obj = Objective( + expr=2 * X[1] - 3 * X[2] - 4 * X[3], sense=minimize + ) + + def setX(self): + self.instance.X = Var([1, 2, 3], within=NonNegativeReals) + + def setUp(self): + # Disable resource warnings + warnings.filterwarnings("ignore", category=ResourceWarning) + instance = self.instance = ConcreteModel() + self.setX() + X = instance.X + instance.R1 = Constraint(expr=-2 * X[2] - 3 * X[3] >= -5) + instance.R2 = Constraint(expr=X[1] + X[2] + 2 * X[3] <= 4) + instance.R3 = Constraint(expr=X[1] + 2 * X[2] + 3 * X[3] <= 7) + self.setObj() + + # Declare suffixes for solution information + instance.status = Suffix(direction=Suffix.IMPORT) + instance.slack = Suffix(direction=Suffix.IMPORT) + instance.rc = Suffix(direction=Suffix.IMPORT) + instance.dual = Suffix(direction=Suffix.IMPORT) + + def tearDown(self): + del self.instance + + def run_solver(self, **kwargs): + opt_sas = self.opt_sas + instance = self.instance + + # Call the solver + self.results = opt_sas.solve(instance, **kwargs) + + +class SASTestLP(SASTestAbc): + def checkSolution(self): + instance = self.instance + results = self.results + # Get the objective sense, we use the same code for minimization and maximization tests + sense = instance.Obj.sense + + # Check status + self.assertEqual(results.solver.status, SolverStatus.ok) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + + # Check objective value + self.assertAlmostEqual(instance.Obj(), sense * -7.5) + + # Check primal solution values + self.assertAlmostEqual(instance.X[1].value, 0.0) + self.assertAlmostEqual(instance.X[2].value, 2.5) + self.assertAlmostEqual(instance.X[3].value, 0.0) + + # Check reduced cost + self.assertAlmostEqual(instance.rc[instance.X[1]], sense * 2.0) + self.assertAlmostEqual(instance.rc[instance.X[2]], sense * 0.0) + self.assertAlmostEqual(instance.rc[instance.X[3]], sense * 0.5) + + # Check slack + self.assertAlmostEqual(instance.slack[instance.R1], -5.0) + self.assertAlmostEqual(instance.slack[instance.R2], 2.5) + self.assertAlmostEqual(instance.slack[instance.R3], 5.0) + + # Check dual solution + self.assertAlmostEqual(instance.dual[instance.R1], sense * 1.5) + self.assertAlmostEqual(instance.dual[instance.R2], sense * 0.0) + self.assertAlmostEqual(instance.dual[instance.R3], sense * 0.0) + + # Check basis status + self.assertEqual(instance.status[instance.X[1]], "L") + self.assertEqual(instance.status[instance.X[2]], "B") + self.assertEqual(instance.status[instance.X[3]], "L") + self.assertEqual(instance.status[instance.R1], "U") + self.assertEqual(instance.status[instance.R2], "B") + self.assertEqual(instance.status[instance.R3], "B") + + def test_solver_default(self): + self.run_solver() + self.checkSolution() + + def test_solver_tee(self): + self.run_solver(tee=True) + self.checkSolution() + + def test_solver_primal(self): + self.run_solver(options={"algorithm": "ps"}) + self.assertIn("NOTE: The Primal Simplex algorithm is used.", self.opt_sas._log) + self.checkSolution() + + def test_solver_ipm(self): + self.run_solver(options={"algorithm": "ip"}) + self.assertIn("NOTE: The Interior Point algorithm is used.", self.opt_sas._log) + self.checkSolution() + + def test_solver_intoption(self): + self.run_solver(options={"maxiter": 20}) + self.checkSolution() + + def test_solver_invalidoption(self): + with self.assertRaisesRegex(ValueError, "syntax error"): + self.run_solver(options={"foo": "bar"}) + + def test_solver_max(self): + X = self.instance.X + self.instance.Obj.set_value(expr=-2 * X[1] + 3 * X[2] + 4 * X[3]) + self.instance.Obj.sense = maximize + self.run_solver() + self.checkSolution() + self.assertEqual(self.results.problem.sense, ProblemSense.maximize) + + def test_solver_infeasible(self): + instance = self.instance + X = instance.X + instance.R4 = Constraint(expr=-2 * X[2] - 3 * X[3] <= -6) + self.run_solver() + results = self.results + self.assertEqual(results.solver.status, SolverStatus.warning) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + self.assertEqual(results.solver.message, "The problem is infeasible.") + + def test_solver_infeasible_or_unbounded(self): + self.instance.X.domain = Reals + self.run_solver() + results = self.results + self.assertEqual(results.solver.status, SolverStatus.warning) + self.assertIn( + results.solver.termination_condition, + [ + TerminationCondition.infeasibleOrUnbounded, + TerminationCondition.unbounded, + ], + ) + self.assertIn( + results.solver.message, + ["The problem is infeasible or unbounded.", "The problem is unbounded."], + ) + + def test_solver_unbounded(self): + self.instance.X.domain = Reals + self.run_solver(options={"presolver": "none", "algorithm": "primal"}) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.warning) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.unbounded + ) + self.assertEqual(results.solver.message, "The problem is unbounded.") + + def checkSolutionDecomp(self): + instance = self.instance + results = self.results + # Get the objective sense, we use the same code for minimization and maximization tests + sense = instance.Obj.sense + + # Check status + self.assertEqual(results.solver.status, SolverStatus.ok) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + + # Check objective value + self.assertAlmostEqual(instance.Obj(), sense * -7.5) + + # Check primal solution values + self.assertAlmostEqual(instance.X[1].value, 0.0) + self.assertAlmostEqual(instance.X[2].value, 2.5) + self.assertAlmostEqual(instance.X[3].value, 0.0) + + # Check reduced cost + self.assertAlmostEqual(instance.rc[instance.X[1]], sense * 2.0) + self.assertAlmostEqual(instance.rc[instance.X[2]], sense * 0.0) + self.assertAlmostEqual(instance.rc[instance.X[3]], sense * 0.5) + + # Check slack + self.assertAlmostEqual(instance.slack[instance.R1], -5.0) + self.assertAlmostEqual(instance.slack[instance.R2], 2.5) + self.assertAlmostEqual(instance.slack[instance.R3], 5.0) + + # Check dual solution + self.assertAlmostEqual(instance.dual[instance.R1], sense * 1.5) + self.assertAlmostEqual(instance.dual[instance.R2], sense * 0.0) + self.assertAlmostEqual(instance.dual[instance.R3], sense * 0.0) + + # Don't check basis status for decomp + + def test_solver_decomp(self): + self.run_solver( + options={ + "decomp": {"absobjgap": 0.0}, + "decompmaster": {"algorithm": "dual"}, + "decompsubprob": {"presolver": "none"}, + } + ) + self.assertIn( + "NOTE: The DECOMP method value DEFAULT is applied.", self.opt_sas._log + ) + self.checkSolutionDecomp() + + def test_solver_iis(self): + self.run_solver(options={"iis": "true"}) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.ok) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.feasible + ) + self.assertIn("NOTE: The IIS= option is enabled.", self.opt_sas._log) + self.assertEqual( + results.solver.message, + "The problem is feasible. This status is displayed when the IIS=TRUE option is specified and the problem is feasible.", + ) + + def test_solver_maxiter(self): + self.run_solver(options={"maxiter": 1}) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.ok) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.maxIterations + ) + self.assertEqual( + results.solver.message, + "The maximum allowable number of iterations was reached.", + ) + + def test_solver_with_milp(self): + self.run_solver(options={"with": "milp"}) + self.assertIn( + "WARNING: The problem has no integer variables.", self.opt_sas._log + ) + + +@unittest.skipIf(not sas94_available, "The SAS94 solver interface is not available") +class SASTestLP94(SASTestLP, unittest.TestCase): + @mock.patch( + "pyomo.solvers.plugins.solvers.SAS.SAS94.sas_version", + return_value="9.sd45s39M4234232", + ) + def test_solver_versionM4(self, sas): + with self.assertRaises(NotImplementedError): + self.run_solver() + + @mock.patch( + "pyomo.solvers.plugins.solvers.SAS.SAS94.sas_version", + return_value="9.34897293M5324u98", + ) + def test_solver_versionM5(self, sas): + self.run_solver() + self.checkSolution() + + @mock.patch("saspy.SASsession.submit", return_value={"LOG": "", "LST": ""}) + @mock.patch("saspy.SASsession.symget", return_value="STATUS=OUT_OF_MEMORY") + def test_solver_out_of_memory(self, submit_mock, symget_mocks): + self.run_solver(load_solutions=False) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.aborted) + + @mock.patch("saspy.SASsession.submit", return_value={"LOG": "", "LST": ""}) + @mock.patch("saspy.SASsession.symget", return_value="STATUS=ERROR") + def test_solver_error(self, submit_mock, symget_mock): + self.run_solver(load_solutions=False) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.error) + + +# @unittest.skipIf(not sascas_available, "The SAS solver is not available") +@unittest.skip("Tests not yet configured for SAS Viya interface.") +class SASTestLPCAS(SASTestLP, unittest.TestCase): + solver_io = "_sascas" + session_options = CAS_OPTIONS + + @mock.patch("pyomo.solvers.plugins.solvers.SAS.stat") + def test_solver_large_file(self, os_stat): + os_stat.return_value.st_size = 3 * 1024**3 + self.run_solver() + self.checkSolution() + + +class SASTestMILP(SASTestAbc): + def setX(self): + self.instance.X = Var([1, 2, 3], within=NonNegativeIntegers) + + def checkSolution(self): + instance = self.instance + results = self.results + + # Get the objective sense, we use the same code for minimization and maximization tests + sense = instance.Obj.sense + + # Check status + self.assertEqual(results.solver.status, SolverStatus.ok) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + + # Check objective value + self.assertAlmostEqual(instance.Obj(), sense * -7) + + # Check primal solution values + self.assertAlmostEqual(instance.X[1].value, 0.0) + self.assertAlmostEqual(instance.X[2].value, 1.0) + self.assertAlmostEqual(instance.X[3].value, 1.0) + + def test_solver_default(self): + self.run_solver() + self.checkSolution() + + def test_solver_tee(self): + self.run_solver(tee=True) + self.checkSolution() + + def test_solver_presolve(self): + self.run_solver(options={"presolver": "none"}) + self.assertIn( + "NOTE: The MILP presolver value NONE is applied.", self.opt_sas._log + ) + self.checkSolution() + + def test_solver_intoption(self): + self.run_solver(options={"maxnodes": 20}) + self.checkSolution() + + def test_solver_invalidoption(self): + with self.assertRaisesRegex(ValueError, "syntax error"): + self.run_solver(options={"foo": "bar"}) + + def test_solver_max(self): + X = self.instance.X + self.instance.Obj.set_value(expr=-2 * X[1] + 3 * X[2] + 4 * X[3]) + self.instance.Obj.sense = maximize + self.run_solver() + self.checkSolution() + + def test_solver_infeasible(self): + instance = self.instance + X = instance.X + instance.R4 = Constraint(expr=-2 * X[2] - 3 * X[3] <= -6) + self.run_solver() + results = self.results + self.assertEqual(results.solver.status, SolverStatus.warning) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + self.assertEqual(results.solver.message, "The problem is infeasible.") + + def test_solver_infeasible_or_unbounded(self): + self.instance.X.domain = Integers + self.run_solver() + results = self.results + self.assertEqual(results.solver.status, SolverStatus.warning) + self.assertIn( + results.solver.termination_condition, + [ + TerminationCondition.infeasibleOrUnbounded, + TerminationCondition.unbounded, + ], + ) + self.assertIn( + results.solver.message, + ["The problem is infeasible or unbounded.", "The problem is unbounded."], + ) + + def test_solver_unbounded(self): + self.instance.X.domain = Integers + self.run_solver( + options={"presolver": "none", "rootnode": {"algorithm": "primal"}} + ) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.warning) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.unbounded + ) + self.assertEqual(results.solver.message, "The problem is unbounded.") + + def test_solver_decomp(self): + self.run_solver( + options={ + "decomp": {"hybrid": "off"}, + "decompmaster": {"algorithm": "dual"}, + "decompmasterip": {"presolver": "none"}, + "decompsubprob": {"presolver": "none"}, + } + ) + self.assertIn( + "NOTE: The DECOMP method value DEFAULT is applied.", self.opt_sas._log + ) + self.checkSolution() + + def test_solver_rootnode(self): + self.run_solver(options={"rootnode": {"presolver": "automatic"}}) + self.checkSolution() + + def test_solver_maxnodes(self): + self.run_solver(options={"maxnodes": 0}) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.ok) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.maxEvaluations + ) + self.assertEqual( + results.solver.message, + "The solver reached the maximum number of nodes specified by the MAXNODES= option and found a solution.", + ) + + def test_solver_maxsols(self): + self.run_solver(options={"maxsols": 1}) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.ok) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.maxEvaluations + ) + self.assertEqual( + results.solver.message, + "The solver reached the maximum number of solutions specified by the MAXSOLS= option.", + ) + + def test_solver_target(self): + self.run_solver(options={"target": -6.0}) + results = self.results + self.assertEqual(results.solver.status, SolverStatus.ok) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual( + results.solver.message, + "The solution is not worse than the target specified by the TARGET= option.", + ) + + def test_solver_primalin(self): + X = self.instance.X + X[1] = None + X[2] = 3 + X[3] = 7 + self.run_solver(warmstart=True) + self.checkSolution() + self.assertIn( + "NOTE: The input solution is infeasible or incomplete. Repair heuristics are applied.", + self.opt_sas._log, + ) + + def test_solver_primalin_nosol(self): + X = self.instance.X + X[1] = None + X[2] = None + X[3] = None + self.run_solver(warmstart=True) + self.checkSolution() + + @mock.patch("pyomo.solvers.plugins.solvers.SAS.stat") + def test_solver_large_file(self, os_stat): + os_stat.return_value.st_size = 3 * 1024**3 + self.run_solver() + self.checkSolution() + + def test_solver_with_lp(self): + self.run_solver(options={"with": "lp"}) + self.assertIn( + "contains integer variables; the linear relaxation will be solved.", + self.opt_sas._log, + ) + + def test_solver_warmstart_capable(self): + self.run_solver() + self.assertTrue(self.opt_sas.warm_start_capable()) + + +# @unittest.skipIf(not sas94_available, "The SAS solver is not available") +@unittest.skip("MILP94 tests disabled.") +class SASTestMILP94(SASTestMILP, unittest.TestCase): + pass + + +# @unittest.skipIf(not sascas_available, "The SAS solver is not available") +@unittest.skip("Tests not yet configured for SAS Viya interface.") +class SASTestMILPCAS(SASTestMILP, unittest.TestCase): + solver_io = "_sascas" + session_options = CAS_OPTIONS + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/solvers/tests/checks/test_amplfunc_merge.py b/pyomo/solvers/tests/checks/test_amplfunc_merge.py new file mode 100644 index 00000000000..710cdcc1efb --- /dev/null +++ b/pyomo/solvers/tests/checks/test_amplfunc_merge.py @@ -0,0 +1,162 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +from pyomo.solvers.amplfunc_merge import unique_paths, amplfunc_merge + + +class TestAMPLFUNCStringMerge(unittest.TestCase): + def test_merge_no_dup(self): + s1 = "my/place/l1.so\nanother/place/l1.so" + s2 = "my/place/l2.so" + sm = unique_paths(s1, s2) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 3) + # The order of lines should be maintained with the second string + # following the first + self.assertEqual(sm_list[0], "my/place/l1.so") + self.assertEqual(sm_list[1], "another/place/l1.so") + self.assertEqual(sm_list[2], "my/place/l2.so") + + def test_merge_empty1(self): + s1 = "" + s2 = "my/place/l2.so" + sm = unique_paths(s1, s2) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 1) + self.assertEqual(sm_list[0], "my/place/l2.so") + + def test_merge_empty2(self): + s1 = "my/place/l2.so" + s2 = "" + sm = unique_paths(s1, s2) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 1) + self.assertEqual(sm_list[0], "my/place/l2.so") + + def test_merge_empty_both(self): + s1 = "" + s2 = "" + sm = unique_paths(s1, s2) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 1) + self.assertEqual(sm_list[0], "") + + def test_merge_bad_type(self): + self.assertRaises(AttributeError, unique_paths, "", 3) + self.assertRaises(AttributeError, unique_paths, 3, "") + self.assertRaises(AttributeError, unique_paths, 3, 3) + self.assertRaises(AttributeError, unique_paths, None, "") + self.assertRaises(AttributeError, unique_paths, "", None) + self.assertRaises(AttributeError, unique_paths, 2.3, "") + self.assertRaises(AttributeError, unique_paths, "", 2.3) + + def test_merge_duplicate1(self): + s1 = "my/place/l1.so\nanother/place/l1.so" + s2 = "my/place/l1.so\nanother/place/l1.so" + sm = unique_paths(s1, s2) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 2) + # The order of lines should be maintained with the second string + # following the first + self.assertEqual(sm_list[0], "my/place/l1.so") + self.assertEqual(sm_list[1], "another/place/l1.so") + + def test_merge_duplicate2(self): + s1 = "my/place/l1.so\nanother/place/l1.so" + s2 = "my/place/l1.so" + sm = unique_paths(s1, s2) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 2) + # The order of lines should be maintained with the second string + # following the first + self.assertEqual(sm_list[0], "my/place/l1.so") + self.assertEqual(sm_list[1], "another/place/l1.so") + + def test_merge_extra_linebreaks(self): + s1 = "\nmy/place/l1.so\nanother/place/l1.so\n" + s2 = "\nmy/place/l1.so\n\n" + sm = unique_paths(s1, s2) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 2) + # The order of lines should be maintained with the second string + # following the first + self.assertEqual(sm_list[0], "my/place/l1.so") + self.assertEqual(sm_list[1], "another/place/l1.so") + + +class TestAMPLFUNCMerge(unittest.TestCase): + def test_merge_no_dup(self): + env = { + "AMPLFUNC": "my/place/l1.so\nanother/place/l1.so", + "PYOMO_AMPLFUNC": "my/place/l2.so", + } + sm = amplfunc_merge(env) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 3) + self.assertEqual(sm_list[0], "my/place/l1.so") + self.assertEqual(sm_list[1], "another/place/l1.so") + self.assertEqual(sm_list[2], "my/place/l2.so") + + def test_merge_empty1(self): + env = {"AMPLFUNC": "", "PYOMO_AMPLFUNC": "my/place/l2.so"} + sm = amplfunc_merge(env) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 1) + self.assertEqual(sm_list[0], "my/place/l2.so") + + def test_merge_empty2(self): + env = {"AMPLFUNC": "my/place/l2.so", "PYOMO_AMPLFUNC": ""} + sm = amplfunc_merge(env) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 1) + self.assertEqual(sm_list[0], "my/place/l2.so") + + def test_merge_empty_both(self): + env = {"AMPLFUNC": "", "PYOMO_AMPLFUNC": ""} + sm = amplfunc_merge(env) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 1) + self.assertEqual(sm_list[0], "") + + def test_merge_duplicate1(self): + env = { + "AMPLFUNC": "my/place/l1.so\nanother/place/l1.so", + "PYOMO_AMPLFUNC": "my/place/l1.so\nanother/place/l1.so", + } + sm = amplfunc_merge(env) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 2) + self.assertEqual(sm_list[0], "my/place/l1.so") + self.assertEqual(sm_list[1], "another/place/l1.so") + + def test_merge_no_pyomo(self): + env = {"AMPLFUNC": "my/place/l1.so\nanother/place/l1.so"} + sm = amplfunc_merge(env) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 2) + self.assertEqual(sm_list[0], "my/place/l1.so") + self.assertEqual(sm_list[1], "another/place/l1.so") + + def test_merge_no_user(self): + env = {"PYOMO_AMPLFUNC": "my/place/l1.so\nanother/place/l1.so"} + sm = amplfunc_merge(env) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 2) + self.assertEqual(sm_list[0], "my/place/l1.so") + self.assertEqual(sm_list[1], "another/place/l1.so") + + def test_merge_nothing(self): + env = {} + sm = amplfunc_merge(env) + sm_list = sm.split("\n") + self.assertEqual(len(sm_list), 1) + self.assertEqual(sm_list[0], "") diff --git a/pyomo/solvers/tests/checks/test_cbc.py b/pyomo/solvers/tests/checks/test_cbc.py index 420de7cc61d..9bd864470de 100644 --- a/pyomo/solvers/tests/checks/test_cbc.py +++ b/pyomo/solvers/tests/checks/test_cbc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_cplex.py b/pyomo/solvers/tests/checks/test_cplex.py index ff5ac5f17e1..1e06812f7d3 100644 --- a/pyomo/solvers/tests/checks/test_cplex.py +++ b/pyomo/solvers/tests/checks/test_cplex.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_gurobi.py b/pyomo/solvers/tests/checks/test_gurobi.py index e87685a046c..dfea5520e5b 100644 --- a/pyomo/solvers/tests/checks/test_gurobi.py +++ b/pyomo/solvers/tests/checks/test_gurobi.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,11 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import io import pyomo.common.unittest as unittest from unittest.mock import patch, MagicMock try: - from pyomo.solvers.plugins.solvers.GUROBI_RUN import gurobi_run + from pyomo.solvers.plugins.solvers.GUROBI_RUN import gurobi_run, write_result from gurobipy import GRB gurobipy_available = True @@ -26,11 +27,8 @@ @unittest.skipIf(not gurobipy_available, "gurobipy is not available") class GurobiTest(unittest.TestCase): @unittest.skipIf(not has_worklimit, "gurobi < 9.5") - @patch("builtins.open") @patch("pyomo.solvers.plugins.solvers.GUROBI_RUN.read") - def test_work_limit(self, read: MagicMock, open: MagicMock): - file = MagicMock() - open.return_value = file + def test_work_limit(self, read: MagicMock): model = MagicMock() read.return_value = model @@ -49,8 +47,8 @@ def getAttr(attr): return None model.getAttr = getAttr - gurobi_run(None, None, None, None, {}, []) - self.assertTrue("WorkLimit" in file.write.call_args[0][0]) + result = gurobi_run(None, None, None, {}, []) + self.assertIn("WorkLimit", result['solver']['message']) if __name__ == '__main__': diff --git a/pyomo/solvers/tests/checks/test_gurobi_direct.py b/pyomo/solvers/tests/checks/test_gurobi_direct.py index 1e3a366a37a..9920f80b66c 100644 --- a/pyomo/solvers/tests/checks/test_gurobi_direct.py +++ b/pyomo/solvers/tests/checks/test_gurobi_direct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_gurobi_persistent.py b/pyomo/solvers/tests/checks/test_gurobi_persistent.py index a2c089207e5..052704dd32c 100644 --- a/pyomo/solvers/tests/checks/test_gurobi_persistent.py +++ b/pyomo/solvers/tests/checks/test_gurobi_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -382,7 +382,7 @@ def test_add_column_exceptions(self): # add indexed constraint self.assertRaises(AttributeError, opt.add_column, m, m.y, -2, [m.ci], [1]) - # add something not a _ConstraintData + # add something not a ConstraintData self.assertRaises(AttributeError, opt.add_column, m, m.y, -2, [m.x], [1]) # constraint not on solver model diff --git a/pyomo/solvers/tests/checks/test_ipopt.py b/pyomo/solvers/tests/checks/test_ipopt.py new file mode 100644 index 00000000000..d42399b02bc --- /dev/null +++ b/pyomo/solvers/tests/checks/test_ipopt.py @@ -0,0 +1,42 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common import unittest +from pyomo.solvers.plugins.solvers import IPOPT +import pyomo.environ + +ipopt_available = IPOPT.IPOPT().available() + + +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestIpoptInterface(unittest.TestCase): + def test_has_linear_solver(self): + opt = IPOPT.IPOPT() + self.assertTrue( + any( + map( + opt.has_linear_solver, + [ + 'mumps', + 'ma27', + 'ma57', + 'ma77', + 'ma86', + 'ma97', + 'pardiso', + 'pardisomkl', + 'spral', + 'wsmp', + ], + ) + ) + ) + self.assertFalse(opt.has_linear_solver('bogus_linear_solver')) diff --git a/pyomo/solvers/tests/checks/test_no_solution_behavior.py b/pyomo/solvers/tests/checks/test_no_solution_behavior.py index 81a2d2bf297..c17983bd571 100644 --- a/pyomo/solvers/tests/checks/test_no_solution_behavior.py +++ b/pyomo/solvers/tests/checks/test_no_solution_behavior.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_pickle.py b/pyomo/solvers/tests/checks/test_pickle.py index 745320cb4eb..5a495dd62b8 100644 --- a/pyomo/solvers/tests/checks/test_pickle.py +++ b/pyomo/solvers/tests/checks/test_pickle.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_writers.py b/pyomo/solvers/tests/checks/test_writers.py index 55002c71357..e977ea4ffc8 100644 --- a/pyomo/solvers/tests/checks/test_writers.py +++ b/pyomo/solvers/tests/checks/test_writers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index ddae860cd92..ad066af20e0 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -1,31 +1,37 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import logging import pyomo.common.unittest as unittest -import pyomo.environ as pe +import pyomo.environ as pyo +import pyomo.solvers.plugins.solvers.xpress_direct as xpd + +from pyomo.common.log import LoggingIntercept from pyomo.core.expr.taylor_series import taylor_series_expansion -from pyomo.solvers.plugins.solvers.xpress_direct import xpress_available from pyomo.opt.results.solver import TerminationCondition, SolverStatus +from pyomo.solvers.plugins.solvers.xpress_persistent import XpressPersistent + +xpress_available = pyo.SolverFactory('xpress_persistent').available(False) class TestXpressPersistent(unittest.TestCase): @unittest.skipIf(not xpress_available, "xpress is not available") def test_basics(self): - m = pe.ConcreteModel() - m.x = pe.Var(bounds=(-10, 10)) - m.y = pe.Var() - m.obj = pe.Objective(expr=m.x**2 + m.y**2) - m.c1 = pe.Constraint(expr=m.y >= 2 * m.x + 1) + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y >= 2 * m.x + 1) - opt = pe.SolverFactory('xpress_persistent') + opt = pyo.SolverFactory('xpress_persistent') opt.set_instance(m) self.assertEqual(opt.get_xpress_attribute('cols'), 2) @@ -34,11 +40,24 @@ def test_basics(self): res = opt.solve() self.assertAlmostEqual(m.x.value, -0.4, delta=1e-6) self.assertAlmostEqual(m.y.value, 0.2, delta=1e-6) + opt.load_duals() + self.assertEqual(len(m.dual), 1) self.assertAlmostEqual(m.dual[m.c1], -0.4, delta=1e-6) del m.dual - m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + opt.load_rc() + self.assertEqual(len(m.rc), 2) + self.assertAlmostEqual(m.rc[m.x], 0, delta=1e-8) + self.assertAlmostEqual(m.rc[m.y], 0, delta=1e-8) + del m.rc + + opt.load_slacks() + self.assertEqual(len(m.slack), 1) + self.assertAlmostEqual(m.slack[m.c1], 0, delta=1e-6) + del m.slack + + m.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) opt.add_constraint(m.c2) self.assertEqual(opt.get_xpress_attribute('cols'), 2) self.assertEqual(opt.get_xpress_attribute('rows'), 2) @@ -47,8 +66,8 @@ def test_basics(self): self.assertAlmostEqual(m.x.value, -0.4, delta=1e-6) self.assertAlmostEqual(m.y.value, 0.2, delta=1e-6) opt.load_vars() - self.assertAlmostEqual(m.x.value, 0, delta=1e-6) - self.assertAlmostEqual(m.y.value, 1, delta=2e-6) + self.assertAlmostEqual(m.x.value, 0, delta=2.5e-6) + self.assertAlmostEqual(m.y.value, 1, delta=2.5e-6) opt.remove_constraint(m.c2) m.del_component(m.c2) @@ -92,7 +111,7 @@ def test_basics(self): self.assertEqual(lb[0], -5) self.assertEqual(ub[0], 5) - m.c2 = pe.Constraint(expr=m.y >= m.x**2) + m.c2 = pyo.Constraint(expr=m.y >= m.x**2) opt.add_constraint(m.c2) self.assertEqual(opt.get_xpress_attribute('cols'), 2) self.assertEqual(opt.get_xpress_attribute('rows'), 2) @@ -102,23 +121,48 @@ def test_basics(self): self.assertEqual(opt.get_xpress_attribute('cols'), 2) self.assertEqual(opt.get_xpress_attribute('rows'), 1) - m.z = pe.Var() + m.z = pyo.Var() opt.add_var(m.z) self.assertEqual(opt.get_xpress_attribute('cols'), 3) opt.remove_var(m.z) del m.z self.assertEqual(opt.get_xpress_attribute('cols'), 2) + @unittest.skipIf(not xpress_available, "xpress is not available") + def test_vartype_change(self): + # test for issue #3565 + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 1)) + m.o = pyo.Objective(expr=m.x) + + opt = pyo.SolverFactory('xpress_persistent') + opt.set_instance(m) + + m.x.fix(1) + opt.update_var(m.x) + + x_idx = opt._solver_model.getIndex(opt._pyomo_var_to_solver_var_map[m.x]) + lb = [] + opt._solver_model.getlb(lb, x_idx, x_idx) + self.assertEqual(lb[0], 1) + + m.x.domain = pyo.Binary + opt.update_var(m.x) + + lb = [] + opt._solver_model.getlb(lb, x_idx, x_idx) + self.assertEqual(lb[0], 1) + @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_qconstraint(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c1 = pe.Constraint(expr=m.z >= m.x**2 + m.y**2) - - opt = pe.SolverFactory('xpress_persistent') + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c1 = pyo.Constraint(expr=m.z >= m.x**2 + m.y**2) + + opt = pyo.SolverFactory('xpress_persistent') opt.set_instance(m) self.assertEqual(opt.get_xpress_attribute('rows'), 1) @@ -130,14 +174,14 @@ def test_add_remove_qconstraint(self): @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_lconstraint(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() - m.z = pe.Var() - m.obj = pe.Objective(expr=m.z) - m.c2 = pe.Constraint(expr=m.x + m.y == 1) - - opt = pe.SolverFactory('xpress_persistent') + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.z = pyo.Var() + m.obj = pyo.Objective(expr=m.z) + m.c2 = pyo.Constraint(expr=m.x + m.y == 1) + + opt = pyo.SolverFactory('xpress_persistent') opt.set_instance(m) self.assertEqual(opt.get_xpress_attribute('rows'), 1) @@ -149,14 +193,14 @@ def test_add_remove_lconstraint(self): @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_sosconstraint(self): - m = pe.ConcreteModel() - m.a = pe.Set(initialize=[1, 2, 3], ordered=True) - m.x = pe.Var(m.a, within=pe.Binary) - m.y = pe.Var(within=pe.Binary) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.SOSConstraint(var=m.x, sos=1) - - opt = pe.SolverFactory('xpress_persistent') + m = pyo.ConcreteModel() + m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) + m.x = pyo.Var(m.a, within=pyo.Binary) + m.y = pyo.Var(within=pyo.Binary) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.SOSConstraint(var=m.x, sos=1) + + opt = pyo.SolverFactory('xpress_persistent') opt.set_instance(m) self.assertEqual(opt.get_xpress_attribute('sets'), 1) @@ -168,17 +212,17 @@ def test_add_remove_sosconstraint(self): @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_sosconstraint2(self): - m = pe.ConcreteModel() - m.a = pe.Set(initialize=[1, 2, 3], ordered=True) - m.x = pe.Var(m.a, within=pe.Binary) - m.y = pe.Var(within=pe.Binary) - m.obj = pe.Objective(expr=m.y) - m.c1 = pe.SOSConstraint(var=m.x, sos=1) - - opt = pe.SolverFactory('xpress_persistent') + m = pyo.ConcreteModel() + m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) + m.x = pyo.Var(m.a, within=pyo.Binary) + m.y = pyo.Var(within=pyo.Binary) + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.SOSConstraint(var=m.x, sos=1) + + opt = pyo.SolverFactory('xpress_persistent') opt.set_instance(m) self.assertEqual(opt.get_xpress_attribute('sets'), 1) - m.c2 = pe.SOSConstraint(var=m.x, sos=2) + m.c2 = pyo.SOSConstraint(var=m.x, sos=2) opt.add_sos_constraint(m.c2) self.assertEqual(opt.get_xpress_attribute('sets'), 2) opt.remove_sos_constraint(m.c2) @@ -186,11 +230,11 @@ def test_add_remove_sosconstraint2(self): @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_var(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.y = pe.Var() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() - opt = pe.SolverFactory('xpress_persistent') + opt = pyo.SolverFactory('xpress_persistent') opt.set_instance(m) self.assertEqual(opt.get_xpress_attribute('cols'), 2) @@ -207,17 +251,17 @@ def test_add_remove_var(self): @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_column(self): - m = pe.ConcreteModel() - m.x = pe.Var(within=pe.NonNegativeReals) - m.c = pe.Constraint(expr=(0, m.x, 1)) - m.obj = pe.Objective(expr=-m.x) + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.NonNegativeReals) + m.c = pyo.Constraint(expr=(0, m.x, 1)) + m.obj = pyo.Objective(expr=-m.x) - opt = pe.SolverFactory('xpress_persistent') + opt = pyo.SolverFactory('xpress_persistent') opt.set_instance(m) opt.solve() self.assertAlmostEqual(m.x.value, 1) - m.y = pe.Var(within=pe.NonNegativeReals) + m.y = pyo.Var(within=pyo.NonNegativeReals) opt.add_column(m, m.y, -3, [m.c], [2]) opt.solve() @@ -227,42 +271,42 @@ def test_add_column(self): @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_column_exceptions(self): - m = pe.ConcreteModel() - m.x = pe.Var() - m.c = pe.Constraint(expr=(0, m.x, 1)) - m.ci = pe.Constraint([1, 2], rule=lambda m, i: (0, m.x, i + 1)) - m.cd = pe.Constraint(expr=(0, -m.x, 1)) + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.c = pyo.Constraint(expr=(0, m.x, 1)) + m.ci = pyo.Constraint([1, 2], rule=lambda m, i: (0, m.x, i + 1)) + m.cd = pyo.Constraint(expr=(0, -m.x, 1)) m.cd.deactivate() - m.obj = pe.Objective(expr=-m.x) + m.obj = pyo.Objective(expr=-m.x) - opt = pe.SolverFactory('xpress_persistent') + opt = pyo.SolverFactory('xpress_persistent') # set_instance not called self.assertRaises(RuntimeError, opt.add_column, m, m.x, 0, [m.c], [1]) opt.set_instance(m) - m2 = pe.ConcreteModel() - m2.y = pe.Var() - m2.c = pe.Constraint(expr=(0, m.x, 1)) + m2 = pyo.ConcreteModel() + m2.y = pyo.Var() + m2.c = pyo.Constraint(expr=(0, m.x, 1)) # different model than attached to opt self.assertRaises(RuntimeError, opt.add_column, m2, m2.y, 0, [], []) # pyomo var attached to different model self.assertRaises(RuntimeError, opt.add_column, m, m2.y, 0, [], []) - z = pe.Var() + z = pyo.Var() # pyomo var floating self.assertRaises(RuntimeError, opt.add_column, m, z, -2, [m.c, z], [1]) - m.y = pe.Var() + m.y = pyo.Var() # len(coefficients) == len(constraints) self.assertRaises(RuntimeError, opt.add_column, m, m.y, -2, [m.c], [1, 2]) self.assertRaises(RuntimeError, opt.add_column, m, m.y, -2, [m.c, z], [1]) # add indexed constraint self.assertRaises(AttributeError, opt.add_column, m, m.y, -2, [m.ci], [1]) - # add something not a _ConstraintData + # add something not a ConstraintData self.assertRaises(AttributeError, opt.add_column, m, m.y, -2, [m.x], [1]) # constraint not on solver model @@ -279,18 +323,21 @@ def test_add_column_exceptions(self): def test_nonconvexqp_locally_optimal(self): """Test non-convex QP for which xpress_direct should find a locally optimal solution.""" - m = pe.ConcreteModel() - m.x1 = pe.Var() - m.x2 = pe.Var() - m.x3 = pe.Var() + m = pyo.ConcreteModel() + m.x1 = pyo.Var() + m.x2 = pyo.Var() + m.x3 = pyo.Var() - m.obj = pe.Objective(rule=lambda m: 2 * m.x1 + m.x2 + m.x3, sense=pe.minimize) - m.equ1 = pe.Constraint(rule=lambda m: m.x1 + m.x2 + m.x3 == 1) - m.cone = pe.Constraint(rule=lambda m: m.x2 * m.x2 + m.x3 * m.x3 <= m.x1 * m.x1) - m.equ2 = pe.Constraint(rule=lambda m: m.x1 >= 0) + m.obj = pyo.Objective(rule=lambda m: 2 * m.x1 + m.x2 + m.x3, sense=pyo.minimize) + m.equ1 = pyo.Constraint(rule=lambda m: m.x1 + m.x2 + m.x3 == 1) + m.cone = pyo.Constraint(rule=lambda m: m.x2 * m.x2 + m.x3 * m.x3 <= m.x1 * m.x1) + m.equ2 = pyo.Constraint(rule=lambda m: m.x1 >= 0) - opt = pe.SolverFactory('xpress_direct') + opt = pyo.SolverFactory('xpress_direct') opt.options['XSLP_SOLVER'] = 0 + # xpress 9.5.0 now defaults to trying (and failing) to solve this problem + # using the global solver. This option forces the use of the local solver. + opt.options['NLPSOLVER'] = 1 results = opt.solve(m) self.assertEqual(results.solver.status, SolverStatus.ok) @@ -307,18 +354,18 @@ def test_nonconvexqp_locally_optimal(self): @unittest.skipIf(not xpress_available, "xpress is not available") def test_nonconvexqp_infeasible(self): """Test non-convex QP which xpress_direct should prove infeasible.""" - m = pe.ConcreteModel() - m.x1 = pe.Var() - m.x2 = pe.Var() - m.x3 = pe.Var() - - m.obj = pe.Objective(rule=lambda m: 2 * m.x1 + m.x2 + m.x3, sense=pe.minimize) - m.equ1a = pe.Constraint(rule=lambda m: m.x1 + m.x2 + m.x3 == 1) - m.equ1b = pe.Constraint(rule=lambda m: m.x1 + m.x2 + m.x3 == -1) - m.cone = pe.Constraint(rule=lambda m: m.x2 * m.x2 + m.x3 * m.x3 <= m.x1 * m.x1) - m.equ2 = pe.Constraint(rule=lambda m: m.x1 >= 0) - - opt = pe.SolverFactory('xpress_direct') + m = pyo.ConcreteModel() + m.x1 = pyo.Var() + m.x2 = pyo.Var() + m.x3 = pyo.Var() + + m.obj = pyo.Objective(rule=lambda m: 2 * m.x1 + m.x2 + m.x3, sense=pyo.minimize) + m.equ1a = pyo.Constraint(rule=lambda m: m.x1 + m.x2 + m.x3 == 1) + m.equ1b = pyo.Constraint(rule=lambda m: m.x1 + m.x2 + m.x3 == -1) + m.cone = pyo.Constraint(rule=lambda m: m.x2 * m.x2 + m.x3 * m.x3 <= m.x1 * m.x1) + m.equ2 = pyo.Constraint(rule=lambda m: m.x1 >= 0) + + opt = pyo.SolverFactory('xpress_direct') opt.options['XSLP_SOLVER'] = 0 results = opt.solve(m) @@ -326,3 +373,52 @@ def test_nonconvexqp_infeasible(self): self.assertEqual( results.solver.termination_condition, TerminationCondition.infeasible ) + + def test_available(self): + class mock_xpress(object): + def __init__(self, importable, initable): + self._initable = initable + xpd.xpress_available = importable + + def log_import_warning(self, logger): + logging.getLogger(logger).warning("import warning") + + def init(self): + if not self._initable: + raise RuntimeError("init failed") + + def free(self): + pass + + orig = xpd.xpress, xpd.xpress_available + try: + _xpress_persistent = XpressPersistent + xpd.xpress = mock_xpress(True, True) + with LoggingIntercept() as LOG: + self.assertTrue(XpressPersistent().available(True)) + self.assertTrue(XpressPersistent().available(False)) + self.assertEqual(LOG.getvalue(), "") + + xpd.xpress = mock_xpress(False, False) + with LoggingIntercept() as LOG: + self.assertFalse(XpressPersistent().available(False)) + self.assertEqual(LOG.getvalue(), "") + with LoggingIntercept() as LOG: + with self.assertRaisesRegex( + xpd.ApplicationError, + "No Python bindings available for .*XpressPersistent.* " + "solver plugin", + ): + XpressPersistent().available(True) + self.assertEqual(LOG.getvalue(), "import warning\n") + + xpd.xpress = mock_xpress(True, False) + with LoggingIntercept() as LOG: + self.assertFalse(XpressPersistent().available(False)) + self.assertEqual(LOG.getvalue(), "") + with LoggingIntercept() as LOG: + with self.assertRaisesRegex(RuntimeError, "init failed"): + XpressPersistent().available(True) + self.assertEqual(LOG.getvalue(), "") + finally: + xpd.xpress, xpd.xpress_available = orig diff --git a/pyomo/solvers/tests/mip/__init__.py b/pyomo/solvers/tests/mip/__init__.py index 707a8c4b7e5..55e69ce5ff2 100644 --- a/pyomo/solvers/tests/mip/__init__.py +++ b/pyomo/solvers/tests/mip/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/mip/model.py b/pyomo/solvers/tests/mip/model.py index 83c1411fe6c..acd519baaac 100644 --- a/pyomo/solvers/tests/mip/model.py +++ b/pyomo/solvers/tests/mip/model.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/mip/test_asl.py b/pyomo/solvers/tests/mip/test_asl.py index 6f23a06eff2..34228a534b9 100644 --- a/pyomo/solvers/tests/mip/test_asl.py +++ b/pyomo/solvers/tests/mip/test_asl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/mip/test_convert.py b/pyomo/solvers/tests/mip/test_convert.py index 962b021c4ae..504ef8b57a8 100644 --- a/pyomo/solvers/tests/mip/test_convert.py +++ b/pyomo/solvers/tests/mip/test_convert.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/mip/test_factory.py b/pyomo/solvers/tests/mip/test_factory.py index 31d47486aa4..a31d51e698b 100644 --- a/pyomo/solvers/tests/mip/test_factory.py +++ b/pyomo/solvers/tests/mip/test_factory.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -53,8 +53,8 @@ def setUpClass(cls): def tearDown(self): ReaderFactory.unregister('rtest3') - ReaderFactory.unregister('stest3') - ReaderFactory.unregister('wtest3') + SolverFactory.unregister('stest3') + WriterFactory.unregister('wtest3') def test_solver_factory(self): """ @@ -119,6 +119,9 @@ def test_writer_instance(self): ans = WriterFactory("none") self.assertEqual(ans, None) ans = WriterFactory("wtest3") + self.assertEqual(ans, None) + WriterFactory.register('wtest3')(MockWriter) + ans = WriterFactory("wtest3") self.assertNotEqual(ans, None) def test_writer_registration(self): diff --git a/pyomo/solvers/tests/mip/test_ipopt.py b/pyomo/solvers/tests/mip/test_ipopt.py index 38c3b35d8a1..30c233772e3 100644 --- a/pyomo/solvers/tests/mip/test_ipopt.py +++ b/pyomo/solvers/tests/mip/test_ipopt.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/mip/test_mip.yml b/pyomo/solvers/tests/mip/test_mip.yml deleted file mode 100644 index b3d1610b581..00000000000 --- a/pyomo/solvers/tests/mip/test_mip.yml +++ /dev/null @@ -1,103 +0,0 @@ -python: - - import pyomo.opt - -driver: pyomo.mip - -solvers: - glpk: - _mock_glpk: - cplex: - _mock_cplex: - cbc: - _mock_cbc: - gurobi: - asl_gurobi: - name: 'asl:gurobi_ampl' - -problems: - test1: - files: test1.mps - test2: - files: test2.lp - test4: - files: test4.nl - -suites: - - glpk: - categories: - - nightly - - smoke - - glpk - solvers: - glpk: - _mock_glpk: - problems: - test1: - baseline: test1_glpk.txt - test2: - baseline: test2_glpk.txt - test4: - baseline: test4_glpk.txt - use_pico_convert: True - - cplex: - categories: - - nightly - - smoke - - cplex - solvers: - cplex: - _mock_cplex: - problems: - test1: - baseline: test1_cplex.txt - test2: - baseline: test2_cplex.txt - test4: - baseline: test4_cplex.txt - - gurobi: - categories: - - nightly - - smoke - - gurobi - solvers: - gurobi: - problems: - test1: - baseline: test1_gurobi.txt - test2: - baseline: test2_gurobi.txt - test4: - baseline: test4_gurobi.txt - use_pico_convert: True - - asl: - categories: - - asl - solvers: - asl_gurobi: - problems: - test4: - baseline: test4_gurobi.txt - results_format: sol - - cbc: - categories: - - nightly - - smoke - - cbc - solvers: - cbc: - _mock_cbc: - problems: - test1: - baseline: test1_cbc.txt - test2: - baseline: test2_cbc.txt - test4: - baseline: test4_cbc.txt - use_pico_convert: True - results_format: sol - diff --git a/pyomo/solvers/tests/mip/test_qp.py b/pyomo/solvers/tests/mip/test_qp.py index 9c5cb5ffbc4..c61e3e6c849 100644 --- a/pyomo/solvers/tests/mip/test_qp.py +++ b/pyomo/solvers/tests/mip/test_qp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -53,7 +53,8 @@ def _qp_model(self): return m @unittest.skipUnless( - gurobi_lp.available(exception_flag=False), "needs Gurobi LP interface" + gurobi_lp.available(exception_flag=False) and gurobi_lp.license_is_valid(), + "needs Gurobi LP interface", ) def test_qp_objective_gurobi_lp(self): m = self._qp_model() @@ -61,7 +62,8 @@ def test_qp_objective_gurobi_lp(self): self.assertEqual(m.obj(), results['Problem'][0]['Upper bound']) @unittest.skipUnless( - gurobi_nl.available(exception_flag=False), "needs Gurobi NL interface" + gurobi_nl.available(exception_flag=False) and gurobi_nl.license_is_valid(), + "needs Gurobi NL interface", ) def test_qp_objective_gurobi_nl(self): m = self._qp_model() diff --git a/pyomo/solvers/tests/mip/test_scip.py b/pyomo/solvers/tests/mip/test_scip.py index 01de0d16826..2c3abd77125 100644 --- a/pyomo/solvers/tests/mip/test_scip.py +++ b/pyomo/solvers/tests/mip/test_scip.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -106,6 +106,12 @@ def test_scip_solve_from_instance_options(self): results.write(filename=_out, times=False, format='json') self.compare_json(_out, join(currdir, "test_scip_solve_from_instance.baseline")) + def test_scip_solve_from_instance_with_reoptimization(self): + # Test scip with re-optimization option enabled + # This case changes the Scip output results which may break the results parser + self.scip.options['reoptimization/enable'] = True + self.test_scip_solve_from_instance() + if __name__ == "__main__": deleteFiles = False diff --git a/pyomo/solvers/tests/mip/test_scip_log_data.py b/pyomo/solvers/tests/mip/test_scip_log_data.py index a0006d69eb7..3fe7635a7c1 100644 --- a/pyomo/solvers/tests/mip/test_scip_log_data.py +++ b/pyomo/solvers/tests/mip/test_scip_log_data.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/mip/test_scip_solve_from_instance.baseline b/pyomo/solvers/tests/mip/test_scip_solve_from_instance.baseline index a3eb9ffacec..976e4a1b82e 100644 --- a/pyomo/solvers/tests/mip/test_scip_solve_from_instance.baseline +++ b/pyomo/solvers/tests/mip/test_scip_solve_from_instance.baseline @@ -1,7 +1,7 @@ { "Problem": [ { - "Lower bound": -Infinity, + "Lower bound": 1.0, "Number of constraints": 0, "Number of objectives": 1, "Number of variables": 1, diff --git a/pyomo/solvers/tests/mip/test_scip_version.py b/pyomo/solvers/tests/mip/test_scip_version.py index f83bed2da32..0030d871b2e 100644 --- a/pyomo/solvers/tests/mip/test_scip_version.py +++ b/pyomo/solvers/tests/mip/test_scip_version.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/mip/test_solver.py b/pyomo/solvers/tests/mip/test_solver.py index bf3550a001d..e463dfa6c9c 100644 --- a/pyomo/solvers/tests/mip/test_solver.py +++ b/pyomo/solvers/tests/mip/test_solver.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_block.py b/pyomo/solvers/tests/models/LP_block.py index 37b01dc1c2d..e72daec0eb3 100644 --- a/pyomo/solvers/tests/models/LP_block.py +++ b/pyomo/solvers/tests/models/LP_block.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_compiled.py b/pyomo/solvers/tests/models/LP_compiled.py index 960b8730e0c..6ef795bad26 100644 --- a/pyomo/solvers/tests/models/LP_compiled.py +++ b/pyomo/solvers/tests/models/LP_compiled.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_constant_objective1.py b/pyomo/solvers/tests/models/LP_constant_objective1.py index 0c01cd7085f..8c7d46ae3d6 100644 --- a/pyomo/solvers/tests/models/LP_constant_objective1.py +++ b/pyomo/solvers/tests/models/LP_constant_objective1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_constant_objective2.py b/pyomo/solvers/tests/models/LP_constant_objective2.py index 07739c1f708..580643c9c84 100644 --- a/pyomo/solvers/tests/models/LP_constant_objective2.py +++ b/pyomo/solvers/tests/models/LP_constant_objective2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_duals_maximize.py b/pyomo/solvers/tests/models/LP_duals_maximize.py index ed45e4eee29..c39661bde7b 100644 --- a/pyomo/solvers/tests/models/LP_duals_maximize.py +++ b/pyomo/solvers/tests/models/LP_duals_maximize.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_duals_minimize.py b/pyomo/solvers/tests/models/LP_duals_minimize.py index 3f97276a61e..e9e8ec9e214 100644 --- a/pyomo/solvers/tests/models/LP_duals_minimize.py +++ b/pyomo/solvers/tests/models/LP_duals_minimize.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_inactive_index.py b/pyomo/solvers/tests/models/LP_inactive_index.py index 5e2b570a1e8..95224261699 100644 --- a/pyomo/solvers/tests/models/LP_inactive_index.py +++ b/pyomo/solvers/tests/models/LP_inactive_index.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_infeasible1.py b/pyomo/solvers/tests/models/LP_infeasible1.py index 8cba441a6c3..591c5ca795f 100644 --- a/pyomo/solvers/tests/models/LP_infeasible1.py +++ b/pyomo/solvers/tests/models/LP_infeasible1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_infeasible2.py b/pyomo/solvers/tests/models/LP_infeasible2.py index 7f417d9145c..e1dd31b00f8 100644 --- a/pyomo/solvers/tests/models/LP_infeasible2.py +++ b/pyomo/solvers/tests/models/LP_infeasible2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_piecewise.py b/pyomo/solvers/tests/models/LP_piecewise.py index 22ee9d08694..8d79cf392a5 100644 --- a/pyomo/solvers/tests/models/LP_piecewise.py +++ b/pyomo/solvers/tests/models/LP_piecewise.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_simple.py b/pyomo/solvers/tests/models/LP_simple.py index 4f1e6dcbc7e..2d489674ed0 100644 --- a/pyomo/solvers/tests/models/LP_simple.py +++ b/pyomo/solvers/tests/models/LP_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_trivial_constraints.py b/pyomo/solvers/tests/models/LP_trivial_constraints.py index 3958f2b4493..d9eb83c8760 100644 --- a/pyomo/solvers/tests/models/LP_trivial_constraints.py +++ b/pyomo/solvers/tests/models/LP_trivial_constraints.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_unbounded.py b/pyomo/solvers/tests/models/LP_unbounded.py index e75977c40ba..1f6095b59db 100644 --- a/pyomo/solvers/tests/models/LP_unbounded.py +++ b/pyomo/solvers/tests/models/LP_unbounded.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -46,19 +46,17 @@ def warmstart_model(self): model.y.value = None def post_solve_test_validation(self, tester, results): + outcomes = [ + TerminationCondition.unbounded, + TerminationCondition.infeasibleOrUnbounded, + ] + if '_gams_' in str(tester): + # GAMS maps CPLEX's InfeasibleOrUnbounded to Infeasible + outcomes.append(TerminationCondition.infeasible) if tester is None: - assert results['Solver'][0]['termination condition'] in ( - TerminationCondition.unbounded, - TerminationCondition.infeasibleOrUnbounded, - ) + assert results['Solver'][0]['termination condition'] in outcomes else: - tester.assertIn( - results['Solver'][0]['termination condition'], - ( - TerminationCondition.unbounded, - TerminationCondition.infeasibleOrUnbounded, - ), - ) + tester.assertIn(results['Solver'][0]['termination condition'], outcomes) @register_model diff --git a/pyomo/solvers/tests/models/LP_unique_duals.py b/pyomo/solvers/tests/models/LP_unique_duals.py index f5a4df6338d..c9b44365ba3 100644 --- a/pyomo/solvers/tests/models/LP_unique_duals.py +++ b/pyomo/solvers/tests/models/LP_unique_duals.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/LP_unused_vars.py b/pyomo/solvers/tests/models/LP_unused_vars.py index 0062fc58463..e43bdc5d951 100644 --- a/pyomo/solvers/tests/models/LP_unused_vars.py +++ b/pyomo/solvers/tests/models/LP_unused_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/MILP_discrete_var_bounds.py b/pyomo/solvers/tests/models/MILP_discrete_var_bounds.py index 22876a7a291..2d093b7bd4a 100644 --- a/pyomo/solvers/tests/models/MILP_discrete_var_bounds.py +++ b/pyomo/solvers/tests/models/MILP_discrete_var_bounds.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/MILP_infeasible1.py b/pyomo/solvers/tests/models/MILP_infeasible1.py index e95fef92744..a023ec602ac 100644 --- a/pyomo/solvers/tests/models/MILP_infeasible1.py +++ b/pyomo/solvers/tests/models/MILP_infeasible1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/MILP_simple.py b/pyomo/solvers/tests/models/MILP_simple.py index 488c7841024..f2793a14722 100644 --- a/pyomo/solvers/tests/models/MILP_simple.py +++ b/pyomo/solvers/tests/models/MILP_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/MILP_unbounded.py b/pyomo/solvers/tests/models/MILP_unbounded.py index c5a166a6141..08e78ba0563 100644 --- a/pyomo/solvers/tests/models/MILP_unbounded.py +++ b/pyomo/solvers/tests/models/MILP_unbounded.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/MILP_unused_vars.py b/pyomo/solvers/tests/models/MILP_unused_vars.py index b6e06c8db0c..9ea53b4a6e9 100644 --- a/pyomo/solvers/tests/models/MILP_unused_vars.py +++ b/pyomo/solvers/tests/models/MILP_unused_vars.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/MIQCP_simple.py b/pyomo/solvers/tests/models/MIQCP_simple.py index 5946e83fadb..06da05e65c2 100644 --- a/pyomo/solvers/tests/models/MIQCP_simple.py +++ b/pyomo/solvers/tests/models/MIQCP_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/MIQP_simple.py b/pyomo/solvers/tests/models/MIQP_simple.py index 6922d6be97d..f8a890c8e80 100644 --- a/pyomo/solvers/tests/models/MIQP_simple.py +++ b/pyomo/solvers/tests/models/MIQP_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/QCP_simple.py b/pyomo/solvers/tests/models/QCP_simple.py index 5f4311a3ab9..e1766e5c142 100644 --- a/pyomo/solvers/tests/models/QCP_simple.py +++ b/pyomo/solvers/tests/models/QCP_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/QP_constant_objective.py b/pyomo/solvers/tests/models/QP_constant_objective.py index 6ea34b69f51..c3df588a336 100644 --- a/pyomo/solvers/tests/models/QP_constant_objective.py +++ b/pyomo/solvers/tests/models/QP_constant_objective.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/QP_simple.py b/pyomo/solvers/tests/models/QP_simple.py index c5f4f40c576..1d114570c89 100644 --- a/pyomo/solvers/tests/models/QP_simple.py +++ b/pyomo/solvers/tests/models/QP_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/SOS1_simple.py b/pyomo/solvers/tests/models/SOS1_simple.py index ba3c89e680b..db964218641 100644 --- a/pyomo/solvers/tests/models/SOS1_simple.py +++ b/pyomo/solvers/tests/models/SOS1_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/SOS2_simple.py b/pyomo/solvers/tests/models/SOS2_simple.py index 2062611f8cf..b9ceb07a21c 100644 --- a/pyomo/solvers/tests/models/SOS2_simple.py +++ b/pyomo/solvers/tests/models/SOS2_simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/models/__init__.py b/pyomo/solvers/tests/models/__init__.py index 46a1c96936d..298ea21762c 100644 --- a/pyomo/solvers/tests/models/__init__.py +++ b/pyomo/solvers/tests/models/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,40 +9,34 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.solvers.tests.models.base - -import pyomo.solvers.tests.models.LP_block -import pyomo.solvers.tests.models.LP_compiled -import pyomo.solvers.tests.models.LP_constant_objective1 -import pyomo.solvers.tests.models.LP_constant_objective2 -import pyomo.solvers.tests.models.LP_duals_maximize -import pyomo.solvers.tests.models.LP_duals_minimize -import pyomo.solvers.tests.models.LP_inactive_index -import pyomo.solvers.tests.models.LP_infeasible1 -import pyomo.solvers.tests.models.LP_infeasible2 -import pyomo.solvers.tests.models.LP_piecewise -import pyomo.solvers.tests.models.LP_simple -import pyomo.solvers.tests.models.LP_trivial_constraints -import pyomo.solvers.tests.models.LP_unbounded -import pyomo.solvers.tests.models.LP_unused_vars - -# WEH - Omitting this for because it's not reliably solved by ipopt -# import pyomo.solvers.tests.models.LP_unique_duals - -import pyomo.solvers.tests.models.MILP_discrete_var_bounds -import pyomo.solvers.tests.models.MILP_infeasible1 -import pyomo.solvers.tests.models.MILP_simple -import pyomo.solvers.tests.models.MILP_unbounded -import pyomo.solvers.tests.models.MILP_unused_vars - -import pyomo.solvers.tests.models.MIQCP_simple - -import pyomo.solvers.tests.models.MIQP_simple - -import pyomo.solvers.tests.models.QCP_simple - -import pyomo.solvers.tests.models.QP_constant_objective -import pyomo.solvers.tests.models.QP_simple - -import pyomo.solvers.tests.models.SOS1_simple -import pyomo.solvers.tests.models.SOS2_simple +from pyomo.solvers.tests.models import ( + base, + LP_block, + LP_compiled, + LP_constant_objective1, + LP_constant_objective2, + LP_duals_maximize, + LP_duals_minimize, + LP_inactive_index, + LP_infeasible1, + LP_infeasible2, + LP_piecewise, + LP_simple, + LP_trivial_constraints, + LP_unbounded, + LP_unused_vars, + # WEH - Omitting this for because it's not reliably solved by ipopt, + # LP_unique_duals, + MILP_discrete_var_bounds, + MILP_infeasible1, + MILP_simple, + MILP_unbounded, + MILP_unused_vars, + MIQCP_simple, + MIQP_simple, + QCP_simple, + QP_constant_objective, + QP_simple, + SOS1_simple, + SOS2_simple, +) diff --git a/pyomo/solvers/tests/models/base.py b/pyomo/solvers/tests/models/base.py index 25442611806..ede4395cdc6 100644 --- a/pyomo/solvers/tests/models/base.py +++ b/pyomo/solvers/tests/models/base.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/__init__.py b/pyomo/solvers/tests/piecewise_linear/__init__.py index 79b33f0d427..b6eaa37f6fe 100644 --- a/pyomo/solvers/tests/piecewise_linear/__init__.py +++ b/pyomo/solvers/tests/piecewise_linear/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/kernel_problems/concave_var.py b/pyomo/solvers/tests/piecewise_linear/kernel_problems/concave_var.py index 45270d7dc34..0409d93a2cc 100644 --- a/pyomo/solvers/tests/piecewise_linear/kernel_problems/concave_var.py +++ b/pyomo/solvers/tests/piecewise_linear/kernel_problems/concave_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/kernel_problems/convex_var.py b/pyomo/solvers/tests/piecewise_linear/kernel_problems/convex_var.py index cf28dc044eb..e0581d9725f 100644 --- a/pyomo/solvers/tests/piecewise_linear/kernel_problems/convex_var.py +++ b/pyomo/solvers/tests/piecewise_linear/kernel_problems/convex_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/kernel_problems/piecewise_var.py b/pyomo/solvers/tests/piecewise_linear/kernel_problems/piecewise_var.py index cadbff305e8..715527418a7 100644 --- a/pyomo/solvers/tests/piecewise_linear/kernel_problems/piecewise_var.py +++ b/pyomo/solvers/tests/piecewise_linear/kernel_problems/piecewise_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/kernel_problems/step_var.py b/pyomo/solvers/tests/piecewise_linear/kernel_problems/step_var.py index 1e6e418acf0..8f6d8307ee5 100644 --- a/pyomo/solvers/tests/piecewise_linear/kernel_problems/step_var.py +++ b/pyomo/solvers/tests/piecewise_linear/kernel_problems/step_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/problems/concave_multi_vararray1.py b/pyomo/solvers/tests/piecewise_linear/problems/concave_multi_vararray1.py index 473b3328660..57700473121 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/concave_multi_vararray1.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/concave_multi_vararray1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ | 7x+12 , -4 <= x <= -3 | 5x+6 , -3 <= x <= -2 | 3x+2 , -2 <= x <= -1 -f(x) = | 1 , -1 <= x <= 1 +f(x) = | 1 , -1 <= x <= 1 | -3x+2 , 1 <= x <= 2 | -5x+6 , 2 <= x <= 3 \ -7x+12, 3 <= x <= 4 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/concave_multi_vararray2.py b/pyomo/solvers/tests/piecewise_linear/problems/concave_multi_vararray2.py index e6b57a4b652..17397636bfc 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/concave_multi_vararray2.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/concave_multi_vararray2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ | 7x+12 , -4 <= x <= -3 | 5x+6 , -3 <= x <= -2 | 3x+2 , -2 <= x <= -1 -f(x) = | 1 , -1 <= x <= 1 +f(x) = | 1 , -1 <= x <= 1 | -3x+2 , 1 <= x <= 2 | -5x+6 , 2 <= x <= 3 \ -7x+12, 3 <= x <= 4 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/concave_var.py b/pyomo/solvers/tests/piecewise_linear/problems/concave_var.py index b225cee4f87..a8563b3bc9d 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/concave_var.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/concave_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ | 7x+12 , -4 <= x <= -3 | 5x+6 , -3 <= x <= -2 | 3x+2 , -2 <= x <= -1 -f(x) = | 1 , -1 <= x <= 1 +f(x) = | 1 , -1 <= x <= 1 | -3x+2 , 1 <= x <= 2 | -5x+6 , 2 <= x <= 3 \ -7x+12, 3 <= x <= 4 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/concave_vararray.py b/pyomo/solvers/tests/piecewise_linear/problems/concave_vararray.py index 727fc33ed80..bc9e21cbf7c 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/concave_vararray.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/concave_vararray.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ | 7x+12 , -4 <= x <= -3 | 5x+6 , -3 <= x <= -2 | 3x+2 , -2 <= x <= -1 -f(x) = | 1 , -1 <= x <= 1 +f(x) = | 1 , -1 <= x <= 1 | -3x+2 , 1 <= x <= 2 | -5x+6 , 2 <= x <= 3 \ -7x+12, 3 <= x <= 4 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/convex_multi_vararray1.py b/pyomo/solvers/tests/piecewise_linear/problems/convex_multi_vararray1.py index 98f369b8c45..7fe17700911 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/convex_multi_vararray1.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/convex_multi_vararray1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ | -7x-12, -4 <= x <= -3 | -5x-6 , -3 <= x <= -2 | -3x-2 , -2 <= x <= -1 -f(x) = | 1 , -1 <= x <= 1 +f(x) = | 1 , -1 <= x <= 1 | 3x-2 , 1 <= x <= 2 | 5x-6 , 2 <= x <= 3 \ 7x-12 , 3 <= x <= 4 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/convex_multi_vararray2.py b/pyomo/solvers/tests/piecewise_linear/problems/convex_multi_vararray2.py index c877bb6b72b..ea9c96d2c0e 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/convex_multi_vararray2.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/convex_multi_vararray2.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ | -7x-12, -4 <= x <= -3 | -5x-6 , -3 <= x <= -2 | -3x-2 , -2 <= x <= -1 -f(x) = | 1 , -1 <= x <= 1 +f(x) = | 1 , -1 <= x <= 1 | 3x-2 , 1 <= x <= 2 | 5x-6 , 2 <= x <= 3 \ 7x-12 , 3 <= x <= 4 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/convex_var.py b/pyomo/solvers/tests/piecewise_linear/problems/convex_var.py index 842ef50515b..6040cc1fb23 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/convex_var.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/convex_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ | -7x-12, -4 <= x <= -3 | -5x-6 , -3 <= x <= -2 | -3x-2 , -2 <= x <= -1 -f(x) = | 1 , -1 <= x <= 1 +f(x) = | 1 , -1 <= x <= 1 | 3x-2 , 1 <= x <= 2 | 5x-6 , 2 <= x <= 3 \ 7x-12 , 3 <= x <= 4 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/convex_vararray.py b/pyomo/solvers/tests/piecewise_linear/problems/convex_vararray.py index 087d0977ee0..654cb54863f 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/convex_vararray.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/convex_vararray.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,7 +14,7 @@ | -7x-12, -4 <= x <= -3 | -5x-6 , -3 <= x <= -2 | -3x-2 , -2 <= x <= -1 -f(x) = | 1 , -1 <= x <= 1 +f(x) = | 1 , -1 <= x <= 1 | 3x-2 , 1 <= x <= 2 | 5x-6 , 2 <= x <= 3 \ 7x-12 , 3 <= x <= 4 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/piecewise_multi_vararray.py b/pyomo/solvers/tests/piecewise_linear/problems/piecewise_multi_vararray.py index 56452e0cd19..508c049631e 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/piecewise_multi_vararray.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/piecewise_multi_vararray.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/problems/piecewise_var.py b/pyomo/solvers/tests/piecewise_linear/problems/piecewise_var.py index 60c45a69e80..87da443ad8b 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/piecewise_var.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/piecewise_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/problems/piecewise_vararray.py b/pyomo/solvers/tests/piecewise_linear/problems/piecewise_vararray.py index 9e53edb0c93..29fd7b92a29 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/piecewise_vararray.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/piecewise_vararray.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/problems/step_var.py b/pyomo/solvers/tests/piecewise_linear/problems/step_var.py index 59cefdd39c9..8086da9e0f3 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/step_var.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/step_var.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/problems/step_vararray.py b/pyomo/solvers/tests/piecewise_linear/problems/step_vararray.py index e4853e666d6..a41d4b78082 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/step_vararray.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/step_vararray.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/problems/tester.py b/pyomo/solvers/tests/piecewise_linear/problems/tester.py index 56261f7cc38..701b3a65d3b 100644 --- a/pyomo/solvers/tests/piecewise_linear/problems/tester.py +++ b/pyomo/solvers/tests/piecewise_linear/problems/tester.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/test_examples.py b/pyomo/solvers/tests/piecewise_linear/test_examples.py index 3454f62d56b..6d72f02819a 100644 --- a/pyomo/solvers/tests/piecewise_linear/test_examples.py +++ b/pyomo/solvers/tests/piecewise_linear/test_examples.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear.py b/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear.py index 48472c2dabf..ed9bf83395e 100644 --- a/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear.py +++ b/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear_kernel.py b/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear_kernel.py index 20addb2b1eb..f449d824295 100644 --- a/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear_kernel.py +++ b/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear_kernel.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index 918a801ae37..60a3f36857b 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/solvers/tests/testcases.py b/pyomo/solvers/tests/testcases.py index 6bef40818d9..3003ca94a87 100644 --- a/pyomo/solvers/tests/testcases.py +++ b/pyomo/solvers/tests/testcases.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -285,8 +285,17 @@ # # KNITROAMPL # -# NO EXPECTED FAILURES -# +for prob in ('LP_trivial_constraints', 'LP_trivial_constraints_kernel'): + ExpectedFailures['knitroampl', 'nl', prob] = ( + lambda v: True, + 'Knitro does not consider tight trivial constraints to have zero dual value', + ) + +for prob in ('MILP_unbounded', 'MILP_unbounded_kernel'): + ExpectedFailures['knitroampl', 'nl', prob] = ( + lambda v: v[:2] <= (14, 2), + 'Unbounded MILP detection not operational in Knitro, fixed in 15.0', + ) def generate_scenarios(arg=None): @@ -355,7 +364,7 @@ def run_scenarios(options): for key, test_case in generate_scenarios(): model, solver, io = key - if len(solvers) > 0 and not solver in solvers: + if len(solvers) > 0 and solver not in solvers: continue if test_case.status == 'skip': continue @@ -381,7 +390,7 @@ def run_scenarios(options): # Validate solution status try: model_class.post_solve_test_validation(None, results) - except: + except Exception: if test_case.status == 'expected failure': stat[key] = (True, "Expected failure") else: @@ -431,7 +440,7 @@ def run_scenarios(options): total = Bunch(NumEPass=0, NumEFail=0, NumUPass=0, NumUFail=0) for key in stat: model, solver, io = key - if not solver in summary: + if solver not in summary: summary[solver] = Bunch(NumEPass=0, NumEFail=0, NumUPass=0, NumUFail=0) _pass, _str = stat[key] if _pass: diff --git a/pyomo/solvers/wrappers.py b/pyomo/solvers/wrappers.py index ee167ce1cb0..b8c4dba08e3 100644 --- a/pyomo/solvers/wrappers.py +++ b/pyomo/solvers/wrappers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/__init__.py b/pyomo/util/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/util/__init__.py +++ b/pyomo/util/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/blockutil.py b/pyomo/util/blockutil.py index 9f043e64ab7..a3611aad17b 100644 --- a/pyomo/util/blockutil.py +++ b/pyomo/util/blockutil.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/calc_var_value.py b/pyomo/util/calc_var_value.py index b5e620fea07..de34f994c85 100644 --- a/pyomo/util/calc_var_value.py +++ b/pyomo/util/calc_var_value.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -12,7 +12,7 @@ from pyomo.common.errors import IterationLimitError from pyomo.common.numeric_types import native_numeric_types, native_complex_types, value from pyomo.core.expr.calculus.derivatives import differentiate -from pyomo.core.base.constraint import Constraint, _ConstraintData +from pyomo.core.base.constraint import Constraint import logging @@ -53,9 +53,9 @@ def calculate_variable_from_constraint( Parameters: ----------- - variable: :py:class:`_VarData` + variable: :py:class:`VarData` The variable to solve for - constraint: :py:class:`_ConstraintData` or relational expression or `tuple` + constraint: :py:class:`ConstraintData` or relational expression or `tuple` The equality constraint to use to solve for the variable value. May be a `ConstraintData` object or any valid argument for ``Constraint(expr=<>)`` (i.e., a relational expression or 2- or @@ -81,10 +81,17 @@ def calculate_variable_from_constraint( """ # Leverage all the Constraint logic to process the incoming tuple/expression - if not isinstance(constraint, _ConstraintData): + if not getattr(constraint, 'ctype', None) is Constraint: constraint = Constraint(expr=constraint, name=type(constraint).__name__) constraint.construct() + if constraint.is_indexed(): + raise ValueError( + 'calculate_variable_from_constraint(): constraint must be a ' + 'scalar constraint or a single ConstraintData. Received ' + f'{constraint.__class__.__name__} ("{constraint.name}")' + ) + body = constraint.body lower = constraint.lb upper = constraint.ub @@ -169,7 +176,10 @@ def calculate_variable_from_constraint( intercept = (residual_1 - upper) - slope * x1 if slope: variable.set_value(-intercept / slope, skip_validation=True) - body_val = value(body, exception=False) + try: + body_val = value(body, exception=False) + except OverflowError: + body_val = None if body_val.__class__ not in _invalid_types and abs(body_val - upper) < eps: # Re-set the variable value to trigger any warnings WRT # the final variable state @@ -270,18 +280,25 @@ def calculate_variable_from_constraint( while alpha > alpha_min: # check if the value at xkp1 has sufficient reduction in # the residual - fkp1 = value(expr, exception=False) - # HACK for Python3 support, pending resolution of #879 - # Issue #879 also pertains to other checks for "complex" - # in this method. - if fkp1.__class__ in _invalid_types: - # We cannot perform computations on complex numbers - fkp1 = None - if fkp1 is not None and fkp1**2 < c1 * fk**2: - # found an alpha value with sufficient reduction - # continue to the next step - fk = fkp1 - break + try: + fkp1 = value(expr, exception=False) + # HACK for Python3 support, pending resolution of #879 + # Issue #879 also pertains to other checks for "complex" + # in this method. + if fkp1.__class__ in _invalid_types: + # We cannot perform computations on complex numbers + fkp1 = None + if fkp1 is not None and fkp1**2 < c1 * fk**2: + # found an alpha value with sufficient reduction + # continue to the next step + fk = fkp1 + break + except OverflowError: + # Encountered an overflow, either from evaluating + # this point in the line search (to get fkp1) or + # from squaring fkp1. (The example from #3540 + # actually triggers both). Reject this alpha value. + pass alpha /= 2.0 xkp1 = xk + alpha * pk variable.set_value(xkp1, skip_validation=True) diff --git a/pyomo/util/check_units.py b/pyomo/util/check_units.py index 6f95486c8cd..e1753be3f06 100644 --- a/pyomo/util/check_units.py +++ b/pyomo/util/check_units.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -10,7 +10,7 @@ # __________________________________________________________________________ # # -""" Pyomo Units Checking Module +"""Pyomo Units Checking Module This module has some helpful methods to support checking units on Pyomo module objects. """ diff --git a/pyomo/util/components.py b/pyomo/util/components.py index 2f1d85a4934..650bb93690a 100644 --- a/pyomo/util/components.py +++ b/pyomo/util/components.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -15,8 +15,7 @@ def rename_components(model, component_list, prefix): - """ - Rename components in component_list using the prefix AND + """Rename components in component_list using the prefix AND unique_component_name Parameters @@ -30,8 +29,13 @@ def rename_components(model, component_list, prefix): Examples -------- - >>> c_list = list(model.component_objects(ctype=Var, descend_into=True)) - >>> rename_components(model, component_list=c_list, prefix='special_') + >>> model = pyo.ConcreteModel() + >>> model.x = pyo.Var() + >>> model.y = pyo.Var() + >>> c_list = list(model.component_objects(ctype=pyo.Var, descend_into=True)) + >>> new = rename_components(model, component_list=c_list, prefix='special_') + >>> str(new) + "ComponentMap({'special_x (key=...)': 'x', 'special_y (key=...)': 'y'})" Returns ------- @@ -40,7 +44,8 @@ def rename_components(model, component_list, prefix): ToDo ---- - - need to add a check to see if someone accidentally passes a generator since this can lead to an infinite loop + - need to add a check to see if someone accidentally passes a + generator since this can lead to an infinite loop """ # Need to collect any Reference first so that we can record the old mapping of data objects before renaming @@ -99,18 +104,20 @@ def rename_components(model, component_list, prefix): def iter_component(obj): - """ - Yield "child" objects from a component that is defined with either the `base` or `kernel` APIs. - If the component is not indexed, it returns itself. + """Yield "child" objects from a component that is defined with either + the `base` or `kernel` APIs. If the component is not indexed, it + returns itself. Parameters ---------- obj : ComponentType - eg. `TupleContainer`, `ListContainer`, `DictContainer`, `IndexedComponent`, or `Component` + eg. `TupleContainer`, `ListContainer`, `DictContainer`, + `IndexedComponent`, or `Component` Returns ------- Iterator[ComponentType] : Iterator of the component data objects. + """ try: # catches `IndexedComponent`, and kernel's `_dict` diff --git a/pyomo/util/config_domains.py b/pyomo/util/config_domains.py new file mode 100644 index 00000000000..757a8453f8f --- /dev/null +++ b/pyomo/util/config_domains.py @@ -0,0 +1,72 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.collections import ComponentSet +from typing import Sequence + + +class ComponentDataSet: + """ComponentDataSet(ctype) + Domain validation class that accepts singleton or iterable arguments and + compiles them into a ComponentSet, verifying that they are all ComponentDatas + of type 'ctype.' + + Parameters + ---------- + ctype: Either a single component type or an iterable of component types + + Raises + ------ + ValueError if all of the arguments are not of a type in 'ctype' + """ + + def __init__(self, ctype): + if isinstance(ctype, Sequence): + self._ctypes = set(ctype) + else: + self._ctypes = set([ctype]) + + def __call__(self, x): + return ComponentSet(self._process(x)) + + def _process(self, x): + if hasattr(x, 'ctype'): + if x.ctype not in self._ctypes: + # Ordering for determinism + _names = ', '.join(sorted([ct.__name__ for ct in self._ctypes])) + raise ValueError( + f"Expected component or iterable of one " + f"of the following ctypes: " + f"{_names}.\n\tReceived {type(x)}" + ) + if x.is_indexed(): + yield from x.values() + else: + yield x + elif hasattr(x, '__iter__'): + for y in x: + yield from self._process(y) + else: + # Ordering for determinism + _names = ', '.join(sorted([ct.__name__ for ct in self._ctypes])) + raise ValueError( + f"Expected component or iterable of one " + f"of the following ctypes: " + f"{_names}.\n\tReceived {type(x)}" + ) + + def domain_name(self): + # Ordering for determinism + _ctypes = sorted([ct.__name__ for ct in self._ctypes]) + _names = ', '.join(_ctypes) + if len(self._ctypes) > 1: + _names = '[' + _names + ']' + return f"ComponentDataSet({_names})" diff --git a/pyomo/util/diagnostics.py b/pyomo/util/diagnostics.py index 709a483f2ff..15fc7191c97 100644 --- a/pyomo/util/diagnostics.py +++ b/pyomo/util/diagnostics.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/infeasible.py b/pyomo/util/infeasible.py index 961d5b35036..c435945f6a2 100644 --- a/pyomo/util/infeasible.py +++ b/pyomo/util/infeasible.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -159,7 +159,7 @@ def log_infeasible_constraints( if log_variables: line += ''.join( f"\n - VAR {v.name}: {v.value}" - for v in identify_variables(constr.body, include_fixed=True) + for v in identify_variables(constr.expr, include_fixed=True) ) logger.info(line) diff --git a/pyomo/util/model_size.py b/pyomo/util/model_size.py index 1fdac357368..1eb3f027ca8 100644 --- a/pyomo/util/model_size.py +++ b/pyomo/util/model_size.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/report_scaling.py b/pyomo/util/report_scaling.py index 201319ea92a..91842be3a3b 100644 --- a/pyomo/util/report_scaling.py +++ b/pyomo/util/report_scaling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -11,9 +11,9 @@ import pyomo.environ as pyo import math -from pyomo.core.base.block import _BlockData +from pyomo.core.base.block import BlockData from pyomo.common.collections import ComponentSet -from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.var import Var from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd import logging @@ -42,7 +42,7 @@ def _print_var_set(var_set): return s -def _check_var_bounds(m: _BlockData, too_large: float): +def _check_var_bounds(m: BlockData, too_large: float): vars_without_bounds = ComponentSet() vars_with_large_bounds = ComponentSet() for v in m.component_data_objects(pyo.Var, descend_into=True): @@ -73,7 +73,7 @@ def _check_coefficients( ): ders = reverse_sd(expr) for _v, _der in ders.items(): - if isinstance(_v, _GeneralVarData): + if getattr(_v, 'ctype', None) is Var: if _v.is_fixed(): continue der_lb, der_ub = compute_bounds_on_expr(_der) @@ -90,7 +90,7 @@ def _check_coefficients( def report_scaling( - m: _BlockData, too_large: float = 5e4, too_small: float = 1e-6 + m: BlockData, too_large: float = 5e4, too_small: float = 1e-6 ) -> bool: """ This function logs potentially poorly scaled parts of the model. @@ -107,7 +107,7 @@ def report_scaling( Parameters ---------- - m: _BlockData + m: BlockData The pyomo model or block too_large: float Values above too_large will generate a log entry diff --git a/pyomo/util/slices.py b/pyomo/util/slices.py index 53f6d364219..aab8f920327 100644 --- a/pyomo/util/slices.py +++ b/pyomo/util/slices.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -92,18 +92,16 @@ def slice_component_along_sets(comp, sets, context=None): Parameters: ----------- - comp: `pyomo.core.base.component.Component` or - `pyomo.core.base.component.ComponentData` + comp: :class:`Component` or :class:`ComponentData` Component whose parent structure to search and replace - sets: `pyomo.common.collections.ComponentSet` + sets: `~pyomo.common.collections.ComponentSet` Contains the sets to replace with slices - context: `pyomo.core.base.block.Block` or - `pyomo.core.base.block._BlockData` + context: :class:`Block` or :class:`BlockData` Block below which to search for sets Returns: -------- - `pyomo.core.base.indexed_component_slice.IndexedComponent_slice`: + `~pyomo.core.base.indexed_component_slice.IndexedComponent_slice`: Slice of `comp` with wildcards replacing the indices of `sets` """ diff --git a/pyomo/util/subsystems.py b/pyomo/util/subsystems.py index 70a0af1b2a7..20e2c23fc4d 100644 --- a/pyomo/util/subsystems.py +++ b/pyomo/util/subsystems.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -14,21 +14,38 @@ from pyomo.core.expr.visitor import identify_variables from pyomo.common.collections import ComponentSet, ComponentMap from pyomo.common.modeling import unique_component_name - +from pyomo.util.vars_from_expressions import get_vars_from_components from pyomo.core.base.constraint import Constraint from pyomo.core.base.expression import Expression +from pyomo.core.base.objective import Objective from pyomo.core.base.external import ExternalFunction from pyomo.core.expr.visitor import StreamBasedExpressionVisitor from pyomo.core.expr.numeric_expr import ExternalFunctionExpression -from pyomo.core.expr.numvalue import native_types +from pyomo.core.expr.numvalue import native_types, NumericValue class _ExternalFunctionVisitor(StreamBasedExpressionVisitor): + def __init__(self, descend_into_named_expressions=True): + super().__init__() + self._descend_into_named_expressions = descend_into_named_expressions + self.named_expressions = [] + def initializeWalker(self, expr): self._functions = [] self._seen = set() return True, None + def beforeChild(self, parent, child, index): + if child.__class__ in native_types: + return False, None + elif ( + not self._descend_into_named_expressions + and child.is_named_expression_type() + ): + self.named_expressions.append(child) + return False, None + return True, None + def exitNode(self, node, data): if type(node) is ExternalFunctionExpression: if id(node) not in self._seen: @@ -38,17 +55,6 @@ def exitNode(self, node, data): def finalizeResult(self, result): return self._functions - def enterNode(self, node): - pass - - def acceptChildResult(self, node, data, child_result, child_idx): - pass - - def acceptChildResult(self, node, data, child_result, child_idx): - if child_result.__class__ in native_types: - return False, None - return child_result.is_expression_type(), None - def identify_external_functions(expr): yield from _ExternalFunctionVisitor().walk_expression(expr) @@ -56,8 +62,28 @@ def identify_external_functions(expr): def add_local_external_functions(block): ef_exprs = [] - for comp in block.component_data_objects((Constraint, Expression), active=True): - ef_exprs.extend(identify_external_functions(comp.expr)) + named_expressions = [] + visitor = _ExternalFunctionVisitor(descend_into_named_expressions=False) + for comp in block.component_data_objects( + (Constraint, Expression, Objective), active=True + ): + ef_exprs.extend(visitor.walk_expression(comp.expr)) + named_expr_set = ComponentSet(visitor.named_expressions) + # List of unique named expressions + named_expressions = list(named_expr_set) + while named_expressions: + expr = named_expressions.pop() + # Clear named expression cache so we don't re-check named expressions + # we've seen before. + visitor.named_expressions.clear() + ef_exprs.extend(visitor.walk_expression(expr)) + # Only add to the stack named expressions that we have + # not encountered yet. + for local_expr in visitor.named_expressions: + if local_expr not in named_expr_set: + named_expressions.append(local_expr) + named_expr_set.add(local_expr) + unique_functions = [] fcn_set = set() for expr in ef_exprs: @@ -106,11 +132,9 @@ def create_subsystem_block(constraints, variables=None, include_fixed=False): block.cons = Reference(constraints) var_set = ComponentSet(variables) input_vars = [] - for con in constraints: - for var in identify_variables(con.expr, include_fixed=include_fixed): - if var not in var_set: - input_vars.append(var) - var_set.add(var) + for var in get_vars_from_components(block, Constraint, include_fixed=include_fixed): + if var not in var_set: + input_vars.append(var) block.input_vars = Reference(input_vars) add_local_external_functions(block) return block @@ -148,7 +172,14 @@ class TemporarySubsystemManager(object): """ - def __init__(self, to_fix=None, to_deactivate=None, to_reset=None, to_unfix=None): + def __init__( + self, + to_fix=None, + to_deactivate=None, + to_reset=None, + to_unfix=None, + remove_bounds_on_fix=False, + ): """ Arguments --------- @@ -168,6 +199,8 @@ def __init__(self, to_fix=None, to_deactivate=None, to_reset=None, to_unfix=None List of var data objects to be temporarily unfixed. These are restored to their original status on exit from this object's context manager. + remove_bounds_on_fix: Bool + Whether bounds should be removed temporarily for fixed variables """ if to_fix is None: @@ -194,6 +227,8 @@ def __init__(self, to_fix=None, to_deactivate=None, to_reset=None, to_unfix=None self._con_was_active = None self._comp_original_value = None self._var_was_unfixed = None + self._remove_bounds_on_fix = remove_bounds_on_fix + self._fixed_var_bounds = None def __enter__(self): to_fix = self._vars_to_fix @@ -203,8 +238,13 @@ def __enter__(self): self._var_was_fixed = [(var, var.fixed) for var in to_fix + to_unfix] self._con_was_active = [(con, con.active) for con in to_deactivate] self._comp_original_value = [(comp, comp.value) for comp in to_set] + self._fixed_var_bounds = [(var.lb, var.ub) for var in to_fix] for var in self._vars_to_fix: + if self._remove_bounds_on_fix: + # TODO: Potentially override var.domain as well? + var.setlb(None) + var.setub(None) var.fix() for con in self._cons_to_deactivate: @@ -223,6 +263,11 @@ def __exit__(self, ex_type, ex_val, ex_bt): var.fix() else: var.unfix() + if self._remove_bounds_on_fix: + for var, (lb, ub) in zip(self._vars_to_fix, self._fixed_var_bounds): + var.setlb(lb) + var.setub(ub) + for con, was_active in self._con_was_active: if was_active: con.activate() @@ -241,26 +286,33 @@ class ParamSweeper(TemporarySubsystemManager): calculation, over a range of values for which the calculation is valid. For example: - >>> model = ... # Make model somehow - >>> solver = ... # Make solver somehow - >>> input_vars = [model.v1] - >>> n_scen = 2 - >>> input_values = ComponentMap([(model.v1, [1.1, 2.1])]) - >>> output_values = ComponentMap([(model.v2, [1.2, 2.2])]) - >>> with ParamSweeper( - ... n_scen, - ... input_values, - ... output_values, - ... to_fix=input_vars, - ... ) as param_sweeper: - >>> for inputs, outputs in param_sweeper: - >>> solver.solve(model) - >>> # inputs and outputs contain the correct values for this - >>> # instance of the model - >>> for var, val in outputs.items(): - >>> # Test that model.v2 was calculated properly. - >>> # First that it equals 1.2, then that it equals 2.2 - >>> assert var.value == val + .. testcode:: + :skipif: not glpk_available + + model = pyo.ConcreteModel() + model.v1 = pyo.Var() + model.v2 = pyo.Var() + model.c = pyo.Constraint(expr=model.v2 - model.v1 >= 0.1) + model.o = pyo.Objective(expr=model.v1 + model.v2) + solver = pyo.SolverFactory('glpk') + input_vars = [model.v1] + n_scen = 2 + input_values = pyo.ComponentMap([(model.v1, [1.1, 2.1])]) + output_values = pyo.ComponentMap([(model.v2, [1.2, 2.2])]) + with ParamSweeper( + n_scen, + input_values, + output_values, + to_fix=input_vars, + ) as param_sweeper: + for inputs, outputs in param_sweeper: + solver.solve(model) + # inputs and outputs contain the correct values for this + # instance of the model + for var, val in outputs.items(): + # Test that model.v2 was calculated properly. + # First that it equals 1.2, then that it equals 2.2 + assert var.value == val, f"{var.value} != {val}" """ diff --git a/pyomo/util/tests/__init__.py b/pyomo/util/tests/__init__.py index a4a626013c4..6eb9ea8b81d 100644 --- a/pyomo/util/tests/__init__.py +++ b/pyomo/util/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/tests/test_blockutil.py b/pyomo/util/tests/test_blockutil.py index dfe4f482fb2..fd91e2acd47 100644 --- a/pyomo/util/tests/test_blockutil.py +++ b/pyomo/util/tests/test_blockutil.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/tests/test_calc_var_value.py b/pyomo/util/tests/test_calc_var_value.py index a02d7a7d838..217fc84e49b 100644 --- a/pyomo/util/tests/test_calc_var_value.py +++ b/pyomo/util/tests/test_calc_var_value.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -101,6 +101,15 @@ def test_initialize_value(self): ): calculate_variable_from_constraint(m.x, m.lt) + m.indexed = Constraint([1, 2], rule=lambda m, i: m.x <= i) + with self.assertRaisesRegex( + ValueError, + r"calculate_variable_from_constraint\(\): constraint must be a scalar " + r"constraint or a single ConstraintData. Received IndexedConstraint " + r'\("indexed"\)', + ): + calculate_variable_from_constraint(m.x, m.indexed) + def test_linear(self): m = ConcreteModel() m.x = Var() @@ -450,3 +459,13 @@ def test_external_function_explicit_sympy(self): calculate_variable_from_constraint( m.x, m.c, diff_mode=differentiate.Modes.sympy ) + + def test_linea_search_overflow(self): + # This tests the example from #3540 + m = ConcreteModel() + m.x = Var(initialize=5) + m.y = Var(initialize=1) + m.con = Constraint(expr=m.y == 10 ** (-m.x)) + + calculate_variable_from_constraint(m.x, m.con) + self.assertAlmostEqual(value(m.x), 0) diff --git a/pyomo/util/tests/test_check_units.py b/pyomo/util/tests/test_check_units.py index 9cde8d8dbae..ce4f539c6b2 100644 --- a/pyomo/util/tests/test_check_units.py +++ b/pyomo/util/tests/test_check_units.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/tests/test_components.py b/pyomo/util/tests/test_components.py index 1027815ca6b..120ecade700 100644 --- a/pyomo/util/tests/test_components.py +++ b/pyomo/util/tests/test_components.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/tests/test_config_domains.py b/pyomo/util/tests/test_config_domains.py new file mode 100644 index 00000000000..f1ed348abab --- /dev/null +++ b/pyomo/util/tests/test_config_domains.py @@ -0,0 +1,99 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.collections import ComponentSet +from pyomo.common.config import ConfigDict, ConfigValue +import pyomo.common.unittest as unittest +from pyomo.core import Block, ConcreteModel, Constraint, Objective, Var +from pyomo.util.config_domains import ComponentDataSet + + +def ComponentSetConfig(): + CONFIG = ConfigDict() + CONFIG.declare( + 'var_set', + ConfigValue(default=None, domain=ComponentDataSet(Var), doc="VarDataSet"), + ) + CONFIG.declare( + 'var_and_constraint_set', + ConfigValue( + default=None, + domain=ComponentDataSet(ctype=(Var, Constraint)), + doc="VarAndConstraintSet", + ), + ) + return CONFIG + + +def a_model(): + m = ConcreteModel() + m.x = Var() + m.y = Var([1, 2]) + m.c = Constraint(expr=m.x + m.y[1] <= 3) + m.c2 = Constraint(expr=m.y[2] >= 7) + m.obj = Objective(expr=m.x + m.y[1] + m.y[2]) + m.b = Block() + + return m + + +class TestComponentDataSetDomain(unittest.TestCase): + def test_var_set(self): + m = a_model() + config = ComponentSetConfig() + self.assertIsNone(config.var_set) + config.var_set = ComponentSet([m.x, m.y]) + self.assertIsInstance(config.var_set, ComponentSet) + self.assertEqual(len(config.var_set), 3) + for v in [m.x, m.y[1], m.y[2]]: + self.assertIn(v, config.var_set) + + with self.assertRaisesRegex( + ValueError, + ".*Expected component or iterable of one " + "of the following ctypes: Var.\n\t" + "Received ", + ): + config.var_set = ComponentSet([m.y, m.c]) + + def test_var_and_constraint_set(self): + m = a_model() + config = ComponentSetConfig() + self.assertIsNone(config.var_and_constraint_set) + config.var_and_constraint_set = ComponentSet([m.x, m.c]) + self.assertIsInstance(config.var_and_constraint_set, ComponentSet) + self.assertEqual(len(config.var_and_constraint_set), 2) + for v in [m.x, m.c]: + self.assertIn(v, config.var_and_constraint_set) + + with self.assertRaisesRegex( + ValueError, + ".*Expected component or iterable of one " + "of the following ctypes: Constraint, Var.\n\t" + "Received ", + ): + config.var_and_constraint_set = ComponentSet([m.y, m.c, m.b]) + + with self.assertRaisesRegex( + ValueError, + ".*Expected component or iterable of one " + "of the following ctypes: Constraint, Var.\n\t" + "Received ", + ): + config.var_and_constraint_set = ComponentSet([3, m.y, m.c]) + + def test_domain_name(self): + config = ComponentSetConfig() + self.assertEqual(config.get("var_set").domain_name(), "ComponentDataSet(Var)") + self.assertEqual( + config.get("var_and_constraint_set").domain_name(), + "ComponentDataSet([Constraint, Var])", + ) diff --git a/pyomo/util/tests/test_infeasible.py b/pyomo/util/tests/test_infeasible.py index 687a578e5c8..b804f3fcdee 100644 --- a/pyomo/util/tests/test_infeasible.py +++ b/pyomo/util/tests/test_infeasible.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/tests/test_model_size.py b/pyomo/util/tests/test_model_size.py index 2380d272a24..0b7cc0b465d 100644 --- a/pyomo/util/tests/test_model_size.py +++ b/pyomo/util/tests/test_model_size.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/tests/test_report_scaling.py b/pyomo/util/tests/test_report_scaling.py index 2eaed2d0ade..5f582122c7a 100644 --- a/pyomo/util/tests/test_report_scaling.py +++ b/pyomo/util/tests/test_report_scaling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import pyomo.environ as pe +import pyomo.environ as pyo from pyomo.util.report_scaling import report_scaling import logging from pyomo.common import unittest @@ -20,10 +20,10 @@ class TestReportScaling(unittest.TestCase): def test_report_scaling(self): - m = pe.ConcreteModel() - m.x = pe.Var(list(range(5))) - m.c = pe.Constraint(list(range(4))) - m.p = pe.Param(initialize=0, mutable=True) + m = pyo.ConcreteModel() + m.x = pyo.Var(list(range(5))) + m.c = pyo.Constraint(list(range(4))) + m.p = pyo.Param(initialize=0, mutable=True) m.x[0].setlb(-1) m.x[0].setub(1) @@ -34,8 +34,8 @@ def test_report_scaling(self): m.x[3].setlb(-20) m.x[3].setub(20) - m.obj1 = pe.Objective(expr=1e-8 * m.x[0] + pe.exp(m.x[3]) + m.x[1] * m.x[2]) - m.obj2 = pe.Objective(expr=m.x[0] * m.x[3] + m.x[1] ** 2) + m.obj1 = pyo.Objective(expr=1e-8 * m.x[0] + pyo.exp(m.x[3]) + m.x[1] * m.x[2]) + m.obj2 = pyo.Objective(expr=m.x[0] * m.x[3] + m.x[1] ** 2) m.c[0] = m.x[0] + m.x[3] == 0 m.c[1] = 1 / m.x[1] == 1 diff --git a/pyomo/util/tests/test_slices.py b/pyomo/util/tests/test_slices.py index 992bdc0a332..eb54c963a59 100644 --- a/pyomo/util/tests/test_slices.py +++ b/pyomo/util/tests/test_slices.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/util/tests/test_subsystems.py b/pyomo/util/tests/test_subsystems.py index 87a4fb3cf28..effd077d26e 100644 --- a/pyomo/util/tests/test_subsystems.py +++ b/pyomo/util/tests/test_subsystems.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -292,7 +292,7 @@ def test_generate_dont_fix_inputs_with_fixed_var(self): self.assertFalse(m.v3.fixed) self.assertTrue(m.v4.fixed) - def _make_model_with_external_functions(self): + def _make_model_with_external_functions(self, named_expressions=False): m = pyo.ConcreteModel() gsl = find_GSL() m.bessel = pyo.ExternalFunction(library=gsl, function="gsl_sf_bessel_J0") @@ -300,9 +300,21 @@ def _make_model_with_external_functions(self): m.v1 = pyo.Var(initialize=1.0) m.v2 = pyo.Var(initialize=2.0) m.v3 = pyo.Var(initialize=3.0) + if named_expressions: + m.subexpr = pyo.Expression(pyo.PositiveIntegers) + m.subexpr[1] = 2 * m.fermi(m.v1) + m.subexpr[2] = m.bessel(m.v1) - m.bessel(m.v2) + m.subexpr[3] = m.subexpr[2] + m.v3**2 + subexpr1 = m.subexpr[1] + subexpr2 = m.subexpr[2] + subexpr3 = m.subexpr[3] + else: + subexpr1 = 2 * m.fermi(m.v1) + subexpr2 = m.bessel(m.v1) - m.bessel(m.v2) + subexpr3 = subexpr2 + m.v3**2 m.con1 = pyo.Constraint(expr=m.v1 == 0.5) - m.con2 = pyo.Constraint(expr=2 * m.fermi(m.v1) + m.v2**2 - m.v3 == 1.0) - m.con3 = pyo.Constraint(expr=m.bessel(m.v1) - m.bessel(m.v2) + m.v3**2 == 2.0) + m.con2 = pyo.Constraint(expr=subexpr1 + m.v2**2 - m.v3 == 1.0) + m.con3 = pyo.Constraint(expr=subexpr3 == 2.0) return m @unittest.skipUnless(find_GSL(), "Could not find the AMPL GSL library") @@ -329,6 +341,15 @@ def test_identify_external_functions(self): pred_fcn_data = {(gsl, "gsl_sf_bessel_J0"), (gsl, "gsl_sf_fermi_dirac_m1")} self.assertEqual(fcn_data, pred_fcn_data) + @unittest.skipUnless(find_GSL(), "Could not find the AMPL GSL library") + def test_local_external_functions_with_named_expressions(self): + m = self._make_model_with_external_functions(named_expressions=True) + variables = list(m.component_data_objects(pyo.Var)) + constraints = list(m.component_data_objects(pyo.Constraint, active=True)) + b = create_subsystem_block(constraints, variables) + self.assertTrue(isinstance(b._gsl_sf_bessel_J0, pyo.ExternalFunction)) + self.assertTrue(isinstance(b._gsl_sf_fermi_dirac_m1, pyo.ExternalFunction)) + def _solve_ef_model_with_ipopt(self): m = self._make_model_with_external_functions() ipopt = pyo.SolverFactory("ipopt") @@ -362,6 +383,33 @@ def test_with_external_function(self): self.assertAlmostEqual(m.v2.value, m_full.v2.value) self.assertAlmostEqual(m.v3.value, m_full.v3.value) + @unittest.skipUnless(find_GSL(), "Could not find the AMPL GSL library") + @unittest.skipUnless( + pyo.SolverFactory("ipopt").available(), "ipopt is not available" + ) + def test_with_external_function_in_named_expression(self): + m = self._make_model_with_external_functions(named_expressions=True) + subsystem = ([m.con2, m.con3], [m.v2, m.v3]) + + m.v1.set_value(0.5) + block = create_subsystem_block(*subsystem) + ipopt = pyo.SolverFactory("ipopt") + with TemporarySubsystemManager(to_fix=list(block.input_vars.values())): + ipopt.solve(block) + + # Correct values obtained by solving with Ipopt directly + # in another script. + self.assertEqual(m.v1.value, 0.5) + self.assertFalse(m.v1.fixed) + self.assertAlmostEqual(m.v2.value, 1.04816, delta=1e-5) + self.assertAlmostEqual(m.v3.value, 1.34356, delta=1e-5) + + # Result obtained by solving the full system + m_full = self._solve_ef_model_with_ipopt() + self.assertAlmostEqual(m.v1.value, m_full.v1.value) + self.assertAlmostEqual(m.v2.value, m_full.v2.value) + self.assertAlmostEqual(m.v3.value, m_full.v3.value) + @unittest.skipUnless(find_GSL(), "Could not find the AMPL GSL library") def test_external_function_with_potential_name_collision(self): m = self._make_model_with_external_functions() diff --git a/pyomo/util/vars_from_expressions.py b/pyomo/util/vars_from_expressions.py index f9b3f1ab8ae..f3df1d624c7 100644 --- a/pyomo/util/vars_from_expressions.py +++ b/pyomo/util/vars_from_expressions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -17,7 +17,7 @@ actually in the subtree or not. """ from pyomo.core import Block -import pyomo.core.expr as EXPR +from pyomo.core.expr.visitor import IdentifyVariableVisitor def get_vars_from_components( @@ -42,6 +42,7 @@ def get_vars_from_components( descend_into: Ctypes to descend into when finding Constraints descent_order: Traversal strategy for finding the objects of type ctype """ + visitor = IdentifyVariableVisitor(include_fixed, {}) seen = set() for constraint in block.component_data_objects( ctype, @@ -50,9 +51,7 @@ def get_vars_from_components( descend_into=descend_into, descent_order=descent_order, ): - for var in EXPR.identify_variables( - constraint.expr, include_fixed=include_fixed - ): + for var in visitor.walk_expression(constraint.expr): if id(var) not in seen: seen.add(id(var)) yield var diff --git a/pyomo/version/__init__.py b/pyomo/version/__init__.py index acc92ff6b37..6e45d221a55 100644 --- a/pyomo/version/__init__.py +++ b/pyomo/version/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/version/info.py b/pyomo/version/info.py index 0db00ac240f..63032015be6 100644 --- a/pyomo/version/info.py +++ b/pyomo/version/info.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -25,8 +25,8 @@ # should generally be left at 0, unless a downstream package is tracking # main and needs a hard reference to "suitably new" development. major = 6 -minor = 7 -micro = 1 +minor = 9 +micro = 3 releaselevel = 'invalid' # releaselevel = 'final' serial = 0 diff --git a/pyomo/version/tests/__init__.py b/pyomo/version/tests/__init__.py index f013ccd3fa3..d679f297249 100644 --- a/pyomo/version/tests/__init__.py +++ b/pyomo/version/tests/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/version/tests/check.py b/pyomo/version/tests/check.py index 0fca9badb2f..580a1de39d9 100644 --- a/pyomo/version/tests/check.py +++ b/pyomo/version/tests/check.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/version/tests/test_version.py b/pyomo/version/tests/test_version.py index 3b39bd71cb1..6ac52d18835 100644 --- a/pyomo/version/tests/test_version.py +++ b/pyomo/version/tests/test_version.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/scripts/admin/README.md b/scripts/admin/README.md index 50ad2020b94..d7ec9fb3231 100644 --- a/scripts/admin/README.md +++ b/scripts/admin/README.md @@ -1,15 +1,19 @@ -# Contributors Script +# Admin Scripts + +-------- + +## Contributors Script The `contributors.py` script is intended to be used to determine contributors to a public GitHub repository within a given time frame. -## Requirements +### Requirements -1. Python 3.7+ +1. Python 3.9+ 1. [PyGithub](https://pypi.org/project/PyGithub/) 1. A [GitHub Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with `repo` access, exported to the environment variable `GH_TOKEN` -## Usage +### Usage ``` Usage: contributors.py @@ -21,8 +25,48 @@ ALSO REQUIRED: Please generate a GitHub token (with repo permissions) and export Visit GitHub's official documentation for more details. ``` -## Results +### Results A list of contributors will print to the terminal upon completion. More detailed information, including authors, committers, reviewers, and pull requests, can be found in the `contributors-start_date-end_date.json` generated file. + + +---------- + +## Big Wheel of Misfortune + +The `bwom.py` script is intended to be used during weekly Dev Calls to generate +a list of random open issues so developers can more proactively review issues +in the backlog. + +### Requirements + +1. Python 3.9+ +1. [PyGithub](https://pypi.org/project/PyGithub/) +1. A [GitHub Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with `repo` access, exported to the environment variable `GH_TOKEN` + +### Usage + +``` +Usage: bwom.py [num_issues] + : the GitHub organization/repository combo (e.g., Pyomo/pyomo) + [Number of issues] : optional number of random open issues to return (default is 5) + +ALSO REQUIRED: Please generate a GitHub token (with repo permissions) and export to the environment variable GH_TOKEN. + Visit GitHub's official documentation for more details. +``` + +### Results + +A list of `n` random open issues (default is 5) on the target repository. +This list includes the issue number, title, and URL. For example: + +``` +Randomly selected open issues from Pyomo/pyomo: +- Issue #2087: Add Installation Environment Test (URL: https://github.com/Pyomo/pyomo/issues/2087) +- Issue #1310: Pynumero.sparse transpose (URL: https://github.com/Pyomo/pyomo/issues/1310) +- Issue #2218: cyipopt does not support `symbolic_solver_labels` or `load_solutions=False` (URL: https://github.com/Pyomo/pyomo/issues/2218) +- Issue #2123: k_aug interface in Pyomo sensitivity toolbox reports wrong answer (URL: https://github.com/Pyomo/pyomo/issues/2123) +- Issue #1761: slow quadratic constraint creation (URL: https://github.com/Pyomo/pyomo/issues/1761) +``` diff --git a/scripts/admin/bwom.py b/scripts/admin/bwom.py new file mode 100644 index 00000000000..61fe5f83215 --- /dev/null +++ b/scripts/admin/bwom.py @@ -0,0 +1,134 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +""" +This script is intended to query the GitHub REST API and get a list of open +issues for a given repository, returning a random selection of n issues. + +We colloquially call this the "Big Wheel of Misfortune" (BWOM) +""" + +import sys +import random +import os + +from github import Github, Auth + + +def get_random_open_issues(repository, number_to_return): + """ + Return a random selection of open issues from a repository. + + Parameters + ---------- + repository : String + The org/repo combination for target repository (GitHub). E.g., + IDAES/idaes-pse. + number_to_return : int + The number of random open issues to return. + + Returns + ------- + random_issues : List + A list of dictionaries containing information about randomly selected open issues. + """ + # Collect the authorization token from the user's environment + token = os.environ.get('GH_TOKEN') + auth_token = Auth.Token(token) + # Create a connection to GitHub + gh = Github(auth=auth_token) + # Create a repository object for the requested repository + repo = gh.get_repo(repository) + # Get all open issues + open_issues = repo.get_issues(state='open') + open_issues_list = [issue for issue in open_issues if "pull" not in issue.html_url] + + # Randomly select the specified number of issues + random_issues = random.sample( + open_issues_list, min(number_to_return, len(open_issues_list)) + ) + + return random_issues + + +def print_big_wheel(): + """Prints a specified ASCII art representation of a big wheel.""" + wheel = [ + " . __", + " / \\ . ' || ' .", + " )J( .` || `.", + " (8)7) . \\ || / .", + " (') .'/ _ \\ .-''-. / _ \\", + " (=) .' J `- .' .--. '. -` L", + " (') .' F======' ((<>)) '======J", + " )J(' L '. `||' .' F", + " (7(8) \\ _.- `-||-' -._ /", + " \\' . / || \\ .", + " / | . / || \\ .", + " / | ` . _||_ . `", + " / |___________ _.-||_________", + " (()\\.'| ___.....'''' ||._ .'", + " \\.`- .'. /__\\/ .'|", + ".'_______________________________.' ||", + " |'---------------------------'|==.||", + " ||.' || ||.' ||", + " ||===========================|| (__)", + " || ||", + " (__) LGB (__)", + " Credit: ascii.co.uk/art/spinningwheel", + ] + for line in wheel: + print(line) + + +def main(): + if len(sys.argv) < 2 or len(sys.argv) > 3: + print(f"Usage: {sys.argv[0]} [num_issues]") + print( + " : the GitHub organization/repository combo (e.g., Pyomo/pyomo)" + ) + print( + " [Number of issues] : optional number of random open issues to return (default is 5)" + ) + print("") + print( + "ALSO REQUIRED: Please generate a GitHub token (with repo permissions) " + "and export to the environment variable GH_TOKEN." + ) + print(" Visit GitHub's official documentation for more details.") + sys.exit(1) + + repository = sys.argv[1] + num_issues = 5 + if len(sys.argv) == 3: + try: + num_issues = int(sys.argv[2]) + if num_issues <= 0: + raise (ValueError("Need a positive number; why did you try <= 0?")) + except ValueError as e: + print( + "*** ERROR: You did something weird when declaring the number of issues. Defaulting to 5.\n" + f"(For posterity, this is the error that was returned: {e})\n" + ) + + print("Spinning the Big Wheel of Misfortune...\n") + print_big_wheel() + + random_issues = get_random_open_issues(repository, num_issues) + + print(f"\nRandomly selected open issues from {repository}:") + for issue in random_issues: + print(f"- Issue #{issue.number}: {issue.title} (URL: {issue.html_url})") + + +if __name__ == '__main__': + main() diff --git a/scripts/admin/contributors.py b/scripts/admin/contributors.py index ffc02059d6f..fd1f8e114da 100644 --- a/scripts/admin/contributors.py +++ b/scripts/admin/contributors.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/scripts/get_pyomo.py b/scripts/get_pyomo.py index d90773f2315..f1f624707a1 100644 --- a/scripts/get_pyomo.py +++ b/scripts/get_pyomo.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/scripts/get_pyomo_extras.py b/scripts/get_pyomo_extras.py index 6688f3c6dc4..48218a0118d 100644 --- a/scripts/get_pyomo_extras.py +++ b/scripts/get_pyomo_extras.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/scripts/performance/compare.py b/scripts/performance/compare.py index e62440fd6d9..5ee19dd63d3 100755 --- a/scripts/performance/compare.py +++ b/scripts/performance/compare.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/scripts/performance/compare_components.py b/scripts/performance/compare_components.py index 764b50217ef..5023942bb25 100644 --- a/scripts/performance/compare_components.py +++ b/scripts/performance/compare_components.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/scripts/performance/expr_perf.py b/scripts/performance/expr_perf.py index 9abdd560887..cbf69af31e1 100644 --- a/scripts/performance/expr_perf.py +++ b/scripts/performance/expr_perf.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/scripts/performance/main.py b/scripts/performance/main.py index 07dc38a11a7..f29e5981a6b 100755 --- a/scripts/performance/main.py +++ b/scripts/performance/main.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/scripts/performance/simple.py b/scripts/performance/simple.py index c5fb836b64b..a72ade2bb50 100644 --- a/scripts/performance/simple.py +++ b/scripts/performance/simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/setup.cfg b/setup.cfg index b606138f38c..5b6214d40ab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,6 @@ [metadata] license_files = LICENSE.md -[bdist_wheel] -universal=1 - [tool:pytest] filterwarnings = ignore::RuntimeWarning junit_family = xunit2 @@ -22,3 +19,4 @@ markers = lp: marks lp tests gams: marks gams tests bar: marks bar tests + builders: tests that should be run when testing custom (extension) builders \ No newline at end of file diff --git a/setup.py b/setup.py index 70c1626a650..f324f4c5d1a 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain @@ -53,19 +53,30 @@ def get_version(): return import_pyomo_module('pyomo', 'version', 'info.py')['__version__'] +def check_config_arg(name): + if name in sys.argv: + sys.argv.remove(name) + return True + if name in os.getenv('PYOMO_SETUP_ARGS', '').split(): + return True + return False + + CYTHON_REQUIRED = "required" if not any( - arg.startswith(cmd) for cmd in ('build', 'install', 'bdist') for arg in sys.argv + arg.startswith(cmd) + for cmd in ('build', 'install', 'bdist', 'wheel') + for arg in sys.argv ): using_cython = False -else: +elif sys.version_info[:2] < (3, 11): using_cython = "automatic" -if '--with-cython' in sys.argv: +else: + using_cython = False +if check_config_arg('--with-cython'): using_cython = CYTHON_REQUIRED - sys.argv.remove('--with-cython') -if '--without-cython' in sys.argv: +if check_config_arg('--without-cython'): using_cython = False - sys.argv.remove('--without-cython') ext_modules = [] if using_cython: @@ -107,14 +118,7 @@ def get_version(): raise using_cython = False -if ('--with-distributable-extensions' in sys.argv) or ( - os.getenv('PYOMO_SETUP_ARGS') is not None - and '--with-distributable-extensions' in os.getenv('PYOMO_SETUP_ARGS') -): - try: - sys.argv.remove('--with-distributable-extensions') - except: - pass +if check_config_arg('--with-distributable-extensions'): # # Import the APPSI extension builder # NOTE: There is inconsistent behavior in Windows for APPSI. @@ -200,7 +204,7 @@ def __ne__(self, other): setup_kwargs = dict( - name='Pyomo', + name='pyomo', # # Note: the release number is set in pyomo/version/info.py # @@ -230,17 +234,17 @@ def __ne__(self, other): 'Operating System :: Unix', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Scientific/Engineering :: Mathematics', 'Topic :: Software Development :: Libraries :: Python Modules', ], - python_requires='>=3.8', + python_requires='>=3.9', install_requires=['ply'], extras_require={ # There are certain tests that also require pytest-qt, but because those @@ -248,23 +252,25 @@ def __ne__(self, other): # the dependencies. 'tests': ['coverage', 'parameterized', 'pybind11', 'pytest', 'pytest-parallel'], 'docs': [ - 'Sphinx>4', + 'Sphinx>4,!=8.2.0', 'sphinx-copybutton', 'sphinx_rtd_theme>0.5', 'sphinxcontrib-jsmath', 'sphinxcontrib-napoleon', 'sphinx-toolbox>=2.16.0', 'sphinx-jinja2-compat>=0.1.1', - 'enum_tools', 'numpy', # Needed by autodoc for pynumero 'scipy', # Needed by autodoc for pynumero ], 'optional': [ 'dill', # No direct use, but improves lambda pickle 'ipython', # contrib.viewer + 'linear-tree', # contrib.piecewise # Note: matplotlib 3.6.1 has bug #24127, which breaks # seaborn's histplot (triggering parmest failures) - 'matplotlib!=3.6.1', + # Note: minimum version from community_detection use of + # matplotlib.pyplot.get_cmap() + 'matplotlib>=3.6.0,!=3.6.1', # network, incidence_analysis, community_detection # Note: networkx 3.2 is Python>-3.9, but there is a broken # 3.2 package on conda-forge that will get implicitly @@ -306,6 +312,7 @@ def __ne__(self, other): "pyomo.contrib.mcpp": ["*.cpp"], "pyomo.contrib.pynumero": ['src/*', 'src/tests/*'], "pyomo.contrib.viewer": ["*.ui"], + "pyomo.contrib.simplification.ginac": ["src/*.cpp", "src/*.hpp"], }, ext_modules=ext_modules, entry_points="""