diff --git a/.coin-or/projDesc.xml b/.coin-or/projDesc.xml index 1ee247e100f..b5da0efb6fa 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.8.2 + 6.8.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/.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 ef44806d6d4..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,46 +16,101 @@ 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: name: Build wheels (${{ matrix.wheel-version }}) on ${{ matrix.os }} for native and cross-compiled architecture runs-on: ${{ matrix.os }} 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: '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.2 + 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 + name: native_wheels-${{ matrix.os }}-${{ matrix.TARGET }} path: dist/*.whl + overwrite: true alternative_wheels: name: Build wheels (${{ matrix.wheel-version }}) on ${{ matrix.os }} for aarch64 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: '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 @@ -61,21 +118,56 @@ 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.2 + 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 + 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 }} @@ -87,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 }} @@ -106,4 +198,5 @@ jobs: with: name: generictarball path: dist + overwrite: true diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml index e5513d25975..caedbfc8fdd 100644 --- a/.github/workflows/test_branches.yml +++ b/.github/workflows/test_branches.yml @@ -21,8 +21,8 @@ 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 }} @@ -40,12 +40,27 @@ 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 + 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,29 +71,29 @@ 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 TARGET: linux PYENV: pip - os: macos-latest - python: '3.10' + python: 3.12 TARGET: osx PYENV: pip - os: windows-latest - python: 3.9 + python: 3.11 TARGET: win PYENV: conda - PACKAGES: glpk pytest-qt + PACKAGES: glpk pytest-qt filelock - os: ubuntu-latest - python: '3.11' + python: 3.11 other: /conda skip_doctest: 1 TARGET: linux @@ -86,16 +101,16 @@ jobs: 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 @@ -104,7 +119,7 @@ jobs: PACKAGES: cython - os: windows-latest - python: 3.8 + python: 3.9 other: /pip skip_doctest: 1 TARGET: win @@ -180,7 +195,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 +207,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 +234,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 +280,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 @@ -320,6 +338,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 +355,33 @@ 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() (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.0' + else + XPRESS='xpress' + fi 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 @@ -364,6 +403,7 @@ jobs: conda install -y "$PKG" || _BUILDS="" fi fi + echo "" if test -z "$_BUILDS"; then echo "WARNING: $PKG is not available" fi @@ -401,6 +441,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,6 +451,8 @@ jobs: IPOPT_DIR=$TPL_DIR/ipopt echo "$IPOPT_DIR" >> $GITHUB_PATH echo "LD_LIBRARY_PATH=$LD_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 @@ -484,7 +529,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 +548,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 +593,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" @@ -610,6 +670,11 @@ 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 @@ -618,6 +683,7 @@ jobs: - name: Run Pyomo tests if: matrix.mpi == 0 run: | + export PATH=$PYOMO_PATH $PYTHON_EXE -m pytest -v \ -W ignore::Warning ${{matrix.category}} \ pyomo `pwd`/pyomo-model-libraries \ @@ -631,7 +697,7 @@ jobs: $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 @@ -651,6 +717,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 +727,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: | @@ -708,12 +775,12 @@ jobs: 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 +795,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 diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index c5028606c17..0bd5cac42ea 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,8 +29,8 @@ 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 }} @@ -34,6 +39,8 @@ jobs: lint: name: lint/style-and-typos runs-on: ubuntu-latest + if: | + contains(github.event.pull_request.title, '[WIP]') != true && !github.event.pull_request.draft steps: - name: Checkout Pyomo source uses: actions/checkout@v4 @@ -43,7 +50,8 @@ 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 @@ -60,7 +68,7 @@ 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: [""] @@ -76,10 +84,10 @@ jobs: - os: windows-latest TARGET: win PYENV: conda - PACKAGES: glpk pytest-qt + PACKAGES: glpk pytest-qt filelock - os: ubuntu-latest - python: '3.11' + python: 3.11 other: /conda skip_doctest: 1 TARGET: linux @@ -87,24 +95,16 @@ jobs: 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 - - - os: ubuntu-latest - python: '3.11' - other: /singletest - category: "-m 'neos or importtest'" - skip_doctest: 1 - TARGET: linux - PYENV: pip + PACKAGES: openmpi mpi4py - os: ubuntu-latest - python: '3.10' + python: 3.12 other: /cython setup_options: --with-cython skip_doctest: 1 @@ -113,14 +113,22 @@ jobs: 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'" + skip_doctest: 1 + TARGET: linux + PYENV: pip + + - os: ubuntu-latest + python: 3.9 other: /slim slim: 1 skip_doctest: 1 @@ -128,14 +136,23 @@ jobs: PYENV: pip - os: ubuntu-latest - python: 3.9 + python: 3.12 + other: /numpy2 + slim: 1 + skip_doctest: 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 + python: 'pypy-3.10' skip_doctest: 1 TARGET: linux PYENV: pip @@ -210,7 +227,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 +239,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 +266,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 +312,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 @@ -350,6 +370,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 +387,33 @@ 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() (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.0' + else + XPRESS='xpress' + fi 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 @@ -394,6 +435,7 @@ jobs: conda install -y "$PKG" || _BUILDS="" fi fi + echo "" if test -z "$_BUILDS"; then echo "WARNING: $PKG is not available" fi @@ -431,6 +473,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,6 +483,8 @@ jobs: IPOPT_DIR=$TPL_DIR/ipopt echo "$IPOPT_DIR" >> $GITHUB_PATH echo "LD_LIBRARY_PATH=$LD_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 @@ -514,7 +561,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 +580,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 +625,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" @@ -640,6 +702,11 @@ 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 @@ -648,6 +715,7 @@ jobs: - name: Run Pyomo tests if: matrix.mpi == 0 run: | + export PATH=$PYOMO_PATH $PYTHON_EXE -m pytest -v \ -W ignore::Warning ${{matrix.category}} \ pyomo `pwd`/pyomo-model-libraries \ @@ -661,7 +729,7 @@ jobs: $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 @@ -681,6 +749,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 +759,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 +767,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 +802,18 @@ jobs: cover: name: process-coverage-${{ matrix.TARGET }} needs: build - if: always() # run even if a build job fails + if: success() || failure() # run even if a build job fails, but not if cancelled 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 +828,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 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..8771427805d 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,43 @@ 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 + CODECOV_JOB_NAME=$(echo ${JOB_NAME} \ + | sed -r 's/^(.*autotest_)?Pyomo_([^\/]+).*/\2/').$BUILD_NUMBER.$python + 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 \ + --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..af3fa8bfec2 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,12 +8,13 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.11" 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..5f68e827a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,310 @@ Pyomo CHANGELOG =============== +------------------------------------------------------------------------------- +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 192d315e4b5..9fd5d9b810c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,7 @@ LICENSE ======= -Copyright (c) 2008-2022 National Technology and Engineering Solutions of +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. 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..34301a529f0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,17 +1,22 @@ -We are pleased to announce the release of Pyomo 6.7.0. +We are pleased to announce the release of Pyomo 6.8.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: - - - 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 +The following are highlights of the 6.8 release series: + +- Complete Documentation Reorganization +- Added support for Python 3.13 +- Refactor default Gurobi interface to support version 12 +- Support for NumPy2 +- Refactor of Design of Experiments (`contrib.doe`) +- New packages: + - alternative_solutions: alternative (near) optimal solutions +- New solver interfaces: + - SAS: Statistical Analysis System + - 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/.codecov.yml b/codecov.yml similarity index 54% rename from .codecov.yml rename to codecov.yml index 6b88f948fe1..318a907905f 100644 --- a/.codecov.yml +++ b/codecov.yml @@ -1,19 +1,21 @@ +codecov: + notify: + # GHA: 5, Jenkins: 11 + # Accurate as of July 3, 2024 + # Potential to change when Python versions change + after_n_builds: 16 + wait_for_ci: true coverage: - range: "50...100" + range: + - 50.0 + - 100.0 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/conftest.py b/conftest.py index df5b0f31e59..34b366f9fd6 100644 --- a/conftest.py +++ b/conftest.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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..443a3afaeff 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,12 @@ help: %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -clean clean_tests: +clean: @$(SPHINXBUILD) -M clean "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) @echo "Removing *.spy, *.out" @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 + +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/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..83cbcdd9989 --- /dev/null +++ b/doc/OnlineDocs/code.rst @@ -0,0 +1,9 @@ +:orphan: + +.. autosummary:: + :toctree: api + :caption: Library Reference + :template: recursive-module.rst + :recursive: + + pyomo diff --git a/doc/OnlineDocs/conf.py b/doc/OnlineDocs/conf.py index ef6510daedf..f99aac06b4b 100644 --- a/doc/OnlineDocs/conf.py +++ b/doc/OnlineDocs/conf.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + #!/usr/bin/env python3 # -*- coding: utf-8 -*- # @@ -24,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')) @@ -33,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) @@ -46,8 +59,7 @@ 'numpy': ('https://numpy.org/doc/stable/', None), 'pandas': ('https://pandas.pydata.org/docs/', None), 'scikit-learn': ('https://scikit-learn.org/stable/', None), - 'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None), - 'Sphinx': ('https://www.sphinx-doc.org/en/stable/', None), + 'scipy': ('https://docs.scipy.org/doc/scipy/', None), } # -- General configuration ------------------------------------------------ @@ -61,18 +73,17 @@ # 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', - #'sphinx.ext.githubpages', + # Our version of 'autoenum', designed to work with autosummary. + # This adds 'sphinx.ext.autosummary', and 'sphinx.ext.autodoc': + 'pyomo_autosummary_autoenum', ] viewcode_follow_imported_members = True @@ -95,7 +106,7 @@ # General information about the project. project = u'Pyomo' -copyright = u'2008-2023, Sandia National Laboratories' +copyright = u'2008-2024, Sandia National Laboratories' author = u'Pyomo Developers' # The version info for the project you're documenting, acts as replacement for @@ -119,7 +130,21 @@ # 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'] +# 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' @@ -155,7 +180,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, @@ -220,7 +245,10 @@ ] # 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 @@ -254,20 +282,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')) @@ -278,6 +318,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() @@ -288,4 +333,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/index.rst b/doc/OnlineDocs/contributed_packages/mpc/index.rst deleted file mode 100644 index b93abf223e2..00000000000 --- a/doc/OnlineDocs/contributed_packages/mpc/index.rst +++ /dev/null @@ -1,12 +0,0 @@ -MPC -=== - -This package contains data structures and utilities for dynamic optimization -and rolling horizon applications, e.g. model predictive control. - -.. toctree:: - :maxdepth: 1 - - overview.rst - examples.rst - faq.rst 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..9ad5bdfee0e 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,50 +404,10 @@ 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 @@ -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/index.rst b/doc/OnlineDocs/developer_reference/index.rst deleted file mode 100644 index 8c29150015c..00000000000 --- a/doc/OnlineDocs/developer_reference/index.rst +++ /dev/null @@ -1,14 +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 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 64% rename from doc/OnlineDocs/contributed_packages/doe/doe.rst rename to doc/OnlineDocs/explanation/analysis/doe/doe.rst index 8c22ff7370d..05d9d867e2b 100644 --- a/doc/OnlineDocs/contributed_packages/doe/doe.rst +++ b/doc/OnlineDocs/explanation/analysis/doe/doe.rst @@ -3,7 +3,7 @@ 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: @@ -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. @@ -116,64 +116,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: +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: -* 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 ---------------------------- - -.. 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 ----------------------- @@ -203,89 +153,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, 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/explanation/analysis/mpc/api.rst b/doc/OnlineDocs/explanation/analysis/mpc/api.rst new file mode 100644 index 00000000000..2752fea8af6 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/mpc/api.rst @@ -0,0 +1,10 @@ +.. _mpc_api: + +API Reference +============= + +.. toctree:: + data.rst + conversion.rst + interface.rst + modeling.rst diff --git a/doc/OnlineDocs/explanation/analysis/mpc/conversion.rst b/doc/OnlineDocs/explanation/analysis/mpc/conversion.rst new file mode 100644 index 00000000000..e78a1d69e0b --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/mpc/conversion.rst @@ -0,0 +1,6 @@ +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/explanation/analysis/mpc/index.rst b/doc/OnlineDocs/explanation/analysis/mpc/index.rst new file mode 100644 index 00000000000..e512d1a6ef5 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/mpc/index.rst @@ -0,0 +1,32 @@ +MPC +=== + +Pyomo MPC contains data structures and utilities for dynamic optimization +and rolling horizon applications, e.g. model predictive control. + +.. toctree:: + :maxdepth: 1 + + overview.rst + examples.rst + faq.rst + api.rst + +Citation +-------- + +If you use Pyomo MPC in your research, please cite the following paper: + +.. code-block:: bibtex + + @article{parker2023mpc, + title = {Model predictive control simulations with block-hierarchical differential-algebraic process models}, + journal = {Journal of Process Control}, + volume = {132}, + pages = {103113}, + year = {2023}, + issn = {0959-1524}, + doi = {https://doi.org/10.1016/j.jprocont.2023.103113}, + url = {https://www.sciencedirect.com/science/article/pii/S0959152423002007}, + author = {Robert B. Parker and Bethany L. Nicholson and John D. Siirola and Lorenz T. Biegler}, + } diff --git a/doc/OnlineDocs/explanation/analysis/mpc/interface.rst b/doc/OnlineDocs/explanation/analysis/mpc/interface.rst new file mode 100644 index 00000000000..13a5bf24360 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/mpc/interface.rst @@ -0,0 +1,10 @@ +Interfaces +========== + +.. automodule:: pyomo.contrib.mpc.interfaces.model_interface + :noindex: + :members: + +.. automodule:: pyomo.contrib.mpc.interfaces.var_linker + :noindex: + :members: diff --git a/doc/OnlineDocs/explanation/analysis/mpc/modeling.rst b/doc/OnlineDocs/explanation/analysis/mpc/modeling.rst new file mode 100644 index 00000000000..2bc213f1702 --- /dev/null +++ b/doc/OnlineDocs/explanation/analysis/mpc/modeling.rst @@ -0,0 +1,14 @@ +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 99% rename from doc/OnlineDocs/contributed_packages/mpc/overview.rst rename to doc/OnlineDocs/explanation/analysis/mpc/overview.rst index f5dbe85e523..f3bc7504b59 100644 --- a/doc/OnlineDocs/contributed_packages/mpc/overview.rst +++ b/doc/OnlineDocs/explanation/analysis/mpc/overview.rst @@ -189,7 +189,7 @@ a tracking cost expression. >>> m.setpoint_idx = var_set >>> m.tracking_cost = tr_cost >>> m.tracking_cost.pprint() - tracking_cost : Size=6, Index=tracking_cost_index + tracking_cost : Size=6, Index=setpoint_idx*time Key : Expression (0, 0) : (var[0,A] - 0.5)**2 (0, 1) : (var[1,A] - 0.5)**2 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 93% rename from doc/OnlineDocs/contributed_packages/sensitivity_toolbox.rst rename to doc/OnlineDocs/explanation/analysis/sensitivity_toolbox.rst index 2a2ccff4b09..6d3bbea426a 100644 --- a/doc/OnlineDocs/contributed_packages/sensitivity_toolbox.rst +++ b/doc/OnlineDocs/explanation/analysis/sensitivity_toolbox.rst @@ -67,6 +67,18 @@ 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) +.. 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: @@ -138,7 +150,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 +174,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 @@ -183,3 +195,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..3f2653aa732 --- /dev/null +++ b/doc/OnlineDocs/explanation/experimental/solvers.rst @@ -0,0 +1,358 @@ +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.factory.SolverFactory`` + - Name registered in the |br| ``pyomo.opt.base.solvers.LegacySolverFactory`` + * - Ipopt + - ``ipopt`` + - ``ipopt_v2`` + * - Gurobi (persistent) + - ``gurobi`` + - ``gurobi_v2`` + * - Gurobi (direct) + - ``gurobi_direct`` + - ``gurobi_direct_v2`` + +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 + 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 + +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.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 through the new interfaces + status.display() + model.pprint() + +.. testoutput:: + :skipif: not ipopt_available + :hide: + + solution_loader: ... + ... + 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.util import assert_optimal_termination + from pyomo.contrib.solver.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) + assert_optimal_termination(status) + # Displays important results information; only available through the new interfaces + status.display() + model.pprint() + +.. testoutput:: + :skipif: not ipopt_available + :hide: + + solution_loader: ... + ... + 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.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 through the new interfaces + 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 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.ipopt.Ipopt + :noindex: + :members: solve + +.. 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 + :noindex: + :members: + +Persistent solvers include additional members as well as other configuration options: + +.. autoclass:: pyomo.contrib.solver.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.results.Results + :noindex: + :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 + :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.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.ipopt.IpoptSolutionLoader + :noindex: + :members: + :show-inheritance: + :inherited-members: 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 99% rename from doc/OnlineDocs/modeling_extensions/dae.rst rename to doc/OnlineDocs/explanation/modeling/dae.rst index 703e83f4f14..8661fcf4af7 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 @@ -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 @@ -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 @@ -287,6 +290,7 @@ Declaring Integrals equations. .. autoclass:: pyomo.dae.Integral + :noindex: :members: Declaring an :py:class:`Integral` component is similar to @@ -556,7 +560,8 @@ 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: @@ -722,7 +727,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 +744,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. diff --git a/doc/OnlineDocs/modeling_extensions/gdp/concepts.rst b/doc/OnlineDocs/explanation/modeling/gdp/concepts.rst similarity index 100% rename from doc/OnlineDocs/modeling_extensions/gdp/concepts.rst rename to doc/OnlineDocs/explanation/modeling/gdp/concepts.rst diff --git a/doc/OnlineDocs/modeling_extensions/gdp/index.rst b/doc/OnlineDocs/explanation/modeling/gdp/index.rst similarity index 57% rename from doc/OnlineDocs/modeling_extensions/gdp/index.rst rename to doc/OnlineDocs/explanation/modeling/gdp/index.rst index 0c8529c60cb..770be256009 100644 --- a/doc/OnlineDocs/modeling_extensions/gdp/index.rst +++ b/doc/OnlineDocs/explanation/modeling/gdp/index.rst @@ -9,7 +9,11 @@ Generalized Disjunctive Programming :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: +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:: @@ -32,9 +36,12 @@ Here, we have the minimization of an objective :math:`obj` subject to global lin 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. +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: @@ -68,12 +75,3 @@ The following sections describe the key concepts, modeling, and solution approac 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/gdp/modeling.rst b/doc/OnlineDocs/explanation/modeling/gdp/modeling.rst similarity index 99% rename from doc/OnlineDocs/modeling_extensions/gdp/modeling.rst rename to doc/OnlineDocs/explanation/modeling/gdp/modeling.rst index b70e37d5935..996ebcb0366 100644 --- a/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst +++ b/doc/OnlineDocs/explanation/modeling/gdp/modeling.rst @@ -166,7 +166,7 @@ Usage: >>> TransformationFactory('core.logical_to_linear').apply_to(m) >>> # constraint auto-generated by transformation >>> m.logic_to_linear.transformed_constraints.pprint() - transformed_constraints : Size=1, Index=logic_to_linear.transformed_constraints_index, Active=True + transformed_constraints : Size=1, Index={1}, Active=True Key : Lower : Body : Upper : Active 1 : 3.0 : Y_asbinary[1] + Y_asbinary[2] + Y_asbinary[3] + Y_asbinary[4] : +Inf : True diff --git a/doc/OnlineDocs/modeling_extensions/gdp/solving.rst b/doc/OnlineDocs/explanation/modeling/gdp/solving.rst similarity index 68% rename from doc/OnlineDocs/modeling_extensions/gdp/solving.rst rename to doc/OnlineDocs/explanation/modeling/gdp/solving.rst index 2f3076862e6..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: @@ -140,6 +149,10 @@ For example, to apply the transformation and store the M values, use: From the Pyomo command line, include the ``--transform pyomo.gdp.mbigm`` option. +.. warning:: + The Multiple Big-M transformation does not currently support Suffixes and will + ignore "BigM" Suffixes. + Hull Reformulation (HR) ^^^^^^^^^^^^^^^^^^^^^^^ @@ -164,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: @@ -181,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 99% rename from doc/OnlineDocs/pyomo_modeling_components/Sets.rst rename to doc/OnlineDocs/explanation/modeling/math_programming/sets.rst index 73c3539d79d..ababa80be5a 100644 --- a/doc/OnlineDocs/pyomo_modeling_components/Sets.rst +++ b/doc/OnlineDocs/explanation/modeling/math_programming/sets.rst @@ -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 99% rename from doc/OnlineDocs/modeling_extensions/network.rst rename to doc/OnlineDocs/explanation/modeling/network.rst index 3fce9448997..3c4b60bfb6a 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 @@ -62,10 +64,12 @@ Arc *** .. autoclass:: pyomo.network.Arc + :noindex: :members: :exclude-members: construct .. autoclass:: pyomo.network.arc._ArcData + :noindex: :members: :special-members: __getattr__ @@ -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 63% rename from doc/OnlineDocs/advanced_topics/flattener/index.rst rename to doc/OnlineDocs/explanation/modeling_utils/flattener/index.rst index 377de5233ec..f9dd8ea6abb 100644 --- a/doc/OnlineDocs/advanced_topics/flattener/index.rst +++ b/doc/OnlineDocs/explanation/modeling_utils/flattener/index.rst @@ -30,8 +30,9 @@ The ``pyomo.dae.flatten`` module aims to address this use case by providing utilities to generate all components indexed, explicitly or implicitly, by user-provided sets. -**When we say "flatten a model," we mean "generate all components in the model, -preserving all user-specified indexing sets."** +**When we say "flatten a model," we mean "recursively generate all components in +the model," where a component can be indexed only by user-specified indexing +sets (or is not indexed at all)**. Data structures --------------- @@ -42,3 +43,23 @@ Slices are necessary as they can encode "implicit indexing" -- where a component is contained in an indexed block. It is natural to return references to these slices, so they may be accessed and manipulated like any other component. + +Citation +-------- +If you use the ``pyomo.dae.flatten`` module in your research, we would appreciate +you citing the following paper, which gives more detail about the motivation for +and examples of using this functinoality. + +.. code-block:: bibtex + + @article{parker2023mpc, + title = {Model predictive control simulations with block-hierarchical differential-algebraic process models}, + journal = {Journal of Process Control}, + volume = {132}, + pages = {103113}, + year = {2023}, + issn = {0959-1524}, + doi = {https://doi.org/10.1016/j.jprocont.2023.103113}, + url = {https://www.sciencedirect.com/science/article/pii/S0959152423002007}, + author = {Robert B. Parker and Bethany L. Nicholson and John D. Siirola and Lorenz T. Biegler}, + } 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 96% rename from doc/OnlineDocs/contributed_packages/preprocessing.rst rename to doc/OnlineDocs/explanation/modeling_utils/preprocessing.rst index fd26f2bf6db..1f68208fdc3 100644 --- a/doc/OnlineDocs/contributed_packages/preprocessing.rst +++ b/doc/OnlineDocs/explanation/modeling_utils/preprocessing.rst @@ -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 @@ -77,6 +78,7 @@ Explicit Constraints to Variable Bounds >>> 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..29c12c98098 --- /dev/null +++ b/doc/OnlineDocs/explanation/philosophy/abstract_modeling.rst @@ -0,0 +1,60 @@ +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 92% rename from doc/OnlineDocs/contributed_packages/gdpopt.rst rename to doc/OnlineDocs/explanation/solvers/gdpopt.rst index d550b0ced76..953799f0555 100644 --- a/doc/OnlineDocs/contributed_packages/gdpopt.rst +++ b/doc/OnlineDocs/explanation/solvers/gdpopt.rst @@ -93,10 +93,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 +106,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:: @@ -197,17 +197,11 @@ 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: +.. autosummary:: -.. autoclass:: pyomo.contrib.gdpopt.loa.GDP_LOA_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 -.. autoclass:: pyomo.contrib.gdpopt.gloa.GDP_GLOA_Solver - :members: - -.. autoclass:: pyomo.contrib.gdpopt.ric.GDP_RIC_Solver - :members: - -.. 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 94% rename from doc/OnlineDocs/contributed_packages/mindtpy.rst rename to doc/OnlineDocs/explanation/solvers/mindtpy.rst index a850a42c740..38798a0183c 100644 --- a/doc/OnlineDocs/contributed_packages/mindtpy.rst +++ b/doc/OnlineDocs/explanation/solvers/mindtpy.rst @@ -11,7 +11,7 @@ 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`_] @@ -26,7 +26,6 @@ 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 @@ -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 98% rename from doc/OnlineDocs/contributed_packages/multistart.rst rename to doc/OnlineDocs/explanation/solvers/multistart.rst index 069d770aa91..f54cb26d00f 100644 --- a/doc/OnlineDocs/contributed_packages/multistart.rst +++ b/doc/OnlineDocs/explanation/solvers/multistart.rst @@ -31,4 +31,5 @@ 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 98% rename from doc/OnlineDocs/advanced_topics/persistent_solvers.rst rename to doc/OnlineDocs/explanation/solvers/persistent.rst index aebb0545dd0..8ee32120ff3 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 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 96% rename from doc/OnlineDocs/contributed_packages/pynumero/tutorial.nlp_interfaces.rst rename to doc/OnlineDocs/explanation/solvers/pynumero/tutorial.nlp_interfaces.rst index 28818709330..832ba521052 100644 --- a/doc/OnlineDocs/contributed_packages/pynumero/tutorial.nlp_interfaces.rst +++ b/doc/OnlineDocs/explanation/solvers/pynumero/tutorial.nlp_interfaces.rst @@ -2,8 +2,8 @@ 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 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 79% rename from doc/OnlineDocs/contributed_packages/pyros.rst rename to doc/OnlineDocs/explanation/solvers/pyros.rst index 3ff1bfccf0e..986603efeab 100644 --- a/doc/OnlineDocs/contributed_packages/pyros.rst +++ b/doc/OnlineDocs/explanation/solvers/pyros.rst @@ -14,19 +14,18 @@ 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: @@ -39,20 +38,21 @@ PyROS is designed to operate on deterministic models of the general form 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 +63,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,7 +77,8 @@ 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} @@ -100,10 +89,66 @@ Based on the above notation, the form of the robust counterpart addressed by PyR & & & \displaystyle ~~ h_j\left(x, z, y, q\right) = 0 & & \forall\,j \in \mathcal{J} \end{array} -PyROS solves problems of this form using the -Generalized Robust Cutting-Set algorithm developed in [Isenberg_et_al]_. +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``: + + :: + + 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,20 +173,25 @@ 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. + PyROS Uncertainty Sets ----------------------------- Uncertainty sets are represented by subclasses of @@ -210,45 +260,18 @@ 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 - -.. 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 +.. autosummary:: -.. 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 @@ -304,23 +327,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:: @@ -403,22 +433,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 ``Param`` object that is to be considered uncertain by PyROS - must have the property ``mutable=True``. + Any :class:`~pyomo.core.base.param.Param` object that is + to be considered uncertain by PyROS must have the property + ``mutable=True``. + +.. note:: + 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. @@ -465,7 +510,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``. @@ -518,7 +563,7 @@ correspond to first-stage degrees of freedom. >>> # === Designate which variables correspond to first-stage >>> # and second-stage degrees of freedom === - >>> first_stage_variables =[ + >>> first_stage_variables = [ ... m.x1, m.x2, m.x3, m.x4, m.x5, m.x6, ... m.x19, m.x20, m.x21, m.x22, m.x23, m.x24, m.x31, ... ] @@ -530,7 +575,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, @@ -539,7 +584,7 @@ correspond to first-stage degrees of freedom. ... load_solution=False, ... ) ============================================================================== - PyROS: The Pyomo Robust Optimization Solver. + PyROS: The Pyomo Robust Optimization Solver... ... ------------------------------------------------------------------------------ Robust optimal solution identified. @@ -574,7 +619,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 `, @@ -604,7 +649,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``: @@ -623,7 +668,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, @@ -657,6 +702,54 @@ For this example, we notice a ~25% decrease in the final objective value when switching from a static decision rule (no second-stage recourse) to an affine decision rule. + +Specifying Arguments Indirectly Through ``options`` +""""""""""""""""""""""""""""""""""""""""""""""""""" +Like other Pyomo solver interface methods, +: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.PyROS.solve` +to their desired values. +For example, the ``solve()`` statement in the +:ref:`two-stage problem snippet ` +could have been equivalently written as: + +.. doctest:: + :skipif: not (baron.available() and baron.license_is_valid()) + + >>> results_2 = pyros_solver.solve( + ... model=m, + ... first_stage_variables=first_stage_variables, + ... second_stage_variables=second_stage_variables, + ... uncertain_params=uncertain_params, + ... uncertainty_set=box_uncertainty_set, + ... local_solver=local_solver, + ... global_solver=global_solver, + ... options={ + ... "objective_focus": pyros.ObjectiveType.worst_case, + ... "solve_master_globally": True, + ... "decision_rule_order": 1, + ... }, + ... ) + ============================================================================== + PyROS: The Pyomo Robust Optimization Solver... + ... + ------------------------------------------------------------------------------ + Robust optimal solution identified. + ------------------------------------------------------------------------------ + ... + ------------------------------------------------------------------------------ + All done. Exiting PyROS. + ============================================================================== + +In the event an argument is passed directly +by position or keyword, *and* indirectly through ``options``, +an appropriate warning is issued, +and the value passed directly takes precedence over the value +passed through ``options``. + + The Price of Robustness """""""""""""""""""""""" In conjunction with standard Python control flow tools, @@ -695,7 +788,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, @@ -750,7 +843,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 `. @@ -787,11 +880,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 @@ -812,12 +906,20 @@ 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." * **Iteration log table** (lines 59--69). Summary information on the problem iterates and subproblem outcomes. The constituent columns are defined in detail in @@ -854,21 +956,21 @@ Observe that the log contains the following information: :linenos: ============================================================================== - PyROS: The Pyomo Robust Optimization Solver, v1.2.9. - Pyomo version: 6.7.0 + PyROS: The Pyomo Robust Optimization Solver, v1.3.4. + Pyomo version: 6.9.0 Commit hash: unknown - Invoked at UTC 2023-12-16T00:00:00.000000 - + Invoked at UTC 2025-02-13T00:00:00.000000 + 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). ============================================================================== ================================= DISCLAIMER ================================= - PyROS is still under development. + PyROS is still under development. Please provide feedback and/or report any issues by creating a ticket at https://github.com/Pyomo/pyomo/issues/new/choose ============================================================================== @@ -877,6 +979,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 @@ -893,55 +996,55 @@ 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.009s. ------------------------------------------------------------------------------ - 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 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.412 + 1 3.5838e+07 1.2289e-09 1.5886e-12 5 2.8919e+02 0.992 + 2 3.6269e+07 3.1647e-01 1.0432e-01 4 2.9020e+02 1.865 + 3 3.6285e+07 7.6526e-01 2.2258e-01 0 2.3874e-12g 3.508 ------------------------------------------------------------------------------ Robust optimal solution identified. ------------------------------------------------------------------------------ Timing breakdown: - + Identifier ncalls cumtime percall % ----------------------------------------------------------- - main 1 11.457 11.457 100.0 + main 1 3.509 3.509 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 3 0.209 0.070 6.0 + global_separation 27 0.590 0.022 16.8 + local_separation 108 1.569 0.015 44.7 + master 4 0.654 0.163 18.6 + master_feasibility 3 0.083 0.028 2.4 + preprocessing 1 0.009 0.009 0.3 + other n/a 0.394 n/a 11.2 ====================================================== =========================================================== - + ------------------------------------------------------------------------------ Termination stats: - Iterations : 5 - Solve time (wall s) : 11.457 + Iterations : 4 + Solve time (wall s) : 3.509 Final objective value : 3.6285e+07 Termination condition : pyrosTerminationCondition.robust_optimal ------------------------------------------------------------------------------ @@ -999,10 +1102,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, @@ -1010,13 +1113,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 99% rename from doc/OnlineDocs/contributed_packages/trustregion.rst rename to doc/OnlineDocs/explanation/solvers/trustregion.rst index f477c905e33..0bdfef2f10d 100644 --- a/doc/OnlineDocs/contributed_packages/trustregion.rst +++ b/doc/OnlineDocs/explanation/solvers/trustregion.rst @@ -102,6 +102,7 @@ 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 diff --git a/doc/OnlineDocs/contributed_packages/satsolver.rst b/doc/OnlineDocs/explanation/solvers/z3_interface.rst similarity index 100% rename from doc/OnlineDocs/contributed_packages/satsolver.rst rename to doc/OnlineDocs/explanation/solvers/z3_interface.rst diff --git a/doc/OnlineDocs/ext/pyomo_autosummary_autoenum.py b/doc/OnlineDocs/ext/pyomo_autosummary_autoenum.py new file mode 100644 index 00000000000..a447466b75f --- /dev/null +++ b/doc/OnlineDocs/ext/pyomo_autosummary_autoenum.py @@ -0,0 +1,256 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +"""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, + template_name: str, + imported_members: bool, + app: Any, + recursive: bool, + context: dict, + modname: Union[str, None] = None, + qualname: Union[str, None] = None, +) -> 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: + # Sphinx >= 7.2 + _get_members = caller.f_globals['_get_members'] + 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, + template_name, + imported_members, + app, + recursive, + context, + modname, + qualname, + ) + + +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 :pt: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/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 100% rename from doc/OnlineDocs/pyomo_overview/abstract_concrete.rst rename to doc/OnlineDocs/getting_started/pyomo_overview/abstract_concrete.rst 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 98% rename from doc/OnlineDocs/pyomo_overview/simple_examples.rst rename to doc/OnlineDocs/getting_started/pyomo_overview/simple_examples.rst index 11305884c54..1741c694d68 100644 --- a/doc/OnlineDocs/pyomo_overview/simple_examples.rst +++ b/doc/OnlineDocs/getting_started/pyomo_overview/simple_examples.rst @@ -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 82% rename from doc/OnlineDocs/working_abstractmodels/data/raw_dicts.rst rename to doc/OnlineDocs/howto/abstract_models/data/raw_dicts.rst index e10042b3ceb..f78e349c28b 100644 --- a/doc/OnlineDocs/working_abstractmodels/data/raw_dicts.rst +++ b/doc/OnlineDocs/howto/abstract_models/data/raw_dicts.rst @@ -28,13 +28,10 @@ components, the required data dictionary maps the implicit index ... }} >>> i = m.create_instance(data) >>> i.pprint() - 2 Set Declarations + 1 Set Declarations I : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} - r_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : I*I : 9 : {(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)} 3 Param Declarations p : Size=1, Index=None, Domain=Any, Default=None, Mutable=False @@ -45,12 +42,12 @@ components, the required data dictionary maps the implicit index 1 : 10 2 : 20 3 : 30 - r : Size=9, Index=r_index, Domain=Any, Default=0, Mutable=False + r : Size=9, Index=I*I, Domain=Any, Default=0, Mutable=False Key : Value (1, 1) : 110 (1, 2) : 120 (2, 3) : 230 - 5 Declarations: I p q r_index r + 4 Declarations: I p q r 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..0d7ccc41592 100644 --- a/doc/OnlineDocs/index.rst +++ b/doc/OnlineDocs/index.rst @@ -1,55 +1,116 @@ +============================= Pyomo Documentation |release| ============================= +About Pyomo +----------- + .. 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` +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. -Pyomo Resources ---------------- -The Pyomo home page provides resources for Pyomo users: +Contents +-------- +.. list-table:: + :width: 100% + :class: diataxis + + * - .. toctree:: + :maxdepth: 2 + :titlesonly: + + getting_started/index + - .. toctree:: + :maxdepth: 2 + :titlesonly: + + howto/index + * - .. toctree:: + :maxdepth: 3 + :titlesonly: + + explanation/index + - .. toctree:: + :maxdepth: 3 + :titlesonly: -* http://pyomo.org + reference/index + +.. + toctree:: + :maxdepth: 1 + :titlesonly: + :hidden: + + genindex + modindex + + +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 7a400b26ce3..00000000000 --- a/doc/OnlineDocs/library_reference/common/config.rst +++ /dev/null @@ -1,83 +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 - 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:: 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 0e92f583c73..00000000000 --- a/doc/OnlineDocs/library_reference/expressions/context_managers.rst +++ /dev/null @@ -1,13 +0,0 @@ - -Context Managers -================ - -.. autoclass:: pyomo.core.expr.nonlinear_expression - :members: - -.. autoclass:: pyomo.core.expr.linear_expression - :members: - -.. autoclass:: pyomo.core.expr.current.clone_counter - :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/examples/conic.py b/doc/OnlineDocs/library_reference/kernel/examples/conic.py deleted file mode 100644 index 9282bc67f9a..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/examples/conic.py +++ /dev/null @@ -1,22 +0,0 @@ -# @Class -import pyomo.kernel as pmo - -m = pmo.block() -m.x1 = pmo.variable(lb=0) -m.x2 = pmo.variable() -m.r = pmo.variable(lb=0) -m.q = pmo.conic.primal_exponential(x1=m.x1, x2=m.x2, r=m.r) -# @Class -del m - -# @Domain -import pyomo.kernel as pmo -import math - -m = pmo.block() -m.x = pmo.variable(lb=0) -m.y = pmo.variable(lb=0) -m.b = pmo.conic.primal_exponential.as_domain( - x1=math.sqrt(2) * m.x, x2=2.0, r=2 * (m.x + m.y) -) -# @Domain diff --git a/doc/OnlineDocs/library_reference/kernel/examples/kernel_containers.py b/doc/OnlineDocs/library_reference/kernel/examples/kernel_containers.py deleted file mode 100644 index f2a4ec25ac5..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/examples/kernel_containers.py +++ /dev/null @@ -1,7 +0,0 @@ -import pyomo.kernel - -# @all -vlist = pyomo.kernel.variable_list() -vlist.append(pyomo.kernel.variable_dict()) -vlist[0]['x'] = pyomo.kernel.variable() -# @all diff --git a/doc/OnlineDocs/library_reference/kernel/examples/kernel_solving.py b/doc/OnlineDocs/library_reference/kernel/examples/kernel_solving.py deleted file mode 100644 index 5a8eed9fd89..00000000000 --- a/doc/OnlineDocs/library_reference/kernel/examples/kernel_solving.py +++ /dev/null @@ -1,11 +0,0 @@ -import pyomo.kernel as pmo - -model = pmo.block() -model.x = pmo.variable() -model.c = pmo.constraint(model.x >= 1) -model.o = pmo.objective(model.x) - -opt = pmo.SolverFactory("ipopt") - -result = opt.solve(model) -assert str(result.solver.termination_condition) == "optimal" 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/__init__.py b/doc/OnlineDocs/modeling_extensions/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 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/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..be64644a0f2 --- /dev/null +++ b/doc/OnlineDocs/reference/bibliography.rst @@ -0,0 +1,151 @@ +.. _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. 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..830c34f8366 --- /dev/null +++ b/doc/OnlineDocs/reference/index.rst @@ -0,0 +1,17 @@ +Reference Guides +================ + +.. toctree:: + :maxdepth: 2 + + topical/index + 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 99% rename from doc/OnlineDocs/library_reference/appsi/appsi.rst rename to doc/OnlineDocs/reference/topical/appsi/appsi.rst index e26e4b0e82a..4f4a2ffa60c 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: 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 32600b226e1..aa2f46e71fa 100644 --- a/doc/OnlineDocs/src/data/ABCD1.py +++ b/doc/OnlineDocs/src/data/ABCD1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/ABCD2.py b/doc/OnlineDocs/src/data/ABCD2.py index 65a46415368..ec0e7ccb15c 100644 --- a/doc/OnlineDocs/src/data/ABCD2.py +++ b/doc/OnlineDocs/src/data/ABCD2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/ABCD3.py b/doc/OnlineDocs/src/data/ABCD3.py index 48797ced5bb..ba55fd970cc 100644 --- a/doc/OnlineDocs/src/data/ABCD3.py +++ b/doc/OnlineDocs/src/data/ABCD3.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/ABCD4.py b/doc/OnlineDocs/src/data/ABCD4.py index 20f6a21c011..2fb397aa3b0 100644 --- a/doc/OnlineDocs/src/data/ABCD4.py +++ b/doc/OnlineDocs/src/data/ABCD4.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/ABCD5.py b/doc/OnlineDocs/src/data/ABCD5.py index 58461af056b..abc03505e96 100644 --- a/doc/OnlineDocs/src/data/ABCD5.py +++ b/doc/OnlineDocs/src/data/ABCD5.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/ABCD6.py b/doc/OnlineDocs/src/data/ABCD6.py index 961408dbc7e..59e0e8e98ae 100644 --- a/doc/OnlineDocs/src/data/ABCD6.py +++ b/doc/OnlineDocs/src/data/ABCD6.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/ABCD7.py b/doc/OnlineDocs/src/data/ABCD7.py index a97e764fa5a..1bfb4d1e3fb 100644 --- a/doc/OnlineDocs/src/data/ABCD7.py +++ b/doc/OnlineDocs/src/data/ABCD7.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * import pyomo.common import sys diff --git a/doc/OnlineDocs/src/data/ABCD8.py b/doc/OnlineDocs/src/data/ABCD8.py index 9bcd950c681..aa1ba0b4cf5 100644 --- a/doc/OnlineDocs/src/data/ABCD8.py +++ b/doc/OnlineDocs/src/data/ABCD8.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * import pyomo.common import sys diff --git a/doc/OnlineDocs/src/data/ABCD9.py b/doc/OnlineDocs/src/data/ABCD9.py index 29fcb6426db..194c71486d9 100644 --- a/doc/OnlineDocs/src/data/ABCD9.py +++ b/doc/OnlineDocs/src/data/ABCD9.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * import pyomo.common import sys diff --git a/doc/OnlineDocs/src/data/diet1.py b/doc/OnlineDocs/src/data/diet1.py index ef0d8096350..40582e16ba0 100644 --- a/doc/OnlineDocs/src/data/diet1.py +++ b/doc/OnlineDocs/src/data/diet1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # diet1.py from pyomo.environ import * diff --git a/doc/OnlineDocs/src/data/ex.py b/doc/OnlineDocs/src/data/ex.py index 8c9473f2852..a66ee30b494 100644 --- a/doc/OnlineDocs/src/data/ex.py +++ b/doc/OnlineDocs/src/data/ex.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/import1.tab.py b/doc/OnlineDocs/src/data/import1.tab.py index c9164ab73ec..e160e4fdcde 100644 --- a/doc/OnlineDocs/src/data/import1.tab.py +++ b/doc/OnlineDocs/src/data/import1.tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/import2.tab.py b/doc/OnlineDocs/src/data/import2.tab.py index d03f053d090..54339551279 100644 --- a/doc/OnlineDocs/src/data/import2.tab.py +++ b/doc/OnlineDocs/src/data/import2.tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/import3.tab.py b/doc/OnlineDocs/src/data/import3.tab.py index e86557677ee..664151d1438 100644 --- a/doc/OnlineDocs/src/data/import3.tab.py +++ b/doc/OnlineDocs/src/data/import3.tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/import4.tab.py b/doc/OnlineDocs/src/data/import4.tab.py index 93df9c761ab..91dd3f26a42 100644 --- a/doc/OnlineDocs/src/data/import4.tab.py +++ b/doc/OnlineDocs/src/data/import4.tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/import5.tab.py b/doc/OnlineDocs/src/data/import5.tab.py index 1d20476a16f..263677c308c 100644 --- a/doc/OnlineDocs/src/data/import5.tab.py +++ b/doc/OnlineDocs/src/data/import5.tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/import6.tab.py b/doc/OnlineDocs/src/data/import6.tab.py index 8a1ab232f86..8f4824ad3fe 100644 --- a/doc/OnlineDocs/src/data/import6.tab.py +++ b/doc/OnlineDocs/src/data/import6.tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/import7.tab.py b/doc/OnlineDocs/src/data/import7.tab.py index 747d884be31..503f9224323 100644 --- a/doc/OnlineDocs/src/data/import7.tab.py +++ b/doc/OnlineDocs/src/data/import7.tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/import8.tab.py b/doc/OnlineDocs/src/data/import8.tab.py index b7866d7a3e5..02b8724fe45 100644 --- a/doc/OnlineDocs/src/data/import8.tab.py +++ b/doc/OnlineDocs/src/data/import8.tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param1.py b/doc/OnlineDocs/src/data/param1.py index c4bc8de5acc..336a04287b9 100644 --- a/doc/OnlineDocs/src/data/param1.py +++ b/doc/OnlineDocs/src/data/param1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param2.py b/doc/OnlineDocs/src/data/param2.py index f46f05ceebc..a7d0feafff9 100644 --- a/doc/OnlineDocs/src/data/param2.py +++ b/doc/OnlineDocs/src/data/param2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param2a.py b/doc/OnlineDocs/src/data/param2a.py index 4557f63d841..42056793ffd 100644 --- a/doc/OnlineDocs/src/data/param2a.py +++ b/doc/OnlineDocs/src/data/param2a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param3.py b/doc/OnlineDocs/src/data/param3.py index 149155ce67d..952f9a9b707 100644 --- a/doc/OnlineDocs/src/data/param3.py +++ b/doc/OnlineDocs/src/data/param3.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param3a.py b/doc/OnlineDocs/src/data/param3a.py index 0e99cad0c7a..028e1d07296 100644 --- a/doc/OnlineDocs/src/data/param3a.py +++ b/doc/OnlineDocs/src/data/param3a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param3b.py b/doc/OnlineDocs/src/data/param3b.py index deda175ea12..97f8598610a 100644 --- a/doc/OnlineDocs/src/data/param3b.py +++ b/doc/OnlineDocs/src/data/param3b.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param3c.py b/doc/OnlineDocs/src/data/param3c.py index 4056dc8107d..582b0f7db75 100644 --- a/doc/OnlineDocs/src/data/param3c.py +++ b/doc/OnlineDocs/src/data/param3c.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param4.py b/doc/OnlineDocs/src/data/param4.py index 1190dae8dec..010c46fc9c5 100644 --- a/doc/OnlineDocs/src/data/param4.py +++ b/doc/OnlineDocs/src/data/param4.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param5.py b/doc/OnlineDocs/src/data/param5.py index 69f6cc46552..2db07f3f990 100644 --- a/doc/OnlineDocs/src/data/param5.py +++ b/doc/OnlineDocs/src/data/param5.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param5a.py b/doc/OnlineDocs/src/data/param5a.py index 303b92f9f2e..32a53d24e9b 100644 --- a/doc/OnlineDocs/src/data/param5a.py +++ b/doc/OnlineDocs/src/data/param5a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param6.py b/doc/OnlineDocs/src/data/param6.py index c3e4b25d144..e3364a933cf 100644 --- a/doc/OnlineDocs/src/data/param6.py +++ b/doc/OnlineDocs/src/data/param6.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param6a.py b/doc/OnlineDocs/src/data/param6a.py index 07e8280cc18..3d2fa645411 100644 --- a/doc/OnlineDocs/src/data/param6a.py +++ b/doc/OnlineDocs/src/data/param6a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param7a.py b/doc/OnlineDocs/src/data/param7a.py index 3bb68b3f3b7..b3aba9ec23d 100644 --- a/doc/OnlineDocs/src/data/param7a.py +++ b/doc/OnlineDocs/src/data/param7a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param7b.py b/doc/OnlineDocs/src/data/param7b.py index 6e5c857851f..8b022f399a8 100644 --- a/doc/OnlineDocs/src/data/param7b.py +++ b/doc/OnlineDocs/src/data/param7b.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/param8a.py b/doc/OnlineDocs/src/data/param8a.py index 57c9b08ca43..abfa885ded4 100644 --- a/doc/OnlineDocs/src/data/param8a.py +++ b/doc/OnlineDocs/src/data/param8a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/set1.py b/doc/OnlineDocs/src/data/set1.py index 5248e9d5dc9..c84c1ef0819 100644 --- a/doc/OnlineDocs/src/data/set1.py +++ b/doc/OnlineDocs/src/data/set1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/set2.py b/doc/OnlineDocs/src/data/set2.py index 82772f48e46..9048a49fecb 100644 --- a/doc/OnlineDocs/src/data/set2.py +++ b/doc/OnlineDocs/src/data/set2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/set2a.py b/doc/OnlineDocs/src/data/set2a.py index edf28757f96..f2fa4d71916 100644 --- a/doc/OnlineDocs/src/data/set2a.py +++ b/doc/OnlineDocs/src/data/set2a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/set3.py b/doc/OnlineDocs/src/data/set3.py index d58e0c0dd43..9cdacbe39e0 100644 --- a/doc/OnlineDocs/src/data/set3.py +++ b/doc/OnlineDocs/src/data/set3.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/set4.py b/doc/OnlineDocs/src/data/set4.py index 29548519571..b3485638c6f 100644 --- a/doc/OnlineDocs/src/data/set4.py +++ b/doc/OnlineDocs/src/data/set4.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/set5.py b/doc/OnlineDocs/src/data/set5.py index 35acd4e4317..d745d8408d0 100644 --- a/doc/OnlineDocs/src/data/set5.py +++ b/doc/OnlineDocs/src/data/set5.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table0.py b/doc/OnlineDocs/src/data/table0.py index af7f634bd34..de0fae0c861 100644 --- a/doc/OnlineDocs/src/data/table0.py +++ b/doc/OnlineDocs/src/data/table0.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table0.ul.py b/doc/OnlineDocs/src/data/table0.ul.py index 213407b071c..524c3756782 100644 --- a/doc/OnlineDocs/src/data/table0.ul.py +++ b/doc/OnlineDocs/src/data/table0.ul.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table1.py b/doc/OnlineDocs/src/data/table1.py index 1f86508c60a..f36714b8f1f 100644 --- a/doc/OnlineDocs/src/data/table1.py +++ b/doc/OnlineDocs/src/data/table1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table2.py b/doc/OnlineDocs/src/data/table2.py index d7708b9277f..03648a00f8c 100644 --- a/doc/OnlineDocs/src/data/table2.py +++ b/doc/OnlineDocs/src/data/table2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table2.txt b/doc/OnlineDocs/src/data/table2.txt index 60eb55aab4a..a710b6b6042 100644 --- a/doc/OnlineDocs/src/data/table2.txt +++ b/doc/OnlineDocs/src/data/table2.txt @@ -1,13 +1,10 @@ -3 Set Declarations +2 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'B1', 'B2', 'B3'} - N_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 'B1'), ('A1', 'B2'), ('A1', 'B3'), ('A2', 'B1'), ('A2', 'B2'), ('A2', 'B3'), ('A3', 'B1'), ('A3', 'B2'), ('A3', 'B3')} 2 Param Declarations M : Size=3, Index=A, Domain=Any, Default=None, Mutable=False @@ -15,10 +12,10 @@ A1 : 4.3 A2 : 4.4 A3 : 4.5 - N : Size=3, Index=N_index, Domain=Any, Default=None, Mutable=False + N : Size=3, Index=A*B, Domain=Any, Default=None, Mutable=False Key : Value ('A1', 'B1') : 5.3 ('A2', 'B2') : 5.4 ('A3', 'B3') : 5.5 -5 Declarations: A B M N_index N +4 Declarations: A B M N diff --git a/doc/OnlineDocs/src/data/table3.py b/doc/OnlineDocs/src/data/table3.py index fa871a4f79c..2c598f112df 100644 --- a/doc/OnlineDocs/src/data/table3.py +++ b/doc/OnlineDocs/src/data/table3.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table3.txt b/doc/OnlineDocs/src/data/table3.txt index cb5e63b30d4..c0c61cd5a5b 100644 --- a/doc/OnlineDocs/src/data/table3.txt +++ b/doc/OnlineDocs/src/data/table3.txt @@ -1,13 +1,10 @@ -4 Set Declarations +3 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'B1', 'B2', 'B3'} - N_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 'B1'), ('A1', 'B2'), ('A1', 'B3'), ('A2', 'B1'), ('A2', 'B2'), ('A2', 'B3'), ('A3', 'B1'), ('A3', 'B2'), ('A3', 'B3')} Z : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 2 : Any : 3 : {('A1', 'B1'), ('A2', 'B2'), ('A3', 'B3')} @@ -18,10 +15,10 @@ A1 : 4.3 A2 : 4.4 A3 : 4.5 - N : Size=3, Index=N_index, Domain=Any, Default=None, Mutable=False + N : Size=3, Index=A*B, Domain=Any, Default=None, Mutable=False Key : Value ('A1', 'B1') : 5.3 ('A2', 'B2') : 5.4 ('A3', 'B3') : 5.5 -6 Declarations: A B Z M N_index N +5 Declarations: A B Z M N diff --git a/doc/OnlineDocs/src/data/table3.ul.py b/doc/OnlineDocs/src/data/table3.ul.py index 713d36b9f3a..18ced12b388 100644 --- a/doc/OnlineDocs/src/data/table3.ul.py +++ b/doc/OnlineDocs/src/data/table3.ul.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table3.ul.txt b/doc/OnlineDocs/src/data/table3.ul.txt index cb5e63b30d4..c0c61cd5a5b 100644 --- a/doc/OnlineDocs/src/data/table3.ul.txt +++ b/doc/OnlineDocs/src/data/table3.ul.txt @@ -1,13 +1,10 @@ -4 Set Declarations +3 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'B1', 'B2', 'B3'} - N_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 'B1'), ('A1', 'B2'), ('A1', 'B3'), ('A2', 'B1'), ('A2', 'B2'), ('A2', 'B3'), ('A3', 'B1'), ('A3', 'B2'), ('A3', 'B3')} Z : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 2 : Any : 3 : {('A1', 'B1'), ('A2', 'B2'), ('A3', 'B3')} @@ -18,10 +15,10 @@ A1 : 4.3 A2 : 4.4 A3 : 4.5 - N : Size=3, Index=N_index, Domain=Any, Default=None, Mutable=False + N : Size=3, Index=A*B, Domain=Any, Default=None, Mutable=False Key : Value ('A1', 'B1') : 5.3 ('A2', 'B2') : 5.4 ('A3', 'B3') : 5.5 -6 Declarations: A B Z M N_index N +5 Declarations: A B Z M N diff --git a/doc/OnlineDocs/src/data/table4.py b/doc/OnlineDocs/src/data/table4.py index 1af9fe47a44..bd20682b5a9 100644 --- a/doc/OnlineDocs/src/data/table4.py +++ b/doc/OnlineDocs/src/data/table4.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table4.ul.py b/doc/OnlineDocs/src/data/table4.ul.py index 2acf8e21ca8..9f16f21fe19 100644 --- a/doc/OnlineDocs/src/data/table4.ul.py +++ b/doc/OnlineDocs/src/data/table4.ul.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table5.py b/doc/OnlineDocs/src/data/table5.py index 2fe3d08fe91..a3cb01209a2 100644 --- a/doc/OnlineDocs/src/data/table5.py +++ b/doc/OnlineDocs/src/data/table5.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table6.py b/doc/OnlineDocs/src/data/table6.py index fcbc2f10860..1db0a764a23 100644 --- a/doc/OnlineDocs/src/data/table6.py +++ b/doc/OnlineDocs/src/data/table6.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/data/table7.py b/doc/OnlineDocs/src/data/table7.py index f8f8e769b2e..84a841aca86 100644 --- a/doc/OnlineDocs/src/data/table7.py +++ b/doc/OnlineDocs/src/data/table7.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/dataportal/PP_sqlite.py b/doc/OnlineDocs/src/dataportal/PP_sqlite.py index 9c6fc5ddc0b..1592e820900 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-2022 +# 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 diff --git a/doc/OnlineDocs/src/dataportal/dataportal_tab.py b/doc/OnlineDocs/src/dataportal/dataportal_tab.py index d1a75196c99..655329d31de 100644 --- a/doc/OnlineDocs/src/dataportal/dataportal_tab.py +++ b/doc/OnlineDocs/src/dataportal/dataportal_tab.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * # -------------------------------------------------- diff --git a/doc/OnlineDocs/src/dataportal/dataportal_tab.txt b/doc/OnlineDocs/src/dataportal/dataportal_tab.txt index 2e507971157..a23c63d90c9 100644 --- a/doc/OnlineDocs/src/dataportal/dataportal_tab.txt +++ b/doc/OnlineDocs/src/dataportal/dataportal_tab.txt @@ -85,19 +85,16 @@ A3 : 4.5 2 Declarations: A w -3 Set Declarations +2 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} I : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 4 : {'I1', 'I2', 'I3', 'I4'} - u_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : I*A : 12 : {('I1', 'A1'), ('I1', 'A2'), ('I1', 'A3'), ('I2', 'A1'), ('I2', 'A2'), ('I2', 'A3'), ('I3', 'A1'), ('I3', 'A2'), ('I3', 'A3'), ('I4', 'A1'), ('I4', 'A2'), ('I4', 'A3')} 1 Param Declarations - u : Size=12, Index=u_index, Domain=Any, Default=None, Mutable=False + u : Size=12, Index=I*A, Domain=Any, Default=None, Mutable=False Key : Value ('I1', 'A1') : 1.3 ('I1', 'A2') : 2.3 @@ -112,20 +109,17 @@ ('I4', 'A2') : 2.6 ('I4', 'A3') : 3.6 -4 Declarations: A I u_index u -3 Set Declarations +3 Declarations: A I u +2 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} I : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 4 : {'I1', 'I2', 'I3', 'I4'} - t_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*I : 12 : {('A1', 'I1'), ('A1', 'I2'), ('A1', 'I3'), ('A1', 'I4'), ('A2', 'I1'), ('A2', 'I2'), ('A2', 'I3'), ('A2', 'I4'), ('A3', 'I1'), ('A3', 'I2'), ('A3', 'I3'), ('A3', 'I4')} 1 Param Declarations - t : Size=12, Index=t_index, Domain=Any, Default=None, Mutable=False + t : Size=12, Index=A*I, Domain=Any, Default=None, Mutable=False Key : Value ('A1', 'I1') : 1.3 ('A1', 'I2') : 1.4 @@ -140,7 +134,7 @@ ('A3', 'I3') : 3.5 ('A3', 'I4') : 3.6 -4 Declarations: A I t_index t +3 Declarations: A I t 1 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members @@ -185,13 +179,9 @@ None : 1 : Any : 3 : {'A1', 'A2', 'A3'} 1 Declarations: A -1 Set Declarations - y_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {'A1', 'A2', 'A3'} 2 Param Declarations - y : Size=3, Index=y_index, Domain=Any, Default=None, Mutable=False + y : Size=3, Index={A1, A2, A3}, Domain=Any, Default=None, Mutable=False Key : Value A1 : 3.3 A2 : 3.4 @@ -200,7 +190,7 @@ Key : Value None : 1.1 -3 Declarations: z y_index y +2 Declarations: z y ['A1', 'A2', 'A3'] 1.1 A1 3.3 diff --git a/doc/OnlineDocs/src/dataportal/param_initialization.py b/doc/OnlineDocs/src/dataportal/param_initialization.py index 5567b01f284..7f9270b5fda 100644 --- a/doc/OnlineDocs/src/dataportal/param_initialization.py +++ b/doc/OnlineDocs/src/dataportal/param_initialization.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * import numpy diff --git a/doc/OnlineDocs/src/dataportal/param_initialization.txt b/doc/OnlineDocs/src/dataportal/param_initialization.txt index fec8a06a84a..49ea105f120 100644 --- a/doc/OnlineDocs/src/dataportal/param_initialization.txt +++ b/doc/OnlineDocs/src/dataportal/param_initialization.txt @@ -1,24 +1,16 @@ -2 Set Declarations - b_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - c_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - 3 Param Declarations a : Size=1, Index=None, Domain=Any, Default=None, Mutable=False Key : Value None : 1.1 - b : Size=3, Index=b_index, Domain=Any, Default=None, Mutable=False + b : Size=3, Index={1, 2, 3}, Domain=Any, Default=None, Mutable=False Key : Value 1 : 1 2 : 2 3 : 3 - c : Size=3, Index=c_index, Domain=Any, Default=None, Mutable=False + c : Size=3, Index={1, 2, 3}, Domain=Any, Default=None, Mutable=False Key : Value 1 : 1 2 : 2 3 : 3 -5 Declarations: a b_index b c_index c +3 Declarations: a b c diff --git a/doc/OnlineDocs/src/dataportal/set_initialization.py b/doc/OnlineDocs/src/dataportal/set_initialization.py index aa7b426fa82..a5ab03894e3 100644 --- a/doc/OnlineDocs/src/dataportal/set_initialization.py +++ b/doc/OnlineDocs/src/dataportal/set_initialization.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * import numpy diff --git a/doc/OnlineDocs/src/dataportal/set_initialization.txt b/doc/OnlineDocs/src/dataportal/set_initialization.txt index c6be448eba9..3c2960ce4ef 100644 --- a/doc/OnlineDocs/src/dataportal/set_initialization.txt +++ b/doc/OnlineDocs/src/dataportal/set_initialization.txt @@ -1,6 +1,6 @@ WARNING: Initializing ordered Set B with a fundamentally unordered data source (type: set). This WILL potentially lead to nondeterministic behavior in Pyomo -9 Set Declarations +8 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {2, 3, 5} @@ -22,13 +22,10 @@ WARNING: Initializing ordered Set B with a fundamentally unordered data source G : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {2, 3, 5} - H : Size=3, Index=H_index, Ordered=Insertion + H : Size=3, Index={2, 3, 4}, Ordered=Insertion Key : Dimen : Domain : Size : Members 2 : 1 : Any : 3 : {1, 3, 5} 3 : 1 : Any : 3 : {2, 4, 6} 4 : 1 : Any : 3 : {3, 5, 7} - H_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {2, 3, 4} -9 Declarations: A B C D E F G H_index H +8 Declarations: A B C D E F G H diff --git a/doc/OnlineDocs/src/expr/design.py b/doc/OnlineDocs/src/expr/design.py index b122a5f2bf3..647a4537ca4 100644 --- a/doc/OnlineDocs/src/expr/design.py +++ b/doc/OnlineDocs/src/expr/design.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * # --------------------------------------------- diff --git a/doc/OnlineDocs/src/expr/index.py b/doc/OnlineDocs/src/expr/index.py index 9c9c79bf7be..fe5b03461c0 100644 --- a/doc/OnlineDocs/src/expr/index.py +++ b/doc/OnlineDocs/src/expr/index.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * # --------------------------------------------- diff --git a/doc/OnlineDocs/src/expr/managing.py b/doc/OnlineDocs/src/expr/managing.py index 0a59c13bc1b..7342d0616a7 100644 --- a/doc/OnlineDocs/src/expr/managing.py +++ b/doc/OnlineDocs/src/expr/managing.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * from math import isclose import math @@ -90,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 # --------------------------------------------- @@ -111,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 = ConcreteModel() +m.x = Var() +m.p = 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 @@ -170,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 diff --git a/doc/OnlineDocs/src/expr/overview.py b/doc/OnlineDocs/src/expr/overview.py index 6207a4c4288..d33725edb88 100644 --- a/doc/OnlineDocs/src/expr/overview.py +++ b/doc/OnlineDocs/src/expr/overview.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * # --------------------------------------------- diff --git a/doc/OnlineDocs/src/expr/performance.py b/doc/OnlineDocs/src/expr/performance.py index 53ac5bb4f9e..8936bd2ed8c 100644 --- a/doc/OnlineDocs/src/expr/performance.py +++ b/doc/OnlineDocs/src/expr/performance.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * # --------------------------------------------- diff --git a/doc/OnlineDocs/src/expr/quicksum.py b/doc/OnlineDocs/src/expr/quicksum.py index a1ad9660664..1b6cd3f9909 100644 --- a/doc/OnlineDocs/src/expr/quicksum.py +++ b/doc/OnlineDocs/src/expr/quicksum.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * from pyomo.repn import generate_standard_repn import 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/src/kernel/examples.txt b/doc/OnlineDocs/src/kernel/examples.txt index e85c64efd86..8ba072d28b1 100644 --- a/doc/OnlineDocs/src/kernel/examples.txt +++ b/doc/OnlineDocs/src/kernel/examples.txt @@ -1,22 +1,7 @@ -6 Set Declarations - cd_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : s*q : 6 : {(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3)} - cl_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - ol_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} +1 Set Declarations s : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 2 : {1, 2} - sd_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {1, 2} - vl_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} 1 RangeSet Declarations q : Dimen=1, Size=3, Bounds=(1, 3) @@ -43,7 +28,7 @@ Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : None : None : 9 : False : True : Reals 2 : None : None : 9 : False : True : Reals - vl : Size=3, Index=vl_index + vl : Size=3, Index={1, 2, 3} Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : 1 : None : None : False : True : Reals 2 : 2 : None : None : False : True : Reals @@ -66,7 +51,7 @@ Key : Active : Sense : Expression 1 : True : minimize : - vd[1] 2 : True : minimize : - vd[2] - ol : Size=3, Index=ol_index, Active=True + ol : Size=3, Index={1, 2, 3}, Active=True Key : Active : Sense : Expression 1 : True : minimize : - vl[1] 2 : True : minimize : - vl[2] @@ -76,7 +61,7 @@ c : Size=1, Index=None, Active=True Key : Lower : Body : Upper : Active None : -Inf : vd[1] + vd[2] : 9.0 : True - cd : Size=6, Index=cd_index, Active=True + cd : Size=6, Index=s*q, Active=True Key : Lower : Body : Upper : Active (1, 1) : 1.0 : vd[1] : 1.0 : True (1, 2) : 2.0 : vd[1] : 2.0 : True @@ -84,14 +69,14 @@ (2, 1) : 1.0 : vd[2] : 1.0 : True (2, 2) : 2.0 : vd[2] : 2.0 : True (2, 3) : 3.0 : vd[2] : 3.0 : True - cl : Size=3, Index=cl_index, Active=True + cl : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : -5.0 : vl[1] - v : 5.0 : True 2 : -5.0 : vl[2] - v : 5.0 : True 3 : -5.0 : vl[3] - v : 5.0 : True 3 SOSConstraint Declarations - sd : Size=2 Index= sd_index + sd : Size=2 Index= OrderedScalarSet 1 Type=1 Weight : Variable @@ -119,16 +104,8 @@ b : Size=1, Index=None, Active=True 0 Declarations: pw : Size=1, Index=None, Active=True - 2 Set Declarations - SOS2_constraint_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - SOS2_y_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 4 : {0, 1, 2, 3} - 1 Var Declarations - SOS2_y : Size=4, Index=pw.SOS2_y_index + SOS2_y : Size=4, Index={0, 1, 2, 3} Key : Lower : Value : Upper : Fixed : Stale : Domain 0 : 0 : None : None : False : True : NonNegativeReals 1 : 0 : None : None : False : True : NonNegativeReals @@ -136,7 +113,7 @@ 3 : 0 : None : None : False : True : NonNegativeReals 1 Constraint Declarations - SOS2_constraint : Size=3, Index=pw.SOS2_constraint_index, Active=True + SOS2_constraint : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : v - (pw.SOS2_y[0] + 2*pw.SOS2_y[1] + 3*pw.SOS2_y[2] + 4*pw.SOS2_y[3]) : 0.0 : True 2 : 0.0 : f - (pw.SOS2_y[0] + 2*pw.SOS2_y[1] + pw.SOS2_y[2] + 2*pw.SOS2_y[3]) : 0.0 : True @@ -151,13 +128,13 @@ 3 : pw.SOS2_y[2] 4 : pw.SOS2_y[3] - 5 Declarations: SOS2_y_index SOS2_y SOS2_constraint_index SOS2_constraint SOS2_sosconstraint + 3 Declarations: SOS2_y SOS2_constraint SOS2_sosconstraint 1 Suffix Declarations dual : Direction=IMPORT, Datatype=FLOAT Key : Value -27 Declarations: b s q p pd v vd vl_index vl c cd_index cd cl_index cl e ed o od ol_index ol sos1 sos2 sd_index sd dual f pw +22 Declarations: b s q p pd v vd vl c cd cl e ed o od ol sos1 sos2 sd dual f pw : block(active=True, ctype=IBlock) - b: block(active=True, ctype=IBlock) - p: parameter(active=True, value=0) @@ -231,4 +208,4 @@ - pw.c[2]: linear_constraint(active=True, expr=pw.v[0] + pw.v[1] + pw.v[2] + pw.v[3] == 1) - pw.s: sos(active=True, level=2, entries=['(pw.v[0],1)', '(pw.v[1],2)', '(pw.v[2],3)', '(pw.v[3],4)']) Memory: 1.9 KB -Memory: 9.5 KB +Memory: 9.7 KB diff --git a/doc/OnlineDocs/library_reference/kernel/examples/aml_example.py b/doc/OnlineDocs/src/kernel/examples/aml_example.py similarity index 83% rename from doc/OnlineDocs/library_reference/kernel/examples/aml_example.py rename to doc/OnlineDocs/src/kernel/examples/aml_example.py index 146048a6046..a640b94cc76 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/aml_example.py +++ b/doc/OnlineDocs/src/kernel/examples/aml_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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_Syntax import pyomo.environ as aml diff --git a/pyomo/core/base/rangeset.py b/doc/OnlineDocs/src/kernel/examples/conic.py similarity index 53% rename from pyomo/core/base/rangeset.py rename to doc/OnlineDocs/src/kernel/examples/conic.py index 18dedb84c34..0418d188722 100644 --- a/pyomo/core/base/rangeset.py +++ b/doc/OnlineDocs/src/kernel/examples/conic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,14 +9,25 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['RangeSet'] +# @Class +import pyomo.kernel as pmo -from .set import RangeSet +m = pmo.block() +m.x1 = pmo.variable(lb=0) +m.x2 = pmo.variable() +m.r = pmo.variable(lb=0) +m.q = pmo.conic.primal_exponential(x1=m.x1, x2=m.x2, r=m.r) +# @Class +del m -from pyomo.common.deprecation import deprecation_warning +# @Domain +import pyomo.kernel as pmo +import math -deprecation_warning( - 'The pyomo.core.base.rangeset module is deprecated. ' - 'Import RangeSet objects from pyomo.core.base.set or pyomo.core.', - version='5.7', +m = pmo.block() +m.x = pmo.variable(lb=0) +m.y = pmo.variable(lb=0) +m.b = pmo.conic.primal_exponential.as_domain( + x1=math.sqrt(2) * m.x, x2=2.0, r=2 * (m.x + m.y) ) +# @Domain diff --git a/doc/OnlineDocs/src/kernel/examples/kernel_containers.py b/doc/OnlineDocs/src/kernel/examples/kernel_containers.py new file mode 100644 index 00000000000..1931c6d9b56 --- /dev/null +++ b/doc/OnlineDocs/src/kernel/examples/kernel_containers.py @@ -0,0 +1,18 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel + +# @all +vlist = pyomo.kernel.variable_list() +vlist.append(pyomo.kernel.variable_dict()) +vlist[0]['x'] = pyomo.kernel.variable() +# @all diff --git a/doc/OnlineDocs/library_reference/kernel/examples/kernel_example.py b/doc/OnlineDocs/src/kernel/examples/kernel_example.py similarity index 84% rename from doc/OnlineDocs/library_reference/kernel/examples/kernel_example.py rename to doc/OnlineDocs/src/kernel/examples/kernel_example.py index 1caf064bb2a..1f80bce9788 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/kernel_example.py +++ b/doc/OnlineDocs/src/kernel/examples/kernel_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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_Syntax import pyomo.kernel as pmo diff --git a/pyomo/solvers/tests/mip/test_mip.py b/doc/OnlineDocs/src/kernel/examples/kernel_solving.py similarity index 63% rename from pyomo/solvers/tests/mip/test_mip.py rename to doc/OnlineDocs/src/kernel/examples/kernel_solving.py index 0257e65de20..13d7efc052a 100644 --- a/pyomo/solvers/tests/mip/test_mip.py +++ b/doc/OnlineDocs/src/kernel/examples/kernel_solving.py @@ -1,23 +1,22 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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. # ___________________________________________________________________________ -# -# Tests driven by test_mip.yml -# -import os -from os.path import abspath, dirname +import pyomo.kernel as pmo -currdir = dirname(abspath(__file__)) + os.sep +model = pmo.block() +model.x = pmo.variable() +model.c = pmo.constraint(model.x >= 1) +model.o = pmo.objective(model.x) -import pyomo.common.unittest as unittest +opt = pmo.SolverFactory("ipopt") -if __name__ == "__main__": - unittest.main() +result = opt.solve(model) +assert str(result.solver.termination_condition) == "optimal" diff --git a/doc/OnlineDocs/library_reference/kernel/examples/kernel_subclassing.py b/doc/OnlineDocs/src/kernel/examples/kernel_subclassing.py similarity index 76% rename from doc/OnlineDocs/library_reference/kernel/examples/kernel_subclassing.py rename to doc/OnlineDocs/src/kernel/examples/kernel_subclassing.py index c21c6dc890b..d6e38f6b0e0 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/kernel_subclassing.py +++ b/doc/OnlineDocs/src/kernel/examples/kernel_subclassing.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel diff --git a/doc/OnlineDocs/library_reference/kernel/examples/transformer.py b/doc/OnlineDocs/src/kernel/examples/transformer.py similarity index 71% rename from doc/OnlineDocs/library_reference/kernel/examples/transformer.py rename to doc/OnlineDocs/src/kernel/examples/transformer.py index 66893008cf9..43a1d0675bf 100644 --- a/doc/OnlineDocs/library_reference/kernel/examples/transformer.py +++ b/doc/OnlineDocs/src/kernel/examples/transformer.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ import pyomo.kernel diff --git a/doc/OnlineDocs/src/scripting/AbstractSuffixes.py b/doc/OnlineDocs/src/scripting/AbstractSuffixes.py index 20a4cc20581..1c064042c6b 100644 --- a/doc/OnlineDocs/src/scripting/AbstractSuffixes.py +++ b/doc/OnlineDocs/src/scripting/AbstractSuffixes.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/doc/OnlineDocs/src/scripting/Isinglebuild.py b/doc/OnlineDocs/src/scripting/Isinglebuild.py index 00f79c9a750..344f8905a4a 100644 --- a/doc/OnlineDocs/src/scripting/Isinglebuild.py +++ b/doc/OnlineDocs/src/scripting/Isinglebuild.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Isinglebuild.py # NodesIn and NodesOut are created by a build action using the Arcs from pyomo.environ import * diff --git a/doc/OnlineDocs/src/scripting/NodesIn_init.py b/doc/OnlineDocs/src/scripting/NodesIn_init.py index 4a90029baa3..c17b70150bc 100644 --- a/doc/OnlineDocs/src/scripting/NodesIn_init.py +++ b/doc/OnlineDocs/src/scripting/NodesIn_init.py @@ -1,3 +1,15 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + def NodesIn_init(model, node): retval = [] for i, j in model.Arcs: diff --git a/doc/OnlineDocs/src/scripting/Z_init.py b/doc/OnlineDocs/src/scripting/Z_init.py index 426de6f7d08..1dd2843f4f0 100644 --- a/doc/OnlineDocs/src/scripting/Z_init.py +++ b/doc/OnlineDocs/src/scripting/Z_init.py @@ -1,3 +1,15 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + def Z_init(model, i): if i > 10: return Set.End diff --git a/doc/OnlineDocs/src/scripting/abstract2.py b/doc/OnlineDocs/src/scripting/abstract2.py index 1e14d1d1898..544399a8a42 100644 --- a/doc/OnlineDocs/src/scripting/abstract2.py +++ b/doc/OnlineDocs/src/scripting/abstract2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # abstract2.py diff --git a/doc/OnlineDocs/src/scripting/abstract2piece.py b/doc/OnlineDocs/src/scripting/abstract2piece.py index 225ec0d1a64..03c5139004e 100644 --- a/doc/OnlineDocs/src/scripting/abstract2piece.py +++ b/doc/OnlineDocs/src/scripting/abstract2piece.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # abstract2piece.py # Similar to abstract2.py, but the objective is now c times x to the fourth power diff --git a/doc/OnlineDocs/src/scripting/abstract2piecebuild.py b/doc/OnlineDocs/src/scripting/abstract2piecebuild.py index 1f00cdb0265..d454d7fbc79 100644 --- a/doc/OnlineDocs/src/scripting/abstract2piecebuild.py +++ b/doc/OnlineDocs/src/scripting/abstract2piecebuild.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # abstract2piecebuild.py # Similar to abstract2piece.py, but the breakpoints are created using a build action diff --git a/doc/OnlineDocs/src/scripting/block_iter_example.py b/doc/OnlineDocs/src/scripting/block_iter_example.py index 680e0d1728b..10c8a4ea43d 100644 --- a/doc/OnlineDocs/src/scripting/block_iter_example.py +++ b/doc/OnlineDocs/src/scripting/block_iter_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # written by jds, adapted for doc by dlw from pyomo.environ import * diff --git a/doc/OnlineDocs/src/scripting/concrete1.py b/doc/OnlineDocs/src/scripting/concrete1.py index 2cd1a1f722c..399715efde6 100644 --- a/doc/OnlineDocs/src/scripting/concrete1.py +++ b/doc/OnlineDocs/src/scripting/concrete1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = ConcreteModel() diff --git a/doc/OnlineDocs/src/scripting/doubleA.py b/doc/OnlineDocs/src/scripting/doubleA.py index 12a07944db3..abf35979a05 100644 --- a/doc/OnlineDocs/src/scripting/doubleA.py +++ b/doc/OnlineDocs/src/scripting/doubleA.py @@ -1,3 +1,15 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + def doubleA_init(model): return (i * 2 for i in model.A) diff --git a/doc/OnlineDocs/src/scripting/driveabs2.py b/doc/OnlineDocs/src/scripting/driveabs2.py index 45862195a57..f8f972460b1 100644 --- a/doc/OnlineDocs/src/scripting/driveabs2.py +++ b/doc/OnlineDocs/src/scripting/driveabs2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # driveabs2.py import pyomo.environ as pyo diff --git a/doc/OnlineDocs/src/scripting/driveconc1.py b/doc/OnlineDocs/src/scripting/driveconc1.py index 95b0f42806d..49b92f32d09 100644 --- a/doc/OnlineDocs/src/scripting/driveconc1.py +++ b/doc/OnlineDocs/src/scripting/driveconc1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # driveconc1.py import pyomo.environ as pyo diff --git a/doc/OnlineDocs/src/scripting/iterative1.py b/doc/OnlineDocs/src/scripting/iterative1.py index 61b0fd3828e..939120e834f 100644 --- a/doc/OnlineDocs/src/scripting/iterative1.py +++ b/doc/OnlineDocs/src/scripting/iterative1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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_symbols_for_pyomo # iterative1.py import pyomo.environ as pyo diff --git a/doc/OnlineDocs/src/scripting/iterative2.py b/doc/OnlineDocs/src/scripting/iterative2.py index e559a2c8400..7506337a491 100644 --- a/doc/OnlineDocs/src/scripting/iterative2.py +++ b/doc/OnlineDocs/src/scripting/iterative2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # iterative2.py import pyomo.environ as pyo diff --git a/doc/OnlineDocs/src/scripting/noiteration1.py b/doc/OnlineDocs/src/scripting/noiteration1.py index be9fb529855..c7a86e9d1e9 100644 --- a/doc/OnlineDocs/src/scripting/noiteration1.py +++ b/doc/OnlineDocs/src/scripting/noiteration1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # noiteration1.py import pyomo.environ as pyo diff --git a/doc/OnlineDocs/src/scripting/parallel.py b/doc/OnlineDocs/src/scripting/parallel.py index cf9b55d9605..e6cfa002780 100644 --- a/doc/OnlineDocs/src/scripting/parallel.py +++ b/doc/OnlineDocs/src/scripting/parallel.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # parallel.py # run with mpirun -np 2 python -m mpi4py parallel.py import pyomo.environ as pyo diff --git a/doc/OnlineDocs/src/scripting/spy4Constraints.py b/doc/OnlineDocs/src/scripting/spy4Constraints.py index f0033bbc33e..66f82802402 100644 --- a/doc/OnlineDocs/src/scripting/spy4Constraints.py +++ b/doc/OnlineDocs/src/scripting/spy4Constraints.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ David L. Woodruff and Mingye Yang, Spring 2018 Code snippets for Constraints.rst in testable form diff --git a/doc/OnlineDocs/src/scripting/spy4Expressions.py b/doc/OnlineDocs/src/scripting/spy4Expressions.py index 0e8a50c78b3..cf7ed1f112f 100644 --- a/doc/OnlineDocs/src/scripting/spy4Expressions.py +++ b/doc/OnlineDocs/src/scripting/spy4Expressions.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ David L. Woodruff and Mingye Yang, Spring 2018 Code snippets for Expressions.rst in testable form diff --git a/doc/OnlineDocs/src/scripting/spy4PyomoCommand.py b/doc/OnlineDocs/src/scripting/spy4PyomoCommand.py index f655b812076..9f6698d63c9 100644 --- a/doc/OnlineDocs/src/scripting/spy4PyomoCommand.py +++ b/doc/OnlineDocs/src/scripting/spy4PyomoCommand.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ David L. Woodruff and Mingye Yang, Spring 2018 Code snippets for PyomoCommand.rst in testable form diff --git a/doc/OnlineDocs/src/scripting/spy4Variables.py b/doc/OnlineDocs/src/scripting/spy4Variables.py index c4e2ff612f1..1bc2dc9f1ef 100644 --- a/doc/OnlineDocs/src/scripting/spy4Variables.py +++ b/doc/OnlineDocs/src/scripting/spy4Variables.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ David L. Woodruff and Mingye Yang, Spring 2018 Code snippets for Variables.rst in testable form diff --git a/doc/OnlineDocs/src/scripting/spy4scripts.py b/doc/OnlineDocs/src/scripting/spy4scripts.py index 48ba923d09c..f71a1b67b11 100644 --- a/doc/OnlineDocs/src/scripting/spy4scripts.py +++ b/doc/OnlineDocs/src/scripting/spy4scripts.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + ###NOTE: as of May 16, this will not even come close to running. DLW ### and it is "wrong" in a lot of places. ### Someone should edit this file, then delete these comment lines. DLW may 16 diff --git a/doc/OnlineDocs/src/strip_examples.py b/doc/OnlineDocs/src/strip_examples.py index 045af6b87cc..2fd03256499 100644 --- a/doc/OnlineDocs/src/strip_examples.py +++ b/doc/OnlineDocs/src/strip_examples.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # This script finds all *.py files in the current and subdirectories. # It processes these files to find blocks that start/end with "# @" diff --git a/doc/OnlineDocs/src/test_examples.py b/doc/OnlineDocs/src/test_examples.py index a7991eadf19..c5c9a135ee9 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-2022 +# 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 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 11f35fddd13..7e11ec59263 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-2022 +# 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 diff --git a/examples/dae/Optimal_Control.py b/examples/dae/Optimal_Control.py index ed44d5eeb59..676c95271f2 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-2022 +# 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 diff --git a/examples/dae/PDE_example.py b/examples/dae/PDE_example.py index 6cb7eb4a7fe..0aea173415b 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-2022 +# 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 diff --git a/examples/dae/Parameter_Estimation.py b/examples/dae/Parameter_Estimation.py index 7ee2f112b94..332a21d93dc 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-2022 +# 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 diff --git a/examples/dae/Path_Constraint.py b/examples/dae/Path_Constraint.py index 866b4b3b90a..69f31980c63 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-2022 +# 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 diff --git a/examples/dae/ReactionKinetics.py b/examples/dae/ReactionKinetics.py index ef760820c4b..2e474ae40d3 100644 --- a/examples/dae/ReactionKinetics.py +++ b/examples/dae/ReactionKinetics.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -304,7 +304,10 @@ def regression_model(): # Model & data from: # - # http://www.doiserbia.nb.rs/img/doi/0367-598X/2014/0367-598X1300037A.pdf + # https://doiserbia.nb.rs/img/doi/0367-598X/2014/0367-598X1300037A.pdf + # Almagrbi, A. M., Hatami, T., Glišić, S., & Orlović, A. (2014). + # Determination of kinetic parameters for complex transesterification + # reaction by standard optimisation methods. # model = ConcreteModel() diff --git a/examples/dae/car_example.py b/examples/dae/car_example.py index a157159cf6c..b6ca2203860 100644 --- a/examples/dae/car_example.py +++ b/examples/dae/car_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Ampl Car Example # # Shows how to convert a minimize final time optimal control problem diff --git a/examples/dae/disease_DAE.py b/examples/dae/disease_DAE.py index 59e598aa504..bfeb2530fc9 100644 --- a/examples/dae/disease_DAE.py +++ b/examples/dae/disease_DAE.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + ### # SIR disease model using radau collocation ### diff --git a/examples/dae/distill_DAE.py b/examples/dae/distill_DAE.py index cdfd543f9a8..e822cfb1752 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-2022 +# 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 diff --git a/examples/dae/dynamic_scheduling.py b/examples/dae/dynamic_scheduling.py index 13cabeb5bcf..137307e31a9 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-2022 +# 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 diff --git a/examples/dae/laplace_BVP.py b/examples/dae/laplace_BVP.py index 6b2e2841575..61f911b3826 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-2022 +# 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 diff --git a/examples/dae/run_Optimal_Control.py b/examples/dae/run_Optimal_Control.py index 2523bd8c607..2e7bc79dff4 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-2022 +# 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 diff --git a/examples/dae/run_Parameter_Estimation.py b/examples/dae/run_Parameter_Estimation.py index a319000cb59..c9b649df8dd 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-2022 +# 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 diff --git a/examples/dae/run_Path_Constraint.py b/examples/dae/run_Path_Constraint.py index 17a576a57d8..996b432a555 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-2022 +# 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 diff --git a/examples/dae/run_disease.py b/examples/dae/run_disease.py index 139046d434e..5d9595a89d5 100644 --- a/examples/dae/run_disease.py +++ b/examples/dae/run_disease.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * from pyomo.dae import * from disease_DAE import model diff --git a/examples/dae/run_distill.py b/examples/dae/run_distill.py index d9ececf34fc..9b09850f90a 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-2022 +# 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 diff --git a/examples/dae/run_stochpdegas_automatic.py b/examples/dae/run_stochpdegas_automatic.py index dd710588406..6fc9f6d594c 100644 --- a/examples/dae/run_stochpdegas_automatic.py +++ b/examples/dae/run_stochpdegas_automatic.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 time from pyomo.environ import * diff --git a/examples/dae/simulator_dae_example.py b/examples/dae/simulator_dae_example.py index ef6484be6c6..4ea1f9fd5f0 100644 --- a/examples/dae/simulator_dae_example.py +++ b/examples/dae/simulator_dae_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # Batch reactor example from Biegler book on Nonlinear Programming Chapter 9 # diff --git a/examples/dae/simulator_dae_multindex_example.py b/examples/dae/simulator_dae_multindex_example.py index d1a97fec79f..775eb4f8c79 100644 --- a/examples/dae/simulator_dae_multindex_example.py +++ b/examples/dae/simulator_dae_multindex_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # Batch reactor example from Biegler book on Nonlinear Programming Chapter 9 # diff --git a/examples/dae/simulator_ode_example.py b/examples/dae/simulator_ode_example.py index bf600cf163e..f6f28b87d07 100644 --- a/examples/dae/simulator_ode_example.py +++ b/examples/dae/simulator_ode_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # Example from Scipy odeint examples # diff --git a/examples/dae/simulator_ode_multindex_example.py b/examples/dae/simulator_ode_multindex_example.py index fa2623f4cc2..b1b9111084b 100644 --- a/examples/dae/simulator_ode_multindex_example.py +++ b/examples/dae/simulator_ode_multindex_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # Example from Scipy odeint examples # diff --git a/examples/dae/stochpdegas_automatic.py b/examples/dae/stochpdegas_automatic.py index fdde099a396..397b4a18100 100644 --- a/examples/dae/stochpdegas_automatic.py +++ b/examples/dae/stochpdegas_automatic.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # stochastic pde model for natural gas network # victor m. zavala / 2013 diff --git a/examples/doc/samples/__init__.py b/examples/doc/samples/__init__.py index 3115f06ef53..0110902b288 100644 --- a/examples/doc/samples/__init__.py +++ b/examples/doc/samples/__init__.py @@ -1 +1,12 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Dummy file for pytest diff --git a/examples/doc/samples/case_studies/deer/DeerProblem.py b/examples/doc/samples/case_studies/deer/DeerProblem.py index 0b6b7252aaa..d09c9b53887 100644 --- a/examples/doc/samples/case_studies/deer/DeerProblem.py +++ b/examples/doc/samples/case_studies/deer/DeerProblem.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * # diff --git a/examples/doc/samples/case_studies/diet/DietProblem.py b/examples/doc/samples/case_studies/diet/DietProblem.py index f070201c28e..64624310943 100644 --- a/examples/doc/samples/case_studies/diet/DietProblem.py +++ b/examples/doc/samples/case_studies/diet/DietProblem.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * model = AbstractModel() diff --git a/examples/doc/samples/case_studies/diet/DietProblem.tex b/examples/doc/samples/case_studies/diet/DietProblem.tex index d933e097d88..e2ae7ba4c62 100644 --- a/examples/doc/samples/case_studies/diet/DietProblem.tex +++ b/examples/doc/samples/case_studies/diet/DietProblem.tex @@ -54,7 +54,7 @@ \subsection*{Build the model} 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} diff --git a/examples/doc/samples/case_studies/diet/README.txt b/examples/doc/samples/case_studies/diet/README.txt index c30e963dc27..c382b4d653c 100644 --- a/examples/doc/samples/case_studies/diet/README.txt +++ b/examples/doc/samples/case_studies/diet/README.txt @@ -68,7 +68,7 @@ model.nutrient_value=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 diff --git a/examples/doc/samples/case_studies/disease_est/DiseaseEstimation.py b/examples/doc/samples/case_studies/disease_est/DiseaseEstimation.py index c685a6ee67f..6a0edb38350 100644 --- a/examples/doc/samples/case_studies/disease_est/DiseaseEstimation.py +++ b/examples/doc/samples/case_studies/disease_est/DiseaseEstimation.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * model = AbstractModel() diff --git a/examples/doc/samples/case_studies/max_flow/MaxFlow.py b/examples/doc/samples/case_studies/max_flow/MaxFlow.py index c6eb42ccf7d..1e75fa4e79d 100644 --- a/examples/doc/samples/case_studies/max_flow/MaxFlow.py +++ b/examples/doc/samples/case_studies/max_flow/MaxFlow.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * model = AbstractModel() diff --git a/examples/doc/samples/case_studies/network_flow/networkFlow1.py b/examples/doc/samples/case_studies/network_flow/networkFlow1.py index adfaab4476b..eb8c8e48a1a 100644 --- a/examples/doc/samples/case_studies/network_flow/networkFlow1.py +++ b/examples/doc/samples/case_studies/network_flow/networkFlow1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * model = AbstractModel() diff --git a/examples/doc/samples/case_studies/rosen/Rosenbrock.py b/examples/doc/samples/case_studies/rosen/Rosenbrock.py index 9677cea95dd..51e7d51b57d 100644 --- a/examples/doc/samples/case_studies/rosen/Rosenbrock.py +++ b/examples/doc/samples/case_studies/rosen/Rosenbrock.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # @intro: from pyomo.core import * diff --git a/examples/doc/samples/case_studies/transportation/transportation.py b/examples/doc/samples/case_studies/transportation/transportation.py index 26fcb5f0b66..588ae764953 100644 --- a/examples/doc/samples/case_studies/transportation/transportation.py +++ b/examples/doc/samples/case_studies/transportation/transportation.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * model = AbstractModel() diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_cplex.py b/examples/doc/samples/comparisons/cutstock/cutstock_cplex.py index 796c39810f8..f49c5b591ae 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_cplex.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_cplex.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 cplex from cutstock_util import * from cplex.exceptions import CplexSolverError diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_grb.py b/examples/doc/samples/comparisons/cutstock/cutstock_grb.py index 4fa4556fc96..483d84b02e6 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_grb.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_grb.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 gurobipy import * from cutstock_util import * diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_lpsolve.py b/examples/doc/samples/comparisons/cutstock/cutstock_lpsolve.py index 658ee006c30..9a6c8301e8f 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_lpsolve.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_lpsolve.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 lpsolve55 import * from cutstock_util import * diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_pulpor.py b/examples/doc/samples/comparisons/cutstock/cutstock_pulpor.py index 2f2506ba3d6..d14b0fe46c1 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_pulpor.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_pulpor.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pulp import * from cutstock_util import * diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_pyomo.py b/examples/doc/samples/comparisons/cutstock/cutstock_pyomo.py index a67ebdd0675..48d7e6b26fd 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_pyomo.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_pyomo.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * import pyomo.opt from cutstock_util import * diff --git a/examples/doc/samples/comparisons/cutstock/cutstock_util.py b/examples/doc/samples/comparisons/cutstock/cutstock_util.py index 1cd8c61922f..da5349ec06c 100644 --- a/examples/doc/samples/comparisons/cutstock/cutstock_util.py +++ b/examples/doc/samples/comparisons/cutstock/cutstock_util.py @@ -1,3 +1,15 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + def getCutCount(): cutCount = 0 fout1 = open('WidthDemand.csv', 'r') diff --git a/examples/doc/samples/comparisons/sched/pyomo/sched.py b/examples/doc/samples/comparisons/sched/pyomo/sched.py index 627bc083fbe..cf781713641 100644 --- a/examples/doc/samples/comparisons/sched/pyomo/sched.py +++ b/examples/doc/samples/comparisons/sched/pyomo/sched.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * model = AbstractModel() diff --git a/examples/doc/samples/scripts/__init__.py b/examples/doc/samples/scripts/__init__.py index 3115f06ef53..0110902b288 100644 --- a/examples/doc/samples/scripts/__init__.py +++ b/examples/doc/samples/scripts/__init__.py @@ -1 +1,12 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Dummy file for pytest diff --git a/examples/doc/samples/scripts/s1/knapsack.py b/examples/doc/samples/scripts/s1/knapsack.py index 642e0faaaed..cee3937b668 100644 --- a/examples/doc/samples/scripts/s1/knapsack.py +++ b/examples/doc/samples/scripts/s1/knapsack.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * diff --git a/examples/doc/samples/scripts/s1/script.py b/examples/doc/samples/scripts/s1/script.py index 02b6b406922..4ddaea45e19 100644 --- a/examples/doc/samples/scripts/s1/script.py +++ b/examples/doc/samples/scripts/s1/script.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * import pyomo.opt import pyomo.environ diff --git a/examples/doc/samples/scripts/s2/knapsack.py b/examples/doc/samples/scripts/s2/knapsack.py index a7d693f5d35..3131cee7bc5 100644 --- a/examples/doc/samples/scripts/s2/knapsack.py +++ b/examples/doc/samples/scripts/s2/knapsack.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * diff --git a/examples/doc/samples/scripts/s2/script.py b/examples/doc/samples/scripts/s2/script.py index 88de1dec680..fe97d6ab8fd 100644 --- a/examples/doc/samples/scripts/s2/script.py +++ b/examples/doc/samples/scripts/s2/script.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * import pyomo.opt import pyomo.environ diff --git a/examples/doc/samples/scripts/test_scripts.py b/examples/doc/samples/scripts/test_scripts.py index ca0c8a7cc4e..691a44aea2d 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-2022 +# 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 diff --git a/examples/doc/samples/update.py b/examples/doc/samples/update.py index 9eae2f4b694..8789413303c 100644 --- a/examples/doc/samples/update.py +++ b/examples/doc/samples/update.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + #!/usr/bin/env python # # This is a Python script that regenerates the top-level TRAC.txt file, which diff --git a/examples/gdp/batchProcessing.py b/examples/gdp/batchProcessing.py index f0980dd5034..9810f5d63f1 100644 --- a/examples/gdp/batchProcessing.py +++ b/examples/gdp/batchProcessing.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * from pyomo.gdp import * diff --git a/examples/gdp/circles/circles.py b/examples/gdp/circles/circles.py index ae905998403..586cc7d3af3 100644 --- a/examples/gdp/circles/circles.py +++ b/examples/gdp/circles/circles.py @@ -1,6 +1,17 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ -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 245aa2df58e..d38fd0cc66b 100644 --- a/examples/gdp/constrained_layout/cons_layout_model.py +++ b/examples/gdp/constrained_layout/cons_layout_model.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """2-D constrained layout example. Example based on: https://www.minlp.org/library/problem/index.php?i=107&lib=GDP diff --git a/examples/gdp/disease_model.py b/examples/gdp/disease_model.py index bc3e69600ec..498337e35e6 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-2022 +# 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 diff --git a/examples/gdp/eight_process/eight_proc_logical.py b/examples/gdp/eight_process/eight_proc_logical.py index 60f7acee876..4496427d421 100644 --- a/examples/gdp/eight_process/eight_proc_logical.py +++ b/examples/gdp/eight_process/eight_proc_logical.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Disjunctive re-implementation of eight-process problem. Re-implementation of Duran example 3 superstructure synthesis problem in Pyomo diff --git a/examples/gdp/eight_process/eight_proc_model.py b/examples/gdp/eight_process/eight_proc_model.py index 840b6911d83..41bb6d462f1 100644 --- a/examples/gdp/eight_process/eight_proc_model.py +++ b/examples/gdp/eight_process/eight_proc_model.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Disjunctive re-implementation of eight-process problem. Re-implementation of Duran example 3 superstructure synthesis problem in Pyomo diff --git a/examples/gdp/eight_process/eight_proc_verbose_model.py b/examples/gdp/eight_process/eight_proc_verbose_model.py index cae584d4127..1fd68909146 100644 --- a/examples/gdp/eight_process/eight_proc_verbose_model.py +++ b/examples/gdp/eight_process/eight_proc_verbose_model.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Disjunctive re-implementation of eight-process problem. This is the more verbose formulation of the same problem given in diff --git a/examples/gdp/farm_layout/farm_layout.py b/examples/gdp/farm_layout/farm_layout.py index 411e2de3242..487b3b73cd2 100644 --- a/examples/gdp/farm_layout/farm_layout.py +++ b/examples/gdp/farm_layout/farm_layout.py @@ -1,9 +1,20 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ 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 bc656dc4717..0cd5b5ab274 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-2022 +# 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 diff --git a/examples/gdp/jobshop.py b/examples/gdp/jobshop.py index 619ece47e72..7119ee7655c 100644 --- a/examples/gdp/jobshop.py +++ b/examples/gdp/jobshop.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/gdp/medTermPurchasing_Literal.py b/examples/gdp/medTermPurchasing_Literal.py index c9b27920396..b6d16c216fe 100755 --- a/examples/gdp/medTermPurchasing_Literal.py +++ b/examples/gdp/medTermPurchasing_Literal.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * from pyomo.gdp import * diff --git a/examples/gdp/nine_process/small_process.py b/examples/gdp/nine_process/small_process.py index 2758069f316..d5b7ac5d327 100644 --- a/examples/gdp/nine_process/small_process.py +++ b/examples/gdp/nine_process/small_process.py @@ -1,6 +1,15 @@ -"""Small process synthesis-inspired toy GDP example. +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ -""" +"""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 f7c77b111f0..de41c0bfd00 100644 --- a/examples/gdp/simple1.py +++ b/examples/gdp/simple1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Example: modeling a complementarity condition as a # disjunction # diff --git a/examples/gdp/simple2.py b/examples/gdp/simple2.py index 6bcc7bbf747..b066d705036 100644 --- a/examples/gdp/simple2.py +++ b/examples/gdp/simple2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Example: modeling a complementarity condition as a # disjunction # diff --git a/examples/gdp/simple3.py b/examples/gdp/simple3.py index 6b3d6ec46c4..890daf8882b 100644 --- a/examples/gdp/simple3.py +++ b/examples/gdp/simple3.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Example: modeling a complementarity condition as a # disjunction # diff --git a/examples/gdp/small_lit/basic_step.py b/examples/gdp/small_lit/basic_step.py index 48ef52d9ba0..1919a6e16f8 100644 --- a/examples/gdp/small_lit/basic_step.py +++ b/examples/gdp/small_lit/basic_step.py @@ -1,7 +1,18 @@ -""" Example from Section 3.2 in paper of Pseudo Basic Steps +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +"""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 diff --git a/examples/gdp/small_lit/contracts_problem.py b/examples/gdp/small_lit/contracts_problem.py index 500fe15cb2a..0c59d2264ee 100644 --- a/examples/gdp/small_lit/contracts_problem.py +++ b/examples/gdp/small_lit/contracts_problem.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ Example from 'Lagrangean Relaxation of the Hull-Reformulation of Linear \ Generalized Disjunctive Programs and its use in Disjunctive Branch \ and Bound' Page 25 f. diff --git a/examples/gdp/small_lit/ex1_Lee.py b/examples/gdp/small_lit/ex1_Lee.py index ddd2e1c3d2f..abbf470a1c3 100644 --- a/examples/gdp/small_lit/ex1_Lee.py +++ b/examples/gdp/small_lit/ex1_Lee.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Simple example of nonlinear problem modeled with GDP framework. Taken from Example 1 of the paper "New Algorithms for Nonlinear Generalized Disjunctive Programming" by Lee and Grossmann diff --git a/examples/gdp/small_lit/ex_633_trespalacios.py b/examples/gdp/small_lit/ex_633_trespalacios.py index 61b7294e3ba..b0c5fbd85ac 100644 --- a/examples/gdp/small_lit/ex_633_trespalacios.py +++ b/examples/gdp/small_lit/ex_633_trespalacios.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Analytical example from Section 6.3.3 of F. Trespalacions Ph.D. Thesis (2015) Analytical example for a nonconvex GDP with 2 disjunctions, each with 2 disjuncts. diff --git a/examples/gdp/small_lit/nonconvex_HEN.py b/examples/gdp/small_lit/nonconvex_HEN.py index 99e2c4f15e2..05fad970b84 100644 --- a/examples/gdp/small_lit/nonconvex_HEN.py +++ b/examples/gdp/small_lit/nonconvex_HEN.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ Example from 'Systematic Modeling of Discrete-Continuous Optimization \ Models through Generalized Disjunctive Programming' Ignacio E. Grossmann and Francisco Trespalacios, 2013 diff --git a/examples/gdp/stickies.py b/examples/gdp/stickies.py index 75beb911415..73b537ff13d 100644 --- a/examples/gdp/stickies.py +++ b/examples/gdp/stickies.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.fileutils import this_file_dir diff --git a/examples/gdp/strip_packing/stripPacking.py b/examples/gdp/strip_packing/stripPacking.py index 0e8902c5ee4..39f7208b838 100644 --- a/examples/gdp/strip_packing/stripPacking.py +++ b/examples/gdp/strip_packing/stripPacking.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * from pyomo.gdp import * diff --git a/examples/gdp/strip_packing/strip_packing_8rect.py b/examples/gdp/strip_packing/strip_packing_8rect.py index e1350dbc39e..2bd7c4840ca 100644 --- a/examples/gdp/strip_packing/strip_packing_8rect.py +++ b/examples/gdp/strip_packing/strip_packing_8rect.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Strip packing example from MINLP.org library. 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 diff --git a/examples/gdp/strip_packing/strip_packing_concrete.py b/examples/gdp/strip_packing/strip_packing_concrete.py index 1313d75561c..b0907cdea61 100644 --- a/examples/gdp/strip_packing/strip_packing_concrete.py +++ b/examples/gdp/strip_packing/strip_packing_concrete.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Strip packing example from MINLP.org library. Strip-packing example from http://minlp.org/library/lib.php?lib=GDP diff --git a/examples/gdp/two_rxn_lee/two_rxn_model.py b/examples/gdp/two_rxn_lee/two_rxn_model.py index 2e5f1734130..98e4cc2e878 100644 --- a/examples/gdp/two_rxn_lee/two_rxn_model.py +++ b/examples/gdp/two_rxn_lee/two_rxn_model.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Two reactor model from literature. See README.md.""" from pyomo.core import ConcreteModel, Constraint, Objective, Param, Var, maximize diff --git a/examples/kernel/blocks.py b/examples/kernel/blocks.py index 7036981dcc8..db1cb6655c2 100644 --- a/examples/kernel/blocks.py +++ b/examples/kernel/blocks.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo # diff --git a/examples/kernel/conic.py b/examples/kernel/conic.py index a2a787794a4..5ee66a00ee9 100644 --- a/examples/kernel/conic.py +++ b/examples/kernel/conic.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo # diff --git a/examples/kernel/constraints.py b/examples/kernel/constraints.py index 6495ad12f63..69823a6ebbe 100644 --- a/examples/kernel/constraints.py +++ b/examples/kernel/constraints.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo v = pmo.variable() diff --git a/examples/kernel/containers.py b/examples/kernel/containers.py index 9b525e87af6..9ec749b8c3e 100644 --- a/examples/kernel/containers.py +++ b/examples/kernel/containers.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo # diff --git a/examples/kernel/expressions.py b/examples/kernel/expressions.py index 1756e5d3fd4..faef8d1d4ad 100644 --- a/examples/kernel/expressions.py +++ b/examples/kernel/expressions.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo v = pmo.variable(value=2) diff --git a/examples/kernel/mosek/geometric1.py b/examples/kernel/mosek/geometric1.py index b5ec59541c4..8148e707819 100644 --- a/examples/kernel/mosek/geometric1.py +++ b/examples/kernel/mosek/geometric1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Source: https://docs.mosek.com/9.0/pythonapi/tutorial-gp-shared.html import pyomo.kernel as pmo diff --git a/examples/kernel/mosek/geometric2.py b/examples/kernel/mosek/geometric2.py index 84825c0a39b..3fb62c86312 100644 --- a/examples/kernel/mosek/geometric2.py +++ b/examples/kernel/mosek/geometric2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Source: https://docs.mosek.com/modeling-cookbook/expo.html # (first example in Section 5.3.1) diff --git a/examples/kernel/mosek/maximum_volume_cuboid.py b/examples/kernel/mosek/maximum_volume_cuboid.py index 92e210cf400..df200cc801c 100644 --- a/examples/kernel/mosek/maximum_volume_cuboid.py +++ b/examples/kernel/mosek/maximum_volume_cuboid.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 scipy.spatial import ConvexHull from mpl_toolkits.mplot3d import Axes3D from mpl_toolkits.mplot3d.art3d import Poly3DCollection diff --git a/examples/kernel/mosek/power1.py b/examples/kernel/mosek/power1.py index d7a12c1ce54..a6d6ebbe47d 100644 --- a/examples/kernel/mosek/power1.py +++ b/examples/kernel/mosek/power1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Source: https://docs.mosek.com/9.0/pythonapi/tutorial-pow-shared.html import pyomo.kernel as pmo diff --git a/examples/kernel/mosek/semidefinite.py b/examples/kernel/mosek/semidefinite.py index 44ab7c95a68..6be47d85451 100644 --- a/examples/kernel/mosek/semidefinite.py +++ b/examples/kernel/mosek/semidefinite.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Source: https://docs.mosek.com/latest/pythonfusion/tutorial-sdo-shared.html#doc-tutorial-sdo # This examples illustrates SDP formulations in Pyomo using diff --git a/examples/kernel/objectives.py b/examples/kernel/objectives.py index 7d87671ef8d..27a41f4edb5 100644 --- a/examples/kernel/objectives.py +++ b/examples/kernel/objectives.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo v = pmo.variable(value=2) diff --git a/examples/kernel/parameters.py b/examples/kernel/parameters.py index 55b230add6b..e9e412525bb 100644 --- a/examples/kernel/parameters.py +++ b/examples/kernel/parameters.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo # diff --git a/examples/kernel/piecewise_functions.py b/examples/kernel/piecewise_functions.py index 528d4c16791..73a7f680725 100644 --- a/examples/kernel/piecewise_functions.py +++ b/examples/kernel/piecewise_functions.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo # diff --git a/examples/kernel/piecewise_nd_functions.py b/examples/kernel/piecewise_nd_functions.py index 847bb5f4a84..7de37fcbfc6 100644 --- a/examples/kernel/piecewise_nd_functions.py +++ b/examples/kernel/piecewise_nd_functions.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 random import sys diff --git a/examples/kernel/special_ordered_sets.py b/examples/kernel/special_ordered_sets.py index 9526a551c12..abacc3d4205 100644 --- a/examples/kernel/special_ordered_sets.py +++ b/examples/kernel/special_ordered_sets.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo v1 = pmo.variable() diff --git a/examples/kernel/suffixes.py b/examples/kernel/suffixes.py index 39caa5b8652..ae95fbbdd09 100644 --- a/examples/kernel/suffixes.py +++ b/examples/kernel/suffixes.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo # diff --git a/examples/kernel/variables.py b/examples/kernel/variables.py index 7ab571245a1..36865b58183 100644 --- a/examples/kernel/variables.py +++ b/examples/kernel/variables.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.kernel as pmo # diff --git a/examples/mpec/bard1.py b/examples/mpec/bard1.py index dbe666a7004..59955eefb8e 100644 --- a/examples/mpec/bard1.py +++ b/examples/mpec/bard1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # bard1.py QQR2-MN-8-5 # Original Pyomo coding by William Hart # Adapted from AMPL coding by Sven Leyffer diff --git a/examples/mpec/df.py b/examples/mpec/df.py index 41984992bdd..7bb25b11e07 100644 --- a/examples/mpec/df.py +++ b/examples/mpec/df.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/mpec/indexed.py b/examples/mpec/indexed.py index b69d5093477..0aff5de5b20 100644 --- a/examples/mpec/indexed.py +++ b/examples/mpec/indexed.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/mpec/linear1.py b/examples/mpec/linear1.py index eba04759ae3..f24fd357e62 100644 --- a/examples/mpec/linear1.py +++ b/examples/mpec/linear1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/mpec/munson1.py b/examples/mpec/munson1.py index debdf709db9..99c240b5c06 100644 --- a/examples/mpec/munson1.py +++ b/examples/mpec/munson1.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 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 519db4e6ec2..67f8f318531 100644 --- a/examples/mpec/munson1a.py +++ b/examples/mpec/munson1a.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/mpec/munson1b.py b/examples/mpec/munson1b.py index ff2b7b51294..46fff90a785 100644 --- a/examples/mpec/munson1b.py +++ b/examples/mpec/munson1b.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/mpec/munson1c.py b/examples/mpec/munson1c.py index 2592b25c515..dee5b224e75 100644 --- a/examples/mpec/munson1c.py +++ b/examples/mpec/munson1c.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/mpec/munson1d.py b/examples/mpec/munson1d.py index 0fb08ce73fb..157177f2eb0 100644 --- a/examples/mpec/munson1d.py +++ b/examples/mpec/munson1d.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/mpec/scholtes4.py b/examples/mpec/scholtes4.py index 904729780cf..8d574dd1916 100644 --- a/examples/mpec/scholtes4.py +++ b/examples/mpec/scholtes4.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # scholtes4.py LQR2-MN-3-2 # Original Pyomo coding by William Hart # Adapted from AMPL coding by Sven Leyffer diff --git a/examples/performance/dae/run_stochpdegas1_automatic.py b/examples/performance/dae/run_stochpdegas1_automatic.py index 993e22c7c86..fffa1a71ae1 100644 --- a/examples/performance/dae/run_stochpdegas1_automatic.py +++ b/examples/performance/dae/run_stochpdegas1_automatic.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 time from pyomo.environ import * diff --git a/examples/performance/dae/stochpdegas1_automatic.py b/examples/performance/dae/stochpdegas1_automatic.py index 905ec9a5330..ce6132e6cf5 100644 --- a/examples/performance/dae/stochpdegas1_automatic.py +++ b/examples/performance/dae/stochpdegas1_automatic.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # stochastic pde model for natural gas network # victor m. zavala / 2013 diff --git a/examples/performance/jump/clnlbeam.py b/examples/performance/jump/clnlbeam.py index d2ceda790ec..410068a6753 100644 --- a/examples/performance/jump/clnlbeam.py +++ b/examples/performance/jump/clnlbeam.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = AbstractModel() diff --git a/examples/performance/jump/facility.py b/examples/performance/jump/facility.py index 6832e8d32ac..fa0c306d6e5 100644 --- a/examples/performance/jump/facility.py +++ b/examples/performance/jump/facility.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * model = AbstractModel() diff --git a/examples/performance/jump/lqcp.py b/examples/performance/jump/lqcp.py index bb3e66b36f5..b8ef096d7be 100644 --- a/examples/performance/jump/lqcp.py +++ b/examples/performance/jump/lqcp.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import * model = ConcreteModel() diff --git a/examples/performance/jump/opf_66200bus.py b/examples/performance/jump/opf_66200bus.py index f3e1822fbfb..702ff59a61c 100644 --- a/examples/performance/jump/opf_66200bus.py +++ b/examples/performance/jump/opf_66200bus.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/jump/opf_6620bus.py b/examples/performance/jump/opf_6620bus.py index 64348ae931e..34b910f43c0 100644 --- a/examples/performance/jump/opf_6620bus.py +++ b/examples/performance/jump/opf_6620bus.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/jump/opf_662bus.py b/examples/performance/jump/opf_662bus.py index 6ff97c577e3..8a768ca16e0 100644 --- a/examples/performance/jump/opf_662bus.py +++ b/examples/performance/jump/opf_662bus.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/bilinear1_100.py b/examples/performance/misc/bilinear1_100.py index e68fbba6283..d86091c4c76 100644 --- a/examples/performance/misc/bilinear1_100.py +++ b/examples/performance/misc/bilinear1_100.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/bilinear1_100000.py b/examples/performance/misc/bilinear1_100000.py index 924d7233d24..0fa2eafedc6 100644 --- a/examples/performance/misc/bilinear1_100000.py +++ b/examples/performance/misc/bilinear1_100000.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/bilinear2_100.py b/examples/performance/misc/bilinear2_100.py index 4dd9f9ead57..227bfe000e0 100644 --- a/examples/performance/misc/bilinear2_100.py +++ b/examples/performance/misc/bilinear2_100.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/bilinear2_100000.py b/examples/performance/misc/bilinear2_100000.py index 90eeaf82271..9d2a4d6fb7c 100644 --- a/examples/performance/misc/bilinear2_100000.py +++ b/examples/performance/misc/bilinear2_100000.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/diag1_100.py b/examples/performance/misc/diag1_100.py index e47a9179974..369d81982f0 100644 --- a/examples/performance/misc/diag1_100.py +++ b/examples/performance/misc/diag1_100.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/diag1_100000.py b/examples/performance/misc/diag1_100000.py index a110c0d9d67..536758fda5d 100644 --- a/examples/performance/misc/diag1_100000.py +++ b/examples/performance/misc/diag1_100000.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/diag2_100.py b/examples/performance/misc/diag2_100.py index fe820e8590b..6ad47528ff2 100644 --- a/examples/performance/misc/diag2_100.py +++ b/examples/performance/misc/diag2_100.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/diag2_100000.py b/examples/performance/misc/diag2_100000.py index 38563de57b9..b95e2dd1d6f 100644 --- a/examples/performance/misc/diag2_100000.py +++ b/examples/performance/misc/diag2_100000.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * diff --git a/examples/performance/misc/set1.py b/examples/performance/misc/set1.py index 53227a3ee73..8a8b84fdcc3 100644 --- a/examples/performance/misc/set1.py +++ b/examples/performance/misc/set1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * model = ConcreteModel() diff --git a/examples/performance/misc/sparse1.py b/examples/performance/misc/sparse1.py index 264862760f9..b4883d379bc 100644 --- a/examples/performance/misc/sparse1.py +++ b/examples/performance/misc/sparse1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # This is a performance test that we cannot easily execute right now # diff --git a/examples/performance/pmedian/pmedian1.py b/examples/performance/pmedian/pmedian1.py index 3d3f6c5407f..a22540efdd5 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-2022 +# 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 diff --git a/examples/performance/pmedian/pmedian2.py b/examples/performance/pmedian/pmedian2.py index 434ded6dcbc..ff25a6c15eb 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-2022 +# 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 diff --git a/examples/pyomo/amplbook2/diet.py b/examples/pyomo/amplbook2/diet.py index 8cdffefa20f..cc52eacae20 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-2022 +# 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 diff --git a/examples/pyomo/amplbook2/dieti.py b/examples/pyomo/amplbook2/dieti.py index 0934dcf83c6..45d403dd810 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-2022 +# 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 diff --git a/examples/pyomo/amplbook2/econ2min.py b/examples/pyomo/amplbook2/econ2min.py index 0d27df780bb..fb870e02364 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-2022 +# 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 diff --git a/examples/pyomo/amplbook2/econmin.py b/examples/pyomo/amplbook2/econmin.py index 84e41107ff2..d9c95758d4d 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-2022 +# 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 diff --git a/examples/pyomo/amplbook2/prod.py b/examples/pyomo/amplbook2/prod.py index 74e456e013f..236f7254b29 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-2022 +# 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 diff --git a/examples/pyomo/amplbook2/steel.py b/examples/pyomo/amplbook2/steel.py index 43bea775526..8c5c9b2a1d3 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-2022 +# 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 diff --git a/examples/pyomo/amplbook2/steel3.py b/examples/pyomo/amplbook2/steel3.py index e9e494b6a1a..dd3b3ac202f 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-2022 +# 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 diff --git a/examples/pyomo/amplbook2/steel4.py b/examples/pyomo/amplbook2/steel4.py index b6709e478e9..10cb0979d24 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-2022 +# 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 diff --git a/examples/pyomo/benders/master.py b/examples/pyomo/benders/master.py index a457bf28b06..372810dc024 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-2022 +# 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 diff --git a/examples/pyomo/benders/subproblem.py b/examples/pyomo/benders/subproblem.py index 886f71ff321..ae46dad2d41 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-2022 +# 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 diff --git a/examples/pyomo/callbacks/sc.py b/examples/pyomo/callbacks/sc.py index ce32b0a1074..0882815c6b7 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-2022 +# 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 diff --git a/examples/pyomo/callbacks/sc_callback.py b/examples/pyomo/callbacks/sc_callback.py index 0dae9e1befc..cacc438b380 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-2022 +# 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 diff --git a/examples/pyomo/callbacks/sc_script.py b/examples/pyomo/callbacks/sc_script.py index 8e4ade21b51..d3044e4d667 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-2022 +# 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 diff --git a/examples/pyomo/callbacks/scalability/run.py b/examples/pyomo/callbacks/scalability/run.py index 8465e3f5019..cf95076fcc3 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-2022 +# 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 diff --git a/examples/pyomo/callbacks/tsp.py b/examples/pyomo/callbacks/tsp.py index d3e28a98d3f..8526a540b66 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-2022 +# 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 diff --git a/examples/pyomo/columngeneration/cutting_stock.py b/examples/pyomo/columngeneration/cutting_stock.py index 58df6a5ad16..d331da9608b 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-2022 +# 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 @@ -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 9bc8dd87e9d..3d3c19e94ac 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-2022 +# 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 diff --git a/examples/pyomo/concrete/knapsack-abstract.py b/examples/pyomo/concrete/knapsack-abstract.py index bbef95f7810..9766d902722 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-2022 +# 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 diff --git a/examples/pyomo/concrete/knapsack-concrete.py b/examples/pyomo/concrete/knapsack-concrete.py index cd115ab40a3..8966d0b8498 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-2022 +# 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 diff --git a/examples/pyomo/concrete/rosen.py b/examples/pyomo/concrete/rosen.py index a8e8a175127..ae51ae50ac0 100644 --- a/examples/pyomo/concrete/rosen.py +++ b/examples/pyomo/concrete/rosen.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # rosen.py from pyomo.environ import * diff --git a/examples/pyomo/concrete/sodacan.py b/examples/pyomo/concrete/sodacan.py index 3c0cfd3aab2..5429b27a9d5 100644 --- a/examples/pyomo/concrete/sodacan.py +++ b/examples/pyomo/concrete/sodacan.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # sodacan.py from pyomo.environ import * from math import pi diff --git a/examples/pyomo/concrete/sodacan_fig.py b/examples/pyomo/concrete/sodacan_fig.py index bf9ae476b4c..b263eaf558d 100644 --- a/examples/pyomo/concrete/sodacan_fig.py +++ b/examples/pyomo/concrete/sodacan_fig.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 mpl_toolkits.mplot3d import Axes3D from matplotlib import cm from matplotlib.ticker import LinearLocator, FormatStrFormatter diff --git a/examples/pyomo/concrete/sp.py b/examples/pyomo/concrete/sp.py index edc2d68b170..e82a4bca0a9 100644 --- a/examples/pyomo/concrete/sp.py +++ b/examples/pyomo/concrete/sp.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # sp.py from pyomo.environ import * from sp_data import * # define c, b, h, and d diff --git a/examples/pyomo/concrete/sp_data.py b/examples/pyomo/concrete/sp_data.py index 58210126819..4453a10cead 100644 --- a/examples/pyomo/concrete/sp_data.py +++ b/examples/pyomo/concrete/sp_data.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + c = 1.0 b = 1.5 h = 0.1 diff --git a/examples/pyomo/connectors/network_flow.py b/examples/pyomo/connectors/network_flow.py index cb75ca7ecf2..d5587fdf4c8 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-2022 +# 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 diff --git a/examples/pyomo/connectors/network_flow_proposed.py b/examples/pyomo/connectors/network_flow_proposed.py index ed603ff6626..f234f2decf4 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-2022 +# 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 diff --git a/examples/pyomo/core/block1.py b/examples/pyomo/core/block1.py index 96f8114f19c..161fc2ca2f7 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-2022 +# 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 diff --git a/examples/pyomo/core/integrality1.py b/examples/pyomo/core/integrality1.py index db81805555f..0ab3a433dac 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-2022 +# 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 diff --git a/examples/pyomo/core/integrality2.py b/examples/pyomo/core/integrality2.py index 2d85c9f2455..6461d36f923 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-2022 +# 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 diff --git a/examples/pyomo/core/simple.py b/examples/pyomo/core/simple.py index d0359c143bf..6976f3d25ad 100644 --- a/examples/pyomo/core/simple.py +++ b/examples/pyomo/core/simple.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/examples/pyomo/core/t1.py b/examples/pyomo/core/t1.py index 4135049d4be..5d5416985a9 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-2022 +# 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 diff --git a/examples/pyomo/core/t2.py b/examples/pyomo/core/t2.py index 5d687917fba..4d3f1934cbe 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-2022 +# 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 diff --git a/examples/pyomo/core/t5.py b/examples/pyomo/core/t5.py index 38605751015..6b9d94e0ff1 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-2022 +# 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 diff --git a/examples/pyomo/diet/diet-sqlite.py b/examples/pyomo/diet/diet-sqlite.py index e8963485294..dccd3c338d0 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-2022 +# 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 diff --git a/examples/pyomo/diet/diet1.py b/examples/pyomo/diet/diet1.py index 1fd61ca268c..217f80b9c25 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-2022 +# 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 diff --git a/examples/pyomo/diet/diet2.py b/examples/pyomo/diet/diet2.py index 526dbcef484..291261b0901 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-2022 +# 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 diff --git a/examples/pyomo/draft/api.py b/examples/pyomo/draft/api.py index 5b506882d9b..d785f41935e 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-2022 +# 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 diff --git a/examples/pyomo/draft/bpack.py b/examples/pyomo/draft/bpack.py index 697ce531013..7b076f7737b 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-2022 +# 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 diff --git a/examples/pyomo/draft/diet2.py b/examples/pyomo/draft/diet2.py index 9e4d2c5d9c4..d23fa3cf5db 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-2022 +# 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 diff --git a/examples/pyomo/p-median/decorated_pmedian.py b/examples/pyomo/p-median/decorated_pmedian.py index 90345daf78d..c66971945f3 100644 --- a/examples/pyomo/p-median/decorated_pmedian.py +++ b/examples/pyomo/p-median/decorated_pmedian.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * import random diff --git a/examples/pyomo/p-median/pmedian.py b/examples/pyomo/p-median/pmedian.py index 88731f287d8..865aa7cb61f 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-2022 +# 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 diff --git a/examples/pyomo/p-median/solver1.py b/examples/pyomo/p-median/solver1.py index 113bf9fdd29..2652ab13943 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-2022 +# 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 diff --git a/examples/pyomo/p-median/solver2.py b/examples/pyomo/p-median/solver2.py index c62f161fd24..50ec5388811 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-2022 +# 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 diff --git a/examples/pyomo/piecewise/convex.py b/examples/pyomo/piecewise/convex.py index a3233ae5c3e..fb8095f80e3 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-2022 +# 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 diff --git a/examples/pyomo/piecewise/indexed.py b/examples/pyomo/piecewise/indexed.py index dea56df3911..cde21ec847e 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-2022 +# 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 diff --git a/examples/pyomo/piecewise/indexed_nonlinear.py b/examples/pyomo/piecewise/indexed_nonlinear.py index e871508d1be..d72fbc8a899 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-2022 +# 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 diff --git a/examples/pyomo/piecewise/indexed_points.py b/examples/pyomo/piecewise/indexed_points.py index 15b1c33a7ec..66110bea342 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-2022 +# 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 diff --git a/examples/pyomo/piecewise/nonconvex.py b/examples/pyomo/piecewise/nonconvex.py index 004748ab2eb..5300278d5b9 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-2022 +# 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 diff --git a/examples/pyomo/piecewise/points.py b/examples/pyomo/piecewise/points.py index c822ceb5860..91d45684c4f 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-2022 +# 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 diff --git a/examples/pyomo/piecewise/step.py b/examples/pyomo/piecewise/step.py index c3fbb4762ab..95aac74d7f7 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-2022 +# 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 diff --git a/examples/pyomo/quadratic/example1.py b/examples/pyomo/quadratic/example1.py index dff911a0f0c..ab77c5a1733 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-2022 +# 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 diff --git a/examples/pyomo/quadratic/example2.py b/examples/pyomo/quadratic/example2.py index 981f2ef0bfb..ce02c6f70c8 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-2022 +# 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 diff --git a/examples/pyomo/quadratic/example3.py b/examples/pyomo/quadratic/example3.py index 4d96afe3328..bdba936f694 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-2022 +# 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 diff --git a/examples/pyomo/quadratic/example4.py b/examples/pyomo/quadratic/example4.py index 256fc862a16..ecfc9981162 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-2022 +# 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 diff --git a/examples/pyomo/radertext/Ex2_1.py b/examples/pyomo/radertext/Ex2_1.py index d352325798a..981388d4c72 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-2022 +# 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 diff --git a/examples/pyomo/radertext/Ex2_2.py b/examples/pyomo/radertext/Ex2_2.py index 13c23dd1816..41b56e52669 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-2022 +# 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 diff --git a/examples/pyomo/radertext/Ex2_3.py b/examples/pyomo/radertext/Ex2_3.py index d4dc3109ea1..7dc39afa773 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-2022 +# 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 diff --git a/examples/pyomo/radertext/Ex2_5.py b/examples/pyomo/radertext/Ex2_5.py index da90b473b1f..fee49b46cb0 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-2022 +# 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 diff --git a/examples/pyomo/radertext/Ex2_6a.py b/examples/pyomo/radertext/Ex2_6a.py index dc33a9b64e2..24bb866ec51 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-2022 +# 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 diff --git a/examples/pyomo/radertext/Ex2_6b.py b/examples/pyomo/radertext/Ex2_6b.py index 8049d4ebb05..1be55461b9e 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-2022 +# 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 diff --git a/examples/pyomo/sos/DepotSiting.py b/examples/pyomo/sos/DepotSiting.py index 98697681f44..40826e989b7 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-2022 +# 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 diff --git a/examples/pyomo/sos/basic_sos2_example.py b/examples/pyomo/sos/basic_sos2_example.py index 655169ffe54..3aa0887356c 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-2022 +# 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 diff --git a/examples/pyomo/sos/sos2_piecewise.py b/examples/pyomo/sos/sos2_piecewise.py index 4e79ce2ee62..1d952beb336 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-2022 +# 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 @@ -10,11 +10,11 @@ # ___________________________________________________________________________ """ -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 """ diff --git a/examples/pyomo/suffixes/duals_pyomo.py b/examples/pyomo/suffixes/duals_pyomo.py index 9743add3ddd..6ce88fde429 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-2022 +# 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 diff --git a/examples/pyomo/suffixes/duals_script.py b/examples/pyomo/suffixes/duals_script.py index a9db615cad3..e8ef9aef1bc 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-2022 +# 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 diff --git a/examples/pyomo/suffixes/gurobi_ampl_basis.py b/examples/pyomo/suffixes/gurobi_ampl_basis.py index cd8e4e8f129..eab86f8aa47 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-2022 +# 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 diff --git a/examples/pyomo/suffixes/gurobi_ampl_example.py b/examples/pyomo/suffixes/gurobi_ampl_example.py index d133fa422dc..4f3364c09dc 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-2022 +# 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 diff --git a/examples/pyomo/suffixes/gurobi_ampl_iis.py b/examples/pyomo/suffixes/gurobi_ampl_iis.py index ccba226db78..da5bad073e7 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-2022 +# 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 diff --git a/examples/pyomo/suffixes/ipopt_scaling.py b/examples/pyomo/suffixes/ipopt_scaling.py index c192a98dd98..7113128c21d 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-2022 +# 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 diff --git a/examples/pyomo/suffixes/ipopt_warmstart.py b/examples/pyomo/suffixes/ipopt_warmstart.py index 6975bbaaa62..4882c48c8c8 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-2022 +# 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 diff --git a/examples/pyomo/suffixes/sipopt_hicks.py b/examples/pyomo/suffixes/sipopt_hicks.py index dbf4e07b8f7..c7e058d5907 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-2022 +# 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 diff --git a/examples/pyomo/suffixes/sipopt_parametric.py b/examples/pyomo/suffixes/sipopt_parametric.py index 29bba934bd8..0cb1c35f441 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-2022 +# 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 diff --git a/examples/pyomo/transform/scaling_ex.py b/examples/pyomo/transform/scaling_ex.py index a5960393e75..34f937cbb45 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-2022 +# 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 diff --git a/examples/pyomo/tutorials/data.out b/examples/pyomo/tutorials/data.out index d1353f87858..7dce6012e2f 100644 --- a/examples/pyomo/tutorials/data.out +++ b/examples/pyomo/tutorials/data.out @@ -1,4 +1,4 @@ -20 Set Declarations +14 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} @@ -9,30 +9,18 @@ Key : Dimen : Domain : Size : Members None : 2 : A*B : 9 : {('A1', 1), ('A1', 2), ('A1', 3), ('A2', 1), ('A2', 2), ('A2', 3), ('A3', 1), ('A3', 2), ('A3', 3)} D : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 2 : D_domain : 3 : {('A1', 1), ('A2', 2), ('A3', 3)} - D_domain : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1), ('A1', 2), ('A1', 3), ('A2', 1), ('A2', 2), ('A2', 3), ('A3', 1), ('A3', 2), ('A3', 3)} + None : 2 : A*B : 3 : {('A1', 1), ('A2', 2), ('A3', 3)} E : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 3 : E_domain : 6 : {('A1', 1, 'A1'), ('A1', 1, 'A2'), ('A2', 2, 'A2'), ('A2', 2, 'A3'), ('A3', 3, 'A1'), ('A3', 3, 'A3')} - E_domain : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 3 : E_domain_index_0*A : 27 : {('A1', 1, 'A1'), ('A1', 1, 'A2'), ('A1', 1, 'A3'), ('A1', 2, 'A1'), ('A1', 2, 'A2'), ('A1', 2, 'A3'), ('A1', 3, 'A1'), ('A1', 3, 'A2'), ('A1', 3, 'A3'), ('A2', 1, 'A1'), ('A2', 1, 'A2'), ('A2', 1, 'A3'), ('A2', 2, 'A1'), ('A2', 2, 'A2'), ('A2', 2, 'A3'), ('A2', 3, 'A1'), ('A2', 3, 'A2'), ('A2', 3, 'A3'), ('A3', 1, 'A1'), ('A3', 1, 'A2'), ('A3', 1, 'A3'), ('A3', 2, 'A1'), ('A3', 2, 'A2'), ('A3', 2, 'A3'), ('A3', 3, 'A1'), ('A3', 3, 'A2'), ('A3', 3, 'A3')} - E_domain_index_0 : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1), ('A1', 2), ('A1', 3), ('A2', 1), ('A2', 2), ('A2', 3), ('A3', 1), ('A3', 2), ('A3', 3)} + None : 3 : A*B*A : 6 : {('A1', 1, 'A1'), ('A1', 1, 'A2'), ('A2', 2, 'A2'), ('A2', 2, 'A3'), ('A3', 3, 'A1'), ('A3', 3, 'A3')} F : Size=3, Index=A, Ordered=Insertion Key : Dimen : Domain : Size : Members A1 : 1 : Any : 3 : {1, 3, 5} A2 : 1 : Any : 3 : {2, 4, 6} A3 : 1 : Any : 3 : {3, 5, 7} - G : Size=0, Index=G_index, Ordered=Insertion + G : Size=0, Index=A*B, Ordered=Insertion Key : Dimen : Domain : Size : Members - G_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1), ('A1', 2), ('A1', 3), ('A2', 1), ('A2', 2), ('A2', 3), ('A3', 1), ('A3', 2), ('A3', 3)} H : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'H1', 'H2', 'H3'} @@ -45,12 +33,6 @@ K : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 2 : Any : 3 : {('A1', 'B1'), ('A2', 'B2'), ('A3', 'B3')} - T_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*I : 12 : {('A1', 'I1'), ('A1', 'I2'), ('A1', 'I3'), ('A1', 'I4'), ('A2', 'I1'), ('A2', 'I2'), ('A2', 'I3'), ('A2', 'I4'), ('A3', 'I1'), ('A3', 'I2'), ('A3', 'I3'), ('A3', 'I4')} - U_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : I*A : 12 : {('I1', 'A1'), ('I1', 'A2'), ('I1', 'A3'), ('I2', 'A1'), ('I2', 'A2'), ('I2', 'A3'), ('I3', 'A1'), ('I3', 'A2'), ('I3', 'A3'), ('I4', 'A1'), ('I4', 'A2'), ('I4', 'A3')} x : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} @@ -116,7 +98,7 @@ Key : Value A1 : 3.3 A3 : 3.5 - T : Size=12, Index=T_index, Domain=Any, Default=None, Mutable=False + T : Size=12, Index=A*I, Domain=Any, Default=None, Mutable=False Key : Value ('A1', 'I1') : 1.3 ('A1', 'I2') : 1.4 @@ -130,7 +112,7 @@ ('A3', 'I2') : 3.4 ('A3', 'I3') : 3.5 ('A3', 'I4') : 3.6 - U : Size=12, Index=U_index, Domain=Any, Default=None, Mutable=False + U : Size=12, Index=I*A, Domain=Any, Default=None, Mutable=False Key : Value ('I1', 'A1') : 1.3 ('I1', 'A2') : 2.3 @@ -166,4 +148,4 @@ Key : Value None : 2 -38 Declarations: A B C D_domain D E_domain_index_0 E_domain E F G_index G H I J K Z ZZ Y X W U_index U T_index T S R Q P PP O z y x M N MM MMM NNN +32 Declarations: A B C D E F G H I J K Z ZZ Y X W U T S R Q P PP O z y x M N MM MMM NNN diff --git a/examples/pyomo/tutorials/data.py b/examples/pyomo/tutorials/data.py index d065c9ff9bc..ea2569af934 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-2022 +# 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 diff --git a/examples/pyomo/tutorials/excel.out b/examples/pyomo/tutorials/excel.out index 5064d4fa511..5e30827f7ae 100644 --- a/examples/pyomo/tutorials/excel.out +++ b/examples/pyomo/tutorials/excel.out @@ -1,4 +1,4 @@ -16 Set Declarations +10 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} @@ -9,27 +9,15 @@ Key : Dimen : Domain : Size : Members None : 2 : A*B : 9 : {('A1', 1.0), ('A1', 2.0), ('A1', 3.0), ('A2', 1.0), ('A2', 2.0), ('A2', 3.0), ('A3', 1.0), ('A3', 2.0), ('A3', 3.0)} D : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 2 : D_domain : 3 : {('A1', 1.0), ('A2', 2.0), ('A3', 3.0)} - D_domain : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1.0), ('A1', 2.0), ('A1', 3.0), ('A2', 1.0), ('A2', 2.0), ('A2', 3.0), ('A3', 1.0), ('A3', 2.0), ('A3', 3.0)} + None : 2 : A*B : 3 : {('A1', 1.0), ('A2', 2.0), ('A3', 3.0)} E : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 3 : E_domain : 6 : {('A1', 1.0, 'A1'), ('A1', 1.0, 'A2'), ('A2', 2.0, 'A2'), ('A2', 2.0, 'A3'), ('A3', 3.0, 'A1'), ('A3', 3.0, 'A3')} - E_domain : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 3 : E_domain_index_0*A : 27 : {('A1', 1.0, 'A1'), ('A1', 1.0, 'A2'), ('A1', 1.0, 'A3'), ('A1', 2.0, 'A1'), ('A1', 2.0, 'A2'), ('A1', 2.0, 'A3'), ('A1', 3.0, 'A1'), ('A1', 3.0, 'A2'), ('A1', 3.0, 'A3'), ('A2', 1.0, 'A1'), ('A2', 1.0, 'A2'), ('A2', 1.0, 'A3'), ('A2', 2.0, 'A1'), ('A2', 2.0, 'A2'), ('A2', 2.0, 'A3'), ('A2', 3.0, 'A1'), ('A2', 3.0, 'A2'), ('A2', 3.0, 'A3'), ('A3', 1.0, 'A1'), ('A3', 1.0, 'A2'), ('A3', 1.0, 'A3'), ('A3', 2.0, 'A1'), ('A3', 2.0, 'A2'), ('A3', 2.0, 'A3'), ('A3', 3.0, 'A1'), ('A3', 3.0, 'A2'), ('A3', 3.0, 'A3')} - E_domain_index_0 : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1.0), ('A1', 2.0), ('A1', 3.0), ('A2', 1.0), ('A2', 2.0), ('A2', 3.0), ('A3', 1.0), ('A3', 2.0), ('A3', 3.0)} + None : 3 : A*B : 6 : {('A1', 1.0, 'A1'), ('A1', 1.0, 'A2'), ('A2', 2.0, 'A2'), ('A2', 2.0, 'A3'), ('A3', 3.0, 'A1'), ('A3', 3.0, 'A3')} F : Size=0, Index=A, Ordered=Insertion Key : Dimen : Domain : Size : Members - G : Size=0, Index=G_index, Ordered=Insertion + G : Size=0, Index=A*B, Ordered=Insertion Key : Dimen : Domain : Size : Members - G_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1.0), ('A1', 2.0), ('A1', 3.0), ('A2', 1.0), ('A2', 2.0), ('A2', 3.0), ('A3', 1.0), ('A3', 2.0), ('A3', 3.0)} H : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'H1', 'H2', 'H3'} @@ -39,12 +27,6 @@ J : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 2 : Any : 3 : {('A1', 'B1'), ('A2', 'B2'), ('A3', 'B3')} - T_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*I : 12 : {('A1', 'I1'), ('A1', 'I2'), ('A1', 'I3'), ('A1', 'I4'), ('A2', 'I1'), ('A2', 'I2'), ('A2', 'I3'), ('A2', 'I4'), ('A3', 'I1'), ('A3', 'I2'), ('A3', 'I3'), ('A3', 'I4')} - U_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : I*A : 12 : {('I1', 'A1'), ('I1', 'A2'), ('I1', 'A3'), ('I2', 'A1'), ('I2', 'A2'), ('I2', 'A3'), ('I3', 'A1'), ('I3', 'A2'), ('I3', 'A3'), ('I4', 'A1'), ('I4', 'A2'), ('I4', 'A3')} 12 Param Declarations O : Size=3, Index=J, Domain=Reals, Default=None, Mutable=False @@ -76,7 +58,7 @@ Key : Value A1 : 3.3 A3 : 3.5 - T : Size=12, Index=T_index, Domain=Any, Default=None, Mutable=False + T : Size=12, Index=A*I, Domain=Any, Default=None, Mutable=False Key : Value ('A1', 'I1') : 1.3 ('A1', 'I2') : 1.4 @@ -90,7 +72,7 @@ ('A3', 'I2') : 3.4 ('A3', 'I3') : 3.5 ('A3', 'I4') : 3.6 - U : Size=12, Index=U_index, Domain=Any, Default=None, Mutable=False + U : Size=12, Index=I*A, Domain=Any, Default=None, Mutable=False Key : Value ('I1', 'A1') : 1.3 ('I1', 'A2') : 2.3 @@ -123,4 +105,4 @@ Key : Value None : 1.01 -28 Declarations: A B C D_domain D E_domain_index_0 E_domain E F G_index G H I J Z Y X W U_index U T_index T S R Q P PP O +22 Declarations: A B C D E F G H I J Z Y X W U T S R Q P PP O diff --git a/examples/pyomo/tutorials/excel.py b/examples/pyomo/tutorials/excel.py index 127db722c07..f9a5f66826b 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-2022 +# 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 diff --git a/examples/pyomo/tutorials/param.out b/examples/pyomo/tutorials/param.out index 57e6a752ea5..ea258f5b493 100644 --- a/examples/pyomo/tutorials/param.out +++ b/examples/pyomo/tutorials/param.out @@ -1,22 +1,13 @@ -5 Set Declarations +2 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 4 : {2, 4, 6, 8} B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} - R_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 12 : {(2, 1), (2, 2), (2, 3), (4, 1), (4, 2), (4, 3), (6, 1), (6, 2), (6, 3), (8, 1), (8, 2), (8, 3)} - W_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 12 : {(2, 1), (2, 2), (2, 3), (4, 1), (4, 2), (4, 3), (6, 1), (6, 2), (6, 3), (8, 1), (8, 2), (8, 3)} - X_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 12 : {(2, 1), (2, 2), (2, 3), (4, 1), (4, 2), (4, 3), (6, 1), (6, 2), (6, 3), (8, 1), (8, 2), (8, 3)} 9 Param Declarations - R : Size=12, Index=R_index, Domain=Any, Default=99.0, Mutable=False + R : Size=12, Index=A*B, Domain=Any, Default=99.0, Mutable=False Key : Value (2, 1) : 1 (2, 2) : 1 @@ -35,7 +26,7 @@ 1 : 1 2 : 2 3 : 9 - W : Size=12, Index=W_index, Domain=Any, Default=None, Mutable=False + W : Size=12, Index=A*B, Domain=Any, Default=None, Mutable=False Key : Value (2, 1) : 2 (2, 2) : 4 @@ -49,7 +40,7 @@ (8, 1) : 8 (8, 2) : 16 (8, 3) : 24 - X : Size=12, Index=X_index, Domain=Any, Default=None, Mutable=False + X : Size=12, Index=A*B, Domain=Any, Default=None, Mutable=False Key : Value (2, 1) : 1.3 (2, 2) : 1.4 @@ -73,4 +64,4 @@ Key : Value None : 1.1 -14 Declarations: A B Z Y X_index X W_index W V U T S R_index R +11 Declarations: A B Z Y X W V U T S R diff --git a/examples/pyomo/tutorials/param.py b/examples/pyomo/tutorials/param.py index ba31975ab4b..5a94bafaa5e 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-2022 +# 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 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 b01b666c012..3f278a2f9b2 100644 --- a/examples/pyomo/tutorials/set.out +++ b/examples/pyomo/tutorials/set.out @@ -1,15 +1,12 @@ -28 Set Declarations +25 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 4 : {2, 3, 4, 5} - C : Size=0, Index=C_index, Ordered=Insertion + C : Size=0, Index=A*B, Ordered=Insertion Key : Dimen : Domain : Size : Members - C_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 12 : {(1, 2), (1, 3), (1, 4), (1, 5), (2, 2), (2, 3), (2, 4), (2, 5), (3, 2), (3, 3), (3, 4), (3, 5)} D : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members None : 1 : A | B : 5 : {1, 2, 3, 4, 5} @@ -26,15 +23,9 @@ Key : Dimen : Domain : Size : Members None : 2 : A*B : 12 : {(1, 2), (1, 3), (1, 4), (1, 5), (2, 2), (2, 3), (2, 4), (2, 5), (3, 2), (3, 3), (3, 4), (3, 5)} Hsub : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 2 : Hsub_domain : 3 : {(1, 2), (1, 3), (3, 3)} - Hsub_domain : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : A*B : 12 : {(1, 2), (1, 3), (1, 4), (1, 5), (2, 2), (2, 3), (2, 4), (2, 5), (3, 2), (3, 3), (3, 4), (3, 5)} + None : 2 : A*B : 3 : {(1, 2), (1, 3), (3, 3)} I : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 2 : I_domain : 12 : {(1, 2), (1, 3), (1, 4), (1, 5), (2, 2), (2, 3), (2, 4), (2, 5), (3, 2), (3, 3), (3, 4), (3, 5)} - I_domain : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members None : 2 : A*B : 12 : {(1, 2), (1, 3), (1, 4), (1, 5), (2, 2), (2, 3), (2, 4), (2, 5), (3, 2), (3, 3), (3, 4), (3, 5)} J : Size=1, Index=None, Ordered=Insertion @@ -53,15 +44,12 @@ Key : Dimen : Domain : Size : Members None : 1 : Any : 2 : {1, 3} N : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 2 : N_domain : 0 : {} - N_domain : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : A*B : 12 : {(1, 2), (1, 3), (1, 4), (1, 5), (2, 2), (2, 3), (2, 4), (2, 5), (3, 2), (3, 3), (3, 4), (3, 5)} + None : 2 : A*B : 0 : {} O : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : -- : Any : 0 : {} - P : Size=16, Index=P_index, Ordered=Insertion + P : Size=16, Index=B*B, Ordered=Insertion Key : Dimen : Domain : Size : Members (2, 2) : 1 : Any : 4 : {0, 1, 2, 3} (2, 3) : 1 : Any : 6 : {0, 1, 2, 3, 4, 5} @@ -79,9 +67,6 @@ (5, 3) : 1 : Any : 15 : {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14} (5, 4) : 1 : Any : 20 : {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19} (5, 5) : 1 : Any : 25 : {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24} - P_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : B*B : 16 : {(2, 2), (2, 3), (2, 4), (2, 5), (3, 2), (3, 3), (3, 4), (3, 5), (4, 2), (4, 3), (4, 4), (4, 5), (5, 2), (5, 3), (5, 4), (5, 5)} R : Size=3, Index=B, Ordered=Insertion Key : Dimen : Domain : Size : Members 2 : 1 : Any : 3 : {1, 3, 5} @@ -95,19 +80,23 @@ 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} - V : Size=4, Index=V_index, Ordered=Insertion + V : Size=4, Index=[1:4], Ordered=Insertion Key : Dimen : Domain : Size : Members 1 : 1 : Any : 5 : {1, 2, 3, 4, 5} 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} -1 RangeSet Declarations - V_index : Dimen=1, Size=4, Bounds=(1, 4) - Key : Finite : Members - None : True : [1:4] - -29 Declarations: A B C_index C D E F G H Hsub_domain Hsub I_domain I J K K_2 L M N_domain N O P_index P R S T U V_index 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 78f2656d739..220bfbc82da 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-2022 +# 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 @@ -171,6 +171,13 @@ def P_init(model, i, j): # model.S = 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 = Set(model.B, within=model.S) + # # Validation of set arrays can also be performed with the _validate_ option. @@ -180,7 +187,17 @@ def T_validate(model, value): return value in model.A -model.T = Set(model.B, validate=M_validate) +model.T = 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 = Set(model.B, validate=T_indexed_validate) ## diff --git a/examples/pyomo/tutorials/table.out b/examples/pyomo/tutorials/table.out index 1eba28afd19..75e2b0aee33 100644 --- a/examples/pyomo/tutorials/table.out +++ b/examples/pyomo/tutorials/table.out @@ -1,4 +1,4 @@ -16 Set Declarations +10 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'A1', 'A2', 'A3'} @@ -9,27 +9,15 @@ Key : Dimen : Domain : Size : Members None : 2 : A*B : 9 : {('A1', 1), ('A1', 2), ('A1', 3), ('A2', 1), ('A2', 2), ('A2', 3), ('A3', 1), ('A3', 2), ('A3', 3)} D : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 2 : D_domain : 3 : {('A1', 1), ('A2', 2), ('A3', 3)} - D_domain : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1), ('A1', 2), ('A1', 3), ('A2', 1), ('A2', 2), ('A2', 3), ('A3', 1), ('A3', 2), ('A3', 3)} + None : 2 : A*B : 3 : {('A1', 1), ('A2', 2), ('A3', 3)} E : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 3 : E_domain : 6 : {('A1', 1, 'A1'), ('A1', 1, 'A2'), ('A2', 2, 'A2'), ('A2', 2, 'A3'), ('A3', 3, 'A1'), ('A3', 3, 'A3')} - E_domain : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 3 : E_domain_index_0*A : 27 : {('A1', 1, 'A1'), ('A1', 1, 'A2'), ('A1', 1, 'A3'), ('A1', 2, 'A1'), ('A1', 2, 'A2'), ('A1', 2, 'A3'), ('A1', 3, 'A1'), ('A1', 3, 'A2'), ('A1', 3, 'A3'), ('A2', 1, 'A1'), ('A2', 1, 'A2'), ('A2', 1, 'A3'), ('A2', 2, 'A1'), ('A2', 2, 'A2'), ('A2', 2, 'A3'), ('A2', 3, 'A1'), ('A2', 3, 'A2'), ('A2', 3, 'A3'), ('A3', 1, 'A1'), ('A3', 1, 'A2'), ('A3', 1, 'A3'), ('A3', 2, 'A1'), ('A3', 2, 'A2'), ('A3', 2, 'A3'), ('A3', 3, 'A1'), ('A3', 3, 'A2'), ('A3', 3, 'A3')} - E_domain_index_0 : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1), ('A1', 2), ('A1', 3), ('A2', 1), ('A2', 2), ('A2', 3), ('A3', 1), ('A3', 2), ('A3', 3)} + None : 3 : A*B*A : 6 : {('A1', 1, 'A1'), ('A1', 1, 'A2'), ('A2', 2, 'A2'), ('A2', 2, 'A3'), ('A3', 3, 'A1'), ('A3', 3, 'A3')} F : Size=0, Index=A, Ordered=Insertion Key : Dimen : Domain : Size : Members - G : Size=0, Index=G_index, Ordered=Insertion + G : Size=0, Index=A*B, Ordered=Insertion Key : Dimen : Domain : Size : Members - G_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {('A1', 1), ('A1', 2), ('A1', 3), ('A2', 1), ('A2', 2), ('A2', 3), ('A3', 1), ('A3', 2), ('A3', 3)} H : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {'H1', 'H2', 'H3'} @@ -39,12 +27,6 @@ J : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 2 : Any : 3 : {('A1', 'B1'), ('A2', 'B2'), ('A3', 'B3')} - T_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*I : 12 : {('A1', 'I1'), ('A1', 'I2'), ('A1', 'I3'), ('A1', 'I4'), ('A2', 'I1'), ('A2', 'I2'), ('A2', 'I3'), ('A2', 'I4'), ('A3', 'I1'), ('A3', 'I2'), ('A3', 'I3'), ('A3', 'I4')} - U_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : I*A : 12 : {('I1', 'A1'), ('I1', 'A2'), ('I1', 'A3'), ('I2', 'A1'), ('I2', 'A2'), ('I2', 'A3'), ('I3', 'A1'), ('I3', 'A2'), ('I3', 'A3'), ('I4', 'A1'), ('I4', 'A2'), ('I4', 'A3')} 12 Param Declarations O : Size=3, Index=J, Domain=Reals, Default=None, Mutable=False @@ -76,7 +58,7 @@ Key : Value A1 : 3.3 A3 : 3.5 - T : Size=12, Index=T_index, Domain=Any, Default=None, Mutable=False + T : Size=12, Index=A*I, Domain=Any, Default=None, Mutable=False Key : Value ('A1', 'I1') : 1.3 ('A1', 'I2') : 1.4 @@ -90,7 +72,7 @@ ('A3', 'I2') : 3.4 ('A3', 'I3') : 3.5 ('A3', 'I4') : 3.6 - U : Size=12, Index=U_index, Domain=Any, Default=None, Mutable=False + U : Size=12, Index=I*A, Domain=Any, Default=None, Mutable=False Key : Value ('I1', 'A1') : 1.3 ('I1', 'A2') : 2.3 @@ -123,4 +105,4 @@ Key : Value None : 1.01 -28 Declarations: A B C D_domain D E_domain_index_0 E_domain E F G_index G H I J Z Y X W U_index U T_index T S R Q P PP O +22 Declarations: A B C D E F G H I J Z Y X W U T S R Q P PP O diff --git a/examples/pyomo/tutorials/table.py b/examples/pyomo/tutorials/table.py index 16951352ee1..7d9fceda14a 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-2022 +# 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 diff --git a/examples/pyomobook/__init__.py b/examples/pyomobook/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/examples/pyomobook/__init__.py +++ b/examples/pyomobook/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/examples/pyomobook/abstract-ch/AbstHLinScript.py b/examples/pyomobook/abstract-ch/AbstHLinScript.py index adf700bfd5c..687d3fc4e6b 100644 --- a/examples/pyomobook/abstract-ch/AbstHLinScript.py +++ b/examples/pyomobook/abstract-ch/AbstHLinScript.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # AbstHLinScript.py - Script for a simple linear version of (H) import pyomo.environ as pyo diff --git a/examples/pyomobook/abstract-ch/AbstractH.py b/examples/pyomobook/abstract-ch/AbstractH.py index da9f0a4931c..7595cbc4933 100644 --- a/examples/pyomobook/abstract-ch/AbstractH.py +++ b/examples/pyomobook/abstract-ch/AbstractH.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # AbstractH.py - Implement model (H) import pyomo.environ as pyo diff --git a/examples/pyomobook/abstract-ch/AbstractHLinear.py b/examples/pyomobook/abstract-ch/AbstractHLinear.py index 575487d3e95..f312020a9d5 100644 --- a/examples/pyomobook/abstract-ch/AbstractHLinear.py +++ b/examples/pyomobook/abstract-ch/AbstractHLinear.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # AbstractHLinear.py - A simple linear version of (H) import pyomo.environ as pyo diff --git a/examples/pyomobook/abstract-ch/abstract5.py b/examples/pyomobook/abstract-ch/abstract5.py index 3a06256dff8..8849d2dfe7f 100644 --- a/examples/pyomobook/abstract-ch/abstract5.py +++ b/examples/pyomobook/abstract-ch/abstract5.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # abstract5.py import pyomo.environ as pyo diff --git a/examples/pyomobook/abstract-ch/abstract6.py b/examples/pyomobook/abstract-ch/abstract6.py index d11a4652f64..121b12a51fa 100644 --- a/examples/pyomobook/abstract-ch/abstract6.py +++ b/examples/pyomobook/abstract-ch/abstract6.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # abstract6.py import pyomo.environ as pyo diff --git a/examples/pyomobook/abstract-ch/abstract7.py b/examples/pyomobook/abstract-ch/abstract7.py index 2fd5d467d3e..3e8131bf42b 100644 --- a/examples/pyomobook/abstract-ch/abstract7.py +++ b/examples/pyomobook/abstract-ch/abstract7.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # abstract7.py import pyomo.environ as pyo import pickle diff --git a/examples/pyomobook/abstract-ch/buildactions.py b/examples/pyomobook/abstract-ch/buildactions.py index ad918e2b5f2..6963f285c4c 100644 --- a/examples/pyomobook/abstract-ch/buildactions.py +++ b/examples/pyomobook/abstract-ch/buildactions.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # buildactions.py: Warehouse location problem showing build actions import pyomo.environ as pyo diff --git a/examples/pyomobook/abstract-ch/concrete1.py b/examples/pyomobook/abstract-ch/concrete1.py index 0ad41c79ea3..2c89fbafaad 100644 --- a/examples/pyomobook/abstract-ch/concrete1.py +++ b/examples/pyomobook/abstract-ch/concrete1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/abstract-ch/concrete2.py b/examples/pyomobook/abstract-ch/concrete2.py index 6aee434d556..f68c4d6e242 100644 --- a/examples/pyomobook/abstract-ch/concrete2.py +++ b/examples/pyomobook/abstract-ch/concrete2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/abstract-ch/diet1.py b/examples/pyomobook/abstract-ch/diet1.py index eb8b071cdb5..fa8bf5f549f 100644 --- a/examples/pyomobook/abstract-ch/diet1.py +++ b/examples/pyomobook/abstract-ch/diet1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # diet1.py import pyomo.environ as pyo diff --git a/examples/pyomobook/abstract-ch/ex.py b/examples/pyomobook/abstract-ch/ex.py index 88005b7dc0c..83cfd445e01 100644 --- a/examples/pyomobook/abstract-ch/ex.py +++ b/examples/pyomobook/abstract-ch/ex.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param1.py b/examples/pyomobook/abstract-ch/param1.py index fc9fac99ff4..3ff8b648661 100644 --- a/examples/pyomobook/abstract-ch/param1.py +++ b/examples/pyomobook/abstract-ch/param1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param2.py b/examples/pyomobook/abstract-ch/param2.py index d51cbeffe84..aca8fac0baf 100644 --- a/examples/pyomobook/abstract-ch/param2.py +++ b/examples/pyomobook/abstract-ch/param2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param2a.py b/examples/pyomobook/abstract-ch/param2a.py index fe928eb4197..6b6f77f2a8f 100644 --- a/examples/pyomobook/abstract-ch/param2a.py +++ b/examples/pyomobook/abstract-ch/param2a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param3.py b/examples/pyomobook/abstract-ch/param3.py index 64efba5c5ad..7545b47dadc 100644 --- a/examples/pyomobook/abstract-ch/param3.py +++ b/examples/pyomobook/abstract-ch/param3.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param3a.py b/examples/pyomobook/abstract-ch/param3a.py index 857d96f8318..4c52b6432fb 100644 --- a/examples/pyomobook/abstract-ch/param3a.py +++ b/examples/pyomobook/abstract-ch/param3a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param3b.py b/examples/pyomobook/abstract-ch/param3b.py index 655694c33dd..786d6b58a16 100644 --- a/examples/pyomobook/abstract-ch/param3b.py +++ b/examples/pyomobook/abstract-ch/param3b.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param3c.py b/examples/pyomobook/abstract-ch/param3c.py index 7d58b8b6a39..3f5da5f837e 100644 --- a/examples/pyomobook/abstract-ch/param3c.py +++ b/examples/pyomobook/abstract-ch/param3c.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param4.py b/examples/pyomobook/abstract-ch/param4.py index c902b9034ad..c1926ddea74 100644 --- a/examples/pyomobook/abstract-ch/param4.py +++ b/examples/pyomobook/abstract-ch/param4.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param5.py b/examples/pyomobook/abstract-ch/param5.py index 488e1debda8..7e0020f70b7 100644 --- a/examples/pyomobook/abstract-ch/param5.py +++ b/examples/pyomobook/abstract-ch/param5.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param5a.py b/examples/pyomobook/abstract-ch/param5a.py index 7e814b917cc..efdd1855f3f 100644 --- a/examples/pyomobook/abstract-ch/param5a.py +++ b/examples/pyomobook/abstract-ch/param5a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param6.py b/examples/pyomobook/abstract-ch/param6.py index d9c49a548b2..f6d60f11e4b 100644 --- a/examples/pyomobook/abstract-ch/param6.py +++ b/examples/pyomobook/abstract-ch/param6.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param6a.py b/examples/pyomobook/abstract-ch/param6a.py index e9aca384ee6..280e942d01d 100644 --- a/examples/pyomobook/abstract-ch/param6a.py +++ b/examples/pyomobook/abstract-ch/param6a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param7a.py b/examples/pyomobook/abstract-ch/param7a.py index 2a18cceabf6..21839bf3b64 100644 --- a/examples/pyomobook/abstract-ch/param7a.py +++ b/examples/pyomobook/abstract-ch/param7a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param7b.py b/examples/pyomobook/abstract-ch/param7b.py index acf02ddd62f..a4d79b6dee9 100644 --- a/examples/pyomobook/abstract-ch/param7b.py +++ b/examples/pyomobook/abstract-ch/param7b.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/param8a.py b/examples/pyomobook/abstract-ch/param8a.py index e68378961ed..f00ed649c30 100644 --- a/examples/pyomobook/abstract-ch/param8a.py +++ b/examples/pyomobook/abstract-ch/param8a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/postprocess_fn.py b/examples/pyomobook/abstract-ch/postprocess_fn.py index f96a5b4dac1..2f2d114c216 100644 --- a/examples/pyomobook/abstract-ch/postprocess_fn.py +++ b/examples/pyomobook/abstract-ch/postprocess_fn.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 csv diff --git a/examples/pyomobook/abstract-ch/set1.py b/examples/pyomobook/abstract-ch/set1.py index ee281bd10bd..5a23fe683e0 100644 --- a/examples/pyomobook/abstract-ch/set1.py +++ b/examples/pyomobook/abstract-ch/set1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/set2.py b/examples/pyomobook/abstract-ch/set2.py index 27af609cead..5ecc0914bee 100644 --- a/examples/pyomobook/abstract-ch/set2.py +++ b/examples/pyomobook/abstract-ch/set2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/set2a.py b/examples/pyomobook/abstract-ch/set2a.py index bf8f06dd7a8..7252ec0ad69 100644 --- a/examples/pyomobook/abstract-ch/set2a.py +++ b/examples/pyomobook/abstract-ch/set2a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/set3.py b/examples/pyomobook/abstract-ch/set3.py index 7661963d19d..f3e3efc33c7 100644 --- a/examples/pyomobook/abstract-ch/set3.py +++ b/examples/pyomobook/abstract-ch/set3.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/set4.py b/examples/pyomobook/abstract-ch/set4.py index c9125dad657..0c29798b816 100644 --- a/examples/pyomobook/abstract-ch/set4.py +++ b/examples/pyomobook/abstract-ch/set4.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/set5.py b/examples/pyomobook/abstract-ch/set5.py index 9f79870d3ff..781b956404e 100644 --- a/examples/pyomobook/abstract-ch/set5.py +++ b/examples/pyomobook/abstract-ch/set5.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/abstract-ch/wl_abstract.py b/examples/pyomobook/abstract-ch/wl_abstract.py index f35a5327bfb..61eeed6b506 100644 --- a/examples/pyomobook/abstract-ch/wl_abstract.py +++ b/examples/pyomobook/abstract-ch/wl_abstract.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_abstract.py: AbstractModel version of warehouse location determination problem import pyomo.environ as pyo diff --git a/examples/pyomobook/abstract-ch/wl_abstract_script.py b/examples/pyomobook/abstract-ch/wl_abstract_script.py index 0b042405714..7f0871350fc 100644 --- a/examples/pyomobook/abstract-ch/wl_abstract_script.py +++ b/examples/pyomobook/abstract-ch/wl_abstract_script.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_abstract_script.py: Scripting using an AbstractModel import pyomo.environ as pyo diff --git a/examples/pyomobook/blocks-ch/blocks_gen.py b/examples/pyomobook/blocks-ch/blocks_gen.py index 109e881cad5..31a4462f7d6 100644 --- a/examples/pyomobook/blocks-ch/blocks_gen.py +++ b/examples/pyomobook/blocks-ch/blocks_gen.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo time = range(5) diff --git a/examples/pyomobook/blocks-ch/blocks_gen.txt b/examples/pyomobook/blocks-ch/blocks_gen.txt index 63d634b3b95..1636f7e4590 100644 --- a/examples/pyomobook/blocks-ch/blocks_gen.txt +++ b/examples/pyomobook/blocks-ch/blocks_gen.txt @@ -9,13 +9,8 @@ 1 Block Declarations Generator : Size=2, Index=GEN_UNITS, Active=True Generator[G_EAST] : Active=True - 1 Set Declarations - CostCoef_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {1, 2} - 3 Param Declarations - CostCoef : Size=0, Index=Generator[G_EAST].CostCoef_index, Domain=Any, Default=None, Mutable=False + CostCoef : Size=0, Index={1, 2}, Domain=Any, Default=None, Mutable=False Key : Value MaxPower : Size=1, Index=None, Domain=NonNegativeReals, Default=None, Mutable=False Key : Value @@ -27,11 +22,11 @@ 2 Var Declarations Power : Size=5, Index=TIME Key : Lower : Value : Upper : Fixed : Stale : Domain - 0 : 0 : 120.0 : 500 : False : False : Reals - 1 : 0 : 145.0 : 500 : False : False : Reals - 2 : 0 : 119.0 : 500 : False : False : Reals - 3 : 0 : 42.0 : 500 : False : False : Reals - 4 : 0 : 190.0 : 500 : False : False : Reals + 0 : 0 : 120.0 : 500.0 : False : False : Reals + 1 : 0 : 145.0 : 500.0 : False : False : Reals + 2 : 0 : 119.0 : 500.0 : False : False : Reals + 3 : 0 : 42.0 : 500.0 : False : False : Reals + 4 : 0 : 190.0 : 500.0 : False : False : Reals UnitOn : Size=5, Index=TIME Key : Lower : Value : Upper : Fixed : Stale : Domain 0 : 0 : None : 1 : False : True : Binary @@ -57,15 +52,10 @@ 3 : -50.0 : Generator[G_EAST].Power[3] - Generator[G_EAST].Power[2] : Generator[G_EAST].RampLimit : True 4 : -50.0 : Generator[G_EAST].Power[4] - Generator[G_EAST].Power[3] : Generator[G_EAST].RampLimit : True - 8 Declarations: MaxPower RampLimit Power UnitOn limit_ramp CostCoef_index CostCoef Cost + 7 Declarations: MaxPower RampLimit Power UnitOn limit_ramp CostCoef Cost Generator[G_MAIN] : Active=True - 1 Set Declarations - CostCoef_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {1, 2} - 3 Param Declarations - CostCoef : Size=0, Index=Generator[G_MAIN].CostCoef_index, Domain=Any, Default=None, Mutable=False + CostCoef : Size=0, Index={1, 2}, Domain=Any, Default=None, Mutable=False Key : Value MaxPower : Size=1, Index=None, Domain=NonNegativeReals, Default=None, Mutable=False Key : Value @@ -77,11 +67,11 @@ 2 Var Declarations Power : Size=5, Index=TIME Key : Lower : Value : Upper : Fixed : Stale : Domain - 0 : 0 : 120.0 : 500 : False : False : Reals - 1 : 0 : 145.0 : 500 : False : False : Reals - 2 : 0 : 119.0 : 500 : False : False : Reals - 3 : 0 : 42.0 : 500 : False : False : Reals - 4 : 0 : 190.0 : 500 : False : False : Reals + 0 : 0 : 120.0 : 500.0 : False : False : Reals + 1 : 0 : 145.0 : 500.0 : False : False : Reals + 2 : 0 : 119.0 : 500.0 : False : False : Reals + 3 : 0 : 42.0 : 500.0 : False : False : Reals + 4 : 0 : 190.0 : 500.0 : False : False : Reals UnitOn : Size=5, Index=TIME Key : Lower : Value : Upper : Fixed : Stale : Domain 0 : 0 : None : 1 : False : True : Binary @@ -107,7 +97,7 @@ 3 : -50.0 : Generator[G_MAIN].Power[3] - Generator[G_MAIN].Power[2] : Generator[G_MAIN].RampLimit : True 4 : -50.0 : Generator[G_MAIN].Power[4] - Generator[G_MAIN].Power[3] : Generator[G_MAIN].RampLimit : True - 8 Declarations: MaxPower RampLimit Power UnitOn limit_ramp CostCoef_index CostCoef Cost + 7 Declarations: MaxPower RampLimit Power UnitOn limit_ramp CostCoef Cost 3 Declarations: TIME GEN_UNITS Generator 2 Set Declarations @@ -121,13 +111,8 @@ 1 Block Declarations Generator : Size=2, Index=GEN_UNITS, Active=True Generator[G_EAST] : Active=True - 1 Set Declarations - CostCoef_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {1, 2} - 3 Param Declarations - CostCoef : Size=0, Index=Generator[G_EAST].CostCoef_index, Domain=Any, Default=None, Mutable=False + CostCoef : Size=0, Index={1, 2}, Domain=Any, Default=None, Mutable=False Key : Value MaxPower : Size=1, Index=None, Domain=NonNegativeReals, Default=None, Mutable=False Key : Value @@ -139,11 +124,11 @@ 2 Var Declarations Power : Size=5, Index=TIME Key : Lower : Value : Upper : Fixed : Stale : Domain - 0 : 0 : 120.0 : 500 : False : False : Reals - 1 : 0 : 145.0 : 500 : False : False : Reals - 2 : 0 : 119.0 : 500 : False : False : Reals - 3 : 0 : 42.0 : 500 : False : False : Reals - 4 : 0 : 190.0 : 500 : False : False : Reals + 0 : 0 : 120.0 : 500.0 : False : False : Reals + 1 : 0 : 145.0 : 500.0 : False : False : Reals + 2 : 0 : 119.0 : 500.0 : False : False : Reals + 3 : 0 : 42.0 : 500.0 : False : False : Reals + 4 : 0 : 190.0 : 500.0 : False : False : Reals UnitOn : Size=5, Index=TIME Key : Lower : Value : Upper : Fixed : Stale : Domain 0 : 0 : None : 1 : False : True : Binary @@ -169,15 +154,10 @@ 3 : -50.0 : Generator[G_EAST].Power[3] - Generator[G_EAST].Power[2] : Generator[G_EAST].RampLimit : True 4 : -50.0 : Generator[G_EAST].Power[4] - Generator[G_EAST].Power[3] : Generator[G_EAST].RampLimit : True - 8 Declarations: MaxPower RampLimit Power UnitOn limit_ramp CostCoef_index CostCoef Cost + 7 Declarations: MaxPower RampLimit Power UnitOn limit_ramp CostCoef Cost Generator[G_MAIN] : Active=True - 1 Set Declarations - CostCoef_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {1, 2} - 3 Param Declarations - CostCoef : Size=0, Index=Generator[G_MAIN].CostCoef_index, Domain=Any, Default=None, Mutable=False + CostCoef : Size=0, Index={1, 2}, Domain=Any, Default=None, Mutable=False Key : Value MaxPower : Size=1, Index=None, Domain=NonNegativeReals, Default=None, Mutable=False Key : Value @@ -189,11 +169,11 @@ 2 Var Declarations Power : Size=5, Index=TIME Key : Lower : Value : Upper : Fixed : Stale : Domain - 0 : 0 : 120.0 : 500 : False : False : Reals - 1 : 0 : 145.0 : 500 : False : False : Reals - 2 : 0 : 119.0 : 500 : False : False : Reals - 3 : 0 : 42.0 : 500 : False : False : Reals - 4 : 0 : 190.0 : 500 : False : False : Reals + 0 : 0 : 120.0 : 500.0 : False : False : Reals + 1 : 0 : 145.0 : 500.0 : False : False : Reals + 2 : 0 : 119.0 : 500.0 : False : False : Reals + 3 : 0 : 42.0 : 500.0 : False : False : Reals + 4 : 0 : 190.0 : 500.0 : False : False : Reals UnitOn : Size=5, Index=TIME Key : Lower : Value : Upper : Fixed : Stale : Domain 0 : 0 : None : 1 : False : True : Binary @@ -219,7 +199,7 @@ 3 : -50.0 : Generator[G_MAIN].Power[3] - Generator[G_MAIN].Power[2] : Generator[G_MAIN].RampLimit : True 4 : -50.0 : Generator[G_MAIN].Power[4] - Generator[G_MAIN].Power[3] : Generator[G_MAIN].RampLimit : True - 8 Declarations: MaxPower RampLimit Power UnitOn limit_ramp CostCoef_index CostCoef Cost + 7 Declarations: MaxPower RampLimit Power UnitOn limit_ramp CostCoef Cost 3 Declarations: TIME GEN_UNITS Generator Generator[G_MAIN].Power[4] = 190.0 diff --git a/examples/pyomobook/blocks-ch/blocks_intro.py b/examples/pyomobook/blocks-ch/blocks_intro.py index ad3ceaa4349..ba2bd9d3a97 100644 --- a/examples/pyomobook/blocks-ch/blocks_intro.py +++ b/examples/pyomobook/blocks-ch/blocks_intro.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo # @hierarchy: diff --git a/examples/pyomobook/blocks-ch/blocks_lotsizing.py b/examples/pyomobook/blocks-ch/blocks_lotsizing.py index fe0717d8c7c..758ad964dc5 100644 --- a/examples/pyomobook/blocks-ch/blocks_lotsizing.py +++ b/examples/pyomobook/blocks-ch/blocks_lotsizing.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/blocks-ch/lotsizing.py b/examples/pyomobook/blocks-ch/lotsizing.py index 47ea265246e..ece4d6b541c 100644 --- a/examples/pyomobook/blocks-ch/lotsizing.py +++ b/examples/pyomobook/blocks-ch/lotsizing.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/blocks-ch/lotsizing_no_time.py b/examples/pyomobook/blocks-ch/lotsizing_no_time.py index 901467a0cbb..60e8ba44424 100644 --- a/examples/pyomobook/blocks-ch/lotsizing_no_time.py +++ b/examples/pyomobook/blocks-ch/lotsizing_no_time.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/blocks-ch/lotsizing_uncertain.py b/examples/pyomobook/blocks-ch/lotsizing_uncertain.py index 6d16de7e3a7..f72161db5c6 100644 --- a/examples/pyomobook/blocks-ch/lotsizing_uncertain.py +++ b/examples/pyomobook/blocks-ch/lotsizing_uncertain.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/blocks-ch/lotsizing_uncertain.txt b/examples/pyomobook/blocks-ch/lotsizing_uncertain.txt index db9eee79cc3..08f92ae9262 100644 --- a/examples/pyomobook/blocks-ch/lotsizing_uncertain.txt +++ b/examples/pyomobook/blocks-ch/lotsizing_uncertain.txt @@ -1,20 +1,3 @@ -5 Set Declarations - i_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : T*S : 25 : {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (4, 1), (4, 2), (4, 3), (4, 4), (4, 5), (5, 1), (5, 2), (5, 3), (5, 4), (5, 5)} - i_neg_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : T*S : 25 : {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (4, 1), (4, 2), (4, 3), (4, 4), (4, 5), (5, 1), (5, 2), (5, 3), (5, 4), (5, 5)} - i_pos_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : T*S : 25 : {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (4, 1), (4, 2), (4, 3), (4, 4), (4, 5), (5, 1), (5, 2), (5, 3), (5, 4), (5, 5)} - x_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : T*S : 25 : {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (4, 1), (4, 2), (4, 3), (4, 4), (4, 5), (5, 1), (5, 2), (5, 3), (5, 4), (5, 5)} - y_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : T*S : 25 : {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (4, 1), (4, 2), (4, 3), (4, 4), (4, 5), (5, 1), (5, 2), (5, 3), (5, 4), (5, 5)} - 2 RangeSet Declarations S : Dimen=1, Size=5, Bounds=(1, 5) Key : Finite : Members @@ -24,7 +7,7 @@ None : True : [1:5] 5 Var Declarations - i : Size=25, Index=i_index + i : Size=25, Index=T*S Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 1) : None : None : None : False : True : Reals (1, 2) : None : None : None : False : True : Reals @@ -51,7 +34,7 @@ (5, 3) : None : None : None : False : True : Reals (5, 4) : None : None : None : False : True : Reals (5, 5) : None : None : None : False : True : Reals - i_neg : Size=25, Index=i_neg_index + i_neg : Size=25, Index=T*S Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 1) : 0 : None : None : False : True : NonNegativeReals (1, 2) : 0 : None : None : False : True : NonNegativeReals @@ -78,7 +61,7 @@ (5, 3) : 0 : None : None : False : True : NonNegativeReals (5, 4) : 0 : None : None : False : True : NonNegativeReals (5, 5) : 0 : None : None : False : True : NonNegativeReals - i_pos : Size=25, Index=i_pos_index + i_pos : Size=25, Index=T*S Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 1) : 0 : None : None : False : True : NonNegativeReals (1, 2) : 0 : None : None : False : True : NonNegativeReals @@ -105,7 +88,7 @@ (5, 3) : 0 : None : None : False : True : NonNegativeReals (5, 4) : 0 : None : None : False : True : NonNegativeReals (5, 5) : 0 : None : None : False : True : NonNegativeReals - x : Size=25, Index=x_index + x : Size=25, Index=T*S Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 1) : 0 : None : None : False : True : NonNegativeReals (1, 2) : 0 : None : None : False : True : NonNegativeReals @@ -132,7 +115,7 @@ (5, 3) : 0 : None : None : False : True : NonNegativeReals (5, 4) : 0 : None : None : False : True : NonNegativeReals (5, 5) : 0 : None : None : False : True : NonNegativeReals - y : Size=25, Index=y_index + y : Size=25, Index=T*S Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 1) : 0 : None : 1 : False : True : Binary (1, 2) : 0 : None : 1 : False : True : Binary @@ -160,4 +143,4 @@ (5, 4) : 0 : None : 1 : False : True : Binary (5, 5) : 0 : None : 1 : False : True : Binary -12 Declarations: T S y_index y x_index x i_index i i_pos_index i_pos i_neg_index i_neg +7 Declarations: T S y x i i_pos i_neg diff --git a/examples/pyomobook/dae-ch/dae_tester_model.py b/examples/pyomobook/dae-ch/dae_tester_model.py index 9e0da9f4a62..396b8a53db1 100644 --- a/examples/pyomobook/dae-ch/dae_tester_model.py +++ b/examples/pyomobook/dae-ch/dae_tester_model.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # This is a file for testing miscellaneous code snippets from the DAE chapter import pyomo.environ as pyo import pyomo.dae as dae diff --git a/examples/pyomobook/dae-ch/path_constraint.py b/examples/pyomobook/dae-ch/path_constraint.py index 5fe41dd132d..5e252d1b99f 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-2022 +# 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 diff --git a/examples/pyomobook/dae-ch/path_constraint.txt b/examples/pyomobook/dae-ch/path_constraint.txt index 421692b33e9..97e56ab8816 100644 --- a/examples/pyomobook/dae-ch/path_constraint.txt +++ b/examples/pyomobook/dae-ch/path_constraint.txt @@ -1,8 +1,3 @@ -1 RangeSet Declarations - t_domain : Dimen=1, Size=Inf, Bounds=(0, 1) - Key : Finite : Members - None : False : [0..1] - 1 Param Declarations tf : Size=1, Index=None, Domain=Any, Default=None, Mutable=False Key : Value @@ -68,4 +63,4 @@ 0 : None : None : None : False : True : Reals 1 : None : None : None : False : True : Reals -15 Declarations: tf t_domain t u x1 x2 x3 dx1 dx2 dx3 x1dotcon x2dotcon x3dotcon obj con +14 Declarations: tf t u x1 x2 x3 dx1 dx2 dx3 x1dotcon x2dotcon x3dotcon obj con diff --git a/examples/pyomobook/dae-ch/plot_path_constraint.py b/examples/pyomobook/dae-ch/plot_path_constraint.py index 4c04bc1b6b6..be86f13cbc0 100644 --- a/examples/pyomobook/dae-ch/plot_path_constraint.py +++ b/examples/pyomobook/dae-ch/plot_path_constraint.py @@ -1,3 +1,15 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + # @plot_path: def plotter(subplot, x, *y, **kwds): plt.subplot(subplot) diff --git a/examples/pyomobook/dae-ch/run_path_constraint.py b/examples/pyomobook/dae-ch/run_path_constraint.py index b819d6a7127..fc115f5649c 100644 --- a/examples/pyomobook/dae-ch/run_path_constraint.py +++ b/examples/pyomobook/dae-ch/run_path_constraint.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo from pyomo.dae import * from path_constraint import m diff --git a/examples/pyomobook/dae-ch/run_path_constraint_tester.py b/examples/pyomobook/dae-ch/run_path_constraint_tester.py index bbcd83f5da5..22d887e9b11 100644 --- a/examples/pyomobook/dae-ch/run_path_constraint_tester.py +++ b/examples/pyomobook/dae-ch/run_path_constraint_tester.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.tee import capture_output from six import StringIO diff --git a/examples/pyomobook/gdp-ch/gdp_uc.py b/examples/pyomobook/gdp-ch/gdp_uc.py index 2495ed9bef1..ff3c554c039 100644 --- a/examples/pyomobook/gdp-ch/gdp_uc.py +++ b/examples/pyomobook/gdp-ch/gdp_uc.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # gdp_uc.py import pyomo.environ as pyo from pyomo.gdp import * @@ -105,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 76597326700..d1cf4b172bd 100644 --- a/examples/pyomobook/gdp-ch/scont.py +++ b/examples/pyomobook/gdp-ch/scont.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # scont.py import pyomo.environ as pyo from pyomo.gdp import Disjunct, Disjunction diff --git a/examples/pyomobook/gdp-ch/scont2.py b/examples/pyomobook/gdp-ch/scont2.py index 94e510b358a..2c77fe670d5 100644 --- a/examples/pyomobook/gdp-ch/scont2.py +++ b/examples/pyomobook/gdp-ch/scont2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo import scont diff --git a/examples/pyomobook/gdp-ch/scont_script.py b/examples/pyomobook/gdp-ch/scont_script.py index 22c9b88ad0c..fe0702dc262 100644 --- a/examples/pyomobook/gdp-ch/scont_script.py +++ b/examples/pyomobook/gdp-ch/scont_script.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo import scont diff --git a/examples/pyomobook/gdp-ch/verify_scont.py b/examples/pyomobook/gdp-ch/verify_scont.py index db44024fe66..222453560b6 100644 --- a/examples/pyomobook/gdp-ch/verify_scont.py +++ b/examples/pyomobook/gdp-ch/verify_scont.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 diff --git a/examples/pyomobook/intro-ch/abstract5.py b/examples/pyomobook/intro-ch/abstract5.py index 2184ed7b3aa..2caad5f9351 100644 --- a/examples/pyomobook/intro-ch/abstract5.py +++ b/examples/pyomobook/intro-ch/abstract5.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/intro-ch/coloring_concrete.py b/examples/pyomobook/intro-ch/coloring_concrete.py index 107a31668c4..9931b5d80de 100644 --- a/examples/pyomobook/intro-ch/coloring_concrete.py +++ b/examples/pyomobook/intro-ch/coloring_concrete.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # Graph coloring example adapted from # diff --git a/examples/pyomobook/intro-ch/concrete1.py b/examples/pyomobook/intro-ch/concrete1.py index a39ca1d41cd..169fbeb281c 100644 --- a/examples/pyomobook/intro-ch/concrete1.py +++ b/examples/pyomobook/intro-ch/concrete1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/intro-ch/concrete1_generic.py b/examples/pyomobook/intro-ch/concrete1_generic.py index de648470469..9a2d26bded8 100644 --- a/examples/pyomobook/intro-ch/concrete1_generic.py +++ b/examples/pyomobook/intro-ch/concrete1_generic.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo import mydata diff --git a/examples/pyomobook/intro-ch/mydata.py b/examples/pyomobook/intro-ch/mydata.py index 83aa26bacd9..209546ebeaf 100644 --- a/examples/pyomobook/intro-ch/mydata.py +++ b/examples/pyomobook/intro-ch/mydata.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + N = [1, 2] M = [1, 2] c = {1: 1, 2: 2} diff --git a/examples/pyomobook/mpec-ch/ex1a.py b/examples/pyomobook/mpec-ch/ex1a.py index 30cd2842556..e6f1c33fbbc 100644 --- a/examples/pyomobook/mpec-ch/ex1a.py +++ b/examples/pyomobook/mpec-ch/ex1a.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ex1a.py import pyomo.environ as pyo from pyomo.mpec import Complementarity, complements diff --git a/examples/pyomobook/mpec-ch/ex1b.py b/examples/pyomobook/mpec-ch/ex1b.py index 9592c81c4f6..2b0ac2ce1b7 100644 --- a/examples/pyomobook/mpec-ch/ex1b.py +++ b/examples/pyomobook/mpec-ch/ex1b.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ex1b.py import pyomo.environ as pyo from pyomo.mpec import ComplementarityList, complements diff --git a/examples/pyomobook/mpec-ch/ex1c.py b/examples/pyomobook/mpec-ch/ex1c.py index aad9c9b0d47..eaf0292b50d 100644 --- a/examples/pyomobook/mpec-ch/ex1c.py +++ b/examples/pyomobook/mpec-ch/ex1c.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ex1c.py import pyomo.environ as pyo from pyomo.mpec import ComplementarityList, complements diff --git a/examples/pyomobook/mpec-ch/ex1d.py b/examples/pyomobook/mpec-ch/ex1d.py index fa5247ff831..4c0e0d9fd0f 100644 --- a/examples/pyomobook/mpec-ch/ex1d.py +++ b/examples/pyomobook/mpec-ch/ex1d.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ex1d.py import pyomo.environ as pyo from pyomo.mpec import Complementarity, complements diff --git a/examples/pyomobook/mpec-ch/ex1e.py b/examples/pyomobook/mpec-ch/ex1e.py index bf714411396..c552847fcfb 100644 --- a/examples/pyomobook/mpec-ch/ex1e.py +++ b/examples/pyomobook/mpec-ch/ex1e.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ex1e.py import pyomo.environ as pyo from pyomo.mpec import ComplementarityList, complements diff --git a/examples/pyomobook/mpec-ch/ex2.py b/examples/pyomobook/mpec-ch/ex2.py index c192ccc7a34..6981af33376 100644 --- a/examples/pyomobook/mpec-ch/ex2.py +++ b/examples/pyomobook/mpec-ch/ex2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ex2.py import pyomo.environ as pyo from pyomo.mpec import * diff --git a/examples/pyomobook/mpec-ch/munson1.py b/examples/pyomobook/mpec-ch/munson1.py index c7d171eb416..e85d9359768 100644 --- a/examples/pyomobook/mpec-ch/munson1.py +++ b/examples/pyomobook/mpec-ch/munson1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # munson1.py import pyomo.environ as pyo from pyomo.mpec import Complementarity, complements diff --git a/examples/pyomobook/mpec-ch/ralph1.py b/examples/pyomobook/mpec-ch/ralph1.py index 1d44a303b84..b6a8b45e8df 100644 --- a/examples/pyomobook/mpec-ch/ralph1.py +++ b/examples/pyomobook/mpec-ch/ralph1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ralph1.py import pyomo.environ as pyo from pyomo.mpec import Complementarity, complements diff --git a/examples/pyomobook/nonlinear-ch/deer/DeerProblem.py b/examples/pyomobook/nonlinear-ch/deer/DeerProblem.py index c076a7f4687..dc3ca179a58 100644 --- a/examples/pyomobook/nonlinear-ch/deer/DeerProblem.py +++ b/examples/pyomobook/nonlinear-ch/deer/DeerProblem.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # DeerProblem.py import pyomo.environ as pyo diff --git a/examples/pyomobook/nonlinear-ch/disease_est/disease_estimation.py b/examples/pyomobook/nonlinear-ch/disease_est/disease_estimation.py index 4eb859dc349..5675d7a715b 100644 --- a/examples/pyomobook/nonlinear-ch/disease_est/disease_estimation.py +++ b/examples/pyomobook/nonlinear-ch/disease_est/disease_estimation.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # disease_estimation.py import pyomo.environ as pyo diff --git a/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init1.py b/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init1.py index c435cafc3d5..a50bf3321d6 100644 --- a/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init1.py +++ b/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # multimodal_init1.py import pyomo.environ as pyo from math import pi diff --git a/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init2.py b/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init2.py index aa0dbae1e66..6a209334521 100644 --- a/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init2.py +++ b/examples/pyomobook/nonlinear-ch/multimodal/multimodal_init2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo from math import pi diff --git a/examples/pyomobook/nonlinear-ch/react_design/ReactorDesign.py b/examples/pyomobook/nonlinear-ch/react_design/ReactorDesign.py index 90822c153a5..1cfe3b7193f 100644 --- a/examples/pyomobook/nonlinear-ch/react_design/ReactorDesign.py +++ b/examples/pyomobook/nonlinear-ch/react_design/ReactorDesign.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ import pyomo.environ as pyo diff --git a/examples/pyomobook/nonlinear-ch/react_design/ReactorDesignTable.py b/examples/pyomobook/nonlinear-ch/react_design/ReactorDesignTable.py index a242c85fbc2..2bd9574b427 100644 --- a/examples/pyomobook/nonlinear-ch/react_design/ReactorDesignTable.py +++ b/examples/pyomobook/nonlinear-ch/react_design/ReactorDesignTable.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo from ReactorDesign import create_model diff --git a/examples/pyomobook/nonlinear-ch/rosen/rosenbrock.py b/examples/pyomobook/nonlinear-ch/rosen/rosenbrock.py index e1633e2df69..bec1d04c12c 100644 --- a/examples/pyomobook/nonlinear-ch/rosen/rosenbrock.py +++ b/examples/pyomobook/nonlinear-ch/rosen/rosenbrock.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # rosenbrock.py # A Pyomo model for the Rosenbrock problem import pyomo.environ as pyo diff --git a/examples/pyomobook/optimization-ch/ConcHLinScript.py b/examples/pyomobook/optimization-ch/ConcHLinScript.py index 8481a83afbf..b94903585dc 100644 --- a/examples/pyomobook/optimization-ch/ConcHLinScript.py +++ b/examples/pyomobook/optimization-ch/ConcHLinScript.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ConcHLinScript.py - Linear (H) as a script import pyomo.environ as pyo diff --git a/examples/pyomobook/optimization-ch/ConcHLinScript.txt b/examples/pyomobook/optimization-ch/ConcHLinScript.txt index c04591c94dc..0d34868ed99 100644 --- a/examples/pyomobook/optimization-ch/ConcHLinScript.txt +++ b/examples/pyomobook/optimization-ch/ConcHLinScript.txt @@ -1,7 +1,7 @@ Model 'Linear (H)' Variables: - x : Size=2, Index=x_index + x : Size=2, Index={I_C_Scoops, Peanuts} Key : Lower : Value : Upper : Fixed : Stale : Domain I_C_Scoops : 0 : 0.0 : 100 : False : False : Reals Peanuts : 0 : 40.6 : 40.6 : False : False : Reals @@ -9,7 +9,7 @@ Model 'Linear (H)' Objectives: z : Size=1, Index=None, Active=True Key : Active : Value - None : True : 3.83388751715 + None : True : 3.8338875171467763 Constraints: budgetconstr : Size=1 diff --git a/examples/pyomobook/optimization-ch/ConcreteH.py b/examples/pyomobook/optimization-ch/ConcreteH.py index 1bf2a9446c1..d7474291d0d 100644 --- a/examples/pyomobook/optimization-ch/ConcreteH.py +++ b/examples/pyomobook/optimization-ch/ConcreteH.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ConcreteH.py - Implement a particular instance of (H) # @fct: diff --git a/examples/pyomobook/optimization-ch/ConcreteH.txt b/examples/pyomobook/optimization-ch/ConcreteH.txt index 5e669ff71e0..04bbbdab857 100644 --- a/examples/pyomobook/optimization-ch/ConcreteH.txt +++ b/examples/pyomobook/optimization-ch/ConcreteH.txt @@ -1,10 +1,5 @@ -1 Set Declarations - x_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {'I_C_Scoops', 'Peanuts'} - 1 Var Declarations - x : Size=2, Index=x_index + x : Size=2, Index={I_C_Scoops, Peanuts} Key : Lower : Value : Upper : Fixed : Stale : Domain I_C_Scoops : 0 : None : 100 : False : True : Reals Peanuts : 0 : None : 40.6 : False : True : Reals @@ -19,4 +14,4 @@ Key : Lower : Body : Upper : Active None : -Inf : 3.14*x[I_C_Scoops] + 0.2718*x[Peanuts] : 12.0 : True -4 Declarations: x_index x z budgetconstr +3 Declarations: x z budgetconstr diff --git a/examples/pyomobook/optimization-ch/ConcreteHLinear.py b/examples/pyomobook/optimization-ch/ConcreteHLinear.py index 0b42d5e2187..772c18cb6d5 100644 --- a/examples/pyomobook/optimization-ch/ConcreteHLinear.py +++ b/examples/pyomobook/optimization-ch/ConcreteHLinear.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ConcreteHLinear.py - Linear (H) import pyomo.environ as pyo diff --git a/examples/pyomobook/optimization-ch/ConcreteHLinear.txt b/examples/pyomobook/optimization-ch/ConcreteHLinear.txt index 2e778c2bd1b..7f19aca87ec 100644 --- a/examples/pyomobook/optimization-ch/ConcreteHLinear.txt +++ b/examples/pyomobook/optimization-ch/ConcreteHLinear.txt @@ -1,10 +1,5 @@ -1 Set Declarations - x_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {'I_C_Scoops', 'Peanuts'} - 1 Var Declarations - x : Size=2, Index=x_index + x : Size=2, Index={I_C_Scoops, Peanuts} Key : Lower : Value : Upper : Fixed : Stale : Domain I_C_Scoops : 0 : None : 100 : False : True : Reals Peanuts : 0 : None : 40.6 : False : True : Reals @@ -19,4 +14,4 @@ Key : Lower : Body : Upper : Active None : -Inf : 3.14*x[I_C_Scoops] + 0.2718*x[Peanuts] : 12.0 : True -4 Declarations: x_index x z budgetconstr +3 Declarations: x z budgetconstr diff --git a/examples/pyomobook/optimization-ch/IC_model_dict.py b/examples/pyomobook/optimization-ch/IC_model_dict.py index 4c54ef83701..b7e359777c7 100644 --- a/examples/pyomobook/optimization-ch/IC_model_dict.py +++ b/examples/pyomobook/optimization-ch/IC_model_dict.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # IC_model_dict.py - Implement a particular instance of (H) # @fct: diff --git a/examples/pyomobook/overview-ch/var_obj_con_snippet.py b/examples/pyomobook/overview-ch/var_obj_con_snippet.py index 49bb7c1276b..22524b5815a 100644 --- a/examples/pyomobook/overview-ch/var_obj_con_snippet.py +++ b/examples/pyomobook/overview-ch/var_obj_con_snippet.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/overview-ch/wl_abstract.py b/examples/pyomobook/overview-ch/wl_abstract.py index f35a5327bfb..61eeed6b506 100644 --- a/examples/pyomobook/overview-ch/wl_abstract.py +++ b/examples/pyomobook/overview-ch/wl_abstract.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_abstract.py: AbstractModel version of warehouse location determination problem import pyomo.environ as pyo diff --git a/examples/pyomobook/overview-ch/wl_abstract_script.py b/examples/pyomobook/overview-ch/wl_abstract_script.py index 0b042405714..7f0871350fc 100644 --- a/examples/pyomobook/overview-ch/wl_abstract_script.py +++ b/examples/pyomobook/overview-ch/wl_abstract_script.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_abstract_script.py: Scripting using an AbstractModel import pyomo.environ as pyo diff --git a/examples/pyomobook/overview-ch/wl_concrete.py b/examples/pyomobook/overview-ch/wl_concrete.py index 29316304f0a..c1bf70b07f1 100644 --- a/examples/pyomobook/overview-ch/wl_concrete.py +++ b/examples/pyomobook/overview-ch/wl_concrete.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_concrete.py # ConcreteModel version of warehouse location problem import pyomo.environ as pyo diff --git a/examples/pyomobook/overview-ch/wl_concrete_script.py b/examples/pyomobook/overview-ch/wl_concrete_script.py index 278937f5aed..b369521994c 100644 --- a/examples/pyomobook/overview-ch/wl_concrete_script.py +++ b/examples/pyomobook/overview-ch/wl_concrete_script.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_concrete_script.py # Solve an instance of the warehouse location problem diff --git a/examples/pyomobook/overview-ch/wl_concrete_script.txt b/examples/pyomobook/overview-ch/wl_concrete_script.txt index dae31e1a035..165289552d3 100644 --- a/examples/pyomobook/overview-ch/wl_concrete_script.txt +++ b/examples/pyomobook/overview-ch/wl_concrete_script.txt @@ -1,4 +1,4 @@ -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 1.0 : 1 : False : False : Binary Harlingen : 0 : 1.0 : 1 : False : False : Binary diff --git a/examples/pyomobook/overview-ch/wl_excel.py b/examples/pyomobook/overview-ch/wl_excel.py index 1c4ad997225..180e36422fe 100644 --- a/examples/pyomobook/overview-ch/wl_excel.py +++ b/examples/pyomobook/overview-ch/wl_excel.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_excel.py: Loading Excel data using Pandas import pandas import pyomo.environ as pyo diff --git a/examples/pyomobook/overview-ch/wl_excel.txt b/examples/pyomobook/overview-ch/wl_excel.txt index dae31e1a035..165289552d3 100644 --- a/examples/pyomobook/overview-ch/wl_excel.txt +++ b/examples/pyomobook/overview-ch/wl_excel.txt @@ -1,4 +1,4 @@ -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 1.0 : 1 : False : False : Binary Harlingen : 0 : 1.0 : 1 : False : False : Binary diff --git a/examples/pyomobook/overview-ch/wl_list.py b/examples/pyomobook/overview-ch/wl_list.py index 64db76be548..37cba5a9595 100644 --- a/examples/pyomobook/overview-ch/wl_list.py +++ b/examples/pyomobook/overview-ch/wl_list.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_list.py: Warehouse location problem using constraint lists import pyomo.environ as pyo diff --git a/examples/pyomobook/overview-ch/wl_list.txt b/examples/pyomobook/overview-ch/wl_list.txt index 2054efe153d..c0d44f1a0c9 100644 --- a/examples/pyomobook/overview-ch/wl_list.txt +++ b/examples/pyomobook/overview-ch/wl_list.txt @@ -1,25 +1,5 @@ -6 Set Declarations - demand_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 4 : {1, 2, 3, 4} - warehouse_active_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 12 : {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} - x_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : x_index_0*x_index_1 : 12 : {('Harlingen', 'NYC'), ('Harlingen', 'LA'), ('Harlingen', 'Chicago'), ('Harlingen', 'Houston'), ('Memphis', 'NYC'), ('Memphis', 'LA'), ('Memphis', 'Chicago'), ('Memphis', 'Houston'), ('Ashland', 'NYC'), ('Ashland', 'LA'), ('Ashland', 'Chicago'), ('Ashland', 'Houston')} - x_index_0 : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {'Harlingen', 'Memphis', 'Ashland'} - x_index_1 : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 4 : {'NYC', 'LA', 'Chicago', 'Houston'} - y_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {'Harlingen', 'Memphis', 'Ashland'} - 2 Var Declarations - x : Size=12, Index=x_index + x : Size=12, Index={Harlingen, Memphis, Ashland}*{NYC, LA, Chicago, Houston} Key : Lower : Value : Upper : Fixed : Stale : Domain ('Ashland', 'Chicago') : 0 : None : 1 : False : True : Reals ('Ashland', 'Houston') : 0 : None : 1 : False : True : Reals @@ -33,7 +13,7 @@ ('Memphis', 'Houston') : 0 : None : 1 : False : True : Reals ('Memphis', 'LA') : 0 : None : 1 : False : True : Reals ('Memphis', 'NYC') : 0 : None : 1 : False : True : Reals - y : Size=3, Index=y_index + y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : None : 1 : False : True : Binary Harlingen : 0 : None : 1 : False : True : Binary @@ -45,7 +25,7 @@ None : True : minimize : 1956*x[Harlingen,NYC] + 1606*x[Harlingen,LA] + 1410*x[Harlingen,Chicago] + 330*x[Harlingen,Houston] + 1096*x[Memphis,NYC] + 1792*x[Memphis,LA] + 531*x[Memphis,Chicago] + 567*x[Memphis,Houston] + 485*x[Ashland,NYC] + 2322*x[Ashland,LA] + 324*x[Ashland,Chicago] + 1236*x[Ashland,Houston] 3 Constraint Declarations - demand : Size=4, Index=demand_index, Active=True + demand : Size=4, Index={1, 2, 3, 4}, Active=True Key : Lower : Body : Upper : Active 1 : 1.0 : x[Harlingen,NYC] + x[Memphis,NYC] + x[Ashland,NYC] : 1.0 : True 2 : 1.0 : x[Harlingen,LA] + x[Memphis,LA] + x[Ashland,LA] : 1.0 : True @@ -54,7 +34,7 @@ num_warehouses : Size=1, Index=None, Active=True Key : Lower : Body : Upper : Active None : -Inf : y[Harlingen] + y[Memphis] + y[Ashland] : 2.0 : True - warehouse_active : Size=12, Index=warehouse_active_index, Active=True + warehouse_active : Size=12, Index={1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, Active=True Key : Lower : Body : Upper : Active 1 : -Inf : x[Harlingen,NYC] - y[Harlingen] : 0.0 : True 2 : -Inf : x[Harlingen,LA] - y[Harlingen] : 0.0 : True @@ -69,4 +49,4 @@ 11 : -Inf : x[Ashland,Chicago] - y[Ashland] : 0.0 : True 12 : -Inf : x[Ashland,Houston] - y[Ashland] : 0.0 : True -12 Declarations: x_index_0 x_index_1 x_index x y_index y obj demand_index demand warehouse_active_index warehouse_active num_warehouses +6 Declarations: x y obj demand warehouse_active num_warehouses diff --git a/examples/pyomobook/overview-ch/wl_mutable.py b/examples/pyomobook/overview-ch/wl_mutable.py index e5c4f5e9dbb..8e129dd3c49 100644 --- a/examples/pyomobook/overview-ch/wl_mutable.py +++ b/examples/pyomobook/overview-ch/wl_mutable.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_mutable.py: warehouse location problem with mutable param import pyomo.environ as pyo diff --git a/examples/pyomobook/overview-ch/wl_mutable_excel.py b/examples/pyomobook/overview-ch/wl_mutable_excel.py index 0906fbb25b3..935fa4963e5 100644 --- a/examples/pyomobook/overview-ch/wl_mutable_excel.py +++ b/examples/pyomobook/overview-ch/wl_mutable_excel.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_mutable_excel.py: solve problem with different values for P import pandas import pyomo.environ as pyo diff --git a/examples/pyomobook/overview-ch/wl_scalar.py b/examples/pyomobook/overview-ch/wl_scalar.py index ac10fbe8265..6f538baedb8 100644 --- a/examples/pyomobook/overview-ch/wl_scalar.py +++ b/examples/pyomobook/overview-ch/wl_scalar.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl_scalar.py: snippets that show the warehouse location problem implemented as scalar quantities import pyomo.environ as pyo diff --git a/examples/pyomobook/performance-ch/SparseSets.py b/examples/pyomobook/performance-ch/SparseSets.py index 90d097b53aa..519808306de 100644 --- a/examples/pyomobook/performance-ch/SparseSets.py +++ b/examples/pyomobook/performance-ch/SparseSets.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/performance-ch/lin_expr.py b/examples/pyomobook/performance-ch/lin_expr.py index 75f4e70ec2a..af50ddd6228 100644 --- a/examples/pyomobook/performance-ch/lin_expr.py +++ b/examples/pyomobook/performance-ch/lin_expr.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo from pyomo.common.timing import TicTocTimer from pyomo.core.expr.numeric_expr import LinearExpression diff --git a/examples/pyomobook/performance-ch/persistent.py b/examples/pyomobook/performance-ch/persistent.py index 98207909cb6..67f8c656cfe 100644 --- a/examples/pyomobook/performance-ch/persistent.py +++ b/examples/pyomobook/performance-ch/persistent.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # @model: import pyomo.environ as pyo diff --git a/examples/pyomobook/performance-ch/wl.py b/examples/pyomobook/performance-ch/wl.py index 34c8a73f36e..000f81272a1 100644 --- a/examples/pyomobook/performance-ch/wl.py +++ b/examples/pyomobook/performance-ch/wl.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # wl.py # define a script to demonstrate performance profiling and improvements # @imports: import pyomo.environ as pyo # import pyomo environment diff --git a/examples/pyomobook/performance-ch/wl.txt b/examples/pyomobook/performance-ch/wl.txt index f7d2e0ada19..b762acf55cf 100644 --- a/examples/pyomobook/performance-ch/wl.txt +++ b/examples/pyomobook/performance-ch/wl.txt @@ -3,94 +3,102 @@ Building model 0 seconds to construct Block ConcreteModel; 1 index total 0 seconds to construct Set Any; 1 index total 0 seconds to construct Param P; 1 index total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total 0 seconds to construct Set SetProduct_OrderedSet; 1 index total - 0 seconds to construct Set SetProduct_OrderedSet; 1 index total 0.02 seconds to construct Var x; 40000 indices total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total 0 seconds to construct Var y; 200 indices total - 0.13 seconds to construct Objective obj; 1 index total + 0.14 seconds to construct Objective obj; 1 index total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total 0.13 seconds to construct Constraint demand; 200 indices total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total 0 seconds to construct Set SetProduct_OrderedSet; 1 index total - 0 seconds to construct Set SetProduct_OrderedSet; 1 index total - 0.48 seconds to construct Constraint warehouse_active; 40000 indices total + 0.50 seconds to construct Constraint warehouse_active; 40000 indices total 0 seconds to construct Constraint num_warehouses; 1 index total Building model with LinearExpression ------------------------------------ 0 seconds to construct Block ConcreteModel; 1 index total 0 seconds to construct Set Any; 1 index total 0 seconds to construct Param P; 1 index total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total 0 seconds to construct Set SetProduct_OrderedSet; 1 index total - 0 seconds to construct Set SetProduct_OrderedSet; 1 index total 0.02 seconds to construct Var x; 40000 indices total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total 0 seconds to construct Var y; 200 indices total - 0.06 seconds to construct Objective obj; 1 index total + 0.20 seconds to construct Objective obj; 1 index total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total - 0.18 seconds to construct Constraint demand; 200 indices total + 0.05 seconds to construct Constraint demand; 200 indices total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total + 0 seconds to construct SetOf OrderedSetOf 0 seconds to construct Set OrderedScalarSet; 1 index total 0 seconds to construct Set SetProduct_OrderedSet; 1 index total - 0 seconds to construct Set SetProduct_OrderedSet; 1 index total - 0.33 seconds to construct Constraint warehouse_active; 40000 indices total + 0.34 seconds to construct Constraint warehouse_active; 40000 indices total 0 seconds to construct Constraint num_warehouses; 1 index total [ 0.00] start -[+ 0.79] Built model -[+ 2.56] Wrote LP file and solved -[+ 10.96] Finished parameter sweep - 7372057 function calls (7368345 primitive calls) in 13.627 seconds +[+ 1.00] Built model +[+ 2.28] Wrote LP file and solved +[+ 9.06] Finished parameter sweep + 7294708 function calls (7291012 primitive calls) in 10.989 seconds Ordered by: cumulative time List reduced from 673 to 15 due to restriction <15> ncalls tottime percall cumtime percall filename:lineno(function) - 1 0.001 0.001 13.627 13.627 /home/jdsiiro/Research/pyomo/examples/pyomobook/performance-ch/wl.py:132(solve_parametric) - 30 0.002 0.000 13.551 0.452 /home/jdsiiro/Research/pyomo/pyomo/opt/base/solvers.py:530(solve) - 30 0.001 0.000 10.383 0.346 /home/jdsiiro/Research/pyomo/pyomo/opt/solver/shellcmd.py:247(_apply_solver) - 30 0.002 0.000 10.381 0.346 /home/jdsiiro/Research/pyomo/pyomo/opt/solver/shellcmd.py:310(_execute_command) - 30 0.001 0.000 10.360 0.345 /projects/sems/install/rhel7-x86_64/pyomo/compiler/python/3.11.6/lib/python3.11/subprocess.py:506(run) - 30 0.000 0.000 10.288 0.343 /projects/sems/install/rhel7-x86_64/pyomo/compiler/python/3.11.6/lib/python3.11/subprocess.py:1165(communicate) - 60 0.000 0.000 10.287 0.171 /projects/sems/install/rhel7-x86_64/pyomo/compiler/python/3.11.6/lib/python3.11/subprocess.py:1259(wait) - 60 0.001 0.000 10.287 0.171 /projects/sems/install/rhel7-x86_64/pyomo/compiler/python/3.11.6/lib/python3.11/subprocess.py:2014(_wait) - 30 0.000 0.000 10.286 0.343 /projects/sems/install/rhel7-x86_64/pyomo/compiler/python/3.11.6/lib/python3.11/subprocess.py:2001(_try_wait) - 30 10.286 0.343 10.286 0.343 {built-in method posix.waitpid} - 30 0.000 0.000 2.123 0.071 /home/jdsiiro/Research/pyomo/pyomo/solvers/plugins/solvers/GUROBI.py:214(_presolve) - 30 0.000 0.000 2.122 0.071 /home/jdsiiro/Research/pyomo/pyomo/opt/solver/shellcmd.py:215(_presolve) - 30 0.000 0.000 2.114 0.070 /home/jdsiiro/Research/pyomo/pyomo/opt/base/solvers.py:687(_presolve) - 30 0.000 0.000 2.114 0.070 /home/jdsiiro/Research/pyomo/pyomo/opt/base/solvers.py:756(_convert_problem) - 30 0.001 0.000 2.114 0.070 /home/jdsiiro/Research/pyomo/pyomo/opt/base/convert.py:27(convert_problem) + 1 0.001 0.001 10.989 10.989 pyomo/examples/pyomobook/performance-ch/wl.py:132(solve_parametric) + 30 0.002 0.000 10.913 0.364 pyomo/pyomo/opt/base/solvers.py:530(solve) + 30 0.001 0.000 7.816 0.261 pyomo/pyomo/opt/solver/shellcmd.py:247(_apply_solver) + 30 0.002 0.000 7.814 0.260 pyomo/pyomo/opt/solver/shellcmd.py:310(_execute_command) + 30 0.001 0.000 7.793 0.260 /lib/python3.11/subprocess.py:506(run) + 30 0.000 0.000 7.609 0.254 /lib/python3.11/subprocess.py:1165(communicate) + 60 0.000 0.000 7.608 0.127 /lib/python3.11/subprocess.py:1259(wait) + 60 0.000 0.000 7.608 0.127 /lib/python3.11/subprocess.py:2014(_wait) + 30 0.000 0.000 7.608 0.254 /lib/python3.11/subprocess.py:2001(_try_wait) + 30 7.607 0.254 7.607 0.254 {built-in method posix.waitpid} + 30 0.000 0.000 2.166 0.072 pyomo/pyomo/solvers/plugins/solvers/GUROBI.py:214(_presolve) + 30 0.000 0.000 2.166 0.072 pyomo/pyomo/opt/solver/shellcmd.py:215(_presolve) + 30 0.000 0.000 2.156 0.072 pyomo/pyomo/opt/base/solvers.py:687(_presolve) + 30 0.000 0.000 2.156 0.072 pyomo/pyomo/opt/base/solvers.py:754(_convert_problem) + 30 0.001 0.000 2.156 0.072 pyomo/pyomo/opt/base/convert.py:27(convert_problem) - 7372057 function calls (7368345 primitive calls) in 13.627 seconds + 7294708 function calls (7291012 primitive calls) in 10.989 seconds Ordered by: internal time List reduced from 673 to 15 due to restriction <15> ncalls tottime percall cumtime percall filename:lineno(function) - 30 10.286 0.343 10.286 0.343 {built-in method posix.waitpid} - 30 0.325 0.011 2.078 0.069 /home/jdsiiro/Research/pyomo/pyomo/repn/plugins/lp_writer.py:250(write) - 76560 0.278 0.000 0.668 0.000 /home/jdsiiro/Research/pyomo/pyomo/repn/plugins/lp_writer.py:576(write_expression) - 30 0.248 0.008 0.508 0.017 /home/jdsiiro/Research/pyomo/pyomo/solvers/plugins/solvers/GUROBI.py:394(process_soln_file) - 76560 0.221 0.000 0.395 0.000 /home/jdsiiro/Research/pyomo/pyomo/repn/linear.py:664(_before_linear) - 301530 0.131 0.000 0.178 0.000 /home/jdsiiro/Research/pyomo/pyomo/core/expr/symbol_map.py:133(getSymbol) - 30 0.119 0.004 0.192 0.006 /home/jdsiiro/Research/pyomo/pyomo/core/base/PyomoModel.py:461(select) - 77190 0.117 0.000 0.161 0.000 /home/jdsiiro/Research/pyomo/pyomo/solvers/plugins/solvers/GUROBI.py:451() - 30 0.116 0.004 0.285 0.010 /home/jdsiiro/Research/pyomo/pyomo/core/base/PyomoModel.py:337(add_solution) - 76530 0.080 0.000 0.106 0.000 /home/jdsiiro/Research/pyomo/pyomo/core/expr/symbol_map.py:63(addSymbol) - 239550 0.079 0.000 0.079 0.000 /home/jdsiiro/Research/pyomo/pyomo/core/base/indexed_component.py:611(__getitem__) - 1062450 0.078 0.000 0.078 0.000 {built-in method builtins.id} - 163050 0.074 0.000 0.128 0.000 /home/jdsiiro/Research/pyomo/pyomo/core/base/var.py:1045(__getitem__) - 76560 0.074 0.000 0.080 0.000 /home/jdsiiro/Research/pyomo/pyomo/repn/linear.py:834(finalizeResult) - 153150 0.073 0.000 0.191 0.000 /home/jdsiiro/Research/pyomo/pyomo/core/base/block.py:1505(_component_data_itervalues) + 30 7.607 0.254 7.607 0.254 {built-in method posix.waitpid} + 30 0.328 0.011 2.101 0.070 pyomo/pyomo/repn/plugins/lp_writer.py:250(write) + 76560 0.284 0.000 0.680 0.000 pyomo/pyomo/repn/plugins/lp_writer.py:576(write_expression) + 76560 0.220 0.000 0.388 0.000 pyomo/pyomo/repn/linear.py:664(_before_linear) + 30 0.209 0.007 0.438 0.015 pyomo/pyomo/solvers/plugins/solvers/GUROBI.py:394(process_soln_file) + 30 0.175 0.006 0.175 0.006 {built-in method _posixsubprocess.fork_exec} + 301530 0.134 0.000 0.181 0.000 pyomo/pyomo/core/expr/symbol_map.py:133(getSymbol) + 30 0.109 0.004 0.178 0.006 pyomo/pyomo/core/base/PyomoModel.py:461(select) + 77190 0.105 0.000 0.145 0.000 pyomo/pyomo/solvers/plugins/solvers/GUROBI.py:451() + 30 0.104 0.003 0.257 0.009 pyomo/pyomo/core/base/PyomoModel.py:337(add_solution) + 76530 0.081 0.000 0.109 0.000 pyomo/pyomo/core/expr/symbol_map.py:63(addSymbol) + 1062470 0.079 0.000 0.079 0.000 {built-in method builtins.id} + 76560 0.073 0.000 0.079 0.000 pyomo/pyomo/repn/linear.py:834(finalizeResult) + 239550 0.073 0.000 0.073 0.000 pyomo/pyomo/core/base/indexed_component.py:612(__getitem__) + 153150 0.070 0.000 0.179 0.000 pyomo/pyomo/core/base/block.py:1463(_component_data_itervalues) [ 0.00] Resetting the tic/toc delta timer -[+ 0.66] Finished parameter sweep with persistent interface +[+ 0.49] Finished parameter sweep with persistent interface diff --git a/examples/pyomobook/pyomo-components-ch/con_declaration.py b/examples/pyomobook/pyomo-components-ch/con_declaration.py index 7775c1b26a0..0890ba4771b 100644 --- a/examples/pyomobook/pyomo-components-ch/con_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/con_declaration.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/pyomo-components-ch/con_declaration.txt b/examples/pyomobook/pyomo-components-ch/con_declaration.txt index 019cd448eb0..b4709bd5490 100644 --- a/examples/pyomobook/pyomo-components-ch/con_declaration.txt +++ b/examples/pyomobook/pyomo-components-ch/con_declaration.txt @@ -1,10 +1,5 @@ -1 Set Declarations - x_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {1, 2} - 1 Var Declarations - x : Size=2, Index=x_index + x : Size=2, Index={1, 2} Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : None : 1.0 : None : False : False : Reals 2 : None : 1.0 : None : False : False : Reals @@ -14,14 +9,9 @@ Key : Lower : Body : Upper : Active None : -Inf : x[2] - x[1] : 7.5 : True -3 Declarations: x_index x diff -1 Set Declarations - x_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {1, 2} - +2 Declarations: x diff 1 Var Declarations - x : Size=2, Index=x_index + x : Size=2, Index={1, 2} Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : None : 1.0 : None : False : False : Reals 2 : None : 1.0 : None : False : False : Reals @@ -31,40 +21,24 @@ Key : Lower : Body : Upper : Active None : -Inf : x[2] - x[1] : 7.5 : True -3 Declarations: x_index x diff -2 Set Declarations - CoverConstr_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - y_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - +2 Declarations: x diff 1 Var Declarations - y : Size=3, Index=y_index + y : Size=3, Index={1, 2, 3} Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : 0 : 0.0 : None : False : False : NonNegativeReals 2 : 0 : 0.0 : None : False : False : NonNegativeReals 3 : 0 : 0.0 : None : False : False : NonNegativeReals 1 Constraint Declarations - CoverConstr : Size=3, Index=CoverConstr_index, Active=True + CoverConstr : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 1.0 : y[1] : +Inf : True 2 : 2.9 : 3.1*y[2] : +Inf : True 3 : 3.1 : 4.5*y[3] : +Inf : True -4 Declarations: y_index y CoverConstr_index CoverConstr -2 Set Declarations - Pred_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 5 : {1, 2, 3, 4, 5} - StartTime_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 5 : {1, 2, 3, 4, 5} - +2 Declarations: y CoverConstr 1 Var Declarations - StartTime : Size=5, Index=StartTime_index + StartTime : Size=5, Index={1, 2, 3, 4, 5} Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : None : 1.0 : None : False : False : Reals 2 : None : 1.0 : None : False : False : Reals @@ -73,14 +47,14 @@ 5 : None : 1.0 : None : False : False : Reals 1 Constraint Declarations - Pred : Size=4, Index=Pred_index, Active=True + Pred : Size=4, Index={1, 2, 3, 4, 5}, Active=True Key : Lower : Body : Upper : Active 1 : -Inf : StartTime[1] - StartTime[2] : 0.0 : True 2 : -Inf : StartTime[2] - StartTime[3] : 0.0 : True 3 : -Inf : StartTime[3] - StartTime[4] : 0.0 : True 4 : -Inf : StartTime[4] - StartTime[5] : 0.0 : True -4 Declarations: StartTime_index StartTime Pred_index Pred +2 Declarations: StartTime Pred 0.0 inf 7.5 diff --git a/examples/pyomobook/pyomo-components-ch/examples.py b/examples/pyomobook/pyomo-components-ch/examples.py index 6ba96792e28..1a59e9e308e 100644 --- a/examples/pyomobook/pyomo-components-ch/examples.py +++ b/examples/pyomobook/pyomo-components-ch/examples.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo print("indexed1") diff --git a/examples/pyomobook/pyomo-components-ch/examples.txt b/examples/pyomobook/pyomo-components-ch/examples.txt index 635b988cbcd..27ea1ba130b 100644 --- a/examples/pyomobook/pyomo-components-ch/examples.txt +++ b/examples/pyomobook/pyomo-components-ch/examples.txt @@ -1,20 +1,17 @@ indexed1 -3 Set Declarations +2 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 2 : {'Q', 'R'} - y_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 6 : {(1, 'Q'), (1, 'R'), (2, 'Q'), (2, 'R'), (3, 'Q'), (3, 'R')} 2 Var Declarations x : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain None : None : None : None : False : True : Reals - y : Size=6, Index=y_index + y : Size=6, Index=A*B Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 'Q') : None : None : None : False : True : Reals (1, 'R') : None : None : None : False : True : Reals @@ -38,4 +35,4 @@ indexed1 2 : -Inf : 2*x : 0.0 : True 3 : -Inf : 3*x : 0.0 : True -8 Declarations: A B x y_index y o c d +7 Declarations: A B x y o c d diff --git a/examples/pyomobook/pyomo-components-ch/expr_declaration.py b/examples/pyomobook/pyomo-components-ch/expr_declaration.py index 8974a4d406a..da0d854e513 100644 --- a/examples/pyomobook/pyomo-components-ch/expr_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/expr_declaration.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/pyomo-components-ch/expr_declaration.txt b/examples/pyomobook/pyomo-components-ch/expr_declaration.txt index 66c99f6502a..86e0feac27f 100644 --- a/examples/pyomobook/pyomo-components-ch/expr_declaration.txt +++ b/examples/pyomobook/pyomo-components-ch/expr_declaration.txt @@ -18,28 +18,20 @@ None : x + 2 3 Declarations: x e1 e2 -2 Set Declarations - e_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - x_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - 1 Var Declarations - x : Size=3, Index=x_index + x : Size=3, Index={1, 2, 3} Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : None : None : None : False : True : Reals 2 : None : None : None : False : True : Reals 3 : None : None : None : False : True : Reals 1 Expression Declarations - e : Size=2, Index=e_index + e : Size=2, Index={1, 2, 3} Key : Expression 2 : x[2]**2 3 : x[3]**2 -4 Declarations: x_index x e_index e +2 Declarations: x e 1 Var Declarations x : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain diff --git a/examples/pyomobook/pyomo-components-ch/obj_declaration.py b/examples/pyomobook/pyomo-components-ch/obj_declaration.py index 2c26c2b3363..a63fc441206 100644 --- a/examples/pyomobook/pyomo-components-ch/obj_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/obj_declaration.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/pyomo-components-ch/obj_declaration.txt b/examples/pyomobook/pyomo-components-ch/obj_declaration.txt index e43134b8d92..e4d4b02a252 100644 --- a/examples/pyomobook/pyomo-components-ch/obj_declaration.txt +++ b/examples/pyomobook/pyomo-components-ch/obj_declaration.txt @@ -14,7 +14,7 @@ declexprrule Model unknown Variables: - x : Size=2, Index=x_index + x : Size=2, Index={1, 2} Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : None : 1.0 : None : False : False : Reals 2 : None : 1.0 : None : False : False : Reals @@ -34,19 +34,19 @@ declskip Model unknown Variables: - x : Size=3, Index=x_index + x : Size=3, Index={Q, R, S} Key : Lower : Value : Upper : Fixed : Stale : Domain Q : None : 1.0 : None : False : False : Reals R : None : 1.0 : None : False : False : Reals S : None : 1.0 : None : False : False : Reals Objectives: - d : Size=3, Index=d_index, Active=True + d : Size=3, Index={Q, R, S}, Active=True Key : Active : Value Q : True : 1.0 R : True : 1.0 S : True : 1.0 - e : Size=2, Index=e_index, Active=True + e : Size=2, Index={Q, R, S}, Active=True Key : Active : Value Q : True : 1.0 S : True : 1.0 @@ -55,12 +55,12 @@ Model unknown None value x[Q] + 2*x[R] -1 +minimize 6.5 Model unknown Variables: - x : Size=2, Index=x_index + x : Size=2, Index={Q, R} Key : Lower : Value : Upper : Fixed : Stale : Domain Q : None : 1.5 : None : False : False : Reals R : None : 2.5 : None : False : False : Reals diff --git a/examples/pyomobook/pyomo-components-ch/param_declaration.py b/examples/pyomobook/pyomo-components-ch/param_declaration.py index a9d3256abfe..98b16548c28 100644 --- a/examples/pyomobook/pyomo-components-ch/param_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/param_declaration.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/pyomo-components-ch/param_declaration.txt b/examples/pyomobook/pyomo-components-ch/param_declaration.txt index 9b8ce9cacdb..8c8a49eedc6 100644 --- a/examples/pyomobook/pyomo-components-ch/param_declaration.txt +++ b/examples/pyomobook/pyomo-components-ch/param_declaration.txt @@ -1,16 +1,13 @@ -3 Set Declarations +2 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 2 : {'A', 'B'} - T_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 6 : {(1, 'A'), (1, 'B'), (2, 'A'), (2, 'B'), (3, 'A'), (3, 'B')} 3 Param Declarations - T : Size=3, Index=T_index, Domain=Any, Default=None, Mutable=False + T : Size=3, Index=A*B, Domain=Any, Default=None, Mutable=False Key : Value (1, 'A') : 10 (2, 'B') : 20 @@ -24,4 +21,4 @@ Key : Value None : 32 -6 Declarations: Z A B U T_index T +5 Declarations: Z A B U T diff --git a/examples/pyomobook/pyomo-components-ch/param_initialization.py b/examples/pyomobook/pyomo-components-ch/param_initialization.py index 11c257d2c31..88da8a68354 100644 --- a/examples/pyomobook/pyomo-components-ch/param_initialization.py +++ b/examples/pyomobook/pyomo-components-ch/param_initialization.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/pyomo-components-ch/param_initialization.txt b/examples/pyomobook/pyomo-components-ch/param_initialization.txt index d1ac6aba989..e0bcdf11a71 100644 --- a/examples/pyomobook/pyomo-components-ch/param_initialization.txt +++ b/examples/pyomobook/pyomo-components-ch/param_initialization.txt @@ -1,27 +1,15 @@ -6 Set Declarations +2 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} - T_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*B : 9 : {(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)} - U_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*A : 9 : {(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)} - XX_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*A : 9 : {(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)} - X_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : A*A : 9 : {(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)} 5 Param Declarations - T : Size=0, Index=T_index, Domain=Any, Default=None, Mutable=False + T : Size=0, Index=A*B, Domain=Any, Default=None, Mutable=False Key : Value - U : Size=9, Index=U_index, Domain=Any, Default=0, Mutable=False + U : Size=9, Index=A*A, Domain=Any, Default=0, Mutable=False Key : Value (1, 1) : 10 (2, 2) : 20 @@ -30,7 +18,7 @@ Key : Value 1 : 10 3 : 30 - X : Size=9, Index=X_index, Domain=Any, Default=None, Mutable=False + X : Size=9, Index=A*A, Domain=Any, Default=None, Mutable=False Key : Value (1, 1) : 1 (1, 2) : 2 @@ -41,7 +29,7 @@ (3, 1) : 3 (3, 2) : 6 (3, 3) : 9 - XX : Size=9, Index=XX_index, Domain=Any, Default=None, Mutable=False + XX : Size=9, Index=A*A, Domain=Any, Default=None, Mutable=False Key : Value (1, 1) : 1 (1, 2) : 2 @@ -53,7 +41,7 @@ (3, 2) : 8 (3, 3) : 14 -11 Declarations: A X_index X XX_index XX B W U_index U T_index T +7 Declarations: A X XX B W U T 2 3 False diff --git a/examples/pyomobook/pyomo-components-ch/param_misc.py b/examples/pyomobook/pyomo-components-ch/param_misc.py index baf76cc7c03..72fca60f787 100644 --- a/examples/pyomobook/pyomo-components-ch/param_misc.py +++ b/examples/pyomobook/pyomo-components-ch/param_misc.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo # @mutable1: diff --git a/examples/pyomobook/pyomo-components-ch/param_validation.py b/examples/pyomobook/pyomo-components-ch/param_validation.py index c82657c8d0f..baf5f0ac1e2 100644 --- a/examples/pyomobook/pyomo-components-ch/param_validation.py +++ b/examples/pyomobook/pyomo-components-ch/param_validation.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/pyomo-components-ch/rangeset.py b/examples/pyomobook/pyomo-components-ch/rangeset.py index d5e1015064c..169060e9ab2 100644 --- a/examples/pyomobook/pyomo-components-ch/rangeset.py +++ b/examples/pyomobook/pyomo-components-ch/rangeset.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/pyomo-components-ch/set_declaration.py b/examples/pyomobook/pyomo-components-ch/set_declaration.py index 1a507d4f588..bf3cfa1be15 100644 --- a/examples/pyomobook/pyomo-components-ch/set_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/set_declaration.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/pyomo-components-ch/set_declaration.txt b/examples/pyomobook/pyomo-components-ch/set_declaration.txt index bdbb7376de4..a588e5601b6 100644 --- a/examples/pyomobook/pyomo-components-ch/set_declaration.txt +++ b/examples/pyomobook/pyomo-components-ch/set_declaration.txt @@ -5,22 +5,16 @@ 1 Declarations: A 0 Declarations: -4 Set Declarations - E : Size=1, Index=E_index, Ordered=Insertion +2 Set Declarations + E : Size=1, Index={1, 2, 3}, Ordered=Insertion Key : Dimen : Domain : Size : Members 2 : 1 : Any : 3 : {21, 22, 23} - E_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - F : Size=2, Index=F_index, Ordered=Insertion + F : Size=2, Index={1, 2, 3}, Ordered=Insertion Key : Dimen : Domain : Size : Members 1 : 1 : Any : 3 : {11, 12, 13} 3 : 1 : Any : 3 : {31, 32, 33} - F_index : Size=1, Index=None, Ordered=False - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} -4 Declarations: E_index E F_index F +2 Declarations: E F 6 Set Declarations A : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members diff --git a/examples/pyomobook/pyomo-components-ch/set_initialization.py b/examples/pyomobook/pyomo-components-ch/set_initialization.py index 89dbaa713db..bdfd662c985 100644 --- a/examples/pyomobook/pyomo-components-ch/set_initialization.py +++ b/examples/pyomobook/pyomo-components-ch/set_initialization.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/pyomo-components-ch/set_initialization.txt b/examples/pyomobook/pyomo-components-ch/set_initialization.txt index af2ba54a8d2..29900ccb7b2 100644 --- a/examples/pyomobook/pyomo-components-ch/set_initialization.txt +++ b/examples/pyomobook/pyomo-components-ch/set_initialization.txt @@ -1,19 +1,16 @@ -10 Set Declarations +7 Set Declarations B : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {2, 3, 4} C : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 2 : Any : 2 : {(1, 4), (9, 16)} - F : Size=3, Index=F_index, Ordered=Insertion + F : Size=3, Index={2, 3, 4}, Ordered=Insertion Key : Dimen : Domain : Size : Members 2 : 1 : Any : 3 : {1, 3, 5} 3 : 1 : Any : 3 : {2, 4, 6} 4 : 1 : Any : 3 : {3, 5, 7} - F_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {2, 3, 4} - J : Size=9, Index=J_index, Ordered=Insertion + J : Size=9, Index=B*B, Ordered=Insertion Key : Dimen : Domain : Size : Members (2, 2) : 1 : Any : 4 : {0, 1, 2, 3} (2, 3) : 1 : Any : 6 : {0, 1, 2, 3, 4, 5} @@ -24,21 +21,15 @@ (4, 2) : 1 : Any : 8 : {0, 1, 2, 3, 4, 5, 6, 7} (4, 3) : 1 : Any : 12 : {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} (4, 4) : 1 : Any : 16 : {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} - J_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : B*B : 9 : {(2, 2), (2, 3), (2, 4), (3, 2), (3, 3), (3, 4), (4, 2), (4, 3), (4, 4)} P : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 5 : {1, 2, 3, 5, 7} Q : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 4 : {4, 6, 8, 9} - R : Size=2, Index=R_index, Ordered=Insertion + R : Size=2, Index={1, 2, 3}, Ordered=Insertion Key : Dimen : Domain : Size : Members 1 : 1 : Any : 1 : {1,} 2 : 1 : Any : 2 : {1, 2} - R_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} -10 Declarations: B C F_index F J_index J P Q R_index R +7 Declarations: B C F J P Q R diff --git a/examples/pyomobook/pyomo-components-ch/set_misc.py b/examples/pyomobook/pyomo-components-ch/set_misc.py index 9a795b196b8..20ed9518f52 100644 --- a/examples/pyomobook/pyomo-components-ch/set_misc.py +++ b/examples/pyomobook/pyomo-components-ch/set_misc.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/pyomo-components-ch/set_options.py b/examples/pyomobook/pyomo-components-ch/set_options.py index 8d49882de2f..30c0b49706d 100644 --- a/examples/pyomobook/pyomo-components-ch/set_options.py +++ b/examples/pyomobook/pyomo-components-ch/set_options.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/pyomo-components-ch/set_validation.py b/examples/pyomobook/pyomo-components-ch/set_validation.py index a55dfc9ab7c..2300c0be693 100644 --- a/examples/pyomobook/pyomo-components-ch/set_validation.py +++ b/examples/pyomobook/pyomo-components-ch/set_validation.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.AbstractModel() diff --git a/examples/pyomobook/pyomo-components-ch/suffix_declaration.py b/examples/pyomobook/pyomo-components-ch/suffix_declaration.py index 650669ef5a6..619093712f1 100644 --- a/examples/pyomobook/pyomo-components-ch/suffix_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/suffix_declaration.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo print('') diff --git a/examples/pyomobook/pyomo-components-ch/var_declaration.py b/examples/pyomobook/pyomo-components-ch/var_declaration.py index 60d3b00756a..2ee5d7fb749 100644 --- a/examples/pyomobook/pyomo-components-ch/var_declaration.py +++ b/examples/pyomobook/pyomo-components-ch/var_declaration.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/python-ch/BadIndent.py b/examples/pyomobook/python-ch/BadIndent.py index 6ab545a6f46..4a00cae12ef 100644 --- a/examples/pyomobook/python-ch/BadIndent.py +++ b/examples/pyomobook/python-ch/BadIndent.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # This comment is the first line of BadIndent.py, # which will cause Python to give an error message # concerning indentation. diff --git a/examples/pyomobook/python-ch/LineExample.py b/examples/pyomobook/python-ch/LineExample.py index 0109a64167e..31cface5760 100644 --- a/examples/pyomobook/python-ch/LineExample.py +++ b/examples/pyomobook/python-ch/LineExample.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # This comment is the first line of LineExample.py # all characters on a line after the #-character are # ignored by Python diff --git a/examples/pyomobook/python-ch/class.py b/examples/pyomobook/python-ch/class.py index 562cef07ea7..a09f991d37b 100644 --- a/examples/pyomobook/python-ch/class.py +++ b/examples/pyomobook/python-ch/class.py @@ -1,25 +1,36 @@ -# class.py - - -# @all: -class IntLocker: - sint = None - - def __init__(self, i): - self.set_value(i) - - def set_value(self, i): - if type(i) is not int: - print("Error: %d is not integer." % i) - else: - self.sint = i - - def pprint(self): - print("The Int Locker has " + str(self.sint)) - - -a = IntLocker(3) -a.pprint() # prints: The Int Locker has 3 -a.set_value(5) -a.pprint() # prints: The Int Locker has 5 -# @:all +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +# class.py + + +# @all: +class IntLocker: + sint = None + + def __init__(self, i): + self.set_value(i) + + def set_value(self, i): + if type(i) is not int: + print("Error: %d is not integer." % i) + else: + self.sint = i + + def pprint(self): + print("The Int Locker has " + str(self.sint)) + + +a = IntLocker(3) +a.pprint() # prints: The Int Locker has 3 +a.set_value(5) +a.pprint() # prints: The Int Locker has 5 +# @:all diff --git a/examples/pyomobook/python-ch/ctob.py b/examples/pyomobook/python-ch/ctob.py index e418d27f103..8945e4863de 100644 --- a/examples/pyomobook/python-ch/ctob.py +++ b/examples/pyomobook/python-ch/ctob.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # An example of a silly decorator to change 'c' to 'b' # in the return value of a function. diff --git a/examples/pyomobook/python-ch/example.py b/examples/pyomobook/python-ch/example.py index 0a404add58d..184153545a3 100644 --- a/examples/pyomobook/python-ch/example.py +++ b/examples/pyomobook/python-ch/example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # This is a comment line, which is ignored by Python print("Hello World") diff --git a/examples/pyomobook/python-ch/example2.py b/examples/pyomobook/python-ch/example2.py index da7d14e24ae..9a6a28bedbd 100644 --- a/examples/pyomobook/python-ch/example2.py +++ b/examples/pyomobook/python-ch/example2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # A modified example.py program print("Hello World") diff --git a/examples/pyomobook/python-ch/functions.py b/examples/pyomobook/python-ch/functions.py index 7948c5e55df..97fb77edbe4 100644 --- a/examples/pyomobook/python-ch/functions.py +++ b/examples/pyomobook/python-ch/functions.py @@ -1,24 +1,35 @@ -# functions.py - - -# @all: -def Apply(f, a): - r = [] - for i in range(len(a)): - r.append(f(a[i])) - return r - - -def SqifOdd(x): - # if x is odd, 2*int(x/2) is not x - # due to integer divide of x/2 - if 2 * int(x / 2) == x: - return x - else: - return x * x - - -ShortList = range(4) -B = Apply(SqifOdd, ShortList) -print(B) -# @:all +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +# functions.py + + +# @all: +def Apply(f, a): + r = [] + for i in range(len(a)): + r.append(f(a[i])) + return r + + +def SqifOdd(x): + # if x is odd, 2*int(x/2) is not x + # due to integer divide of x/2 + if 2 * int(x / 2) == x: + return x + else: + return x * x + + +ShortList = range(4) +B = Apply(SqifOdd, ShortList) +print(B) +# @:all diff --git a/examples/pyomobook/python-ch/iterate.py b/examples/pyomobook/python-ch/iterate.py index 3a3422b2a09..50d74f93da7 100644 --- a/examples/pyomobook/python-ch/iterate.py +++ b/examples/pyomobook/python-ch/iterate.py @@ -1,18 +1,29 @@ -# iterate.py - -# @all: -D = {'Mary': 231} -D['Bob'] = 123 -D['Alice'] = 331 -D['Ted'] = 987 - -for i in sorted(D): - if i == 'Alice': - continue - if i == 'John': - print("Loop ends. Cleese alert!") - break - print(i + " " + str(D[i])) -else: - print("Cleese is not in the list.") -# @:all +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +# iterate.py + +# @all: +D = {'Mary': 231} +D['Bob'] = 123 +D['Alice'] = 331 +D['Ted'] = 987 + +for i in sorted(D): + if i == 'Alice': + continue + if i == 'John': + print("Loop ends. Cleese alert!") + break + print(i + " " + str(D[i])) +else: + print("Cleese is not in the list.") +# @:all diff --git a/examples/pyomobook/python-ch/pythonconditional.py b/examples/pyomobook/python-ch/pythonconditional.py index 205428e5ad1..a39e148622b 100644 --- a/examples/pyomobook/python-ch/pythonconditional.py +++ b/examples/pyomobook/python-ch/pythonconditional.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # pythonconditional.py # @all: diff --git a/examples/pyomobook/scripts-ch/attributes.py b/examples/pyomobook/scripts-ch/attributes.py index 643162082b6..c406bbf3e1c 100644 --- a/examples/pyomobook/scripts-ch/attributes.py +++ b/examples/pyomobook/scripts-ch/attributes.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 json import pyomo.environ as pyo from warehouse_model import create_wl_model diff --git a/examples/pyomobook/scripts-ch/prob_mod_ex.py b/examples/pyomobook/scripts-ch/prob_mod_ex.py index 6d610e9b44a..dceafe9d4f0 100644 --- a/examples/pyomobook/scripts-ch/prob_mod_ex.py +++ b/examples/pyomobook/scripts-ch/prob_mod_ex.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/scripts-ch/sudoku/sudoku.py b/examples/pyomobook/scripts-ch/sudoku/sudoku.py index ea0c0044e1d..8aa39f91203 100644 --- a/examples/pyomobook/scripts-ch/sudoku/sudoku.py +++ b/examples/pyomobook/scripts-ch/sudoku/sudoku.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo # create a standard python dict for mapping subsquares to diff --git a/examples/pyomobook/scripts-ch/sudoku/sudoku_run.py b/examples/pyomobook/scripts-ch/sudoku/sudoku_run.py index 266362308fa..b3f861f86b5 100644 --- a/examples/pyomobook/scripts-ch/sudoku/sudoku_run.py +++ b/examples/pyomobook/scripts-ch/sudoku/sudoku_run.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.opt import SolverFactory, TerminationCondition from sudoku import create_sudoku_model, print_solution, add_integer_cut diff --git a/examples/pyomobook/scripts-ch/value_expression.py b/examples/pyomobook/scripts-ch/value_expression.py index 51c07500ea8..00c79fec501 100644 --- a/examples/pyomobook/scripts-ch/value_expression.py +++ b/examples/pyomobook/scripts-ch/value_expression.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo model = pyo.ConcreteModel() diff --git a/examples/pyomobook/scripts-ch/warehouse_cuts.py b/examples/pyomobook/scripts-ch/warehouse_cuts.py index c6516e796af..345dc5540cb 100644 --- a/examples/pyomobook/scripts-ch/warehouse_cuts.py +++ b/examples/pyomobook/scripts-ch/warehouse_cuts.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 warnings warnings.filterwarnings("ignore") diff --git a/examples/pyomobook/scripts-ch/warehouse_cuts.txt b/examples/pyomobook/scripts-ch/warehouse_cuts.txt index 9afe6c4e944..1f097e06cea 100644 --- a/examples/pyomobook/scripts-ch/warehouse_cuts.txt +++ b/examples/pyomobook/scripts-ch/warehouse_cuts.txt @@ -1,7 +1,7 @@ --- Solver Status: optimal --- Optimal Obj. Value = 2745.0 -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 1.0 : 1 : False : False : Binary Harlingen : 0 : 1.0 : 1 : False : False : Binary @@ -9,7 +9,7 @@ y : Size=3, Index=y_index --- Solver Status: optimal --- Optimal Obj. Value = 3168.0 -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 1.0 : 1 : False : False : Binary Harlingen : 0 : 0.0 : 1 : False : False : Binary @@ -17,7 +17,7 @@ y : Size=3, Index=y_index --- Solver Status: optimal --- Optimal Obj. Value = 3563.0 -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 0.0 : 1 : False : False : Binary Harlingen : 0 : 1.0 : 1 : False : False : Binary @@ -25,7 +25,7 @@ y : Size=3, Index=y_index --- Solver Status: optimal --- Optimal Obj. Value = 3986.0 -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 0.0 : 1 : False : False : Binary Harlingen : 0 : 0.0 : 1 : False : False : Binary @@ -33,7 +33,7 @@ y : Size=3, Index=y_index --- Solver Status: optimal --- Optimal Obj. Value = 4367.0 -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 1.0 : 1 : False : False : Binary Harlingen : 0 : 0.0 : 1 : False : False : Binary @@ -41,7 +41,7 @@ y : Size=3, Index=y_index --- Solver Status: optimal --- Optimal Obj. Value = 5302.0 -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 0.0 : 1 : False : False : Binary Harlingen : 0 : 1.0 : 1 : False : False : Binary diff --git a/examples/pyomobook/scripts-ch/warehouse_load_solutions.py b/examples/pyomobook/scripts-ch/warehouse_load_solutions.py index 790333a0e64..d38412f84df 100644 --- a/examples/pyomobook/scripts-ch/warehouse_load_solutions.py +++ b/examples/pyomobook/scripts-ch/warehouse_load_solutions.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 json import pyomo.environ as pyo from warehouse_model import create_wl_model diff --git a/examples/pyomobook/scripts-ch/warehouse_model.py b/examples/pyomobook/scripts-ch/warehouse_model.py index f5983d3cd89..149eb212759 100644 --- a/examples/pyomobook/scripts-ch/warehouse_model.py +++ b/examples/pyomobook/scripts-ch/warehouse_model.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo diff --git a/examples/pyomobook/scripts-ch/warehouse_print.py b/examples/pyomobook/scripts-ch/warehouse_print.py index e0e2f961345..8c862506bf0 100644 --- a/examples/pyomobook/scripts-ch/warehouse_print.py +++ b/examples/pyomobook/scripts-ch/warehouse_print.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 json import pyomo.environ as pyo from warehouse_model import create_wl_model diff --git a/examples/pyomobook/scripts-ch/warehouse_script.py b/examples/pyomobook/scripts-ch/warehouse_script.py index f2635a45d3d..617b8036abf 100644 --- a/examples/pyomobook/scripts-ch/warehouse_script.py +++ b/examples/pyomobook/scripts-ch/warehouse_script.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # @script: import json import pyomo.environ as pyo diff --git a/examples/pyomobook/scripts-ch/warehouse_script.txt b/examples/pyomobook/scripts-ch/warehouse_script.txt index b922643dd2b..fac3aef0880 100644 --- a/examples/pyomobook/scripts-ch/warehouse_script.txt +++ b/examples/pyomobook/scripts-ch/warehouse_script.txt @@ -1,36 +1,10 @@ -y : Size=3, Index=y_index +y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 1.0 : 1 : False : False : Binary Harlingen : 0 : 1.0 : 1 : False : False : Binary Memphis : 0 : 0.0 : 1 : False : False : Binary -8 Set Declarations - one_per_cust_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 4 : {'NYC', 'LA', 'Chicago', 'Houston'} - warehouse_active_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : warehouse_active_index_0*warehouse_active_index_1 : 12 : {('Harlingen', 'NYC'), ('Harlingen', 'LA'), ('Harlingen', 'Chicago'), ('Harlingen', 'Houston'), ('Memphis', 'NYC'), ('Memphis', 'LA'), ('Memphis', 'Chicago'), ('Memphis', 'Houston'), ('Ashland', 'NYC'), ('Ashland', 'LA'), ('Ashland', 'Chicago'), ('Ashland', 'Houston')} - warehouse_active_index_0 : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {'Harlingen', 'Memphis', 'Ashland'} - warehouse_active_index_1 : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 4 : {'NYC', 'LA', 'Chicago', 'Houston'} - x_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : x_index_0*x_index_1 : 12 : {('Harlingen', 'NYC'), ('Harlingen', 'LA'), ('Harlingen', 'Chicago'), ('Harlingen', 'Houston'), ('Memphis', 'NYC'), ('Memphis', 'LA'), ('Memphis', 'Chicago'), ('Memphis', 'Houston'), ('Ashland', 'NYC'), ('Ashland', 'LA'), ('Ashland', 'Chicago'), ('Ashland', 'Houston')} - x_index_0 : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {'Harlingen', 'Memphis', 'Ashland'} - x_index_1 : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 4 : {'NYC', 'LA', 'Chicago', 'Houston'} - y_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {'Harlingen', 'Memphis', 'Ashland'} - 2 Var Declarations - x : Size=12, Index=x_index + x : Size=12, Index={Harlingen, Memphis, Ashland}*{NYC, LA, Chicago, Houston} Key : Lower : Value : Upper : Fixed : Stale : Domain ('Ashland', 'Chicago') : 0 : 1.0 : 1 : False : False : Reals ('Ashland', 'Houston') : 0 : 0.0 : 1 : False : False : Reals @@ -40,11 +14,11 @@ y : Size=3, Index=y_index ('Harlingen', 'Houston') : 0 : 1.0 : 1 : False : False : Reals ('Harlingen', 'LA') : 0 : 1.0 : 1 : False : False : Reals ('Harlingen', 'NYC') : 0 : 0.0 : 1 : False : False : Reals - ('Memphis', 'Chicago') : 0 : -0.0 : 1 : False : False : Reals + ('Memphis', 'Chicago') : 0 : 0.0 : 1 : False : False : Reals ('Memphis', 'Houston') : 0 : 0.0 : 1 : False : False : Reals ('Memphis', 'LA') : 0 : 0.0 : 1 : False : False : Reals ('Memphis', 'NYC') : 0 : 0.0 : 1 : False : False : Reals - y : Size=3, Index=y_index + y : Size=3, Index={Harlingen, Memphis, Ashland} Key : Lower : Value : Upper : Fixed : Stale : Domain Ashland : 0 : 1.0 : 1 : False : False : Binary Harlingen : 0 : 1.0 : 1 : False : False : Binary @@ -59,13 +33,13 @@ y : Size=3, Index=y_index num_warehouses : Size=1, Index=None, Active=True Key : Lower : Body : Upper : Active None : -Inf : y[Harlingen] + y[Memphis] + y[Ashland] : 2.0 : True - one_per_cust : Size=4, Index=one_per_cust_index, Active=True + one_per_cust : Size=4, Index={NYC, LA, Chicago, Houston}, Active=True Key : Lower : Body : Upper : Active Chicago : 1.0 : x[Harlingen,Chicago] + x[Memphis,Chicago] + x[Ashland,Chicago] : 1.0 : True Houston : 1.0 : x[Harlingen,Houston] + x[Memphis,Houston] + x[Ashland,Houston] : 1.0 : True LA : 1.0 : x[Harlingen,LA] + x[Memphis,LA] + x[Ashland,LA] : 1.0 : True NYC : 1.0 : x[Harlingen,NYC] + x[Memphis,NYC] + x[Ashland,NYC] : 1.0 : True - warehouse_active : Size=12, Index=warehouse_active_index, Active=True + warehouse_active : Size=12, Index={Harlingen, Memphis, Ashland}*{NYC, LA, Chicago, Houston}, Active=True Key : Lower : Body : Upper : Active ('Ashland', 'Chicago') : -Inf : x[Ashland,Chicago] - y[Ashland] : 0.0 : True ('Ashland', 'Houston') : -Inf : x[Ashland,Houston] - y[Ashland] : 0.0 : True @@ -80,4 +54,4 @@ y : Size=3, Index=y_index ('Memphis', 'LA') : -Inf : x[Memphis,LA] - y[Memphis] : 0.0 : True ('Memphis', 'NYC') : -Inf : x[Memphis,NYC] - y[Memphis] : 0.0 : True -14 Declarations: x_index_0 x_index_1 x_index x y_index y obj one_per_cust_index one_per_cust warehouse_active_index_0 warehouse_active_index_1 warehouse_active_index warehouse_active num_warehouses +6 Declarations: x y obj one_per_cust warehouse_active num_warehouses diff --git a/examples/pyomobook/scripts-ch/warehouse_solver_options.py b/examples/pyomobook/scripts-ch/warehouse_solver_options.py index c8eaf11a0f3..4e79e158d50 100644 --- a/examples/pyomobook/scripts-ch/warehouse_solver_options.py +++ b/examples/pyomobook/scripts-ch/warehouse_solver_options.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # @script: import json import pyomo.environ as pyo diff --git a/examples/pyomobook/strip_examples.py b/examples/pyomobook/strip_examples.py index 0a65eef7c04..84017299fb6 100644 --- a/examples/pyomobook/strip_examples.py +++ b/examples/pyomobook/strip_examples.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 glob import sys import os diff --git a/examples/pyomobook/test_book_examples.py b/examples/pyomobook/test_book_examples.py index e946864c1aa..192330dc1bf 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-2022 +# 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 diff --git a/pyomo/__init__.py b/pyomo/__init__.py index 20ee59d48b2..1c7bea821ff 100644 --- a/pyomo/__init__.py +++ b/pyomo/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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..3fd82b33044 --- /dev/null +++ b/pyomo/_archive/__init__.py @@ -0,0 +1,18 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +__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/_archive/chull.py b/pyomo/_archive/chull.py new file mode 100644 index 00000000000..0d31e484afb --- /dev/null +++ b/pyomo/_archive/chull.py @@ -0,0 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +"This is the deprecated pyomo.gdp.plugins.chull module" + +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 b48fa96a483..fbccc171689 100644 --- a/pyomo/common/plugin.py +++ b/pyomo/_archive/component_map.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 ecac3967dfe..b2efe88afd8 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-2022 +# 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 @@ -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 0a2ff01c82a..aca46f2aa69 100644 --- a/pyomo/core/expr/current.py +++ b/pyomo/_archive/current.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 57% rename from pyomo/core/base/plugin.py rename to pyomo/_archive/plugin.py index 4ecb12d86a6..64352f58509 100644 --- a/pyomo/core/base/plugin.py +++ b/pyomo/_archive/plugin.py @@ -1,49 +1,15 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 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, -) - -__all__ = [ - 'pyomo_callback', - 'IPyomoExpression', - 'ExpressionFactory', - 'ExpressionRegistration', - 'IPyomoPresolver', - 'IPyomoPresolveAction', - 'IParamRepresentation', - 'ParamRepresentationFactory', - 'IPyomoScriptPreprocess', - 'IPyomoScriptCreateModel', - 'IPyomoScriptCreateDataPortal', - 'IPyomoScriptModifyInstance', - 'IPyomoScriptPrintModel', - 'IPyomoScriptPrintInstance', - 'IPyomoScriptSaveInstance', - 'IPyomoScriptPrintResults', - 'IPyomoScriptSaveResults', - 'IPyomoScriptPostprocess', - 'ModelComponentFactory', - 'Transformation', - 'TransformationFactory', -] +"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 e8b2507ab81..2c313ecb79a 100644 --- a/pyomo/common/getGSL.py +++ b/pyomo/_archive/rangeset.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 5f7812354d9..a8c67a53c17 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-2022 +# 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 @@ -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..8e2eb6bd7c9 --- /dev/null +++ b/pyomo/_archive/sets.py @@ -0,0 +1,23 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +"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..eda4033d09a --- /dev/null +++ b/pyomo/_archive/template_expr.py @@ -0,0 +1,18 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +"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 563974b5617..c2299bdcea8 100644 --- a/pyomo/common/__init__.py +++ b/pyomo/common/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 ae633648ace..ad521659aa7 100644 --- a/pyomo/common/_command.py +++ b/pyomo/common/_command.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -13,8 +13,6 @@ Management of Pyomo commands """ -__all__ = ['pyomo_command', 'get_pyomo_commands'] - import logging logger = logging.getLogger('pyomo.common') diff --git a/pyomo/common/_common.py b/pyomo/common/_common.py index 21a5ddcc7bc..0d50f74537a 100644 --- a/pyomo/common/_common.py +++ b/pyomo/common/_common.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/autoslots.py b/pyomo/common/autoslots.py index 1b55a818b83..a5ba44818c3 100644 --- a/pyomo/common/autoslots.py +++ b/pyomo/common/autoslots.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,27 +9,28 @@ # 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: # Python does not duplicate "unchanged" tuples (i.e. allows the - # original objecct to be returned from deepcopy()). We will + # original object to be returned from deepcopy()). We will # preserve that behavior here. # # It also appears to be faster *not* to cache the fact that this @@ -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 36f2dac87ab..e70b0f6d267 100644 --- a/pyomo/common/backports.py +++ b/pyomo/common/backports.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/cmake_builder.py b/pyomo/common/cmake_builder.py index bb612b43b72..523dbf64c91 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-2022 +# 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 diff --git a/pyomo/common/collections/__init__.py b/pyomo/common/collections/__init__.py index 9ffd1e931f6..717caf87b2c 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-2022 +# 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 @@ -14,6 +14,6 @@ from collections import UserDict from .orderedset import OrderedDict, OrderedSet -from .component_map import ComponentMap +from .component_map import ComponentMap, DefaultComponentMap from .component_set import ComponentSet from .bunch import Bunch diff --git a/pyomo/common/collections/bunch.py b/pyomo/common/collections/bunch.py index f19e4ad64e3..2ae9cf8c517 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-2022 +# 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 diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index 41796876d7c..a9248dfba60 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-2022 +# 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 @@ -9,21 +9,61 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from collections.abc import MutableMapping as collections_MutableMapping +import collections from collections.abc import Mapping as collections_Mapping from pyomo.common.autoslots import AutoSlots -def _rebuild_ids(encode, val): +def _rehash_keys(encode, val): if encode: return val else: # object id() may have changed after unpickling, # so we rebuild the dictionary keys - return {id(obj): (obj, v) for obj, v in val.values()} + return {_hasher[obj.__class__](obj): (obj, v) for obj, v in val.values()} -class ComponentMap(AutoSlots.Mixin, collections_MutableMapping): +class _Hasher(collections.defaultdict): + def __init__(self, *args, **kwargs): + super().__init__(lambda: self._missing_impl, *args, **kwargs) + self[tuple] = self._tuple + + def _missing_impl(self, val): + try: + hash(val) + self[val.__class__] = self._hashable + except: + self[val.__class__] = self._unhashable + return self[val.__class__](val) + + @staticmethod + def _hashable(val): + return val + + @staticmethod + def _unhashable(val): + return id(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() + + +class ComponentMap(AutoSlots.Mixin, collections.abc.MutableMapping): """ This class is a replacement for dict that allows Pyomo modeling components to be used as entry keys. The @@ -49,18 +89,20 @@ class ComponentMap(AutoSlots.Mixin, collections_MutableMapping): """ __slots__ = ("_dict",) - __autoslot_mappers__ = {'_dict': _rebuild_ids} + __autoslot_mappers__ = {'_dict': _rehash_keys} + # Expose a "public" interface to the global _hasher dict + hasher = _hasher def __init__(self, *args, **kwds): - # maps id(obj) -> (obj,val) + # maps id_hash(obj) -> (obj,val) self._dict = {} # handle the dict-style initialization scenarios self.update(*args, **kwds) def __str__(self): """String representation of the mapping.""" - tmp = {str(c) + " (id=" + str(id(c)) + ")": v for c, v in self.items()} - return "ComponentMap(" + str(tmp) + ")" + tmp = {f"{v[0]} (key={k})": v[1] for k, v in self._dict.items()} + return f"ComponentMap({tmp})" # # Implement MutableMapping abstract methods @@ -68,18 +110,20 @@ def __str__(self): def __getitem__(self, obj): try: - return self._dict[id(obj)][1] + return self._dict[_hasher[obj.__class__](obj)][1] except KeyError: - raise KeyError("Component with id '%s': %s" % (id(obj), str(obj))) + _id = _hasher[obj.__class__](obj) + raise KeyError(f"{obj} (key={_id})") from None def __setitem__(self, obj, val): - self._dict[id(obj)] = (obj, val) + self._dict[_hasher[obj.__class__](obj)] = (obj, val) def __delitem__(self, obj): try: - del self._dict[id(obj)] + del self._dict[_hasher[obj.__class__](obj)] except KeyError: - raise KeyError("Component with id '%s': %s" % (id(obj), str(obj))) + _id = _hasher[obj.__class__](obj) + raise KeyError(f"{obj} (key={_id})") from None def __iter__(self): return (obj for obj, val in self._dict.values()) @@ -107,7 +151,7 @@ def __eq__(self, other): return False # Note we have already verified the dicts are the same size for key, val in other.items(): - other_id = id(key) + other_id = _hasher[key.__class__](key) if other_id not in self._dict: return False self_val = self._dict[other_id][1] @@ -130,7 +174,7 @@ def __ne__(self, other): # def __contains__(self, obj): - return id(obj) in self._dict + return _hasher[obj.__class__](obj) in self._dict def clear(self): 'D.clear() -> None. Remove all items from D.' @@ -149,3 +193,32 @@ def setdefault(self, key, default=None): else: self[key] = default return default + + +class DefaultComponentMap(ComponentMap): + """A :py:class:`defaultdict` admitting Pyomo Components as keys + + This class is a replacement for defaultdict that allows Pyomo + modeling components to be used as entry keys. The base + implementation builds on :py:class:`ComponentMap`. + + """ + + __slots__ = ('default_factory',) + + def __init__(self, default_factory=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.default_factory = default_factory + + def __missing__(self, key): + if self.default_factory is None: + raise KeyError(key) + self[key] = ans = self.default_factory() + return ans + + def __getitem__(self, obj): + _key = _hasher[obj.__class__](obj) + if _key in self._dict: + return self._dict[_key][1] + else: + return self.__missing__(obj) diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index e205773220f..bbfee062436 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-2022 +# 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 @@ -12,8 +12,30 @@ from collections.abc import MutableSet as collections_MutableSet from collections.abc import Set as collections_Set +from pyomo.common.autoslots import AutoSlots +from pyomo.common.collections.component_map import _hasher + + +def _rehash_keys(encode, val): + if encode: + # TBD [JDS 2/2024]: if we + # + # return list(val.values()) + # + # here, then we get a strange failure when deepcopying + # ComponentSets containing an _ImplicitAny domain. We could + # track it down to the implementation of + # autoslots.fast_deepcopy, but couldn't find an obvious bug. + # There is no error if we just return the original dict, or if + # we return a tuple(val.values) + return val + else: + # object id() may have changed after unpickling, + # so we rebuild the dictionary keys + return {_hasher[obj.__class__](obj): obj for obj in val.values()} + -class ComponentSet(collections_MutableSet): +class ComponentSet(AutoSlots.Mixin, collections_MutableSet): """ This class is a replacement for set that allows Pyomo modeling components to be used as entries. The @@ -38,47 +60,34 @@ class ComponentSet(collections_MutableSet): """ __slots__ = ("_data",) + __autoslot_mappers__ = {'_data': _rehash_keys} + # Expose a "public" interface to the global _hasher dict + hasher = _hasher - def __init__(self, *args): - self._data = dict() - if len(args) > 0: - if len(args) > 1: - raise TypeError( - "%s expected at most 1 arguments, " - "got %s" % (self.__class__.__name__, len(args)) - ) - self.update(args[0]) + def __init__(self, iterable=None): + # maps id_hash(obj) -> obj + self._data = {} + if iterable is not None: + self.update(iterable) def __str__(self): """String representation of the mapping.""" - tmp = [] - for objid, obj in self._data.items(): - tmp.append(str(obj) + " (id=" + str(objid) + ")") - return "ComponentSet(" + str(tmp) + ")" + tmp = [f"{v} (key={k})" for k, v in self._data.items()] + return f"ComponentSet({tmp})" - def update(self, args): + def update(self, iterable): """Update a set with the union of itself and others.""" - self._data.update((id(obj), obj) for obj in args) - - # - # This method must be defined for deepcopy/pickling - # because this class relies on Python ids. - # - def __setstate__(self, state): - # object id() may have changed after unpickling, - # so we rebuild the dictionary keys - assert len(state) == 1 - self._data = {id(obj): obj for obj in state['_data']} - - def __getstate__(self): - return {'_data': tuple(self._data.values())} + if isinstance(iterable, ComponentSet): + self._data.update(iterable._data) + else: + self._data.update((_hasher[val.__class__](val), val) for val in iterable) # # Implement MutableSet abstract methods # def __contains__(self, val): - return self._data.__contains__(id(val)) + return _hasher[val.__class__](val) in self._data def __iter__(self): return iter(self._data.values()) @@ -88,27 +97,26 @@ def __len__(self): def add(self, val): """Add an element.""" - self._data[id(val)] = val + self._data[_hasher[val.__class__](val)] = val def discard(self, val): """Remove an element. Do not raise an exception if absent.""" - if id(val) in self._data: - del self._data[id(val)] + _id = _hasher[val.__class__](val) + if _id in self._data: + del self._data[_id] # # Overload MutableSet default implementations # - # We want to avoid generating Pyomo expressions due to - # comparison of values, so we convert both objects to a - # plain dictionary mapping key->(type(val), id(val)) and - # compare that instead. def __eq__(self, other): if self is other: return True if not isinstance(other, collections_Set): return False - return len(self) == len(other) and all(id(key) in self._data for key in other) + return len(self) == len(other) and all( + _hasher[val.__class__](val) in self._data for val in other + ) def __ne__(self, other): return not (self == other) @@ -125,6 +133,7 @@ def clear(self): def remove(self, val): """Remove an element. If not a member, raise a KeyError.""" try: - del self._data[id(val)] + del self._data[_hasher[val.__class__](val)] except KeyError: - raise KeyError("Component with id '%s': %s" % (id(val), str(val))) + _id = _hasher[val.__class__](val) + raise KeyError(f"{val} (key={_id})") from None diff --git a/pyomo/common/collections/orderedset.py b/pyomo/common/collections/orderedset.py index 448939c8822..834101e3896 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-2022 +# 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 @@ -9,42 +9,30 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from collections.abc import MutableSet from collections import OrderedDict +from collections.abc import MutableSet +from pyomo.common.autoslots import AutoSlots -class OrderedSet(MutableSet): +class OrderedSet(AutoSlots.Mixin, MutableSet): __slots__ = ('_dict',) def __init__(self, iterable=None): - # TODO: Starting in Python 3.7, dict is ordered (and is faster - # than OrderedDict). dict began supporting reversed() in 3.8. - # We should consider changing the underlying data type here from - # OrderedDict to dict. - self._dict = OrderedDict() + # Starting in Python 3.7, dict is ordered (and is faster than + # OrderedDict). dict began supporting reversed() in 3.8. + self._dict = {} if iterable is not None: - if iterable.__class__ is OrderedSet: - self._dict.update(iterable._dict) - else: - self.update(iterable) + self.update(iterable) def __str__(self): """String representation of the mapping.""" return "OrderedSet(%s)" % (', '.join(repr(x) for x in self)) def update(self, iterable): - for val in iterable: - self.add(val) - - # - # This method must be defined for deepcopy/pickling - # because this class is slotized. - # - def __setstate__(self, state): - self._dict = state - - def __getstate__(self): - return self._dict + if isinstance(iterable, OrderedSet): + self._dict.update(iterable._dict) + else: + self._dict.update((val, None) for val in iterable) # # Implement MutableSet abstract methods diff --git a/pyomo/common/config.py b/pyomo/common/config.py index 15f15872fc6..ddeac28e521 100644 --- a/pyomo/common/config.py +++ b/pyomo/common/config.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -39,10 +39,9 @@ deprecation_warning, relocated_module_attribute, ) -from pyomo.common.errors import DeveloperError 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__) @@ -299,7 +298,71 @@ 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): + """ + Domain validator for type checking. + + Parameters + ---------- + *bases : tuple of type + Valid types. + document_full_base_names : bool, optional + True to prepend full module qualifier to the name of each + member of `bases` in ``self.domain_name()`` and/or any + error messages generated by this object, False otherwise. + """ + + def __init__(self, *bases, document_full_base_names=False): + assert bases + self.baseClasses = bases + self.document_full_base_names = document_full_base_names + + @staticmethod + def _fullname(klass): + """ + Get full name of class, including appropriate module qualifier. + """ + module_name = klass.__module__ + module_qual = "" if module_name == "builtins" else f"{module_name}." + return f"{module_qual}{klass.__name__}" + + def _get_class_name(self, klass): + """ + Get name of class. Module qualifier may be included, + depending on value of `self.document_full_base_names`. + """ + if self.document_full_base_names: + return self._fullname(klass) + else: + return klass.__name__ + + def __call__(self, obj): + if isinstance(obj, self.baseClasses): + return obj + if len(self.baseClasses) > 1: + class_names = ", ".join( + f"{self._get_class_name(kls)!r}" for kls in self.baseClasses + ) + msg = ( + "Expected an instance of one of these types: " + f"{class_names}, but received value {obj!r} of type " + f"{self._get_class_name(type(obj))!r}" + ) + else: + msg = ( + f"Expected an instance of " + f"{self._get_class_name(self.baseClasses[0])!r}, " + f"but received value {obj!r} of type " + f"{self._get_class_name(type(obj))!r}" + ) + raise ValueError(msg) + + def domain_name(self): + class_names = (_domain_name(kls) for kls in self.baseClasses) + return f"IsInstance[{', '.join(class_names)}]" class ListOf(object): @@ -379,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 """ @@ -424,9 +486,14 @@ def __call__(self, module_id): class Path(object): - """Domain validator for path-like options. + """ + Domain validator for a + :py:term:`path-like object `. - This will admit any object and convert it to a string. It will then + This will admit a path-like object + and get the object's file system representation + through :py:obj:`os.fsdecode`. + It will then expand any environment variables and leading usernames (e.g., "~myuser" or "~/") appearing in either the value or the base path before concatenating the base path and value, expanding the path to @@ -454,7 +521,7 @@ def __init__(self, basePath=None, expandPath=None): self.expandPath = expandPath def __call__(self, path): - path = str(path) + path = os.fsdecode(path) _expand = self.expandPath if _expand is None: _expand = not Path.SuppressPathExpansion @@ -465,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 = "" @@ -489,14 +556,21 @@ def __call__(self, path): ) return ans + def domain_name(self): + return _domain_name(type(self)) + class PathList(Path): - """Domain validator for a list of path-like objects. + """ + Domain validator for a list of + :py:term:`path-like objects `. - This will admit any iterable or object convertible to a string. - Iterable objects (other than strings) will have each member - normalized using :py:class:`Path`. Other types will be passed to - :py:class:`Path`, returning a list with the single resulting path. + This admits a path-like object or iterable of such. + If a path-like object is passed, then + a singleton list containing the object normalized through + :py:class:`Path` is returned. + An iterable of path-like objects is cast to a list, each + entry of which is normalized through :py:class:`Path`. Parameters ---------- @@ -513,7 +587,8 @@ class PathList(Path): """ def __call__(self, data): - if hasattr(data, "__iter__") and not isinstance(data, str): + is_path_like = isinstance(data, (str, bytes)) or hasattr(data, "__fspath__") + if hasattr(data, "__iter__") and not is_path_like: return [super(PathList, self).__call__(i) for i in data] else: return [super(PathList, self).__call__(data)] @@ -537,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 @@ -623,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 @@ -709,6 +792,7 @@ def from_enum_or_string(cls, arg): NonNegativeFloat In InEnum + IsInstance ListOf Module Path @@ -805,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 @@ -868,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:: @@ -919,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 @@ -1028,14 +1105,19 @@ class will still create ``c`` instances that only have the single def _dump(*args, **kwds): + # TODO: Change the default behavior to no longer be YAML. + # This was a legacy decision that may no longer be the best + # decision, given changes to technology over the years. try: - from yaml import dump + from yaml import safe_dump as dump except ImportError: # dump = lambda x,**y: str(x) # YAML uses lowercase True/False 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() @@ -1053,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 @@ -1088,15 +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: - _str += _dump(_data, default_flow_style=True).rstrip() + 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 + ).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() @@ -1104,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() @@ -1119,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 @@ -1401,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}' ), ], ) @@ -1434,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()) @@ -1578,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', @@ -1608,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: @@ -1632,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. @@ -1670,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: @@ -1686,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: @@ -1754,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 @@ -2085,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): @@ -2107,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): @@ -2132,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 + + 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: - Now, config.a and config.b cannot be changed. To make them mutable again, + .. 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): @@ -2151,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: @@ -2213,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) @@ -2270,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 @@ -2349,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. @@ -2383,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, @@ -2411,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 @@ -2463,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 @@ -2484,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) @@ -2682,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 0a179b5c2de..68a6ed79d59 100644 --- a/pyomo/common/dependencies.py +++ b/pyomo/common/dependencies.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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,49 @@ 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') + 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 2e39083770d..50ad43ff1f8 100644 --- a/pyomo/common/deprecation.py +++ b/pyomo/common/deprecation.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 79d5302a58e..30e048f37fc 100644 --- a/pyomo/common/download.py +++ b/pyomo/common/download.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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..cb9f2b6d9c6 --- /dev/null +++ b/pyomo/common/enums.py @@ -0,0 +1,212 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +"""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 __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 2ce0f368b9e..a6b94a48622 100644 --- a/pyomo/common/env.py +++ b/pyomo/common/env.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 d74cb764641..1f933d4b08c 100644 --- a/pyomo/common/envvar.py +++ b/pyomo/common/envvar.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/errors.py b/pyomo/common/errors.py index 17013ce4dca..3c82f2b07c1 100644 --- a/pyomo/common/errors.py +++ b/pyomo/common/errors.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/extensions.py b/pyomo/common/extensions.py index e4f7b047bb3..0ac27f125a7 100644 --- a/pyomo/common/extensions.py +++ b/pyomo/common/extensions.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/factory.py b/pyomo/common/factory.py index 6a97759c714..c449cf826b4 100644 --- a/pyomo/common/factory.py +++ b/pyomo/common/factory.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/fileutils.py b/pyomo/common/fileutils.py index 557901c401e..d6d20a53950 100644 --- a/pyomo/common/fileutils.py +++ b/pyomo/common/fileutils.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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..6a3b0a98c93 --- /dev/null +++ b/pyomo/common/flags.py @@ -0,0 +1,139 @@ +# ___________________________________________________________________________ +# +# 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 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 5c2b329ce21..430ec96ca09 100644 --- a/pyomo/common/formatting.py +++ b/pyomo/common/formatting.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/gc_manager.py b/pyomo/common/gc_manager.py index 54fbca32736..751eb95cf18 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-2022 +# 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 diff --git a/pyomo/common/gsl.py b/pyomo/common/gsl.py index 5243758a0de..96fab8623b3 100644 --- a/pyomo/common/gsl.py +++ b/pyomo/common/gsl.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 3097fe1c6de..6810c1ee123 100644 --- a/pyomo/common/log.py +++ b/pyomo/common/log.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -28,21 +28,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 +234,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 +272,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. @@ -289,13 +294,15 @@ class LoggingIntercept(object): 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() + 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' """ @@ -333,8 +340,8 @@ def __exit__(self, et, ev, tb): 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]) class LogStream(io.TextIOBase): diff --git a/pyomo/common/modeling.py b/pyomo/common/modeling.py index 5ecc56cce9b..5839e97544d 100644 --- a/pyomo/common/modeling.py +++ b/pyomo/common/modeling.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 415d8aaba7e..a2dace2be0f 100644 --- a/pyomo/common/multithread.py +++ b/pyomo/common/multithread.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 collections import defaultdict from threading import get_ident, main_thread diff --git a/pyomo/common/numeric_types.py b/pyomo/common/numeric_types.py index 19718b308b6..52dd9ec7f5c 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-2022 +# 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 @@ -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 67960ebbb12..75b8657d1a9 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-2022 +# 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 diff --git a/pyomo/common/plugins.py b/pyomo/common/plugins.py index 7db8077855a..ed44f8bf776 100644 --- a/pyomo/common/plugins.py +++ b/pyomo/common/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/pyomo_typing.py b/pyomo/common/pyomo_typing.py index 64ab2ddafc9..22ec3480842 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-2022 +# 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 diff --git a/pyomo/common/shutdown.py b/pyomo/common/shutdown.py index 5054fd21279..a96a6bc04fc 100644 --- a/pyomo/common/shutdown.py +++ b/pyomo/common/shutdown.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 atexit diff --git a/pyomo/common/sorting.py b/pyomo/common/sorting.py index 31e796c6a9e..4f78a7892b8 100644 --- a/pyomo/common/sorting.py +++ b/pyomo/common/sorting.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/common/tee.py b/pyomo/common/tee.py index 029d66f5767..db3ab9ea2f8 100644 --- a/pyomo/common/tee.py +++ b/pyomo/common/tee.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -50,6 +50,32 @@ 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 redirect_fd(object): """Redirect a file descriptor to a new file or file descriptor. @@ -90,9 +116,9 @@ 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() self.original_file = getattr(sys, self.std) + # important: flush the current file buffer when redirecting + self.original_file.flush() # Duplicate the original standard file descriptor(file # descriptor 1 or 2) to a different file descriptor number self.original_fd = os.dup(self.fd) @@ -109,7 +135,7 @@ def __enter__(self): 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: @@ -152,10 +178,33 @@ 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, 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 low-level file descriptors + associated with stdout (1) and stderr (2) to the ``output``. + This is useful for capturing output emitted directly to the + process stdout / stderr by external compiled modules. + + Returns + ------- + io.TextIOBase + + This is the output stream object where all data is sent. + """ def __init__(self, output=None, capture_fd=False): @@ -169,19 +218,22 @@ def __init__(self, output=None, capture_fd=False): self.fd_redirect = None def __enter__(self): + self.old = (sys.stdout, sys.stderr) if isinstance(self.output, str): self.output_stream = open(self.output, 'w') else: self.output_stream = self.output - self.old = (sys.stdout, sys.stderr) - self.tee = TeeStream(self.output_stream) + if isinstance(self.output, TeeStream): + self.tee = self.output + else: + self.tee = TeeStream(self.output_stream) self.tee.__enter__() 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()), + redirect_fd(1, self.tee.STDOUT.fileno(), synchronize=False), + redirect_fd(2, self.tee.STDERR.fileno(), synchronize=False), ) self.fd_redirect[0].__enter__() self.fd_redirect[1].__enter__() @@ -220,6 +272,7 @@ class _StreamHandle(object): 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 +286,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 @@ -268,9 +328,7 @@ def close(self): def finalize(self, ostreams): 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: @@ -307,10 +365,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:] @@ -320,13 +378,15 @@ def writeOutputBuffer(self, ostreams): if not ostring: return - for stream in ostreams: + for local_stream, user_stream in ostreams: try: - written = stream.write(ostring) + written = local_stream.write(ostring) except: written = 0 - if written and not self.buffering: - stream.flush() + if flush or (written and not self.buffering): + local_stream.flush() + if local_stream is not user_stream: + user_stream.flush() # 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 @@ -335,30 +395,47 @@ def writeOutputBuffer(self, ostreams): 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:]) + "the output buffer:\n\t%r" % (local_stream, ostring[written:]) ) class TeeStream(object): - def __init__(self, *ostreams, encoding=None): - self.ostreams = ostreams + def __init__(self, *ostreams, encoding=None, buffering=-1): + self.ostreams = [] self.encoding = encoding + self.buffering = buffering self._stdout = None self._stderr = None self._handles = [] self._active_handles = [] self._threads = [] + for user_stream in ostreams: + try: + fileno = user_stream.fileno() + except: + self.ostreams.append((user_stream, user_stream)) + continue + local_stream = os.fdopen( + os.dup(fileno), mode=getattr(user_stream, 'mode', None), closefd=True + ) + self.ostreams.append((local_stream, user_stream)) @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): @@ -422,6 +499,9 @@ def close(self, in_exception=False): self._active_handles.clear() self._stdout = None self._stderr = None + for local, orig in self.ostreams: + if orig is not local: + local.close() def __enter__(self): return self @@ -454,15 +534,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 +559,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 +579,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 +590,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 +605,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 f51fad3f3ac..bd49bf21777 100644 --- a/pyomo/common/tempfiles.py +++ b/pyomo/common/tempfiles.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 bc8dfa27c9c..d8d8856e52f 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-2022 +# 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 diff --git a/pyomo/common/tests/config_plugin.py b/pyomo/common/tests/config_plugin.py index ada788fd7d4..6aebc40806a 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-2022 +# 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 diff --git a/pyomo/common/tests/dep_mod.py b/pyomo/common/tests/dep_mod.py index 54530393783..34c7219c6eb 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-2022 +# 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 @@ -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 8132e8a08ac..16936996eeb 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-2022 +# 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 diff --git a/pyomo/common/tests/deps.py b/pyomo/common/tests/deps.py index e5236d0f7ec..5f8c1fffdf8 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-2022 +# 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 @@ -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 e19ad956044..73375bdc819 100644 --- a/pyomo/common/tests/import_ex.py +++ b/pyomo/common/tests/import_ex.py @@ -1,3 +1,15 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + def a(): pass diff --git a/pyomo/contrib/parmest/ipopt_solver_wrapper.py b/pyomo/common/tests/mod.py similarity index 75% rename from pyomo/contrib/parmest/ipopt_solver_wrapper.py rename to pyomo/common/tests/mod.py index a6d5e0506fb..8e34e3dea54 100644 --- a/pyomo/contrib/parmest/ipopt_solver_wrapper.py +++ b/pyomo/common/tests/mod.py @@ -1,14 +1,17 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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. # ___________________________________________________________________________ +# + +# This is a simple module used as part of testing import callbacks -from pyomo.common.deprecation import relocated_module -relocated_module('pyomo.contrib.parmest.utils.ipopt_solver_wrapper', version='6.4.2') +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..fdc017cb10e --- /dev/null +++ b/pyomo/common/tests/moved.py @@ -0,0 +1,17 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ +# + +# 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 20b0712e09b..4881caba671 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-2022 +# 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 diff --git a/pyomo/common/tests/relo_mod_new.py b/pyomo/common/tests/relo_mod_new.py index 1ef27681b66..0f59f3beebc 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-2022 +# 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 diff --git a/pyomo/common/tests/relocated.py b/pyomo/common/tests/relocated.py index 9de63e0cec9..90cb28c23ba 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-2022 +# 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 diff --git a/pyomo/common/tests/test_bunch.py b/pyomo/common/tests/test_bunch.py index a8daf5a0071..8c10df83005 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-2022 +# 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 diff --git a/pyomo/common/tests/test_component_map.py b/pyomo/common/tests/test_component_map.py new file mode 100644 index 00000000000..1dc246be350 --- /dev/null +++ b/pyomo/common/tests/test_component_map.py @@ -0,0 +1,111 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest + +from pyomo.common.collections import ComponentMap, ComponentSet, DefaultComponentMap +from pyomo.environ import ConcreteModel, Block, Var, Constraint + + +class TestComponentMap(unittest.TestCase): + def test_tuple(self): + m = ConcreteModel() + m.v = Var() + m.c = Constraint(expr=m.v >= 0) + m.cm = cm = ComponentMap() + + cm[(1, 2)] = 5 + self.assertEqual(len(cm), 1) + self.assertIn((1, 2), cm) + self.assertEqual(cm[1, 2], 5) + + cm[(1, 2)] = 50 + self.assertEqual(len(cm), 1) + self.assertIn((1, 2), cm) + self.assertEqual(cm[1, 2], 50) + + cm[(1, (2, m.v))] = 10 + self.assertEqual(len(cm), 2) + self.assertIn((1, (2, m.v)), cm) + self.assertEqual(cm[1, (2, m.v)], 10) + + cm[(1, (2, m.v))] = 100 + self.assertEqual(len(cm), 2) + self.assertIn((1, (2, m.v)), cm) + self.assertEqual(cm[1, (2, m.v)], 100) + + i = m.clone() + self.assertIn((1, 2), i.cm) + self.assertIn((1, (2, i.v)), i.cm) + self.assertNotIn((1, (2, i.v)), m.cm) + 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): + dcm = DefaultComponentMap(ComponentSet) + + m = ConcreteModel() + m.x = Var() + m.b = Block() + m.b.y = Var() + + self.assertEqual(len(dcm), 0) + + dcm[m.x].add(m) + self.assertEqual(len(dcm), 1) + self.assertIn(m.x, dcm) + self.assertIn(m, dcm[m.x]) + + dcm[m.b.y].add(m.b) + self.assertEqual(len(dcm), 2) + self.assertIn(m.b.y, dcm) + self.assertNotIn(m, dcm[m.b.y]) + self.assertIn(m.b, dcm[m.b.y]) + + dcm[m.b.y].add(m) + self.assertEqual(len(dcm), 2) + self.assertIn(m.b.y, dcm) + self.assertIn(m, dcm[m.b.y]) + self.assertIn(m.b, dcm[m.b.y]) + + def test_no_default_factory(self): + dcm = DefaultComponentMap() + + dcm['found'] = 5 + self.assertEqual(len(dcm), 1) + self.assertIn('found', dcm) + self.assertEqual(dcm['found'], 5) + + with self.assertRaisesRegex(KeyError, "'missing'"): + dcm["missing"] diff --git a/pyomo/common/tests/test_config.py b/pyomo/common/tests/test_config.py index 1b732d86c0a..88e0706ba05 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-2022 +# 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 @@ -60,6 +60,7 @@ def yaml_load(arg): NonPositiveFloat, NonNegativeFloat, In, + IsInstance, ListOf, Module, Path, @@ -87,6 +88,7 @@ def _display(obj, *args): class GlobalClass(object): "test class for test_known_types" + pass @@ -448,12 +450,83 @@ class TestEnum(enum.Enum): with self.assertRaisesRegex(ValueError, '.*invalid value'): cfg.enum = 'ITEM_THREE' + def test_IsInstance(self): + c = ConfigDict() + c.declare("val", ConfigValue(None, IsInstance(int))) + c.val = 1 + self.assertEqual(c.val, 1) + exc_str = ( + "Expected an instance of 'int', but received value 2.4 of type 'float'" + ) + with self.assertRaisesRegex(ValueError, exc_str): + c.val = 2.4 + + class TestClass: + def __repr__(self): + return f"{TestClass.__name__}()" + + c.declare("val2", ConfigValue(None, IsInstance(TestClass))) + testinst = TestClass() + c.val2 = testinst + self.assertEqual(c.val2, testinst) + exc_str = ( + r"Expected an instance of 'TestClass', " + "but received value 2.4 of type 'float'" + ) + with self.assertRaisesRegex(ValueError, exc_str): + c.val2 = 2.4 + + c.declare( + "val3", + ConfigValue( + None, IsInstance(int, TestClass, document_full_base_names=True) + ), + ) + self.assertRegex(c.get("val3").domain_name(), r"IsInstance\[int, TestClass\]") + c.val3 = 2 + self.assertEqual(c.val3, 2) + exc_str = ( + r"Expected an instance of one of these types: 'int', '.*\.TestClass'" + r", but received value 2.4 of type 'float'" + ) + with self.assertRaisesRegex(ValueError, exc_str): + c.val3 = 2.4 + + c.declare( + "val4", + ConfigValue( + None, IsInstance(int, TestClass, document_full_base_names=False) + ), + ) + self.assertEqual(c.get("val4").domain_name(), "IsInstance[int, TestClass]") + c.val4 = 2 + self.assertEqual(c.val4, 2) + exc_str = ( + r"Expected an instance of one of these types: 'int', 'TestClass'" + r", but received value 2.4 of type 'float'" + ) + with self.assertRaisesRegex(ValueError, exc_str): + c.val4 = 2.4 + def test_Path(self): def norm(x): if cwd[1] == ':' and x[0] == '/': x = cwd[:2] + x return x.replace('/', os.path.sep) + class ExamplePathLike: + def __init__(self, path_str_or_bytes): + self.path = path_str_or_bytes + + def __fspath__(self): + return self.path + + def __str__(self): + path_str = str(self.path) + return f"{type(self).__name__}({path_str})" + + self.assertEqual(Path().domain_name(), "Path") + cwd = os.getcwd() + os.path.sep c = ConfigDict() @@ -462,12 +535,30 @@ def norm(x): c.a = "/a/b/c" self.assertTrue(os.path.sep in c.a) self.assertEqual(c.a, norm('/a/b/c')) + c.a = b"/a/b/c" + self.assertTrue(os.path.sep in c.a) + self.assertEqual(c.a, norm('/a/b/c')) + c.a = ExamplePathLike("/a/b/c") + self.assertTrue(os.path.sep in c.a) + self.assertEqual(c.a, norm('/a/b/c')) c.a = "a/b/c" self.assertTrue(os.path.sep in c.a) self.assertEqual(c.a, norm(cwd + 'a/b/c')) + c.a = b'a/b/c' + self.assertTrue(os.path.sep in c.a) + self.assertEqual(c.a, norm(cwd + 'a/b/c')) + c.a = ExamplePathLike('a/b/c') + self.assertTrue(os.path.sep in c.a) + self.assertEqual(c.a, norm(cwd + 'a/b/c')) c.a = "${CWD}/a/b/c" self.assertTrue(os.path.sep in c.a) self.assertEqual(c.a, norm(cwd + 'a/b/c')) + c.a = b'${CWD}/a/b/c' + self.assertTrue(os.path.sep in c.a) + self.assertEqual(c.a, norm(cwd + 'a/b/c')) + c.a = ExamplePathLike('${CWD}/a/b/c') + self.assertTrue(os.path.sep in c.a) + self.assertEqual(c.a, norm(cwd + 'a/b/c')) c.a = None self.assertIs(c.a, None) @@ -476,12 +567,30 @@ def norm(x): c.b = "/a/b/c" self.assertTrue(os.path.sep in c.b) self.assertEqual(c.b, norm('/a/b/c')) + c.b = b"/a/b/c" + self.assertTrue(os.path.sep in c.b) + self.assertEqual(c.b, norm('/a/b/c')) + c.b = ExamplePathLike("/a/b/c") + self.assertTrue(os.path.sep in c.b) + self.assertEqual(c.b, norm('/a/b/c')) c.b = "a/b/c" self.assertTrue(os.path.sep in c.b) self.assertEqual(c.b, norm(cwd + 'rel/path/a/b/c')) + c.b = b"a/b/c" + self.assertTrue(os.path.sep in c.b) + self.assertEqual(c.b, norm(cwd + 'rel/path/a/b/c')) + c.b = ExamplePathLike("a/b/c") + self.assertTrue(os.path.sep in c.b) + self.assertEqual(c.b, norm(cwd + "rel/path/a/b/c")) c.b = "${CWD}/a/b/c" self.assertTrue(os.path.sep in c.b) self.assertEqual(c.b, norm(cwd + 'a/b/c')) + c.b = b"${CWD}/a/b/c" + self.assertTrue(os.path.sep in c.b) + self.assertEqual(c.b, norm(cwd + 'a/b/c')) + c.b = ExamplePathLike("${CWD}/a/b/c") + self.assertTrue(os.path.sep in c.b) + self.assertEqual(c.b, norm(cwd + 'a/b/c')) c.b = None self.assertIs(c.b, None) @@ -490,12 +599,30 @@ def norm(x): c.c = "/a/b/c" self.assertTrue(os.path.sep in c.c) self.assertEqual(c.c, norm('/a/b/c')) + c.c = b"/a/b/c" + self.assertTrue(os.path.sep in c.c) + self.assertEqual(c.c, norm('/a/b/c')) + c.c = ExamplePathLike("/a/b/c") + self.assertTrue(os.path.sep in c.c) + self.assertEqual(c.c, norm('/a/b/c')) c.c = "a/b/c" self.assertTrue(os.path.sep in c.c) self.assertEqual(c.c, norm('/my/dir/a/b/c')) + c.c = b"a/b/c" + self.assertTrue(os.path.sep in c.c) + self.assertEqual(c.c, norm('/my/dir/a/b/c')) + c.c = ExamplePathLike("a/b/c") + self.assertTrue(os.path.sep in c.c) + self.assertEqual(c.c, norm("/my/dir/a/b/c")) c.c = "${CWD}/a/b/c" self.assertTrue(os.path.sep in c.c) self.assertEqual(c.c, norm(cwd + 'a/b/c')) + c.c = b"${CWD}/a/b/c" + self.assertTrue(os.path.sep in c.c) + self.assertEqual(c.c, norm(cwd + 'a/b/c')) + c.c = ExamplePathLike("${CWD}/a/b/c") + self.assertTrue(os.path.sep in c.c) + self.assertEqual(c.c, norm(cwd + 'a/b/c')) c.c = None self.assertIs(c.c, None) @@ -505,12 +632,30 @@ def norm(x): c.d = "/a/b/c" self.assertTrue(os.path.sep in c.d) self.assertEqual(c.d, norm('/a/b/c')) + c.d = b"/a/b/c" + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm('/a/b/c')) + c.d = ExamplePathLike("/a/b/c") + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm('/a/b/c')) c.d = "a/b/c" self.assertTrue(os.path.sep in c.d) self.assertEqual(c.d, norm(cwd + 'a/b/c')) + c.d = b"a/b/c" + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm(cwd + 'a/b/c')) + c.d = ExamplePathLike("a/b/c") + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm(cwd + 'a/b/c')) c.d = "${CWD}/a/b/c" self.assertTrue(os.path.sep in c.d) self.assertEqual(c.d, norm(cwd + 'a/b/c')) + c.d = b"${CWD}/a/b/c" + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm(cwd + 'a/b/c')) + c.d = ExamplePathLike("${CWD}/a/b/c") + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm(cwd + 'a/b/c')) c.d_base = '/my/dir' c.d = "/a/b/c" @@ -527,12 +672,30 @@ def norm(x): c.d = "/a/b/c" self.assertTrue(os.path.sep in c.d) self.assertEqual(c.d, norm('/a/b/c')) + c.d = b"/a/b/c" + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm('/a/b/c')) + c.d = ExamplePathLike("/a/b/c") + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm('/a/b/c')) c.d = "a/b/c" self.assertTrue(os.path.sep in c.d) self.assertEqual(c.d, norm(cwd + 'rel/path/a/b/c')) + c.d = b"a/b/c" + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm(cwd + 'rel/path/a/b/c')) + c.d = ExamplePathLike("a/b/c") + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm(cwd + 'rel/path/a/b/c')) c.d = "${CWD}/a/b/c" self.assertTrue(os.path.sep in c.d) self.assertEqual(c.d, norm(cwd + 'a/b/c')) + c.d = b"${CWD}/a/b/c" + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm(cwd + 'a/b/c')) + c.d = ExamplePathLike("${CWD}/a/b/c") + self.assertTrue(os.path.sep in c.d) + self.assertEqual(c.d, norm(cwd + 'a/b/c')) try: Path.SuppressPathExpansion = True @@ -540,14 +703,38 @@ def norm(x): self.assertTrue('/' in c.d) self.assertTrue('\\' not in c.d) self.assertEqual(c.d, '/a/b/c') + c.d = b"/a/b/c" + self.assertTrue('/' in c.d) + self.assertTrue('\\' not in c.d) + self.assertEqual(c.d, '/a/b/c') + c.d = ExamplePathLike("/a/b/c") + self.assertTrue('/' in c.d) + self.assertTrue('\\' not in c.d) + self.assertEqual(c.d, '/a/b/c') c.d = "a/b/c" self.assertTrue('/' in c.d) self.assertTrue('\\' not in c.d) self.assertEqual(c.d, 'a/b/c') + c.d = b"a/b/c" + self.assertTrue('/' in c.d) + self.assertTrue('\\' not in c.d) + self.assertEqual(c.d, 'a/b/c') + c.d = ExamplePathLike("a/b/c") + self.assertTrue('/' in c.d) + self.assertTrue('\\' not in c.d) + self.assertEqual(c.d, 'a/b/c') c.d = "${CWD}/a/b/c" self.assertTrue('/' in c.d) self.assertTrue('\\' not in c.d) self.assertEqual(c.d, "${CWD}/a/b/c") + c.d = b"${CWD}/a/b/c" + self.assertTrue('/' in c.d) + self.assertTrue('\\' not in c.d) + self.assertEqual(c.d, "${CWD}/a/b/c") + c.d = ExamplePathLike("${CWD}/a/b/c") + self.assertTrue('/' in c.d) + self.assertTrue('\\' not in c.d) + self.assertEqual(c.d, "${CWD}/a/b/c") finally: Path.SuppressPathExpansion = False @@ -560,6 +747,8 @@ def norm(x): cwd = os.getcwd() + os.path.sep c = ConfigDict() + self.assertEqual(PathList().domain_name(), "PathList") + c.declare('a', ConfigValue(None, PathList())) self.assertEqual(c.a, None) c.a = "/a/b/c" @@ -582,6 +771,13 @@ def norm(x): self.assertEqual(len(c.a), 0) self.assertIs(type(c.a), list) + exc_str = r".*expected str, bytes or os.PathLike.*int" + + with self.assertRaisesRegex(ValueError, exc_str): + c.a = 2 + with self.assertRaisesRegex(ValueError, exc_str): + c.a = ["/a/b/c", 2] + def test_ListOf(self): c = ConfigDict() c.declare('a', ConfigValue(domain=ListOf(int), default=None)) @@ -978,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("#"): @@ -1140,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): @@ -1179,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: @@ -1202,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: @@ -1217,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 @@ -1229,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 @@ -1238,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] @@ -1288,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] @@ -1302,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] @@ -1312,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] @@ -1372,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] @@ -1386,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] @@ -1398,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") @@ -1433,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") @@ -1454,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': []}]} ) @@ -1464,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") @@ -1472,15 +1642,13 @@ 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}) + self.assertEqual(yaml_load(test), {'bar': {'baz': None}, 'foo': 0}) @unittest.skipIf(not yaml_available, "Test requires PyYAML") 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") @@ -1488,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): @@ -1692,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() @@ -1901,7 +2081,6 @@ def test_generate_custom_documentation(self): "generate_documentation is deprecated.", LOG, ) - self.maxDiff = None # print(test) self.assertEqual(test, reference) @@ -1916,7 +2095,6 @@ def test_generate_custom_documentation(self): ) ) self.assertEqual(LOG.getvalue(), "") - self.maxDiff = None # print(test) self.assertEqual(test, reference) @@ -1962,7 +2140,6 @@ def test_generate_custom_documentation(self): "generate_documentation is deprecated.", LOG, ) - self.maxDiff = None # print(test) self.assertEqual(test, reference) @@ -2227,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: @@ -2241,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: @@ -2255,7 +2430,6 @@ def test_list_manipulation(self): """, ) test = _display(self.config['scenarios']) - sys.stdout.write(test) self.assertEqual( test, """- @@ -2380,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 @@ -2575,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): @@ -2722,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): @@ -2909,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() @@ -2929,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 @@ -2947,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 @@ -2958,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.""" @@ -2968,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 @@ -2981,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 @@ -2992,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.""" @@ -3001,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. @@ -3013,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. @@ -3022,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) @@ -3068,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 65058e01812..fc4af1af53b 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-2022 +# 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 @@ -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 1fb4a471740..bd3ff24d0b8 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-2022 +# 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 @@ -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 8c41edc1512..4ee781d5738 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-2022 +# 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 @@ -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..80d081505e9 --- /dev/null +++ b/pyomo/common/tests/test_enums.py @@ -0,0 +1,97 @@ +# ___________________________________________________________________________ +# +# 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 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 d14326ddc19..93802fc40bb 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-2022 +# 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 diff --git a/pyomo/common/tests/test_errors.py b/pyomo/common/tests/test_errors.py index ec77643f722..67a200e84e3 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-2022 +# 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 diff --git a/pyomo/common/tests/test_fileutils.py b/pyomo/common/tests/test_fileutils.py index 63570774e5b..068360b55cb 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-2022 +# 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 diff --git a/pyomo/common/tests/test_flags.py b/pyomo/common/tests/test_flags.py new file mode 100644 index 00000000000..b4436907c99 --- /dev/null +++ b/pyomo/common/tests/test_flags.py @@ -0,0 +1,49 @@ +# ___________________________________________________________________________ +# +# 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 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 d502c81da5a..29db26676ab 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-2022 +# 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 diff --git a/pyomo/common/tests/test_gc.py b/pyomo/common/tests/test_gc.py index b2f23102a0e..176010b8d0d 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-2022 +# 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 diff --git a/pyomo/common/tests/test_log.py b/pyomo/common/tests/test_log.py index 39fab153e98..166e1e44cdb 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-2022 +# 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 @@ -511,7 +511,6 @@ def test_verbatim(self): "\n" " quote block\n" ) - self.maxDiff = None self.assertEqual(self.stream.getvalue(), ans) diff --git a/pyomo/common/tests/test_modeling.py b/pyomo/common/tests/test_modeling.py index 0684d77b2e9..553f71611a7 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-2022 +# 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 @@ -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 ae1bc48be44..fa1a46fa25f 100644 --- a/pyomo/common/tests/test_multithread.py +++ b/pyomo/common/tests/test_multithread.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 threading import pyomo.common.unittest as unittest from pyomo.common.multithread import * diff --git a/pyomo/common/tests/test_numeric_types.py b/pyomo/common/tests/test_numeric_types.py new file mode 100644 index 00000000000..b7ffb5fb255 --- /dev/null +++ b/pyomo/common/tests/test_numeric_types.py @@ -0,0 +1,219 @@ +# ___________________________________________________________________________ +# +# 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 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 d87bebc1e4a..8f944e66bd7 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-2022 +# 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 diff --git a/pyomo/common/tests/test_plugin.py b/pyomo/common/tests/test_plugin.py index 86d136dd9d1..54431334d5b 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-2022 +# 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 diff --git a/pyomo/common/tests/test_sorting.py b/pyomo/common/tests/test_sorting.py index 7a9fe5ac923..7fbefda6a19 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-2022 +# 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 diff --git a/pyomo/common/tests/test_tee.py b/pyomo/common/tests/test_tee.py index 666a431631f..e3740e39604 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-2022 +# 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 @@ -10,7 +10,10 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import gc +import itertools import os +import platform import time import sys @@ -22,7 +25,60 @@ 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(): + self.buf.append((time.time(), float(line.strip()))) + + 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() @@ -56,7 +112,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 +132,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() @@ -183,6 +272,9 @@ class MockStream(object): def write(self, data): time.sleep(0.2) + 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 @@ -203,6 +295,216 @@ def write(self, data): 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)]] + if fd: + # TODO: [JDS] If we are capturing the file descriptor, the + # stdout channel is sometimes 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. + baseline.append([(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): self.out = sys.stdout diff --git a/pyomo/common/tests/test_tempfile.py b/pyomo/common/tests/test_tempfile.py index 5e75c55305a..c49aa8c6771 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-2022 +# 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 diff --git a/pyomo/common/tests/test_timing.py b/pyomo/common/tests/test_timing.py index d885359e6c6..fdf6217dddf 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-2022 +# 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 @@ -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 982462f8a8d..e65effe7f29 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-2022 +# 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 diff --git a/pyomo/common/tests/test_unittest.py b/pyomo/common/tests/test_unittest.py index e3779e6f86e..cd81a08fd2b 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-2022 +# 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 @@ -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 b37570fa666..32c3b269dd4 100644 --- a/pyomo/common/timing.py +++ b/pyomo/common/timing.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 1ed26f72320..899bd74912b 100644 --- a/pyomo/common/unittest.py +++ b/pyomo/common/unittest.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 e69de29bb2d..83d661830ba 100644 --- a/pyomo/contrib/__init__.py +++ b/pyomo/contrib/__init__.py @@ -0,0 +1,25 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +# +# 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..0b01e359879 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/__init__.py @@ -0,0 +1,20 @@ +# ___________________________________________________________________________ +# +# 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.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..23c31f3874a --- /dev/null +++ b/pyomo/contrib/alternative_solutions/aos_utils.py @@ -0,0 +1,304 @@ +# ___________________________________________________________________________ +# +# 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 + +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 pe +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(pe.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 = pe.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 = pe.Constraint( + expr=objective_expr <= objective_cutoff + ) + else: + aos_block.optimality_tol_rel = pe.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 = pe.Constraint( + expr=objective_expr <= objective_cutoff + ) + else: + aos_block.optimality_tol_abs = pe.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 = (pe.Objective, pe.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 == pe.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 == pe.Block + ): + block = comp[0] + descend_into = pe.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 = pe.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 == pe.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..5331c3de07b --- /dev/null +++ b/pyomo/contrib/alternative_solutions/balas.py @@ -0,0 +1,258 @@ +# ___________________________________________________________________________ +# +# 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 + +logger = logging.getLogger(__name__) + +import pyomo.environ as pe +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 = pe.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 pe.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 = pe.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 = pe.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 = pe.Objective(expr=expr, sense=pe.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 = pe.Objective(expr=expr, sense=pe.maximize) + + results = opt.solve(model, tee=tee, load_solutions=False) + status = results.solver.status + condition = results.solver.termination_condition + if pe.check_optimal_termination(results): + model.solutions.load_from(results) + orig_obj_value = pe.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 == pe.TerminationCondition.infeasibleOrUnbounded + or condition == pe.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..3021887b24c --- /dev/null +++ b/pyomo/contrib/alternative_solutions/lp_enum.py @@ -0,0 +1,330 @@ +# ___________________________________________________________________________ +# +# 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 + +logger = logging.getLogger(__name__) + +import pyomo.environ as pe +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 = pe.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 = pe.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 = pe.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 = pe.Set(pe.PositiveIntegers) + + # w variables + cb.basic_lower = pe.Var(pe.Any, domain=pe.Binary, dense=False) + cb.basic_upper = pe.Var(pe.Any, domain=pe.Binary, dense=False) + cb.basic_slack = pe.Var(pe.Any, domain=pe.Binary, dense=False) + + # w upper bounds constraints (Eqn (3)) + cb.bound_lower = pe.Constraint(pe.Any) + cb.bound_upper = pe.Constraint(pe.Any) + cb.bound_slack = pe.Constraint(pe.Any) + + # non-zero basic variable no-good cut set + cb.cut_set = pe.Constraint(pe.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 = pe.Constraint(pe.Any) + # y variables + cb.basic_last_lower = pe.Var(pe.Any, domain=pe.Binary, dense=False) + cb.basic_last_upper = pe.Var(pe.Any, domain=pe.Binary, dense=False) + cb.basic_last_slack = pe.Var(pe.Any, domain=pe.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 = pe.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 == pe.TerminationCondition.infeasibleOrUnbounded + or condition == pe.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..5fa9739758e --- /dev/null +++ b/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py @@ -0,0 +1,235 @@ +# ___________________________________________________________________________ +# +# 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 + +logger = logging.getLogger(__name__) + +from pyomo.common.dependencies import attempt_import + +gurobipy, gurobi_available = attempt_import("gurobipy") + +import pyomo.environ as pe +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 = pe.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 != pe.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 = pe.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 = pe.Var(lower_index, domain=pe.Binary) + cb.basic_upper = pe.Var(upper_index, domain=pe.Binary) + cb.basic_slack = pe.Var(cb.slack_index, domain=pe.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 = pe.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 = pe.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 = pe.Constraint(cb.slack_index, rule=bound_slack_rule) + + cb.cl = pe.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..eb74d75a5db --- /dev/null +++ b/pyomo/contrib/alternative_solutions/obbt.py @@ -0,0 +1,355 @@ +# ___________________________________________________________________________ +# +# 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 + +logger = logging.getLogger(__name__) + +import pyomo.environ as pe +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 = pe.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 = pe.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 = pe.TerminationCondition.optimal + infeas_or_unbdd_tc = pe.TerminationCondition.infeasibleOrUnbounded + unbdd_tc = pe.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 = pe.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 = pe.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 = pe.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 = pe.ComponentMap() + solns = [Solution(model, all_variables, objective=orig_objective)] + + senses = [(pe.minimize, "LB"), (pe.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 = pe.value(var) + variable_bounds[var][idx] = obj_val + + if refine_discrete_bounds and not var.is_continuous(): + if sense == pe.minimize and var.lb < obj_val: + aos_block.bound_constraints.add(var >= obj_val) + new_constraint = True + + if sense == pe.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 == pe.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(pe.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..2f3ae489ba4 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/shifted_lp.py @@ -0,0 +1,229 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pe +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 = pe.Var( + new_vars.keys(), domain=pe.NonNegativeReals, bounds=var_range + ) + shifted_lp.var_upper = pe.Var( + new_vars.keys(), domain=pe.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 = pe.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 = pe.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(pe.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 = pe.Set(initialize=new_constraints.keys()) + shifted_lp.slack_index = pe.Set(initialize=slacks) + shifted_lp.slack_vars = pe.Var(shifted_lp.slack_index, domain=pe.NonNegativeReals) + shifted_lp.constraints = pe.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..2f82440c169 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -0,0 +1,109 @@ +# ___________________________________________________________________________ +# +# 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 + +logger = logging.getLogger(__name__) + +from pyomo.common.dependencies import attempt_import +from pyomo.common.errors import ApplicationError + +import pyomo.environ as pe +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..7b224e3089b --- /dev/null +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -0,0 +1,158 @@ +# ___________________________________________________________________________ +# +# 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 json +import pyomo.environ as pe +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] = pe.value(var) + + if objective is None: + objective = aos_utils.get_active_objective(model) + self.objective = (objective, pe.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/pyomo/contrib/alternative_solutions/tests/__init__.py b/pyomo/contrib/alternative_solutions/tests/__init__.py new file mode 100644 index 00000000000..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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..625104fa56a --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_aos_utils.py @@ -0,0 +1,300 @@ +# ___________________________________________________________________________ +# +# 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 import unittest + +from pyomo.common.dependencies import numpy as numpy, numpy_available + +import pyomo.environ as pe +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 = pe.ConcreteModel() + m.b1 = pe.Block() + m.b2 = pe.Block() + m.x = pe.Var() + m.y = pe.Var() + m.b1.o = pe.Objective(expr=m.x) + m.b2.o = pe.Objective([0, 1]) + m.b2.o[0] = pe.Objective(expr=m.y) + m.b2.o[1] = pe.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, pe.Block) + + def get_simple_model(self, sense=pe.minimize): + """Create a simple 2d linear program with an objective.""" + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.o = pe.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=pe.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=pe.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 = pe.ConcreteModel() + + m.b1 = pe.Block() + m.b2 = pe.Block() + m.b1.sb1 = pe.Block() + m.b2.sb2 = pe.Block() + + m.x = pe.Var(domain=pe.Reals) + m.b1.y = pe.Var(domain=pe.Binary) + m.b2.z = pe.Var(domain=pe.Integers) + + m.x_f = pe.Var(domain=pe.Reals) + m.b1.y_f = pe.Var(domain=pe.Binary) + m.b2.z_f = pe.Var(domain=pe.Integers) + m.x_f.fix(0) + m.b1.y_f.fix(0) + m.b2.z_f.fix(0) + + m.b1.sb1.x_l = pe.Var(indices, domain=pe.Reals) + m.b1.sb1.y_l = pe.Var(indices, domain=pe.Binary) + m.b2.sb2.z_l = pe.Var(indices, domain=pe.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 = pe.Constraint(expr=sum(v for v in vars_minus_x) <= 1) + m.b1.con = pe.Constraint(expr=m.b1.y <= 1) + m.b1.sb1.con = pe.Constraint(expr=m.b1.sb1.y_l[0] <= 1) + m.obj = pe.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..27c3c7b014d --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_balas.py @@ -0,0 +1,155 @@ +# ___________________________________________________________________________ +# +# 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 collections import Counter + +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 pe +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..2cac807ca7e --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_cases.py @@ -0,0 +1,438 @@ +# ___________________________________________________________________________ +# +# 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 itertools import product +from math import ceil, floor +from collections import Counter + +from pyomo.common.dependencies import numpy as np + +import pyomo.environ as pe + +""" +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 = pe.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 = pe.ConcreteModel() + m.x = pe.Var(within=pe.Integers if discrete_x else pe.Reals, bounds=(-10, 10)) + m.y = pe.Var(within=pe.Integers if discrete_y else pe.Reals, bounds=(-10, 10)) + + m.o = pe.Objective(expr=m.x + m.y, sense=pe.maximize) + + m.c1 = pe.Constraint(expr=-4 / 5 * m.x - 4 <= m.y) + m.c2 = pe.Constraint(expr=5 / 9 * m.x - 5 <= m.y) + m.c3 = pe.Constraint(expr=2 / 9 * m.x + 2 >= m.y) + m.c4 = pe.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 = pe.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 = pe.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 = pe.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 = pe.ConcreteModel() + m.x = pe.Var([0, 1, 2], within=pe.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 = pe.Constraint([i for i in range(5)], rule=_constraint_switch_rule) + + m.o = pe.Objective(expr=m.x[0] + m.x[2], sense=pe.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 = pe.ConcreteModel() + m.x = pe.Var(within=pe.Reals) + m.y = pe.Var(within=pe.Reals) + + m.o = pe.Objective(expr=m.y - m.x) + + m.c1 = pe.Constraint(expr=m.x <= 4) + m.c2 = pe.Constraint(expr=m.y >= 2) + + m.extreme_points = {(4, 2)} + + m.continuous_bounds = pe.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 = pe.ConcreteModel() + + m.x = pe.Var(within=pe.Reals, bounds=(-1, 3)) + m.y = pe.Var(within=pe.Reals, bounds=(-3, 2)) + + m.obj = pe.Objective(expr=m.x + 2 * m.y, sense=pe.maximize) + + m.con1 = pe.Constraint(expr=m.x + m.y <= 3) + m.con2 = pe.Constraint(expr=m.x + 2 * m.y <= 5) + m.con3 = pe.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 = pe.ConcreteModel() + m.x = pe.Var(within=pe.NonNegativeIntegers, bounds=(0, var_max)) + m.y = pe.Var(within=pe.NonNegativeIntegers, bounds=(0, var_max)) + + m.o = pe.Objective(expr=m.x + m.y, sense=pe.maximize) + m.c = pe.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 = pe.ConcreteModel() + m.x = pe.Var(within=pe.NonNegativeIntegers, bounds=(0, 5)) + m.y = pe.Var(within=pe.NonNegativeIntegers, bounds=(0, 5)) + m.z = pe.Var(within=pe.NonNegativeIntegers, bounds=(0, 5)) + + m.o = pe.Objective(expr=m.x + m.z) + + m.c1 = pe.Constraint(expr=m.x + m.y == 3) + m.c2 = pe.Constraint(expr=m.x + m.y + m.z <= 5) + m.c3 = pe.Constraint(expr=m.x + m.y + m.z >= 4) + + m.var_bounds = pe.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 = pe.ConcreteModel() + m.i = pe.RangeSet(0, num_vars - 1) + + if var_max == 1: + m.x = pe.Var(m.i, within=pe.Binary) + else: + m.x = pe.Var(m.i, within=pe.NonNegativeIntegers, bounds=(0, var_max)) + + m.o = pe.Objective(expr=sum(values[i] * m.x[i] for i in m.i), sense=pe.maximize) + + m.c = pe.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 = pe.ConcreteModel() + m.x = pe.Var(within=pe.Reals, bounds=(0, 2 * var_max)) + m.y = pe.Var(within=pe.Reals, bounds=(0, 2 * var_max)) + m.z = pe.Var(within=pe.NonNegativeReals, bounds=(0, 2 * var_max)) + m.o = pe.Objective(expr=m.z, sense=pe.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 = pe.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 = pe.ConcreteModel() + m.x = pe.Var(within=pe.Integers, bounds=(-var_max, var_max)) + m.y = pe.Var(within=pe.Integers, bounds=(-var_max, var_max)) + m.z = pe.Var(within=pe.NonNegativeReals, bounds=(0, var_max)) + m.o = pe.Objective(expr=m.z, sense=pe.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 = pe.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 = pe.ConcreteModel() + m.x = pe.Var([1, 2], within=pe.Integers, bounds=(-var_max, var_max)) + m.z = pe.Var(within=pe.NonNegativeReals, bounds=(0, var_max)) + m.o = pe.Objective(expr=m.z, sense=pe.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 = pe.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 = pe.ConcreteModel() + m.x = pe.Var(within=pe.Integers, bounds=(-2 * var_max, 2 * var_max)) + m.y = pe.Var(within=pe.Integers, bounds=(-2 * var_max, var_max)) + m.z = pe.Var(within=pe.NonNegativeReals, bounds=(0, 2 * var_max)) + m.var_bounds = pe.ComponentMap() + m.o = pe.Objective(expr=m.z, sense=pe.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 = pe.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..d761522b019 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py @@ -0,0 +1,107 @@ +# ___________________________________________________________________________ +# +# 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.dependencies import numpy as numpy, numpy_available + +import pyomo.environ as pe +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 = pe.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 = pe.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 = pe.minimize + n.x.domain = pe.Reals + n.y.domain = pe.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..ee9f4657acf --- /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-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.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 pe + +# 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 = pe.Reals + n.y.domain = pe.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..d2b180c9e3d --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_obbt.py @@ -0,0 +1,245 @@ +# ___________________________________________________________________________ +# +# 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 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 pe +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 = pe.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..da17e537914 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_shifted_lp.py @@ -0,0 +1,68 @@ +# ___________________________________________________________________________ +# +# 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.dependencies import numpy as numpy, numpy_available + +if numpy_available: + from numpy.testing import assert_array_almost_equal + +import pyomo.environ as pe +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 = pe.Reals + + opt = pe.SolverFactory(lp_solver) + old_results = opt.solve(m, tee=False) + old_obj = pe.value(m.o) + + new_model = shifted_lp.get_shifted_linear_model(m) + new_results = opt.solve(new_model, tee=False) + new_obj = pe.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 = pe.SolverFactory(lp_solver) + old_results = opt.solve(m, tee=False) + old_obj = pe.value(m.o) + + new_model = shifted_lp.get_shifted_linear_model(m) + new_results = opt.solve(new_model, tee=False) + new_obj = pe.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..7e601906a69 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -0,0 +1,144 @@ +# ___________________________________________________________________________ +# +# 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 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 +import pyomo.environ as pe + +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..a3ef042b5fe --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_solution.py @@ -0,0 +1,93 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.opt +import pyomo.environ as pe +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 = pe.ConcreteModel() + m.x = pe.Var(domain=pe.NonNegativeReals) + m.y = pe.Var(domain=pe.Binary) + m.z = pe.Var(domain=pe.NonNegativeIntegers) + m.f = pe.Var(domain=pe.Reals) + + m.f.fix(1) + m.obj = pe.Objective(expr=m.x + m.y + m.z + m.f, sense=pe.maximize) + + m.con_x = pe.Constraint(expr=m.x <= 1.5) + m.con_y = pe.Constraint(expr=m.y <= 1) + m.con_z = pe.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 = pe.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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/ampl_function_demo/__init__.py +++ b/pyomo/contrib/ampl_function_demo/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/ampl_function_demo/build.py b/pyomo/contrib/ampl_function_demo/build.py index cd35064ea4e..764a613b3d7 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-2022 +# 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 diff --git a/pyomo/contrib/ampl_function_demo/plugins.py b/pyomo/contrib/ampl_function_demo/plugins.py index 230d9c4b667..5a200174c43 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-2022 +# 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 diff --git a/pyomo/contrib/ampl_function_demo/src/CMakeLists.txt b/pyomo/contrib/ampl_function_demo/src/CMakeLists.txt index ce2c1a60f82..67efc13d3c8 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-2022 +# 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 diff --git a/pyomo/contrib/ampl_function_demo/src/FindASL.cmake b/pyomo/contrib/ampl_function_demo/src/FindASL.cmake index f413176f1cc..8bbc048fa6e 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-2022 +# 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 diff --git a/pyomo/contrib/ampl_function_demo/src/functions.c b/pyomo/contrib/ampl_function_demo/src/functions.c index f62148c995a..e87af745aea 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-2022 + * 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 diff --git a/pyomo/contrib/ampl_function_demo/tests/__init__.py b/pyomo/contrib/ampl_function_demo/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/ampl_function_demo/tests/__init__.py +++ b/pyomo/contrib/ampl_function_demo/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 af52c2def9f..39890494d55 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-2022 +# 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 diff --git a/pyomo/contrib/appsi/__init__.py b/pyomo/contrib/appsi/__init__.py index df3ba212448..2f06fc89e70 100644 --- a/pyomo/contrib/appsi/__init__.py +++ b/pyomo/contrib/appsi/__init__.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 . import base from . import solvers from . import writers diff --git a/pyomo/contrib/appsi/base.py b/pyomo/contrib/appsi/base.py index e6186eeedd2..b6b60ce2166 100644 --- a/pyomo/contrib/appsi/base.py +++ b/pyomo/contrib/appsi/base.py @@ -1,5 +1,20 @@ +# ___________________________________________________________________________ +# +# 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 +import os +import re +import weakref + from typing import ( Sequence, Dict, @@ -10,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, @@ -36,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 @@ -86,6 +101,9 @@ class TerminationCondition(enum.Enum): class SolverConfig(ConfigDict): """ + Common configuration options for all APPSI solver interfaces + + Attributes ---------- time_limit: float @@ -135,6 +153,8 @@ def __init__( class MIPSolverConfig(SolverConfig): """ + Configuration options common to all MIP solvers + Attributes ---------- mip_gap: float @@ -168,9 +188,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. @@ -186,8 +204,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. @@ -205,8 +223,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. @@ -224,8 +242,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. @@ -245,8 +263,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. @@ -292,8 +310,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 ' @@ -308,8 +326,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 ' @@ -325,8 +343,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 ' @@ -342,8 +360,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 ' @@ -361,6 +379,8 @@ def get_reduced_costs( class Results(object): """ + Base class for all APPSI solver results + Attributes ---------- termination_condition: TerminationCondition @@ -376,6 +396,8 @@ 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 @@ -418,6 +440,8 @@ def __str__(self): class UpdateConfig(ConfigDict): """ + Config options common to all persistent solvers + Attributes ---------- check_for_new_or_removed_constraints: bool @@ -585,7 +609,7 @@ def __init__( class Solver(abc.ABC): - class Availability(enum.IntEnum): + class Availability(IntEnum): NotFound = 0 BadVersion = -1 BadLicense = -2 @@ -610,20 +634,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 @@ -672,7 +696,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. """ @@ -697,9 +721,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. @@ -715,13 +737,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. @@ -741,8 +763,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 ---------- @@ -760,8 +782,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 ---------- @@ -788,43 +810,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 @@ -846,20 +868,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) @@ -943,10 +965,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( @@ -964,19 +986,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) @@ -984,7 +1006,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) @@ -993,14 +1015,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: @@ -1023,10 +1045,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( @@ -1043,10 +1065,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 @@ -1121,10 +1143,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: @@ -1143,10 +1165,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: @@ -1163,10 +1185,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) @@ -1187,10 +1209,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)] @@ -1235,10 +1257,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, @@ -1323,12 +1345,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) @@ -1356,40 +1378,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') @@ -1518,7 +1513,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, @@ -1654,7 +1649,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') @@ -1662,7 +1657,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 @@ -1685,7 +1680,7 @@ def decorator(cls): class LegacySolver(LegacySolverInterface, cls): pass - LegacySolverFactory.register(name, doc)(LegacySolver) + LegacySolverFactory.register('appsi_' + name, doc)(LegacySolver) return cls diff --git a/pyomo/contrib/appsi/build.py b/pyomo/contrib/appsi/build.py index 2c8d02dd3ac..38f8cb713ca 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-2022 +# 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 @@ -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 9c276b518de..cc2aec28241 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-2022 +# 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 diff --git a/pyomo/contrib/appsi/cmodel/src/cmodel_bindings.cpp b/pyomo/contrib/appsi/cmodel/src/cmodel_bindings.cpp index db9d3112069..5a838ffd786 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-2022 + * 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 @@ -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 255a0a3a70f..6f8002cb50e 100644 --- a/pyomo/contrib/appsi/cmodel/src/common.cpp +++ b/pyomo/contrib/appsi/cmodel/src/common.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "common.hpp" double inf; diff --git a/pyomo/contrib/appsi/cmodel/src/common.hpp b/pyomo/contrib/appsi/cmodel/src/common.hpp index 36afd549116..9edc9571a4d 100644 --- a/pyomo/contrib/appsi/cmodel/src/common.hpp +++ b/pyomo/contrib/appsi/cmodel/src/common.hpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include #include diff --git a/pyomo/contrib/appsi/cmodel/src/expression.cpp b/pyomo/contrib/appsi/cmodel/src/expression.cpp index 1923d3a1894..a49d6f2e499 100644 --- a/pyomo/contrib/appsi/cmodel/src/expression.cpp +++ b/pyomo/contrib/appsi/cmodel/src/expression.cpp @@ -1,1970 +1,1986 @@ -#include "expression.hpp" - -bool Leaf::is_leaf() { return true; } - -bool Var::is_variable_type() { return true; } - -bool Param::is_param_type() { return true; } - -bool Constant::is_constant_type() { return true; } - -bool Expression::is_expression_type() { return true; } - -double Leaf::evaluate() { return value; } - -double Var::get_lb() { - if (fixed) - return value; - else - return std::max(lb->evaluate(), domain_lb); -} - -double Var::get_ub() { - if (fixed) - return value; - else - return std::min(ub->evaluate(), domain_ub); -} - -Domain Var::get_domain() { return domain; } - -bool Operator::is_operator_type() { return true; } - -std::vector> Expression::get_operators() { - std::vector> res(n_operators); - for (unsigned int i = 0; i < n_operators; ++i) { - res[i] = operators[i]; - } - return res; -} - -double Leaf::get_value_from_array(double *val_array) { return value; } - -double Expression::get_value_from_array(double *val_array) { - return val_array[n_operators - 1]; -} - -double Operator::get_value_from_array(double *val_array) { - return val_array[index]; -} - -void MultiplyOperator::evaluate(double *values) { - values[index] = operand1->get_value_from_array(values) * - operand2->get_value_from_array(values); -} - -void ExternalOperator::evaluate(double *values) { - // It would be nice to implement this, but it will take some more work. - // This would require dynamic linking to the external function. - throw std::runtime_error("cannot evaluate ExternalOperator yet"); -} - -void LinearOperator::evaluate(double *values) { - values[index] = constant->evaluate(); - for (unsigned int i = 0; i < nterms; ++i) { - values[index] += coefficients[i]->evaluate() * variables[i]->evaluate(); - } -} - -void SumOperator::evaluate(double *values) { - values[index] = 0.0; - for (unsigned int i = 0; i < nargs; ++i) { - values[index] += operands[i]->get_value_from_array(values); - } -} - -void DivideOperator::evaluate(double *values) { - values[index] = operand1->get_value_from_array(values) / - operand2->get_value_from_array(values); -} - -void PowerOperator::evaluate(double *values) { - values[index] = std::pow(operand1->get_value_from_array(values), - operand2->get_value_from_array(values)); -} - -void NegationOperator::evaluate(double *values) { - values[index] = -operand->get_value_from_array(values); -} - -void ExpOperator::evaluate(double *values) { - values[index] = std::exp(operand->get_value_from_array(values)); -} - -void LogOperator::evaluate(double *values) { - values[index] = std::log(operand->get_value_from_array(values)); -} - -void AbsOperator::evaluate(double *values) { - values[index] = std::fabs(operand->get_value_from_array(values)); -} - -void SqrtOperator::evaluate(double *values) { - values[index] = std::pow(operand->get_value_from_array(values), 0.5); -} - -void Log10Operator::evaluate(double *values) { - values[index] = std::log10(operand->get_value_from_array(values)); -} - -void SinOperator::evaluate(double *values) { - values[index] = std::sin(operand->get_value_from_array(values)); -} - -void CosOperator::evaluate(double *values) { - values[index] = std::cos(operand->get_value_from_array(values)); -} - -void TanOperator::evaluate(double *values) { - values[index] = std::tan(operand->get_value_from_array(values)); -} - -void AsinOperator::evaluate(double *values) { - values[index] = std::asin(operand->get_value_from_array(values)); -} - -void AcosOperator::evaluate(double *values) { - values[index] = std::acos(operand->get_value_from_array(values)); -} - -void AtanOperator::evaluate(double *values) { - values[index] = std::atan(operand->get_value_from_array(values)); -} - -double Expression::evaluate() { - double *values = new double[n_operators]; - for (unsigned int i = 0; i < n_operators; ++i) { - operators[i]->index = i; - operators[i]->evaluate(values); - } - double res = get_value_from_array(values); - delete[] values; - return res; -} - -void UnaryOperator::identify_variables( - std::set> &var_set, - std::shared_ptr>> var_vec) { - if (operand->is_variable_type()) { - if (var_set.count(operand) == 0) { - var_vec->push_back(std::dynamic_pointer_cast(operand)); - var_set.insert(operand); - } - } -} - -void BinaryOperator::identify_variables( - std::set> &var_set, - std::shared_ptr>> var_vec) { - if (operand1->is_variable_type()) { - if (var_set.count(operand1) == 0) { - var_vec->push_back(std::dynamic_pointer_cast(operand1)); - var_set.insert(operand1); - } - } - if (operand2->is_variable_type()) { - if (var_set.count(operand2) == 0) { - var_vec->push_back(std::dynamic_pointer_cast(operand2)); - var_set.insert(operand2); - } - } -} - -void ExternalOperator::identify_variables( - std::set> &var_set, - std::shared_ptr>> var_vec) { - for (unsigned int i = 0; i < nargs; ++i) { - if (operands[i]->is_variable_type()) { - if (var_set.count(operands[i]) == 0) { - var_vec->push_back(std::dynamic_pointer_cast(operands[i])); - var_set.insert(operands[i]); - } - } - } -} - -void LinearOperator::identify_variables( - std::set> &var_set, - std::shared_ptr>> var_vec) { - for (unsigned int i = 0; i < nterms; ++i) { - if (var_set.count(variables[i]) == 0) { - var_vec->push_back(std::dynamic_pointer_cast(variables[i])); - var_set.insert(variables[i]); - } - } -} - -void SumOperator::identify_variables( - std::set> &var_set, - std::shared_ptr>> var_vec) { - for (unsigned int i = 0; i < nargs; ++i) { - if (operands[i]->is_variable_type()) { - if (var_set.count(operands[i]) == 0) { - var_vec->push_back(std::dynamic_pointer_cast(operands[i])); - var_set.insert(operands[i]); - } - } - } -} - -std::shared_ptr>> -Expression::identify_variables() { - std::set> var_set; - std::shared_ptr>> res = - std::make_shared>>(var_set.size()); - for (unsigned int i = 0; i < n_operators; ++i) { - operators[i]->identify_variables(var_set, res); - } - return res; -} - -std::shared_ptr>> Var::identify_variables() { - std::shared_ptr>> res = - std::make_shared>>(); - res->push_back(shared_from_this()); - return res; -} - -std::shared_ptr>> -Constant::identify_variables() { - std::shared_ptr>> res = - std::make_shared>>(); - return res; -} - -std::shared_ptr>> Param::identify_variables() { - std::shared_ptr>> res = - std::make_shared>>(); - return res; -} - -std::shared_ptr>> -Expression::identify_external_operators() { - std::set> external_set; - for (unsigned int i = 0; i < n_operators; ++i) { - if (operators[i]->is_external_operator()) { - external_set.insert(operators[i]); - } - } - std::shared_ptr>> res = - std::make_shared>>( - external_set.size()); - int ndx = 0; - for (std::shared_ptr n : external_set) { - (*res)[ndx] = std::dynamic_pointer_cast(n); - ndx += 1; - } - return res; -} - -std::shared_ptr>> -Var::identify_external_operators() { - std::shared_ptr>> res = - std::make_shared>>(); - return res; -} - -std::shared_ptr>> -Constant::identify_external_operators() { - std::shared_ptr>> res = - std::make_shared>>(); - return res; -} - -std::shared_ptr>> -Param::identify_external_operators() { - std::shared_ptr>> res = - std::make_shared>>(); - return res; -} - -int Var::get_degree_from_array(int *degree_array) { return 1; } - -int Param::get_degree_from_array(int *degree_array) { return 0; } - -int Constant::get_degree_from_array(int *degree_array) { return 0; } - -int Expression::get_degree_from_array(int *degree_array) { - return degree_array[n_operators - 1]; -} - -int Operator::get_degree_from_array(int *degree_array) { - return degree_array[index]; -} - -void LinearOperator::propagate_degree_forward(int *degrees, double *values) { - degrees[index] = 1; -} - -void SumOperator::propagate_degree_forward(int *degrees, double *values) { - int deg = 0; - int _deg; - for (unsigned int i = 0; i < nargs; ++i) { - _deg = operands[i]->get_degree_from_array(degrees); - if (_deg > deg) { - deg = _deg; - } - } - degrees[index] = deg; -} - -void MultiplyOperator::propagate_degree_forward(int *degrees, double *values) { - degrees[index] = operand1->get_degree_from_array(degrees) + - operand2->get_degree_from_array(degrees); -} - -void ExternalOperator::propagate_degree_forward(int *degrees, double *values) { - // External functions are always considered nonlinear - // Anything larger than 2 is nonlinear - degrees[index] = 3; -} - -void DivideOperator::propagate_degree_forward(int *degrees, double *values) { - // anything larger than 2 is nonlinear - degrees[index] = std::max(operand1->get_degree_from_array(degrees), - 3 * (operand2->get_degree_from_array(degrees))); -} - -void PowerOperator::propagate_degree_forward(int *degrees, double *values) { - if (operand2->get_degree_from_array(degrees) != 0) { - degrees[index] = 3; - } else { - double val2 = operand2->get_value_from_array(values); - double intpart; - if (std::modf(val2, &intpart) == 0.0) { - degrees[index] = operand1->get_degree_from_array(degrees) * (int)val2; - } else { - degrees[index] = 3; - } - } -} - -void NegationOperator::propagate_degree_forward(int *degrees, double *values) { - degrees[index] = operand->get_degree_from_array(degrees); -} - -void UnaryOperator::propagate_degree_forward(int *degrees, double *values) { - if (operand->get_degree_from_array(degrees) == 0) { - degrees[index] = 0; - } else { - degrees[index] = 3; - } -} - -std::string Var::__str__() { return name; } - -std::string Param::__str__() { return name; } - -std::string Constant::__str__() { return std::to_string(value); } - -std::string Expression::__str__() { - std::string *string_array = new std::string[n_operators]; - std::shared_ptr oper; - for (unsigned int i = 0; i < n_operators; ++i) { - oper = operators[i]; - oper->index = i; - oper->print(string_array); - } - std::string res = string_array[n_operators - 1]; - delete[] string_array; - return res; -} - -std::string Leaf::get_string_from_array(std::string *string_array) { - return __str__(); -} - -std::string Expression::get_string_from_array(std::string *string_array) { - return string_array[n_operators - 1]; -} - -std::string Operator::get_string_from_array(std::string *string_array) { - return string_array[index]; -} - -void MultiplyOperator::print(std::string *string_array) { - string_array[index] = - ("(" + operand1->get_string_from_array(string_array) + "*" + - operand2->get_string_from_array(string_array) + ")"); -} - -void ExternalOperator::print(std::string *string_array) { - std::string res = function_name + "("; - for (unsigned int i = 0; i < (nargs - 1); ++i) { - res += operands[i]->get_string_from_array(string_array); - res += ", "; - } - res += operands[nargs - 1]->get_string_from_array(string_array); - res += ")"; - string_array[index] = res; -} - -void DivideOperator::print(std::string *string_array) { - string_array[index] = - ("(" + operand1->get_string_from_array(string_array) + "/" + - operand2->get_string_from_array(string_array) + ")"); -} - -void PowerOperator::print(std::string *string_array) { - string_array[index] = - ("(" + operand1->get_string_from_array(string_array) + "**" + - operand2->get_string_from_array(string_array) + ")"); -} - -void NegationOperator::print(std::string *string_array) { - string_array[index] = - ("(-" + operand->get_string_from_array(string_array) + ")"); -} - -void ExpOperator::print(std::string *string_array) { - string_array[index] = - ("exp(" + operand->get_string_from_array(string_array) + ")"); -} - -void LogOperator::print(std::string *string_array) { - string_array[index] = - ("log(" + operand->get_string_from_array(string_array) + ")"); -} - -void AbsOperator::print(std::string *string_array) { - string_array[index] = - ("abs(" + operand->get_string_from_array(string_array) + ")"); -} - -void SqrtOperator::print(std::string *string_array) { - string_array[index] = - ("sqrt(" + operand->get_string_from_array(string_array) + ")"); -} - -void Log10Operator::print(std::string *string_array) { - string_array[index] = - ("log10(" + operand->get_string_from_array(string_array) + ")"); -} - -void SinOperator::print(std::string *string_array) { - string_array[index] = - ("sin(" + operand->get_string_from_array(string_array) + ")"); -} - -void CosOperator::print(std::string *string_array) { - string_array[index] = - ("cos(" + operand->get_string_from_array(string_array) + ")"); -} - -void TanOperator::print(std::string *string_array) { - string_array[index] = - ("tan(" + operand->get_string_from_array(string_array) + ")"); -} - -void AsinOperator::print(std::string *string_array) { - string_array[index] = - ("asin(" + operand->get_string_from_array(string_array) + ")"); -} - -void AcosOperator::print(std::string *string_array) { - string_array[index] = - ("acos(" + operand->get_string_from_array(string_array) + ")"); -} - -void AtanOperator::print(std::string *string_array) { - string_array[index] = - ("atan(" + operand->get_string_from_array(string_array) + ")"); -} - -void LinearOperator::print(std::string *string_array) { - std::string res = "(" + constant->__str__(); - for (unsigned int i = 0; i < nterms; ++i) { - res += " + " + coefficients[i]->__str__() + "*" + variables[i]->__str__(); - } - res += ")"; - string_array[index] = res; -} - -void SumOperator::print(std::string *string_array) { - std::string res = "(" + operands[0]->get_string_from_array(string_array); - for (unsigned int i = 1; i < nargs; ++i) { - res += " + " + operands[i]->get_string_from_array(string_array); - } - res += ")"; - string_array[index] = res; -} - -std::shared_ptr>> -Leaf::get_prefix_notation() { - std::shared_ptr>> res = - std::make_shared>>(); - res->push_back(shared_from_this()); - return res; -} - -std::shared_ptr>> -Expression::get_prefix_notation() { - std::shared_ptr>> res = - std::make_shared>>(); - std::shared_ptr>> stack = - std::make_shared>>(); - std::shared_ptr node; - stack->push_back(operators[n_operators - 1]); - while (stack->size() > 0) { - node = stack->back(); - stack->pop_back(); - res->push_back(node); - node->fill_prefix_notation_stack(stack); - } - - return res; -} - -void BinaryOperator::fill_prefix_notation_stack( - std::shared_ptr>> stack) { - stack->push_back(operand2); - stack->push_back(operand1); -} - -void UnaryOperator::fill_prefix_notation_stack( - std::shared_ptr>> stack) { - stack->push_back(operand); -} - -void SumOperator::fill_prefix_notation_stack( - std::shared_ptr>> stack) { - int ndx = nargs - 1; - while (ndx >= 0) { - stack->push_back(operands[ndx]); - ndx -= 1; - } -} - -void LinearOperator::fill_prefix_notation_stack( - std::shared_ptr>> stack) { - ; // This is treated as a leaf in this context; write_nl_string will take care - // of it -} - -void ExternalOperator::fill_prefix_notation_stack( - std::shared_ptr>> stack) { - int i = nargs - 1; - while (i >= 0) { - stack->push_back(operands[i]); - i -= 1; - } -} - -void Var::write_nl_string(std::ofstream &f) { f << "v" << index << "\n"; } - -void Param::write_nl_string(std::ofstream &f) { f << "n" << value << "\n"; } - -void Constant::write_nl_string(std::ofstream &f) { f << "n" << value << "\n"; } - -void Expression::write_nl_string(std::ofstream &f) { - std::shared_ptr>> prefix_notation = - get_prefix_notation(); - for (std::shared_ptr &node : *(prefix_notation)) { - node->write_nl_string(f); - } -} - -void MultiplyOperator::write_nl_string(std::ofstream &f) { f << "o2\n"; } - -void ExternalOperator::write_nl_string(std::ofstream &f) { - f << "f" << external_function_index << " " << nargs << "\n"; -} - -void SumOperator::write_nl_string(std::ofstream &f) { - if (nargs == 2) { - f << "o0\n"; - } else { - f << "o54\n"; - f << nargs << "\n"; - } -} - -void LinearOperator::write_nl_string(std::ofstream &f) { - bool has_const = - (!constant->is_constant_type()) || (constant->evaluate() != 0); - unsigned int n_sum_args = nterms + (has_const ? 1 : 0); - if (n_sum_args == 2) { - f << "o0\n"; - } else { - f << "o54\n"; - f << n_sum_args << "\n"; - } - if (has_const) - f << "n" << constant->evaluate() << "\n"; - for (unsigned int ndx = 0; ndx < nterms; ++ndx) { - f << "o2\n"; - f << "n" << coefficients[ndx]->evaluate() << "\n"; - variables[ndx]->write_nl_string(f); - } -} - -void DivideOperator::write_nl_string(std::ofstream &f) { f << "o3\n"; } - -void PowerOperator::write_nl_string(std::ofstream &f) { f << "o5\n"; } - -void NegationOperator::write_nl_string(std::ofstream &f) { f << "o16\n"; } - -void ExpOperator::write_nl_string(std::ofstream &f) { f << "o44\n"; } - -void LogOperator::write_nl_string(std::ofstream &f) { f << "o43\n"; } - -void AbsOperator::write_nl_string(std::ofstream &f) { f << "o15\n"; } - -void SqrtOperator::write_nl_string(std::ofstream &f) { f << "o39\n"; } - -void Log10Operator::write_nl_string(std::ofstream &f) { f << "o42\n"; } - -void SinOperator::write_nl_string(std::ofstream &f) { f << "o41\n"; } - -void CosOperator::write_nl_string(std::ofstream &f) { f << "o46\n"; } - -void TanOperator::write_nl_string(std::ofstream &f) { f << "o38\n"; } - -void AsinOperator::write_nl_string(std::ofstream &f) { f << "o51\n"; } - -void AcosOperator::write_nl_string(std::ofstream &f) { f << "o53\n"; } - -void AtanOperator::write_nl_string(std::ofstream &f) { f << "o49\n"; } - -bool BinaryOperator::is_binary_operator() { return true; } - -bool UnaryOperator::is_unary_operator() { return true; } - -bool LinearOperator::is_linear_operator() { return true; } - -bool SumOperator::is_sum_operator() { return true; } - -bool MultiplyOperator::is_multiply_operator() { return true; } - -bool DivideOperator::is_divide_operator() { return true; } - -bool PowerOperator::is_power_operator() { return true; } - -bool NegationOperator::is_negation_operator() { return true; } - -bool ExpOperator::is_exp_operator() { return true; } - -bool LogOperator::is_log_operator() { return true; } - -bool AbsOperator::is_abs_operator() { return true; } - -bool SqrtOperator::is_sqrt_operator() { return true; } - -bool ExternalOperator::is_external_operator() { return true; } - -void Leaf::fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) { - ; -} - -void Expression::fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) { - throw std::runtime_error("This should not happen"); -} - -void BinaryOperator::fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) { - oper_ndx -= 1; - oper_array[oper_ndx] = shared_from_this(); - // The order does not actually matter here. It - // will just be easier to debug this way. - operand2->fill_expression(oper_array, oper_ndx); - operand1->fill_expression(oper_array, oper_ndx); -} - -void UnaryOperator::fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) { - oper_ndx -= 1; - oper_array[oper_ndx] = shared_from_this(); - operand->fill_expression(oper_array, oper_ndx); -} - -void LinearOperator::fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) { - oper_ndx -= 1; - oper_array[oper_ndx] = shared_from_this(); -} - -void SumOperator::fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) { - oper_ndx -= 1; - oper_array[oper_ndx] = shared_from_this(); - // The order does not actually matter here. It - // will just be easier to debug this way. - int arg_ndx = nargs - 1; - while (arg_ndx >= 0) { - operands[arg_ndx]->fill_expression(oper_array, oper_ndx); - arg_ndx -= 1; - } -} - -void ExternalOperator::fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) { - oper_ndx -= 1; - oper_array[oper_ndx] = shared_from_this(); - // The order does not actually matter here. It - // will just be easier to debug this way. - int arg_ndx = nargs - 1; - while (arg_ndx >= 0) { - operands[arg_ndx]->fill_expression(oper_array, oper_ndx); - arg_ndx -= 1; - } -} - -double Leaf::get_lb_from_array(double *lbs) { return value; } - -double Leaf::get_ub_from_array(double *ubs) { return value; } - -double Var::get_lb_from_array(double *lbs) { return get_lb(); } - -double Var::get_ub_from_array(double *ubs) { return get_ub(); } - -double Expression::get_lb_from_array(double *lbs) { - return lbs[n_operators - 1]; -} - -double Expression::get_ub_from_array(double *ubs) { - return ubs[n_operators - 1]; -} - -double Operator::get_lb_from_array(double *lbs) { return lbs[index]; } - -double Operator::get_ub_from_array(double *ubs) { return ubs[index]; } - -void Leaf::set_bounds_in_array(double new_lb, double new_ub, double *lbs, - double *ubs, double feasibility_tol, - double integer_tol, double improvement_tol, - std::set> &improved_vars) { - if (new_lb < value - feasibility_tol || new_lb > value + feasibility_tol) { - throw InfeasibleConstraintException( - "Infeasible constraint; bounds computed on parameter or constant " - "disagree with the value of the parameter or constant\n value: " + - std::to_string(value) + "\n computed LB: " + std::to_string(new_lb) + - "\n computed UB: " + std::to_string(new_ub)); - } - - if (new_ub < value - feasibility_tol || new_ub > value + feasibility_tol) { - throw InfeasibleConstraintException( - "Infeasible constraint; bounds computed on parameter or constant " - "disagree with the value of the parameter or constant\n value: " + - std::to_string(value) + "\n computed LB: " + std::to_string(new_lb) + - "\n computed UB: " + std::to_string(new_ub)); - } -} - -void Var::set_bounds_in_array(double new_lb, double new_ub, double *lbs, - double *ubs, double feasibility_tol, - double integer_tol, double improvement_tol, - std::set> &improved_vars) { - if (new_lb > new_ub) { - if (new_lb - feasibility_tol > new_ub) - throw InfeasibleConstraintException( - "Infeasible constraint; The computed lower bound for a variable is " - "larger than the computed upper bound.\n computed LB: " + - std::to_string(new_lb) + - "\n computed UB: " + std::to_string(new_ub)); - else { - new_lb -= feasibility_tol; - new_ub += feasibility_tol; - } - } - if (new_lb >= inf) - throw InfeasibleConstraintException( - "Infeasible constraint; The compute lower bound for " + name + - " is inf"); - if (new_ub <= -inf) - throw InfeasibleConstraintException( - "Infeasible constraint; The computed upper bound for " + name + - " is -inf"); - - if (domain == integers || domain == binary) { - if (new_lb > -inf) { - double lb_floor = floor(new_lb); - double lb_ceil = ceil(new_lb - integer_tol); - if (lb_floor > lb_ceil) - new_lb = lb_floor; - else - new_lb = lb_ceil; - } - if (new_ub < inf) { - double ub_ceil = ceil(new_ub); - double ub_floor = floor(new_ub + integer_tol); - if (ub_ceil < ub_floor) - new_ub = ub_ceil; - else - new_ub = ub_floor; - } - } - - double current_lb = get_lb(); - double current_ub = get_ub(); - - if (new_lb > current_lb + improvement_tol || - new_ub < current_ub - improvement_tol) - improved_vars.insert(shared_from_this()); - - if (new_lb > current_lb) { - if (lb->is_leaf()) - std::dynamic_pointer_cast(lb)->value = new_lb; - else - throw py::value_error( - "variable bounds cannot be expressions when performing FBBT"); - } - - if (new_ub < current_ub) { - if (ub->is_leaf()) - std::dynamic_pointer_cast(ub)->value = new_ub; - else - throw py::value_error( - "variable bounds cannot be expressions when performing FBBT"); - } -} - -void Expression::set_bounds_in_array( - double new_lb, double new_ub, double *lbs, double *ubs, - double feasibility_tol, double integer_tol, double improvement_tol, - std::set> &improved_vars) { - lbs[n_operators - 1] = new_lb; - ubs[n_operators - 1] = new_ub; -} - -void Operator::set_bounds_in_array( - double new_lb, double new_ub, double *lbs, double *ubs, - double feasibility_tol, double integer_tol, double improvement_tol, - std::set> &improved_vars) { - lbs[index] = new_lb; - ubs[index] = new_ub; -} - -void Expression::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - for (unsigned int ndx = 0; ndx < n_operators; ++ndx) { - operators[ndx]->index = ndx; - operators[ndx]->propagate_bounds_forward(lbs, ubs, feasibility_tol, - integer_tol); - } -} - -void Expression::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - int ndx = n_operators - 1; - while (ndx >= 0) { - operators[ndx]->propagate_bounds_backward( - lbs, ubs, feasibility_tol, integer_tol, improvement_tol, improved_vars); - ndx -= 1; - } -} - -void Operator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - lbs[index] = -inf; - ubs[index] = inf; -} - -void Operator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - ; -} - -void MultiplyOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - if (operand1 == operand2) { - interval_power(operand1->get_lb_from_array(lbs), - operand1->get_ub_from_array(ubs), 2, 2, &lbs[index], - &ubs[index], feasibility_tol); - } else { - interval_mul(operand1->get_lb_from_array(lbs), - operand1->get_ub_from_array(ubs), - operand2->get_lb_from_array(lbs), - operand2->get_ub_from_array(ubs), &lbs[index], &ubs[index]); - } -} - -void MultiplyOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand1->get_lb_from_array(lbs); - double xu = operand1->get_ub_from_array(ubs); - double yl = operand2->get_lb_from_array(lbs); - double yu = operand2->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu, new_yl, new_yu; - - if (operand1 == operand2) { - _inverse_power1(lb, ub, 2, 2, xl, xu, &new_xl, &new_xu, feasibility_tol); - new_yl = new_xl; - new_yu = new_xu; - } else { - interval_div(lb, ub, yl, yu, &new_xl, &new_xu, feasibility_tol); - interval_div(lb, ub, xl, xu, &new_yl, &new_yu, feasibility_tol); - } - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand1->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); - - if (new_yl > yl) - yl = new_yl; - if (new_yu < yu) - yu = new_yu; - operand2->set_bounds_in_array(yl, yu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void SumOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - double lb = operands[0]->get_lb_from_array(lbs); - double ub = operands[0]->get_ub_from_array(ubs); - double tmp_lb; - double tmp_ub; - - for (unsigned int ndx = 1; ndx < nargs; ++ndx) { - interval_add(lb, ub, operands[ndx]->get_lb_from_array(lbs), - operands[ndx]->get_ub_from_array(ubs), &tmp_lb, &tmp_ub); - lb = tmp_lb; - ub = tmp_ub; - } - - lbs[index] = lb; - ubs[index] = ub; -} - -void SumOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double *accumulated_lbs = new double[nargs]; - double *accumulated_ubs = new double[nargs]; - - accumulated_lbs[0] = operands[0]->get_lb_from_array(lbs); - accumulated_ubs[0] = operands[0]->get_ub_from_array(ubs); - for (unsigned int ndx = 1; ndx < nargs; ++ndx) { - interval_add(accumulated_lbs[ndx - 1], accumulated_ubs[ndx - 1], - operands[ndx]->get_lb_from_array(lbs), - operands[ndx]->get_ub_from_array(ubs), &accumulated_lbs[ndx], - &accumulated_ubs[ndx]); - } - - double new_sum_lb = get_lb_from_array(lbs); - double new_sum_ub = get_ub_from_array(ubs); - - if (new_sum_lb > accumulated_lbs[nargs - 1]) - accumulated_lbs[nargs - 1] = new_sum_lb; - if (new_sum_ub < accumulated_ubs[nargs - 1]) - accumulated_ubs[nargs - 1] = new_sum_ub; - - double lb0, ub0, lb1, ub1, lb2, ub2, _lb1, _ub1, _lb2, _ub2; - - int ndx = nargs - 1; - while (ndx >= 1) { - lb0 = accumulated_lbs[ndx]; - ub0 = accumulated_ubs[ndx]; - lb1 = accumulated_lbs[ndx - 1]; - ub1 = accumulated_ubs[ndx - 1]; - lb2 = operands[ndx]->get_lb_from_array(lbs); - ub2 = operands[ndx]->get_ub_from_array(ubs); - interval_sub(lb0, ub0, lb2, ub2, &_lb1, &_ub1); - interval_sub(lb0, ub0, lb1, ub1, &_lb2, &_ub2); - if (_lb1 > lb1) - lb1 = _lb1; - if (_ub1 < ub1) - ub1 = _ub1; - if (_lb2 > lb2) - lb2 = _lb2; - if (_ub2 < ub2) - ub2 = _ub2; - accumulated_lbs[ndx - 1] = lb1; - accumulated_ubs[ndx - 1] = ub1; - operands[ndx]->set_bounds_in_array(lb2, ub2, lbs, ubs, feasibility_tol, - integer_tol, improvement_tol, - improved_vars); - ndx -= 1; - } - - // take care of ndx = 0 - lb1 = operands[0]->get_lb_from_array(lbs); - ub1 = operands[0]->get_ub_from_array(ubs); - _lb1 = accumulated_lbs[0]; - _ub1 = accumulated_ubs[0]; - if (_lb1 > lb1) - lb1 = _lb1; - if (_ub1 < ub1) - ub1 = _ub1; - operands[0]->set_bounds_in_array(lb1, ub1, lbs, ubs, feasibility_tol, - integer_tol, improvement_tol, improved_vars); - - delete[] accumulated_lbs; - delete[] accumulated_ubs; -} - -void LinearOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - double lb = constant->evaluate(); - double ub = lb; - double tmp_lb; - double tmp_ub; - double coef; - - for (unsigned int ndx = 0; ndx < nterms; ++ndx) { - coef = coefficients[ndx]->evaluate(); - interval_mul(coef, coef, variables[ndx]->get_lb(), variables[ndx]->get_ub(), - &tmp_lb, &tmp_ub); - interval_add(lb, ub, tmp_lb, tmp_ub, &lb, &ub); - } - - lbs[index] = lb; - ubs[index] = ub; -} - -void LinearOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double *accumulated_lbs = new double[nterms + 1]; - double *accumulated_ubs = new double[nterms + 1]; - - double coef; - - accumulated_lbs[0] = constant->evaluate(); - accumulated_ubs[0] = constant->evaluate(); - for (unsigned int ndx = 0; ndx < nterms; ++ndx) { - coef = coefficients[ndx]->evaluate(); - interval_mul(coef, coef, variables[ndx]->get_lb(), variables[ndx]->get_ub(), - &accumulated_lbs[ndx + 1], &accumulated_ubs[ndx + 1]); - interval_add(accumulated_lbs[ndx], accumulated_ubs[ndx], - accumulated_lbs[ndx + 1], accumulated_ubs[ndx + 1], - &accumulated_lbs[ndx + 1], &accumulated_ubs[ndx + 1]); - } - - double new_sum_lb = get_lb_from_array(lbs); - double new_sum_ub = get_ub_from_array(ubs); - - if (new_sum_lb > accumulated_lbs[nterms]) - accumulated_lbs[nterms] = new_sum_lb; - if (new_sum_ub < accumulated_ubs[nterms]) - accumulated_ubs[nterms] = new_sum_ub; - - double lb0, ub0, lb1, ub1, lb2, ub2, _lb1, _ub1, _lb2, _ub2, new_v_lb, - new_v_ub; - - int ndx = nterms - 1; - while (ndx >= 0) { - lb0 = accumulated_lbs[ndx + 1]; - ub0 = accumulated_ubs[ndx + 1]; - lb1 = accumulated_lbs[ndx]; - ub1 = accumulated_ubs[ndx]; - coef = coefficients[ndx]->evaluate(); - interval_mul(coef, coef, variables[ndx]->get_lb(), variables[ndx]->get_ub(), - &lb2, &ub2); - interval_sub(lb0, ub0, lb2, ub2, &_lb1, &_ub1); - interval_sub(lb0, ub0, lb1, ub1, &_lb2, &_ub2); - if (_lb1 > lb1) - lb1 = _lb1; - if (_ub1 < ub1) - ub1 = _ub1; - if (_lb2 > lb2) - lb2 = _lb2; - if (_ub2 < ub2) - ub2 = _ub2; - accumulated_lbs[ndx] = lb1; - accumulated_ubs[ndx] = ub1; - interval_div(lb2, ub2, coef, coef, &new_v_lb, &new_v_ub, feasibility_tol); - variables[ndx]->set_bounds_in_array(new_v_lb, new_v_ub, lbs, ubs, - feasibility_tol, integer_tol, - improvement_tol, improved_vars); - ndx -= 1; - } - - delete[] accumulated_lbs; - delete[] accumulated_ubs; -} - -void DivideOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_div( - operand1->get_lb_from_array(lbs), operand1->get_ub_from_array(ubs), - operand2->get_lb_from_array(lbs), operand2->get_ub_from_array(ubs), - &lbs[index], &ubs[index], feasibility_tol); -} - -void DivideOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand1->get_lb_from_array(lbs); - double xu = operand1->get_ub_from_array(ubs); - double yl = operand2->get_lb_from_array(lbs); - double yu = operand2->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl; - double new_xu; - double new_yl; - double new_yu; - - interval_mul(lb, ub, yl, yu, &new_xl, &new_xu); - interval_div(xl, xu, lb, ub, &new_yl, &new_yu, feasibility_tol); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand1->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); - - if (new_yl > yl) - yl = new_yl; - if (new_yu < yu) - yu = new_yu; - operand2->set_bounds_in_array(yl, yu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void NegationOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_sub(0, 0, operand->get_lb_from_array(lbs), - operand->get_ub_from_array(ubs), &lbs[index], &ubs[index]); -} - -void NegationOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl; - double new_xu; - - interval_sub(0, 0, lb, ub, &new_xl, &new_xu); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void PowerOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_power( - operand1->get_lb_from_array(lbs), operand1->get_ub_from_array(ubs), - operand2->get_lb_from_array(lbs), operand2->get_ub_from_array(ubs), - &lbs[index], &ubs[index], feasibility_tol); -} - -void PowerOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand1->get_lb_from_array(lbs); - double xu = operand1->get_ub_from_array(ubs); - double yl = operand2->get_lb_from_array(lbs); - double yu = operand2->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu, new_yl, new_yu; - _inverse_power1(lb, ub, yl, yu, xl, xu, &new_xl, &new_xu, feasibility_tol); - if (yl != yu) - _inverse_power2(lb, ub, xl, xu, &new_yl, &new_yu, feasibility_tol); - else { - new_yl = yl; - new_yu = yu; - } - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand1->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); - - if (new_yl > yl) - yl = new_yl; - if (new_yu < yu) - yu = new_yu; - operand2->set_bounds_in_array(yl, yu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void SqrtOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_power(operand->get_lb_from_array(lbs), - operand->get_ub_from_array(ubs), 0.5, 0.5, &lbs[index], - &ubs[index], feasibility_tol); -} - -void SqrtOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double yl = 0.5; - double yu = 0.5; - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - _inverse_power1(lb, ub, yl, yu, xl, xu, &new_xl, &new_xu, feasibility_tol); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void ExpOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_exp(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), - &lbs[index], &ubs[index]); -} - -void ExpOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_log(lb, ub, &new_xl, &new_xu); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void LogOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_log(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), - &lbs[index], &ubs[index]); -} - -void LogOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_exp(lb, ub, &new_xl, &new_xu); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void AbsOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_abs(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), - &lbs[index], &ubs[index]); -} - -void AbsOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - _inverse_abs(lb, ub, &new_xl, &new_xu); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void Log10Operator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_log10(operand->get_lb_from_array(lbs), - operand->get_ub_from_array(ubs), &lbs[index], &ubs[index]); -} - -void Log10Operator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_power(10, 10, lb, ub, &new_xl, &new_xu, feasibility_tol); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void SinOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_sin(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), - &lbs[index], &ubs[index]); -} - -void SinOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_asin(lb, ub, xl, xu, &new_xl, &new_xu, feasibility_tol); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void CosOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_cos(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), - &lbs[index], &ubs[index]); -} - -void CosOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_acos(lb, ub, xl, xu, &new_xl, &new_xu, feasibility_tol); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void TanOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_tan(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), - &lbs[index], &ubs[index]); -} - -void TanOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_atan(lb, ub, xl, xu, &new_xl, &new_xu); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void AsinOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_asin(operand->get_lb_from_array(lbs), - operand->get_ub_from_array(ubs), -inf, inf, &lbs[index], - &ubs[index], feasibility_tol); -} - -void AsinOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_sin(lb, ub, &new_xl, &new_xu); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void AcosOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_acos(operand->get_lb_from_array(lbs), - operand->get_ub_from_array(ubs), -inf, inf, &lbs[index], - &ubs[index], feasibility_tol); -} - -void AcosOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_cos(lb, ub, &new_xl, &new_xu); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -void AtanOperator::propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) { - interval_atan(operand->get_lb_from_array(lbs), - operand->get_ub_from_array(ubs), -inf, inf, &lbs[index], - &ubs[index]); -} - -void AtanOperator::propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, std::set> &improved_vars) { - double xl = operand->get_lb_from_array(lbs); - double xu = operand->get_ub_from_array(ubs); - double lb = get_lb_from_array(lbs); - double ub = get_ub_from_array(ubs); - - double new_xl, new_xu; - interval_tan(lb, ub, &new_xl, &new_xu); - - if (new_xl > xl) - xl = new_xl; - if (new_xu < xu) - xu = new_xu; - operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, - improvement_tol, improved_vars); -} - -std::vector> create_vars(int n_vars) { - std::vector> res; - for (int i = 0; i < n_vars; ++i) { - res.push_back(std::make_shared()); - } - return res; -} - -std::vector> create_params(int n_params) { - std::vector> res; - for (int i = 0; i < n_params; ++i) { - res.push_back(std::make_shared()); - } - return res; -} - -std::vector> create_constants(int n_constants) { - std::vector> res; - for (int i = 0; i < n_constants; ++i) { - res.push_back(std::make_shared()); - } - return res; -} - -std::shared_ptr -appsi_operator_from_pyomo_expr(py::handle expr, py::handle var_map, - py::handle param_map, - PyomoExprTypes &expr_types) { - std::shared_ptr res; - ExprType tmp_type = - expr_types.expr_type_map[py::type::of(expr)].cast(); - - switch (tmp_type) { - case py_float: { - res = std::make_shared(expr.cast()); - break; - } - case var: { - res = var_map[expr_types.id(expr)].cast>(); - break; - } - case param: { - res = param_map[expr_types.id(expr)].cast>(); - break; - } - case product: { - res = std::make_shared(); - break; - } - case sum: { - res = std::make_shared(expr.attr("nargs")().cast()); - break; - } - case negation: { - res = std::make_shared(); - break; - } - case external_func: { - res = std::make_shared(expr.attr("nargs")().cast()); - std::shared_ptr oper = - std::dynamic_pointer_cast(res); - oper->function_name = - expr.attr("_fcn").attr("_function").cast(); - break; - } - case power: { - res = std::make_shared(); - break; - } - case division: { - res = std::make_shared(); - break; - } - case unary_func: { - std::string function_name = expr.attr("getname")().cast(); - if (function_name == "exp") - res = std::make_shared(); - else if (function_name == "log") - res = std::make_shared(); - else if (function_name == "log10") - res = std::make_shared(); - else if (function_name == "sin") - res = std::make_shared(); - else if (function_name == "cos") - res = std::make_shared(); - else if (function_name == "tan") - res = std::make_shared(); - else if (function_name == "asin") - res = std::make_shared(); - else if (function_name == "acos") - res = std::make_shared(); - else if (function_name == "atan") - res = std::make_shared(); - else if (function_name == "sqrt") - res = std::make_shared(); - else - throw py::value_error("Unrecognized expression type: " + function_name); - break; - } - case linear: { - res = std::make_shared( - expr_types.len(expr.attr("linear_vars")).cast()); - break; - } - case named_expr: { - res = appsi_operator_from_pyomo_expr(expr.attr("expr"), var_map, param_map, - expr_types); - break; - } - case numeric_constant: { - res = std::make_shared(expr.attr("value").cast()); - break; - } - case pyomo_unit: { - res = std::make_shared(1.0); - break; - } - case unary_abs: { - res = std::make_shared(); - break; - } - default: { - throw py::value_error("Unrecognized expression type: " + - expr_types.builtins.attr("str")(py::type::of(expr)) - .cast()); - break; - } - } - return res; -} - -void prep_for_repn_helper(py::handle expr, py::handle named_exprs, - py::handle variables, py::handle fixed_vars, - py::handle external_funcs, - PyomoExprTypes &expr_types) { - ExprType tmp_type = - expr_types.expr_type_map[py::type::of(expr)].cast(); - - switch (tmp_type) { - case py_float: { - break; - } - case var: { - variables[expr_types.id(expr)] = expr; - if (expr.attr("fixed").cast()) { - fixed_vars[expr_types.id(expr)] = expr; - } - break; - } - case param: { - break; - } - case product: { - py::tuple args = expr.attr("_args_"); - for (py::handle arg : args) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - break; - } - case sum: { - py::tuple args = expr.attr("args"); - for (py::handle arg : args) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - break; - } - case negation: { - py::tuple args = expr.attr("_args_"); - for (py::handle arg : args) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - break; - } - case external_func: { - external_funcs[expr_types.id(expr)] = expr; - py::tuple args = expr.attr("args"); - for (py::handle arg : args) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - break; - } - case power: { - py::tuple args = expr.attr("_args_"); - for (py::handle arg : args) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - break; - } - case division: { - py::tuple args = expr.attr("_args_"); - for (py::handle arg : args) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - break; - } - case unary_func: { - py::tuple args = expr.attr("_args_"); - for (py::handle arg : args) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - break; - } - case linear: { - py::list linear_vars = expr.attr("linear_vars"); - py::list linear_coefs = expr.attr("linear_coefs"); - for (py::handle arg : linear_vars) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - for (py::handle arg : linear_coefs) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - prep_for_repn_helper(expr.attr("constant"), named_exprs, variables, - fixed_vars, external_funcs, expr_types); - break; - } - case named_expr: { - named_exprs[expr_types.id(expr)] = expr; - prep_for_repn_helper(expr.attr("expr"), named_exprs, variables, fixed_vars, - external_funcs, expr_types); - break; - } - case numeric_constant: { - break; - } - case pyomo_unit: { - break; - } - case unary_abs: { - py::tuple args = expr.attr("_args_"); - for (py::handle arg : args) { - prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, - external_funcs, expr_types); - } - break; - } - default: { - if (expr_types.builtins.attr("hasattr")(expr, "is_constant").cast()) { - if (expr.attr("is_constant")().cast()) - break; - } - throw py::value_error("Unrecognized expression type: " + - expr_types.builtins.attr("str")(py::type::of(expr)) - .cast()); - break; - } - } -} - -py::tuple prep_for_repn(py::handle expr, PyomoExprTypes &expr_types) { - py::dict named_exprs; - py::dict variables; - py::dict fixed_vars; - py::dict external_funcs; - - prep_for_repn_helper(expr, named_exprs, variables, fixed_vars, external_funcs, - expr_types); - - py::list named_expr_list = named_exprs.attr("values")(); - py::list variable_list = variables.attr("values")(); - py::list fixed_var_list = fixed_vars.attr("values")(); - py::list external_func_list = external_funcs.attr("values")(); - - py::tuple res = py::make_tuple(named_expr_list, variable_list, fixed_var_list, - external_func_list); - return res; -} - -int build_expression_tree(py::handle pyomo_expr, - std::shared_ptr appsi_expr, py::handle var_map, - py::handle param_map, PyomoExprTypes &expr_types) { - int num_nodes = 0; - - if (expr_types.expr_type_map[py::type::of(pyomo_expr)].cast() == - named_expr) - pyomo_expr = pyomo_expr.attr("expr"); - - if (appsi_expr->is_leaf()) { - ; - } else if (appsi_expr->is_binary_operator()) { - num_nodes += 1; - std::shared_ptr oper = - std::dynamic_pointer_cast(appsi_expr); - py::list pyomo_args = pyomo_expr.attr("args"); - oper->operand1 = appsi_operator_from_pyomo_expr(pyomo_args[0], var_map, - param_map, expr_types); - oper->operand2 = appsi_operator_from_pyomo_expr(pyomo_args[1], var_map, - param_map, expr_types); - num_nodes += build_expression_tree(pyomo_args[0], oper->operand1, var_map, - param_map, expr_types); - num_nodes += build_expression_tree(pyomo_args[1], oper->operand2, var_map, - param_map, expr_types); - } else if (appsi_expr->is_unary_operator()) { - num_nodes += 1; - std::shared_ptr oper = - std::dynamic_pointer_cast(appsi_expr); - py::list pyomo_args = pyomo_expr.attr("args"); - oper->operand = appsi_operator_from_pyomo_expr(pyomo_args[0], var_map, - param_map, expr_types); - num_nodes += build_expression_tree(pyomo_args[0], oper->operand, var_map, - param_map, expr_types); - } else if (appsi_expr->is_sum_operator()) { - num_nodes += 1; - std::shared_ptr oper = - std::dynamic_pointer_cast(appsi_expr); - py::list pyomo_args = pyomo_expr.attr("args"); - for (unsigned int arg_ndx = 0; arg_ndx < oper->nargs; ++arg_ndx) { - oper->operands[arg_ndx] = appsi_operator_from_pyomo_expr( - pyomo_args[arg_ndx], var_map, param_map, expr_types); - num_nodes += - build_expression_tree(pyomo_args[arg_ndx], oper->operands[arg_ndx], - var_map, param_map, expr_types); - } - } else if (appsi_expr->is_linear_operator()) { - num_nodes += 1; - std::shared_ptr oper = - std::dynamic_pointer_cast(appsi_expr); - oper->constant = appsi_expr_from_pyomo_expr(pyomo_expr.attr("constant"), - var_map, param_map, expr_types); - py::list pyomo_vars = pyomo_expr.attr("linear_vars"); - py::list pyomo_coefs = pyomo_expr.attr("linear_coefs"); - for (unsigned int arg_ndx = 0; arg_ndx < oper->nterms; ++arg_ndx) { - oper->variables[arg_ndx] = var_map[expr_types.id(pyomo_vars[arg_ndx])] - .cast>(); - oper->coefficients[arg_ndx] = appsi_expr_from_pyomo_expr( - pyomo_coefs[arg_ndx], var_map, param_map, expr_types); - } - } else if (appsi_expr->is_external_operator()) { - num_nodes += 1; - std::shared_ptr oper = - std::dynamic_pointer_cast(appsi_expr); - py::list pyomo_args = pyomo_expr.attr("args"); - for (unsigned int arg_ndx = 0; arg_ndx < oper->nargs; ++arg_ndx) { - oper->operands[arg_ndx] = appsi_operator_from_pyomo_expr( - pyomo_args[arg_ndx], var_map, param_map, expr_types); - num_nodes += - build_expression_tree(pyomo_args[arg_ndx], oper->operands[arg_ndx], - var_map, param_map, expr_types); - } - } else { - throw py::value_error( - "Unrecognized expression type: " + - expr_types.builtins.attr("str")(py::type::of(pyomo_expr)) - .cast()); - } - return num_nodes; -} - -std::shared_ptr -appsi_expr_from_pyomo_expr(py::handle expr, py::handle var_map, - py::handle param_map, PyomoExprTypes &expr_types) { - std::shared_ptr node = - appsi_operator_from_pyomo_expr(expr, var_map, param_map, expr_types); - int num_nodes = - build_expression_tree(expr, node, var_map, param_map, expr_types); - if (num_nodes == 0) { - return std::dynamic_pointer_cast(node); - } else { - std::shared_ptr res = std::make_shared(num_nodes); - node->fill_expression(res->operators, num_nodes); - return res; - } -} - -std::vector> -appsi_exprs_from_pyomo_exprs(py::list expr_list, py::dict var_map, - py::dict param_map) { - PyomoExprTypes expr_types = PyomoExprTypes(); - int num_exprs = expr_types.builtins.attr("len")(expr_list).cast(); - std::vector> res(num_exprs); - - int ndx = 0; - for (py::handle expr : expr_list) { - res[ndx] = appsi_expr_from_pyomo_expr(expr, var_map, param_map, expr_types); - ndx += 1; - } - return res; -} - -void process_pyomo_vars(PyomoExprTypes &expr_types, py::list pyomo_vars, - py::dict var_map, py::dict param_map, - py::dict var_attrs, py::dict rev_var_map, - py::bool_ _set_name, py::handle symbol_map, - py::handle labeler, py::bool_ _update) { - py::tuple v_attrs; - std::shared_ptr cv; - py::handle v_lb; - py::handle v_ub; - py::handle v_val; - py::tuple domain_interval; - py::handle interval_lb; - py::handle interval_ub; - py::handle interval_step; - bool v_fixed; - bool set_name = _set_name.cast(); - bool update = _update.cast(); - double domain_step; - - for (py::handle v : pyomo_vars) { - v_attrs = var_attrs[expr_types.id(v)]; - v_lb = v_attrs[1]; - v_ub = v_attrs[2]; - v_fixed = v_attrs[3].cast(); - domain_interval = v_attrs[4]; - v_val = v_attrs[5]; - - interval_lb = domain_interval[0]; - interval_ub = domain_interval[1]; - interval_step = domain_interval[2]; - domain_step = interval_step.cast(); - - if (update) { - cv = var_map[expr_types.id(v)].cast>(); - } else { - cv = std::make_shared(); - } - - if (!(v_lb.is(py::none()))) { - cv->lb = appsi_expr_from_pyomo_expr(v_lb, var_map, param_map, expr_types); - } else { - cv->lb = std::make_shared(-inf); - } - if (!(v_ub.is(py::none()))) { - cv->ub = appsi_expr_from_pyomo_expr(v_ub, var_map, param_map, expr_types); - } else { - cv->ub = std::make_shared(inf); - } - - if (!(v_val.is(py::none()))) { - cv->value = v_val.cast(); - } - - if (v_fixed) { - cv->fixed = true; - } else { - cv->fixed = false; - } - - if (set_name && !update) { - cv->name = symbol_map.attr("getSymbol")(v, labeler).cast(); - } - - if (interval_lb.is(py::none())) - cv->domain_lb = -inf; - else - cv->domain_lb = interval_lb.cast(); - if (interval_ub.is(py::none())) - cv->domain_ub = inf; - else - cv->domain_ub = interval_ub.cast(); - if (domain_step == 0) - cv->domain = continuous; - else if (domain_step == 1) { - if ((cv->domain_lb == 0) && (cv->domain_ub == 1)) - cv->domain = binary; - else - cv->domain = integers; - } else - throw py::value_error("Unrecognized domain step"); - - if (!update) { - var_map[expr_types.id(v)] = py::cast(cv); - rev_var_map[py::cast(cv)] = v; - } - } -} +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + +#include "expression.hpp" + +bool Leaf::is_leaf() { return true; } + +bool Var::is_variable_type() { return true; } + +bool Param::is_param_type() { return true; } + +bool Constant::is_constant_type() { return true; } + +bool Expression::is_expression_type() { return true; } + +double Leaf::evaluate() { return value; } + +double Var::get_lb() { + if (fixed) + return value; + else + return std::max(lb->evaluate(), domain_lb); +} + +double Var::get_ub() { + if (fixed) + return value; + else + return std::min(ub->evaluate(), domain_ub); +} + +Domain Var::get_domain() { return domain; } + +bool Operator::is_operator_type() { return true; } + +std::vector> Expression::get_operators() { + std::vector> res(n_operators); + for (unsigned int i = 0; i < n_operators; ++i) { + res[i] = operators[i]; + } + return res; +} + +double Leaf::get_value_from_array(double *val_array) { return value; } + +double Expression::get_value_from_array(double *val_array) { + return val_array[n_operators - 1]; +} + +double Operator::get_value_from_array(double *val_array) { + return val_array[index]; +} + +void MultiplyOperator::evaluate(double *values) { + values[index] = operand1->get_value_from_array(values) * + operand2->get_value_from_array(values); +} + +void ExternalOperator::evaluate(double *values) { + // It would be nice to implement this, but it will take some more work. + // This would require dynamic linking to the external function. + throw std::runtime_error("cannot evaluate ExternalOperator yet"); +} + +void LinearOperator::evaluate(double *values) { + values[index] = constant->evaluate(); + for (unsigned int i = 0; i < nterms; ++i) { + values[index] += coefficients[i]->evaluate() * variables[i]->evaluate(); + } +} + +void SumOperator::evaluate(double *values) { + values[index] = 0.0; + for (unsigned int i = 0; i < nargs; ++i) { + values[index] += operands[i]->get_value_from_array(values); + } +} + +void DivideOperator::evaluate(double *values) { + values[index] = operand1->get_value_from_array(values) / + operand2->get_value_from_array(values); +} + +void PowerOperator::evaluate(double *values) { + values[index] = std::pow(operand1->get_value_from_array(values), + operand2->get_value_from_array(values)); +} + +void NegationOperator::evaluate(double *values) { + values[index] = -operand->get_value_from_array(values); +} + +void ExpOperator::evaluate(double *values) { + values[index] = std::exp(operand->get_value_from_array(values)); +} + +void LogOperator::evaluate(double *values) { + values[index] = std::log(operand->get_value_from_array(values)); +} + +void AbsOperator::evaluate(double *values) { + values[index] = std::fabs(operand->get_value_from_array(values)); +} + +void SqrtOperator::evaluate(double *values) { + values[index] = std::pow(operand->get_value_from_array(values), 0.5); +} + +void Log10Operator::evaluate(double *values) { + values[index] = std::log10(operand->get_value_from_array(values)); +} + +void SinOperator::evaluate(double *values) { + values[index] = std::sin(operand->get_value_from_array(values)); +} + +void CosOperator::evaluate(double *values) { + values[index] = std::cos(operand->get_value_from_array(values)); +} + +void TanOperator::evaluate(double *values) { + values[index] = std::tan(operand->get_value_from_array(values)); +} + +void AsinOperator::evaluate(double *values) { + values[index] = std::asin(operand->get_value_from_array(values)); +} + +void AcosOperator::evaluate(double *values) { + values[index] = std::acos(operand->get_value_from_array(values)); +} + +void AtanOperator::evaluate(double *values) { + values[index] = std::atan(operand->get_value_from_array(values)); +} + +double Expression::evaluate() { + double *values = new double[n_operators]; + for (unsigned int i = 0; i < n_operators; ++i) { + operators[i]->index = i; + operators[i]->evaluate(values); + } + double res = get_value_from_array(values); + delete[] values; + return res; +} + +void UnaryOperator::identify_variables( + std::set> &var_set, + std::shared_ptr>> var_vec) { + if (operand->is_variable_type()) { + if (var_set.count(operand) == 0) { + var_vec->push_back(std::dynamic_pointer_cast(operand)); + var_set.insert(operand); + } + } +} + +void BinaryOperator::identify_variables( + std::set> &var_set, + std::shared_ptr>> var_vec) { + if (operand1->is_variable_type()) { + if (var_set.count(operand1) == 0) { + var_vec->push_back(std::dynamic_pointer_cast(operand1)); + var_set.insert(operand1); + } + } + if (operand2->is_variable_type()) { + if (var_set.count(operand2) == 0) { + var_vec->push_back(std::dynamic_pointer_cast(operand2)); + var_set.insert(operand2); + } + } +} + +void ExternalOperator::identify_variables( + std::set> &var_set, + std::shared_ptr>> var_vec) { + for (unsigned int i = 0; i < nargs; ++i) { + if (operands[i]->is_variable_type()) { + if (var_set.count(operands[i]) == 0) { + var_vec->push_back(std::dynamic_pointer_cast(operands[i])); + var_set.insert(operands[i]); + } + } + } +} + +void LinearOperator::identify_variables( + std::set> &var_set, + std::shared_ptr>> var_vec) { + for (unsigned int i = 0; i < nterms; ++i) { + if (var_set.count(variables[i]) == 0) { + var_vec->push_back(std::dynamic_pointer_cast(variables[i])); + var_set.insert(variables[i]); + } + } +} + +void SumOperator::identify_variables( + std::set> &var_set, + std::shared_ptr>> var_vec) { + for (unsigned int i = 0; i < nargs; ++i) { + if (operands[i]->is_variable_type()) { + if (var_set.count(operands[i]) == 0) { + var_vec->push_back(std::dynamic_pointer_cast(operands[i])); + var_set.insert(operands[i]); + } + } + } +} + +std::shared_ptr>> +Expression::identify_variables() { + std::set> var_set; + std::shared_ptr>> res = + std::make_shared>>(var_set.size()); + for (unsigned int i = 0; i < n_operators; ++i) { + operators[i]->identify_variables(var_set, res); + } + return res; +} + +std::shared_ptr>> Var::identify_variables() { + std::shared_ptr>> res = + std::make_shared>>(); + res->push_back(shared_from_this()); + return res; +} + +std::shared_ptr>> +Constant::identify_variables() { + std::shared_ptr>> res = + std::make_shared>>(); + return res; +} + +std::shared_ptr>> Param::identify_variables() { + std::shared_ptr>> res = + std::make_shared>>(); + return res; +} + +std::shared_ptr>> +Expression::identify_external_operators() { + std::set> external_set; + for (unsigned int i = 0; i < n_operators; ++i) { + if (operators[i]->is_external_operator()) { + external_set.insert(operators[i]); + } + } + std::shared_ptr>> res = + std::make_shared>>( + external_set.size()); + int ndx = 0; + for (std::shared_ptr n : external_set) { + (*res)[ndx] = std::dynamic_pointer_cast(n); + ndx += 1; + } + return res; +} + +std::shared_ptr>> +Var::identify_external_operators() { + std::shared_ptr>> res = + std::make_shared>>(); + return res; +} + +std::shared_ptr>> +Constant::identify_external_operators() { + std::shared_ptr>> res = + std::make_shared>>(); + return res; +} + +std::shared_ptr>> +Param::identify_external_operators() { + std::shared_ptr>> res = + std::make_shared>>(); + return res; +} + +int Var::get_degree_from_array(int *degree_array) { return 1; } + +int Param::get_degree_from_array(int *degree_array) { return 0; } + +int Constant::get_degree_from_array(int *degree_array) { return 0; } + +int Expression::get_degree_from_array(int *degree_array) { + return degree_array[n_operators - 1]; +} + +int Operator::get_degree_from_array(int *degree_array) { + return degree_array[index]; +} + +void LinearOperator::propagate_degree_forward(int *degrees, double *values) { + degrees[index] = 1; +} + +void SumOperator::propagate_degree_forward(int *degrees, double *values) { + int deg = 0; + int _deg; + for (unsigned int i = 0; i < nargs; ++i) { + _deg = operands[i]->get_degree_from_array(degrees); + if (_deg > deg) { + deg = _deg; + } + } + degrees[index] = deg; +} + +void MultiplyOperator::propagate_degree_forward(int *degrees, double *values) { + degrees[index] = operand1->get_degree_from_array(degrees) + + operand2->get_degree_from_array(degrees); +} + +void ExternalOperator::propagate_degree_forward(int *degrees, double *values) { + // External functions are always considered nonlinear + // Anything larger than 2 is nonlinear + degrees[index] = 3; +} + +void DivideOperator::propagate_degree_forward(int *degrees, double *values) { + // anything larger than 2 is nonlinear + degrees[index] = std::max(operand1->get_degree_from_array(degrees), + 3 * (operand2->get_degree_from_array(degrees))); +} + +void PowerOperator::propagate_degree_forward(int *degrees, double *values) { + if (operand2->get_degree_from_array(degrees) != 0) { + degrees[index] = 3; + } else { + double val2 = operand2->get_value_from_array(values); + double intpart; + if (std::modf(val2, &intpart) == 0.0) { + degrees[index] = operand1->get_degree_from_array(degrees) * (int)val2; + } else { + degrees[index] = 3; + } + } +} + +void NegationOperator::propagate_degree_forward(int *degrees, double *values) { + degrees[index] = operand->get_degree_from_array(degrees); +} + +void UnaryOperator::propagate_degree_forward(int *degrees, double *values) { + if (operand->get_degree_from_array(degrees) == 0) { + degrees[index] = 0; + } else { + degrees[index] = 3; + } +} + +std::string Var::__str__() { return name; } + +std::string Param::__str__() { return name; } + +std::string Constant::__str__() { return std::to_string(value); } + +std::string Expression::__str__() { + std::string *string_array = new std::string[n_operators]; + std::shared_ptr oper; + for (unsigned int i = 0; i < n_operators; ++i) { + oper = operators[i]; + oper->index = i; + oper->print(string_array); + } + std::string res = string_array[n_operators - 1]; + delete[] string_array; + return res; +} + +std::string Leaf::get_string_from_array(std::string *string_array) { + return __str__(); +} + +std::string Expression::get_string_from_array(std::string *string_array) { + return string_array[n_operators - 1]; +} + +std::string Operator::get_string_from_array(std::string *string_array) { + return string_array[index]; +} + +void MultiplyOperator::print(std::string *string_array) { + string_array[index] = + ("(" + operand1->get_string_from_array(string_array) + "*" + + operand2->get_string_from_array(string_array) + ")"); +} + +void ExternalOperator::print(std::string *string_array) { + std::string res = function_name + "("; + for (unsigned int i = 0; i < (nargs - 1); ++i) { + res += operands[i]->get_string_from_array(string_array); + res += ", "; + } + res += operands[nargs - 1]->get_string_from_array(string_array); + res += ")"; + string_array[index] = res; +} + +void DivideOperator::print(std::string *string_array) { + string_array[index] = + ("(" + operand1->get_string_from_array(string_array) + "/" + + operand2->get_string_from_array(string_array) + ")"); +} + +void PowerOperator::print(std::string *string_array) { + string_array[index] = + ("(" + operand1->get_string_from_array(string_array) + "**" + + operand2->get_string_from_array(string_array) + ")"); +} + +void NegationOperator::print(std::string *string_array) { + string_array[index] = + ("(-" + operand->get_string_from_array(string_array) + ")"); +} + +void ExpOperator::print(std::string *string_array) { + string_array[index] = + ("exp(" + operand->get_string_from_array(string_array) + ")"); +} + +void LogOperator::print(std::string *string_array) { + string_array[index] = + ("log(" + operand->get_string_from_array(string_array) + ")"); +} + +void AbsOperator::print(std::string *string_array) { + string_array[index] = + ("abs(" + operand->get_string_from_array(string_array) + ")"); +} + +void SqrtOperator::print(std::string *string_array) { + string_array[index] = + ("sqrt(" + operand->get_string_from_array(string_array) + ")"); +} + +void Log10Operator::print(std::string *string_array) { + string_array[index] = + ("log10(" + operand->get_string_from_array(string_array) + ")"); +} + +void SinOperator::print(std::string *string_array) { + string_array[index] = + ("sin(" + operand->get_string_from_array(string_array) + ")"); +} + +void CosOperator::print(std::string *string_array) { + string_array[index] = + ("cos(" + operand->get_string_from_array(string_array) + ")"); +} + +void TanOperator::print(std::string *string_array) { + string_array[index] = + ("tan(" + operand->get_string_from_array(string_array) + ")"); +} + +void AsinOperator::print(std::string *string_array) { + string_array[index] = + ("asin(" + operand->get_string_from_array(string_array) + ")"); +} + +void AcosOperator::print(std::string *string_array) { + string_array[index] = + ("acos(" + operand->get_string_from_array(string_array) + ")"); +} + +void AtanOperator::print(std::string *string_array) { + string_array[index] = + ("atan(" + operand->get_string_from_array(string_array) + ")"); +} + +void LinearOperator::print(std::string *string_array) { + std::string res = "(" + constant->__str__(); + for (unsigned int i = 0; i < nterms; ++i) { + res += " + " + coefficients[i]->__str__() + "*" + variables[i]->__str__(); + } + res += ")"; + string_array[index] = res; +} + +void SumOperator::print(std::string *string_array) { + std::string res = "(" + operands[0]->get_string_from_array(string_array); + for (unsigned int i = 1; i < nargs; ++i) { + res += " + " + operands[i]->get_string_from_array(string_array); + } + res += ")"; + string_array[index] = res; +} + +std::shared_ptr>> +Leaf::get_prefix_notation() { + std::shared_ptr>> res = + std::make_shared>>(); + res->push_back(shared_from_this()); + return res; +} + +std::shared_ptr>> +Expression::get_prefix_notation() { + std::shared_ptr>> res = + std::make_shared>>(); + std::shared_ptr>> stack = + std::make_shared>>(); + std::shared_ptr node; + stack->push_back(operators[n_operators - 1]); + while (stack->size() > 0) { + node = stack->back(); + stack->pop_back(); + res->push_back(node); + node->fill_prefix_notation_stack(stack); + } + + return res; +} + +void BinaryOperator::fill_prefix_notation_stack( + std::shared_ptr>> stack) { + stack->push_back(operand2); + stack->push_back(operand1); +} + +void UnaryOperator::fill_prefix_notation_stack( + std::shared_ptr>> stack) { + stack->push_back(operand); +} + +void SumOperator::fill_prefix_notation_stack( + std::shared_ptr>> stack) { + int ndx = nargs - 1; + while (ndx >= 0) { + stack->push_back(operands[ndx]); + ndx -= 1; + } +} + +void LinearOperator::fill_prefix_notation_stack( + std::shared_ptr>> stack) { + ; // This is treated as a leaf in this context; write_nl_string will take care + // of it +} + +void ExternalOperator::fill_prefix_notation_stack( + std::shared_ptr>> stack) { + int i = nargs - 1; + while (i >= 0) { + stack->push_back(operands[i]); + i -= 1; + } +} + +void Var::write_nl_string(std::ofstream &f) { f << "v" << index << "\n"; } + +void Param::write_nl_string(std::ofstream &f) { f << "n" << value << "\n"; } + +void Constant::write_nl_string(std::ofstream &f) { f << "n" << value << "\n"; } + +void Expression::write_nl_string(std::ofstream &f) { + std::shared_ptr>> prefix_notation = + get_prefix_notation(); + for (std::shared_ptr &node : *(prefix_notation)) { + node->write_nl_string(f); + } +} + +void MultiplyOperator::write_nl_string(std::ofstream &f) { f << "o2\n"; } + +void ExternalOperator::write_nl_string(std::ofstream &f) { + f << "f" << external_function_index << " " << nargs << "\n"; +} + +void SumOperator::write_nl_string(std::ofstream &f) { + if (nargs == 2) { + f << "o0\n"; + } else { + f << "o54\n"; + f << nargs << "\n"; + } +} + +void LinearOperator::write_nl_string(std::ofstream &f) { + bool has_const = + (!constant->is_constant_type()) || (constant->evaluate() != 0); + unsigned int n_sum_args = nterms + (has_const ? 1 : 0); + if (n_sum_args == 2) { + f << "o0\n"; + } else { + f << "o54\n"; + f << n_sum_args << "\n"; + } + if (has_const) + f << "n" << constant->evaluate() << "\n"; + for (unsigned int ndx = 0; ndx < nterms; ++ndx) { + f << "o2\n"; + f << "n" << coefficients[ndx]->evaluate() << "\n"; + variables[ndx]->write_nl_string(f); + } +} + +void DivideOperator::write_nl_string(std::ofstream &f) { f << "o3\n"; } + +void PowerOperator::write_nl_string(std::ofstream &f) { f << "o5\n"; } + +void NegationOperator::write_nl_string(std::ofstream &f) { f << "o16\n"; } + +void ExpOperator::write_nl_string(std::ofstream &f) { f << "o44\n"; } + +void LogOperator::write_nl_string(std::ofstream &f) { f << "o43\n"; } + +void AbsOperator::write_nl_string(std::ofstream &f) { f << "o15\n"; } + +void SqrtOperator::write_nl_string(std::ofstream &f) { f << "o39\n"; } + +void Log10Operator::write_nl_string(std::ofstream &f) { f << "o42\n"; } + +void SinOperator::write_nl_string(std::ofstream &f) { f << "o41\n"; } + +void CosOperator::write_nl_string(std::ofstream &f) { f << "o46\n"; } + +void TanOperator::write_nl_string(std::ofstream &f) { f << "o38\n"; } + +void AsinOperator::write_nl_string(std::ofstream &f) { f << "o51\n"; } + +void AcosOperator::write_nl_string(std::ofstream &f) { f << "o53\n"; } + +void AtanOperator::write_nl_string(std::ofstream &f) { f << "o49\n"; } + +bool BinaryOperator::is_binary_operator() { return true; } + +bool UnaryOperator::is_unary_operator() { return true; } + +bool LinearOperator::is_linear_operator() { return true; } + +bool SumOperator::is_sum_operator() { return true; } + +bool MultiplyOperator::is_multiply_operator() { return true; } + +bool DivideOperator::is_divide_operator() { return true; } + +bool PowerOperator::is_power_operator() { return true; } + +bool NegationOperator::is_negation_operator() { return true; } + +bool ExpOperator::is_exp_operator() { return true; } + +bool LogOperator::is_log_operator() { return true; } + +bool AbsOperator::is_abs_operator() { return true; } + +bool SqrtOperator::is_sqrt_operator() { return true; } + +bool ExternalOperator::is_external_operator() { return true; } + +void Leaf::fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) { + ; +} + +void Expression::fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) { + throw std::runtime_error("This should not happen"); +} + +void BinaryOperator::fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) { + oper_ndx -= 1; + oper_array[oper_ndx] = shared_from_this(); + // The order does not actually matter here. It + // will just be easier to debug this way. + operand2->fill_expression(oper_array, oper_ndx); + operand1->fill_expression(oper_array, oper_ndx); +} + +void UnaryOperator::fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) { + oper_ndx -= 1; + oper_array[oper_ndx] = shared_from_this(); + operand->fill_expression(oper_array, oper_ndx); +} + +void LinearOperator::fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) { + oper_ndx -= 1; + oper_array[oper_ndx] = shared_from_this(); +} + +void SumOperator::fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) { + oper_ndx -= 1; + oper_array[oper_ndx] = shared_from_this(); + // The order does not actually matter here. It + // will just be easier to debug this way. + int arg_ndx = nargs - 1; + while (arg_ndx >= 0) { + operands[arg_ndx]->fill_expression(oper_array, oper_ndx); + arg_ndx -= 1; + } +} + +void ExternalOperator::fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) { + oper_ndx -= 1; + oper_array[oper_ndx] = shared_from_this(); + // The order does not actually matter here. It + // will just be easier to debug this way. + int arg_ndx = nargs - 1; + while (arg_ndx >= 0) { + operands[arg_ndx]->fill_expression(oper_array, oper_ndx); + arg_ndx -= 1; + } +} + +double Leaf::get_lb_from_array(double *lbs) { return value; } + +double Leaf::get_ub_from_array(double *ubs) { return value; } + +double Var::get_lb_from_array(double *lbs) { return get_lb(); } + +double Var::get_ub_from_array(double *ubs) { return get_ub(); } + +double Expression::get_lb_from_array(double *lbs) { + return lbs[n_operators - 1]; +} + +double Expression::get_ub_from_array(double *ubs) { + return ubs[n_operators - 1]; +} + +double Operator::get_lb_from_array(double *lbs) { return lbs[index]; } + +double Operator::get_ub_from_array(double *ubs) { return ubs[index]; } + +void Leaf::set_bounds_in_array(double new_lb, double new_ub, double *lbs, + double *ubs, double feasibility_tol, + double integer_tol, double improvement_tol, + std::set> &improved_vars) { + if (new_lb < value - feasibility_tol || new_lb > value + feasibility_tol) { + throw InfeasibleConstraintException( + "Infeasible constraint; bounds computed on parameter or constant " + "disagree with the value of the parameter or constant\n value: " + + std::to_string(value) + "\n computed LB: " + std::to_string(new_lb) + + "\n computed UB: " + std::to_string(new_ub)); + } + + if (new_ub < value - feasibility_tol || new_ub > value + feasibility_tol) { + throw InfeasibleConstraintException( + "Infeasible constraint; bounds computed on parameter or constant " + "disagree with the value of the parameter or constant\n value: " + + std::to_string(value) + "\n computed LB: " + std::to_string(new_lb) + + "\n computed UB: " + std::to_string(new_ub)); + } +} + +void Var::set_bounds_in_array(double new_lb, double new_ub, double *lbs, + double *ubs, double feasibility_tol, + double integer_tol, double improvement_tol, + std::set> &improved_vars) { + if (new_lb > new_ub) { + if (new_lb - feasibility_tol > new_ub) + throw InfeasibleConstraintException( + "Infeasible constraint; The computed lower bound for a variable is " + "larger than the computed upper bound.\n computed LB: " + + std::to_string(new_lb) + + "\n computed UB: " + std::to_string(new_ub)); + else { + new_lb -= feasibility_tol; + new_ub += feasibility_tol; + } + } + if (new_lb >= inf) + throw InfeasibleConstraintException( + "Infeasible constraint; The compute lower bound for " + name + + " is inf"); + if (new_ub <= -inf) + throw InfeasibleConstraintException( + "Infeasible constraint; The computed upper bound for " + name + + " is -inf"); + + if (domain == integers || domain == binary) { + if (new_lb > -inf) { + double lb_floor = floor(new_lb); + double lb_ceil = ceil(new_lb - integer_tol); + if (lb_floor > lb_ceil) + new_lb = lb_floor; + else + new_lb = lb_ceil; + } + if (new_ub < inf) { + double ub_ceil = ceil(new_ub); + double ub_floor = floor(new_ub + integer_tol); + if (ub_ceil < ub_floor) + new_ub = ub_ceil; + else + new_ub = ub_floor; + } + } + + double current_lb = get_lb(); + double current_ub = get_ub(); + + if (new_lb > current_lb + improvement_tol || + new_ub < current_ub - improvement_tol) + improved_vars.insert(shared_from_this()); + + if (new_lb > current_lb) { + if (lb->is_leaf()) + std::dynamic_pointer_cast(lb)->value = new_lb; + else + throw py::value_error( + "variable bounds cannot be expressions when performing FBBT"); + } + + if (new_ub < current_ub) { + if (ub->is_leaf()) + std::dynamic_pointer_cast(ub)->value = new_ub; + else + throw py::value_error( + "variable bounds cannot be expressions when performing FBBT"); + } +} + +void Expression::set_bounds_in_array( + double new_lb, double new_ub, double *lbs, double *ubs, + double feasibility_tol, double integer_tol, double improvement_tol, + std::set> &improved_vars) { + lbs[n_operators - 1] = new_lb; + ubs[n_operators - 1] = new_ub; +} + +void Operator::set_bounds_in_array( + double new_lb, double new_ub, double *lbs, double *ubs, + double feasibility_tol, double integer_tol, double improvement_tol, + std::set> &improved_vars) { + lbs[index] = new_lb; + ubs[index] = new_ub; +} + +void Expression::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + for (unsigned int ndx = 0; ndx < n_operators; ++ndx) { + operators[ndx]->index = ndx; + operators[ndx]->propagate_bounds_forward(lbs, ubs, feasibility_tol, + integer_tol); + } +} + +void Expression::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + int ndx = n_operators - 1; + while (ndx >= 0) { + operators[ndx]->propagate_bounds_backward( + lbs, ubs, feasibility_tol, integer_tol, improvement_tol, improved_vars); + ndx -= 1; + } +} + +void Operator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + lbs[index] = -inf; + ubs[index] = inf; +} + +void Operator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + ; +} + +void MultiplyOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + if (operand1 == operand2) { + interval_power(operand1->get_lb_from_array(lbs), + operand1->get_ub_from_array(ubs), 2, 2, &lbs[index], + &ubs[index], feasibility_tol); + } else { + interval_mul(operand1->get_lb_from_array(lbs), + operand1->get_ub_from_array(ubs), + operand2->get_lb_from_array(lbs), + operand2->get_ub_from_array(ubs), &lbs[index], &ubs[index]); + } +} + +void MultiplyOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand1->get_lb_from_array(lbs); + double xu = operand1->get_ub_from_array(ubs); + double yl = operand2->get_lb_from_array(lbs); + double yu = operand2->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu, new_yl, new_yu; + + if (operand1 == operand2) { + _inverse_power1(lb, ub, 2, 2, xl, xu, &new_xl, &new_xu, feasibility_tol); + new_yl = new_xl; + new_yu = new_xu; + } else { + interval_div(lb, ub, yl, yu, &new_xl, &new_xu, feasibility_tol); + interval_div(lb, ub, xl, xu, &new_yl, &new_yu, feasibility_tol); + } + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand1->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); + + if (new_yl > yl) + yl = new_yl; + if (new_yu < yu) + yu = new_yu; + operand2->set_bounds_in_array(yl, yu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void SumOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + double lb = operands[0]->get_lb_from_array(lbs); + double ub = operands[0]->get_ub_from_array(ubs); + double tmp_lb; + double tmp_ub; + + for (unsigned int ndx = 1; ndx < nargs; ++ndx) { + interval_add(lb, ub, operands[ndx]->get_lb_from_array(lbs), + operands[ndx]->get_ub_from_array(ubs), &tmp_lb, &tmp_ub); + lb = tmp_lb; + ub = tmp_ub; + } + + lbs[index] = lb; + ubs[index] = ub; +} + +void SumOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double *accumulated_lbs = new double[nargs]; + double *accumulated_ubs = new double[nargs]; + + accumulated_lbs[0] = operands[0]->get_lb_from_array(lbs); + accumulated_ubs[0] = operands[0]->get_ub_from_array(ubs); + for (unsigned int ndx = 1; ndx < nargs; ++ndx) { + interval_add(accumulated_lbs[ndx - 1], accumulated_ubs[ndx - 1], + operands[ndx]->get_lb_from_array(lbs), + operands[ndx]->get_ub_from_array(ubs), &accumulated_lbs[ndx], + &accumulated_ubs[ndx]); + } + + double new_sum_lb = get_lb_from_array(lbs); + double new_sum_ub = get_ub_from_array(ubs); + + if (new_sum_lb > accumulated_lbs[nargs - 1]) + accumulated_lbs[nargs - 1] = new_sum_lb; + if (new_sum_ub < accumulated_ubs[nargs - 1]) + accumulated_ubs[nargs - 1] = new_sum_ub; + + double lb0, ub0, lb1, ub1, lb2, ub2, _lb1, _ub1, _lb2, _ub2; + + int ndx = nargs - 1; + while (ndx >= 1) { + lb0 = accumulated_lbs[ndx]; + ub0 = accumulated_ubs[ndx]; + lb1 = accumulated_lbs[ndx - 1]; + ub1 = accumulated_ubs[ndx - 1]; + lb2 = operands[ndx]->get_lb_from_array(lbs); + ub2 = operands[ndx]->get_ub_from_array(ubs); + interval_sub(lb0, ub0, lb2, ub2, &_lb1, &_ub1); + interval_sub(lb0, ub0, lb1, ub1, &_lb2, &_ub2); + if (_lb1 > lb1) + lb1 = _lb1; + if (_ub1 < ub1) + ub1 = _ub1; + if (_lb2 > lb2) + lb2 = _lb2; + if (_ub2 < ub2) + ub2 = _ub2; + accumulated_lbs[ndx - 1] = lb1; + accumulated_ubs[ndx - 1] = ub1; + operands[ndx]->set_bounds_in_array(lb2, ub2, lbs, ubs, feasibility_tol, + integer_tol, improvement_tol, + improved_vars); + ndx -= 1; + } + + // take care of ndx = 0 + lb1 = operands[0]->get_lb_from_array(lbs); + ub1 = operands[0]->get_ub_from_array(ubs); + _lb1 = accumulated_lbs[0]; + _ub1 = accumulated_ubs[0]; + if (_lb1 > lb1) + lb1 = _lb1; + if (_ub1 < ub1) + ub1 = _ub1; + operands[0]->set_bounds_in_array(lb1, ub1, lbs, ubs, feasibility_tol, + integer_tol, improvement_tol, improved_vars); + + delete[] accumulated_lbs; + delete[] accumulated_ubs; +} + +void LinearOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + double lb = constant->evaluate(); + double ub = lb; + double tmp_lb; + double tmp_ub; + double coef; + + for (unsigned int ndx = 0; ndx < nterms; ++ndx) { + coef = coefficients[ndx]->evaluate(); + interval_mul(coef, coef, variables[ndx]->get_lb(), variables[ndx]->get_ub(), + &tmp_lb, &tmp_ub); + interval_add(lb, ub, tmp_lb, tmp_ub, &lb, &ub); + } + + lbs[index] = lb; + ubs[index] = ub; +} + +void LinearOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double *accumulated_lbs = new double[nterms + 1]; + double *accumulated_ubs = new double[nterms + 1]; + + double coef; + + accumulated_lbs[0] = constant->evaluate(); + accumulated_ubs[0] = constant->evaluate(); + for (unsigned int ndx = 0; ndx < nterms; ++ndx) { + coef = coefficients[ndx]->evaluate(); + interval_mul(coef, coef, variables[ndx]->get_lb(), variables[ndx]->get_ub(), + &accumulated_lbs[ndx + 1], &accumulated_ubs[ndx + 1]); + interval_add(accumulated_lbs[ndx], accumulated_ubs[ndx], + accumulated_lbs[ndx + 1], accumulated_ubs[ndx + 1], + &accumulated_lbs[ndx + 1], &accumulated_ubs[ndx + 1]); + } + + double new_sum_lb = get_lb_from_array(lbs); + double new_sum_ub = get_ub_from_array(ubs); + + if (new_sum_lb > accumulated_lbs[nterms]) + accumulated_lbs[nterms] = new_sum_lb; + if (new_sum_ub < accumulated_ubs[nterms]) + accumulated_ubs[nterms] = new_sum_ub; + + double lb0, ub0, lb1, ub1, lb2, ub2, _lb1, _ub1, _lb2, _ub2, new_v_lb, + new_v_ub; + + int ndx = nterms - 1; + while (ndx >= 0) { + lb0 = accumulated_lbs[ndx + 1]; + ub0 = accumulated_ubs[ndx + 1]; + lb1 = accumulated_lbs[ndx]; + ub1 = accumulated_ubs[ndx]; + coef = coefficients[ndx]->evaluate(); + interval_mul(coef, coef, variables[ndx]->get_lb(), variables[ndx]->get_ub(), + &lb2, &ub2); + interval_sub(lb0, ub0, lb2, ub2, &_lb1, &_ub1); + interval_sub(lb0, ub0, lb1, ub1, &_lb2, &_ub2); + if (_lb1 > lb1) + lb1 = _lb1; + if (_ub1 < ub1) + ub1 = _ub1; + if (_lb2 > lb2) + lb2 = _lb2; + if (_ub2 < ub2) + ub2 = _ub2; + accumulated_lbs[ndx] = lb1; + accumulated_ubs[ndx] = ub1; + interval_div(lb2, ub2, coef, coef, &new_v_lb, &new_v_ub, feasibility_tol); + variables[ndx]->set_bounds_in_array(new_v_lb, new_v_ub, lbs, ubs, + feasibility_tol, integer_tol, + improvement_tol, improved_vars); + ndx -= 1; + } + + delete[] accumulated_lbs; + delete[] accumulated_ubs; +} + +void DivideOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_div( + operand1->get_lb_from_array(lbs), operand1->get_ub_from_array(ubs), + operand2->get_lb_from_array(lbs), operand2->get_ub_from_array(ubs), + &lbs[index], &ubs[index], feasibility_tol); +} + +void DivideOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand1->get_lb_from_array(lbs); + double xu = operand1->get_ub_from_array(ubs); + double yl = operand2->get_lb_from_array(lbs); + double yu = operand2->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl; + double new_xu; + double new_yl; + double new_yu; + + interval_mul(lb, ub, yl, yu, &new_xl, &new_xu); + interval_div(xl, xu, lb, ub, &new_yl, &new_yu, feasibility_tol); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand1->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); + + if (new_yl > yl) + yl = new_yl; + if (new_yu < yu) + yu = new_yu; + operand2->set_bounds_in_array(yl, yu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void NegationOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_sub(0, 0, operand->get_lb_from_array(lbs), + operand->get_ub_from_array(ubs), &lbs[index], &ubs[index]); +} + +void NegationOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl; + double new_xu; + + interval_sub(0, 0, lb, ub, &new_xl, &new_xu); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void PowerOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_power( + operand1->get_lb_from_array(lbs), operand1->get_ub_from_array(ubs), + operand2->get_lb_from_array(lbs), operand2->get_ub_from_array(ubs), + &lbs[index], &ubs[index], feasibility_tol); +} + +void PowerOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand1->get_lb_from_array(lbs); + double xu = operand1->get_ub_from_array(ubs); + double yl = operand2->get_lb_from_array(lbs); + double yu = operand2->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu, new_yl, new_yu; + _inverse_power1(lb, ub, yl, yu, xl, xu, &new_xl, &new_xu, feasibility_tol); + if (yl != yu) + _inverse_power2(lb, ub, xl, xu, &new_yl, &new_yu, feasibility_tol); + else { + new_yl = yl; + new_yu = yu; + } + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand1->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); + + if (new_yl > yl) + yl = new_yl; + if (new_yu < yu) + yu = new_yu; + operand2->set_bounds_in_array(yl, yu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void SqrtOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_power(operand->get_lb_from_array(lbs), + operand->get_ub_from_array(ubs), 0.5, 0.5, &lbs[index], + &ubs[index], feasibility_tol); +} + +void SqrtOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double yl = 0.5; + double yu = 0.5; + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + _inverse_power1(lb, ub, yl, yu, xl, xu, &new_xl, &new_xu, feasibility_tol); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void ExpOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_exp(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), + &lbs[index], &ubs[index]); +} + +void ExpOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_log(lb, ub, &new_xl, &new_xu); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void LogOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_log(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), + &lbs[index], &ubs[index]); +} + +void LogOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_exp(lb, ub, &new_xl, &new_xu); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void AbsOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_abs(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), + &lbs[index], &ubs[index]); +} + +void AbsOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + _inverse_abs(lb, ub, &new_xl, &new_xu); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void Log10Operator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_log10(operand->get_lb_from_array(lbs), + operand->get_ub_from_array(ubs), &lbs[index], &ubs[index]); +} + +void Log10Operator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_power(10, 10, lb, ub, &new_xl, &new_xu, feasibility_tol); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void SinOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_sin(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), + &lbs[index], &ubs[index]); +} + +void SinOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_asin(lb, ub, xl, xu, &new_xl, &new_xu, feasibility_tol); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void CosOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_cos(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), + &lbs[index], &ubs[index]); +} + +void CosOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_acos(lb, ub, xl, xu, &new_xl, &new_xu, feasibility_tol); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void TanOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_tan(operand->get_lb_from_array(lbs), operand->get_ub_from_array(ubs), + &lbs[index], &ubs[index]); +} + +void TanOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_atan(lb, ub, xl, xu, &new_xl, &new_xu); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void AsinOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_asin(operand->get_lb_from_array(lbs), + operand->get_ub_from_array(ubs), -inf, inf, &lbs[index], + &ubs[index], feasibility_tol); +} + +void AsinOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_sin(lb, ub, &new_xl, &new_xu); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void AcosOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_acos(operand->get_lb_from_array(lbs), + operand->get_ub_from_array(ubs), -inf, inf, &lbs[index], + &ubs[index], feasibility_tol); +} + +void AcosOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_cos(lb, ub, &new_xl, &new_xu); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +void AtanOperator::propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) { + interval_atan(operand->get_lb_from_array(lbs), + operand->get_ub_from_array(ubs), -inf, inf, &lbs[index], + &ubs[index]); +} + +void AtanOperator::propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, std::set> &improved_vars) { + double xl = operand->get_lb_from_array(lbs); + double xu = operand->get_ub_from_array(ubs); + double lb = get_lb_from_array(lbs); + double ub = get_ub_from_array(ubs); + + double new_xl, new_xu; + interval_tan(lb, ub, &new_xl, &new_xu); + + if (new_xl > xl) + xl = new_xl; + if (new_xu < xu) + xu = new_xu; + operand->set_bounds_in_array(xl, xu, lbs, ubs, feasibility_tol, integer_tol, + improvement_tol, improved_vars); +} + +std::vector> create_vars(int n_vars) { + std::vector> res; + for (int i = 0; i < n_vars; ++i) { + res.push_back(std::make_shared()); + } + return res; +} + +std::vector> create_params(int n_params) { + std::vector> res; + for (int i = 0; i < n_params; ++i) { + res.push_back(std::make_shared()); + } + return res; +} + +std::vector> create_constants(int n_constants) { + std::vector> res; + for (int i = 0; i < n_constants; ++i) { + res.push_back(std::make_shared()); + } + return res; +} + +std::shared_ptr +appsi_operator_from_pyomo_expr(py::handle expr, py::handle var_map, + py::handle param_map, + PyomoExprTypes &expr_types) { + std::shared_ptr res; + ExprType tmp_type = + expr_types.expr_type_map[py::type::of(expr)].cast(); + + switch (tmp_type) { + case py_float: { + res = std::make_shared(expr.cast()); + break; + } + case var: { + res = var_map[expr_types.id(expr)].cast>(); + break; + } + case param: { + 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: { + res = std::make_shared(); + break; + } + case sum: { + res = std::make_shared(expr.attr("nargs")().cast()); + break; + } + case negation: { + res = std::make_shared(); + break; + } + case external_func: { + res = std::make_shared(expr.attr("nargs")().cast()); + std::shared_ptr oper = + std::dynamic_pointer_cast(res); + oper->function_name = + expr.attr("_fcn").attr("_function").cast(); + break; + } + case power: { + res = std::make_shared(); + break; + } + case division: { + res = std::make_shared(); + break; + } + case unary_func: { + std::string function_name = expr.attr("getname")().cast(); + if (function_name == "exp") + res = std::make_shared(); + else if (function_name == "log") + res = std::make_shared(); + else if (function_name == "log10") + res = std::make_shared(); + else if (function_name == "sin") + res = std::make_shared(); + else if (function_name == "cos") + res = std::make_shared(); + else if (function_name == "tan") + res = std::make_shared(); + else if (function_name == "asin") + res = std::make_shared(); + else if (function_name == "acos") + res = std::make_shared(); + else if (function_name == "atan") + res = std::make_shared(); + else if (function_name == "sqrt") + res = std::make_shared(); + else + throw py::value_error("Unrecognized expression type: " + function_name); + break; + } + case linear: { + res = std::make_shared( + expr_types.len(expr.attr("linear_vars")).cast()); + break; + } + case named_expr: { + res = appsi_operator_from_pyomo_expr(expr.attr("expr"), var_map, param_map, + expr_types); + break; + } + case numeric_constant: { + res = std::make_shared(expr.attr("value").cast()); + break; + } + case pyomo_unit: { + res = std::make_shared(1.0); + break; + } + case unary_abs: { + res = std::make_shared(); + break; + } + default: { + throw py::value_error("Unrecognized expression type: " + + expr_types.builtins.attr("str")(py::type::of(expr)) + .cast()); + break; + } + } + return res; +} + +void prep_for_repn_helper(py::handle expr, py::handle named_exprs, + py::handle variables, py::handle fixed_vars, + py::handle external_funcs, + PyomoExprTypes &expr_types) { + ExprType tmp_type = + expr_types.expr_type_map[py::type::of(expr)].cast(); + + switch (tmp_type) { + case py_float: { + break; + } + case var: { + variables[expr_types.id(expr)] = expr; + if (expr.attr("fixed").cast()) { + fixed_vars[expr_types.id(expr)] = expr; + } + break; + } + case param: { + break; + } + case product: { + py::tuple args = expr.attr("_args_"); + for (py::handle arg : args) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + break; + } + case sum: { + py::tuple args = expr.attr("args"); + for (py::handle arg : args) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + break; + } + case negation: { + py::tuple args = expr.attr("_args_"); + for (py::handle arg : args) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + break; + } + case external_func: { + external_funcs[expr_types.id(expr)] = expr; + py::tuple args = expr.attr("args"); + for (py::handle arg : args) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + break; + } + case power: { + py::tuple args = expr.attr("_args_"); + for (py::handle arg : args) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + break; + } + case division: { + py::tuple args = expr.attr("_args_"); + for (py::handle arg : args) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + break; + } + case unary_func: { + py::tuple args = expr.attr("_args_"); + for (py::handle arg : args) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + break; + } + case linear: { + py::list linear_vars = expr.attr("linear_vars"); + py::list linear_coefs = expr.attr("linear_coefs"); + for (py::handle arg : linear_vars) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + for (py::handle arg : linear_coefs) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + prep_for_repn_helper(expr.attr("constant"), named_exprs, variables, + fixed_vars, external_funcs, expr_types); + break; + } + case named_expr: { + named_exprs[expr_types.id(expr)] = expr; + prep_for_repn_helper(expr.attr("expr"), named_exprs, variables, fixed_vars, + external_funcs, expr_types); + break; + } + case numeric_constant: { + break; + } + case pyomo_unit: { + break; + } + case unary_abs: { + py::tuple args = expr.attr("_args_"); + for (py::handle arg : args) { + prep_for_repn_helper(arg, named_exprs, variables, fixed_vars, + external_funcs, expr_types); + } + break; + } + default: { + if (expr_types.builtins.attr("hasattr")(expr, "is_constant").cast()) { + if (expr.attr("is_constant")().cast()) + break; + } + throw py::value_error("Unrecognized expression type: " + + expr_types.builtins.attr("str")(py::type::of(expr)) + .cast()); + break; + } + } +} + +py::tuple prep_for_repn(py::handle expr, PyomoExprTypes &expr_types) { + py::dict named_exprs; + py::dict variables; + py::dict fixed_vars; + py::dict external_funcs; + + prep_for_repn_helper(expr, named_exprs, variables, fixed_vars, external_funcs, + expr_types); + + py::list named_expr_list = named_exprs.attr("values")(); + py::list variable_list = variables.attr("values")(); + py::list fixed_var_list = fixed_vars.attr("values")(); + py::list external_func_list = external_funcs.attr("values")(); + + py::tuple res = py::make_tuple(named_expr_list, variable_list, fixed_var_list, + external_func_list); + return res; +} + +int build_expression_tree(py::handle pyomo_expr, + std::shared_ptr appsi_expr, py::handle var_map, + py::handle param_map, PyomoExprTypes &expr_types) { + int num_nodes = 0; + + if (expr_types.expr_type_map[py::type::of(pyomo_expr)].cast() == + named_expr) + return build_expression_tree(pyomo_expr.attr("expr"), appsi_expr, var_map, + param_map, expr_types); + + if (appsi_expr->is_leaf()) { + ; + } else if (appsi_expr->is_binary_operator()) { + num_nodes += 1; + std::shared_ptr oper = + std::dynamic_pointer_cast(appsi_expr); + py::list pyomo_args = pyomo_expr.attr("args"); + oper->operand1 = appsi_operator_from_pyomo_expr(pyomo_args[0], var_map, + param_map, expr_types); + oper->operand2 = appsi_operator_from_pyomo_expr(pyomo_args[1], var_map, + param_map, expr_types); + num_nodes += build_expression_tree(pyomo_args[0], oper->operand1, var_map, + param_map, expr_types); + num_nodes += build_expression_tree(pyomo_args[1], oper->operand2, var_map, + param_map, expr_types); + } else if (appsi_expr->is_unary_operator()) { + num_nodes += 1; + std::shared_ptr oper = + std::dynamic_pointer_cast(appsi_expr); + py::list pyomo_args = pyomo_expr.attr("args"); + oper->operand = appsi_operator_from_pyomo_expr(pyomo_args[0], var_map, + param_map, expr_types); + num_nodes += build_expression_tree(pyomo_args[0], oper->operand, var_map, + param_map, expr_types); + } else if (appsi_expr->is_sum_operator()) { + num_nodes += 1; + std::shared_ptr oper = + std::dynamic_pointer_cast(appsi_expr); + py::list pyomo_args = pyomo_expr.attr("args"); + for (unsigned int arg_ndx = 0; arg_ndx < oper->nargs; ++arg_ndx) { + oper->operands[arg_ndx] = appsi_operator_from_pyomo_expr( + pyomo_args[arg_ndx], var_map, param_map, expr_types); + num_nodes += + build_expression_tree(pyomo_args[arg_ndx], oper->operands[arg_ndx], + var_map, param_map, expr_types); + } + } else if (appsi_expr->is_linear_operator()) { + num_nodes += 1; + std::shared_ptr oper = + std::dynamic_pointer_cast(appsi_expr); + oper->constant = appsi_expr_from_pyomo_expr(pyomo_expr.attr("constant"), + var_map, param_map, expr_types); + py::list pyomo_vars = pyomo_expr.attr("linear_vars"); + py::list pyomo_coefs = pyomo_expr.attr("linear_coefs"); + for (unsigned int arg_ndx = 0; arg_ndx < oper->nterms; ++arg_ndx) { + oper->variables[arg_ndx] = var_map[expr_types.id(pyomo_vars[arg_ndx])] + .cast>(); + oper->coefficients[arg_ndx] = appsi_expr_from_pyomo_expr( + pyomo_coefs[arg_ndx], var_map, param_map, expr_types); + } + } else if (appsi_expr->is_external_operator()) { + num_nodes += 1; + std::shared_ptr oper = + std::dynamic_pointer_cast(appsi_expr); + py::list pyomo_args = pyomo_expr.attr("args"); + for (unsigned int arg_ndx = 0; arg_ndx < oper->nargs; ++arg_ndx) { + oper->operands[arg_ndx] = appsi_operator_from_pyomo_expr( + pyomo_args[arg_ndx], var_map, param_map, expr_types); + num_nodes += + build_expression_tree(pyomo_args[arg_ndx], oper->operands[arg_ndx], + var_map, param_map, expr_types); + } + } else { + throw py::value_error( + "Unrecognized expression type: " + + expr_types.builtins.attr("str")(py::type::of(pyomo_expr)) + .cast()); + } + return num_nodes; +} + +std::shared_ptr +appsi_expr_from_pyomo_expr(py::handle expr, py::handle var_map, + py::handle param_map, PyomoExprTypes &expr_types) { + std::shared_ptr node = + appsi_operator_from_pyomo_expr(expr, var_map, param_map, expr_types); + int num_nodes = + build_expression_tree(expr, node, var_map, param_map, expr_types); + if (num_nodes == 0) { + return std::dynamic_pointer_cast(node); + } else { + std::shared_ptr res = std::make_shared(num_nodes); + node->fill_expression(res->operators, num_nodes); + return res; + } +} + +std::vector> +appsi_exprs_from_pyomo_exprs(py::list expr_list, py::dict var_map, + py::dict param_map) { + PyomoExprTypes expr_types = PyomoExprTypes(); + int num_exprs = expr_types.builtins.attr("len")(expr_list).cast(); + std::vector> res(num_exprs); + + int ndx = 0; + for (py::handle expr : expr_list) { + res[ndx] = appsi_expr_from_pyomo_expr(expr, var_map, param_map, expr_types); + ndx += 1; + } + return res; +} + +void process_pyomo_vars(PyomoExprTypes &expr_types, py::list pyomo_vars, + py::dict var_map, py::dict param_map, + py::dict var_attrs, py::dict rev_var_map, + py::bool_ _set_name, py::handle symbol_map, + py::handle labeler, py::bool_ _update) { + py::tuple v_attrs; + std::shared_ptr cv; + py::handle v_lb; + py::handle v_ub; + py::handle v_val; + py::tuple domain_interval; + py::handle interval_lb; + py::handle interval_ub; + py::handle interval_step; + bool v_fixed; + bool set_name = _set_name.cast(); + bool update = _update.cast(); + double domain_step; + + for (py::handle v : pyomo_vars) { + v_attrs = var_attrs[expr_types.id(v)]; + v_lb = v_attrs[1]; + v_ub = v_attrs[2]; + v_fixed = v_attrs[3].cast(); + domain_interval = v_attrs[4]; + v_val = v_attrs[5]; + + interval_lb = domain_interval[0]; + interval_ub = domain_interval[1]; + interval_step = domain_interval[2]; + domain_step = interval_step.cast(); + + if (update) { + cv = var_map[expr_types.id(v)].cast>(); + } else { + cv = std::make_shared(); + } + + if (!(v_lb.is(py::none()))) { + cv->lb = appsi_expr_from_pyomo_expr(v_lb, var_map, param_map, expr_types); + } else { + cv->lb = std::make_shared(-inf); + } + if (!(v_ub.is(py::none()))) { + cv->ub = appsi_expr_from_pyomo_expr(v_ub, var_map, param_map, expr_types); + } else { + cv->ub = std::make_shared(inf); + } + + if (!(v_val.is(py::none()))) { + cv->value = v_val.cast(); + } + + if (v_fixed) { + cv->fixed = true; + } else { + cv->fixed = false; + } + + if (set_name && !update) { + cv->name = symbol_map.attr("getSymbol")(v, labeler).cast(); + } + + if (interval_lb.is(py::none())) + cv->domain_lb = -inf; + else + cv->domain_lb = interval_lb.cast(); + if (interval_ub.is(py::none())) + cv->domain_ub = inf; + else + cv->domain_ub = interval_ub.cast(); + if (domain_step == 0) + cv->domain = continuous; + else if (domain_step == 1) { + if ((cv->domain_lb == 0) && (cv->domain_ub == 1)) + cv->domain = binary; + else + cv->domain = integers; + } else + throw py::value_error("Unrecognized domain step"); + + if (!update) { + var_map[expr_types.id(v)] = py::cast(cv); + rev_var_map[py::cast(cv)] = v; + } + } +} diff --git a/pyomo/contrib/appsi/cmodel/src/expression.hpp b/pyomo/contrib/appsi/cmodel/src/expression.hpp index 9a991102a90..e91ca0af3b3 100644 --- a/pyomo/contrib/appsi/cmodel/src/expression.hpp +++ b/pyomo/contrib/appsi/cmodel/src/expression.hpp @@ -1,788 +1,800 @@ -#ifndef EXPRESSION_HEADER -#define EXPRESSION_HEADER - -#include "interval.hpp" -#include - -class Node; -class ExpressionBase; -class Leaf; -class Var; -class Constant; -class Param; -class Expression; -class Operator; -class BinaryOperator; -class UnaryOperator; -class LinearOperator; -class SumOperator; -class MultiplyOperator; -class DivideOperator; -class PowerOperator; -class NegationOperator; -class ExpOperator; -class LogOperator; -class AbsOperator; -class ExternalOperator; -class PyomoExprTypes; - -extern double inf; - -class Node : public std::enable_shared_from_this { -public: - Node() = default; - virtual ~Node() = default; - virtual bool is_variable_type() { return false; } - virtual bool is_param_type() { return false; } - virtual bool is_expression_type() { return false; } - virtual bool is_operator_type() { return false; } - virtual bool is_constant_type() { return false; } - virtual bool is_leaf() { return false; } - virtual bool is_binary_operator() { return false; } - virtual bool is_unary_operator() { return false; } - virtual bool is_linear_operator() { return false; } - virtual bool is_sum_operator() { return false; } - virtual bool is_multiply_operator() { return false; } - virtual bool is_divide_operator() { return false; } - virtual bool is_power_operator() { return false; } - virtual bool is_negation_operator() { return false; } - virtual bool is_exp_operator() { return false; } - virtual bool is_log_operator() { return false; } - virtual bool is_abs_operator() { return false; } - virtual bool is_sqrt_operator() { return false; } - virtual bool is_external_operator() { return false; } - virtual double get_value_from_array(double *) = 0; - virtual int get_degree_from_array(int *) = 0; - virtual std::string get_string_from_array(std::string *) = 0; - virtual void fill_prefix_notation_stack( - std::shared_ptr>> stack) = 0; - virtual void write_nl_string(std::ofstream &) = 0; - virtual void fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) = 0; - virtual double get_lb_from_array(double *lbs) = 0; - virtual double get_ub_from_array(double *ubs) = 0; - virtual void - set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, - double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) = 0; -}; - -class ExpressionBase : public Node { -public: - ExpressionBase() = default; - virtual double evaluate() = 0; - virtual std::string __str__() = 0; - virtual std::shared_ptr>> - identify_variables() = 0; - virtual std::shared_ptr>> - identify_external_operators() = 0; - virtual std::shared_ptr>> - get_prefix_notation() = 0; - std::shared_ptr shared_from_this() { - return std::static_pointer_cast(Node::shared_from_this()); - } - void fill_prefix_notation_stack( - std::shared_ptr>> stack) override { - ; - } -}; - -class Leaf : public ExpressionBase { -public: - Leaf() = default; - Leaf(double value) : value(value) {} - virtual ~Leaf() = default; - double value = 0.0; - bool is_leaf() override; - double evaluate() override; - double get_value_from_array(double *) override; - std::string get_string_from_array(std::string *) override; - std::shared_ptr>> - get_prefix_notation() override; - void fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) override; - double get_lb_from_array(double *lbs) override; - double get_ub_from_array(double *ubs) override; - void - set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, - double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class Constant : public Leaf { -public: - Constant() = default; - Constant(double value) : Leaf(value) {} - bool is_constant_type() override; - std::string __str__() override; - int get_degree_from_array(int *) override; - std::shared_ptr>> - identify_variables() override; - std::shared_ptr>> - identify_external_operators() override; - void write_nl_string(std::ofstream &) override; -}; - -enum Domain { continuous, binary, integers }; - -class Var : public Leaf { -public: - Var() = default; - Var(double val) : Leaf(val) {} - Var(std::string _name) : name(_name) {} - Var(std::string _name, double val) : Leaf(val), name(_name) {} - std::string name = "v"; - std::string __str__() override; - std::shared_ptr lb; - std::shared_ptr ub; - int index = -1; - bool fixed = false; - double domain_lb = -inf; - double domain_ub = inf; - Domain domain = continuous; - bool is_variable_type() override; - int get_degree_from_array(int *) override; - std::shared_ptr>> - identify_variables() override; - std::shared_ptr>> - identify_external_operators() override; - void write_nl_string(std::ofstream &) override; - std::shared_ptr shared_from_this() { - return std::static_pointer_cast(Node::shared_from_this()); - } - double get_lb(); - double get_ub(); - Domain get_domain(); - double get_lb_from_array(double *lbs) override; - double get_ub_from_array(double *ubs) override; - void - set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, - double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class Param : public Leaf { -public: - Param() = default; - Param(double val) : Leaf(val) {} - Param(std::string _name) : name(_name) {} - Param(std::string _name, double val) : Leaf(val), name(_name) {} - std::string name = "p"; - std::string __str__() override; - bool is_param_type() override; - int get_degree_from_array(int *) override; - std::shared_ptr>> - identify_variables() override; - std::shared_ptr>> - identify_external_operators() override; - void write_nl_string(std::ofstream &) override; -}; - -class Expression : public ExpressionBase { -public: - Expression(int _n_operators) : ExpressionBase() { - operators = new std::shared_ptr[_n_operators]; - n_operators = _n_operators; - } - ~Expression() { delete[] operators; } - std::string __str__() override; - bool is_expression_type() override; - double evaluate() override; - double get_value_from_array(double *) override; - int get_degree_from_array(int *) override; - std::shared_ptr>> - identify_variables() override; - std::shared_ptr>> - identify_external_operators() override; - std::string get_string_from_array(std::string *) override; - std::shared_ptr>> - get_prefix_notation() override; - void write_nl_string(std::ofstream &) override; - std::vector> get_operators(); - std::shared_ptr *operators; - unsigned int n_operators; - void fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, double integer_tol); - void propagate_bounds_backward(double *lbs, double *ubs, - double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars); - double get_lb_from_array(double *lbs) override; - double get_ub_from_array(double *ubs) override; - void - set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, - double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class Operator : public Node { -public: - Operator() = default; - int index = 0; - virtual void evaluate(double *values) = 0; - virtual void propagate_degree_forward(int *degrees, double *values) = 0; - virtual void - identify_variables(std::set> &, - std::shared_ptr>>) = 0; - std::shared_ptr shared_from_this() { - return std::static_pointer_cast(Node::shared_from_this()); - } - bool is_operator_type() override; - double get_value_from_array(double *) override; - int get_degree_from_array(int *) override; - std::string get_string_from_array(std::string *) override; - virtual void print(std::string *) = 0; - virtual std::string name() = 0; - virtual void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol); - virtual void - propagate_bounds_backward(double *lbs, double *ubs, double feasibility_tol, - double integer_tol, double improvement_tol, - std::set> &improved_vars); - double get_lb_from_array(double *lbs) override; - double get_ub_from_array(double *ubs) override; - void - set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, - double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class BinaryOperator : public Operator { -public: - BinaryOperator() = default; - virtual ~BinaryOperator() = default; - void identify_variables( - std::set> &, - std::shared_ptr>>) override; - std::shared_ptr operand1; - std::shared_ptr operand2; - void fill_prefix_notation_stack( - std::shared_ptr>> stack) override; - bool is_binary_operator() override; - void fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) override; -}; - -class UnaryOperator : public Operator { -public: - UnaryOperator() = default; - virtual ~UnaryOperator() = default; - void identify_variables( - std::set> &, - std::shared_ptr>>) override; - std::shared_ptr operand; - void fill_prefix_notation_stack( - std::shared_ptr>> stack) override; - bool is_unary_operator() override; - void propagate_degree_forward(int *degrees, double *values) override; - void fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) override; -}; - -class LinearOperator : public Operator { -public: - LinearOperator(int _nterms) { - variables = new std::shared_ptr[_nterms]; - coefficients = new std::shared_ptr[_nterms]; - nterms = _nterms; - } - ~LinearOperator() { - delete[] variables; - delete[] coefficients; - } - void identify_variables( - std::set> &, - std::shared_ptr>>) override; - std::shared_ptr *variables; - std::shared_ptr *coefficients; - std::shared_ptr constant = std::make_shared(0); - void evaluate(double *values) override; - void propagate_degree_forward(int *degrees, double *values) override; - void print(std::string *) override; - std::string name() override { return "LinearOperator"; }; - void write_nl_string(std::ofstream &) override; - void fill_prefix_notation_stack( - std::shared_ptr>> stack) override; - bool is_linear_operator() override; - unsigned int nterms; - void fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class SumOperator : public Operator { -public: - SumOperator(int _nargs) { - operands = new std::shared_ptr[_nargs]; - nargs = _nargs; - } - ~SumOperator() { delete[] operands; } - void identify_variables( - std::set> &, - std::shared_ptr>>) override; - void evaluate(double *values) override; - void propagate_degree_forward(int *degrees, double *values) override; - void print(std::string *) override; - std::string name() override { return "SumOperator"; }; - void write_nl_string(std::ofstream &) override; - void fill_prefix_notation_stack( - std::shared_ptr>> stack) override; - bool is_sum_operator() override; - std::shared_ptr *operands; - unsigned int nargs; - void fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class MultiplyOperator : public BinaryOperator { -public: - MultiplyOperator() = default; - void evaluate(double *values) override; - void propagate_degree_forward(int *degrees, double *values) override; - void print(std::string *) override; - std::string name() override { return "MultiplyOperator"; }; - void write_nl_string(std::ofstream &) override; - bool is_multiply_operator() override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class ExternalOperator : public Operator { -public: - ExternalOperator(int _nargs) { - operands = new std::shared_ptr[_nargs]; - nargs = _nargs; - } - ~ExternalOperator() { delete[] operands; } - void evaluate(double *values) override; - void propagate_degree_forward(int *degrees, double *values) override; - void print(std::string *) override; - std::string name() override { return "ExternalOperator"; }; - void write_nl_string(std::ofstream &) override; - void fill_prefix_notation_stack( - std::shared_ptr>> stack) override; - void identify_variables( - std::set> &, - std::shared_ptr>>) override; - bool is_external_operator() override; - std::string function_name; - int external_function_index = -1; - std::shared_ptr *operands; - unsigned int nargs; - void fill_expression(std::shared_ptr *oper_array, - int &oper_ndx) override; -}; - -class DivideOperator : public BinaryOperator { -public: - DivideOperator() = default; - void evaluate(double *values) override; - void propagate_degree_forward(int *degrees, double *values) override; - void print(std::string *) override; - std::string name() override { return "DivideOperator"; }; - void write_nl_string(std::ofstream &) override; - bool is_divide_operator() override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class PowerOperator : public BinaryOperator { -public: - PowerOperator() = default; - void evaluate(double *values) override; - void propagate_degree_forward(int *degrees, double *values) override; - void print(std::string *) override; - std::string name() override { return "PowerOperator"; }; - void write_nl_string(std::ofstream &) override; - bool is_power_operator() override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class NegationOperator : public UnaryOperator { -public: - NegationOperator() = default; - void evaluate(double *values) override; - void propagate_degree_forward(int *degrees, double *values) override; - void print(std::string *) override; - std::string name() override { return "NegationOperator"; }; - void write_nl_string(std::ofstream &) override; - bool is_negation_operator() override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class ExpOperator : public UnaryOperator { -public: - ExpOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "ExpOperator"; }; - void write_nl_string(std::ofstream &) override; - bool is_exp_operator() override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class LogOperator : public UnaryOperator { -public: - LogOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "LogOperator"; }; - void write_nl_string(std::ofstream &) override; - bool is_log_operator() override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class AbsOperator : public UnaryOperator { -public: - AbsOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "AbsOperator"; }; - void write_nl_string(std::ofstream &) override; - bool is_abs_operator() override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class SqrtOperator : public UnaryOperator { -public: - SqrtOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "SqrtOperator"; }; - void write_nl_string(std::ofstream &) override; - bool is_sqrt_operator() override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class Log10Operator : public UnaryOperator { -public: - Log10Operator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "Log10Operator"; }; - void write_nl_string(std::ofstream &) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class SinOperator : public UnaryOperator { -public: - SinOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "SinOperator"; }; - void write_nl_string(std::ofstream &) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class CosOperator : public UnaryOperator { -public: - CosOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "CosOperator"; }; - void write_nl_string(std::ofstream &) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class TanOperator : public UnaryOperator { -public: - TanOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "TanOperator"; }; - void write_nl_string(std::ofstream &) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class AsinOperator : public UnaryOperator { -public: - AsinOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "AsinOperator"; }; - void write_nl_string(std::ofstream &) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class AcosOperator : public UnaryOperator { -public: - AcosOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "AcosOperator"; }; - void write_nl_string(std::ofstream &) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -class AtanOperator : public UnaryOperator { -public: - AtanOperator() = default; - void evaluate(double *values) override; - void print(std::string *) override; - std::string name() override { return "AtanOperator"; }; - void write_nl_string(std::ofstream &) override; - void propagate_bounds_forward(double *lbs, double *ubs, - double feasibility_tol, - double integer_tol) override; - void propagate_bounds_backward( - double *lbs, double *ubs, double feasibility_tol, double integer_tol, - double improvement_tol, - std::set> &improved_vars) override; -}; - -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] = power; - expr_type_map[NPV_PowExpression] = 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 builtins = py::module_::import("builtins"); - py::object id = builtins.attr("id"); - py::object len = builtins.attr("len"); - py::dict expr_type_map; -}; - -std::vector> create_vars(int n_vars); -std::vector> create_params(int n_params); -std::vector> create_constants(int n_constants); -std::shared_ptr -appsi_expr_from_pyomo_expr(py::handle expr, py::handle var_map, - py::handle param_map, PyomoExprTypes &expr_types); -std::vector> -appsi_exprs_from_pyomo_exprs(py::list expr_list, py::dict var_map, - py::dict param_map); -py::tuple prep_for_repn(py::handle expr, PyomoExprTypes &expr_types); - -void process_pyomo_vars(PyomoExprTypes &expr_types, py::list pyomo_vars, - py::dict var_map, py::dict param_map, - py::dict var_attrs, py::dict rev_var_map, - py::bool_ _set_name, py::handle symbol_map, - py::handle labeler, py::bool_ _update); - -#endif +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + +#ifndef EXPRESSION_HEADER +#define EXPRESSION_HEADER + +#include "interval.hpp" +#include + +class Node; +class ExpressionBase; +class Leaf; +class Var; +class Constant; +class Param; +class Expression; +class Operator; +class BinaryOperator; +class UnaryOperator; +class LinearOperator; +class SumOperator; +class MultiplyOperator; +class DivideOperator; +class PowerOperator; +class NegationOperator; +class ExpOperator; +class LogOperator; +class AbsOperator; +class ExternalOperator; +class PyomoExprTypes; + +extern double inf; + +class Node : public std::enable_shared_from_this { +public: + Node() = default; + virtual ~Node() = default; + virtual bool is_variable_type() { return false; } + virtual bool is_param_type() { return false; } + virtual bool is_expression_type() { return false; } + virtual bool is_operator_type() { return false; } + virtual bool is_constant_type() { return false; } + virtual bool is_leaf() { return false; } + virtual bool is_binary_operator() { return false; } + virtual bool is_unary_operator() { return false; } + virtual bool is_linear_operator() { return false; } + virtual bool is_sum_operator() { return false; } + virtual bool is_multiply_operator() { return false; } + virtual bool is_divide_operator() { return false; } + virtual bool is_power_operator() { return false; } + virtual bool is_negation_operator() { return false; } + virtual bool is_exp_operator() { return false; } + virtual bool is_log_operator() { return false; } + virtual bool is_abs_operator() { return false; } + virtual bool is_sqrt_operator() { return false; } + virtual bool is_external_operator() { return false; } + virtual double get_value_from_array(double *) = 0; + virtual int get_degree_from_array(int *) = 0; + virtual std::string get_string_from_array(std::string *) = 0; + virtual void fill_prefix_notation_stack( + std::shared_ptr>> stack) = 0; + virtual void write_nl_string(std::ofstream &) = 0; + virtual void fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) = 0; + virtual double get_lb_from_array(double *lbs) = 0; + virtual double get_ub_from_array(double *ubs) = 0; + virtual void + set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, + double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) = 0; +}; + +class ExpressionBase : public Node { +public: + ExpressionBase() = default; + virtual double evaluate() = 0; + virtual std::string __str__() = 0; + virtual std::shared_ptr>> + identify_variables() = 0; + virtual std::shared_ptr>> + identify_external_operators() = 0; + virtual std::shared_ptr>> + get_prefix_notation() = 0; + std::shared_ptr shared_from_this() { + return std::static_pointer_cast(Node::shared_from_this()); + } + void fill_prefix_notation_stack( + std::shared_ptr>> stack) override { + ; + } +}; + +class Leaf : public ExpressionBase { +public: + Leaf() = default; + Leaf(double value) : value(value) {} + virtual ~Leaf() = default; + double value = 0.0; + bool is_leaf() override; + double evaluate() override; + double get_value_from_array(double *) override; + std::string get_string_from_array(std::string *) override; + std::shared_ptr>> + get_prefix_notation() override; + void fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) override; + double get_lb_from_array(double *lbs) override; + double get_ub_from_array(double *ubs) override; + void + set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, + double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class Constant : public Leaf { +public: + Constant() = default; + Constant(double value) : Leaf(value) {} + bool is_constant_type() override; + std::string __str__() override; + int get_degree_from_array(int *) override; + std::shared_ptr>> + identify_variables() override; + std::shared_ptr>> + identify_external_operators() override; + void write_nl_string(std::ofstream &) override; +}; + +enum Domain { continuous, binary, integers }; + +class Var : public Leaf { +public: + Var() = default; + Var(double val) : Leaf(val) {} + Var(std::string _name) : name(_name) {} + Var(std::string _name, double val) : Leaf(val), name(_name) {} + std::string name = "v"; + std::string __str__() override; + std::shared_ptr lb; + std::shared_ptr ub; + int index = -1; + bool fixed = false; + double domain_lb = -inf; + double domain_ub = inf; + Domain domain = continuous; + bool is_variable_type() override; + int get_degree_from_array(int *) override; + std::shared_ptr>> + identify_variables() override; + std::shared_ptr>> + identify_external_operators() override; + void write_nl_string(std::ofstream &) override; + std::shared_ptr shared_from_this() { + return std::static_pointer_cast(Node::shared_from_this()); + } + double get_lb(); + double get_ub(); + Domain get_domain(); + double get_lb_from_array(double *lbs) override; + double get_ub_from_array(double *ubs) override; + void + set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, + double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class Param : public Leaf { +public: + Param() = default; + Param(double val) : Leaf(val) {} + Param(std::string _name) : name(_name) {} + Param(std::string _name, double val) : Leaf(val), name(_name) {} + std::string name = "p"; + std::string __str__() override; + bool is_param_type() override; + int get_degree_from_array(int *) override; + std::shared_ptr>> + identify_variables() override; + std::shared_ptr>> + identify_external_operators() override; + void write_nl_string(std::ofstream &) override; +}; + +class Expression : public ExpressionBase { +public: + Expression(int _n_operators) : ExpressionBase() { + operators = new std::shared_ptr[_n_operators]; + n_operators = _n_operators; + } + ~Expression() { delete[] operators; } + std::string __str__() override; + bool is_expression_type() override; + double evaluate() override; + double get_value_from_array(double *) override; + int get_degree_from_array(int *) override; + std::shared_ptr>> + identify_variables() override; + std::shared_ptr>> + identify_external_operators() override; + std::string get_string_from_array(std::string *) override; + std::shared_ptr>> + get_prefix_notation() override; + void write_nl_string(std::ofstream &) override; + std::vector> get_operators(); + std::shared_ptr *operators; + unsigned int n_operators; + void fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, double integer_tol); + void propagate_bounds_backward(double *lbs, double *ubs, + double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars); + double get_lb_from_array(double *lbs) override; + double get_ub_from_array(double *ubs) override; + void + set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, + double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class Operator : public Node { +public: + Operator() = default; + int index = 0; + virtual void evaluate(double *values) = 0; + virtual void propagate_degree_forward(int *degrees, double *values) = 0; + virtual void + identify_variables(std::set> &, + std::shared_ptr>>) = 0; + std::shared_ptr shared_from_this() { + return std::static_pointer_cast(Node::shared_from_this()); + } + bool is_operator_type() override; + double get_value_from_array(double *) override; + int get_degree_from_array(int *) override; + std::string get_string_from_array(std::string *) override; + virtual void print(std::string *) = 0; + virtual std::string name() = 0; + virtual void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol); + virtual void + propagate_bounds_backward(double *lbs, double *ubs, double feasibility_tol, + double integer_tol, double improvement_tol, + std::set> &improved_vars); + double get_lb_from_array(double *lbs) override; + double get_ub_from_array(double *ubs) override; + void + set_bounds_in_array(double new_lb, double new_ub, double *lbs, double *ubs, + double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class BinaryOperator : public Operator { +public: + BinaryOperator() = default; + virtual ~BinaryOperator() = default; + void identify_variables( + std::set> &, + std::shared_ptr>>) override; + std::shared_ptr operand1; + std::shared_ptr operand2; + void fill_prefix_notation_stack( + std::shared_ptr>> stack) override; + bool is_binary_operator() override; + void fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) override; +}; + +class UnaryOperator : public Operator { +public: + UnaryOperator() = default; + virtual ~UnaryOperator() = default; + void identify_variables( + std::set> &, + std::shared_ptr>>) override; + std::shared_ptr operand; + void fill_prefix_notation_stack( + std::shared_ptr>> stack) override; + bool is_unary_operator() override; + void propagate_degree_forward(int *degrees, double *values) override; + void fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) override; +}; + +class LinearOperator : public Operator { +public: + LinearOperator(int _nterms) { + variables = new std::shared_ptr[_nterms]; + coefficients = new std::shared_ptr[_nterms]; + nterms = _nterms; + } + ~LinearOperator() { + delete[] variables; + delete[] coefficients; + } + void identify_variables( + std::set> &, + std::shared_ptr>>) override; + std::shared_ptr *variables; + std::shared_ptr *coefficients; + std::shared_ptr constant = std::make_shared(0); + void evaluate(double *values) override; + void propagate_degree_forward(int *degrees, double *values) override; + void print(std::string *) override; + std::string name() override { return "LinearOperator"; }; + void write_nl_string(std::ofstream &) override; + void fill_prefix_notation_stack( + std::shared_ptr>> stack) override; + bool is_linear_operator() override; + unsigned int nterms; + void fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class SumOperator : public Operator { +public: + SumOperator(int _nargs) { + operands = new std::shared_ptr[_nargs]; + nargs = _nargs; + } + ~SumOperator() { delete[] operands; } + void identify_variables( + std::set> &, + std::shared_ptr>>) override; + void evaluate(double *values) override; + void propagate_degree_forward(int *degrees, double *values) override; + void print(std::string *) override; + std::string name() override { return "SumOperator"; }; + void write_nl_string(std::ofstream &) override; + void fill_prefix_notation_stack( + std::shared_ptr>> stack) override; + bool is_sum_operator() override; + std::shared_ptr *operands; + unsigned int nargs; + void fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class MultiplyOperator : public BinaryOperator { +public: + MultiplyOperator() = default; + void evaluate(double *values) override; + void propagate_degree_forward(int *degrees, double *values) override; + void print(std::string *) override; + std::string name() override { return "MultiplyOperator"; }; + void write_nl_string(std::ofstream &) override; + bool is_multiply_operator() override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class ExternalOperator : public Operator { +public: + ExternalOperator(int _nargs) { + operands = new std::shared_ptr[_nargs]; + nargs = _nargs; + } + ~ExternalOperator() { delete[] operands; } + void evaluate(double *values) override; + void propagate_degree_forward(int *degrees, double *values) override; + void print(std::string *) override; + std::string name() override { return "ExternalOperator"; }; + void write_nl_string(std::ofstream &) override; + void fill_prefix_notation_stack( + std::shared_ptr>> stack) override; + void identify_variables( + std::set> &, + std::shared_ptr>>) override; + bool is_external_operator() override; + std::string function_name; + int external_function_index = -1; + std::shared_ptr *operands; + unsigned int nargs; + void fill_expression(std::shared_ptr *oper_array, + int &oper_ndx) override; +}; + +class DivideOperator : public BinaryOperator { +public: + DivideOperator() = default; + void evaluate(double *values) override; + void propagate_degree_forward(int *degrees, double *values) override; + void print(std::string *) override; + std::string name() override { return "DivideOperator"; }; + void write_nl_string(std::ofstream &) override; + bool is_divide_operator() override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class PowerOperator : public BinaryOperator { +public: + PowerOperator() = default; + void evaluate(double *values) override; + void propagate_degree_forward(int *degrees, double *values) override; + void print(std::string *) override; + std::string name() override { return "PowerOperator"; }; + void write_nl_string(std::ofstream &) override; + bool is_power_operator() override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class NegationOperator : public UnaryOperator { +public: + NegationOperator() = default; + void evaluate(double *values) override; + void propagate_degree_forward(int *degrees, double *values) override; + void print(std::string *) override; + std::string name() override { return "NegationOperator"; }; + void write_nl_string(std::ofstream &) override; + bool is_negation_operator() override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class ExpOperator : public UnaryOperator { +public: + ExpOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "ExpOperator"; }; + void write_nl_string(std::ofstream &) override; + bool is_exp_operator() override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class LogOperator : public UnaryOperator { +public: + LogOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "LogOperator"; }; + void write_nl_string(std::ofstream &) override; + bool is_log_operator() override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class AbsOperator : public UnaryOperator { +public: + AbsOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "AbsOperator"; }; + void write_nl_string(std::ofstream &) override; + bool is_abs_operator() override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class SqrtOperator : public UnaryOperator { +public: + SqrtOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "SqrtOperator"; }; + void write_nl_string(std::ofstream &) override; + bool is_sqrt_operator() override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class Log10Operator : public UnaryOperator { +public: + Log10Operator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "Log10Operator"; }; + void write_nl_string(std::ofstream &) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class SinOperator : public UnaryOperator { +public: + SinOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "SinOperator"; }; + void write_nl_string(std::ofstream &) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class CosOperator : public UnaryOperator { +public: + CosOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "CosOperator"; }; + void write_nl_string(std::ofstream &) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class TanOperator : public UnaryOperator { +public: + TanOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "TanOperator"; }; + void write_nl_string(std::ofstream &) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class AsinOperator : public UnaryOperator { +public: + AsinOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "AsinOperator"; }; + void write_nl_string(std::ofstream &) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class AcosOperator : public UnaryOperator { +public: + AcosOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "AcosOperator"; }; + void write_nl_string(std::ofstream &) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +class AtanOperator : public UnaryOperator { +public: + AtanOperator() = default; + void evaluate(double *values) override; + void print(std::string *) override; + std::string name() override { return "AtanOperator"; }; + void write_nl_string(std::ofstream &) override; + void propagate_bounds_forward(double *lbs, double *ubs, + double feasibility_tol, + double integer_tol) override; + void propagate_bounds_backward( + double *lbs, double *ubs, double feasibility_tol, double integer_tol, + double improvement_tol, + std::set> &improved_vars) override; +}; + +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[VarData] = 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] = power; + expr_type_map[NPV_PowExpression] = 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[ExpressionData] = 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 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"); + 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 ExpressionData = + expr_module.attr("ExpressionData"); + 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 builtins = py::module_::import("builtins"); + py::object id = builtins.attr("id"); + py::object len = builtins.attr("len"); + py::dict expr_type_map; +}; + +std::vector> create_vars(int n_vars); +std::vector> create_params(int n_params); +std::vector> create_constants(int n_constants); +std::shared_ptr +appsi_expr_from_pyomo_expr(py::handle expr, py::handle var_map, + py::handle param_map, PyomoExprTypes &expr_types); +std::vector> +appsi_exprs_from_pyomo_exprs(py::list expr_list, py::dict var_map, + py::dict param_map); +py::tuple prep_for_repn(py::handle expr, PyomoExprTypes &expr_types); + +void process_pyomo_vars(PyomoExprTypes &expr_types, py::list pyomo_vars, + py::dict var_map, py::dict param_map, + py::dict var_attrs, py::dict rev_var_map, + py::bool_ _set_name, py::handle symbol_map, + py::handle labeler, py::bool_ _update); + +#endif diff --git a/pyomo/contrib/appsi/cmodel/src/fbbt_model.cpp b/pyomo/contrib/appsi/cmodel/src/fbbt_model.cpp index 2e490659fab..ca865d429e2 100644 --- a/pyomo/contrib/appsi/cmodel/src/fbbt_model.cpp +++ b/pyomo/contrib/appsi/cmodel/src/fbbt_model.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "fbbt_model.hpp" FBBTObjective::FBBTObjective(std::shared_ptr _expr) @@ -193,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 3d1c3a76caa..ca1980a797b 100644 --- a/pyomo/contrib/appsi/cmodel/src/fbbt_model.hpp +++ b/pyomo/contrib/appsi/cmodel/src/fbbt_model.hpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "model_base.hpp" class FBBTConstraint; diff --git a/pyomo/contrib/appsi/cmodel/src/interval.cpp b/pyomo/contrib/appsi/cmodel/src/interval.cpp index f0a1aa2c2bb..1d9b3a6f82e 100644 --- a/pyomo/contrib/appsi/cmodel/src/interval.cpp +++ b/pyomo/contrib/appsi/cmodel/src/interval.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "interval.hpp" bool _is_inf(double x) { diff --git a/pyomo/contrib/appsi/cmodel/src/interval.hpp b/pyomo/contrib/appsi/cmodel/src/interval.hpp index c35438887dd..a57f107f8db 100644 --- a/pyomo/contrib/appsi/cmodel/src/interval.hpp +++ b/pyomo/contrib/appsi/cmodel/src/interval.hpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #ifndef INTERVAL_HEADER #define INTERVAL_HEADER diff --git a/pyomo/contrib/appsi/cmodel/src/lp_writer.cpp b/pyomo/contrib/appsi/cmodel/src/lp_writer.cpp index 1ce421b7c97..f33060ee523 100644 --- a/pyomo/contrib/appsi/cmodel/src/lp_writer.cpp +++ b/pyomo/contrib/appsi/cmodel/src/lp_writer.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "lp_writer.hpp" void write_expr(std::ofstream &f, std::shared_ptr obj, @@ -277,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 ee4ad77500a..0b2e2882510 100644 --- a/pyomo/contrib/appsi/cmodel/src/lp_writer.hpp +++ b/pyomo/contrib/appsi/cmodel/src/lp_writer.hpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "model_base.hpp" class LPBase; diff --git a/pyomo/contrib/appsi/cmodel/src/model_base.cpp b/pyomo/contrib/appsi/cmodel/src/model_base.cpp index ab0b25d8e0d..b0ae4013b32 100644 --- a/pyomo/contrib/appsi/cmodel/src/model_base.cpp +++ b/pyomo/contrib/appsi/cmodel/src/model_base.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "model_base.hpp" bool constraint_sorter(std::shared_ptr c1, diff --git a/pyomo/contrib/appsi/cmodel/src/model_base.hpp b/pyomo/contrib/appsi/cmodel/src/model_base.hpp index bc61bc053de..a47f1d14a0b 100644 --- a/pyomo/contrib/appsi/cmodel/src/model_base.hpp +++ b/pyomo/contrib/appsi/cmodel/src/model_base.hpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #ifndef MODEL_HEADER #define MODEL_HEADER diff --git a/pyomo/contrib/appsi/cmodel/src/nl_writer.cpp b/pyomo/contrib/appsi/cmodel/src/nl_writer.cpp index dc7004abc16..854262496ea 100644 --- a/pyomo/contrib/appsi/cmodel/src/nl_writer.cpp +++ b/pyomo/contrib/appsi/cmodel/src/nl_writer.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "nl_writer.hpp" NLBase::NLBase( @@ -515,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 40e4c9b1222..b7439875301 100644 --- a/pyomo/contrib/appsi/cmodel/src/nl_writer.hpp +++ b/pyomo/contrib/appsi/cmodel/src/nl_writer.hpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include "model_base.hpp" class NLBase; diff --git a/pyomo/contrib/appsi/cmodel/tests/__init__.py b/pyomo/contrib/appsi/cmodel/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/appsi/cmodel/tests/__init__.py +++ b/pyomo/contrib/appsi/cmodel/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/appsi/cmodel/tests/test_import.py b/pyomo/contrib/appsi/cmodel/tests/test_import.py index f4647c216ba..76eda902ac0 100644 --- a/pyomo/contrib/appsi/cmodel/tests/test_import.py +++ b/pyomo/contrib/appsi/cmodel/tests/test_import.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import unittest from pyomo.common.fileutils import find_library, this_file_dir import os diff --git a/pyomo/contrib/appsi/examples/__init__.py b/pyomo/contrib/appsi/examples/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/appsi/examples/__init__.py +++ b/pyomo/contrib/appsi/examples/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/appsi/examples/getting_started.py b/pyomo/contrib/appsi/examples/getting_started.py index de22d28e0a4..6bc42d1d377 100644 --- a/pyomo/contrib/appsi/examples/getting_started.py +++ b/pyomo/contrib/appsi/examples/getting_started.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pe from pyomo.contrib import appsi from pyomo.common.timing import HierarchicalTimer diff --git a/pyomo/contrib/appsi/examples/tests/__init__.py b/pyomo/contrib/appsi/examples/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/appsi/examples/tests/__init__.py +++ b/pyomo/contrib/appsi/examples/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/appsi/examples/tests/test_examples.py b/pyomo/contrib/appsi/examples/tests/test_examples.py index d2c88224a7d..a7608d36b98 100644 --- a/pyomo/contrib/appsi/examples/tests/test_examples.py +++ b/pyomo/contrib/appsi/examples/tests/test_examples.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.appsi.examples import getting_started import pyomo.common.unittest as unittest import pyomo.environ as pe diff --git a/pyomo/contrib/appsi/fbbt.py b/pyomo/contrib/appsi/fbbt.py index 92a0e0c8cbc..0422fd2f5bf 100644 --- a/pyomo/contrib/appsi/fbbt.py +++ b/pyomo/contrib/appsi/fbbt.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.appsi.base import PersistentBase from pyomo.common.config import ( ConfigDict, @@ -7,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 @@ -110,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 @@ -132,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] @@ -143,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, @@ -158,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) @@ -173,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) @@ -187,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, @@ -213,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 @@ -264,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) @@ -293,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 5333158239e..3e1b639ce3b 100644 --- a/pyomo/contrib/appsi/plugins.py +++ b/pyomo/contrib/appsi/plugins.py @@ -1,23 +1,35 @@ +# ___________________________________________________________________________ +# +# 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.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 def load(): ExtensionBuilderFactory.register('appsi')(AppsiBuilder) SolverFactory.register( - name='appsi_gurobi', doc='Automated persistent interface to Gurobi' + name='gurobi', doc='Automated persistent interface to Gurobi' )(Gurobi) + SolverFactory.register(name='cplex', doc='Automated persistent interface to Cplex')( + Cplex + ) + SolverFactory.register(name='ipopt', doc='Automated persistent interface to Ipopt')( + Ipopt + ) + SolverFactory.register(name='cbc', doc='Automated persistent interface to Cbc')(Cbc) + SolverFactory.register(name='highs', doc='Automated persistent interface to Highs')( + Highs + ) SolverFactory.register( - name='appsi_cplex', doc='Automated persistent interface to Cplex' - )(Cplex) - SolverFactory.register( - name='appsi_ipopt', doc='Automated persistent interface to Ipopt' - )(Ipopt) - SolverFactory.register( - name='appsi_cbc', doc='Automated persistent interface to Cbc' - )(Cbc) - SolverFactory.register( - name='appsi_highs', doc='Automated persistent interface to Highs' - )(Highs) + 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 20755d1eb07..352571b98f8 100644 --- a/pyomo/contrib/appsi/solvers/__init__.py +++ b/pyomo/contrib/appsi/solvers/__init__.py @@ -1,6 +1,18 @@ +# ___________________________________________________________________________ +# +# 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 .gurobi import Gurobi, GurobiResults from .ipopt import Ipopt from .cbc import Cbc 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 a3aae2a9213..08833e747e2 100644 --- a/pyomo/contrib/appsi/solvers/cbc.py +++ b/pyomo/contrib/appsi/solvers/cbc.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.tempfiles import TempfileManager from pyomo.common.fileutils import Executable from pyomo.contrib.appsi.base import ( @@ -15,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 @@ -153,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): @@ -400,9 +411,11 @@ def _check_and_escape_options(): if cp.returncode != 0: if self.config.load_solution: 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 ' + 'A feasible solution was not found, so no solution can be loaded. ' + 'If using the appsi.solvers.Cbc interface, you can ' + 'set opt.config.load_solution=False. If using the environ.SolverFactory ' + 'interface, you can set opt.solve(model, load_solutions = False). ' + 'Then you can check results.termination_condition and ' 'results.best_feasible_objective before loading a solution.' ) results = Results() @@ -427,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 @@ -464,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 f03bee6ecc5..10de981ce7d 100644 --- a/pyomo/contrib/appsi/solvers/cplex.py +++ b/pyomo/contrib/appsi/solvers/cplex.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.tempfiles import TempfileManager from pyomo.contrib.appsi.base import ( PersistentSolver, @@ -11,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 @@ -168,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): @@ -330,9 +341,11 @@ def _postsolve(self, timer: HierarchicalTimer, solve_time): if config.load_solution: if cpxprob.solution.get_solution_type() == cpxprob.solution.type.none: raise RuntimeError( - 'A feasible solution was not found, so no solution can be loades. ' - 'Please set opt.config.load_solution=False and check ' - 'results.termination_condition and ' + 'A feasible solution was not found, so no solution can be loaded. ' + 'If using the appsi.solvers.Cplex interface, you can ' + 'set opt.config.load_solution=False. If using the environ.SolverFactory ' + 'interface, you can set opt.solve(model, load_solutions = False). ' + 'Then you can check results.termination_condition and ' 'results.best_feasible_objective before loading a solution.' ) else: @@ -349,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 @@ -376,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 @@ -427,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 a173c69abc6..10f3c5bf62c 100644 --- a/pyomo/contrib/appsi/solvers/gurobi.py +++ b/pyomo/contrib/appsi/solvers/gurobi.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 collections.abc import Iterable import logging import math @@ -12,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 @@ -356,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) @@ -447,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() @@ -478,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): @@ -568,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) ( @@ -698,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 @@ -724,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() @@ -738,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() @@ -748,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: @@ -760,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: @@ -935,9 +945,11 @@ def _postsolve(self, timer: HierarchicalTimer): 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 ' + 'A feasible solution was not found, so no solution can be loaded. ' + 'If using the appsi.solvers.Gurobi interface, you can ' + 'set opt.config.load_solution=False. If using the environ.SolverFactory ' + 'interface, you can set opt.solve(model, load_solutions = False). ' + 'Then you can check results.termination_condition and ' 'results.best_feasible_objective before loading a solution.' ) timer.stop('load solution') @@ -1182,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 @@ -1208,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 @@ -1243,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 @@ -1259,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 @@ -1275,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 @@ -1291,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 @@ -1412,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: @@ -1497,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 3d498f9388e..f9f2c759459 100644 --- a/pyomo/contrib/appsi/solvers/highs.py +++ b/pyomo/contrib/appsi/solvers/highs.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 from typing import List, Dict, Optional from pyomo.common.collections import ComponentMap @@ -9,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 @@ -165,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 @@ -214,22 +233,23 @@ 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) + timer.start('optimize') + ostreams[-1].write("RUN!\n") + self._solver_model.HandleKeyboardInterrupt = True + self._solver_model.run() + timer.stop('optimize') return self._postsolve(timer) @@ -297,7 +317,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() @@ -324,7 +344,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): @@ -353,19 +373,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() @@ -445,13 +464,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() @@ -462,7 +481,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() @@ -476,13 +495,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() @@ -504,10 +523,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() @@ -669,9 +688,11 @@ def _postsolve(self, timer: HierarchicalTimer): 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 ' + 'A feasible solution was not found, so no solution can be loaded. ' + 'If using the appsi.solvers.Highs interface, you can ' + 'set opt.config.load_solution=False. If using the environ.SolverFactory ' + 'interface, you can set opt.solve(model, load_solutions = False). ' + 'Then you can check results.termination_condition and ' 'results.best_feasible_objective before loading a solution.' ) timer.stop('load solution') diff --git a/pyomo/contrib/appsi/solvers/ipopt.py b/pyomo/contrib/appsi/solvers/ipopt.py index d38a836a2ac..af40d2e88d2 100644 --- a/pyomo/contrib/appsi/solvers/ipopt.py +++ b/pyomo/contrib/appsi/solvers/ipopt.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.tempfiles import TempfileManager from pyomo.common.fileutils import Executable from pyomo.contrib.appsi.base import ( @@ -17,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 @@ -136,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: @@ -147,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, @@ -216,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): @@ -410,9 +422,11 @@ def _parse_sol(self): results.best_feasible_objective = value(obj_expr_evaluated) elif self.config.load_solution: 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 ' + 'A feasible solution was not found, so no solution can be loaded. ' + 'If using the appsi.solvers.Ipopt interface, you can ' + 'set opt.config.load_solution=False. If using the environ.SolverFactory ' + 'interface, you can set opt.solve(model, load_solutions = False). ' + 'Then you can check results.termination_condition and ' 'results.best_feasible_objective before loading a solution.' ) @@ -500,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 @@ -520,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 @@ -539,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 @@ -555,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..062ea09004e --- /dev/null +++ b/pyomo/contrib/appsi/solvers/maingo.py @@ -0,0 +1,549 @@ +# ___________________________________________________________________________ +# +# 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 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..b12a386284c --- /dev/null +++ b/pyomo/contrib/appsi/solvers/maingo_solvermodel.py @@ -0,0 +1,283 @@ +# ___________________________________________________________________________ +# +# 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 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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/appsi/solvers/tests/__init__.py +++ b/pyomo/contrib/appsi/solvers/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/appsi/solvers/tests/test_gurobi_persistent.py b/pyomo/contrib/appsi/solvers/tests/test_gurobi_persistent.py index b032f5c827e..d7893464b1a 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_gurobi_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_gurobi_persistent.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.errors import PyomoException import pyomo.common.unittest as unittest import pyomo.environ as pe @@ -180,12 +191,16 @@ 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 # - # 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 = pe.ConcreteModel() m.x = pe.Var(bounds=(-5, 5)) m.y = pe.Var(bounds=(-5, 5)) @@ -197,14 +212,22 @@ def test_nonconvex_qcp_objective_bound_1(self): 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 + # Update: as of Gurobi 11, this test no longer tests the + # intended behavior (the solver has improved) m = pe.ConcreteModel() m.x = pe.Var(bounds=(-5, 5)) m.y = pe.Var(bounds=(-5, 5)) @@ -216,7 +239,10 @@ def test_nonconvex_qcp_objective_bound_2(self): 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() diff --git a/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py b/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py index 6451db18087..4d8251e0de9 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 subprocess import sys @@ -69,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 = pe.ConcreteModel() + m.x = pe.Var(domain=pe.Binary) + m.y = pe.Var(domain=pe.Binary) + m.fx = pe.Var(domain=pe.NonNegativeReals) + m.fy = pe.Var(domain=pe.NonNegativeReals) + m.c1 = pe.Constraint(expr=m.fx <= m.x) + m.c2 = pe.Constraint(expr=m.fy <= m.y) + m.c3 = pe.Constraint(expr=m.x + m.y <= 1) + + m.obj = pe.Objective(expr=m.fx * 0.5 + m.fy * 0.4, sense=pe.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 # diff --git a/pyomo/contrib/appsi/solvers/tests/test_ipopt_persistent.py b/pyomo/contrib/appsi/solvers/tests/test_ipopt_persistent.py index 6b86deaa535..8e6473a6b01 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_ipopt_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_ipopt_persistent.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pe import pyomo.common.unittest as unittest from pyomo.contrib.appsi.cmodel import cmodel_available diff --git a/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py b/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py index 33f6877aaf8..67088297cf4 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py +++ b/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pe from pyomo.common.dependencies import attempt_import import pyomo.common.unittest as unittest @@ -6,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 @@ -25,11 +36,23 @@ ('cplex', Cplex), ('cbc', Cbc), ('highs', Highs), + ('maingo', MAiNGO), +] +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), ] -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)] +miqcqp_solvers = [('gurobi', Gurobi), ('cplex', Cplex), ('maingo', MAiNGO)] only_child_vars_options = [True, False] @@ -161,14 +184,16 @@ def test_range_constraint( 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) m.obj.sense = pe.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( @@ -185,9 +210,10 @@ def test_reduced_costs( 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( @@ -202,14 +228,16 @@ def test_reduced_costs2( 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) m.obj.sense = pe.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( @@ -241,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( @@ -279,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( @@ -313,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( @@ -383,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( @@ -412,9 +444,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)) m.c3 = pe.Constraint(expr=m.y >= a3 * m.x + b3) res = opt.solve(m) @@ -423,10 +456,11 @@ def test_add_remove_cons( 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) @@ -435,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( @@ -476,14 +511,15 @@ 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): @@ -500,13 +536,14 @@ def test_duals(self, name: str, opt_class: Type[PersistentSolver], only_child_va 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( @@ -661,7 +698,7 @@ 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() @@ -754,17 +791,19 @@ def test_mutable_param_with_range( 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( @@ -826,13 +865,13 @@ def test_exp(self, name: str, opt_class: Type[PersistentSolver], only_child_vars m.obj = pe.Objective(expr=m.x**2 + m.y**2) m.c1 = pe.Constraint(expr=m.y >= pe.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) @@ -907,6 +946,27 @@ def test_bounds_with_params( 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 = pe.ConcreteModel() + m.p = pe.Param(mutable=False, initialize=1) + m.q = pe.Param([1, 2], mutable=False, initialize=10) + m.y = pe.Var() + m.y.setlb(m.p) + m.y.setub(m.q[1]) + m.obj = pe.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 @@ -941,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 @@ -1046,13 +1107,14 @@ def test_objective_changes( m.obj.sense = pe.maximize opt.config.load_solution = False res = opt.solve(m) - self.assertIn( - res.termination_condition, - { - TerminationCondition.unbounded, - TerminationCondition.infeasibleOrUnbounded, - }, - ) + if opt_class != MAiNGO: + self.assertIn( + res.termination_condition, + { + TerminationCondition.unbounded, + TerminationCondition.infeasibleOrUnbounded, + }, + ) m.obj.sense = pe.minimize opt.config.load_solution = True m.obj = pe.Objective(expr=m.x * m.y) @@ -1149,19 +1211,19 @@ def test_fixed_binaries( m.c = pe.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( @@ -1185,16 +1247,16 @@ def test_with_gdp( pe.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]): @@ -1327,7 +1389,8 @@ def test_param_updates(self, name: str, opt_class: Type[PersistentSolver]): 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) + if opt_class != MAiNGO: + m.dual = pe.Suffix(direction=pe.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: @@ -1339,8 +1402,9 @@ def test_param_updates(self, name: str, opt_class: Type[PersistentSolver]): pe.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]): @@ -1351,11 +1415,14 @@ def test_load_solutions(self, name: str, opt_class: Type[PersistentSolver]): 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) + if opt_class != MAiNGO: + m.dual = pe.Suffix(direction=pe.Suffix.IMPORT) res = opt.solve(m, load_solutions=False) pe.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 d250923f104..6fb25bfb529 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_wntr_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_wntr_persistent.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pe import pyomo.common.unittest as unittest from pyomo.contrib.appsi.base import TerminationCondition, Results, PersistentSolver diff --git a/pyomo/contrib/appsi/solvers/wntr.py b/pyomo/contrib/appsi/solvers/wntr.py index 0a358c6aedf..0a66cc640e5 100644 --- a/pyomo/contrib/appsi/solvers/wntr.py +++ b/pyomo/contrib/appsi/solvers/wntr.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.appsi.base import ( PersistentBase, PersistentSolver, @@ -28,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 @@ -158,14 +169,16 @@ def _solve(self, timer: HierarchicalTimer): timer.stop('load solution') 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 ' + 'A feasible solution was not found, so no solution can be loaded. ' + 'If using the appsi.solvers.Wntr interface, you can ' + 'set opt.config.load_solution=False. If using the environ.SolverFactory ' + 'interface, you can set opt.solve(model, load_solutions = False). ' + 'Then you can check results.termination_condition and ' 'results.best_feasible_objective before loading a solution.' ) 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() @@ -226,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) @@ -257,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) @@ -265,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: @@ -281,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) @@ -289,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] @@ -301,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] @@ -309,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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/appsi/tests/__init__.py +++ b/pyomo/contrib/appsi/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/appsi/tests/test_base.py b/pyomo/contrib/appsi/tests/test_base.py index 0d67ca4d01a..e537cc0f219 100644 --- a/pyomo/contrib/appsi/tests/test_base.py +++ b/pyomo/contrib/appsi/tests/test_base.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import unittest from pyomo.contrib import appsi import pyomo.environ as pe diff --git a/pyomo/contrib/appsi/tests/test_fbbt.py b/pyomo/contrib/appsi/tests/test_fbbt.py index f92960769cf..97af611c572 100644 --- a/pyomo/contrib/appsi/tests/test_fbbt.py +++ b/pyomo/contrib/appsi/tests/test_fbbt.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import unittest import pyomo.environ as pyo from pyomo.contrib import appsi @@ -140,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 7963cc31665..2184f69621a 100644 --- a/pyomo/contrib/appsi/tests/test_interval.py +++ b/pyomo/contrib/appsi/tests/test_interval.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.appsi.cmodel import cmodel, cmodel_available import pyomo.common.unittest as unittest import math diff --git a/pyomo/contrib/appsi/tests/test_ipopt.py b/pyomo/contrib/appsi/tests/test_ipopt.py new file mode 100644 index 00000000000..b3697b9b233 --- /dev/null +++ b/pyomo/contrib/appsi/tests/test_ipopt.py @@ -0,0 +1,42 @@ +# ___________________________________________________________________________ +# +# 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 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 f665736fd4a..e1278431835 100644 --- a/pyomo/contrib/appsi/utils/__init__.py +++ b/pyomo/contrib/appsi/utils/__init__.py @@ -1,2 +1,13 @@ +# ___________________________________________________________________________ +# +# 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 .get_objective import get_objective from .collect_vars_and_named_exprs import collect_vars_and_named_exprs 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 9027080f08c..4e117b04094 100644 --- a/pyomo/contrib/appsi/utils/collect_vars_and_named_exprs.py +++ b/pyomo/contrib/appsi/utils/collect_vars_and_named_exprs.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 diff --git a/pyomo/contrib/appsi/utils/get_objective.py b/pyomo/contrib/appsi/utils/get_objective.py index 30dd911f9c8..110c0188d16 100644 --- a/pyomo/contrib/appsi/utils/get_objective.py +++ b/pyomo/contrib/appsi/utils/get_objective.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.base.objective import Objective diff --git a/pyomo/contrib/appsi/utils/tests/__init__.py b/pyomo/contrib/appsi/utils/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/appsi/utils/tests/__init__.py +++ b/pyomo/contrib/appsi/utils/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 4c2a167a017..62f98728850 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,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import unittest import pyomo.environ as pe from pyomo.contrib.appsi.utils import collect_vars_and_named_exprs diff --git a/pyomo/contrib/appsi/writers/__init__.py b/pyomo/contrib/appsi/writers/__init__.py index eeadfa73d03..18f90e8aa96 100644 --- a/pyomo/contrib/appsi/writers/__init__.py +++ b/pyomo/contrib/appsi/writers/__init__.py @@ -1,2 +1,13 @@ +# ___________________________________________________________________________ +# +# 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 .nl_writer import NLWriter from .lp_writer import LPWriter diff --git a/pyomo/contrib/appsi/writers/config.py b/pyomo/contrib/appsi/writers/config.py index 7a7faadaabe..32d45325e96 100644 --- a/pyomo/contrib/appsi/writers/config.py +++ b/pyomo/contrib/appsi/writers/config.py @@ -1,3 +1,15 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + class WriterConfig(object): def __init__(self): self.symbolic_solver_labels = False diff --git a/pyomo/contrib/appsi/writers/lp_writer.py b/pyomo/contrib/appsi/writers/lp_writer.py index 8a76fa5f9eb..788dfde7892 100644 --- a/pyomo/contrib/appsi/writers/lp_writer.py +++ b/pyomo/contrib/appsi/writers/lp_writer.py @@ -1,10 +1,21 @@ +# ___________________________________________________________________________ +# +# 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 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 @@ -66,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, @@ -80,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] @@ -88,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, @@ -136,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, @@ -156,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 9c739fd6ebb..27cdca004cb 100644 --- a/pyomo/contrib/appsi/writers/nl_writer.py +++ b/pyomo/contrib/appsi/writers/nl_writer.py @@ -1,10 +1,21 @@ +# ___________________________________________________________________________ +# +# 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 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 @@ -67,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 @@ -89,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] @@ -100,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, @@ -115,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) @@ -129,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) @@ -142,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) @@ -150,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, @@ -169,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() @@ -221,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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/appsi/writers/tests/__init__.py +++ b/pyomo/contrib/appsi/writers/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/appsi/writers/tests/test_nl_writer.py b/pyomo/contrib/appsi/writers/tests/test_nl_writer.py index 3b61a5901c3..c6005afceb2 100644 --- a/pyomo/contrib/appsi/writers/tests/test_nl_writer.py +++ b/pyomo/contrib/appsi/writers/tests/test_nl_writer.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest from pyomo.common.tempfiles import TempfileManager import pyomo.environ as pe diff --git a/pyomo/contrib/benders/__init__.py b/pyomo/contrib/benders/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/benders/__init__.py +++ b/pyomo/contrib/benders/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/benders/benders_cuts.py b/pyomo/contrib/benders/benders_cuts.py index 5eb2e91cc82..7ad892904df 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-2022 +# 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 @@ -9,71 +9,76 @@ # 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:: -Original problem: + \min\ & f(x, y) + h0(y) \\ + s.t.\ & g(x, y) <= 0 \\ + & h(y) <= 0 + +where y are the complicating variables. Reformulate to + +.. math:: + + \min\ & h0(y) + \eta \\ + s.t.\ & g(x, y) <= 0 \\ + & f(x, y) <= \eta \\ + & h(y) <= 0 -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:: + + \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 + +.. math:: + + \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 + +.. math:: + + \min\ & _z & \\ + s.t.\ & g(x, y) - z <= 0 & \quad (\alpha) \\ + & f(x, y) - \eta - z <= 0 & \quad (\beta) \\ + & y - y_k = 0 & \quad (\gamma) \\ + & \eta - \eta_k = 0 & \quad (\delta) \\ + """ @@ -166,13 +171,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() @@ -335,7 +340,6 @@ def generate_cut(self): subproblem_solver.remove_constraint(c) subproblem_solver.remove_constraint(subproblem.fix_eta) del subproblem.fix_complicating_vars - del subproblem.fix_complicating_vars_index del subproblem.fix_eta total_num_subproblems = self.global_num_subproblems() diff --git a/pyomo/contrib/benders/examples/__init__.py b/pyomo/contrib/benders/examples/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/benders/examples/__init__.py +++ b/pyomo/contrib/benders/examples/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/benders/examples/farmer.py b/pyomo/contrib/benders/examples/farmer.py index bf5d40e112c..47cdb3511a3 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-2022 +# 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 diff --git a/pyomo/contrib/benders/examples/grothey_ex.py b/pyomo/contrib/benders/examples/grothey_ex.py index 66457fa7293..27d37cac124 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-2022 +# 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 diff --git a/pyomo/contrib/benders/tests/__init__.py b/pyomo/contrib/benders/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/benders/tests/__init__.py +++ b/pyomo/contrib/benders/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/benders/tests/test_benders.py b/pyomo/contrib/benders/tests/test_benders.py index 26a2a0b7910..d985f886c10 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-2022 +# 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 @@ -10,35 +10,24 @@ # ___________________________________________________________________________ import pyomo.common.unittest as unittest -from pyomo.contrib.benders.benders_cuts import BendersCutGenerator import pyomo.environ as pyo -try: - import mpi4py - - mpi4py_available = True -except: - mpi4py_available = False -try: - import numpy as np - - numpy_available = True -except: - numpy_available = False - +from pyomo.common.dependencies import mpi4py_available, numpy_available +from pyomo.contrib.benders.benders_cuts import BendersCutGenerator -ipopt_opt = pyo.SolverFactory('ipopt') -ipopt_available = ipopt_opt.available(exception_flag=False) +ipopt_available = pyo.SolverFactory('ipopt').available(exception_flag=False) -cplex_opt = pyo.SolverFactory('cplex_direct') -cplex_available = cplex_opt.available(exception_flag=False) +for mip_name in ('cplex_direct', 'gurobi_direct', 'gurobi', 'cplex', 'glpk', 'cbc'): + mip_available = pyo.SolverFactory(mip_name).available(exception_flag=False) + if mip_available: + break @unittest.pytest.mark.mpi class MPITestBenders(unittest.TestCase): @unittest.skipIf(not mpi4py_available, 'mpi4py is not available.') @unittest.skipIf(not numpy_available, 'numpy is not available.') - @unittest.skipIf(not cplex_available, 'cplex is not available.') + @unittest.skipIf(not mip_available, 'MIP solver is not available.') def test_farmer(self): class Farmer(object): def __init__(self): @@ -200,9 +189,9 @@ def EnforceQuotas_rule(m, i): subproblem_fn=create_subproblem, subproblem_fn_kwargs=subproblem_fn_kwargs, root_eta=m.eta[s], - subproblem_solver='cplex_direct', + subproblem_solver=mip_name, ) - opt = pyo.SolverFactory('cplex_direct') + opt = pyo.SolverFactory(mip_name) for i in range(30): res = opt.solve(m, tee=False) @@ -261,7 +250,7 @@ def create_subproblem(root): @unittest.skipIf(not mpi4py_available, 'mpi4py is not available.') @unittest.skipIf(not numpy_available, 'numpy is not available.') - @unittest.skipIf(not cplex_available, 'cplex is not available.') + @unittest.skipIf(not mip_available, 'MIP solver is not available.') def test_four_scen_farmer(self): class FourScenFarmer(object): def __init__(self): @@ -430,9 +419,9 @@ def EnforceQuotas_rule(m, i): subproblem_fn=create_subproblem, subproblem_fn_kwargs=subproblem_fn_kwargs, root_eta=m.eta[s], - subproblem_solver='cplex_direct', + subproblem_solver=mip_name, ) - opt = pyo.SolverFactory('cplex_direct') + opt = pyo.SolverFactory(mip_name) for i in range(30): res = opt.solve(m, tee=False) diff --git a/pyomo/contrib/community_detection/__init__.py b/pyomo/contrib/community_detection/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/community_detection/__init__.py +++ b/pyomo/contrib/community_detection/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/community_detection/community_graph.py b/pyomo/contrib/community_detection/community_graph.py index f0a1f9149bd..c67a8cd6690 100644 --- a/pyomo/contrib/community_detection/community_graph.py +++ b/pyomo/contrib/community_detection/community_graph.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Model Graph Generator Code""" from pyomo.common.dependencies import networkx as nx @@ -112,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 c5366394530..e1393014752 100644 --- a/pyomo/contrib/community_detection/detection.py +++ b/pyomo/contrib/community_detection/detection.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ Main module for community detection integration with Pyomo models. @@ -20,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 @@ -569,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() @@ -605,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() @@ -739,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 30e28257de8..13226ade9d5 100644 --- a/pyomo/contrib/community_detection/event_log.py +++ b/pyomo/contrib/community_detection/event_log.py @@ -1,4 +1,15 @@ -""" Logger function for community_graph.py """ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +"""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 0cdc95ad02a..229b7255a27 100644 --- a/pyomo/contrib/community_detection/plugins.py +++ b/pyomo/contrib/community_detection/plugins.py @@ -1,2 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + def load(): import pyomo.contrib.community_detection.detection diff --git a/pyomo/contrib/community_detection/tests/__init__.py b/pyomo/contrib/community_detection/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/community_detection/tests/__init__.py +++ b/pyomo/contrib/community_detection/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/community_detection/tests/test_detection.py b/pyomo/contrib/community_detection/tests/test_detection.py index acfd441005f..6a43ea1b61a 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-2022 +# 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 diff --git a/pyomo/contrib/cp/__init__.py b/pyomo/contrib/cp/__init__.py index c51160bf931..f285cd6be68 100644 --- a/pyomo/contrib/cp/__init__.py +++ b/pyomo/contrib/cp/__init__.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.cp.interval_var import ( IntervalVar, IntervalVarStartTime, @@ -6,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 4e22c2b2d3d..013fa145b15 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-2022 +# 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 @@ -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 445599daab0..b0f7c84eb65 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-2022 +# 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 diff --git a/pyomo/contrib/cp/repn/__init__.py b/pyomo/contrib/cp/repn/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/cp/repn/__init__.py +++ b/pyomo/contrib/cp/repn/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/cp/repn/docplex_writer.py b/pyomo/contrib/cp/repn/docplex_writer.py index 51c3f66140e..6a0eb7749a8 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-2022 +# 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 @@ -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 -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))) @@ -805,6 +856,14 @@ def _handle_at_least_node(visitor, node, *args): ) +def _handle_all_diff_node(visitor, node, *args): + return (_GENERAL, cp.all_diff(_get_int_valued_expr(arg) for arg in args)) + + +def _handle_count_if_node(visitor, node, *args): + return (_GENERAL, cp.count((_get_bool_valued_expr(arg) for arg in args), 1)) + + ## CallExpression handllers @@ -902,46 +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.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, @@ -950,16 +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, } def __init__(self, cpx_model, symbolic_solver_labels=False): @@ -993,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 @@ -1005,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) @@ -1055,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( @@ -1284,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 8b137891791..a4a626013c4 100644 --- a/pyomo/contrib/cp/scheduling_expr/__init__.py +++ b/pyomo/contrib/cp/scheduling_expr/__init__.py @@ -1 +1,10 @@ - +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/cp/scheduling_expr/precedence_expressions.py b/pyomo/contrib/cp/scheduling_expr/precedence_expressions.py index 5340583a216..1bdf6c4b48b 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-2022 +# 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 @@ -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..e5695b57c5c --- /dev/null +++ b/pyomo/contrib/cp/scheduling_expr/scheduling_logic.py @@ -0,0 +1,72 @@ +# ___________________________________________________________________________ +# +# 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.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..3ba799074de --- /dev/null +++ b/pyomo/contrib/cp/scheduling_expr/sequence_expressions.py @@ -0,0 +1,173 @@ +# ___________________________________________________________________________ +# +# 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.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 b4f8fbb4977..5bf5b8324b3 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-2022 +# 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 @@ -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..cb42f445dc3 --- /dev/null +++ b/pyomo/contrib/cp/sequence_var.py @@ -0,0 +1,151 @@ +# ___________________________________________________________________________ +# +# 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 + +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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/cp/tests/__init__.py +++ b/pyomo/contrib/cp/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/cp/tests/test_docplex_walker.py b/pyomo/contrib/cp/tests/test_docplex_walker.py index 97bc538c827..f7abb3d2b3c 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-2022 +# 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 @@ -11,17 +11,28 @@ 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 from pyomo.core.expr.numeric_expr import MinExpression, MaxExpression -from pyomo.core.expr.logical_expr import equivalent, exactly, atleast, atmost +from pyomo.core.expr.logical_expr import ( + equivalent, + exactly, + atleast, + atmost, + all_different, + count_if, +) from pyomo.core.expr.relational_expr import NotEqualExpression from pyomo.environ import ( @@ -39,8 +50,6 @@ Integers, inequality, Expression, - Reals, - Set, Param, ) @@ -91,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 @@ -106,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 @@ -121,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 @@ -136,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) @@ -147,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 @@ -160,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 @@ -171,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))) @@ -185,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))) @@ -202,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): @@ -219,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)) @@ -232,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)) @@ -249,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']) @@ -260,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)) @@ -273,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() @@ -301,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 @@ -318,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 @@ -348,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 @@ -360,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)) @@ -377,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( @@ -396,11 +472,46 @@ 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)) ) + def test_all_diff_expression(self): + m = self.get_model() + m.a.domain = Integers + m.a.bounds = (11, 20) + m.c = LogicalConstraint(expr=all_different(m.a)) + + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + a = {} + 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))) + + def test_count_if_expression(self): + m = self.get_model() + m.a.domain = Integers + m.a.bounds = (11, 20) + m.c = Constraint(expr=count_if(m.a[i] == i for i in m.I) == 5) + + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.expr, m.c, 0)) + + a = {} + 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)) + def test_interval_var_is_present(self): m = self.get_model() m.a.domain = Integers @@ -416,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 @@ -449,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]) @@ -483,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)) @@ -514,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)) @@ -552,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)) @@ -583,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]) @@ -612,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]) @@ -646,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)) @@ -665,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)) @@ -684,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)) @@ -703,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): @@ -716,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) @@ -726,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()) @@ -746,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)) @@ -763,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): @@ -782,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))) @@ -796,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))) @@ -810,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))) @@ -824,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))) @@ -838,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))) @@ -852,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))) @@ -866,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))) @@ -880,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))) @@ -904,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( @@ -930,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( @@ -957,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( @@ -984,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( @@ -1009,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( @@ -1034,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( @@ -1068,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( @@ -1105,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( @@ -1140,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( @@ -1187,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): @@ -1212,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( @@ -1227,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): @@ -1240,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)) @@ -1253,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)))) @@ -1283,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)) @@ -1297,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)))) @@ -1310,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))) @@ -1334,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( @@ -1356,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( @@ -1379,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( @@ -1404,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( @@ -1434,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 d569ef2e696..4f6039993c3 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-2022 +# 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 @@ -12,13 +12,25 @@ 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, + count_if, ConcreteModel, Set, Var, Integers, + Param, LogicalConstraint, implies, value, @@ -254,3 +266,159 @@ def x_bounds(m, i): self.assertEqual(results.problem.sense, minimize) self.assertEqual(results.problem.lower_bound, 6) self.assertEqual(results.problem.upper_bound, 6) + + def test_matching_problem(self): + m = ConcreteModel() + + m.People = Set(initialize=['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7']) + m.Languages = Set(initialize=['English', 'Spanish', 'Hindi', 'Swedish']) + # People have integer names because we don't have categorical vars yet. + m.Names = Set(initialize=range(len(m.People))) + + m.Observed = Param( + m.Names, + m.Names, + m.Languages, + initialize={ + (0, 1, 'English'): 1, + (1, 0, 'English'): 1, + (0, 2, 'English'): 1, + (2, 0, 'English'): 1, + (0, 3, 'English'): 1, + (3, 0, 'English'): 1, + (0, 4, 'English'): 1, + (4, 0, 'English'): 1, + (0, 5, 'English'): 1, + (5, 0, 'English'): 1, + (0, 6, 'English'): 1, + (6, 0, 'English'): 1, + (1, 2, 'Spanish'): 1, + (2, 1, 'Spanish'): 1, + (1, 5, 'Hindi'): 1, + (5, 1, 'Hindi'): 1, + (1, 6, 'Hindi'): 1, + (6, 1, 'Hindi'): 1, + (2, 3, 'Swedish'): 1, + (3, 2, 'Swedish'): 1, + (3, 4, 'English'): 1, + (4, 3, 'English'): 1, + }, + default=0, + mutable=True, + ) # TODO: shouldn't need to + # be mutable, but waiting + # on #3045 + + m.Expected = Param( + m.People, + m.People, + m.Languages, + initialize={ + ('P1', 'P2', 'English'): 1, + ('P2', 'P1', 'English'): 1, + ('P1', 'P3', 'English'): 1, + ('P3', 'P1', 'English'): 1, + ('P1', 'P4', 'English'): 1, + ('P4', 'P1', 'English'): 1, + ('P1', 'P5', 'English'): 1, + ('P5', 'P1', 'English'): 1, + ('P1', 'P6', 'English'): 1, + ('P6', 'P1', 'English'): 1, + ('P1', 'P7', 'English'): 1, + ('P7', 'P1', 'English'): 1, + ('P2', 'P3', 'Spanish'): 1, + ('P3', 'P2', 'Spanish'): 1, + ('P2', 'P6', 'Hindi'): 1, + ('P6', 'P2', 'Hindi'): 1, + ('P2', 'P7', 'Hindi'): 1, + ('P7', 'P2', 'Hindi'): 1, + ('P3', 'P4', 'Swedish'): 1, + ('P4', 'P3', 'Swedish'): 1, + ('P4', 'P5', 'English'): 1, + ('P5', 'P4', 'English'): 1, + }, + default=0, + mutable=True, + ) # TODO: shouldn't need to be mutable, but + # waiting on #3045 + + m.person_name = Var(m.People, bounds=(0, max(m.Names)), domain=Integers) + + m.one_to_one = LogicalConstraint( + expr=all_different(m.person_name[person] for person in m.People) + ) + + m.obj = Objective( + expr=count_if( + m.Observed[m.person_name[p1], m.person_name[p2], l] + == m.Expected[p1, p2, l] + for p1 in m.People + for p2 in m.People + for l in m.Languages + ), + sense=maximize, + ) + + results = SolverFactory('cp_optimizer').solve(m) + + # we can get one of two perfect matches: + perfect = 7 * 7 * 4 + self.assertEqual(results.problem.lower_bound, perfect) + self.assertEqual(results.problem.upper_bound, perfect) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual(value(m.obj), perfect) + self.assertEqual(value(m.person_name['P1']), 0) + self.assertEqual(value(m.person_name['P2']), 1) + self.assertEqual(value(m.person_name['P3']), 2) + self.assertEqual(value(m.person_name['P4']), 3) + self.assertEqual(value(m.person_name['P5']), 4) + # We can't distinguish P6 and P7, so they could each have either of + # names 5 and 6 + self.assertTrue( + value(m.person_name['P6']) == 5 or value(m.person_name['P6']) == 6 + ) + self.assertTrue( + value(m.person_name['P7']) == 5 or value(m.person_name['P7']) == 6 + ) + + m.person_name['P6'].fix(5) + m.person_name['P7'].fix(6) + + results = SolverFactory('cp_optimizer').solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual(value(m.obj), perfect) + + m.person_name['P6'].fix(6) + m.person_name['P7'].fix(5) + + results = SolverFactory('cp_optimizer').solve(m) + self.assertEqual( + 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 edbf889fcda..e44ba00210d 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-2022 +# 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 @@ -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 c6733f34f83..d940468900b 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-2022 +# 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 @@ -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 461dabf564c..3faf054f241 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-2022 +# 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 @@ -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..c7cf94f23d5 --- /dev/null +++ b/pyomo/contrib/cp/tests/test_sequence_expressions.py @@ -0,0 +1,175 @@ +# ___________________________________________________________________________ +# +# 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 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..c1e205c6326 --- /dev/null +++ b/pyomo/contrib/cp/tests/test_sequence_var.py @@ -0,0 +1,158 @@ +# ___________________________________________________________________________ +# +# 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 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 7212cc870d5..a7b30c1d4e6 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-2022 +# 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 diff --git a/pyomo/contrib/cp/transform/__init__.py b/pyomo/contrib/cp/transform/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/cp/transform/__init__.py +++ b/pyomo/contrib/cp/transform/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/cp/transform/logical_to_disjunctive_program.py b/pyomo/contrib/cp/transform/logical_to_disjunctive_program.py index cd7681d4d87..7c5ef8d13c0 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-2022 +# 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 @@ -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 624629d326d..95cbaf57fa5 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-2022 +# 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 @@ -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/doe/__init__.py b/pyomo/contrib/doe/__init__.py index e38b5dce1d9..14589244135 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-2022 +# 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 .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 b451c431f21..3a616b714b8 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-2022 +# 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 @@ -25,853 +25,743 @@ # 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 pathlib import Path + +from pyomo.common.dependencies import ( + numpy as np, + numpy_available, + pandas as pd, + 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 -class CalculationMode(Enum): - sequential_finite = "sequential_finite" - direct_kaug = "direct_kaug" +from pyomo.opt import SolverStatus 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 [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. - """ - - # 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, + # 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 ) - # for simultaneous mode, FIM and Jacobian are extracted with extract_FIM() - analysis_square.result_analysis(result=result_square) - analysis_square.model = m + # 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 + ) - self.analysis_square = analysis_square - return m, analysis_square + 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)) + ) - def _optimize_stochastic_program(self, m): - """ - Solve the stochastic program problem as an optimization problem. - """ + L_vals_sq = np.linalg.cholesky(fim_np) + 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. + + 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 + ) + ) - # 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 + return self._computed_FIM - self.FIM_store_name = FIM_store_name - self.specified_prior = specified_prior + # 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. + + """ + # 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." + ) - # 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 + # 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 + + # 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." + ) - 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 + # Extract the measurement values for the scenario and append + measurement_vals.append( + [pyo.value(k) for k, v in model.experiment_outputs.items()] ) - elif self.mode == CalculationMode.direct_kaug: - FIM_analysis = self._direct_kaug() + # Use the measurement outputs to make the Q matrix + measurement_vals_np = np.array(measurement_vals).T - dT = square_timer.toc(msg=None) - self.logger.info("elapsed time: %0.1f" % dT) + self.seq_jac = np.zeros( + ( + len(model.experiment_outputs.items()), + len(model.unknown_parameters.items()), + ) + ) - return FIM_analysis + # Counting variable for loop + i = 0 - def _sequential_finite(self, read_output, extract_single_model, store_output): - """Sequential_finite mode uses Pyomo Block to evaluate the sensitivity information.""" + # Loop over parameter values and grab correct columns for finite difference calculation - # 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) + for k, v in model.unknown_parameters.items(): + curr_step = v * self.step - # 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, - ) + 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 - return FIM_analysis + # 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 - def _direct_kaug(self): - # create model - mod = self.create_model(model_option=ModelOptionLib.parmest) + # Calculate the column of the sensitivity matrix + self.seq_jac[:, i] = ( + measurement_vals_np[:, col_1] - measurement_vals_np[:, col_2] + ) * scale_factor - # discretize if needed - if self.discretize_model: - mod = self.discretize_model(mod, block=False) + # Increment the count + i += 1 - # add objective function - mod.Obj = pyo.Objective(expr=0, sense=pyo.minimize) + # 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 - # 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]) + # Compute and record FIM + self.seq_FIM = self.seq_jac.T @ cov_y @ self.seq_jac + self.prior_FIM - # generate parameter name list and value dictionary with index - var_name = list(self.param.keys()) + # 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. - # 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 - ) + 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) + sensi *= v + jac[p].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 - - # Create a global model - mod = pyo.ConcreteModel() - - # Set for block/scenarios - mod.scenario = pyo.Set(initialize=self.scenario_data.scenario_indices) + # record kaug jacobian + self.kaug_jac = np.array(jac).T - # 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 +769,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 - """ - r_list = list(range(len(m.regression_parameters))) + # 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 + """ + 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 +1221,1012 @@ 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] + ) + ) + + self.logger.info("FIM provided matches expected dimensions from model.") + + # 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]) + + ME_opt = np.log10(np.linalg.cond(FIM)) + + # 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 m + return self.fim_factorial_results - def _fix_design(self, m, design_val, fix_opt=True, optimize_option=None): + # 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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_experiment_input_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_input_values`" + ) + + y_hat_vals = [ + pyo.value(k) + for k, v in model.scenario_blocks[0].measurement_error.items() + ] + else: + y_hat_vals = [pyo.value(k) for k, v in model.measurement_error.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_experiment_input_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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/doe/examples/__init__.py +++ b/pyomo/contrib/doe/examples/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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": "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\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": "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2yIismLFCnnw4IFOWVJSkixevFgASKdOnXSWnTlzRgCIq6urXLlyRWdZbGysrFy5Uu7evatTPnz4cAEgXbt21Tk+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": "iVBORw0KGgoAAAANSUhEUgAAAl0AAAHZCAYAAAC8S454AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAABw7UlEQVR4nO3dd1gUV/828HuWsnSkqFioKiJYsDeIvcRujB1jjdHYEmMSTVPzmPIkMYlojEaNGhETuzGxPokoYo+KoqJiA5UiSO+w5/3Dl/1JKAK77Cxwf65rr4SZszPf3VncmzlnzkhCCAEiIiIiqlQKuQsgIiIiqgkYuoiIiIh0gKGLiIiISAcYuoiIiIh0gKGLiIiISAcYuoiIiIh0gKGLiIiISAcYuoiIiIh0gKGLiIiISAcYuoiISC/dv38fkiTBxcVF7lJKNWnSJEiShE2bNhVavmnTJkiShEmTJslSF+kfhi6iYri4uECSpEIPExMTuLq6ws/PD+fPn5e7xHJLSkrCkiVL8P3338tdClXQvz+XCoUCVlZWcHR0RJ8+ffDRRx/h+vXrcpdZZt9//z2WLFmCpKQkuUvRKf4u1lwMXUSlaNKkCbp27YquXbuiSZMmiImJwdatW9G5c2ds2bJF7vLKJSkpCUuXLuU/9NVAweeyS5cucHd3h4GBAf73v//hs88+g5eXF1599VUkJCTIXeYLff/991i6dGmJocvIyAhNmzZFo0aNdFuYllhbW6Np06aoV69eoeX8Xay5DOUugEifffDBB4W6BhITEzF9+nTs3LkTs2bNwqBBg2BjYyNfgVQj/ftzCQDx8fHYunUrli1bhl27duHatWs4c+YMrK2t5SlSCxo0aIDw8HC5y6iw4cOHY/jw4XKXQXqEZ7qIysHGxgYbNmyAubk5UlNTceTIEblLIgIA2NvbY968ebhw4QLq1auH8PBwvPXWW3KXRUTPYegiKicrKyu4u7sDeDbQtziHDx/GkCFDULduXSiVSjRs2BCTJ0/GnTt3im1/5swZvPfee2jXrh3q1KkDpVIJR0dHTJgwAdeuXSu1nps3b2L69Olo3LgxTE1NYWdnh7Zt22Lx4sWIjo4G8Gygr6urKwDgwYMHRcar/duff/6J/v37w97eHkqlEq6urnjzzTcRFRVVbA0FY43u37+PY8eO4eWXX4a9vT0kSUJQUFCp9Zf3tRQ4evQoZs+ejVatWsHW1hYmJiZo1KgRZs6cicjIyGK3n5eXhxUrVqBDhw6wtLSEUqlE/fr10aVLFyxevLjYbq68vDysWbMGPj4+qFWrFkxMTODh4YGPPvoIKSkpZX5tuuLs7IzVq1cDAAICAko8ZiXJzc3FypUr0aFDB1hZWcHc3BytWrXCZ599hoyMjCLtnx/sLoTAypUr0aJFC5iZmaFOnTqYMGFCkeNRMMD8wYMHAABXV9dCn8eCz0xpA+mf/+zu2bMHXbp0gYWFBerWrYuJEyciJiZG3Xbjxo1o27YtzM3NUadOHcyYMQPJyclFtpmfn499+/ZhypQp8PLygrW1NczMzNCsWTO89957iI+PL9d7WdxA+rL8Lo4ZMwaSJGH58uUlbnvnzp2QJAnt27cvV00kM0FERTg7OwsAYuPGjcWub9q0qQAg/P39i6ybN2+eACAAiDp16ojWrVsLKysrAUBYWVmJkJCQIs9p1KiRACDs7OxE8+bNRatWrYS1tbUAIExNTcWxY8eKrSMgIEAYGxur27Vp00Z4eHgIpVJZqP7PPvtMtGvXTgAQSqVSdO3atdDjeQsXLlTX37BhQ9G2bVthZmYmAAgbGxtx/vz5Et+vzz//XCgUCmFjYyPat28vGjZsWGLtFX0tBQwMDIQkSaJOnTrC29tbNG/eXJibm6vfx2vXrhXZx4gRI9SvrVGjRqJ9+/bC0dFRGBgYCADi0qVLhdonJyeLl156SQAQCoVCODs7i+bNm6vrbNasmYiNjS3T69OGF30uC+Tn54v69esLAGL9+vVl3n5GRobo2bOn+j1q1qyZaNmypVAoFAKA8Pb2FvHx8YWec+/ePQFAODs7i5kzZwoAwsnJSbRt21aYmJgIAKJ27doiPDxc/ZwDBw6Irl27qo9tu3btCn0eL168WGTb/1ZQo7+/v/qz2qpVK/U2PT09RWZmppg7d64AINzc3ISXl5cwNDQUAES3bt2ESqUqtM2oqCj1sa5Xr576M1jwOlxcXERMTEyRWiZOnFjscdm4caMAICZOnKheVpbfxcOHDwsAokWLFiUeq0GDBgkAYtWqVSW2If3D0EVUjNK+3G7duqX+h/vEiROF1q1Zs0YAEK6uroXCRl5enli2bJn6yyEzM7PQ8zZv3izu3LlTaFlubq5Yv369MDQ0FG5ubiI/P7/Q+vPnzwsjIyMBQLz33nsiLS1NvS4nJ0ds27ZNBAcHq5eV9gVWYP/+/QKAMDQ0FAEBAerlycnJYvjw4eovnoyMjGLfLwMDA7F06VKRm5srhBBCpVKJrKysEvdX0dcihBBr164Vjx49KrQsIyNDfPbZZwKA6N69e6F1Fy5cEACEo6OjuH79eqF1ycnJYt26dSIyMrLQ8jFjxggAolevXoWOz9OnT8Urr7wiAIhXX331ha9PW8oauoT4v4D5xhtvlHn777zzjgAg6tevL/755x/18tu3bwsPDw8BQIwaNarQcwo+V4aGhsLIyEhs27ZNvS4+Pl707t1bABAdOnQoEnIKXs+9e/eKracsocvc3FwEBgaql0dFRYnGjRsLAGLYsGHC2tpa/O9//1Ovv3LlirC1tRUAxIEDBwptMykpSWzatEkkJCQUWp6YmChmz54tAIhJkyYVqaU8oetFr0uIZ6HZyclJAFAH0OfFxsYKQ0NDYWxsXKRW0m8MXUTFKO7LLTk5WRw9elR4enoKAEXOEGVnZwsHBwdhYGBQ7D+UQvzfF+Evv/xS5lr8/PwEgCJnyAYMGCAAiClTppRpO2UJXV27dhUAxLx584qsS09PF/b29gKA2LBhQ6F1Be/X4MGDy1TLv5X3tbyIj4+PACAePnyoXrZt2zYBQLz99ttl2kZoaKj6/UpJSSmyPj09XTg6OgpJksT9+/e1UveLlCd0vfXWWwKAGD58eJm2nZycrD6juWfPniLrz507JwAISZJERESEennB5wqAmDt3bpHnxcbGqs8U/f3338W+Hk1CV3Gf1bVr16rXf/fdd0XWF5zNLa7e0jg6OgozMzP1HxUFtB26hBDi448/LvH1ffvttzoP/KQdHNNFVIrJkyerx1pYW1ujT58+CA8Px+jRo7F///5CbU+fPo2YmBi0adMGrVu3LnZ7Q4YMAQAcP368yLrw8HAsXrwYr7zyCrp37w4fHx/4+Pio24aGhqrbZmZm4ujRowCA9957TyuvNS0tDadPnwYAzJkzp8h6MzMzvP766wBQ4gUEr732Wrn3q8lruXDhAhYuXIghQ4agW7du6vfs1q1bAIArV66o2zo6OgIA/vrrLzx9+vSF296zZw8AYNSoUbC0tCyy3szMDL1794YQAsHBweWqWxfMzc0BAKmpqWVqf/LkSWRkZMDJyQlDhw4tsr59+/bo3LkzhBDq4/Vvs2bNKrKsTp06ePXVVwE8G+uobVOnTi2yzNvbW/3/U6ZMKbK+4Pfz7t27xW7z77//xttvv42BAwfipZdeUn+ukpOTkZGRgdu3b2un+FIU/NsTGBiI3NzcQus2b94MAJx0tQrilBFEpWjSpAnq1KkDIQRiYmJw9+5dGBkZoX379kWmirh69SqAZ4N/fXx8it1ewUDtR48eFVr+xRdf4KOPPoJKpSqxlueDQkREBHJzc1GrVi00bdq0Ii+tiIiICKhUKiiVSri5uRXbxsvLCwDUoebfmjVrVqH9lve1CCEwe/Zs9YDxkjz/nnXu3BkdO3bE2bNn1ZOJvvTSS+jWrRvatGlT5IKCguO5Z88enDp1qtjtFwwE//fx1AdpaWkAnl34URYFx9TDw6PYiyuAZ8f/9OnTxR5/IyMjNG7cuNjnFXwuSvrcaKK4Obxq166t/m9xr79gfcF7VCAnJwejR4/G3r17S91nWUK7plxdXdG9e3ccO3YMBw8eVP/BFhoaitDQUDg4OKB///6VXgdpF0MXUSn+PR9SSEgIhg0bhgULFqBu3brw8/NTryu4GurJkyd48uRJqdvNzMxU//+JEyfwwQcfwMDAAF988QWGDBkCZ2dnmJmZQZIkfPTRR/jss88K/bVbcNVcrVq1tPAqnyn4Aqpdu3aJX7p169YFUPLZk4KzK+VRkdeyZcsWrF69Gubm5vj666/Rp08fNGjQAKampgAAPz8/bN26tdB7plAocPDgQSxduhQBAQHYt28f9u3bB+DZFX9LliwpdKwLjmdERAQiIiJKref541mSmJgY9Rmf57Vu3RorV6584fPLq+CKwTp16pSpfcHxL619acffzs4OCkXxnScv+txowszMrMiygs9vceueXy+EKLT8yy+/xN69e+Hg4ICvvvoKL730EhwcHKBUKgEAPj4+CAkJKXLmqbJMmTIFx44dw+bNm9Whq+Asl5+fHwwMDHRSB2kPQxdROXTt2hXr1q3D8OHDMW/ePAwZMkT9l7SFhQUAYPz48QgICCjzNrdu3QoAePfdd7Fw4cIi64u75L+gu0ubt08pqP/JkycQQhQbvGJjYwvtXxsq8loK3rPly5fjjTfeKLK+pGkSbGxs8P333+O7775DaGgoTpw4gb179+LYsWOYPHkyLCws1MGo4P1Yt24dpk2bVp6XVKysrCyEhIQUWW5oqP1/hlUqlbqruEOHDmV6TsHrjYuLK7FNacc/ISEBKpWq2OBVsE1tfm4qQ8HnatOmTejXr1+R9eWdfkNTI0aMwOzZs/HHH38gISEB1tbWCAwMBMCuxaqKY7qIymnYsGHo1KkTnj59im+//Va93NPTEwAQFhZWru0VzPXVpUuXYtc/P5arQJMmTWBsbIykpCTcvHmzTPsp6exVgcaNG0OhUCA7O7vEsS4Fc4YVzFOmDRV5LaW9Z7m5ubhx40apz5ckCd7e3pg7dy7+/vtvddhdt26duk1Fj2dJCuax+vejPPOYldXevXsRExMDIyMj9O3bt0zPKTimN27cKHIGqEBpxz83N7fEeegKjse/n/eiz6Sulfa5SkhI0Fo3cllft6mpKcaMGYOcnBxs27YNBw8eRGxsLNq1a6fu6qeqhaGLqAIKvqT9/f3V3TK+vr6wt7dHaGhoub5IC7rECs4iPO/IkSPFhi5TU1P1l+k333xTrv2U1BVmYWGh/rIprrsrMzMT69evB4BizwJUlCavpbj3bOPGjS/s3v23Tp06AQAeP36sXlZw+5aAgIAqcR/DAg8ePMDs2bMBPLuwoUGDBmV6no+PD8zMzBAVFaXudn3ehQsXcPr0aUiShD59+hS7jeLG2D158gQ7duwAgCIB8EWfSV0r7XO1fPly5Ofna3U/ZXndBRcCbN68mQPoqwN5Lpok0m8vujRfpVKJZs2aCQDiq6++Ui9fvXq1ACDs7e3F7t27i8xLdPXqVfHee++JkydPqpd9/fXX6sk67969q15+7tw50aBBA/Xl9osXLy60refntlq0aJFIT09Xr8vJyRG//vprobmtVCqVsLS0FACKzFNVoGCeLiMjI7F161b18pSUFPHqq6++cJ6uki79f5HyvpZZs2YJAKJjx44iLi5OvfzgwYPCyspK/Z49f/wCAgLEp59+WqTG+Ph49YSgr732WqF1o0aNEgBE69ati0wDkpeXJ44dOybGjRtXprnItKG0z+WTJ0/EihUr1NN6eHp6iuTk5HJtv2CergYNGhR6vREREeqpUkaPHl3oOc/P02VsbCy2b9+uXpeQkCD69u2rngD1378PAwcOFADEjz/+WGw9ZZkyorzPE0KIY8eOqSdILa6eIUOGiNTUVCHEs9+bzZs3CyMjI/Xn6t8T/pZ3yoiy/C4+r3nz5oXeY87NVXUxdBEVoyzzIW3YsEEAEA4ODoUmO31+RndbW1vRvn170aZNG/WEjADEwYMH1e2Tk5OFm5ubACCMjY1FixYt1DPee3p6ivnz5xcbuoQQYsuWLeqwYmZmJtq0aSOaNWtWbOgQQogpU6YIAMLExES0a9dOdOvWrcgXz/P1Ozo6inbt2qlnerexsRHnzp0r8f2qaOgq72t58OCB+v00NTUV3t7ewsXFRQAQPXr0EOPHjy/ynO+++079uho0aCDat29faHb5Bg0aiAcPHhSqKTU1VfTp00f9PCcnJ9GxY0fRokULYWpqql7+78luK0vB+9ykSRP1DObt2rVTv/aCx8iRIyv0xZyRkSF69Oih3o6np6do1aqVesb+Vq1alWlGemdnZ9GuXTv1e2RnZ1dsuPjll1/U+2revLn681hwZwBdh64LFy6oZ7S3srISbdu2Vc/sP2HCBNGtWzethC4hyva7WGD58uXq18u5uao2hi6iYpQldGVnZ6v/Qf7hhx8KrQsJCRHjxo0Tjo6OwtjYWNja2oqWLVuKKVOmiD///FPk5OQUav/48WPx2muvCXt7e2FsbCxcXV3F/PnzRXJysli8eHGJoUsIIa5duyYmT54snJychLGxsbC3txdt27YVS5YsEdHR0YXapqaminnz5gkXFxd1wCnui2v//v2iT58+wsbGRhgbGwtnZ2cxY8aMIjO2//v90iR0lfe13Lx5U7zyyivC2tpamJiYCA8PD7F06VKRnZ1d7JdgZGSk+O9//yv69OkjnJychImJibCzsxNt2rQRy5YtE4mJicXWlJ+fL7Zu3Sr69esn7O3thZGRkahXr57o2LGjeP/994sNoZWl4H1+/mFhYSEaNmwoevfuLT788MMynTkpTU5OjlixYoU6bJuamooWLVqIZcuWFToDWeD5gKNSqcSKFStE8+bNhYmJibC3txfjx48vdfLYFStWiJYtWxYKsQWhRtehSwghzp49K/r06SMsLCyEubm58Pb2Fv7+/kKlUmk1dJX1d1EIIeLi4tTB948//ii2DVUNkhAljJgkIiJ6gfv378PV1RXOzs4l3gCeNBMeHo5mzZrBwcEBDx8+5FQRVRgH0hMREemxDRs2AAAmTJjAwFXFMXQRERHpqXv37mHt2rUwMDAodk46qlo4OSoREZGeeeutt3Du3DmEhoYiIyMD06dPL/aWR1S18EwXERGRnrl8+TJOnz4NS0tLzJ07F99//73cJZEWcCA9ERERkQ7wTBcRERGRDnBMlx5RqVR4/PgxLC0t9e6eZERERFQ8IQRSU1NRv379Ym/6XoChS488fvwYjo6OcpdBREREFRAVFYWGDRuWuJ6hS49YWloCeHbQrKysZK6GiIiIyiIlJQWOjo7q7/GSMHTpkYIuRSsrK4YuIiKiKuZFQ4M4kJ6IiIhIBxi6iIiIiHSAoYuIiIhIBxi6iIiIiHSAoYuIiIhIBxi6iIiIiHSAoYuIiIhIBxi6iIiIiHSAoYuIiIhIBzgjfTWXrxI4d+8p4lKzUMfSBB1cbWGg4M20iYiIdI2hqxo7FBaNpfuvIzo5S72snrUJFg/2RP/m9WSsjIiIqOZh92I1dSgsGjMDLhYKXAAQk5yFmQEXcSgsWqbKiIiIaiaGrmooXyWwdP91iGLWFSxbuv868lXFtSAiIqLKwNBVDZ2797TIGa7nCQDRyVk4d++p7ooiIiKq4Ri6qqG41JIDV0XaERERkeYYuqqhOpYmWm1HREREmmPoqoY6uNqinrUJSpsYQgLwJDVbVyURERHVeAxd1ZCBQsLiwZ4AUGLwEgDm/noJH+65iqzcfJ3VRkREVFMxdFVT/ZvXw49+beBgXbgLsZ61CX4Y1xpvdm8EANh6NhLDV5/C3SdpcpRJRERUY0hCCM4boCdSUlJgbW2N5ORkWFlZaWWbpc1If/zWE8z/7TIS0nNgZmyAz4e3wLDWDbSyXyIiopqirN/fDF16pDJC14vEpmRh3q+XcObus+kjRrdzxJIhXjA1NtDJ/omIiKq6sn5/s3uxhqtrZYKt0zphbq8mkCTgtwtRGPZDCCLiUuUujYiIqFph6CIYKCTM7+OOrVM7wt5CiZuxqRi8MgQ7/3kod2lERETVBkMXqXVpbI+D83zh09gembn5WLAjFPO3X0ZGTp7cpREREVV5DF1USG1LJTZP6YAFfd2hkIDdFx9h8MqTCI9Jkbs0IiKiKo2hi4owUEiY3bMJtr3eCXWtlLjzJB1DV4Xg13OR4HUXREREFcPQRSXq6GaHA3N90c29NrLzVFi4+yrm/XoZadnsbiQiIiovhi4qlZ2FEhsntcf7/T1goJDwe+hjDF55EtceJ8tdGhERUZXC0EUvpFBImNm9Eba/0Qn1rU1wLz4dw1efwpYzD9jdSEREVEYMXVRmbZ1t8edcX/RuVgc5eSp8vDcMswMvISUrV+7SiIiI9B5DF5WLjbkx1r3WDh8NbAZDhYQ/r0ZjkP9JXHmYJHdpREREeo2hi8pNkiRM83XDjhmd0aCWKSKfZmDEj6ewMeQeuxuJiIhKwNBFFdbayQYH5vqin1dd5OYLLN1/HW9s+QfJGexuJCIi+jeGLtKItZkR1vi1xdIhXjA2UODI9VgM8A/GpchEuUsjIiLSKwxdpDFJkjCxiwt2zewCZzszPErKxMg1p7HuxF12NxIREf1/DF2kNS0aWmP/HB8MbFkPeSqBzw7cwLTNF5CYniN3aURERLJj6CKtsjIxwqqxrbFsWHMYGyrwV3gcBvgH48L9p3KXRkREJCuGLtI6SZLg18kZe9/sCjd7c0QnZ2H0T2ewOigCKhW7G4mIqGaqsaHr/PnzGDBgAGxsbGBubo4OHTogMDCwzM8PCgqCJEklPs6cOVOJ1VcNnvWt8PscHwzzro98lcBXh25i0qbziE/Llrs0IiIinTOUuwA5BAUFoV+/fjA2NsaYMWNgbW2N3bt3Y/z48bh//z4++OCDMm+rW7du6N69e5HlDRs21GLFVZeF0hDfjfZG50Z2WPz7NZy49QQDVgTDf2xrdHKzk7s8IiIinZFEDbu8LC8vDx4eHnj48CFOnz6N1q1bAwBSU1PRuXNn3Lx5E9evX0eTJk1K3U5QUBB69OiBxYsXY8mSJVqpLSUlBdbW1khOToaVlZVWtqlPbsakYlbgRUTEpUEhAW/1dsesHo1hoJDkLo2IiKjCyvr9XeO6F//++2/cuXMH48aNUwcuALC0tMTHH3+MvLw8bNy4UcYKq6+mDpb4fXZXvNq2IVQC+PboLbz281nEpWbJXRoREVGlq3GhKygoCADQt2/fIusKlh0/frzM27t9+zb8/f3x5ZdfYtu2bYiPj9dKndWVmbEhvhnZCstHtoKpkQFCIhIwYMVJhETwfSMiouqtxo3pun37NgAU231oY2MDe3t7dZuyCAwMLDQA39TUFEuXLsW77777wudmZ2cjO/v/BpWnpKSUeb9V3Yi2DdHKsRZmbb2Im7Gp8NtwFnN6NMa83u7sbiQiomqpxp3pSk5OBgBYW1sXu97KykrdpjS1a9fG119/jRs3biA9PR2PHj1CQEAAbG1t8d5772Ht2rUv3MYXX3wBa2tr9cPR0bF8L6aKa1zHAvtmd8XYDo4QAvD/OwLj1p1BbAq7G4mIqPqpcQPp+/bti6NHj+L27dto3LhxkfWNGjXCw4cPC52BKo+wsDC0bdsWNjY2ePz4MRSKknNtcWe6HB0dq+1A+tLsu/wIH+y+ivScfNiaG+PbUa3QvWkducsiIiJ6IQ6kL0HBGa6SzmYVvHEV1bx5c3Ts2BGxsbGIiIgota1SqYSVlVWhR0011LsB9s/xgWc9KzxNz8Gkjefx30PhyMtXyV0aERGRVtS40FUwlqu4cVuJiYmIj49/4XQRL2Jvbw8AyMjI0Gg7NY1bbQvsfrMLJnRyBgD8GHQHY346g8dJmTJXRkREpLkaF7q6desGADhy5EiRdQXLCtpURF5eHi5evAhJkuDk5FTh7dRUJkYG+M+w5vhhXBtYKg1x4UEiBvgH468bsXKXRkREpJEaF7p69eoFNzc3BAYG4vLly+rlqamp+M9//gNDQ0NMmjRJvTw+Ph7h4eFFpoI4ffo0/j0cLi8vD++++y4ePHiAfv36wdbWtjJfSrU2sGU9/DHXBy0aWCMpIxdTN1/AZ39eR04euxuJiKhqqnED6QHg2LFj6NevH5RKJcaOHQsrKyvs3r0b9+7dw7Jly/Dhhx+q2y5ZsgRLly4tMvO8i4sLJElCly5d0KBBAyQlJeHEiRO4efMmnJyccOLECTg7O5erruo+I31FZOfl48uD4dgYch8A4O1YCyvHtoajrZm8hREREf1/HEhfih49euDkyZPw8fHB9u3bsXr1atjZ2SEgIKBQ4CrNzJkz4eLigqCgIKxYsQJbt26FUqnEhx9+iMuXL5c7cFHxlIYGWDzYC2sntIWViSEuRyVhoH8wDl+Lkbs0IiKicqmRZ7r0Fc90lS7qaQbmbLuEy1FJAIBJXVywaIAHlIYG8hZGREQ1Gs90UbXjaGuGHTM6Y/pLbgCATafu49UfT+NBQrrMlREREb0YQxdVKUYGCnwwoBl+ntQOtcyMcPVRMgb5n8SfV6LlLo2IiKhUDF1UJfX0qIsDc33RztkGqdl5mBV4ER/tvYqs3Hy5SyMiIioWQxdVWfVrmeLX6Z3wZvdGAICAM5EYvvoU7j5Jk7kyIiKiohi6qEozNFDgvf4e2DylA+zMjXEjOgWDV57EvsuP5C6NiIioEIYuqha6udfGgXm+6Ohqi/ScfMz79TIW7rqCzBx2NxIRkX5g6KJqo66VCbZO64i5vZpAkoBfz0dh2A8hiIhLlbs0IiIihi6qXgwNFJjfxx0BUzvC3kKJm7GpGLwyBDv/eSh3aUREVMMxdFG11LWxPQ7M80HXxnbIzM3Hgh2heGd7KDJy8uQujYiIaiiGLqq26lia4JcpHfFOH3coJGDXxYcYsioEN2PY3UhERLrH0EXVmoFCwpxeTRD4eifUtVIiIi4NQ1adxG/nI8E7YBERkS4xdFGN0MnNDgfm+qKbe21k56nw/q6rePu3y0jLZncjERHpBkMX1Rh2FkpsnNQe7/f3gIFCwt7LjzFk5Ulce5wsd2lERFQDMHRRjaJQSJjZvRF+m94J9axNcDc+HcNXn8KWMw/Y3UhERJWKoYtqpHYutjgw1xe9POogJ0+Fj/eGYfa2S0jJypW7NCIiqqYYuqjGsjE3xvqJ7fDRwGYwVEj480o0BvmfxNWH7G4kIiLtY+iiGk2SJEzzdcOOGZ3RoJYpIp9mYMSPp7Ap5B67G4mISKsYuogAtHaywYG5vujrWRc5+Sos2X8dMwL+QXIGuxuJiEg7GLqI/j9rMyOsndAWSwZ7wthAgcPXYjFwZTAuRSbKXRoREVUDDF1Ez5EkCZO6umLXzC5wsjXDw8RMjFxzGuuD77K7kYiINMLQRVSMFg2t8cdcHwxsUQ95KoFlf97AtM0XkJieI3dpRERURTF0EZXAysQIq8a1xrJhzWFsqMBf4XEY6B+Mfx48lbs0IiKqghi6iEohSRL8Ojljz5td4GpvjsfJWRi19gx+DLoDlYrdjUREVHYMXURl4FXfGvvn+GCod33kqwT+eygckzedR0JattylERFRFcHQRVRGFkpDfD/aG/8d0QJKQwWO33qCAf7BOHs3Qe7SiIioCmDoIioHSZIwur0Tfp/tg0a1zRGbko2x685g5V+3kc/uRiIiKgVDF1EFNHWwxP45PhjRpiFUAlh+9BZe+/ksnqSyu5GIiIrH0EVUQWbGhlg+qhW+GdkKpkYGCIlIwMsrghESES93aUREpIcYuog09Grbhvh9dlc0rWuJ+LRs+G04i2+P3mJ3IxERFaJx6Dpx4gRCQ0PL1PbKlSs4ceKEprsk0jtN6lpi76yuGNPeEUIA/n/dxvj1ZxCbkiV3aUREpCckoeG9TRQKBXx9fXH8+PEXtu3RoweCg4ORl5enyS6rrZSUFFhbWyM5ORlWVlZyl0MVtO/yI3yw+yrSc/JhZ26Mb0d7o5t7bbnLIiKiSlLW72+tdC+WJ7fx/nVU3Q31boD9c3zQrJ4VEtJzMPHnc/jqUDjy8lVyl0ZERDLS6ZiuhIQEmJqa6nKXRLJwq22BPW92gV8nJwDA6qA7GPPTGTxOypS5MiIikotheZ+QkpKCpKSkQsuys7MRFRVV4lmszMxMHD9+HGFhYWjVqlWFCiWqakyMDLBsWAt0crPDol1XceFBIgb4B+PbUa3Q06Ou3OUREZGOlTt0fffdd/j0008LLbtw4QJcXFzK9PypU6eWd5dEVdqglvXRooE1ZgdewtVHyZiy6QKmv+SGd/s1hZEBLyAmIqopyh26atWqBScnJ/XPkZGRMDY2hoODQ7HtJUmCqakp3NzcMHr0aPj5+VW8WqIqytnOHDtndsYXB8Kx6dR9/HTiLs7de4pV41qjoY2Z3OUREZEOaOXqRR8fH04FoQW8erFmOBQWg/d2hiIlKw9WJob4emQr9PMq/o8WIiLSf2X9/i73ma5/27hxI+rW5fgUorLq39wBXvWtMHvbJYRGJeGNLf9gclcXLHq5GYwN2d1IRFRdaXymi7SHZ7pqlpw8Fb4+HI51wfcAAC0bWmPV2DZwsmN3IxFRVVLW72+th67ExESkpaWVOh/X82PC6P8wdNVMf92IxTs7QpGUkQtLpSH++2pLDGhRT+6yiIiojHQaum7duoUlS5bg0KFDSE5OLrWtJEmckb4EDF011+OkTMzddgkXHiQCACZ0csaHA5vBxMhA5sqIiOhFdBa6Ll++jG7duqnPbpmYmKB27dpQKEoem3Lv3j1NdlltMXTVbLn5Knx79BZ+DLoDAPCsZ4UfxreBq725zJUREVFpdBa6BgwYgEOHDqFXr1747rvv0Lx5c002V6MxdBEABN2Mw/ztoXiangNzYwN8/koLDPVuIHdZRERUAp2Frlq1akGlUiE6Ohrm5vyLXBMMXVQgJjkLc3+9hHP3ngIAxnZwxOLBXuxuJCLSQzq74bVKpULTpk0ZuIi0yMHaBIHTOmJuz8aQJGDbuSgMXRWCiLg0uUsjIqIK0jh0eXt7Izo6Whu1ENFzDA0UmN+3KbZM6Qh7CyVuxqZi8MqT2PXPQ7lLIyKiCtA4dC1atAjR0dHYsmWLNuohon/xaWKPA/N80KWRHTJz8/HOjlAs2BGKjBxeBUxEVJVoHLpefvllrF69Gm+++SbefvtthIWFITMzUxu1EdH/V8fSBFumdsT8Pu5QSMDOfx5i6KoQ3IpNlbs0IiIqI40H0hsYlG9gL+fpKhkH0lNZnL6TgHm/XkJcajZMjBRYOsQLo9o5QpIkuUsjIqqRdDaQXghRrodKpdJ0l0Q1WudGdjgwzxcvuddGVq4K7++6ird/u4y0bP4xQ0Skz7Ry9WJ5H0SkGXsLJTZNao/3+jeFgULC3suPMWTlSVx/nCJ3aUREVAKNQxcRyUOhkPBm98b4dXon1LM2wd34dAxbHYKtZx+Ueu9TIiKSB0MXURXX3sUWB+b6oqdHHeTkqfDhnjDM3nYJqVm5cpdGRETPYegiqgZszI2x/rV2+HBAMxgqJPx5JRqDVp7E1Yel34CeiIh0R+OrFwukp6dj//79CA0NxdOnT5GbW/xf2ZIkYcOGDdrYZbXDqxdJGy5GJmJO4CU8SsqEsYECHwzwwMQuLry6kYiokujs3osA8Ouvv2LmzJlISfm/QbwFm33+H3ohBCRJQn5+vqa7rJYYukhbkjNy8e7OUBy5HgsA6O/lgP++2hLWpkYyV0ZEVP3obMqI06dPY8KECcjPz8eHH36Ixo0bAwDWrVuHTz75BEOGDIEkSTAxMcFnn32Gn3/+WdNdEtELWJsZYe2Etlg82BNGBhIOXYvBQP9gXI5Kkrs0IqIaS+MzXSNGjMDevXuxd+9eDB48GL6+vjh16lShs1nh4eEYOXIkEhMT8c8//6Bu3boaF14d8UwXVYYrD5MwO/ASIp9mwFAhYeHLHpjq48ruRiIiLdHpmS57e3sMHjy4xDYeHh7YtWsXoqOjsXjxYk13qRXnz5/HgAEDYGNjA3Nzc3To0AGBgYHl2oZKpcKqVavQsmVLmJqaonbt2hg1ahRu375dSVUTlV/LhrXwx1wfDGjhgDyVwLI/b+D1Xy4gKSNH7tKIiGoUjUNXQkICnJyc1D8bGxsDeDaw/nnu7u7w8vLCwYMHNd2lxoKCguDj44Pg4GC8+uqrmDlzJuLj4zF+/Hh8/vnnZd7OjBkzMGfOHOTn52POnDkYMGAAfv/9d7Rv3x7Xr1+vxFdAVD5WJkb4YVwb/GdYcxgbKvC/G3EYsCIY/zx4KndpREQ1hsbdi/Xr14etrS3CwsIAAKNHj8bOnTtx6dIltGzZslBbLy8v3LlzB1lZWZrsUiN5eXnw8PDAw4cPcfr0abRu3RoAkJqais6dO+PmzZu4fv06mjRpUup2jh07hp49e8LX1xdHjx6FUqkEAPz111/o06cPfH19cfz48XLVxu5F0oVrj5MxO/AS7sWnw0Ah4d1+TTHd1w0KBbsbiYgqQmfdiy4uLoiOjlb/3KZNGwghsHXr1kLtQkNDcevWLdSuXVvTXWrk77//xp07dzBu3Dh14AIAS0tLfPzxx8jLy8PGjRtfuJ1169YBAJYtW6YOXADQq1cv9OvXDydOnMCtW7e0/wKINORV3xr75/hgSKv6yFcJfHkwHFM2n0dCWrbcpRERVWsah64+ffogKSkJ165dAwCMGzcOJiYm+Oabb+Dn54cffvgBn3zyCXr16gWVSoURI0ZoXLQmgoKCAAB9+/Ytsq5gWVnOUAUFBcHc3Bxdu3Ytsq5fv35l3g6RHCyUhlgxxhtfvtICSkMFgm4+wQD/YJy9myB3aURE1ZbGoWvUqFHo2bMnbt68CQBwdHTEjz/+CENDQwQGBmLu3Ln47LPP8PTpU3Ts2BHLli3TuGhNFAxyL6770MbGBvb29i8cCJ+eno7o6Gi4urrCwMCgyPqCbb9oO9nZ2UhJSSn0INIVSZIwpoMT9s3uika1zRGbko2x685g5V+3ka/ivRuJiLTNUNMNeHl54ejRo4WWTZw4Eb6+vti+fTvu378PU1NT+Pj4YNiwYcWGFF1KTn52WxRra+ti11tZWeHhw4cab+P5diX54osvsHTp0lLbEFU2Dwcr/D7bBx/vC8Pui4+w/OgtnL33FN+N9kZtS+WLN0BERGWicegqiZubGxYuXFhZm68WFi1ahPnz56t/TklJgaOjo4wVUU1lrjTEt6O80dnNDp/su4aTEfEY4B+MFaO90aWxvdzlERFVCzXuhtcFZ6dKOgtVcAWCptt4vl1JlEolrKysCj2I5DSynSN+n90V7nUt8CQ1G+M3nMW3R2+xu5GISAu0fqYrMTERaWlpKG0miufn9dK158dbtW3bttC6xMRExMfHo0uXLqVuw9zcHPXq1cO9e/eQn59fpMu0tHFjRPquSV1L7Jvlg6X7r+HX81Hw/+s2zt1LwIoxrVHXykTu8oiIqiytnOm6desWxo0bB1tbW9jb28PFxQWurq7FPtzc3LSxywrr1q0bAODIkSNF1hUsK2jzou2kp6cjJCSkyLrDhw+XeTtE+sjU2ABfjmiJFWO8YW5sgDN3n2LAimCcuPVE7tKIiKosjSdHvXz5Mrp166Y+u2ViYoLatWtDoSg5z927d0+TXWokLy8PTZs2xaNHj3DmzBl4e3sDKDw56rVr1+Du7g4AiI+PR3x8POzt7WFv/39jW56fHPV///ufeiZ+To5K1c3dJ2mYFXgJN6JTIEnAm90b4e3e7jA0qHGjE4iIilXW72+NQ9eAAQNw6NAh9OrVC9999x2aN2+uyeZ04tixY+jXrx+USiXGjh0LKysr7N69G/fu3cOyZcvw4YcfqtsuWbIES5cuxeLFi7FkyZJC23n99dexfv16eHp6YuDAgYiNjcVvv/0GExMTnDp1Cp6enuWqi6GL9FVWbj7+88d1bD0bCQBo72ID/7GtUc/aVObKiIjkp7MZ6U+dOgULCwvs3bu3SgQuAOjRowdOnjwJHx8fbN++HatXr4adnR0CAgIKBa4XWbt2Lfz9/SFJEvz9/fHnn39i8ODBOHfuXLkDF5E+MzEywGfDW2DVuNawUBri/P1EDFgRjGPhcXKXRkRUZWh8psvKygpNmzbF+fPntVVTjcUzXVQVPEhIx6zAiwh79Owq3TdecsOCfk1hxO5GIqqhdHamy9vbu9C9F4moenO2M8eumV0wqYsLAGDtibsYtfY0HiZmyFsYEZGe0zh0LVq0CNHR0diyZYs26iGiKkBpaIAlQ7ywxq8NLE0McSkyCQP9T+LItRi5SyMi0lsah66XX34Zq1evxptvvom3334bYWFhyMzM1EZtRKTn+jevhwNzfdHKsRaSM3Mxfcs/WLr/GnLyVHKXRkSkdzQe01XeeylKkoS8vDxNdlltcUwXVVU5eSp8dSgc608+mw6mZUNrrBrbBk52ZjJXRkRU+XQ2pksIUa6HSsW/gImqG2NDBT4a5In1r7WDtakRrjxMxkD/YBy8yvGeREQFNA5dKpWq3A8iqp56e9bFgXm+aOtsg9TsPMzcehGf7AtDVm6+3KUREcmO13gTkVY1qGWKX6d3woxujQAAv5x+gBE/nsK9+HSZKyMikhdDFxFpnZGBAgtf9sCmye1ha26Ma49TMHjlSfwe+lju0oiIZFOugfSRkc9uAWJkZIR69eoVWlYeTk5O5X5OTcCB9FQdxSRnYe6vl3Du3lMAwNgOTlg82BMmRuW7CIeISF9Vyr0XFQoFJEmCh4cHrl27VmhZWfHqxZIxdFF1lZevwoq/bmPVsQgIAXg4WGLVuDZoXMdC7tKIiDRW1u9vw/Js1MnJCZIkqc9yPb+MiKgkhgYKvNO3KTq62uGt3y4hPCYVQ1adxLJhzfFKm4Zyl0dEpBMaz9NF2sMzXVQTxKVk4a3fLuPUnQQAwMi2DbF0qBfMjMv1NyARkd7Q2TxdRETlUcfKBFumdsTbvd2hkIAd/zzE0FUhuBWbKndpRESViqGLiHTOQCFhXu8m2DqtE+pYKnE7Lg1DVp3E9vNR4Ml3IqquGLqISDadG9nhwDxf+DaxR1auCu/tuoL520ORns2LbYio+tHamK7Dhw/j0KFDuHv3LtLS0kr8a1WSJPz111/a2GW1wzFdVFOpVAI/Hr+Db4/eQr5KwM3eHD+Mb4Nm9fh7QET6r1KmjChpR8OGDcPx48fL1C0gSRLy83lLkOIwdFFNd/7+U8wJvISYlCwYGyqweLAnxnXgFdJEpN8qZcqI4rz//vsICgqCra0tpk+fjtatW6N27dr8R5KIyq29iy0OzPPFgh2h+Ds8Dh/uCcPpOwn44pUWsDQxkrs8IiKNaHymq27dukhKSsLFixfh5eWlrbpqJJ7pInpGpRJYf/Iuvjp0E3kqARc7M6wa1wbNG1jLXRoRURE6mzIiPT0dTZs2ZeAiIq1RKCRMf6kRts/ojAa1THE/IQOvrD6Fzafu8+pGIqqyNA5dHh4eyMzM1EYtRESFtHGywZ9zfdDHsy5y8lVY/Ps1zAy4iOTMXLlLIyIqN41D16xZs3Dnzh0EBQVpoRwiosJqmRnjpwlt8ckgTxgZSDh0LQYD/YNxOSpJ7tKIiMpF49A1efJkzJkzB6+88gpWrlyJtLQ0bdRFRKQmSRKm+Lhi54wucLQ1xcPETIxccwrrg++yu5GIqgytzNOVnZ2NsWPHYt++fQCA2rVrw8zMrPgdShLu3Lmj6S6rJQ6kJ3qxlKxcLNx1BQeuxgAAejeri29GtkQtM2OZKyOimkpn83TFxsaid+/euH79Oufp0hBDF1HZCCEQcOYB/vPHDeTkq1Df2gQrx7VBW2cbuUsjohpIp/N0Xbt2DY0bN8a7774Lb29vztNFRJVKkiRM6OyC1k42mB14EfcTMjBq7Wm8268ppvu6QaHgvz9EpH80PtPl4OCAlJQUREREoH79+tqqq0bimS6i8kvLzsMHu6/i99DHAIDuTWvj21HesDVndyMR6YZO5+ny8PBg4CIiWVgoDbFijDe+eKUFlIYKBN18ggErgnHu3lO5SyMiKkTj0NWiRQskJCRooxYiogqRJAljOzhh76yucKttjpiULIz56TRW/X0bKhWvbiQi/aBx6Hr33XcRFRWF7du3a6MeIqIKa1bPCvtn++CV1g2gEsA3R25h4sZzeJKaLXdpRESah67hw4fD398f06ZNwzvvvINr164hKytLG7UREZWbudIQ3472xtevtoSJkQLBt+MxwD8YpyLi5S6NiGo4jQfSGxgYlG+HkoS8vDxNdlltcSA9kXbdjk3FrMCLuBWbBkkC5vZsgrm9msCAVzcSkRbpbCC9EKJcD5VKpekuiYjKpEldS+yb5YNR7RpCCGDFX7fht/4s4lJ4Np6IdE/j0KVSqcr9ICLSFVNjA3z1ait8N7oVzIwNcPpuAgb4ByP49hO5SyOiGkbj0BUZGYnIyEiGKSLSa8NbN8T+OT7wcLBEfFoOXvv5HL45fBN5+fy3i4h0Q+PQ5eLigo4dO2qjFiKiStWotgX2zuqK8R2dIASw6lgExq07i+jkTLlLI6IaQOPQZW1tDWdnZygUGm+KiKjSmRgZ4LPhLbBybGtYKA1x7v5TDFgRjGPhcXKXRkTVnFYmR42MjNRGLUREOjO4VX38MccHzRtYITEjF5M3nccXB24gl92NRFRJNA5d8+bNQ0xMDH7++Wdt1ENEpDMu9ubYNbMLJnVxAQCsPXEXo9eexqMkdjcSkfZpHLpGjBiBL7/8ErNmzcLbb7+NixcvIjOT/2ARUdWgNDTAkiFeWOPXBpYmhrgYmYQBK4Jx9Hqs3KURUTXDyVH1CCdHJZJX1NMMzA68iNCHyQCAKV1dsfBlDxgbcswqEZWMk6MSEZWTo60Zdszogqk+rgCAn0PuYeSaU4h6miFzZURUHXByVCKi5xgbKvDxIE+se60drE2NEPowGQP8g3EoLFru0oioiuM5cyKiYvTxrIsD83zRxqkWUrPyMCPgIhbvC0NWbr7cpRFRFaXxmK7nRUVFITg4GI8ePUJmZiY++eQT9brc3FwIIWBsbKyt3VU7HNNFpH9y81X45shNrD1+FwDgVd8KP4xrAxd7c5krIyJ9Udbvb62Ervj4eMyaNQu7du3C85vLz/+/vwj9/Pywbds2nDt3Dm3bttV0l9USQxeR/jp2Mw7vbA/F0/QcWCgN8cUrLTC4VX25yyIiPaCzgfSpqano1q0bduzYgQYNGmDSpElo0KBBkXbTpk2DEAK7d+/WdJdERDrXo2kdHJjriw4utkjLzsOcbZfwwZ6r7G4kojLTOHR99dVXuHHjBkaMGIHw8HBs2LABzs7ORdq99NJLMDU1xbFjxzTdJRGRLBysTRD4ekfM6dkYkgQEno3EsB9CcOdJmtylEVEVoHHo2rlzJ5RKJdavXw9TU9OSd6RQoHHjxrxlEBFVaYYGCrzTtyl+mdIB9hbGCI9JxeCVJ7Hn0kO5SyMiPadx6Lp//z7c3d1hbW39wrZmZmaIj4/XdJdERLLzbVIbB+b6orObHTJy8vH2b6F4d0coMnPY3UhExdM4dJmYmCA1NbVMbaOjo8sUzoiIqoI6ViYImNYRb/VuAkkCdvzzEENWncSt2LL9m0hENYvGocvLywtRUVF48OBBqe0uX76MyMhIXrlIRNWKgULCW73dsXVaR9S2VOJ2XBqGrDqJ7ReioMUZeYioGtA4dPn5+SE/Px/Tp09HRkbxt8pITEzE1KlTIUkSXnvtNU13SUSkd7o0ssfBeb7wbWKPrFwV3tt5Be9sD0V6Nu81S0TPaDxPV35+Pnr27Ing4GC4urpi5MiR2L17N+7cuYN169YhLCwMAQEBiI+PR9++fXHo0CFt1V7tcJ4uoqpPpRL48fgdLD9yEyoBuNU2xw/j2qBZPf5OE1VXOp0cNTU1FdOnT8dvv/0GSZLUp9Sf//9Ro0Zhw4YNMDfnLM4lYegiqj7O3XuKudsuISYlC0pDBRYP9sLYDo6QJEnu0ohIy3QaugpcvXoVe/bswdWrV5GcnAwLCwt4enpi+PDhHMtVBgxdRNXL0/QcvLP9Mo7dfAIAGNyqPj4f3hyWJkYyV0ZE2iRL6CLNMHQRVT8qlcC64Lv4+vBN5KkEXOzMsGpcGzRvwCu5iaoLnd0G6MSJEwgNDS1T2ytXruDEiROa7lJjMTExmDZtGurVqwcTExO4u7vj008/RU5OTrm2I0lSiY8vv/yykqonoqpEoZDwRrdG+O2NzmhQyxT3EzLwyupT+OX0fV7dSFTDaHymS6FQwNfXF8ePH39h2x49eiA4OBh5efJdzRMTE4OOHTsiKioKw4YNg7u7O06ePImQkBD0798ff/75JxSKsmVRSZLg7OyMSZMmFVnXu3dv+Pj4lKs2nukiqt6SMnKwYMcV/O9GLABgQAsHfPFKS1ibsruRqCor6/e3oTZ2Vp7cJvdfdu+//z4iIyOxevVqzJw5U13T5MmTsXnzZmzevBmTJ08u8/ZcXFywZMmSSqqWiKqTWmbGWPdaW/wcch9fHryBA1djcPVRMlaNbYNWjrXkLo+IKpnG3YvlkZCQUOr9GStbamoqfvvtN7i5uWHGjBnq5ZIk4YsvvoBCocC6detkq4+Iqj9JkjDVxxU7Z3SBo60pop5m4tU1p7Dh5D3Z/yglospV7jNdKSkpSEpKKrQsOzsbUVElz76cmZmJ48ePIywsDK1atapQodpw+vRpZGdno0+fPkUu265Xrx5atGiBs2fPIisrCyYmJmXaZlJSEtavX4+4uDjUrl0b3bt3R5MmTSqjfCKqRlo51sIfc3yxcNcVHAyLwX/+uI7TdxLwzciWqGVmLHd5RFQJyh26vvvuO3z66aeFll24cAEuLi5lev7UqVPLu0utuX37NgCUGIqaNGmC0NBQ3L17F56enmXaZmhoKF5//XX1z5IkYfz48Vi7di3MzMw0L5qIqi1rUyOsHt8GW848wLI/buB/N2Ix0P8k/Me2RltnG7nLIyItK3foqlWrFpycnNQ/R0ZGwtjYGA4ODsW2lyQJpqamcHNzw+jRo+Hn51fxajWUnJwMACXedLtg8FtBuxdZsGABRo4ciSZNmkCSJFy6dAkffPABAgICkJeXh23btpX6/OzsbGRnZ6t/TklJKdN+iaj6kCQJr3V2QRsnG8wOvIj7CRkYvfY03u3XFK/7ukGh4GSqRNVFuUPXvHnzMG/ePPXPCoUC7du31+lUEPb29khISChz+2PHjqF79+5ar+Prr78u9HOPHj3w119/oVWrVvj111/x0UcfwcvLq8Tnf/HFF1i6dKnW6yKiqqd5A2vsn+ODD/aEYX/oY3xxMBxn7iZg+Shv2Jqzu5GoOtD46sWNGzeibt262qilzMaOHYvU1NQyty84C1dwhqukM1kFZ5pKOhNWFmZmZhg7diz+85//ICQkpNTQtWjRIsyfP7/Q/h0dHSu8byKq2ixNjOA/xhud3eywdP81HLv5BANWBMN/bGt0cLWVuzwi0pDGoWvixInaqKNcVq5cWaHnFYzlKhjb9W+3b9+GQqGAm5tbhWsDnp2JA4CMjIxS2ymVSiiVSo32RUTViyRJGNfRCa2damFW4EXcfZKOsevOYH4fd8zs1ojdjURVmFanjIiKikJgYCC+/vrrIoPtc3Nzyz3ju7Z16tQJSqUSR48eLXKlZXR0NK5evYqOHTuW+crFkpw9exYAynxxARHRvzWrZ4X9s33wSusGyFcJfH34JiZuPIf4tOwXP5mI9JJWQld8fDxGjx4NV1dXTJgwAQsXLiwyVmny5MkwNTXFP//8o41dVoiVlRVGjx6Nu3fvYs2aNerlQggsWrQIKpWq0JWIwLOzVeHh4YiMjCy0/NKlS8WeydqxYwe2bdsGe3t79O7du3JeCBHVCOZKQywf1QpfvdoSJkYKBN+Ox8srgnHqTrzcpRFRBWh8G6DU1FR06tQJN27cgKOjI3r37o2jR4/i0aNHyM/PV7cLCgpCz549sWjRInz22WcaF15R0dHR6NixIx4+fIjhw4fD3d0dwcHBCAkJQb9+/XDgwIFCtwEKCgpCjx490K1bNwQFBamXT5o0CXv37kWvXr3g5OQEIQQuXryI4OBgmJiYYNeuXRgwYEC5auNtgIioJLdiUzFr60XcjkuDQgLm9mqCOT2bwIDdjUSy09kNr7/66ivcuHEDI0aMQHh4ODZs2ABnZ+ci7V566SWYmpri2LFjmu5SI/Xq1cPZs2cxefJkhISE4Ntvv0VsbCyWLl2Kffv2lfm+i0OHDkX37t1x8eJF/PTTT/jxxx/x8OFDTJ06FZcuXSp34CIiKo17XUv8PtsHo9o1hEoA3//vNiZsOIu4lCy5SyOiMtL4TFezZs1w//59xMTEqK/68/X1xalTpwqd6QKAVq1aISEhAQ8fPtRkl9UWz3QRUVnsvvgQH+0NQ0ZOPuwtjPHdaG/4Nqktd1lENZbOznTdv38f7u7uZZpmwczMDPHxHItARKSJV9o0xO+zfeDhYIn4tBy89vM5fHP4JvLyVXKXRkSl0Dh0mZiYlHnOrOjoaI3mwCIiomca17HA3lldMa6jE4QAVh2LwLh1ZxGTzO5GIn2lcejy8vJCVFQUHjx4UGq7y5cvIzIyEm3bttV0l0REBMDEyACfD28B/7GtYaE0xLn7TzHAPxjHbsbJXRoRFUPj0OXn54f8/HxMnz69xMlAExMTMXXq1Gf3GHvtNU13SUREzxnSqj7+mOMDr/pWeJqeg8kbz+OLgzeQy+5GIr2i8UD6/Px89OzZE8HBwXB1dcXIkSOxe/du3LlzB+vWrUNYWBgCAgIQHx+Pvn374tChQ9qqvdrhQHoi0kRWbj6+OHADm08/63lo62wD/7Gt0aCWqcyVEVVvZf3+1jh0Ac/m6po+fTp+++03SJKknu39+f8fNWoUNmzYAHNzc013V20xdBGRNhy8Go33dl1BalYerE2NsHxkK/T21O09colqEp2GrgJXr17Fnj17cPXqVSQnJ8PCwgKenp4YPnw4x3KVAUMXEWlLZEIG5my7iNCHyQCAaT6ueK+/B4wNtXr3NyKCTKGLNMPQRUTalJOnwpcHw/FzyD0AQCvHWlg1tjUcbc1kroyoetHZPF1ERKSfjA0V+GSwJ9a91g7WpkYIjUrCAP9gHAqLlrs0ohpJ4zNdjx49wpEjR3D+/HnExcUhNTUVVlZWqFOnDjp06IC+ffuiXr162qq3WuOZLiKqLA8TMzBn2yVcikwCAEzs7IwPBjaD0tBA3sKIqoFK715MTU3FW2+9hYCAAOTl5QEAnt+UJD27CauRkREmTpyI5cuXw8LCoiK7qjEYuoioMuXmq/DNkZtYe/wuAKB5AyusGtsGLva8wIlIE5Uaup4+fQpfX1+Eh4dDCIH69eujc+fOcHR0hLm5OdLS0hAZGYnTp08jJiYGkiTBy8sLJ06cQK1atTR5XdUaQxcR6cKx8DjM334ZiRm5sFAa4ssRLTCoZX25yyKqsio1dI0cORK7du1CvXr1sHr1agwZMkR9Zut5Qgjs2bMHc+bMQUxMDEaNGoVt27aVd3c1BkMXEelKdHIm5m67hPP3EwEA4zo64ZNBnjAxYncjUXlVWui6ceMGvLy8ULt2bVy4cAGOjo4vfM6DBw/Qvn17JCQk4Pr162jatGl5dlljMHQRkS7l5avw/f9u44egCAgBeDhY4ofxbdCoNoeCEJVHpV29GBgYCEmS8NFHH5UpcAGAs7MzPvroIwghEBgYWN5dEhFRJTA0UGBBv6b4ZUoH2JkbIzwmFYNXnsTeS4/kLo2oWip36Dp79iwAYPz48eV6XkH7M2fOlHeXRERUiXyb1MbBeb7o7GaHjJx8vPXbZby/8woyc/LlLo2oWil36AoPD4ezszNsbW3L9Tw7Ozu4uLggPDy8vLskIqJKVsfKBAHTOmJeryaQJOC3C1EY+sNJ3I5Nlbs0omqj3KErOTkZ9vb2FdqZvb09kpKSKvRcIiKqXAYKCW/3ccfWqR1R21KJW7FpGLzqJHZciJK7NKJqodyhKy0tDSYmJhXamVKpRFpaWoWeS0REutGlsT0OzPWFbxN7ZOWq8O7OK5i//TLSs/PkLo2oSit36OKtGomIqr/alkpsntwBC/q6QyEBuy8+wpBVJxEekyJ3aURVlmFFnhQXF4dffvmlQs8jIqKqQaGQMLtnE3RwtcPcbZdw50k6hq4KwZIhXhjT3rHY+RmJqGTlnqdLoVBU+BdNCAFJkpCfzytiisN5uohIXz1Nz8H87ZcRdPMJAGBwq/r4fHhzWJoYyVwZkfzK+v1d7jNdTk5O/OuGiKiGsTU3xs8T22Nd8F18dfgm9oc+xtWHSVg1rg2aN7CWuzyiKqHCN7wm7eOZLiKqCv55kIg5gRfxODkLxgYKfDyoGfw6OfMPcqqxKm1GeiIiqtnaOtvgwDxf9G5WFzn5Kny87xpmBV5ESlau3KUR6TWGLiIiKrdaZsZY91pbfDSwGYwMJBy4GoOB/sG48jBJ7tKI9BZDFxERVYgkSZjm64YdM7qgoY0pop5mYsSPp/DzyXucXoioGAxdRESkEW/HWvhzri/6ezkgN1/g0z+uY/qWf5CUkSN3aUR6haGLiIg0Zm1qhB/92mDpEC8YGyhw9HosBvqfxMXIRLlLI9IbDF1ERKQVkiRhYhcX7H6zC5ztzPAoKROj1pzGTyfuQKVidyMRQxcREWlV8wbW+GOODwa1rIc8lcDnB8Ix7ZcLeJrO7kaq2Ri6iIhI6yxNjLBybGt8PrwFjA0V+Ds8DgP9g3H+/lO5SyOSDUMXERFVCkmSMK6jE/bN6go3e3NEJ2dhzE9n8MOxCHY3Uo1UaTPS79u3D/v378eNGzfw9Omzv2xsbW3RrFkzDBkyBEOGDKmM3VZpnJGeiKqr9Ow8fLQ3DHsuPQIA+Daxx3ejvWFvoZS5MiLNlfX7W+uhKyEhAYMGDcLZs2fh7u4OLy8v2NraQgiBxMREXL9+HTdv3kSnTp2wf/9+2NnZaXP3VRpDFxFVZ0II7LjwEJ/8HoasXBXqWCqxYkxrdG7E7wGq2mQLXa+99hpOnTqFX3/9Fe3atSu2zT///IMxY8agS5cu2Lx5szZ3X6UxdBFRTXArNhWztl7E7bg0KCRgXi93zO7ZGAYK3ruRqibZQpetrS3WrVuHESNGlNpu165deP3119Vdj8TQRUQ1R0ZOHhbvu4Yd/zwEAHRpZIfvx3ijjqWJzJURlZ9sN7zOy8uDmZnZC9uZmpoiLy9P27snIqIqwMzYEF+PbIVvR7WCmbEBTt1JwIAVwTh5O17u0ogqjdZDV48ePbB48WLExcWV2CYuLg5Lly5Fz549tb17IiKqQl5p0xC/z/aBh4Ml4tNyMOHns1h+5Cby8lVyl0akdVrvXnzw4AG6d++O2NhY9OjRA15eXqhVqxYkSVIPpD927BgcHBzw999/w9nZWZu7r9LYvUhENVVWbj6W7r+ObeciAQAdXG3hP6Y1HKzZ3Uj6T7YxXQCQnp6ONWvW4M8//8T169eRmPjs3ls2Njbw8vLCoEGD8Prrr8PCwkLbu67SGLqIqKb7PfQxFu26gvScfNiaG+PbUa3QvWkducsiKpWsoYsqhqGLiAi4F5+O2YEXce1xCgBgRrdGeKevO4wMOJ836SfZBtITERFpwtXeHLtmdsFrnZ8NP1lz/A7G/HQGj5MyZa6MSDOyha4bN27g008/lWv3RESkx0yMDPDp0OZYPb4NLJWG+OdBIgb4B+N/12PlLo2owmQLXdevX8fSpUvl2j0REVUBA1rUw59zfdGyoTWSMnIx7ZcLWPbHdeTk8epGqnrYvUhERHrNyc4MO2d0wZSurgCA9SfvYeTa04h6miFzZUTlo/WB9AYGBuVqn5+fr83dV2kcSE9EVLoj12KwYEcoUrLyYGViiK9ebYX+zR3kLotqONmuXjQ1NUWnTp3Qv3//UttdvXoV27ZtY+h6DkMXEdGLPUzMwJxtl3ApMgkAMKmLCxYN8IDSsHx/9BNpi2yhq1OnTqhbty727dtXartdu3Zh1KhRDF3PYegiIiqb3HwVvjl8E2tP3AUAtGhgjVXjWsPZzlzmyqgmkm3KiPbt2+P8+fNlasspwoiIqCKMDBRYNKAZfp7UDjZmRrj6KBkD/U/ijyuP5S6NqERaP9P16NEjREREoFu3btrcbI3AM11EROUXnZyJudsu4fz9Z3c/Gd/RCR8P8oSJEbsbSTc4I30VxNBFRFQxefkqfPe/W1gddAdCAM3qWeGHca3hVpu3m6PKxxnpiYioxjA0UODdfh7YPLkD7MyNcSM6BYNWnsTeS4/kLo1IjaGLiIiqjZfca+PAPF90crNFRk4+3vrtMt7feQWZObxoi+SncfdiZGRkmdsaGBjA0tKSXWclYPciEZF25KsE/P+6Df+/b0MIoGldS/wwvjUa17GUuzSqhnQ2pkuhUECSpHI9p1atWujatStmzJiBAQMGaLL7aoWhi4hIu05FxGPeb5fxJDUbpkYG+M+w5ni1bUO5y6JqRmdjupycnODk5ARDQ0MIISCEgKWlJerXrw9LS0v1MkNDQzg5OcHOzg6JiYn4448/MHjwYMyaNUvTEoiIiIrVpbE9Dsz1hU9je2Tm5mPBjlDM334Z6dl5cpdGNZDGoev+/fsYOnQoFAoFFi9ejPv37yMpKQlRUVFISkrCgwcPsGTJEhgYGGDo0KGIi4tDfHw8vvrqKyiVSqxZswY7d+7UxmshIiIqoralEpundMCCvu5QSMDui48wZNVJhMekyF0a1TAah661a9di5cqVCAwMxOLFi+Hk5FRovaOjIz755BMEBgZi5cqVWLNmDWxtbbFgwQL89NNPEEJg3bp1mpZRZidOnMCCBQvQo0cPWFtbQ5IkTJo0qcLbO3z4MLp37w4rKytYWlqie/fuOHz4sPYKJiIijRkoJMzu2QTbXu+EulZK3HmSjqGrQvDruUhO1E06o/GYrtatWyM5ORl37959YVs3NzdYWVnh8uXL6mW1a9cGADx58kSTMsps0qRJ2Lx5M8zMzODk5ITw8HBMnDgRmzZtKve2tm7dCj8/P9jb22PMmDGQJAnbt29HbGwsAgICMH78+HJtj2O6iIgqX0JaNuZvD8XxW8++d4a0qo/PX2kBC6WhzJVRVaWzMV23bt2Cvb19mdra29vj9u3bhZa5ubkhJUV3p3hnz56NsLAwpKSkYOPGjRXeTmJiImbPng17e3tcvHgRK1euhL+/Py5dugQHBwfMnj0biYmJWqyciIi0wc5CiY2T2mPhyx4wUEj4PfQxBq88iWuPk+Uujao5jUOXubk5rl+/juTk0j+sycnJuH79OszNC9+MNCEhAdbW1pqWUWbt2rWDl5cXDAw0uz3Ejh07kJSUhDlz5sDR0VG9vF69enjrrbeQlJSEHTt2aFouERFVAoVCwoxujbD9jU6ob22Ce/HpGL76FLacecDuRqo0GoeuXr16ISMjA35+fkhNTS22TXp6OiZMmIDMzEz06dOn0PIHDx4UCi1VRVBQEACgb9++Rdb169cPAHD8+HFdlkREROXU1tkWf871Re9mdZCTp8LHe8MwO/ASUrJy5S6NqiGNO7A/++wzHD58GAcOHECjRo3wyiuvoGXLlrC0tERaWhquXLmC3bt348mTJ7CxscGyZcvUzw0MDER+fn6xwUXfFXSTNmnSpMi6gmX/7kr9t+zsbGRnZ6t/1mU3KxERPWNjbox1r7XDhpP38OXBcPx5NRpXHyVj1bjWaNmwltzlUTWicehyc3NDUFAQ/Pz8EBYWhp9++qnQZKkFp2lbtmyJLVu2wNXVVb2uc+fOOHbsGDw9PTUtQ+cKulOL6xo1NzeHgYHBC7tcv/jiCyxdurRS6iMiorKTJAnTfN3QzsUWswMvIvJpBkb8eAofDGiGSV1cyj0JOFFxtHKpRsuWLREaGoqjR4/i6NGjuH37NtLT02Fubg53d3f06dMHvXv3LvKhbd68eYX2Z29vj4SEhDK3P3bsGLp3716hfVWmRYsWYf78+eqfU1JSqmRXKxFRdeHtWAt/zvXFeztDcfhaLJbuv47TdxLw9autYG1mJHd5VMVp7fpYSZLQt29fnXQVjh07tsTxY8VxcHDQeg0FZ7iSk5NhZ2dXaF16ejry8/NfeIGAUqmEUqnUem1ERFRx1qZGWOPXFr+cfoDP/ryBI9djcc0/GKvGtUZrJxu5y6MqTOuTkty6dQu3bt1CamoqLC0t4e7uDnd3d63uY+XKlVrdXkU0adIEFy5cwO3bt4uErtLGexERkf6TJAkTu7igjZMNZm+7iAcJGRi55jTe7++BqT6uUCjY3Ujlp/HViwXWrl0LNzc3NGvWDEOHDoWfnx+GDh2KZs2awc3NTaezzutCt27dAABHjhwpsq5gRvqCNkREVDW1aGiN/XN8MLBlPeSpBD47cAPTfrmAxPQcuUujKkgroWvy5Ml48803cf/+fRgbG6NRo0bo0qULGjVqBGNjY9y/fx8zZszA5MmTtbE7ncrIyEB4eDgiIyMLLR81ahSsra2xcuVKREVFqZdHR0fj+++/R61atTBy5Ehdl0tERFpmZWKEVWNb47PhzWFsqMDf4XEY4B+MC/efyl0aVTEa3wYoMDAQfn5+MDc3x+LFizFjxgxYWFio16elpWHNmjX49NNPkZ6ejoCAAIwdO1bjwivq5MmTWL9+PYBntx4qmOrCx8cHAODh4YGFCxeq2wcFBaFHjx7o1q2bem6uAgEBAZgwYYL6NkAKhQK//fYbYmNjsWXLFvj5+ZWrNt4GiIhIv11/nILZgRdxNz4dBgoJ7/R1x4yXGrG7sYYr6/e3xqGrR48eOHHiBA4ePFjqIPojR46gf//+6N69O/7++29NdqmRTZs2lXrG7d/hqrTQBQCHDh3CF198gYsXLwIA2rRpgw8++EA9QWp5MHQREem/tOw8fLTnKvZefgwAeMm9Nr4d1Qr2FrwwqqbSWeiytbWFnZ3dCycCBQB3d3c8efKE9yQsAUMXEVHVIITAjgsP8cnvYcjKVaGOpRL+Y1ujk5vdi59M1Y7ObnidlZWFWrVqlamtlZVVoRnYiYiIqiJJkjCqvSP2zfJB4zoWiEvNxrh1Z+D/123kq3jvRiqexqHLyckJYWFhiI+PL7XdkydPcO3aNTg5OWm6SyIiIr3Q1MESv8/uilfbNoRKAN8evYXXfj6LuNQsuUsjPaRx6BoyZAiys7MxevRoPHnypNg2cXFxGD16NHJycjB06FBNd0lERKQ3zIwN8c3IVlg+shVMjQwQEpGAAStOIiSi9JMRVPNoPKbr6dOn8Pb2xqNHj6BUKjFy5Eh4enqiTp06iIuLw/Xr17Fjxw5kZWXB0dERly5dgq2trbbqr1Y4pouIqGqLiEvDrK0XcTM2FZIEzOnRGHN7NYGhgdamxSQ9pLOB9AAQERGBsWPH4p9//nm20WJueN2+fXsEBgaiUaNGmu6u2mLoIiKq+rJy87F0/zVsO/dsDscOrrZYObY16lqZyFwZVRadhq4Cf/31F44cOYJbt24hLS0NFhYWcHd3R79+/dCzZ09t7abaYugiIqo+9l1+hA92X0V6Tj5szY3x7ahW6N60jtxlUSWQJXSRZhi6iIiql3vx6Zi19SKuR6cAAGZ2b4R3+rizu7Ga0dmUEURERFQ8V3tz7H6zCyZ0cgYA/Bh0B2N+OoPHSZkyV0ZyKNeZrn/ff7CiOG1E8Ximi4io+jpwNRrv77yC1Ow81DIzwvKRrdCrWV25yyItqJTuRYVCUWiQfEVIkoS8vDyNtlFdMXQREVVvkQkZmL3tIq48TAYAvO7rinf7ecDYkB1PVVmlhC4XFxeNQxcA3Lt3T+NtVEcMXURE1V92Xj6+PBiOjSH3AQDejrWwcmxrONqayVsYVRgH0ldBDF1ERDXH4WsxeHdHKFKy8mBlYoivR7ZCPy8HucuiCuBAeiIiIj3Wz8sBB+b5wtuxFlKy8vDGln+w5PdryM7Ll7s0qiQMXURERDJpaGOGHTM6Y/pLbgCATafu49UfT+NBQrrMlVFlYOgiIiKSkZGBAh8MaIafJ7VDLTMjXH2UjEH+J/HnlWi5SyMtY+giIiLSAz096uLAXF+0c7ZBanYeZgVexEd7ryIrl92N1QVDFxERkZ6oX8sUv07vhDe7P7tPccCZSAxffQp3n6TJXBlpA0MXERGRHjE0UOC9/h7YPKUD7MyNcSM6BYNXnsS+y4/kLo00xNBFRESkh7q518aBeb7o5GaL9Jx8zPv1MhbuuoLMHHY3VlUMXURERHqqrpUJtk7rhLm9mkCSgF/PR2HYDyGIiEuVuzSqAIYuIiIiPWagkDC/jzsCpnaEvYUSN2NTMXhlCHb+81Du0qicGLqIiIiqgK6N7XFgng+6NrZDZm4+FuwIxTvbQ5GRw/sZVxUMXURERFVEHUsT/DKlI97p4w6FBOy6+BBDVoXgZgy7G6sChi4iIqIqxEAhYU6vJgh8vRPqWikREZeGIatO4tdzkeDtlPUbQxcREVEV1MnNDgfm+qKbe21k56mwcPdVvPXbZaRls7tRXzF0ERERVVF2FkpsnNQe7/f3gIFCwr7LjzFk5Ulce5wsd2lUDIYuIiKiKkyhkDCzeyP8Nr0T6lmb4G58OoavPoUtZx6wu1HPMHQRERFVA+1cbHFgri96edRBTp4KH+8Nw+xtl5CSlSt3afT/MXQRERFVEzbmxlg/sR0+GtgMhgoJf16JxiD/k7j6kN2N+oChi4iIqBqRJAnTfN2wY0ZnNKhlisinGRjx4ylsCrnH7kaZMXQRERFVQ62dbHBgri/6etZFTr4KS/Zfx4yAf5Ccwe5GuTB0ERERVVPWZkZYO6Etlgz2hLGBAoevxWLgymBcikyUu7QaiaGLiIioGpMkCZO6umLXzC5wsjXDw8RMjFxzGuuD77K7UccYuoiIiGqAFg2t8cdcHwxsUQ95KoFlf97AtM0XkJieI3dpNQZDFxERUQ1hZWKEVeNaY9mw5jA2VOCv8DgM9A/GhftP5S6tRmDoIiIiqkEkSYJfJ2fsebMLXO3N8Tg5C6N/OoPVQRFQqdjdWJkYuoiIiGogr/rW2D/HB0O96yNfJfDVoZuYvOk8EtKy5S6t2mLoIiIiqqEslIb4frQ3/juiBZSGChy/9QQD/INx9m6C3KVVSwxdRERENZgkSRjd3gm/z/ZB4zoWiE3Jxth1Z7Dyr9vIZ3ejVjF0EREREZo6WOL32V0xok1DqASw/OgtvPbzWTxJZXejtjB0EREREQDAzNgQy0e1wjcjW8HUyAAhEQl4eUUwQiLi5S6tWmDoIiIiokJebdsQ++d0RdO6lohPy4bfhrP49ugtdjdqiKGLiIiIimhcxxJ7Z3XFmPaOEALw/+s2xq8/g9iULLlLq7IYuoiIiKhYpsYG+HJES6wY4w1zYwOcufsUA1YE4/itJ3KXViUxdBEREVGphno3wP45PmhWzwoJ6TmY+PM5/PdQOPLyVXKXVqUwdBEREdELudW2wJ43u2BCJ2cAwI9BdzDmpzN4nJQpc2VVB0MXERERlYmJkQH+M6w5fhjXBpZKQ1x4kIgB/sH4OzxW7tKqBIYuIiIiKpeBLevhj7k+aNHAGkkZuZiy6QI+P3ADuexuLBVDFxEREZWbs505ds7sjEldXAAAP524i5FrTuNhYoa8hekxhi4iIiKqEKWhAZYM8cLaCW1hZWKIy1FJGLAiGIevxchdml5i6CIiIiKN9PNywJ9zfeHtWAspWXl4Y8s/WLr/GnLy2N34PIYuIiIi0pijrRm2v9EZr/u6AgA2htzHq2tOITKB3Y0FGLqIiIhIK4wNFfhwoCc2TGyHWmZGuPIwGQP9g3HgarTcpekFhi4iIiLSql7N6uLAXF+0c7ZBanYe3tx6ER/vDUNWbr7cpcmKoYuIiIi0rn4tU2yb3glvdm8EANhy5gFeWX0K9+LTZa5MPgxdREREVCmMDBR4r78HNk/pAFtzY1yPTsEg/2Dsu/xI7tJkwdBFRERElaqbe20cnOeLjq62SM/Jx7xfL2PR7is1rruRoYuIiIgqXV0rE2yd1hFzezaGJAHbzkVh6KoQRMSlyV2aztS40HXixAksWLAAPXr0gLW1NSRJwqRJkyq0LUmSSnx8+eWX2i2ciIioijM0UGB+36bYMqUj7C2UuBmbisErT2LXPw/lLk0nDOUuQNd+/vlnbN68GWZmZnByckJKSopG23N2di42tPn4+Gi0XSIiourKp4k9Dszzwdu/XUZIRALe2RGK03cT8OlQL5gZV99oIgkhhNxF6NKFCxdgamoKDw8PnD9/Hp07d8bEiROxadOmcm9LkiR069YNQUFBWqktJSUF1tbWSE5OhpWVlVa2SUREpK/yVQI/HIvA9/+7BZUAmtSxwA/j28C9rqXcpZVLWb+/a1z3Yrt27eDl5QUDAwO5SyEiIqrRDBQS5vZqgsDXO6GOpRK349IwZNVJ/HY+EtXxnFD1PYenI0lJSVi/fj3i4uJQu3ZtdO/eHU2aNJG7LCIioiqjk5sdDszzxfztoThx6wne33UVp+8kYNnwFrBQVp+oUn1eiUxCQ0Px+uuvq3+WJAnjx4/H2rVrYWZmVupzs7OzkZ2drf5Z0/FlREREVZW9hRKbJrXHmhN3sPzILey9/BhXHiZj1bg28KxfPYbc1LjuRW1asGABzp49i6dPnyIxMRF///03OnbsiICAAEydOvWFz//iiy9gbW2tfjg6OuqgaiIiIv2kUEh4s3tj/Da9E+pZm+BufDqGrQ5BwJkH1aK7sUoOpLe3t0dCQkKZ2x87dgzdu3cvsvzMmTMaDaQvTkZGBlq1aoWIiAiEhYXBy8urxLbFnelydHTkQHoiIqrxEtNzsGBHKP4KjwMADGxZD1++0gKWJkYyV1ZUWQfSV8nuxbFjxyI1NbXM7R0cHCqxmsLMzMwwduxY/Oc//0FISEipoUupVEKpVOqsNiIioqrCxtwY6ye2w/rge/jvoXD8eSUaYY+SsWpsG7RoaC13eRVSJUPXypUr5S6hVPb29gCenfUiIiKiipEkCa+/5Ia2LjaYE3gJDxIyMOLHU/hggAcmdnGBJElyl1guHNNVCc6ePQsAcHFxkbcQIiKiaqCNkw0OzPVFX8+6yMlXYcn+65gZcBHJmblyl1YuDF0vkJGRgfDwcERGRhZafunSpWLPZO3YsQPbtm2Dvb09evfurasyiYiIqjVrMyOsndAWiwd7wshAwqFrMRjoH4zLUUlyl1ZmVbJ7URMnT57E+vXrAQBPnjxRLyu4lY+HhwcWLlyobn/u3Dn06NGjyMzzK1aswN69e9GrVy84OTlBCIGLFy8iODgYJiYm2Lx5MywsLHT2uoiIiKo7SZIwuasr2jrbYHbgJUQ+zcCrP57Cwpc9MNXHVe+7G2tc6IqIiMDmzZsLLbtz5w7u3LkDAOjWrVuh0FWSoUOHIikpCRcvXsShQ4eQl5eHBg0aYOrUqViwYAE8PDwqpX4iIqKarmXDWvhjrg8W7rqCA1djsOzPGzhzNwHfjGyFWmbGcpdXoio5ZUR1xXsvEhERlZ0QAgFnI/GfP64jJ0+F+tYmWDmuNdo62+q0Dt57kYiIiKo1SZIwoZMz9rzZBa725nicnIVRa8/gx6A7UKn075wSQxcRERFVaV71rbF/jg+GetdHvkrgv4fCMWXzeSSkPZuAPF8lcPpOAvZdfoTTdxKQL1MgY/eiHmH3IhERUcUJIfDb+Sgs/v0asvNUqGulhF9HJwSei0J0cpa6XT1rEywe7In+zetpZb9l/f5m6NIjDF1ERESaC49JwaytF3HnSXqx6wuucfzRr41WghfHdBEREVGN5OFghb2zusLUqPiYU3C2aen+6zrtamToIiIiomon7FEKMnNVJa4XAKKTs3Du3lOd1cTQRURERNVOXGrWixuVo502MHQRERFRtVPH0kSr7bSBoYuIiIiqnQ6utqhnbYKSbgwk4dlVjB1cdTeRKkMXERERVTsGCgmLB3sCQJHgVfDz4sGeMFDo7n6NDF1ERERULfVvXg8/+rWBg3XhLkQHaxOtTRdRHjXuhtdERERUc/RvXg99PB1w7t5TxKVmoY7lsy5FXZ7hKsDQRURERNWagUJC50Z2cpfB7kUiIiIiXWDoIiIiItIBhi4iIiIiHWDoIiIiItIBhi4iIiIiHWDoIiIiItIBhi4iIiIiHWDoIiIiItIBhi4iIiIiHeCM9HpECAEASElJkbkSIiIiKquC7+2C7/GSMHTpkdTUVACAo6OjzJUQERFReaWmpsLa2rrE9ZJ4USwjnVGpVHj8+DEsLS0hSdq7EWdKSgocHR0RFRUFKysrrW2XdIfHsOrjMazaePyqvso8hkIIpKamon79+lAoSh65xTNdekShUKBhw4aVtn0rKyv+Y1HF8RhWfTyGVRuPX9VXWcewtDNcBTiQnoiIiEgHGLqIiIiIdIChqwZQKpVYvHgxlEql3KVQBfEYVn08hlUbj1/Vpw/HkAPpiYiIiHSAZ7qIiIiIdIChi4iIiEgHGLqIiIiIdIChi4iIiEgHGLqqgKSkJMydOxedO3eGg4MDlEolGjRogJ49e2LXrl3F3uspJSUF8+fPh7OzM5RKJZydnTF//vxS7+sYGBiIDh06wNzcHDY2NhgwYAAuXLhQmS+txvrqq68gSRIkScKZM2eKbcNjqF9cXFzUx+zfjxkzZhRpz+Onv/bs2YM+ffrAzs4OpqamcHV1xdixYxEVFVWoHY+hftm0aVOJv4MFj169ehV6jr4dQ169WAVERETA29sbnTp1QuPGjWFra4u4uDjs378fcXFxeP311/HTTz+p26enp8PHxweXL19Gnz590KZNG4SGhuLQoUPw9vbGyZMnYW5uXmgfn3/+OT788EM4OTnh1VdfRVpaGn799VdkZWXh8OHD6N69u45fdfV148YNtG7dGoaGhkhPT8fp06fRqVOnQm14DPWPi4sLkpKS8NZbbxVZ165dOwwaNEj9M4+ffhJCYMaMGfjpp5/QqFEj9OvXD5aWlnj8+DGOHz+OrVu3wsfHBwCPoT66fPky9u7dW+y6nTt34tq1a/jvf/+L9957D4CeHkNBei8vL0/k5uYWWZ6SkiI8PT0FABEWFqZe/sknnwgA4r333ivUvmD5J598Umj5rVu3hKGhoXB3dxdJSUnq5WFhYcLMzEw0atSo2P1T+eXl5Yn27duLDh06CD8/PwFAnD59ukg7HkP94+zsLJydncvUlsdPP61YsUIAELNmzRJ5eXlF1j//HvMYVh3Z2dnCzs5OGBoaipiYGPVyfTyGDF1V3Ntvvy0AiL179wohhFCpVKJ+/frCwsJCpKWlFWqbmZkpbGxsRIMGDYRKpVIvX7RokQAgNm/eXGT7M2bMEADE4cOHK/eF1BCfffaZMDY2FmFhYWLixInFhi4eQ/1U1tDF46efMjIyhK2trXBzc3vhFyePYdXy66+/CgBi2LBh6mX6egw5pqsKy8rKwt9//w1JkuDp6QkAuH37Nh4/foyuXbsWOW1qYmKCl156CY8ePUJERIR6eVBQEACgb9++RfbRr18/AMDx48cr6VXUHGFhYVi6dCk++ugjeHl5ldiOx1B/ZWdnY/Pmzfj888/x448/IjQ0tEgbHj/9dPToUTx9+hTDhg1Dfn4+du/ejS+//BJr1qwpdCwAHsOqZsOGDQCAadOmqZfp6zE01OjZpFNJSUn4/vvvoVKpEBcXhwMHDiAqKgqLFy9GkyZNADz7oAFQ//xvz7d7/v8tLCzg4OBQanuquLy8PEyaNAnNmjXDwoULS23LY6i/YmJiMGnSpELL+vfvjy1btsDe3h4Aj5++KhgIbWhoiFatWuHmzZvqdQqFAm+//Ta++eYbADyGVcmDBw/w119/oUGDBujfv796ub4eQ4auKiQpKQlLly5V/2xkZISvv/4a77zzjnpZcnIyAMDa2rrYbVhZWRVqV/D/derUKXN7Kr/PP/8coaGhOHv2LIyMjEpty2Oon6ZMmYJu3brBy8sLSqUS169fx9KlS3Hw4EEMGTIEISEhkCSJx09PxcXFAQCWL1+ONm3a4Ny5c2jWrBkuXbqE6dOnY/ny5WjUqBFmzpzJY1iFbNy4ESqVCpMnT4aBgYF6ub4eQ3YvViEuLi4QQiAvLw/37t3Dp59+ig8//BAjRoxAXl6e3OVRCUJDQ7Fs2TIsWLAAbdq0kbscqqBPPvkE3bp1g729PSwtLdGxY0f88ccf8PHxwenTp3HgwAG5S6RSqFQqAICxsTH27t2L9u3bw8LCAr6+vti5cycUCgWWL18uc5VUHiqVChs3boQkSZgyZYrc5ZQJQ1cVZGBgABcXFyxcuBDLli3Dnj17sG7dOgD/l+pLSuMFc5M8n/6tra3L1Z7KZ+LEiWjUqBGWLFlSpvY8hlWHQqHA5MmTAQAhISEAePz0VcH7165dO9SvX7/QOi8vL7i5ueHOnTtISkriMawijh49isjISPTs2ROurq6F1unrMWToquIKBvwVDAB8Ub9zcf3cTZo0QVpaGmJiYsrUnsonNDQU4eHhMDExKTSJ3+bNmwEAnTt3hiRJ6vlneAyrloKxXBkZGQB4/PRV06ZNAQC1atUqdn3B8szMTB7DKqK4AfQF9PUYMnRVcY8fPwbwbHAo8OwDUb9+fYSEhCA9Pb1Q26ysLJw4cQL169dH48aN1cu7desGADhy5EiR7R8+fLhQGyq/qVOnFvso+OUdMmQIpk6dChcXFwA8hlXN2bNnAYDHT8/16NEDwLPJif8tNzcXERERMDc3R+3atXkMq4CEhATs27cPtra2GD58eJH1ensMNZpwgnTi0qVLhSZqK5CQkCC8vb0FALFlyxb18vJOCHfz5k1O6ieDkubpEoLHUN9cu3ZNJCYmFlkeHBwsTExMhFKpFA8ePFAv5/HTT3379hUAxLp16wot//TTTwUA4efnp17GY6jfvvvuOwFAzJ07t8Q2+ngMGbqqgHnz5glzc3MxaNAgMWvWLPHee++J0aNHCwsLCwFAjBgxQuTn56vbp6WlqcNYnz59xMKFC8XLL78sAAhvb+8iE8UJIcSyZcsEAOHk5CTmz58v3njjDWFlZSWMjIzE33//rcuXW2OUFrp4DPXL4sWLhampqRg0aJCYPXu2eOedd0S/fv2EJEnCwMCgyJc4j59+ioiIEHXq1BEAxMCBA8U777wjevbsKQAIZ2dnER0drW7LY6jfmjdvLgCIK1eulNhGH48hQ1cVEBwcLCZNmiQ8PDyElZWVMDQ0FHXq1BH9+/cXgYGBhWbULZCUlCTefvtt4ejoKIyMjISjo6N4++23iz1jViAgIEC0a9dOmJqaCmtra9G/f39x7ty5ynxpNVppoUsIHkN9EhQUJEaNGiUaN24sLC0thZGRkWjYsKEYM2aMOHv2bLHP4fHTT5GRkWLSpEnCwcFBfVxmzZolYmNji7TlMdRPZ8+eFQBEhw4dXthW344hb3hNREREpAMcSE9ERESkAwxdRERERDrA0EVERESkAwxdRERERDrA0EVERESkAwxdRERERDrA0EVERESkAwxdRERERDrA0EVEVAmCgoIgSVKhx6ZNm7S2/WHDhhXadsENt4lIfzF0EVGN9u9gVJZH9+7dy7x9KysrdO3aFV27dkXdunULrdu0adMLA9PmzZthYGAASZLw1VdfqZd7enqia9euaNeuXXlfMhHJxFDuAoiI5NS1a9ciy5KTkxEWFlbi+hYtWpR5+61bt0ZQUFCFavv555/x+uuvQ6VSYfny5Zg/f7563eeffw4AuH//PlxdXSu0fSLSLYYuIqrRTp48WWRZUFAQevToUeJ6XVi/fj2mT58OIQRWrFiBuXPnylIHEWkPQxcRkZ5Zu3YtZs6cCQD44Ycf8Oabb8pcERFpA0MXEZEe+fHHHzFr1iz1/7/xxhsyV0RE2sKB9EREemLVqlXqs1rr1q1j4CKqZhi6iIj0gL+/P+bMmQOFQoGff/4ZU6dOlbskItIydi8SEcns0aNHmDdvHiRJwubNm+Hn5yd3SURUCXimi4hIZkII9X8fPnwoczVEVFkYuoiIZNawYUP1vFuLFi3CDz/8IHNFRFQZGLqIiPTAokWLsGjRIgDAnDlztHrLICLSDwxdRER64vPPP8ecOXMghMC0adOwc+dOuUsiIi1i6CIi0iMrVqzA5MmTkZ+fj3HjxuHAgQNyl0REWsLQRUSkRyRJwvr16zFq1Cjk5uZixIgROHbsmNxlEZEWMHQREekZhUKBgIAADBo0CFlZWRgyZAjOnDkjd1lEpCGGLiIiPWRkZIQdO3agZ8+eSEtLw4ABAxAaGip3WUSkAYYuIiI9ZWJigt9//x2dO3dGYmIi+vbti/DwcLnLIqIK4oz0RET/0r17d/WEpZVp0qRJmDRpUqltzM3NcerUqUqvhYgqH0MXEVElunTpEnx8fAAAH374IV5++WWtbPeDDz7AiRMnkJ2drZXtEVHlY+giIqpEKSkpCAkJAQDExsZqbbvXr19Xb5eIqgZJ6OIcOhEREVENx4H0RERERDrA0EVERESkAwxdRERERDrA0EVERESkAwxdRERERDrA0EVERESkAwxdRERERDrA0EVERESkAwxdRERERDrA0EVERESkAwxdRERERDrw/wAfhnao4PtA5QAAAABJRU5ErkJggg==\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 c004ad36f00..00000000000 --- a/pyomo/contrib/doe/examples/reactor_compute_FIM.py +++ /dev/null @@ -1,111 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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..b6703606781 --- /dev/null +++ b/pyomo/contrib/doe/examples/reactor_compute_factorial_FIM.py @@ -0,0 +1,98 @@ +# ___________________________________________________________________________ +# +# 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.dependencies import numpy as np + +from pyomo.contrib.doe.examples.reactor_experiment import ReactorExperiment +from pyomo.contrib.doe import DesignOfExperiments + +import pyomo.environ as pyo + +import json +from pathlib import Path + + +# Example to run a DoE on the reactor +def run_reactor_doe(): + # Read in file + DATA_DIR = 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..1570c870181 --- /dev/null +++ b/pyomo/contrib/doe/examples/reactor_example.py @@ -0,0 +1,130 @@ +# ___________________________________________________________________________ +# +# 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.dependencies import numpy as np + +from pyomo.contrib.doe.examples.reactor_experiment import ReactorExperiment +from pyomo.contrib.doe import DesignOfExperiments + +import pyomo.environ as pyo + +import json +from pathlib import Path + + +# 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 = 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..631510dd23a --- /dev/null +++ b/pyomo/contrib/doe/examples/reactor_experiment.py @@ -0,0 +1,214 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ +# === 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 a4516c36451..00000000000 --- a/pyomo/contrib/doe/examples/reactor_grid_search.py +++ /dev/null @@ -1,140 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 57d06e146c5..00000000000 --- a/pyomo/contrib/doe/examples/reactor_kinetics.py +++ /dev/null @@ -1,247 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 56ea1ffeac3..00000000000 --- a/pyomo/contrib/doe/examples/reactor_optimize_doe.py +++ /dev/null @@ -1,123 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 75fd4f7c485..00000000000 --- a/pyomo/contrib/doe/measurements.py +++ /dev/null @@ -1,328 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 65ded38a63b..00000000000 --- a/pyomo/contrib/doe/result.py +++ /dev/null @@ -1,758 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 eff9c883e0b..00000000000 --- a/pyomo/contrib/doe/scenario.py +++ /dev/null @@ -1,154 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/doe/tests/__init__.py +++ b/pyomo/contrib/doe/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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..5a25bbf4b37 --- /dev/null +++ b/pyomo/contrib/doe/tests/experiment_class_example_flags.py @@ -0,0 +1,262 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ +# === 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..074ab58391c --- /dev/null +++ b/pyomo/contrib/doe/tests/test_doe_build.py @@ -0,0 +1,479 @@ +# ___________________________________________________________________________ +# +# 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 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..52f6ef8cdb8 --- /dev/null +++ b/pyomo/contrib/doe/tests/test_doe_errors.py @@ -0,0 +1,702 @@ +# ___________________________________________________________________________ +# +# 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 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_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_input_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_experiment_input_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_experiment_input_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..c25eb8018f7 --- /dev/null +++ b/pyomo/contrib/doe/tests/test_doe_solve.py @@ -0,0 +1,416 @@ +# ___________________________________________________________________________ +# +# 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 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))) + + # 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 0f143e03677..00000000000 --- a/pyomo/contrib/doe/tests/test_example.py +++ /dev/null @@ -1,70 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 42b463162b2..00000000000 --- a/pyomo/contrib/doe/tests/test_fim_doe.py +++ /dev/null @@ -1,360 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 86c914ec4e0..00000000000 --- a/pyomo/contrib/doe/tests/test_reactor_example.py +++ /dev/null @@ -1,220 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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..649a87b6fe3 --- /dev/null +++ b/pyomo/contrib/doe/utils.py @@ -0,0 +1,102 @@ +# ___________________________________________________________________________ +# +# 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.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 7f2d08a0292..fc9ee68bca3 100644 --- a/pyomo/contrib/example/__init__.py +++ b/pyomo/contrib/example/__init__.py @@ -1,16 +1,28 @@ +# ___________________________________________________________________________ # -# import symbols and sub-packages +# 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 "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 295540d3318..22e5c3997e9 100644 --- a/pyomo/contrib/example/bar.py +++ b/pyomo/contrib/example/bar.py @@ -1 +1,12 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + b = "1" diff --git a/pyomo/contrib/example/foo.py b/pyomo/contrib/example/foo.py index 1337a530cbc..f879bc70722 100644 --- a/pyomo/contrib/example/foo.py +++ b/pyomo/contrib/example/foo.py @@ -1 +1,12 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + a = 1 diff --git a/pyomo/contrib/example/plugins/__init__.py b/pyomo/contrib/example/plugins/__init__.py index dc71adec9dc..8846e7c1650 100644 --- a/pyomo/contrib/example/plugins/__init__.py +++ b/pyomo/contrib/example/plugins/__init__.py @@ -1,6 +1,17 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Define a 'load()' function, which simply imports # sub-packages that define plugin classes. 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 504605205f4..7ee4c414ccf 100644 --- a/pyomo/contrib/example/plugins/ex_plugin.py +++ b/pyomo/contrib/example/plugins/ex_plugin.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.base import Transformation, TransformationFactory diff --git a/pyomo/contrib/example/tests/__init__.py b/pyomo/contrib/example/tests/__init__.py index 5a1047f74ae..9c45a6ef8b6 100644 --- a/pyomo/contrib/example/tests/__init__.py +++ b/pyomo/contrib/example/tests/__init__.py @@ -1 +1,12 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # Tests for pyomo.contrib.example diff --git a/pyomo/contrib/example/tests/test_example.py b/pyomo/contrib/example/tests/test_example.py index c38de1b914f..55394f5d0c1 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-2022 +# 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 diff --git a/pyomo/contrib/fbbt/__init__.py b/pyomo/contrib/fbbt/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/fbbt/__init__.py +++ b/pyomo/contrib/fbbt/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/fbbt/expression_bounds_walker.py b/pyomo/contrib/fbbt/expression_bounds_walker.py index 426d30f0ee6..3cb32fcbf29 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-2022 +# 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 @@ -9,9 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import logging from math import pi from pyomo.common.collections import ComponentMap from pyomo.contrib.fbbt.interval import ( + BoolFlag, + eq, + ineq, + ranged, + if_, add, acos, asin, @@ -30,6 +36,7 @@ ) from pyomo.core.base.expression import Expression from pyomo.core.expr.numeric_expr import ( + NumericExpression, NegationExpression, ProductExpression, DivisionExpression, @@ -40,12 +47,20 @@ LinearExpression, SumExpression, ExternalFunctionExpression, + Expr_ifExpression, +) +from pyomo.core.expr.logical_expr import BooleanExpression +from pyomo.core.expr.relational_expr import ( + EqualityExpression, + InequalityExpression, + RangedExpression, ) from pyomo.core.expr.numvalue import native_numeric_types, native_types, value from pyomo.core.expr.visitor import StreamBasedExpressionVisitor from pyomo.repn.util import BeforeChildDispatcher, ExitNodeDispatcher inf = float('inf') +logger = logging.getLogger(__name__) class ExpressionBoundsBeforeChildDispatcher(BeforeChildDispatcher): @@ -60,6 +75,14 @@ def _before_external_function(visitor, child): # this then this should use them return False, (-inf, inf) + @staticmethod + def _before_native_numeric(visitor, child): + return False, (child, child) + + @staticmethod + def _before_native_logical(visitor, child): + return False, (BoolFlag(child), BoolFlag(child)) + @staticmethod def _before_var(visitor, child): leaf_bounds = visitor.leaf_bounds @@ -67,12 +90,15 @@ def _before_var(visitor, child): pass elif child.is_fixed() and visitor.use_fixed_var_values_as_bounds: val = child.value - if val is None: + try: + ans = visitor._before_child_handlers[val.__class__](visitor, val) + except ValueError: raise ValueError( "Var '%s' is fixed to None. This value cannot be used to " "calculate bounds." % child.name - ) - leaf_bounds[child] = (child.value, child.value) + ) from None + leaf_bounds[child] = ans[1] + return ans else: lb = child.lb ub = child.ub @@ -93,23 +119,20 @@ def _before_named_expression(visitor, child): @staticmethod def _before_param(visitor, child): - return False, (child.value, child.value) - - @staticmethod - def _before_native(visitor, child): - return False, (child, child) + val = child.value + return visitor._before_child_handlers[val.__class__](visitor, val) @staticmethod def _before_string(visitor, child): raise ValueError( - f"{child!r} ({type(child)}) is not a valid numeric type. " + f"{child!r} ({type(child).__name__}) is not a valid numeric type. " f"Cannot compute bounds on expression." ) @staticmethod def _before_invalid(visitor, child): raise ValueError( - f"{child!r} ({type(child)}) is not a valid numeric type. " + f"{child!r} ({type(child).__name__}) is not a valid numeric type. " f"Cannot compute bounds on expression." ) @@ -123,10 +146,7 @@ def _before_complex(visitor, child): @staticmethod def _before_npv(visitor, child): val = value(child) - return False, (val, val) - - -_before_child_handlers = ExpressionBoundsBeforeChildDispatcher() + return visitor._before_child_handlers[val.__class__](visitor, val) def _handle_ProductExpression(visitor, node, arg1, arg2): @@ -207,6 +227,26 @@ def _handle_named_expression(visitor, node, arg): return arg +def _handle_unknowable_bounds(visitor, node, arg): + return -inf, inf + + +def _handle_equality(visitor, node, arg1, arg2): + return eq(*arg1, *arg2, feasibility_tol=visitor.feasibility_tol) + + +def _handle_inequality(visitor, node, arg1, arg2): + return ineq(*arg1, *arg2, feasibility_tol=visitor.feasibility_tol) + + +def _handle_ranged(visitor, node, arg1, arg2, arg3): + return ranged(*arg1, *arg2, *arg3, feasibility_tol=visitor.feasibility_tol) + + +def _handle_expr_if(visitor, node, arg1, arg2, arg3): + return if_(*arg1, *arg2, *arg3) + + _unary_function_dispatcher = { 'exp': _handle_exp, 'log': _handle_log, @@ -221,20 +261,20 @@ def _handle_named_expression(visitor, node, arg): } -_operator_dispatcher = ExitNodeDispatcher( - { - ProductExpression: _handle_ProductExpression, - DivisionExpression: _handle_DivisionExpression, - PowExpression: _handle_PowExpression, - AbsExpression: _handle_AbsExpression, - SumExpression: _handle_SumExpression, - MonomialTermExpression: _handle_ProductExpression, - NegationExpression: _handle_NegationExpression, - UnaryFunctionExpression: _handle_UnaryFunctionExpression, - LinearExpression: _handle_SumExpression, - Expression: _handle_named_expression, - } -) +class ExpressionBoundsExitNodeDispatcher(ExitNodeDispatcher): + def unexpected_expression_type(self, visitor, node, *args): + if isinstance(node, NumericExpression): + ans = -inf, inf + elif isinstance(node, BooleanExpression): + ans = BoolFlag(False), BoolFlag(True) + else: + super().unexpected_expression_type(visitor, node, *args) + logger.warning( + f"Unexpected expression node type '{type(node).__name__}' " + f"found while walking expression tree; returning {ans} " + "for the expression bounds." + ) + return ans class ExpressionBoundsVisitor(StreamBasedExpressionVisitor): @@ -259,6 +299,27 @@ class ExpressionBoundsVisitor(StreamBasedExpressionVisitor): the computed bounds should be valid. """ + _before_child_handlers = ExpressionBoundsBeforeChildDispatcher() + _operator_dispatcher = ExpressionBoundsExitNodeDispatcher( + { + ProductExpression: _handle_ProductExpression, + DivisionExpression: _handle_DivisionExpression, + PowExpression: _handle_PowExpression, + AbsExpression: _handle_AbsExpression, + SumExpression: _handle_SumExpression, + MonomialTermExpression: _handle_ProductExpression, + NegationExpression: _handle_NegationExpression, + UnaryFunctionExpression: _handle_UnaryFunctionExpression, + LinearExpression: _handle_SumExpression, + Expression: _handle_named_expression, + ExternalFunctionExpression: _handle_unknowable_bounds, + EqualityExpression: _handle_equality, + InequalityExpression: _handle_inequality, + RangedExpression: _handle_ranged, + Expr_ifExpression: _handle_expr_if, + } + ) + def __init__( self, leaf_bounds=None, @@ -277,7 +338,7 @@ def initializeWalker(self, expr): return True, expr def beforeChild(self, node, child, child_idx): - return _before_child_handlers[child.__class__](self, child) + return self._before_child_handlers[child.__class__](self, child) def exitNode(self, node, data): - return _operator_dispatcher[node.__class__](self, node, *data) + return self._operator_dispatcher[node.__class__](self, node, *data) diff --git a/pyomo/contrib/fbbt/fbbt.py b/pyomo/contrib/fbbt/fbbt.py index bf42cbe7f33..9a2e4958d9f 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-2022 +# 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 @@ -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 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(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 ) @@ -1169,7 +1251,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) @@ -1249,36 +1331,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 +1351,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 +1398,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 +1585,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 fd86af4c106..4b93d6e3f31 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-2022 +# 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 @@ -17,6 +17,123 @@ inf = float('inf') +class _bool_flag(object): + def __init__(self, val): + self._val = val + + def __bool__(self): + return self._val + + def _op(self, *others): + raise ValueError( + f"{self._val!r} ({type(self._val).__name__}) is not a valid numeric type. " + f"Cannot compute bounds on expression." + ) + + def __repr__(self): + return repr(self._val) + + __float__ = _op + __int__ = _op + __abs__ = _op + __neg__ = _op + __add__ = _op + __sub__ = _op + __mul__ = _op + __div__ = _op + __pow__ = _op + __radd__ = _op + __rsub__ = _op + __rmul__ = _op + __rdiv__ = _op + __rpow__ = _op + + +_true = _bool_flag(True) +_false = _bool_flag(False) + + +def BoolFlag(val): + return _true if val else _false + + +def ineq(xl, xu, yl, yu, feasibility_tol): + """Compute the "bounds" on an InequalityExpression + + Note this is *not* performing interval arithmetic: we are + calculating the "bounds" on a RelationalExpression (whose domain is + {True, False}). Therefore we are determining if `x` can be less + than `y`, `x` can not be less than `y`, or both. + + """ + ans = [] + if yl < xu - feasibility_tol: + ans.append(_false) + if xl <= yu + feasibility_tol: + ans.append(_true) + assert ans + if len(ans) == 1: + ans.append(ans[0]) + return tuple(ans) + + +def eq(xl, xu, yl, yu, feasibility_tol): + """Compute the "bounds" on an EqualityExpression + + Note this is *not* performing interval arithmetic: we are + calculating the "bounds" on a RelationalExpression (whose domain is + {True, False}). Therefore we are determining if `x` can be equal to + `y`, `x` can not be equal to `y`, or both. + + """ + ans = [] + if ( + abs(xl - xu) > feasibility_tol + or abs(yl - yu) > feasibility_tol + or abs(xl - yl) > feasibility_tol + ): + ans.append(_false) + if xl <= yu + feasibility_tol and yl <= xu + feasibility_tol: + ans.append(_true) + assert ans + if len(ans) == 1: + ans.append(ans[0]) + return tuple(ans) + + +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 + calculating the "bounds" on a RelationalExpression (whose domain is + {True, False}). Therefore we are determining if `y` can be between + `z` and `z`, `y` can be outside the range `x` and `z`, or both. + + """ + 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) + if lb[1] and ub[1]: + ans.append(_true) + if len(ans) == 1: + ans.append(ans[0]) + return tuple(ans) + + +def if_(il, iu, tl, tu, fl, fu): + l = [] + u = [] + if iu: + l.append(tl) + u.append(tu) + if not il: + l.append(fl) + u.append(fu) + return min(l), max(u) + + def add(xl, xu, yl, yu): return xl + yl, xu + yu @@ -39,12 +156,18 @@ def mul(xl, xu, yl, yu): def inv(xl, xu, feasibility_tol): - """ - The case where xl is very slightly positive but should be very slightly negative (or xu is very slightly negative - but should be very slightly positive) should not be an issue. Suppose xu is 2 and xl is 1e-15 but should be -1e-15. - The bounds obtained from this function will be [0.5, 1e15] or [0.5, inf), depending on the value of - feasibility_tol. The true bounds are (-inf, -1e15] U [0.5, inf), where U is union. The exclusion of (-inf, -1e15] - should be acceptable. Additionally, it very important to return a non-negative interval when xl is non-negative. + """Compute the inverse of an interval + + The case where xl is very slightly positive but should be very + slightly negative (or xu is very slightly negative but should be + very slightly positive) should not be an issue. Suppose xu is 2 and + xl is 1e-15 but should be -1e-15. The bounds obtained from this + function will be [0.5, 1e15] or [0.5, inf), depending on the value + of feasibility_tol. The true bounds are (-inf, -1e15] U [0.5, inf), + where U is union. The exclusion of (-inf, -1e15] should be + acceptable. Additionally, it very important to return a non-negative + interval when xl is non-negative. + """ if xu - xl <= -feasibility_tol: raise InfeasibleConstraintException( @@ -89,9 +212,8 @@ def power(xl, xu, yl, yu, feasibility_tol): Compute bounds on x**y. """ if xl > 0: - """ - If x is always positive, things are simple. We only need to worry about the sign of y. - """ + # If x is always positive, things are simple. We only need to + # worry about the sign of y. if yl < 0 < yu: lb = min(xu**yl, xl**yu) ub = max(xl**yl, xu**yu) @@ -181,14 +303,15 @@ def power(xl, xu, yl, yu, feasibility_tol): def _inverse_power1(zl, zu, yl, yu, orig_xl, orig_xu, feasibility_tol): - """ - z = x**y => compute bounds on x. + """z = x**y => compute bounds on x. First, start by computing bounds on x with x = exp(ln(z) / y) - However, if y is an integer, then x can be negative, so there are several special cases. See the docs below. + However, if y is an integer, then x can be negative, so there are + several special cases. See the docs below. + """ xl, xu = log(zl, zu) xl, xu = div(xl, xu, yl, yu, feasibility_tol) @@ -199,22 +322,31 @@ def _inverse_power1(zl, zu, yl, yu, orig_xl, orig_xu, feasibility_tol): y = yl if y == 0: # Anything to the power of 0 is 1, so if y is 0, then x can be anything - # (assuming zl <= 1 <= zu, which is enforced when traversing the tree in the other direction) + # (assuming zl <= 1 <= zu, which is enforced when traversing + # the tree in the other direction) xl = -inf xu = inf elif y % 2 == 0: - """ - if y is even, then there are two primary cases (note that it is much easier to walk through these - while looking at plots): + """if y is even, then there are two primary cases (note that it is much + easier to walk through these while looking at plots): + case 1: y is positive - x**y is convex, positive, and symmetric. The bounds on x depend on the lower bound of z. If zl <= 0, - then xl should simply be -xu. However, if zl > 0, then we may be able to say something better. For - example, if the original lower bound on x is positive, then we can keep xl computed from - x = exp(ln(z) / y). Furthermore, if the original lower bound on x is larger than -xl computed from - x = exp(ln(z) / y), then we can still keep the xl computed from x = exp(ln(z) / y). Similar logic - applies to the upper bound of x. + + x**y is convex, positive, and symmetric. The bounds on x + depend on the lower bound of z. If zl <= 0, then xl + should simply be -xu. However, if zl > 0, then we may be + able to say something better. For example, if the + original lower bound on x is positive, then we can keep + xl computed from x = exp(ln(z) / y). Furthermore, if the + original lower bound on x is larger than -xl computed + from x = exp(ln(z) / y), then we can still keep the xl + computed from x = exp(ln(z) / y). Similar logic applies + to the upper bound of x. + case 2: y is negative + The ideas are similar to case 1. + """ if zu + feasibility_tol < 0: raise InfeasibleConstraintException( @@ -262,16 +394,25 @@ def _inverse_power1(zl, zu, yl, yu, orig_xl, orig_xu, feasibility_tol): xl = _xl xu = _xu else: # y % 2 == 1 - """ - y is odd. + """y is odd. + Case 1: y is positive - x**y is monotonically increasing. If y is positive, then we can can compute the bounds on x using - x = z**(1/y) and the signs on xl and xu depend on the signs of zl and zu. + + x**y is monotonically increasing. If y is positive, then + we can can compute the bounds on x using x = z**(1/y) + and the signs on xl and xu depend on the signs of zl and + zu. + Case 2: y is negative - Again, this is easier to visualize with a plot. x**y approaches zero when x approaches -inf or inf. - Thus, if zl < 0 < zu, then no bounds can be inferred for x. If z is positive (zl >=0 ) then we can - use the bounds computed from x = exp(ln(z) / y). If z is negative (zu <= 0), then we live in the - bottom left quadrant, xl depends on zu, and xu depends on zl. + + Again, this is easier to visualize with a plot. x**y + approaches zero when x approaches -inf or inf. Thus, if + zl < 0 < zu, then no bounds can be inferred for x. If z + is positive (zl >=0 ) then we can use the bounds + computed from x = exp(ln(z) / y). If z is negative (zu + <= 0), then we live in the bottom left quadrant, xl + depends on zu, and xu depends on zl. + """ if y > 0: xl = abs(zl) ** (1.0 / y) @@ -298,12 +439,13 @@ def _inverse_power1(zl, zu, yl, yu, orig_xl, orig_xu, feasibility_tol): def _inverse_power2(zl, zu, xl, xu, feasiblity_tol): - """ - z = x**y => compute bounds on y + """z = x**y => compute bounds on y y = ln(z) / ln(x) - This function assumes the exponent can be fractional, so x must be positive. This method should not be called - if the exponent is an integer. + This function assumes the exponent can be fractional, so x must be + positive. This method should not be called if the exponent is an + integer. + """ if xu <= 0: raise IntervalException( @@ -391,10 +533,12 @@ def sin(xl, xu): ub: float """ - # if there is a minimum between xl and xu, then the lower bound is -1. Minimums occur at 2*pi*n - pi/2 - # find the minimum value of i such that 2*pi*i - pi/2 >= xl. Then round i up. If 2*pi*i - pi/2 is still less - # than or equal to xu, then there is a minimum between xl and xu. Thus the lb is -1. Otherwise, the minimum - # occurs at either xl or xu + # if there is a minimum between xl and xu, then the lower bound is + # -1. Minimums occur at 2*pi*n - pi/2 find the minimum value of i + # such that 2*pi*i - pi/2 >= xl. Then round i up. If 2*pi*i - pi/2 + # is still less than or equal to xu, then there is a minimum between + # xl and xu. Thus the lb is -1. Otherwise, the minimum occurs at + # either xl or xu if xl <= -inf or xu >= inf: return -1, 1 pi = math.pi @@ -406,7 +550,8 @@ def sin(xl, xu): else: lb = min(math.sin(xl), math.sin(xu)) - # if there is a maximum between xl and xu, then the upper bound is 1. Maximums occur at 2*pi*n + pi/2 + # if there is a maximum between xl and xu, then the upper bound is + # 1. Maximums occur at 2*pi*n + pi/2 i = (xu - pi / 2) / (2 * pi) i = math.floor(i) x_at_max = 2 * pi * i + pi / 2 @@ -432,10 +577,12 @@ def cos(xl, xu): ub: float """ - # if there is a minimum between xl and xu, then the lower bound is -1. Minimums occur at 2*pi*n - pi - # find the minimum value of i such that 2*pi*i - pi >= xl. Then round i up. If 2*pi*i - pi/2 is still less - # than or equal to xu, then there is a minimum between xl and xu. Thus the lb is -1. Otherwise, the minimum - # occurs at either xl or xu + # if there is a minimum between xl and xu, then the lower bound is + # -1. Minimums occur at 2*pi*n - pi find the minimum value of i such + # that 2*pi*i - pi >= xl. Then round i up. If 2*pi*i - pi/2 is still + # less than or equal to xu, then there is a minimum between xl and + # xu. Thus the lb is -1. Otherwise, the minimum occurs at either xl + # or xu if xl <= -inf or xu >= inf: return -1, 1 pi = math.pi @@ -447,7 +594,8 @@ def cos(xl, xu): else: lb = min(math.cos(xl), math.cos(xu)) - # if there is a maximum between xl and xu, then the upper bound is 1. Maximums occur at 2*pi*n + # if there is a maximum between xl and xu, then the upper bound is + # 1. Maximums occur at 2*pi*n i = (xu) / (2 * pi) i = math.floor(i) x_at_max = 2 * pi * i @@ -473,10 +621,12 @@ def tan(xl, xu): ub: float """ - # tan goes to -inf and inf at every pi*i + pi/2 (integer i). If one of these values is between xl and xu, then - # the lb is -inf and the ub is inf. Otherwise the minimum occurs at xl and the maximum occurs at xu. - # find the minimum value of i such that pi*i + pi/2 >= xl. Then round i up. If pi*i + pi/2 is still less - # than or equal to xu, then there is an undefined point between xl and xu. + # tan goes to -inf and inf at every pi*i + pi/2 (integer i). If one + # of these values is between xl and xu, then the lb is -inf and the + # ub is inf. Otherwise the minimum occurs at xl and the maximum + # occurs at xu. find the minimum value of i such that pi*i + pi/2 + # >= xl. Then round i up. If pi*i + pi/2 is still less than or equal + # to xu, then there is an undefined point between xl and xu. if xl <= -inf or xu >= inf: return -inf, inf pi = math.pi @@ -520,12 +670,12 @@ def asin(xl, xu, yl, yu, feasibility_tol): if yl <= -inf: lb = yl elif xl <= math.sin(yl) <= xu: - # if sin(yl) >= xl then yl satisfies the bounds on x, and the lower bound of y cannot be improved + # if sin(yl) >= xl then yl satisfies the bounds on x, and the + # lower bound of y cannot be improved lb = yl elif math.sin(yl) < xl: - """ - we can only push yl up from its current value to the next lowest value such that xl = sin(y). In other words, - we need to + """we can only push yl up from its current value to the next lowest + value such that xl = sin(y). In other words, we need to min y s.t. @@ -533,19 +683,21 @@ def asin(xl, xu, yl, yu, feasibility_tol): y >= yl globally. + """ - # first find the next minimum of x = sin(y). Minimums occur at y = 2*pi*n - pi/2 for integer n. + # first find the next minimum of x = sin(y). Minimums occur at y + # = 2*pi*n - pi/2 for integer n. i = (yl + pi / 2) / (2 * pi) i1 = math.floor(i) i2 = math.ceil(i) i1 = 2 * pi * i1 - pi / 2 i2 = 2 * pi * i2 - pi / 2 - # now find the next value of y such that xl = sin(y). This can be computed by a distance from the minimum (i). + # now find the next value of y such that xl = sin(y). This can + # be computed by a distance from the minimum (i). y_tmp = math.asin(xl) # this will give me a value between -pi/2 and pi/2 - dist = y_tmp - ( - -pi / 2 - ) # this is the distance between the minimum of the sin function and a value that - # satisfies xl = sin(y) + dist = y_tmp - (-pi / 2) + # this is the distance between the minimum of the sin function + # and a value that satisfies xl = sin(y) lb1 = i1 + dist lb2 = i2 + dist if lb1 >= yl - feasibility_tol: @@ -633,12 +785,12 @@ def acos(xl, xu, yl, yu, feasibility_tol): if yl <= -inf: lb = yl elif xl <= math.cos(yl) <= xu: - # if xl <= cos(yl) <= xu then yl satisfies the bounds on x, and the lower bound of y cannot be improved + # if xl <= cos(yl) <= xu then yl satisfies the bounds on x, and + # the lower bound of y cannot be improved lb = yl elif math.cos(yl) < xl: - """ - we can only push yl up from its current value to the next lowest value such that xl = cos(y). In other words, - we need to + """we can only push yl up from its current value to the next lowest + value such that xl = cos(y). In other words, we need to min y s.t. @@ -646,19 +798,21 @@ def acos(xl, xu, yl, yu, feasibility_tol): y >= yl globally. + """ - # first find the next minimum of x = cos(y). Minimums occur at y = 2*pi*n - pi for integer n. + # first find the next minimum of x = cos(y). Minimums occur at y + # = 2*pi*n - pi for integer n. i = (yl + pi) / (2 * pi) i1 = math.floor(i) i2 = math.ceil(i) i1 = 2 * pi * i1 - pi i2 = 2 * pi * i2 - pi - # now find the next value of y such that xl = cos(y). This can be computed by a distance from the minimum (i). + # now find the next value of y such that xl = cos(y). This can + # be computed by a distance from the minimum (i). y_tmp = math.acos(xl) # this will give me a value between 0 and pi - dist = ( - pi - y_tmp - ) # this is the distance between the minimum of the sin function and a value that - # satisfies xl = sin(y) + dist = pi - y_tmp + # this is the distance between the minimum of the sin function + # and a value that satisfies xl = sin(y) lb1 = i1 + dist lb2 = i2 + dist if lb1 >= yl - feasibility_tol: diff --git a/pyomo/contrib/fbbt/tests/__init__.py b/pyomo/contrib/fbbt/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/fbbt/tests/__init__.py +++ b/pyomo/contrib/fbbt/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/fbbt/tests/test_expression_bounds_walker.py b/pyomo/contrib/fbbt/tests/test_expression_bounds_walker.py index c51230155a7..5d27a2e4087 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-2022 +# 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 @@ -10,10 +10,33 @@ # ___________________________________________________________________________ import math -from pyomo.environ import exp, log, log10, sin, cos, tan, asin, acos, atan, sqrt import pyomo.common.unittest as unittest -from pyomo.contrib.fbbt.expression_bounds_walker import ExpressionBoundsVisitor -from pyomo.core import Any, ConcreteModel, Expression, Param, Var + +from pyomo.environ import ( + exp, + log, + log10, + sin, + cos, + tan, + asin, + acos, + atan, + sqrt, + inequality, + Expr_if, + Any, + ConcreteModel, + Expression, + Param, + Var, +) + +from pyomo.common.errors import DeveloperError +from pyomo.common.log import LoggingIntercept +from pyomo.contrib.fbbt.expression_bounds_walker import ExpressionBoundsVisitor, inf +from pyomo.contrib.fbbt.interval import _true, _false +from pyomo.core.expr import ExpressionBase, NumericExpression, BooleanExpression class TestExpressionBoundsWalker(unittest.TestCase): @@ -273,11 +296,19 @@ def test_npv_expression(self): def test_invalid_numeric_type(self): m = self.make_model() - m.p = Param(initialize=True, domain=Any) + m.p = Param(initialize=True, mutable=True, domain=Any) visitor = ExpressionBoundsVisitor() with self.assertRaisesRegex( ValueError, - r"True \(\) is not a valid numeric type. " + r"True \(bool\) is not a valid numeric type. " + r"Cannot compute bounds on expression.", + ): + lb, ub = visitor.walk_expression(m.p + m.y) + + m.p.set_value(None) + with self.assertRaisesRegex( + ValueError, + r"None \(NoneType\) is not a valid numeric type. " r"Cannot compute bounds on expression.", ): lb, ub = visitor.walk_expression(m.p + m.y) @@ -288,7 +319,7 @@ def test_invalid_string(self): visitor = ExpressionBoundsVisitor() with self.assertRaisesRegex( ValueError, - r"'True' \(\) is not a valid numeric type. " + r"'True' \(str\) is not a valid numeric type. " r"Cannot compute bounds on expression.", ): lb, ub = visitor.walk_expression(m.p + m.y) @@ -303,3 +334,82 @@ def test_invalid_complex(self): r"complex numbers. Encountered when processing \(4\+5j\)", ): lb, ub = visitor.walk_expression(m.p + m.y) + + def test_inequality(self): + m = self.make_model() + visitor = ExpressionBoundsVisitor() + self.assertEqual(visitor.walk_expression(m.z <= m.y), (_true, _true)) + self.assertEqual(visitor.walk_expression(m.y <= m.z), (_false, _false)) + self.assertEqual(visitor.walk_expression(m.y <= m.x), (_false, _true)) + + def test_equality(self): + m = self.make_model() + m.p = Param(initialize=5) + visitor = ExpressionBoundsVisitor() + self.assertEqual(visitor.walk_expression(m.y == m.z), (_false, _false)) + self.assertEqual(visitor.walk_expression(m.y == m.x), (_false, _true)) + self.assertEqual(visitor.walk_expression(m.p == m.p), (_true, _true)) + + def test_ranged(self): + m = self.make_model() + visitor = ExpressionBoundsVisitor() + self.assertEqual( + visitor.walk_expression(inequality(m.z, m.y, 5)), (_true, _true) + ) + self.assertEqual( + visitor.walk_expression(inequality(m.y, m.z, m.y)), (_false, _false) + ) + self.assertEqual( + visitor.walk_expression(inequality(m.y, m.x, m.y)), (_false, _true) + ) + + def test_expr_if(self): + m = self.make_model() + visitor = ExpressionBoundsVisitor() + self.assertEqual( + visitor.walk_expression(Expr_if(IF=m.z <= m.y, THEN=m.z, ELSE=m.y)), + m.z.bounds, + ) + self.assertEqual( + visitor.walk_expression(Expr_if(IF=m.z >= m.y, THEN=m.z, ELSE=m.y)), + m.y.bounds, + ) + self.assertEqual( + visitor.walk_expression(Expr_if(IF=m.y <= m.x, THEN=m.y, ELSE=m.x)), (-2, 5) + ) + + def test_unknown_classes(self): + class UnknownNumeric(NumericExpression): + pass + + class UnknownLogic(BooleanExpression): + def nargs(self): + return 0 + + class UnknownOther(ExpressionBase): + @property + def args(self): + return () + + def nargs(self): + return 0 + + visitor = ExpressionBoundsVisitor() + with LoggingIntercept() as LOG: + self.assertEqual(visitor.walk_expression(UnknownNumeric(())), (-inf, inf)) + self.assertEqual( + LOG.getvalue(), + "Unexpected expression node type 'UnknownNumeric' found while walking " + "expression tree; returning (-inf, inf) for the expression bounds.\n", + ) + with LoggingIntercept() as LOG: + self.assertEqual(visitor.walk_expression(UnknownLogic(())), (_false, _true)) + self.assertEqual( + LOG.getvalue(), + "Unexpected expression node type 'UnknownLogic' found while walking " + "expression tree; returning (False, True) for the expression bounds.\n", + ) + with self.assertRaisesRegex( + DeveloperError, "Unexpected expression node type 'UnknownOther' found" + ): + visitor.walk_expression(UnknownOther()) diff --git a/pyomo/contrib/fbbt/tests/test_fbbt.py b/pyomo/contrib/fbbt/tests/test_fbbt.py index 5e8d656eeab..83e69233bb5 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-2022 +# 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 @@ -1335,3 +1335,31 @@ 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)) diff --git a/pyomo/contrib/fbbt/tests/test_interval.py b/pyomo/contrib/fbbt/tests/test_interval.py index 59c62be4e84..1e42162a35e 100644 --- a/pyomo/contrib/fbbt/tests/test_interval.py +++ b/pyomo/contrib/fbbt/tests/test_interval.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 math import pyomo.common.unittest as unittest from pyomo.common.dependencies import numpy as np, numpy_available diff --git a/pyomo/contrib/fme/__init__.py b/pyomo/contrib/fme/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/fme/__init__.py +++ b/pyomo/contrib/fme/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/fme/fourier_motzkin_elimination.py b/pyomo/contrib/fme/fourier_motzkin_elimination.py index 18aa157545e..021650e8f9a 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-2022 +# 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 @@ -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 324dd583d0f..b8278ccbb27 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-2022 +# 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 diff --git a/pyomo/contrib/fme/tests/__init__.py b/pyomo/contrib/fme/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/fme/tests/__init__.py +++ b/pyomo/contrib/fme/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/fme/tests/test_fourier_motzkin_elimination.py b/pyomo/contrib/fme/tests/test_fourier_motzkin_elimination.py index 11c008acf82..961d34a68c7 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-2022 +# 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 @@ -435,7 +435,7 @@ def check_hull_projected_constraints(self, m, constraints, indices): self.assertIs(body.linear_vars[2], m.startup.binary_indicator_var) self.assertEqual(body.linear_coefs[2], 2) - # 1 <= time1_disjuncts[0].ind_var + time_1.disjuncts[1].ind_var + # 1 <= time1_disjuncts[0].ind_var + time1_disjuncts[1].ind_var cons = constraints[indices[7]] self.assertEqual(cons.lower, 1) self.assertIsNone(cons.upper) @@ -548,12 +548,12 @@ def test_project_disaggregated_vars(self): # we of course get tremendous amounts of garbage, but we make sure that # what should be here is: self.check_hull_projected_constraints( - m, constraints, [23, 19, 8, 10, 54, 67, 35, 3, 4, 1, 2] + m, constraints, [16, 12, 69, 71, 47, 60, 28, 1, 2, 3, 4] ) # and when we filter, it's still there. constraints = filtered._pyomo_contrib_fme_transformation.projected_constraints self.check_hull_projected_constraints( - filtered, constraints, [10, 8, 5, 6, 15, 19, 11, 3, 4, 1, 2] + filtered, constraints, [8, 6, 20, 21, 13, 17, 9, 1, 2, 3, 4] ) @unittest.skipIf(not 'glpk' in solvers, 'glpk not available') @@ -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) @@ -570,7 +570,7 @@ def test_post_processing(self): # They should be the same as the above, but now these are *all* the # constraints self.check_hull_projected_constraints( - m, constraints, [10, 8, 5, 6, 15, 19, 11, 3, 4, 1, 2] + m, constraints, [8, 6, 20, 21, 13, 17, 9, 1, 2, 3, 4] ) # and check that we didn't change the model diff --git a/pyomo/contrib/gdp_bounds/__init__.py b/pyomo/contrib/gdp_bounds/__init__.py index 3a02f9e5f8e..a4a626013c4 100644 --- a/pyomo/contrib/gdp_bounds/__init__.py +++ b/pyomo/contrib/gdp_bounds/__init__.py @@ -1 +1,10 @@ -import pyomo.contrib.gdp_bounds.plugins +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/gdp_bounds/compute_bounds.py b/pyomo/contrib/gdp_bounds/compute_bounds.py index f4f046e79df..3c04e4e1af7 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-2022 +# 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 diff --git a/pyomo/contrib/gdp_bounds/info.py b/pyomo/contrib/gdp_bounds/info.py index 3ee87041d25..e65df2bfab0 100644 --- a/pyomo/contrib/gdp_bounds/info.py +++ b/pyomo/contrib/gdp_bounds/info.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Provides functions for retrieving disjunctive variable bound information stored on a model.""" from pyomo.common.collections import ComponentMap @@ -24,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 1ebe44378f0..016a1fc7b13 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-2022 +# 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 diff --git a/pyomo/contrib/gdp_bounds/tests/__init__.py b/pyomo/contrib/gdp_bounds/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/gdp_bounds/tests/__init__.py +++ b/pyomo/contrib/gdp_bounds/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/gdp_bounds/tests/test_gdp_bounds.py b/pyomo/contrib/gdp_bounds/tests/test_gdp_bounds.py index e856ae247f3..0c8eae2c43b 100644 --- a/pyomo/contrib/gdp_bounds/tests/test_gdp_bounds.py +++ b/pyomo/contrib/gdp_bounds/tests/test_gdp_bounds.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests explicit bound to variable bound transformation module.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/gdpopt/GDPopt.py b/pyomo/contrib/gdpopt/GDPopt.py index 3d45fa504cb..f0ff6d690d6 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/__init__.py b/pyomo/contrib/gdpopt/__init__.py index 307fbc1594c..a84b8385ad3 100644 --- a/pyomo/contrib/gdpopt/__init__.py +++ b/pyomo/contrib/gdpopt/__init__.py @@ -1 +1,12 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + __version__ = (22, 5, 13) # Note: date-based version number diff --git a/pyomo/contrib/gdpopt/algorithm_base_class.py b/pyomo/contrib/gdpopt/algorithm_base_class.py index 5bf41148700..c5929ad4a88 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/branch_and_bound.py b/pyomo/contrib/gdpopt/branch_and_bound.py index 26dc2b5f2eb..36b81c881be 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-2022 +# 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 @@ -230,12 +230,12 @@ def _solve_gdp(self, model, config): no_feasible_soln = float('inf') self.LB = ( node_data.obj_lb - if solve_data.objective_sense == minimize + if self.objective_sense == minimize else -no_feasible_soln ) self.UB = ( no_feasible_soln - if solve_data.objective_sense == minimize + if self.objective_sense == minimize else -node_data.obj_lb ) config.logger.info( diff --git a/pyomo/contrib/gdpopt/config_options.py b/pyomo/contrib/gdpopt/config_options.py index 386826b844c..1bb100e25cb 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-2022 +# 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 @@ -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 12266866dbc..690fe1f15f1 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/cut_generation.py b/pyomo/contrib/gdpopt/cut_generation.py index 36a826a4f83..742a2cde395 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/discrete_problem_initialize.py b/pyomo/contrib/gdpopt/discrete_problem_initialize.py index 3dc18132c5b..81c339b94a2 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/enumerate.py b/pyomo/contrib/gdpopt/enumerate.py index 45ecc8864f9..6c25d0088f4 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/gloa.py b/pyomo/contrib/gdpopt/gloa.py index 68bd692f967..212da057e05 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/ldsda.py b/pyomo/contrib/gdpopt/ldsda.py new file mode 100644 index 00000000000..5563770afd3 --- /dev/null +++ b/pyomo/contrib/gdpopt/ldsda.py @@ -0,0 +1,540 @@ +# ___________________________________________________________________________ +# +# 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 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 44c1f8609e8..354b61ae940 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/nlp_initialization.py b/pyomo/contrib/gdpopt/nlp_initialization.py index fc083c095da..dbc33eb20be 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/oa_algorithm_utils.py b/pyomo/contrib/gdpopt/oa_algorithm_utils.py index 9aba59e4527..ce4012d8800 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/plugins.py b/pyomo/contrib/gdpopt/plugins.py index 9d729c63d9c..1f189c159f5 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-2022 +# 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 @@ -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 586a27362a1..2aa1aaf8c67 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/solve_discrete_problem.py b/pyomo/contrib/gdpopt/solve_discrete_problem.py index 3de66fbaca0..54218edc50a 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/solve_subproblem.py b/pyomo/contrib/gdpopt/solve_subproblem.py index bd9b85c0cef..e3980c3c784 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/tests/__init__.py b/pyomo/contrib/gdpopt/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/gdpopt/tests/__init__.py +++ b/pyomo/contrib/gdpopt/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/gdpopt/tests/common_tests.py b/pyomo/contrib/gdpopt/tests/common_tests.py index 5a363430381..88a2642704a 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-2022 +# 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 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..21a05afc1da --- /dev/null +++ b/pyomo/contrib/gdpopt/tests/four_stage_dynamic_model.py @@ -0,0 +1,396 @@ +# ___________________________________________________________________________ +# +# 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 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 7d25767020e..8a553398fa6 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-2022 +# 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 @@ -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 606dd172064..8798557ddc9 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-2022 +# 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 diff --git a/pyomo/contrib/gdpopt/tests/test_gdpopt.py b/pyomo/contrib/gdpopt/tests/test_gdpopt.py index 1d5559a9b33..873bafabc76 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-2022 +# 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 @@ -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..e72aa334c51 --- /dev/null +++ b/pyomo/contrib/gdpopt/tests/test_ldsda.py @@ -0,0 +1,61 @@ +# ___________________________________________________________________________ +# +# 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 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 f288f9e2647..03e0a6de163 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-2022 +# 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 @@ -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 df9dfebf477..a94d38e24e1 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-2022 +# 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 @@ -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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/gjh/getGJH.py b/pyomo/contrib/gjh/getGJH.py index 112de054745..2d503c71438 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-2022 +# 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 diff --git a/pyomo/contrib/gjh/plugins.py b/pyomo/contrib/gjh/plugins.py index 4af2f38becd..f072f7b2c38 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-2022 +# 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 diff --git a/pyomo/contrib/iis/__init__.py b/pyomo/contrib/iis/__init__.py index eb9f60b8928..961ac576d42 100644 --- a/pyomo/contrib/iis/__init__.py +++ b/pyomo/contrib/iis/__init__.py @@ -1 +1,13 @@ +# ___________________________________________________________________________ +# +# 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.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 bd192d04eb3..1ffd6cb0bd3 100644 --- a/pyomo/contrib/iis/iis.py +++ b/pyomo/contrib/iis/iis.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ This module contains functions for computing an irreducible infeasible set for a Pyomo MILP or LP using a specified commercial solver, one of CPLEX, diff --git a/pyomo/contrib/iis/mis.py b/pyomo/contrib/iis/mis.py new file mode 100644 index 00000000000..0141e615516 --- /dev/null +++ b/pyomo/contrib/iis/mis.py @@ -0,0 +1,377 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ +""" +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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/iis/tests/__init__.py +++ b/pyomo/contrib/iis/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/iis/tests/test_iis.py b/pyomo/contrib/iis/tests/test_iis.py index b1b675d5081..cf7b5613a3a 100644 --- a/pyomo/contrib/iis/tests/test_iis.py +++ b/pyomo/contrib/iis/tests/test_iis.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest import pyomo.environ as pyo from pyomo.contrib.iis import write_iis diff --git a/pyomo/contrib/iis/tests/test_mis.py b/pyomo/contrib/iis/tests/test_mis.py new file mode 100644 index 00000000000..bbdb2367016 --- /dev/null +++ b/pyomo/contrib/iis/tests/test_mis.py @@ -0,0 +1,125 @@ +# ___________________________________________________________________________ +# +# 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 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..7797a3bb654 --- /dev/null +++ b/pyomo/contrib/iis/tests/trivial_mis.py @@ -0,0 +1,29 @@ +# ___________________________________________________________________________ +# +# 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 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 ee078690f2f..1e9fc35812e 100644 --- a/pyomo/contrib/incidence_analysis/__init__.py +++ b/pyomo/contrib/incidence_analysis/__init__.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 .triangularize import block_triangularize from .matching import maximum_matching from .interface import IncidenceGraphInterface, get_bipartite_incidence_graph @@ -7,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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/incidence_analysis/common/__init__.py +++ b/pyomo/contrib/incidence_analysis/common/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/incidence_analysis/common/dulmage_mendelsohn.py b/pyomo/contrib/incidence_analysis/common/dulmage_mendelsohn.py index 09a926cdec2..5bc724fafc1 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/common/tests/__init__.py b/pyomo/contrib/incidence_analysis/common/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/incidence_analysis/common/tests/__init__.py +++ b/pyomo/contrib/incidence_analysis/common/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 1675fc7420a..b17ae9b1dfc 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/config.py b/pyomo/contrib/incidence_analysis/config.py index a107792a9cd..a8616dce00a 100644 --- a/pyomo/contrib/incidence_analysis/config.py +++ b/pyomo/contrib/incidence_analysis/config.py @@ -1,18 +1,20 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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. # ___________________________________________________________________________ -"""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.ampl import AMPLRepnVisitor +from pyomo.repn.util import FileDeterminism, FileDeterminism_to_SortComponents class IncidenceMethod(enum.Enum): @@ -24,6 +26,21 @@ class IncidenceMethod(enum.Enum): standard_repn = 1 """Use ``pyomo.repn.standard_repn.generate_standard_repn``""" + standard_repn_compute_values = 2 + """Use ``pyomo.repn.standard_repn.generate_standard_repn`` with + ``compute_values=True`` + """ + + ampl_repn = 3 + """Use ``pyomo.repn.ampl.AMPLRepnVisitor``""" + + +class IncidenceOrder(enum.Enum): + + dulmage_mendelsohn_upper = 0 + + dulmage_mendelsohn_lower = 1 + _include_fixed = ConfigValue( default=False, @@ -54,6 +71,21 @@ class IncidenceMethod(enum.Enum): ) +def _amplrepnvisitor_validator(visitor): + if not isinstance(visitor, AMPLRepnVisitor): + raise TypeError( + "'visitor' config argument should be an instance of AMPLRepnVisitor" + ) + return visitor + + +_ampl_repn_visitor = ConfigValue( + default=None, + domain=_amplrepnvisitor_validator, + description="Visitor used to generate AMPLRepn of each constraint", +) + + IncidenceConfig = ConfigDict() """Options for incidence graph generation @@ -63,6 +95,9 @@ class IncidenceMethod(enum.Enum): should be included. - ``method`` -- Method used to identify incident variables. Must be a value of the ``IncidenceMethod`` enum. +- ``_ampl_repn_visitor`` -- Expression visitor used to generate ``AMPLRepn`` of each + constraint. Must be an instance of ``AMPLRepnVisitor``. *This option is constructed + automatically when needed and should not be set by users!* """ @@ -74,3 +109,43 @@ class IncidenceMethod(enum.Enum): IncidenceConfig.declare("method", _method) + + +IncidenceConfig.declare("_ampl_repn_visitor", _ampl_repn_visitor) + + +def get_config_from_kwds(**kwds): + """Get an instance of IncidenceConfig from provided keyword arguments. + + If the ``method`` argument is ``IncidenceMethod.ampl_repn`` and no + ``AMPLRepnVisitor`` has been provided, a new ``AMPLRepnVisitor`` is + constructed. This function should generally be used by callers such + as ``IncidenceGraphInterface`` to ensure that a visitor is created then + re-used when calling ``get_incident_variables`` in a loop. + + """ + if ( + kwds.get("method", None) is IncidenceMethod.ampl_repn + and kwds.get("_ampl_repn_visitor", None) is None + ): + subexpression_cache = {} + external_functions = {} + var_map = {} + used_named_expressions = set() + symbolic_solver_labels = False + # TODO: Explore potential performance benefit of exporting defined variables. + # This likely only shows up if we can preserve the subexpression cache across + # multiple constraint expressions. + export_defined_variables = False + sorter = FileDeterminism_to_SortComponents(FileDeterminism.ORDERED) + amplvisitor = AMPLRepnVisitor( + subexpression_cache, + external_functions, + var_map, + used_named_expressions, + symbolic_solver_labels, + export_defined_variables, + sorter, + ) + kwds["_ampl_repn_visitor"] = amplvisitor + return IncidenceConfig(kwds) diff --git a/pyomo/contrib/incidence_analysis/connected.py b/pyomo/contrib/incidence_analysis/connected.py index 2dcf31c0fe0..28d4bdee73f 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/dulmage_mendelsohn.py b/pyomo/contrib/incidence_analysis/dulmage_mendelsohn.py index eb24b0559fc..3a6d06a809c 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/incidence.py b/pyomo/contrib/incidence_analysis/incidence.py index 1852cf75648..4f8f8bd5c3b 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-2022 +# 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. # ___________________________________________________________________________ -"""Functionality for identifying variables that participate in expressions -""" +"""Functionality for identifying variables that participate in expressions""" from contextlib import nullcontext @@ -17,7 +16,10 @@ 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.contrib.incidence_analysis.config import IncidenceMethod, IncidenceConfig +from pyomo.contrib.incidence_analysis.config import ( + IncidenceMethod, + get_config_from_kwds, +) # @@ -29,7 +31,9 @@ def _get_incident_via_identify_variables(expr, include_fixed): return list(identify_variables(expr, include_fixed=include_fixed)) -def _get_incident_via_standard_repn(expr, include_fixed, linear_only): +def _get_incident_via_standard_repn( + expr, include_fixed, linear_only, compute_values=False +): if include_fixed: to_unfix = [ var for var in identify_variables(expr, include_fixed=True) if var.fixed @@ -39,7 +43,9 @@ def _get_incident_via_standard_repn(expr, include_fixed, linear_only): context = nullcontext() with context: - repn = generate_standard_repn(expr, compute_values=False, quadratic=False) + repn = generate_standard_repn( + expr, compute_values=compute_values, quadratic=False + ) linear_vars = [] # Check coefficients to make sure we don't include linear variables with @@ -74,6 +80,42 @@ def _get_incident_via_standard_repn(expr, include_fixed, linear_only): return unique_variables +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 + repn = visitor.walk_expression((expr, None, 0, 1.0)) + + nonlinear_var_id_set = set() + unique_nonlinear_var_ids = [] + 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 = [ + var_map[v_id] + for v_id, coef in repn.linear.items() + if coef != 0.0 and v_id not in nonlinear_var_id_set + ] + if linear_only: + return linear_only_vars + else: + variables = linear_only_vars + nonlinear_vars + return variables + + def get_incident_variables(expr, **kwds): """Get variables that participate in an expression @@ -112,21 +154,38 @@ def get_incident_variables(expr, **kwds): ['x[1]', 'x[2]'] """ - config = IncidenceConfig(kwds) + config = get_config_from_kwds(**kwds) method = config.method include_fixed = config.include_fixed linear_only = config.linear_only + amplrepnvisitor = config._ampl_repn_visitor + + # Check compatibility of arguments if linear_only and method is IncidenceMethod.identify_variables: raise RuntimeError( "linear_only=True is not supported when using identify_variables" ) + if include_fixed and method is IncidenceMethod.ampl_repn: + raise RuntimeError("include_fixed=True is not supported when using ampl_repn") + if method is IncidenceMethod.ampl_repn and amplrepnvisitor is None: + # Developer error, this should never happen! + raise RuntimeError("_ampl_repn_visitor must be provided when using ampl_repn") + + # Dispatch to correct method if method is IncidenceMethod.identify_variables: return _get_incident_via_identify_variables(expr, include_fixed) elif method is IncidenceMethod.standard_repn: - return _get_incident_via_standard_repn(expr, include_fixed, linear_only) + return _get_incident_via_standard_repn( + expr, include_fixed, linear_only, compute_values=False + ) + elif method is IncidenceMethod.standard_repn_compute_values: + return _get_incident_via_standard_repn( + expr, include_fixed, linear_only, compute_values=True + ) + elif method is IncidenceMethod.ampl_repn: + return _get_incident_via_ampl_repn(expr, linear_only, amplrepnvisitor) else: raise ValueError( f"Unrecognized value {method} for the method used to identify incident" - f" variables. Valid options are {IncidenceMethod.identify_variables}" - f" and {IncidenceMethod.standard_repn}." + f" variables. See the IncidenceMethod enum for valid methods." ) diff --git a/pyomo/contrib/incidence_analysis/interface.py b/pyomo/contrib/incidence_analysis/interface.py index e922551c6a4..73d9722eb7e 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-2022 +# 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 @@ -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,8 +28,8 @@ scipy as sp, plotly, ) -from pyomo.common.deprecation import deprecated -from pyomo.contrib.incidence_analysis.config import IncidenceConfig +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 from pyomo.contrib.incidence_analysis.triangularize import ( @@ -47,7 +47,7 @@ from pyomo.contrib.pynumero.asl import AmplInterface pyomo_nlp, pyomo_nlp_available = attempt_import( - 'pyomo.contrib.pynumero.interfaces.pyomo_nlp' + "pyomo.contrib.pynumero.interfaces.pyomo_nlp" ) asl_available = pyomo_nlp_available & AmplInterface.available() @@ -62,7 +62,7 @@ def _check_unindexed(complist): def get_incidence_graph(variables, constraints, **kwds): - config = IncidenceConfig(kwds) + config = get_config_from_kwds(**kwds) return get_bipartite_incidence_graph(variables, constraints, **config) @@ -91,7 +91,9 @@ def get_bipartite_incidence_graph(variables, constraints, **kwds): ``networkx.Graph`` """ - config = IncidenceConfig(kwds) + # Note that this ConfigDict contains the visitor that we will re-use + # when constructing constraints. + config = get_config_from_kwds(**kwds) _check_unindexed(variables + constraints) N = len(variables) M = len(constraints) @@ -134,36 +136,34 @@ def extract_bipartite_subgraph(graph, nodes0, nodes1): in the original graph. """ - subgraph = nx.Graph() - sub_M = len(nodes0) - sub_N = len(nodes1) - subgraph.add_nodes_from(range(sub_M), bipartite=0) - subgraph.add_nodes_from(range(sub_M, sub_M + sub_N), bipartite=1) - + subgraph = graph.subgraph(nodes0 + nodes1) + # TODO: Any error checking that nodes are valid bipartition? + for node in nodes0: + bipartite = graph.nodes[node]["bipartite"] + if bipartite != 0: + raise RuntimeError( + "Invalid bipartite sets. Node {node} in set 0 has" + " bipartite={bipartite}" + ) + for node in nodes1: + bipartite = graph.nodes[node]["bipartite"] + if bipartite != 1: + raise RuntimeError( + "Invalid bipartite sets. Node {node} in set 1 has" + " bipartite={bipartite}" + ) old_new_map = {} for i, node in enumerate(nodes0 + nodes1): if node in old_new_map: raise RuntimeError("Node %s provided more than once.") old_new_map[node] = i - - for node1, node2 in graph.edges(): - if node1 in old_new_map and node2 in old_new_map: - new_node_1 = old_new_map[node1] - new_node_2 = old_new_map[node2] - if ( - subgraph.nodes[new_node_1]["bipartite"] - == subgraph.nodes[new_node_2]["bipartite"] - ): - raise RuntimeError( - "Subgraph is not bipartite. Found an edge between nodes" - " %s and %s (in the original graph)." % (node1, node2) - ) - subgraph.add_edge(new_node_1, new_node_2) - return subgraph + relabeled_subgraph = nx.relabel_nodes(subgraph, old_new_map) + return relabeled_subgraph def _generate_variables_in_constraints(constraints, **kwds): - config = IncidenceConfig(kwds) + # Note: We construct a visitor here + config = get_config_from_kwds(**kwds) known_vars = ComponentSet() for con in constraints: for var in get_incident_variables(con.body, **config): @@ -191,7 +191,7 @@ def get_structural_incidence_matrix(variables, constraints, **kwds): Entries are 1.0. """ - config = IncidenceConfig(kwds) + config = get_config_from_kwds(**kwds) _check_unindexed(variables + constraints) N, M = len(variables), len(constraints) var_idx_map = ComponentMap((v, i) for i, v in enumerate(variables)) @@ -266,7 +266,6 @@ class IncidenceGraphInterface(object): ``evaluate_jacobian_eq`` method instead of ``evaluate_jacobian`` rather than checking constraint expression types. - """ def __init__(self, model=None, active=True, include_inequality=True, **kwds): @@ -275,12 +274,12 @@ def __init__(self, model=None, active=True, include_inequality=True, **kwds): # to cache the incidence graph for fast analysis later on. # WARNING: This cache will become invalid if the user alters their # model. - self._config = IncidenceConfig(kwds) + self._config = get_config_from_kwds(**kwds) if model is None: 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) @@ -330,10 +329,26 @@ def __init__(self, model=None, active=True, include_inequality=True, **kwds): incidence_matrix = nlp.evaluate_jacobian_eq() nxb = nx.algorithms.bipartite self._incidence_graph = nxb.from_biadjacency_matrix(incidence_matrix) + elif isinstance(model, tuple): + # model is a tuple of (nx.Graph, list[pyo.Var], list[pyo.Constraint]) + # We could potentially accept a tuple (variables, constraints). + # TODO: Disallow kwargs if this type of "model" is provided? + nx_graph, variables, constraints = model + self._variables = list(variables) + self._constraints = list(constraints) + self._var_index_map = ComponentMap( + (var, i) for i, var in enumerate(self._variables) + ) + self._con_index_map = ComponentMap( + (con, i) for i, con in enumerate(self._constraints) + ) + # For now, don't check any properties of this graph. We could check + # for a bipartition that matches the variable and constraint lists. + self._incidence_graph = nx_graph 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 @@ -438,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 @@ -464,6 +497,25 @@ def _extract_subgraph(self, variables, constraints): ) return subgraph + def subgraph(self, variables, constraints): + """Extract a subgraph defined by the provided variables and constraints + + Underlying data structures are copied, and constraints are not reinspected + for incidence variables (the edges from this incidence graph are used). + + Returns + ------- + ``IncidenceGraphInterface`` + A new incidence graph containing only the specified variables and + constraints, and the edges between pairs thereof. + + """ + nx_subgraph = self._extract_subgraph(variables, constraints) + subgraph = IncidenceGraphInterface( + (nx_subgraph, variables, constraints), **self._config + ) + return subgraph + @property def incidence_matrix(self): """The structural incidence matrix of variables and constraints. @@ -820,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. @@ -832,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): @@ -886,9 +979,9 @@ def plot(self, variables=None, constraints=None, title=None, show=True): edge_trace = plotly.graph_objects.Scatter( x=edge_x, y=edge_y, - line=dict(width=0.5, color='#888'), - hoverinfo='none', - mode='lines', + line=dict(width=0.5, color="#888"), + hoverinfo="none", + mode="lines", ) node_x = [] @@ -902,28 +995,28 @@ def plot(self, variables=None, constraints=None, title=None, show=True): if node < M: # According to convention, we are a constraint node c = constraints[node] - node_color.append('red') - body_text = '
'.join( + node_color.append("red") + body_text = "
".join( textwrap.wrap(str(c.body), width=120, subsequent_indent=" ") ) node_text.append( - f'{str(c)}
lb: {str(c.lower)}
body: {body_text}
' - f'ub: {str(c.upper)}
active: {str(c.active)}' + f"{str(c)}
lb: {str(c.lower)}
body: {body_text}
" + f"ub: {str(c.upper)}
active: {str(c.active)}" ) else: # According to convention, we are a variable node v = variables[node - M] - node_color.append('blue') + node_color.append("blue") node_text.append( - f'{str(v)}
lb: {str(v.lb)}
ub: {str(v.ub)}
' - f'value: {str(v.value)}
domain: {str(v.domain)}
' - f'fixed: {str(v.is_fixed())}' + f"{str(v)}
lb: {str(v.lb)}
ub: {str(v.ub)}
" + f"value: {str(v.value)}
domain: {str(v.domain)}
" + f"fixed: {str(v.is_fixed())}" ) node_trace = plotly.graph_objects.Scatter( x=node_x, y=node_y, - mode='markers', - hoverinfo='text', + mode="markers", + hoverinfo="text", text=node_text, marker=dict(color=node_color, size=10), ) @@ -932,3 +1025,32 @@ def plot(self, variables=None, constraints=None, title=None, show=True): fig.update_layout(title=dict(text=title)) if show: fig.show() + + def add_edge(self, variable, constraint): + """Adds an edge between variable and constraint in the incidence graph + + Parameters + ---------- + variable: VarData + A variable in the graph + constraint: ConstraintData + A constraint in the graph + """ + if self._incidence_graph is None: + raise RuntimeError( + "Attempting to add edge in an incidence graph from cached " + "incidence graph,\nbut no incidence graph has been cached." + ) + + if variable not in self._var_index_map: + raise RuntimeError("%s is not a variable in the incidence graph" % variable) + + if constraint not in self._con_index_map: + raise RuntimeError( + "%s is not a constraint in the incidence graph" % constraint + ) + + var_id = self._var_index_map[variable] + len(self._con_index_map) + con_id = self._con_index_map[constraint] + + self._incidence_graph.add_edge(var_id, con_id) diff --git a/pyomo/contrib/incidence_analysis/matching.py b/pyomo/contrib/incidence_analysis/matching.py index 14b3cd5b18d..e37b35cd973 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/scc_solver.py b/pyomo/contrib/incidence_analysis/scc_solver.py index d7620278fd3..db201dccb0a 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-2022 +# 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 @@ -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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/incidence_analysis/tests/__init__.py +++ b/pyomo/contrib/incidence_analysis/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/incidence_analysis/tests/models_for_testing.py b/pyomo/contrib/incidence_analysis/tests/models_for_testing.py index 98d61201619..6040e80e068 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/tests/test_connected.py b/pyomo/contrib/incidence_analysis/tests/test_connected.py index a937a5029a1..421231d3dd0 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/tests/test_dulmage_mendelsohn.py b/pyomo/contrib/incidence_analysis/tests/test_dulmage_mendelsohn.py index 98fefea2d80..6195d6afca7 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/tests/test_incidence.py b/pyomo/contrib/incidence_analysis/tests/test_incidence.py index 7f57dd904a7..832fbbfb10c 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-2022 +# 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 @@ -56,44 +56,56 @@ def test_basic_incidence(self): def test_incidence_with_fixed_variable(self): m = pyo.ConcreteModel() - m.x = pyo.Var([1, 2, 3]) + m.x = pyo.Var([1, 2, 3], initialize=1.0) expr = m.x[1] + m.x[1] * m.x[2] + m.x[1] * pyo.exp(m.x[3]) m.x[2].fix() variables = self._get_incident_variables(expr) var_set = ComponentSet(variables) self.assertEqual(var_set, ComponentSet([m.x[1], m.x[3]])) - def test_incidence_with_mutable_parameter(self): + def test_incidence_with_named_expression(self): m = pyo.ConcreteModel() m.x = pyo.Var([1, 2, 3]) - m.p = pyo.Param(mutable=True, initialize=None) - expr = m.x[1] + m.p * m.x[1] * m.x[2] + m.x[1] * pyo.exp(m.x[3]) + m.subexpr = pyo.Expression(pyo.Integers) + m.subexpr[1] = m.x[1] * pyo.exp(m.x[3]) + expr = m.x[1] + m.x[1] * m.x[2] + m.subexpr[1] variables = self._get_incident_variables(expr) self.assertEqual(ComponentSet(variables), ComponentSet(m.x[:])) -class TestIncidenceStandardRepn(unittest.TestCase, _TestIncidence): - def _get_incident_variables(self, expr, **kwds): - method = IncidenceMethod.standard_repn - return get_incident_variables(expr, method=method, **kwds) +class _TestIncidenceLinearOnly(object): + """Tests for methods that support linear_only""" - def test_assumed_standard_repn_behavior(self): + def _get_incident_variables(self, expr): + raise NotImplementedError( + "_TestIncidenceLinearOnly should not be used directly" + ) + + def test_linear_only(self): m = pyo.ConcreteModel() - m.x = pyo.Var([1, 2]) - m.p = pyo.Param(initialize=0.0) + m.x = pyo.Var([1, 2, 3]) - # We rely on variables with constant coefficients of zero not appearing - # in the standard repn (as opposed to appearing with explicit - # coefficients of zero). - expr = m.x[1] + 0 * m.x[2] - repn = generate_standard_repn(expr) - self.assertEqual(len(repn.linear_vars), 1) - self.assertIs(repn.linear_vars[0], m.x[1]) + expr = 2 * m.x[1] + 4 * m.x[2] * m.x[1] - m.x[1] * pyo.exp(m.x[3]) + variables = self._get_incident_variables(expr, linear_only=True) + self.assertEqual(len(variables), 0) - expr = m.p * m.x[1] + m.x[2] - repn = generate_standard_repn(expr) - self.assertEqual(len(repn.linear_vars), 1) - self.assertIs(repn.linear_vars[0], m.x[2]) + expr = 2 * m.x[1] + 2 * m.x[2] * m.x[3] + 3 * m.x[2] + variables = self._get_incident_variables(expr, linear_only=True) + self.assertEqual(ComponentSet(variables), ComponentSet([m.x[1]])) + + m.x[3].fix(2.5) + expr = 2 * m.x[1] + 2 * m.x[2] * m.x[3] + 3 * m.x[2] + variables = self._get_incident_variables(expr, linear_only=True) + self.assertEqual(ComponentSet(variables), ComponentSet([m.x[1], m.x[2]])) + + +class _TestIncidenceLinearCancellation(object): + """Tests for methods that perform linear cancellation""" + + def _get_incident_variables(self, expr): + raise NotImplementedError( + "_TestIncidenceLinearCancellation should not be used directly" + ) def test_zero_coef(self): m = pyo.ConcreteModel() @@ -113,23 +125,6 @@ def test_variable_minus_itself(self): var_set = ComponentSet(variables) self.assertEqual(var_set, ComponentSet([m.x[2], m.x[3]])) - def test_linear_only(self): - m = pyo.ConcreteModel() - m.x = pyo.Var([1, 2, 3]) - - expr = 2 * m.x[1] + 4 * m.x[2] * m.x[1] - m.x[1] * pyo.exp(m.x[3]) - variables = self._get_incident_variables(expr, linear_only=True) - self.assertEqual(len(variables), 0) - - expr = 2 * m.x[1] + 2 * m.x[2] * m.x[3] + 3 * m.x[2] - variables = self._get_incident_variables(expr, linear_only=True) - self.assertEqual(ComponentSet(variables), ComponentSet([m.x[1]])) - - m.x[3].fix(2.5) - expr = 2 * m.x[1] + 2 * m.x[2] * m.x[3] + 3 * m.x[2] - variables = self._get_incident_variables(expr, linear_only=True) - self.assertEqual(ComponentSet(variables), ComponentSet([m.x[1], m.x[2]])) - def test_fixed_zero_linear_coefficient(self): m = pyo.ConcreteModel() m.x = pyo.Var([1, 2, 3]) @@ -148,6 +143,9 @@ def test_fixed_zero_linear_coefficient(self): variables = self._get_incident_variables(expr) self.assertEqual(ComponentSet(variables), ComponentSet([m.x[1], m.x[2]])) + # NOTE: This test assumes that all methods that support linear cancellation + # accept a linear_only argument. If this changes, this test will need to be + # moved. def test_fixed_zero_coefficient_linear_only(self): m = pyo.ConcreteModel() m.x = pyo.Var([1, 2, 3]) @@ -159,6 +157,35 @@ def test_fixed_zero_coefficient_linear_only(self): self.assertEqual(len(variables), 1) self.assertIs(variables[0], m.x[3]) + +class TestIncidenceStandardRepn( + unittest.TestCase, + _TestIncidence, + _TestIncidenceLinearOnly, + _TestIncidenceLinearCancellation, +): + def _get_incident_variables(self, expr, **kwds): + method = IncidenceMethod.standard_repn + return get_incident_variables(expr, method=method, **kwds) + + def test_assumed_standard_repn_behavior(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2]) + m.p = pyo.Param(initialize=0.0) + + # We rely on variables with constant coefficients of zero not appearing + # in the standard repn (as opposed to appearing with explicit + # coefficients of zero). + expr = m.x[1] + 0 * m.x[2] + repn = generate_standard_repn(expr) + self.assertEqual(len(repn.linear_vars), 1) + self.assertIs(repn.linear_vars[0], m.x[1]) + + expr = m.p * m.x[1] + m.x[2] + repn = generate_standard_repn(expr) + self.assertEqual(len(repn.linear_vars), 1) + self.assertIs(repn.linear_vars[0], m.x[2]) + def test_fixed_none_linear_coefficient(self): m = pyo.ConcreteModel() m.x = pyo.Var([1, 2, 3]) @@ -168,6 +195,14 @@ def test_fixed_none_linear_coefficient(self): variables = self._get_incident_variables(expr) self.assertEqual(ComponentSet(variables), ComponentSet([m.x[1], m.x[2]])) + def test_incidence_with_mutable_parameter(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3]) + m.p = pyo.Param(mutable=True, initialize=None) + expr = m.x[1] + m.p * m.x[1] * m.x[2] + m.x[1] * pyo.exp(m.x[3]) + variables = self._get_incident_variables(expr) + self.assertEqual(ComponentSet(variables), ComponentSet(m.x[:])) + class TestIncidenceIdentifyVariables(unittest.TestCase, _TestIncidence): def _get_incident_variables(self, expr, **kwds): @@ -192,6 +227,36 @@ def test_variable_minus_itself(self): var_set = ComponentSet(variables) self.assertEqual(var_set, ComponentSet(m.x[:])) + def test_incidence_with_mutable_parameter(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3]) + m.p = pyo.Param(mutable=True, initialize=None) + expr = m.x[1] + m.p * m.x[1] * m.x[2] + m.x[1] * pyo.exp(m.x[3]) + variables = self._get_incident_variables(expr) + self.assertEqual(ComponentSet(variables), ComponentSet(m.x[:])) + + +class TestIncidenceAmplRepn( + unittest.TestCase, + _TestIncidence, + _TestIncidenceLinearOnly, + _TestIncidenceLinearCancellation, +): + def _get_incident_variables(self, expr, **kwds): + method = IncidenceMethod.ampl_repn + return get_incident_variables(expr, method=method, **kwds) + + +class TestIncidenceStandardRepnComputeValues( + unittest.TestCase, + _TestIncidence, + _TestIncidenceLinearOnly, + _TestIncidenceLinearCancellation, +): + def _get_incident_variables(self, expr, **kwds): + method = IncidenceMethod.standard_repn_compute_values + return get_incident_variables(expr, method=method, **kwds) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/contrib/incidence_analysis/tests/test_interface.py b/pyomo/contrib/incidence_analysis/tests/test_interface.py index 490ea94f63c..9957e78168b 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-2022 +# 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 @@ -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.") @@ -1653,11 +1685,11 @@ def test_extract_exceptions(self): sg_cons = [0, 2, 5] sg_vars = [i + len(constraints) for i in [2, 3]] - msg = "Subgraph is not bipartite" + msg = "Invalid bipartite sets." with self.assertRaisesRegex(RuntimeError, msg): subgraph = extract_bipartite_subgraph(graph, sg_cons, sg_vars) - sg_cons = [0, 2, 5] + sg_cons = [0, 2, 0] sg_vars = [i + len(constraints) for i in [2, 0, 3]] msg = "provided more than once" with self.assertRaisesRegex(RuntimeError, msg): @@ -1745,7 +1777,7 @@ def test_plot(self): m.c2 = pyo.Constraint(expr=m.z >= m.x) m.y.fix() igraph = IncidenceGraphInterface(m, include_inequality=True, include_fixed=True) - igraph.plot(title='test plot', show=False) + igraph.plot(title="test plot", show=False) def test_zero_coeff(self): m = pyo.ConcreteModel() @@ -1791,6 +1823,91 @@ def test_linear_only(self): self.assertIs(matching[m.eq2], m.x[2]) self.assertIs(matching[m.eq3], m.x[3]) + def test_add_edge(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3, 4]) + m.eq1 = pyo.Constraint(expr=m.x[1] ** 2 + m.x[2] ** 2 + m.x[3] ** 2 == 1) + m.eq2 = pyo.Constraint(expr=m.x[2] + pyo.sqrt(m.x[1]) + pyo.exp(m.x[3]) == 1) + m.eq3 = pyo.Constraint(expr=m.x[3] + m.x[2] + m.x[4] == 1) + m.eq4 = pyo.Constraint(expr=m.x[1] + m.x[2] ** 2 == 5) + + igraph = IncidenceGraphInterface(m, linear_only=False) + n_edges_original = igraph.n_edges + + # Test edge is added between previously unconnected nodes + igraph.add_edge(m.x[1], m.eq3) + n_edges_new = igraph.n_edges + assert ComponentSet(igraph.get_adjacent_to(m.eq3)) == ComponentSet(m.x[:]) + self.assertEqual(n_edges_original + 1, n_edges_new) + + # Test no edge is added if there exists a previous edge between nodes + igraph.add_edge(m.x[2], m.eq3) + n_edges2 = igraph.n_edges + self.assertEqual(n_edges_new, n_edges2) + + def test_add_edge_linear_igraph(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3, 4]) + m.eq1 = pyo.Constraint(expr=m.x[1] + m.x[3] == 1) + m.eq2 = pyo.Constraint(expr=m.x[2] + pyo.sqrt(m.x[1]) + pyo.exp(m.x[3]) == 1) + m.eq3 = pyo.Constraint(expr=m.x[4] ** 2 + m.x[1] ** 3 + m.x[2] ** 2 == 1) + + # Make sure error is raised when a variable is not in the igraph + igraph = IncidenceGraphInterface(m, linear_only=True) + + msg = "is not a variable in the incidence graph" + with self.assertRaisesRegex(RuntimeError, msg): + igraph.add_edge(m.x[4], m.eq2) + + def test_var_elim(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3, 4]) + m.eq1 = pyo.Constraint(expr=m.x[1] ** 2 + m.x[2] ** 2 + m.x[3] ** 2 == 1) + m.eq2 = pyo.Constraint(expr=pyo.sqrt(m.x[1]) + pyo.exp(m.x[3]) == 1) + m.eq3 = pyo.Constraint(expr=m.x[3] + m.x[2] + m.x[4] == 1) + m.eq4 = pyo.Constraint(expr=m.x[1] == 5 * m.x[2]) + + igraph = IncidenceGraphInterface(m) + # Eliminate x[1] using eq4 + 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]) + + 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]) + self.assertEqual(7, igraph.n_edges) + + assert m.x[2] in ComponentSet(igraph.get_adjacent_to(m.eq1)) + assert m.x[2] in ComponentSet(igraph.get_adjacent_to(m.eq2)) + + def test_subgraph(self): + m = pyo.ConcreteModel() + m.I = pyo.Set(initialize=[1, 2, 3, 4]) + m.v = pyo.Var(m.I, bounds=(0, None)) + m.eq1 = pyo.Constraint(expr=m.v[1] ** 2 + m.v[2] ** 2 == 1.0) + m.eq2 = pyo.Constraint(expr=m.v[1] + 2.0 == m.v[3]) + m.ineq1 = pyo.Constraint(expr=m.v[2] - m.v[3] ** 0.5 + m.v[4] ** 2 <= 1.0) + m.ineq2 = pyo.Constraint(expr=m.v[2] * m.v[4] >= 1.0) + m.ineq3 = pyo.Constraint(expr=m.v[1] >= m.v[4] ** 4) + m.obj = pyo.Objective(expr=-m.v[1] - m.v[2] + m.v[3] ** 2 + m.v[4] ** 2) + igraph = IncidenceGraphInterface(m) + eq_igraph = igraph.subgraph(igraph.variables, [m.eq1, m.eq2]) + for i in range(len(igraph.variables)): + self.assertIs(igraph.variables[i], eq_igraph.variables[i]) + self.assertEqual( + ComponentSet(eq_igraph.constraints), ComponentSet([m.eq1, m.eq2]) + ) + + subgraph = eq_igraph.subgraph([m.v[1], m.v[3]], [m.eq1, m.eq2]) + self.assertEqual( + ComponentSet(subgraph.get_adjacent_to(m.eq2)), + ComponentSet([m.v[1], m.v[3]]), + ) + self.assertEqual( + ComponentSet(subgraph.get_adjacent_to(m.eq1)), ComponentSet([m.v[1]]) + ) + @unittest.skipUnless(networkx_available, "networkx is not available.") class TestIndexedBlock(unittest.TestCase): @@ -1803,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 b5550b3b84c..2327439f0a2 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py b/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py index 6efe52a7d80..ef4853d7e9a 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-2022 +# 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 @@ -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 76ba4403310..22548a15998 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-2022 +# 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 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..7c5538b671f --- /dev/null +++ b/pyomo/contrib/incidence_analysis/tests/test_visualize.py @@ -0,0 +1,47 @@ +# ___________________________________________________________________________ +# +# 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 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 ac6680a367e..6af251b1ec6 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-2022 +# 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 diff --git a/pyomo/contrib/incidence_analysis/util.py b/pyomo/contrib/incidence_analysis/util.py deleted file mode 100644 index a127161d33d..00000000000 --- a/pyomo/contrib/incidence_analysis/util.py +++ /dev/null @@ -1,22 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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..a6c88f80a2c --- /dev/null +++ b/pyomo/contrib/incidence_analysis/visualize.py @@ -0,0 +1,217 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ +"""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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/interior_point/__init__.py +++ b/pyomo/contrib/interior_point/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/interior_point/examples/__init__.py b/pyomo/contrib/interior_point/examples/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/interior_point/examples/__init__.py +++ b/pyomo/contrib/interior_point/examples/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/interior_point/examples/ex1.py b/pyomo/contrib/interior_point/examples/ex1.py index d9931e1daa8..53700c22922 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-2022 +# 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 @@ -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 7d04f578238..93b83f385ba 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-2022 +# 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 diff --git a/pyomo/contrib/interior_point/interior_point.py b/pyomo/contrib/interior_point/interior_point.py index 00d26ddef03..502de338fdc 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-2022 +# 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 diff --git a/pyomo/contrib/interior_point/inverse_reduced_hessian.py b/pyomo/contrib/interior_point/inverse_reduced_hessian.py index 6144a4afeb8..ac3c6a98463 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-2022 +# 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 diff --git a/pyomo/contrib/interior_point/linalg/__init__.py b/pyomo/contrib/interior_point/linalg/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/interior_point/linalg/__init__.py +++ b/pyomo/contrib/interior_point/linalg/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 722a5c55e8d..c3304fd1395 100644 --- a/pyomo/contrib/interior_point/linalg/base_linear_solver_interface.py +++ b/pyomo/contrib/interior_point/linalg/base_linear_solver_interface.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.pynumero.linalg.base import DirectLinearSolverInterface from abc import ABCMeta, abstractmethod import logging diff --git a/pyomo/contrib/interior_point/linalg/ma27_interface.py b/pyomo/contrib/interior_point/linalg/ma27_interface.py index 7bb98b0b6fd..7604bd432bb 100644 --- a/pyomo/contrib/interior_point/linalg/ma27_interface.py +++ b/pyomo/contrib/interior_point/linalg/ma27_interface.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 .base_linear_solver_interface import IPLinearSolverInterface from pyomo.contrib.pynumero.linalg.base import LinearSolverStatus, LinearSolverResults from pyomo.contrib.pynumero.linalg.ma27_interface import MA27 diff --git a/pyomo/contrib/interior_point/linalg/mumps_interface.py b/pyomo/contrib/interior_point/linalg/mumps_interface.py index 98f0ef03210..c7480e2b6d0 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-2022 +# 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 diff --git a/pyomo/contrib/interior_point/linalg/scipy_interface.py b/pyomo/contrib/interior_point/linalg/scipy_interface.py index b7b7923bad4..d0f773fcb81 100644 --- a/pyomo/contrib/interior_point/linalg/scipy_interface.py +++ b/pyomo/contrib/interior_point/linalg/scipy_interface.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 .base_linear_solver_interface import IPLinearSolverInterface from pyomo.contrib.pynumero.linalg.base import LinearSolverResults from scipy.linalg import eigvals diff --git a/pyomo/contrib/interior_point/linalg/tests/__init__.py b/pyomo/contrib/interior_point/linalg/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/interior_point/linalg/tests/__init__.py +++ b/pyomo/contrib/interior_point/linalg/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 35863aa7cf7..93071a5f215 100644 --- a/pyomo/contrib/interior_point/linalg/tests/test_linear_solvers.py +++ b/pyomo/contrib/interior_point/linalg/tests/test_linear_solvers.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest from pyomo.common.dependencies import attempt_import diff --git a/pyomo/contrib/interior_point/linalg/tests/test_realloc.py b/pyomo/contrib/interior_point/linalg/tests/test_realloc.py index bfe089dc602..3a53d0e7db9 100644 --- a/pyomo/contrib/interior_point/linalg/tests/test_realloc.py +++ b/pyomo/contrib/interior_point/linalg/tests/test_realloc.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest from pyomo.common.dependencies import attempt_import diff --git a/pyomo/contrib/interior_point/tests/__init__.py b/pyomo/contrib/interior_point/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/interior_point/tests/__init__.py +++ b/pyomo/contrib/interior_point/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/interior_point/tests/test_interior_point.py b/pyomo/contrib/interior_point/tests/test_interior_point.py index bff80934d20..a05408abe1e 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-2022 +# 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 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 67657dfce47..61f5e90e3cf 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-2022 +# 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 diff --git a/pyomo/contrib/interior_point/tests/test_realloc.py b/pyomo/contrib/interior_point/tests/test_realloc.py index b3758c946d4..b7a5d00e488 100644 --- a/pyomo/contrib/interior_point/tests/test_realloc.py +++ b/pyomo/contrib/interior_point/tests/test_realloc.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest import pyomo.environ as pe from pyomo.core.base import ConcreteModel, Var, Constraint, Objective diff --git a/pyomo/contrib/interior_point/tests/test_reg.py b/pyomo/contrib/interior_point/tests/test_reg.py index b37d9532428..a7fc686545b 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-2022 +# 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 diff --git a/pyomo/contrib/latex_printer/__init__.py b/pyomo/contrib/latex_printer/__init__.py index 27c1552017a..a4ad3b95f54 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-2023 +# 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 @@ -9,11 +9,8 @@ # 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 b84f9a420fc..cf286472a66 100644 --- a/pyomo/contrib/latex_printer/latex_printer.py +++ b/pyomo/contrib/latex_printer/latex_printer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2023 +# 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 @@ -34,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, @@ -47,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 @@ -64,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 @@ -79,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; @@ -275,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], ) @@ -304,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], ) @@ -363,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, @@ -381,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, @@ -405,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] @@ -573,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 @@ -616,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 @@ -660,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( @@ -691,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) @@ -703,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) @@ -771,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' @@ -788,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' @@ -904,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 @@ -1048,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)): @@ -1070,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) @@ -1088,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) @@ -1116,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 = {} @@ -1146,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) @@ -1225,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 8b137891791..a4a626013c4 100644 --- a/pyomo/contrib/latex_printer/tests/__init__.py +++ b/pyomo/contrib/latex_printer/tests/__init__.py @@ -1 +1,10 @@ - +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/latex_printer/tests/test_latex_printer.py b/pyomo/contrib/latex_printer/tests/test_latex_printer.py index e9de4e4ad05..b0ada97a5fe 100644 --- a/pyomo/contrib/latex_printer/tests/test_latex_printer.py +++ b/pyomo/contrib/latex_printer/tests/test_latex_printer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2023 +# 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 @@ -10,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, @@ -786,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 14e9ebbe0e6..dc3a415618b 100644 --- a/pyomo/contrib/latex_printer/tests/test_latex_printer_vartypes.py +++ b/pyomo/contrib/latex_printer/tests/test_latex_printer_vartypes.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 diff --git a/pyomo/contrib/mcpp/__init__.py b/pyomo/contrib/mcpp/__init__.py index d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/mcpp/build.py b/pyomo/contrib/mcpp/build.py index 55c893335d2..7e119caec9f 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-2022 +# 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 diff --git a/pyomo/contrib/mcpp/getMCPP.py b/pyomo/contrib/mcpp/getMCPP.py index caf9566df64..dbce611d1a0 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-2022 +# 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 diff --git a/pyomo/contrib/mcpp/mcppInterface.cpp b/pyomo/contrib/mcpp/mcppInterface.cpp index 30491fde1b1..a1e74567896 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-2022 + * 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 diff --git a/pyomo/contrib/mcpp/plugins.py b/pyomo/contrib/mcpp/plugins.py index eed8874b1e7..577feec7fe3 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-2022 +# 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 diff --git a/pyomo/contrib/mcpp/pyomo_mcpp.py b/pyomo/contrib/mcpp/pyomo_mcpp.py index 25a4237ff16..0ef0237681b 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-2022 +# 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 @@ -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..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/mcpp/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 23b963e11bf..1cfb46ce328 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-2022 +# 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 diff --git a/pyomo/contrib/mindtpy/MindtPy.py b/pyomo/contrib/mindtpy/MindtPy.py index 6eb27c4c649..7b41e0078a3 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-2022 +# 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 @@ -50,6 +50,14 @@ - Add single-tree implementation. - Add support for cplex_persistent solver. - Fix bug in OA cut expression in cut_generation.py. + +24.1.11 changes: +- fix gurobi single tree termination check bug +- fix Gurobi single tree cycle handling +- fix bug in feasibility pump method +- add special handling for infeasible relaxed NLP +- update the log format of infeasible fixed NLP subproblems +- create a new copy_var_list_values function """ from pyomo.contrib.mindtpy import __version__ diff --git a/pyomo/contrib/mindtpy/__init__.py b/pyomo/contrib/mindtpy/__init__.py index 8e2c2d9eaa4..652493b03a6 100644 --- a/pyomo/contrib/mindtpy/__init__.py +++ b/pyomo/contrib/mindtpy/__init__.py @@ -1 +1,12 @@ -__version__ = (0, 1, 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. +# ___________________________________________________________________________ + +__version__ = (1, 0, 0) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 570e7c0a27d..c91d78d91b7 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-2022 +# 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 @@ -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, @@ -55,7 +49,6 @@ SuppressInfeasibleWarning, _DoNothing, lower_logger_level_to, - copy_var_list_values, get_main_elapsed_time, time_code, ) @@ -80,6 +73,7 @@ set_solver_mipgap, set_solver_constraint_violation_tolerance, update_solver_timelimit, + copy_var_list_values, ) single_tree, single_tree_available = attempt_import('pyomo.contrib.mindtpy.single_tree') @@ -102,12 +96,14 @@ def __init__(self, **kwds): self.fixed_nlp = None # We store bounds, timing info, iteration count, incumbent, and the - # expression of the original (possibly nonlinear) objective function. + # Expression of the original (possibly nonlinear) objective function. self.results = SolverResults() self.timing = Bunch() self.curr_int_sol = [] self.should_terminate = False self.integer_list = [] + # Dictionary {integer solution (tuple): [cuts begin index, cuts end index] (list)} + self.integer_solution_to_cuts_index = dict() # Set up iteration counters self.nlp_iter = 0 @@ -123,9 +119,15 @@ def __init__(self, **kwds): self.log_formatter = ( ' {:>9} {:>15} {:>15g} {:>12g} {:>12g} {:>7.2%} {:>7.2f}' ) + self.termination_condition_log_formatter = ( + ' {:>9} {:>15} {:>15} {:>12g} {:>12g} {:>7.2%} {:>7.2f}' + ) self.fixed_nlp_log_formatter = ( '{:1}{:>9} {:>15} {:>15g} {:>12g} {:>12g} {:>7.2%} {:>7.2f}' ) + self.infeasible_fixed_nlp_log_formatter = ( + '{:1}{:>9} {:>15} {:>15} {:>12g} {:>12g} {:>7.2%} {:>7.2f}' + ) self.log_note_formatter = ' {:>9} {:>15} {:>15}' # Flag indicating whether the solution improved in the past @@ -144,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): @@ -181,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' @@ -294,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: @@ -511,9 +515,9 @@ def get_primal_integral(self): return primal_integral def get_integral_info(self): - ''' + """ Obtain primal integral, dual integral and primal dual gap integral. - ''' + """ self.primal_integral = self.get_primal_integral() self.dual_integral = self.get_dual_integral() self.primal_dual_gap_integral = self.primal_integral + self.dual_integral @@ -625,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. @@ -797,7 +799,7 @@ def MindtPy_initialization(self): try: self.curr_int_sol = get_integer_solution(self.working_model) except TypeError as e: - config.logger.error(e) + config.logger.error(e, exc_info=True) raise ValueError( 'The initial integer combination is not provided or not complete. ' 'Please provide the complete integer combination or use other initialization strategy.' @@ -805,6 +807,10 @@ def MindtPy_initialization(self): self.integer_list.append(self.curr_int_sol) fixed_nlp, fixed_nlp_result = self.solve_subproblem() self.handle_nlp_subproblem_tc(fixed_nlp, fixed_nlp_result) + self.integer_solution_to_cuts_index[self.curr_int_sol] = [ + 1, + len(self.mip.MindtPy_utils.cuts.oa_cuts), + ] elif config.init_strategy == 'FP': self.init_rNLP() self.fp_loop() @@ -834,12 +840,35 @@ 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: self.rnlp.solutions.load_from(results) subprob_terminate_cond = results.solver.termination_condition + + # Sometimes, the NLP solver might be trapped in a infeasible solution if the objective function is nonlinear and partition_obj_nonlinear_terms is True. If this happens, we will use the original objective function instead. + if ( + subprob_terminate_cond == tc.infeasible + and config.partition_obj_nonlinear_terms + and self.rnlp.MindtPy_utils.objective_list[0].expr.polynomial_degree() + not in self.mip_objective_polynomial_degree + ): + config.logger.info( + 'Initial relaxed NLP problem is infeasible. This might be related to partition_obj_nonlinear_terms. Trying to solve it again without partitioning nonlinear objective function.' + ) + self.rnlp.MindtPy_utils.objective.deactivate() + self.rnlp.MindtPy_utils.objective_list[0].activate() + results = self.nlp_opt.solve( + self.rnlp, + tee=config.nlp_solver_tee, + load_solutions=self.nlp_load_solutions, + **nlp_args, + ) + if len(results.solution) > 0: + self.rnlp.solutions.load_from(results) + subprob_terminate_cond = results.solver.termination_condition + if subprob_terminate_cond in {tc.optimal, tc.feasible, tc.locallyOptimal}: main_objective = MindtPy.objective_list[-1] if subprob_terminate_cond == tc.optimal: @@ -880,12 +909,14 @@ def init_rNLP(self, add_oa_cuts=True): self.rnlp.MindtPy_utils.variable_list, self.mip.MindtPy_utils.variable_list, config, + ignore_integrality=True, ) if config.init_strategy == 'FP': copy_var_list_values( self.rnlp.MindtPy_utils.variable_list, self.working_model.MindtPy_utils.variable_list, config, + ignore_integrality=True, ) self.add_cuts( dual_values=dual_values, @@ -962,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) @@ -1050,7 +1084,7 @@ def solve_subproblem(self): 0, c_geq * (rhs - value(c.body)) ) except (ValueError, OverflowError) as e: - config.logger.error(e) + config.logger.error(e, exc_info=True) self.fixed_nlp.tmp_duals[c] = None evaluation_error = True if evaluation_error: @@ -1067,8 +1101,9 @@ def solve_subproblem(self): tolerance=config.constraint_tolerance, ) except InfeasibleConstraintException as e: + config.logger.error(e, exc_info=True) config.logger.error( - str(e) + '\nInfeasibility detected in deactivate_trivial_constraints.' + 'Infeasibility detected in deactivate_trivial_constraints.' ) results = SolverResults() results.solver.termination_condition = tc.infeasible @@ -1081,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: @@ -1219,7 +1254,18 @@ def handle_subproblem_infeasible(self, fixed_nlp, cb_opt=None): # TODO try something else? Reinitialize with different initial # value? config = self.config - config.logger.info('NLP subproblem was locally infeasible.') + config.logger.info( + self.infeasible_fixed_nlp_log_formatter.format( + ' ', + self.nlp_iter, + 'Fixed NLP', + 'Infeasible', + self.primal_bound, + self.dual_bound, + self.rel_gap, + get_main_elapsed_time(self.timing), + ) + ) self.nlp_infeasible_counter += 1 if config.calculate_dual_at_solution: for c in fixed_nlp.MindtPy_utils.constraint_list: @@ -1241,7 +1287,6 @@ def handle_subproblem_infeasible(self, fixed_nlp, cb_opt=None): # elif var.has_lb() and abs(value(var) - var.lb) < config.absolute_bound_tolerance: # fixed_nlp.ipopt_zU_out[var] = -1 - config.logger.info('Solving feasibility problem') feas_subproblem, feas_subproblem_results = self.solve_feasibility_subproblem() # TODO: do we really need this? if self.should_terminate: @@ -1339,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'): @@ -1357,7 +1410,7 @@ def solve_feasibility_subproblem(self): if len(feas_soln.solution) > 0: feas_subproblem.solutions.load_from(feas_soln) except (ValueError, OverflowError) as e: - config.logger.error(e) + config.logger.error(e, exc_info=True) for nlp_var, orig_val in zip( MindtPy.variable_list, self.initial_var_values ): @@ -1375,6 +1428,18 @@ def solve_feasibility_subproblem(self): self.handle_feasibility_subproblem_tc( feas_soln.solver.termination_condition, MindtPy ) + config.logger.info( + self.fixed_nlp_log_formatter.format( + ' ', + self.nlp_iter, + 'Feasibility NLP', + value(feas_subproblem.MindtPy_utils.feas_obj), + self.primal_bound, + self.dual_bound, + self.rel_gap, + get_main_elapsed_time(self.timing), + ) + ) MindtPy.feas_opt.deactivate() for constr in MindtPy.nonlinear_constraint_list: constr.activate() @@ -1486,9 +1551,8 @@ def fix_dual_bound(self, last_iter_cuts): try: self.dual_bound = self.stored_bound[self.primal_bound] except KeyError as e: - config.logger.error( - str(e) + '\nNo stored bound found. Bound fix failed.' - ) + config.logger.error(e, exc_info=True) + config.logger.error('No stored bound found. Bound fix failed.') else: config.logger.info( 'Solve the main problem without the last no_good cut to fix the bound.' @@ -1502,7 +1566,7 @@ def fix_dual_bound(self, last_iter_cuts): self.handle_nlp_subproblem_tc(fixed_nlp, fixed_nlp_result) MindtPy = self.mip.MindtPy_utils - # deactivate the integer cuts generated after the best solution was found. + # Deactivate the integer cuts generated after the best solution was found. self.deactivate_no_good_cuts_when_fixing_bound(MindtPy.cuts.no_good_cuts) if ( config.add_regularization is not None @@ -1519,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: @@ -1601,19 +1665,20 @@ def solve_main(self): # setup main problem self.setup_main() mip_args = self.set_up_mip_solver() + update_solver_timelimit(self.mip_opt, config.mip_solver, self.timing, config) try: 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. if len(main_mip_results.solution) > 0: self.mip.solutions.load_from(main_mip_results) except (ValueError, AttributeError, RuntimeError) as e: - config.logger.error(e) + config.logger.error(e, exc_info=True) if config.single_tree: config.logger.warning('Single tree terminate.') if get_main_elapsed_time(self.timing) >= config.time_limit: @@ -1626,7 +1691,11 @@ def solve_main(self): "No-good cuts are added and GOA algorithm doesn't converge within the time limit. " 'No integer solution is found, so the CPLEX solver will report an error status. ' ) - return None, None + # Value error will be raised if the MIP problem is unbounded and appsi solver is used when loading solutions. Although the problem is unbounded, a valid result is provided and we do not return None to let the algorithm continue. + if 'main_mip_results' in locals(): + return self.mip, main_mip_results + else: + return None, None if config.solution_pool: main_mip_results._solver_model = self.mip_opt._solver_model main_mip_results._pyomo_var_to_solver_var_map = ( @@ -1658,11 +1727,12 @@ def solve_fp_main(self): config = self.config self.setup_fp_main() mip_args = self.set_up_mip_solver() + update_solver_timelimit(self.mip_opt, config.mip_solver, self.timing, config) 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. @@ -1705,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: @@ -1791,7 +1861,6 @@ def handle_main_optimal(self, main_mip, update_bound=True): f"Integer variable {var.name} not initialized. " "Setting it to its lower bound" ) - # nlp_var.bounds[0] var.set_value(var.lb, skip_validation=True) # warm start for the nlp subproblem copy_var_list_values( @@ -1857,11 +1926,6 @@ def handle_main_max_timelimit(self, main_mip, main_mip_results): """ # If we have found a valid feasible solution, we take that. If not, we can at least use the dual bound. MindtPy = main_mip.MindtPy_utils - self.config.logger.info( - 'Unable to optimize MILP main problem ' - 'within time limit. ' - 'Using current solver feasible solution.' - ) copy_var_list_values( main_mip.MindtPy_utils.variable_list, self.fixed_nlp.MindtPy_utils.variable_list, @@ -1870,10 +1934,10 @@ def handle_main_max_timelimit(self, main_mip, main_mip_results): ) self.update_suboptimal_dual_bound(main_mip_results) self.config.logger.info( - self.log_formatter.format( + self.termination_condition_log_formatter.format( self.mip_iter, 'MILP', - value(MindtPy.mip_obj.expr), + 'maxTimeLimit', self.primal_bound, self.dual_bound, self.rel_gap, @@ -1900,8 +1964,18 @@ def handle_main_unbounded(self, main_mip): # to the constraints, and deactivated for the linear main problem. config = self.config MindtPy = main_mip.MindtPy_utils + config.logger.info( + self.termination_condition_log_formatter.format( + self.mip_iter, + 'MILP', + 'Unbounded', + self.primal_bound, + self.dual_bound, + self.rel_gap, + get_main_elapsed_time(self.timing), + ) + ) config.logger.warning( - 'main MILP was unbounded. ' 'Resolving with arbitrary bound values of (-{0:.10g}, {0:.10g}) on the objective. ' 'You can change this bound with the option obj_bound.'.format( config.obj_bound @@ -1917,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: @@ -2200,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(): @@ -2247,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 @@ -2308,8 +2387,9 @@ def solve_fp_subproblem(self): tolerance=config.constraint_tolerance, ) except InfeasibleConstraintException as e: + config.logger.error(e, exc_info=True) config.logger.error( - str(e) + '\nInfeasibility detected in deactivate_trivial_constraints.' + 'Infeasibility detected in deactivate_trivial_constraints.' ) results = SolverResults() results.solver.termination_condition = tc.infeasible @@ -2322,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: @@ -2342,6 +2422,7 @@ def handle_fp_subproblem_optimal(self, fp_nlp): fp_nlp.MindtPy_utils.variable_list, self.working_model.MindtPy_utils.variable_list, self.config, + ignore_integrality=True, ) add_orthogonality_cuts(self.working_model, self.mip, self.config) @@ -2526,7 +2607,7 @@ def fp_loop(self): self.working_model.MindtPy_utils.cuts.del_component('fp_orthogonality_cuts') def initialize_mip_problem(self): - '''Deactivate the nonlinear constraints to create the MIP problem.''' + """Deactivate the nonlinear constraints to create the MIP problem.""" # if single tree is activated, we need to add bounds for unbounded variables in nonlinear constraints to avoid unbounded main problem. config = self.config if config.single_tree: @@ -2557,7 +2638,7 @@ def initialize_mip_problem(self): self.fixed_nlp = self.working_model.clone() TransformationFactory('core.fix_integer_vars').apply_to(self.fixed_nlp) - initialize_feas_subproblem(self.fixed_nlp, config) + initialize_feas_subproblem(self.fixed_nlp, config.feasibility_norm) def initialize_subsolvers(self): """Initialize and set options for MIP and NLP subsolvers.""" @@ -2585,7 +2666,7 @@ def initialize_subsolvers(self): self.nlp_opt, config.nlp_solver, config ) set_solver_constraint_violation_tolerance( - self.feasibility_nlp_opt, config.nlp_solver, config + self.feasibility_nlp_opt, config.nlp_solver, config, warm_start=False ) self.set_appsi_solver_update_config() @@ -2886,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) @@ -2897,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) @@ -2929,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) @@ -2942,10 +3036,12 @@ def MindtPy_iteration_loop(self): # if add_no_good_cuts is True, the bound obtained in the last iteration is no reliable. # we correct it after the iteration. + # There is no need to fix the dual bound if no feasible solution has been found. if ( (config.add_no_good_cuts or config.use_tabu_list) and not self.should_terminate and config.add_regularization is None + and self.best_solution_found is not None ): self.fix_dual_bound(self.last_iter_cuts) config.logger.info( diff --git a/pyomo/contrib/mindtpy/config_options.py b/pyomo/contrib/mindtpy/config_options.py index ed0c86baae9..5d265e72cf6 100644 --- a/pyomo/contrib/mindtpy/config_options.py +++ b/pyomo/contrib/mindtpy/config_options.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- import logging from pyomo.common.config import ( @@ -312,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( @@ -538,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', @@ -620,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 28d302104a3..e932755e9fd 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-2022 +# 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 @@ -210,8 +210,8 @@ def add_oa_cuts_for_grey_box( target_model_grey_box.inputs.values() ) ) + - (output - value(output)) ) - - (output - value(output)) - (slack_var if config.add_slack else 0) <= 0 ) @@ -271,8 +271,9 @@ def add_ecp_cuts( try: upper_slack = constr.uslack() except (ValueError, OverflowError) as e: + config.logger.error(e, exc_info=True) config.logger.error( - str(e) + '\nConstraint {} has caused either a ' + 'Constraint {} has caused either a ' 'ValueError or OverflowError.' '\n'.format(constr) ) @@ -300,8 +301,9 @@ def add_ecp_cuts( try: lower_slack = constr.lslack() except (ValueError, OverflowError) as e: + config.logger.error(e, exc_info=True) config.logger.error( - str(e) + '\nConstraint {} has caused either a ' + 'Constraint {} has caused either a ' 'ValueError or OverflowError.' '\n'.format(constr) ) @@ -424,9 +426,9 @@ def add_affine_cuts(target_model, config, timing): try: mc_eqn = mc(constr.body) except MCPP_Error as e: + config.logger.error(e, exc_info=True) config.logger.error( - '\nSkipping constraint %s due to MCPP error %s' - % (constr.name, str(e)) + 'Skipping constraint %s due to MCPP error' % (constr.name) ) continue # skip to the next constraint diff --git a/pyomo/contrib/mindtpy/extended_cutting_plane.py b/pyomo/contrib/mindtpy/extended_cutting_plane.py index 446304b1361..7bb3ff783c9 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-2022 +# 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 @@ -66,12 +66,6 @@ def MindtPy_iteration_loop(self): add_ecp_cuts(self.mip, self.jacobians, self.config, self.timing) - # if add_no_good_cuts is True, the bound obtained in the last iteration is no reliable. - # we correct it after the iteration. - if ( - self.config.add_no_good_cuts or self.config.use_tabu_list - ) and not self.should_terminate: - self.fix_dual_bound(self.last_iter_cuts) self.config.logger.info( ' ===============================================================================================' ) @@ -84,9 +78,12 @@ def check_config(self): super().check_config() def initialize_mip_problem(self): - '''Deactivate the nonlinear constraints to create the MIP problem.''' + """Deactivate the nonlinear constraints to create the MIP problem.""" super().initialize_mip_problem() - self.jacobians = calc_jacobians(self.mip, self.config) # preload jacobians + self.jacobians = calc_jacobians( + self.mip.MindtPy_utils.nonlinear_constraint_list, + self.config.differentiate_mode, + ) # preload jacobians self.mip.MindtPy_utils.cuts.ecp_cuts = ConstraintList( doc='Extended Cutting Planes' ) @@ -140,7 +137,7 @@ def all_nonlinear_constraint_satisfied(self): lower_slack = nlc.lslack() except (ValueError, OverflowError) as e: # Set lower_slack (upper_slack below) less than -config.ecp_tolerance in this case. - config.logger.error(e) + config.logger.error(e, exc_info=True) lower_slack = -10 * config.ecp_tolerance if lower_slack < -config.ecp_tolerance: config.logger.debug( @@ -153,7 +150,7 @@ def all_nonlinear_constraint_satisfied(self): try: upper_slack = nlc.uslack() except (ValueError, OverflowError) as e: - config.logger.error(e) + config.logger.error(e, exc_info=True) upper_slack = -10 * config.ecp_tolerance if upper_slack < -config.ecp_tolerance: config.logger.debug( diff --git a/pyomo/contrib/mindtpy/feasibility_pump.py b/pyomo/contrib/mindtpy/feasibility_pump.py index 990f56b8f93..5ee1260dd42 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-2022 +# 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 @@ -44,9 +44,12 @@ def check_config(self): super().check_config() def initialize_mip_problem(self): - '''Deactivate the nonlinear constraints to create the MIP problem.''' + """Deactivate the nonlinear constraints to create the MIP problem.""" super().initialize_mip_problem() - self.jacobians = calc_jacobians(self.mip, self.config) # preload jacobians + self.jacobians = calc_jacobians( + self.mip.MindtPy_utils.nonlinear_constraint_list, + self.config.differentiate_mode, + ) # preload jacobians self.mip.MindtPy_utils.cuts.oa_cuts = ConstraintList( doc='Outer approximation cuts' ) diff --git a/pyomo/contrib/mindtpy/global_outer_approximation.py b/pyomo/contrib/mindtpy/global_outer_approximation.py index dfb7ef54630..3c162738be5 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-2022 +# 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 @@ -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 @@ -67,7 +67,7 @@ def check_config(self): super().check_config() def initialize_mip_problem(self): - '''Deactivate the nonlinear constraints to create the MIP problem.''' + """Deactivate the nonlinear constraints to create the MIP problem.""" super().initialize_mip_problem() self.mip.MindtPy_utils.cuts.aff_cuts = ConstraintList(doc='Affine cuts') @@ -108,4 +108,5 @@ def deactivate_no_good_cuts_when_fixing_bound(self, no_good_cuts): if self.config.use_tabu_list: self.integer_list = self.integer_list[:valid_no_good_cuts_num] except KeyError as e: - self.config.logger.error(str(e) + '\nDeactivating no-good cuts failed.') + self.config.logger.error(e, exc_info=True) + self.config.logger.error('Deactivating no-good cuts failed.') diff --git a/pyomo/contrib/mindtpy/outer_approximation.py b/pyomo/contrib/mindtpy/outer_approximation.py index 6cf0b26cb37..aee0893e2be 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-2022 +# 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 @@ -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 @@ -94,9 +94,12 @@ def check_config(self): _MindtPyAlgorithm.check_config(self) def initialize_mip_problem(self): - '''Deactivate the nonlinear constraints to create the MIP problem.''' + """Deactivate the nonlinear constraints to create the MIP problem.""" super().initialize_mip_problem() - self.jacobians = calc_jacobians(self.mip, self.config) # preload jacobians + self.jacobians = calc_jacobians( + self.mip.MindtPy_utils.nonlinear_constraint_list, + self.config.differentiate_mode, + ) # preload jacobians self.mip.MindtPy_utils.cuts.oa_cuts = ConstraintList( doc='Outer approximation cuts' ) diff --git a/pyomo/contrib/mindtpy/plugins.py b/pyomo/contrib/mindtpy/plugins.py index f25706d086a..bf0ab0d1581 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-2022 +# 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 diff --git a/pyomo/contrib/mindtpy/single_tree.py b/pyomo/contrib/mindtpy/single_tree.py index 5383624b6aa..6b501ef874d 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-2022 +# 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 @@ -16,12 +16,12 @@ from pyomo.repn import generate_standard_repn import pyomo.core.expr as EXPR from math import copysign -from pyomo.contrib.mindtpy.util import get_integer_solution -from pyomo.contrib.gdpopt.util import ( +from pyomo.contrib.mindtpy.util import ( + get_integer_solution, copy_var_list_values, - get_main_elapsed_time, - time_code, + set_var_valid_value, ) +from pyomo.contrib.gdpopt.util import get_main_elapsed_time, time_code from pyomo.opt import TerminationCondition as tc from pyomo.core import minimize, value from pyomo.core.expr import identify_variables @@ -35,17 +35,9 @@ class LazyOACallback_cplex( """Inherent class in CPLEX to call Lazy callback.""" def copy_lazy_var_list_values( - self, - opt, - from_list, - to_list, - config, - skip_stale=False, - skip_fixed=True, - ignore_integrality=False, + self, opt, from_list, to_list, config, skip_stale=False, skip_fixed=True ): """This function copies variable values from one list to another. - Rounds to Binary/Integer if necessary. Sets to zero for NonNegativeReals if necessary. @@ -54,17 +46,15 @@ def copy_lazy_var_list_values( opt : SolverFactory The cplex_persistent solver. from_list : list - The variables that provides the values to copy from. + The variable list that provides the values to copy from. to_list : list - The variables that need to set value. + The variable list that needs to set value. config : ConfigBlock The specific configurations for MindtPy. skip_stale : bool, optional Whether to skip the stale variables, by default False. skip_fixed : bool, optional Whether to skip the fixed variables, by default True. - ignore_integrality : bool, optional - Whether to ignore the integrality of integer variables, by default False. """ for v_from, v_to in zip(from_list, to_list): if skip_stale and v_from.stale: @@ -72,43 +62,13 @@ def copy_lazy_var_list_values( if skip_fixed and v_to.is_fixed(): continue # Skip fixed variables. v_val = self.get_values(opt._pyomo_var_to_solver_var_map[v_from]) - try: - # We don't want to trigger the reset of the global stale - # indicator, so we will set this variable to be "stale", - # knowing that set_value will switch it back to "not - # stale" - v_to.stale = True - # NOTE: PEP 2180 changes the var behavior so that domain - # / bounds violations no longer generate exceptions (and - # instead log warnings). This means that the following - # will always succeed and the ValueError should never be - # raised. - v_to.set_value(v_val, skip_validation=True) - except ValueError as e: - # Snap the value to the bounds - config.logger.error(e) - if ( - v_to.has_lb() - and v_val < v_to.lb - and v_to.lb - v_val <= config.variable_tolerance - ): - v_to.set_value(v_to.lb, skip_validation=True) - elif ( - v_to.has_ub() - and v_val > v_to.ub - and v_val - v_to.ub <= config.variable_tolerance - ): - v_to.set_value(v_to.ub, skip_validation=True) - # ... or the nearest integer - elif v_to.is_integer(): - rounded_val = int(round(v_val)) - if ( - ignore_integrality - or abs(v_val - rounded_val) <= config.integer_tolerance - ) and rounded_val in v_to.domain: - v_to.set_value(rounded_val, skip_validation=True) - else: - raise + set_var_valid_value( + v_to, + v_val, + config.integer_tolerance, + config.zero_tolerance, + ignore_integrality=False, + ) def add_lazy_oa_cuts( self, @@ -309,12 +269,11 @@ def add_lazy_affine_cuts(self, mindtpy_solver, config, opt): try: mc_eqn = mc(constr.body) except MCPP_Error as e: + config.logger.error(e, exc_info=True) config.logger.debug( - 'Skipping constraint %s due to MCPP error %s' - % (constr.name, str(e)) + 'Skipping constraint %s due to MCPP error' % (constr.name) ) continue # skip to the next constraint - # TODO: check if the value of ccSlope and cvSlope is not Nan or inf. If so, we skip this. ccSlope = mc_eqn.subcc() cvSlope = mc_eqn.subcv() ccStart = mc_eqn.concave() @@ -705,10 +664,11 @@ def __call__(self): main_mip = self.main_mip mindtpy_solver = self.mindtpy_solver + # The lazy constraint callback may be invoked during MIP start processing. In that case get_solution_source returns mip_start_solution. # Reference: https://www.ibm.com/docs/en/icos/22.1.1?topic=SSSA5P_22.1.1/ilog.odms.cplex.help/refpythoncplex/html/cplex.callbacks.SolutionSource-class.htm # Another solution source is user_solution = 118, but it will not be encountered in LazyConstraintCallback. - config.logger.debug( - "Solution source: %s (111 node_solution, 117 heuristic_solution, 119 mipstart_solution)".format( + config.logger.info( + "Solution source: {} (111 node_solution, 117 heuristic_solution, 119 mipstart_solution)".format( self.get_solution_source() ) ) @@ -717,6 +677,7 @@ def __call__(self): # Lazy constraints separated when processing a MIP start will be discarded after that MIP start has been processed. # This means that the callback may have to separate the same constraint again for the next MIP start or for a solution that is found later in the solution process. # https://www.ibm.com/docs/en/icos/22.1.1?topic=SSSA5P_22.1.1/ilog.odms.cplex.help/refpythoncplex/html/cplex.callbacks.LazyConstraintCallback-class.htm + # For the MINLP3_simple example, all the solutions are obtained from mip_start (solution source). Therefore, it will not go to a branch and bound process.Cause an error output. if ( self.get_solution_source() != cplex.callbacks.SolutionSource.mipstart_solution @@ -727,6 +688,7 @@ def __call__(self): mindtpy_solver.mip_start_lazy_oa_cuts = [] if mindtpy_solver.should_terminate: + # TODO: check the performance difference if we don't use self.abort() and let cplex terminate by itself. self.abort() return self.handle_lazy_main_feasible_solution(main_mip, mindtpy_solver, config, opt) @@ -744,9 +706,9 @@ def __call__(self): mindtpy_solver.mip, None, mindtpy_solver, config, opt ) except ValueError as e: + config.logger.error(e, exc_info=True) config.logger.error( - str(e) - + "\nUsually this error is caused by the MIP start solution causing a math domain error. " + "Usually this error is caused by the MIP start solution causing a math domain error. " "We will skip it." ) return @@ -782,6 +744,7 @@ def __call__(self): ) ) mindtpy_solver.results.solver.termination_condition = tc.optimal + # TODO: check the performance difference if we don't use self.abort() and let cplex terminate by itself. self.abort() return @@ -810,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 @@ -909,19 +875,7 @@ def LazyOACallback_gurobi(cb_m, cb_opt, cb_where, mindtpy_solver, config): if mindtpy_solver.dual_bound != mindtpy_solver.dual_bound_progress[0]: mindtpy_solver.add_regularization() - if ( - abs(mindtpy_solver.primal_bound - mindtpy_solver.dual_bound) - <= config.absolute_bound_tolerance - ): - config.logger.info( - 'MindtPy exiting on bound convergence. ' - '|Primal Bound: {} - Dual Bound: {}| <= (absolute tolerance {}) \n'.format( - mindtpy_solver.primal_bound, - mindtpy_solver.dual_bound, - config.absolute_bound_tolerance, - ) - ) - mindtpy_solver.results.solver.termination_condition = tc.optimal + if mindtpy_solver.bounds_converged() or mindtpy_solver.reached_time_limit(): cb_opt._solver_model.terminate() return @@ -952,15 +906,34 @@ def LazyOACallback_gurobi(cb_m, cb_opt, cb_where, mindtpy_solver, config): ) return elif config.strategy == 'OA': + # Refer to the official document of GUROBI. + # Your callback should be prepared to cut off solutions that violate any of your lazy constraints, including those that have already been added. Node solutions will usually respect previously added lazy constraints, but not always. + # https://www.gurobi.com/documentation/current/refman/cs_cb_addlazy.html + # If this happens, MindtPy will look for the index of corresponding cuts, instead of solving the fixed-NLP again. + begin_index, end_index = mindtpy_solver.integer_solution_to_cuts_index[ + mindtpy_solver.curr_int_sol + ] + for ind in range(begin_index, end_index + 1): + cb_opt.cbLazy(mindtpy_solver.mip.MindtPy_utils.cuts.oa_cuts[ind]) return else: mindtpy_solver.integer_list.append(mindtpy_solver.curr_int_sol) + if config.strategy == 'OA': + 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() mindtpy_solver.handle_nlp_subproblem_tc(fixed_nlp, fixed_nlp_result, cb_opt) + if config.strategy == 'OA': + # store the cut index corresponding to current integer solution. + mindtpy_solver.integer_solution_to_cuts_index[ + mindtpy_solver.curr_int_sol + ] = [cut_ind + 1, len(mindtpy_solver.mip.MindtPy_utils.cuts.oa_cuts)] def handle_lazy_main_feasible_solution_gurobi(cb_m, cb_opt, mindtpy_solver, config): diff --git a/pyomo/contrib/mindtpy/tabu_list.py b/pyomo/contrib/mindtpy/tabu_list.py index 313bd6f6271..15c1d3b3a2b 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-2022 +# 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 diff --git a/pyomo/contrib/mindtpy/tests/MINLP2_simple.py b/pyomo/contrib/mindtpy/tests/MINLP2_simple.py index 10da243d332..f3fd51af79a 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-2022 +# 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 diff --git a/pyomo/contrib/mindtpy/tests/MINLP3_simple.py b/pyomo/contrib/mindtpy/tests/MINLP3_simple.py index f387b0e26a1..a17659e0c51 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-2022 +# 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 diff --git a/pyomo/contrib/mindtpy/tests/MINLP4_simple.py b/pyomo/contrib/mindtpy/tests/MINLP4_simple.py index 7b57c6b8f0d..bd5a4c53e97 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP4_simple.py +++ b/pyomo/contrib/mindtpy/tests/MINLP4_simple.py @@ -1,5 +1,16 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- 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 5ab5f98b894..d5b04d0915c 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP5_simple.py +++ b/pyomo/contrib/mindtpy/tests/MINLP5_simple.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Example in paper 'Using regularization and second order information in outer approximation for convex MINLP' diff --git a/pyomo/contrib/mindtpy/tests/MINLP_simple.py b/pyomo/contrib/mindtpy/tests/MINLP_simple.py index 7454b595986..cde65536f43 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-2022 +# 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 diff --git a/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py b/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py index 547efc0a74c..412067de0b5 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py +++ b/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.dependencies import numpy as np import pyomo.common.dependencies.scipy.sparse as scipy_sparse from pyomo.common.dependencies import attempt_import @@ -114,7 +125,7 @@ def evaluate_jacobian_equality_constraints(self): """Evaluate the Jacobian of the equality constraints.""" return None - ''' + """ def _extract_and_assemble_fim(self): M = np.zeros((self.n_parameters, self.n_parameters)) for i in range(self.n_parameters): @@ -122,7 +133,7 @@ def _extract_and_assemble_fim(self): M[i,k] = self._input_values[self.ele_to_order[(i,k)]] return M - ''' + """ def evaluate_jacobian_outputs(self): """Evaluate the Jacobian of the outputs.""" diff --git a/pyomo/contrib/mindtpy/tests/__init__.py b/pyomo/contrib/mindtpy/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/mindtpy/tests/__init__.py +++ b/pyomo/contrib/mindtpy/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py b/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py index 6038f9a74eb..29546c4f8f9 100644 --- a/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py +++ b/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py @@ -1,5 +1,16 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- 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 d3876a9dc44..ed9059ae4ae 100644 --- a/pyomo/contrib/mindtpy/tests/eight_process_problem.py +++ b/pyomo/contrib/mindtpy/tests/eight_process_problem.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Re-implementation of eight-process problem. diff --git a/pyomo/contrib/mindtpy/tests/feasibility_pump1.py b/pyomo/contrib/mindtpy/tests/feasibility_pump1.py index e0a611c1ed2..fec750f9f12 100644 --- a/pyomo/contrib/mindtpy/tests/feasibility_pump1.py +++ b/pyomo/contrib/mindtpy/tests/feasibility_pump1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Example 1 in paper 'A Feasibility Pump for mixed integer nonlinear programs' diff --git a/pyomo/contrib/mindtpy/tests/feasibility_pump2.py b/pyomo/contrib/mindtpy/tests/feasibility_pump2.py index 48b98dc5800..d739e4efbbe 100644 --- a/pyomo/contrib/mindtpy/tests/feasibility_pump2.py +++ b/pyomo/contrib/mindtpy/tests/feasibility_pump2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Example 2 in paper 'A Feasibility Pump for mixed integer nonlinear programs' diff --git a/pyomo/contrib/mindtpy/tests/from_proposal.py b/pyomo/contrib/mindtpy/tests/from_proposal.py index 6ddab15ee53..f29fbcd2cf7 100644 --- a/pyomo/contrib/mindtpy/tests/from_proposal.py +++ b/pyomo/contrib/mindtpy/tests/from_proposal.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """ See David Bernal PhD proposal example. diff --git a/pyomo/contrib/mindtpy/tests/nonconvex1.py b/pyomo/contrib/mindtpy/tests/nonconvex1.py index 94a4de29405..71b7e22af96 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex1.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex1.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Problem A in paper 'Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs' diff --git a/pyomo/contrib/mindtpy/tests/nonconvex2.py b/pyomo/contrib/mindtpy/tests/nonconvex2.py index 525db1292c1..94c519ab0e1 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex2.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Problem B in paper 'Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs' diff --git a/pyomo/contrib/mindtpy/tests/nonconvex3.py b/pyomo/contrib/mindtpy/tests/nonconvex3.py index b08deb67b63..5b6a1de8d7d 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex3.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex3.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Problem C in paper 'Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs'. The problem in the paper has two optimal solution. Variable y4 and y6 are symmetric. Therefore, we remove variable y6 for simplification. diff --git a/pyomo/contrib/mindtpy/tests/nonconvex4.py b/pyomo/contrib/mindtpy/tests/nonconvex4.py index c30fb9922a0..3b7f6660ddf 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex4.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex4.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Problem D in paper 'Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs' diff --git a/pyomo/contrib/mindtpy/tests/online_doc_example.py b/pyomo/contrib/mindtpy/tests/online_doc_example.py index d741455e7f7..207ecf7d945 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-2022 +# 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 @@ -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 e872eccc670..618967be00f 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-2022 +# 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 @@ -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: @@ -327,6 +356,7 @@ def test_OA_APPSI_ipopt(self): value(model.objective.expr), model.optimal_value, places=1 ) + # CYIPOPT will raise WARNING (W1002) during loading solution. @unittest.skipUnless( SolverFactory('cyipopt').available(exception_flag=False), "APPSI_IPOPT not available.", diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py index b5bfbe62553..dda0f74147e 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Tests for the MindtPy solver.""" import pyomo.common.unittest as unittest @@ -12,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 697a63d17c8..0baa361910e 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Tests for the MindtPy solver.""" import pyomo.common.unittest as unittest @@ -17,8 +28,13 @@ from pyomo.contrib.mindtpy.tests.feasibility_pump1 import FeasPump1 from pyomo.contrib.mindtpy.tests.feasibility_pump2 import FeasPump2 -required_solvers = ('ipopt', 'cplex') -# 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: @@ -69,6 +85,22 @@ def test_FP(self): log_infeasible_constraints(model) self.assertTrue(is_feasible(model, self.get_config(opt))) + def test_FP_L1_norm(self): + """Test the feasibility pump algorithm.""" + with SolverFactory('mindtpy') as opt: + for model in model_list: + model = model.clone() + results = opt.solve( + model, + strategy='FP', + mip_solver=required_solvers[1], + nlp_solver=required_solvers[0], + absolute_bound_tolerance=1e-5, + fp_main_norm='L1', + ) + log_infeasible_constraints(model) + self.assertTrue(is_feasible(model, self.get_config(opt))) + def test_FP_OA_8PP(self): """Test the FP-OA algorithm.""" with SolverFactory('mindtpy') as opt: diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py index 0fa19b30d9c..07774805364 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Tests for the MindtPy solver.""" import pyomo.common.unittest as unittest 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 259cfe9dd7c..792bdb8d993 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Tests for global LP/NLP in the MindtPy solver.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py index f84136ca6bf..e01558d48ef 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-2022 +# 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 @@ -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 2662a0e6f56..97f73ece525 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-2022 +# 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 diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py index 4c2ae4d1220..2e864a49578 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Tests for the MindtPy solver.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py index 7a9898d3c7b..a41f41d4d65 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests for solution pool in the MindtPy solver.""" from pyomo.core.expr.calculus.diff_with_sympy import differentiate_available diff --git a/pyomo/contrib/mindtpy/tests/unit_test.py b/pyomo/contrib/mindtpy/tests/unit_test.py new file mode 100644 index 00000000000..af6ffad282d --- /dev/null +++ b/pyomo/contrib/mindtpy/tests/unit_test.py @@ -0,0 +1,101 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest +from pyomo.contrib.mindtpy.util import set_var_valid_value + +from pyomo.environ import Var, Integers, ConcreteModel, Integers +from pyomo.contrib.mindtpy.algorithm_base_class import _MindtPyAlgorithm +from pyomo.contrib.mindtpy.config_options import _get_MindtPy_OA_config +from pyomo.contrib.mindtpy.tests.MINLP5_simple import SimpleMINLP5 +from pyomo.contrib.mindtpy.util import add_var_bound + + +class UnitTestMindtPy(unittest.TestCase): + def test_set_var_valid_value(self): + m = ConcreteModel() + m.x1 = Var(within=Integers, bounds=(-1, 4), initialize=0) + + set_var_valid_value( + m.x1, + var_val=5, + integer_tolerance=1e-6, + zero_tolerance=1e-6, + ignore_integrality=False, + ) + self.assertEqual(m.x1.value, 4) + + set_var_valid_value( + m.x1, + var_val=-2, + integer_tolerance=1e-6, + zero_tolerance=1e-6, + ignore_integrality=False, + ) + self.assertEqual(m.x1.value, -1) + + set_var_valid_value( + m.x1, + var_val=1.1, + integer_tolerance=1e-6, + zero_tolerance=1e-6, + ignore_integrality=True, + ) + self.assertEqual(m.x1.value, 1.1) + + set_var_valid_value( + m.x1, + var_val=2.00000001, + integer_tolerance=1e-6, + zero_tolerance=1e-6, + ignore_integrality=False, + ) + self.assertEqual(m.x1.value, 2) + + set_var_valid_value( + m.x1, + var_val=0.0000001, + integer_tolerance=1e-9, + zero_tolerance=1e-6, + ignore_integrality=False, + ) + self.assertEqual(m.x1.value, 0) + + def test_add_var_bound(self): + m = SimpleMINLP5().clone() + m.x.lb = None + m.x.ub = None + m.y.lb = None + m.y.ub = None + solver_object = _MindtPyAlgorithm() + solver_object.config = _get_MindtPy_OA_config() + solver_object.set_up_solve_data(m) + solver_object.create_utility_block(solver_object.working_model, 'MindtPy_utils') + add_var_bound(solver_object.working_model, solver_object.config) + self.assertEqual( + solver_object.working_model.x.lower, + -solver_object.config.continuous_var_bound - 1, + ) + self.assertEqual( + solver_object.working_model.x.upper, + solver_object.config.continuous_var_bound, + ) + self.assertEqual( + solver_object.working_model.y.lower, + -solver_object.config.integer_var_bound - 1, + ) + self.assertEqual( + solver_object.working_model.y.upper, solver_object.config.integer_var_bound + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/pyomo/contrib/mindtpy/util.py b/pyomo/contrib/mindtpy/util.py index cd2b31e5954..0b552b750f0 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-2022 +# 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 @@ -23,12 +23,12 @@ RangeSet, ConstraintList, TransformationFactory, + value, ) from pyomo.repn import generate_standard_repn 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 @@ -40,27 +40,24 @@ numpy = attempt_import('numpy')[0] -def calc_jacobians(model, config): +def calc_jacobians(constraint_list, differentiate_mode): """Generates a map of jacobians for the variables in the model. This function generates a map of jacobians corresponding to the variables in the - model. + constraint list. Parameters ---------- - model : Pyomo model - Target model to calculate jacobian. - config : ConfigBlock - The specific configurations for MindtPy. + constraint_list : List + The list of constraints to calculate Jacobians. + differentiate_mode : String + The differentiate mode to calculate Jacobians. """ # Map nonlinear_constraint --> Map( # variable --> jacobian of constraint w.r.t. variable) jacobians = ComponentMap() - if config.differentiate_mode == 'reverse_symbolic': - mode = EXPR.differentiate.Modes.reverse_symbolic - elif config.differentiate_mode == 'sympy': - mode = EXPR.differentiate.Modes.sympy - for c in model.MindtPy_utils.nonlinear_constraint_list: + mode = EXPR.differentiate.Modes(differentiate_mode) + for c in constraint_list: vars_in_constr = list(EXPR.identify_variables(c.body)) jac_list = EXPR.differentiate(c.body, wrt_list=vars_in_constr, mode=mode) jacobians[c] = ComponentMap( @@ -69,7 +66,7 @@ def calc_jacobians(model, config): return jacobians -def initialize_feas_subproblem(m, config): +def initialize_feas_subproblem(m, feasibility_norm): """Adds feasibility slack variables according to config.feasibility_norm (given an infeasible problem). Defines the objective function of the feasibility subproblem. @@ -77,14 +74,14 @@ def initialize_feas_subproblem(m, config): ---------- m : Pyomo model The feasbility NLP subproblem. - config : ConfigBlock - The specific configurations for MindtPy. + feasibility_norm : String + The norm used to generate the objective function. """ MindtPy = m.MindtPy_utils # generate new constraints for i, constr in enumerate(MindtPy.nonlinear_constraint_list, 1): if constr.has_ub(): - if config.feasibility_norm in {'L1', 'L2'}: + if feasibility_norm in {'L1', 'L2'}: MindtPy.feas_opt.feas_constraints.add( constr.body - constr.upper <= MindtPy.feas_opt.slack_var[i] ) @@ -93,7 +90,7 @@ def initialize_feas_subproblem(m, config): constr.body - constr.upper <= MindtPy.feas_opt.slack_var ) if constr.has_lb(): - if config.feasibility_norm in {'L1', 'L2'}: + if feasibility_norm in {'L1', 'L2'}: MindtPy.feas_opt.feas_constraints.add( constr.body - constr.lower >= -MindtPy.feas_opt.slack_var[i] ) @@ -102,11 +99,11 @@ def initialize_feas_subproblem(m, config): constr.body - constr.lower >= -MindtPy.feas_opt.slack_var ) # Setup objective function for the feasibility subproblem. - if config.feasibility_norm == 'L1': + if feasibility_norm == 'L1': MindtPy.feas_obj = Objective( expr=sum(s for s in MindtPy.feas_opt.slack_var.values()), sense=minimize ) - elif config.feasibility_norm == 'L2': + elif feasibility_norm == 'L2': MindtPy.feas_obj = Objective( expr=sum(s * s for s in MindtPy.feas_opt.slack_var.values()), sense=minimize ) @@ -133,12 +130,12 @@ def add_var_bound(model, config): for var in EXPR.identify_variables(c.body): if var.has_lb() and var.has_ub(): continue - elif not var.has_lb(): + if not var.has_lb(): if var.is_integer(): var.setlb(-config.integer_var_bound - 1) else: var.setlb(-config.continuous_var_bound - 1) - elif not var.has_ub(): + if not var.has_ub(): if var.is_integer(): var.setub(config.integer_var_bound) else: @@ -149,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 ---------- @@ -205,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 ---------- @@ -260,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 ---------- @@ -450,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 @@ -568,7 +565,9 @@ def set_solver_mipgap(opt, solver_name, config): opt.options['add_options'].append('option optcr=%s;' % config.mip_solver_mipgap) -def set_solver_constraint_violation_tolerance(opt, solver_name, config): +def set_solver_constraint_violation_tolerance( + opt, solver_name, config, warm_start=True +): """Set constraint violation tolerance for solvers. Parameters @@ -602,15 +601,16 @@ def set_solver_constraint_violation_tolerance(opt, solver_name, config): opt.options['add_options'].append( 'constr_viol_tol ' + str(config.zero_tolerance) ) - # Ipopt warmstart options - opt.options['add_options'].append( - 'warm_start_init_point yes\n' - 'warm_start_bound_push 1e-9\n' - 'warm_start_bound_frac 1e-9\n' - 'warm_start_slack_bound_frac 1e-9\n' - 'warm_start_slack_bound_push 1e-9\n' - 'warm_start_mult_bound_push 1e-9\n' - ) + if warm_start: + # Ipopt warmstart options + opt.options['add_options'].append( + 'warm_start_init_point yes\n' + 'warm_start_bound_push 1e-9\n' + 'warm_start_bound_frac 1e-9\n' + 'warm_start_slack_bound_frac 1e-9\n' + 'warm_start_slack_bound_push 1e-9\n' + 'warm_start_mult_bound_push 1e-9\n' + ) elif config.nlp_solver_args['solver'] == 'conopt': opt.options['add_options'].append( 'RTNWMA ' + str(config.zero_tolerance) @@ -684,49 +684,24 @@ def copy_var_list_values_from_solution_pool( Whether to ignore the integrality of integer variables, by default False. """ for v_from, v_to in zip(from_list, to_list): - try: - if config.mip_solver == 'cplex_persistent': - var_val = solver_model.solution.pool.get_values( - solution_name, var_map[v_from] - ) - elif config.mip_solver == 'gurobi_persistent': - solver_model.setParam(gurobipy.GRB.Param.SolutionNumber, solution_name) - var_val = var_map[v_from].Xn - # We don't want to trigger the reset of the global stale - # indicator, so we will set this variable to be "stale", - # knowing that set_value will switch it back to "not - # stale" - v_to.stale = True - # NOTE: PEP 2180 changes the var behavior so that domain / - # bounds violations no longer generate exceptions (and - # instead log warnings). This means that the following will - # always succeed and the ValueError should never be raised. - v_to.set_value(var_val, skip_validation=True) - except ValueError as e: - config.logger.error(e) - rounded_val = int(round(var_val)) - # Check to see if this is just a tolerance issue - if ignore_integrality and v_to.is_integer(): - v_to.set_value(var_val, skip_validation=True) - elif v_to.is_integer() and ( - abs(var_val - rounded_val) <= config.integer_tolerance - ): - v_to.set_value(rounded_val, skip_validation=True) - elif abs(var_val) <= config.zero_tolerance and 0 in v_to.domain: - v_to.set_value(0, skip_validation=True) - else: - config.logger.error( - 'Unknown validation domain error setting variable %s' % (v_to.name,) - ) - raise + if config.mip_solver == 'cplex_persistent': + var_val = solver_model.solution.pool.get_values( + solution_name, var_map[v_from] + ) + elif config.mip_solver == 'gurobi_persistent': + solver_model.setParam(gurobipy.GRB.Param.SolutionNumber, solution_name) + var_val = var_map[v_from].Xn + set_var_valid_value( + v_to, + var_val, + config.integer_tolerance, + config.zero_tolerance, + ignore_integrality, + ) 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): @@ -743,25 +718,6 @@ def f(gurobi_model, where): return f -def set_up_logger(config): - """Set up the formatter and handler for logger. - - Parameters - ---------- - config : ConfigBlock - The specific configurations for MindtPy. - """ - config.logger.handlers.clear() - config.logger.propagate = False - ch = logging.StreamHandler() - ch.setLevel(config.logging_level) - # create formatter and add it to the handlers - formatter = logging.Formatter('%(message)s') - ch.setFormatter(formatter) - # add the handlers to logger - config.logger.addHandler(ch) - - def epigraph_reformulation(exp, slack_var_list, constraint_list, use_mcpp, sense): """Epigraph reformulation. @@ -965,3 +921,101 @@ def generate_norm_constraint(fp_nlp_model, mip_model, config): mip_model.MindtPy_utils.discrete_variable_list, ): fp_nlp_model.norm_constraint.add(nlp_var - mip_var.value <= rhs) + + +def copy_var_list_values( + from_list, + to_list, + config, + skip_stale=False, + skip_fixed=True, + ignore_integrality=False, +): + """Copy variable values from one list to another. + Rounds to Binary/Integer if necessary + Sets to zero for NonNegativeReals if necessary + + from_list : list + The variables that provide the values to copy from. + to_list : list + The variables that need to set value. + config : ConfigBlock + The specific configurations for MindtPy. + skip_stale : bool, optional + Whether to skip the stale variables, by default False. + skip_fixed : bool, optional + Whether to skip the fixed variables, by default True. + ignore_integrality : bool, optional + Whether to ignore the integrality of integer variables, by default False. + """ + for v_from, v_to in zip(from_list, to_list): + if skip_stale and v_from.stale: + continue # Skip stale variable values. + if skip_fixed and v_to.is_fixed(): + continue # Skip fixed variables. + var_val = value(v_from, exception=False) + set_var_valid_value( + v_to, + var_val, + config.integer_tolerance, + config.zero_tolerance, + ignore_integrality, + ) + + +def set_var_valid_value( + var, var_val, integer_tolerance, zero_tolerance, ignore_integrality +): + """This function tries to set a valid value for variable with the given input. + Rounds to Binary/Integer if necessary. + Sets to zero for NonNegativeReals if necessary. + + Parameters + ---------- + var : Var + The variable that needs to set value. + var_val : float + The desired value to set for var. + integer_tolerance: float + Tolerance on integral values. + zero_tolerance: float + Tolerance on variable equal to zero. + ignore_integrality : bool, optional + Whether to ignore the integrality of integer variables, by default False. + + Raises + ------ + ValueError + Cannot successfully set the value to the variable. + """ + # NOTE: PEP 2180 changes the var behavior so that domain + # bounds violations no longer generate exceptions (and + # instead log warnings). This means that the set_value method + # will always succeed and the ValueError should never be raised. + + # We don't want to trigger the reset of the global stale + # indicator, so we will set this variable to be "stale", + # knowing that set_value will switch it back to "not stale". + var.stale = True + rounded_val = int(round(var_val)) + if ( + var_val in var.domain + and not ((var.has_lb() and var_val < var.lb)) + and not ((var.has_ub() and var_val > var.ub)) + ): + var.set_value(var_val) + elif var.has_lb() and var_val < var.lb: + var.set_value(var.lb) + elif var.has_ub() and var_val > var.ub: + var.set_value(var.ub) + elif ignore_integrality and var.is_integer(): + var.set_value(var_val, skip_validation=True) + elif var.is_integer() and (math.fabs(var_val - rounded_val) <= integer_tolerance): + var.set_value(rounded_val) + elif abs(var_val) <= zero_tolerance and 0 in var.domain: + var.set_value(0) + else: + raise ValueError( + "set_var_valid_value failed with variable {}, value = {} and rounded value = {}" + "".format(var.name, var_val, rounded_val) + ) diff --git a/pyomo/contrib/mpc/README.md b/pyomo/contrib/mpc/README.md new file mode 100644 index 00000000000..27bfa00cfe6 --- /dev/null +++ b/pyomo/contrib/mpc/README.md @@ -0,0 +1,34 @@ +# Pyomo MPC + +Pyomo MPC is an extension for developing model predictive control simulations +using Pyomo models. Please see the +[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: +- Transferring values between different points in time in a dynamic model +(e.g. to initialize a dynamic model to its initial conditions) +- Extracting or loading disturbances and inputs from or to models, and storing +these in model-agnostic, easily JSON-serializable data structures +- Constructing common modeling components, such as weighted-least-squares +tracking objective functions, piecewise-constant input constraints, or +terminal region constraints. + +## Citation + +If you use Pyomo MPC in your research, please cite the following paper, which +discusses the motivation for the Pyomo MPC data structures and the underlying +Pyomo features that make them possible. +```bibtex +@article{parker2023mpc, +title = {Model predictive control simulations with block-hierarchical differential-algebraic process models}, +journal = {Journal of Process Control}, +volume = {132}, +pages = {103113}, +year = {2023}, +issn = {0959-1524}, +doi = {https://doi.org/10.1016/j.jprocont.2023.103113}, +url = {https://www.sciencedirect.com/science/article/pii/S0959152423002007}, +author = {Robert B. Parker and Bethany L. Nicholson and John D. Siirola and Lorenz T. Biegler}, +} +``` diff --git a/pyomo/contrib/mpc/__init__.py b/pyomo/contrib/mpc/__init__.py index da977f365d2..2e1c51e154f 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/__init__.py b/pyomo/contrib/mpc/data/__init__.py index 9061fda4bfd..6051f4ba3a2 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/convert.py b/pyomo/contrib/mpc/data/convert.py index f1d35592a9f..10885370032 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/dynamic_data_base.py b/pyomo/contrib/mpc/data/dynamic_data_base.py index c0223d2dcbe..5e567f060cf 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/find_nearest_index.py b/pyomo/contrib/mpc/data/find_nearest_index.py index 0875bde63e9..c53a7a79841 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/get_cuid.py b/pyomo/contrib/mpc/data/get_cuid.py index 1f229b35645..a6889551b3c 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-2022 +# 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 @@ -16,14 +16,13 @@ def get_indexed_cuid(var, sets=None, dereference=None, context=None): - """ - Attempts to convert the provided "var" object into a CUID with - with wildcards. + """Attempt to convert the provided "var" object into a CUID with wildcards Arguments --------- var: - Object to process + Object to process. May be a VarData, IndexedVar (reference or otherwise), + ComponentUID, slice, or string. sets: Tuple of sets Sets to use if slicing a vardata object dereference: None or int @@ -32,12 +31,14 @@ def get_indexed_cuid(var, sets=None, dereference=None, context=None): context: Block Block with respect to which slices and CUIDs will be generated + Returns + ------- + ``ComponentUID`` + 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/interval_data.py b/pyomo/contrib/mpc/data/interval_data.py index cdd3b0e37dc..54b7ca7e906 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/scalar_data.py b/pyomo/contrib/mpc/data/scalar_data.py index 5426921ef06..b67384c8159 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/series_data.py b/pyomo/contrib/mpc/data/series_data.py index d09ab8cae24..2d79c9170c7 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-2022 +# 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 @@ -25,18 +25,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 +122,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. diff --git a/pyomo/contrib/mpc/data/tests/__init__.py b/pyomo/contrib/mpc/data/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/mpc/data/tests/__init__.py +++ b/pyomo/contrib/mpc/data/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/mpc/data/tests/test_convert.py b/pyomo/contrib/mpc/data/tests/test_convert.py index 0f8a4623e20..dda3583cb00 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-2022 +# 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 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 e90024ef108..8fb92e17534 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/tests/test_get_cuid.py b/pyomo/contrib/mpc/data/tests/test_get_cuid.py index 30ba2b58b1b..66bfb613bcb 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/tests/test_interval_data.py b/pyomo/contrib/mpc/data/tests/test_interval_data.py index 8afe3eb3021..b208c9066f9 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/tests/test_scalar_data.py b/pyomo/contrib/mpc/data/tests/test_scalar_data.py index 110ed749bda..6522242e267 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/data/tests/test_series_data.py b/pyomo/contrib/mpc/data/tests/test_series_data.py index e32559ac074..88b672279f2 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/examples/__init__.py b/pyomo/contrib/mpc/examples/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/mpc/examples/__init__.py +++ b/pyomo/contrib/mpc/examples/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/mpc/examples/cstr/__init__.py b/pyomo/contrib/mpc/examples/cstr/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/mpc/examples/cstr/__init__.py +++ b/pyomo/contrib/mpc/examples/cstr/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/mpc/examples/cstr/model.py b/pyomo/contrib/mpc/examples/cstr/model.py index d794084f122..376e77186dd 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/examples/cstr/run_mpc.py b/pyomo/contrib/mpc/examples/cstr/run_mpc.py index 86ae7e4e47b..588ed7d49fe 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/examples/cstr/run_openloop.py b/pyomo/contrib/mpc/examples/cstr/run_openloop.py index 36ddb990545..66fd0680a01 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/examples/cstr/tests/__init__.py b/pyomo/contrib/mpc/examples/cstr/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/mpc/examples/cstr/tests/__init__.py +++ b/pyomo/contrib/mpc/examples/cstr/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/mpc/examples/cstr/tests/test_mpc.py b/pyomo/contrib/mpc/examples/cstr/tests/test_mpc.py index 741a1533da3..e808b8fc414 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/examples/cstr/tests/test_openloop.py b/pyomo/contrib/mpc/examples/cstr/tests/test_openloop.py index 218865ceabb..c21cb55233e 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/interfaces/__init__.py b/pyomo/contrib/mpc/interfaces/__init__.py index 8e02003f99e..9b70a983e24 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/interfaces/copy_values.py b/pyomo/contrib/mpc/interfaces/copy_values.py index 896656b230d..faf1594f114 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/interfaces/load_data.py b/pyomo/contrib/mpc/interfaces/load_data.py index efa9515901e..3bf3310f115 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-2022 +# 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 @@ -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 35f81af4a7a..916009049d8 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-2022 +# 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 @@ -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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/mpc/interfaces/tests/__init__.py +++ b/pyomo/contrib/mpc/interfaces/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/mpc/interfaces/tests/test_interface.py b/pyomo/contrib/mpc/interfaces/tests/test_interface.py index 65ffc7bb40a..e67e58bf900 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/interfaces/tests/test_var_linker.py b/pyomo/contrib/mpc/interfaces/tests/test_var_linker.py index ceec9fada36..e169af686f3 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/interfaces/var_linker.py b/pyomo/contrib/mpc/interfaces/var_linker.py index fd831c9a2c1..87831379204 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/modeling/__init__.py b/pyomo/contrib/mpc/modeling/__init__.py index 0eb255a9f56..a174bafc944 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/modeling/constraints.py b/pyomo/contrib/mpc/modeling/constraints.py index 6fb6a311afb..e6a1edf648b 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/modeling/cost_expressions.py b/pyomo/contrib/mpc/modeling/cost_expressions.py index 65a376e42d2..9ea0c599d40 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-2022 +# 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 @@ -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 c25efca280a..83161ca67e9 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-2022 +# 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 @@ -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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/mpc/modeling/tests/__init__.py +++ b/pyomo/contrib/mpc/modeling/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/mpc/modeling/tests/test_cost_expressions.py b/pyomo/contrib/mpc/modeling/tests/test_cost_expressions.py index 5db390ffa47..67c474f7722 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/modeling/tests/test_input_constraints.py b/pyomo/contrib/mpc/modeling/tests/test_input_constraints.py index e3ba3bf3760..be9edad37b9 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-2022 +# 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 diff --git a/pyomo/contrib/mpc/modeling/tests/test_terminal.py b/pyomo/contrib/mpc/modeling/tests/test_terminal.py index b835f0b1087..ef89fe24b57 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-2022 +# 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 diff --git a/pyomo/contrib/multistart/__init__.py b/pyomo/contrib/multistart/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/multistart/__init__.py +++ b/pyomo/contrib/multistart/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/multistart/high_conf_stop.py b/pyomo/contrib/multistart/high_conf_stop.py index 96b350557ae..ce24d2dc1fc 100644 --- a/pyomo/contrib/multistart/high_conf_stop.py +++ b/pyomo/contrib/multistart/high_conf_stop.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Utility functions for the high confidence stopping rule. This stopping criterion operates by estimating the amount of missing optima, diff --git a/pyomo/contrib/multistart/multi.py b/pyomo/contrib/multistart/multi.py index 867d47d4951..377ac8182e2 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-2022 +# 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 diff --git a/pyomo/contrib/multistart/plugins.py b/pyomo/contrib/multistart/plugins.py index 297b2f059cc..f094e2f58cc 100644 --- a/pyomo/contrib/multistart/plugins.py +++ b/pyomo/contrib/multistart/plugins.py @@ -1,2 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + def load(): import pyomo.contrib.multistart.multi diff --git a/pyomo/contrib/multistart/reinit.py b/pyomo/contrib/multistart/reinit.py index de10fe3ba8b..2b097bbc898 100644 --- a/pyomo/contrib/multistart/reinit.py +++ b/pyomo/contrib/multistart/reinit.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Helper functions for variable reinitialization.""" import logging diff --git a/pyomo/contrib/multistart/tests/__init__.py b/pyomo/contrib/multistart/tests/__init__.py new file mode 100644 index 00000000000..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/multistart/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/multistart/test_multi.py b/pyomo/contrib/multistart/tests/test_multi.py similarity index 90% rename from pyomo/contrib/multistart/test_multi.py rename to pyomo/contrib/multistart/tests/test_multi.py index 16c8563ae9e..f8103eed3b8 100644 --- a/pyomo/contrib/multistart/test_multi.py +++ b/pyomo/contrib/multistart/tests/test_multi.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 from itertools import product diff --git a/pyomo/contrib/parmest/__init__.py b/pyomo/contrib/parmest/__init__.py index d340885b3fd..78c238834fe 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-2022 +# 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 @@ -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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/examples/reaction_kinetics/__init__.py b/pyomo/contrib/parmest/examples/reaction_kinetics/__init__.py index d93cfd77b3c..a4a626013c4 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-2022 +# 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 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 719a930251c..9a0309811a3 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-2022 +# 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. # ___________________________________________________________________________ -''' +''' 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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/examples/reactor_design/bootstrap_example.py b/pyomo/contrib/parmest/examples/reactor_design/bootstrap_example.py index 16ae9343dfd..598fef32b60 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-2022 +# 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 @@ -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..73129baf5cb --- /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-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 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 507a3ee7582..be08e727be9 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-2022 +# 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 @@ -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 cda50ef3efd..9560981ca5c 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-2022 +# 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 @@ -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 448354f600a..c2bff254077 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-2022 +# 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 @@ -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 10d56c8e457..208981a784a 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-2022 +# 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 @@ -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 43af4fbcb94..d29cbfd4d49 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-2022 +# 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 @@ -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 e86446febd7..a396c1ea721 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-2022 +# 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 @@ -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 ff6c167f68d..4eb191afd6d 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-2022 +# 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 @@ -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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/examples/rooney_biegler/bootstrap_example.py b/pyomo/contrib/parmest/examples/rooney_biegler/bootstrap_example.py index 1c82adb909a..944a01ac95e 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-2022 +# 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 @@ -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 7cd77166a4b..54343993286 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-2022 +# 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 @@ -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 9aa59be6a17..3c9a93100bb 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-2022 +# 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 @@ -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 7a48dcf190d..2dd3ba41007 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-2022 +# 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 @@ -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 0ad65b1eb7a..dd82b50cf7a 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-2022 +# 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 @@ -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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/examples/semibatch/parallel_example.py b/pyomo/contrib/parmest/examples/semibatch/parallel_example.py index ba69b9f2d06..962694385fc 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-2022 +# 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 @@ -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 fc4c9f5c675..7eafdd2b9c3 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-2022 +# 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 @@ -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 071e53236c4..697cb9ac7a5 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-2022 +# 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 @@ -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 6762531a338..3bb576bb551 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-2022 +# 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 @@ -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..4f797d6c89c --- /dev/null +++ b/pyomo/contrib/parmest/experiment.py @@ -0,0 +1,31 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + +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 65efb5cfd64..c57bfb19696 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 82bf893dd06..41e7792570b 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-2022 +# 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 @@ -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,1215 @@ 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()]) + self.estimator_theta_names = list(set(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 +1545,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 58d2d4da722..e887dd2e8be 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-2022 +# 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 @@ -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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/tests/test_examples.py b/pyomo/contrib/parmest/tests/test_examples.py index 67e06130384..3b0c869affa 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-2022 +# 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 @@ -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 c18659e9948..3b4d0224ebe 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index b5c1fe1bfac..52b7cd390e8 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-2022 +# 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 @@ -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 22a851ae32e..af755e34b67 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-2022 +# 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 @@ -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 eb655023b9b..77eca3a13b6 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/tests/test_utils.py b/pyomo/contrib/parmest/tests/test_utils.py index 514c14b1e82..d5e66ab58d5 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-2022 +# 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 @@ -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 1615ab206f7..3c6900aa5d9 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/utils/create_ef.py b/pyomo/contrib/parmest/utils/create_ef.py index 2e6c8541fa1..aaadc7f98b9 100644 --- a/pyomo/contrib/parmest/utils/create_ef.py +++ b/pyomo/contrib/parmest/utils/create_ef.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # This software is distributed under the 3-clause BSD License. # Copied with minor modifications from create_EF in mpisppy/utils/sputils.py # from the mpi-sppy library (https://github.com/Pyomo/mpi-sppy). diff --git a/pyomo/contrib/parmest/utils/ipopt_solver_wrapper.py b/pyomo/contrib/parmest/utils/ipopt_solver_wrapper.py index 7d8289cd181..08388dc5ec1 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/utils/model_utils.py b/pyomo/contrib/parmest/utils/model_utils.py index c3c71dc2d6c..7778ebcc9f1 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-2022 +# 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 @@ -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 35c4bf137bc..45e3260117d 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-2022 +# 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 diff --git a/pyomo/contrib/parmest/utils/scenario_tree.py b/pyomo/contrib/parmest/utils/scenario_tree.py index d46a8f2c5f0..f245e053cad 100644 --- a/pyomo/contrib/parmest/utils/scenario_tree.py +++ b/pyomo/contrib/parmest/utils/scenario_tree.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # This software is distributed under the 3-clause BSD License. # Copied with minor modifications from mpisppy/scenario_tree.py # from the mpi-sppy library (https://github.com/Pyomo/mpi-sppy). @@ -14,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 33cfc6f1606..28aeee74c56 100644 --- a/pyomo/contrib/piecewise/__init__.py +++ b/pyomo/contrib/piecewise/__init__.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.piecewise.piecewise_linear_expression import ( PiecewiseLinearExpression, ) @@ -22,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..283c64cb27f --- /dev/null +++ b/pyomo/contrib/piecewise/ordered_3d_j1_triangulation_data.py @@ -0,0 +1,3119 @@ +# ___________________________________________________________________________ +# +# 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.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 ea1d95b0f51..7197e04cf50 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-2022 +# 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 @@ -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 6d4fa658f88..f4dcdce8db4 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-2022 +# 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 @@ -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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/piecewise/tests/__init__.py +++ b/pyomo/contrib/piecewise/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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..e0b8e878be3 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/common_inner_repn_tests.py @@ -0,0 +1,80 @@ +# ___________________________________________________________________________ +# +# 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 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 c77d7064544..c891c8d502a 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-2022 +# 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 @@ -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 be2811a70a4..e209b1ac879 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-2022 +# 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 @@ -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..f848c610e9d --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_disaggregated_logarithmic.py @@ -0,0 +1,275 @@ +# ___________________________________________________________________________ +# +# 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 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..8ca43df20f3 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_incremental.py @@ -0,0 +1,204 @@ +# ___________________________________________________________________________ +# +# 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 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 a0dbd1cca19..e7505bb92d3 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-2022 +# 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 @@ -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..2024f014f55 --- /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-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 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..b937e09ce8b --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -0,0 +1,760 @@ +# ___________________________________________________________________________ +# +# 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 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 edc5d9d3d95..5ee18875cb9 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-2022 +# 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 diff --git a/pyomo/contrib/piecewise/tests/test_piecewise_linear_function.py b/pyomo/contrib/piecewise/tests/test_piecewise_linear_function.py index e740e5e3384..a49519ae25e 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-2022 +# 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 @@ -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 a2d41c04016..b70281c83ed 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-2022 +# 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 diff --git a/pyomo/contrib/piecewise/tests/test_triangulations.py b/pyomo/contrib/piecewise/tests/test_triangulations.py new file mode 100644 index 00000000000..7217750dfb1 --- /dev/null +++ b/pyomo/contrib/piecewise/tests/test_triangulations.py @@ -0,0 +1,245 @@ +# ___________________________________________________________________________ +# +# 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 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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/piecewise/transform/__init__.py +++ b/pyomo/contrib/piecewise/transform/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/piecewise/transform/convex_combination.py b/pyomo/contrib/piecewise/transform/convex_combination.py index abfeac27129..21b72bd9e5d 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-2022 +# 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 diff --git a/pyomo/contrib/piecewise/transform/disaggregated_convex_combination.py b/pyomo/contrib/piecewise/transform/disaggregated_convex_combination.py index 44059935e09..0117bf1d045 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-2022 +# 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 diff --git a/pyomo/contrib/piecewise/transform/disaggregated_logarithmic.py b/pyomo/contrib/piecewise/transform/disaggregated_logarithmic.py new file mode 100644 index 00000000000..e242f717b07 --- /dev/null +++ b/pyomo/contrib/piecewise/transform/disaggregated_logarithmic.py @@ -0,0 +1,200 @@ +# ___________________________________________________________________________ +# +# 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.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..f7143676b58 --- /dev/null +++ b/pyomo/contrib/piecewise/transform/incremental.py @@ -0,0 +1,188 @@ +# ___________________________________________________________________________ +# +# 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.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 627e41aeae9..e4818c1cbb9 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-2022 +# 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 @@ -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 97dc8e9d2b3..9291afa8862 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-2022 +# 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 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..dbbd8c73bad --- /dev/null +++ b/pyomo/contrib/piecewise/transform/nested_inner_repn.py @@ -0,0 +1,209 @@ +# ___________________________________________________________________________ +# +# 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.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..036307afca6 --- /dev/null +++ b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py @@ -0,0 +1,753 @@ +# ___________________________________________________________________________ +# +# 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 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 04cd01e1246..6c26772fe6a 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-2022 +# 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 @@ -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 ed4902ae6d5..6921ff3a29c 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-2022 +# 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 @@ -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 e3347cf206a..d40bbd8bb34 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-2022 +# 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 @@ -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 b89852530d9..a19507a93fd 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-2022 +# 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 @@ -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..8eb16a87d86 --- /dev/null +++ b/pyomo/contrib/piecewise/triangulations.py @@ -0,0 +1,728 @@ +# ___________________________________________________________________________ +# +# 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 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 dcd444ad312..a4a626013c4 100644 --- a/pyomo/contrib/preprocessing/__init__.py +++ b/pyomo/contrib/preprocessing/__init__.py @@ -1 +1,10 @@ -import pyomo.contrib.preprocessing.plugins +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/preprocessing/plugins/__init__.py b/pyomo/contrib/preprocessing/plugins/__init__.py index 12eee351308..d562e703f08 100644 --- a/pyomo/contrib/preprocessing/plugins/__init__.py +++ b/pyomo/contrib/preprocessing/plugins/__init__.py @@ -1,13 +1,27 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + 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 33eaa731816..8cc17296ac3 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/plugins/constraint_tightener.py b/pyomo/contrib/preprocessing/plugins/constraint_tightener.py index 4c8b28e0319..73851bce618 100644 --- a/pyomo/contrib/preprocessing/plugins/constraint_tightener.py +++ b/pyomo/contrib/preprocessing/plugins/constraint_tightener.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 from pyomo.common import deprecated diff --git a/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py b/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py index a91e0a292f2..59e475e9ba1 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/plugins/detect_fixed_vars.py b/pyomo/contrib/preprocessing/plugins/detect_fixed_vars.py index bafbec7b8bd..89946fe1529 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-2022 +# 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 @@ -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 03e2e11dadb..357a556fcb2 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/plugins/induced_linearity.py b/pyomo/contrib/preprocessing/plugins/induced_linearity.py index 6378c94e44e..ba291070644 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/plugins/init_vars.py b/pyomo/contrib/preprocessing/plugins/init_vars.py index 7469722cf23..a81d898d52c 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/plugins/int_to_binary.py b/pyomo/contrib/preprocessing/plugins/int_to_binary.py index 6ed6c3a9cfa..e1f7f98a81b 100644 --- a/pyomo/contrib/preprocessing/plugins/int_to_binary.py +++ b/pyomo/contrib/preprocessing/plugins/int_to_binary.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Transformation to reformulate integer variables into binary.""" from math import floor, log diff --git a/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py b/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py index 256c94d4b7a..ca2052fa471 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/plugins/strip_bounds.py b/pyomo/contrib/preprocessing/plugins/strip_bounds.py index 51704bc9d58..196de64e405 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/plugins/var_aggregator.py b/pyomo/contrib/preprocessing/plugins/var_aggregator.py index 651c0ecf7e0..3430d29de3a 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-2022 +# 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 @@ -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 @@ -248,6 +255,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 16c6614cb3b..df6867719d2 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/tests/__init__.py b/pyomo/contrib/preprocessing/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/preprocessing/tests/__init__.py +++ b/pyomo/contrib/preprocessing/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 c2b8acd3e49..0df9dd2462d 100644 --- a/pyomo/contrib/preprocessing/tests/test_bounds_to_vars_xfrm.py +++ b/pyomo/contrib/preprocessing/tests/test_bounds_to_vars_xfrm.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests explicit bound to variable bound transformation module.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/tests/test_constraint_tightener.py b/pyomo/contrib/preprocessing/tests/test_constraint_tightener.py index 8f36bee15a1..acb939552f8 100644 --- a/pyomo/contrib/preprocessing/tests/test_constraint_tightener.py +++ b/pyomo/contrib/preprocessing/tests/test_constraint_tightener.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests the Bounds Tightening module.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py b/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py index fa0ca6cfa9a..9e26aab8b77 100644 --- a/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py +++ b/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: utf-8 -*- """Tests deactivation of trivial constraints.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/tests/test_detect_fixed_vars.py b/pyomo/contrib/preprocessing/tests/test_detect_fixed_vars.py index b3c72531f77..a67291dc69f 100644 --- a/pyomo/contrib/preprocessing/tests/test_detect_fixed_vars.py +++ b/pyomo/contrib/preprocessing/tests/test_detect_fixed_vars.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests detection of fixed variables.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/tests/test_equality_propagate.py b/pyomo/contrib/preprocessing/tests/test_equality_propagate.py index b77f5c5f3f5..6b12f464710 100644 --- a/pyomo/contrib/preprocessing/tests/test_equality_propagate.py +++ b/pyomo/contrib/preprocessing/tests/test_equality_propagate.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests the equality set propagation module.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/tests/test_induced_linearity.py b/pyomo/contrib/preprocessing/tests/test_induced_linearity.py index c2c24c33f14..4853cb838df 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/tests/test_init_vars.py b/pyomo/contrib/preprocessing/tests/test_init_vars.py index e52c9fd5cc8..a90d39af91c 100644 --- a/pyomo/contrib/preprocessing/tests/test_init_vars.py +++ b/pyomo/contrib/preprocessing/tests/test_init_vars.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests initialization of uninitialized variables.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/tests/test_int_to_binary.py b/pyomo/contrib/preprocessing/tests/test_int_to_binary.py index bb75a075592..8aa244212ed 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-2022 +# 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 diff --git a/pyomo/contrib/preprocessing/tests/test_strip_bounds.py b/pyomo/contrib/preprocessing/tests/test_strip_bounds.py index a8526c613c4..f36ff4e9f52 100644 --- a/pyomo/contrib/preprocessing/tests/test_strip_bounds.py +++ b/pyomo/contrib/preprocessing/tests/test_strip_bounds.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests stripping of variable bounds.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/tests/test_var_aggregator.py b/pyomo/contrib/preprocessing/tests/test_var_aggregator.py index 1f2c06dd0d1..b0b672b76b0 100644 --- a/pyomo/contrib/preprocessing/tests/test_var_aggregator.py +++ b/pyomo/contrib/preprocessing/tests/test_var_aggregator.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests the variable aggregation module.""" import pyomo.common.unittest as unittest @@ -8,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, @@ -199,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 bec889c7635..41ece8e804f 100644 --- a/pyomo/contrib/preprocessing/tests/test_zero_sum_propagate.py +++ b/pyomo/contrib/preprocessing/tests/test_zero_sum_propagate.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests the zero sum propagation module.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py b/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py index d1b74822747..c5b7477c8f6 100644 --- a/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py +++ b/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Tests detection of zero terms.""" import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/preprocessing/util.py b/pyomo/contrib/preprocessing/util.py index 69182f56656..13f3e5dd18c 100644 --- a/pyomo/contrib/preprocessing/util.py +++ b/pyomo/contrib/preprocessing/util.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 from io import StringIO 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 9364a552999..39ee2197cbf 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/algorithms/__init__.py b/pyomo/contrib/pynumero/algorithms/__init__.py index d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/algorithms/solvers/__init__.py b/pyomo/contrib/pynumero/algorithms/solvers/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/__init__.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py index cedbf430a12..9d627bf9f6d 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-2022 +# 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 @@ -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,22 @@ 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)) + if not objectives: + 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 not objectives: + model.del_component(objective) problem = cyipopt_interface.CyIpoptNLP( nlp, @@ -375,23 +379,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 +417,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 e0bc0170d33..e40580c1161 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/algorithms/solvers/pyomo_ext_cyipopt.py b/pyomo/contrib/pynumero/algorithms/solvers/pyomo_ext_cyipopt.py index b234d2f0890..1e8bc71c365 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-2022 +# 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 @@ -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 53f657c984f..ec1f106b73c 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/algorithms/solvers/square_solver_base.py b/pyomo/contrib/pynumero/algorithms/solvers/square_solver_base.py index c4a33d97611..1be3032c358 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/__init__.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/__init__.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 119c4604f19..88d4df1e17d 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-2022 +# 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 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 7ead30117cb..9578be510a7 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-2022 +# 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 @@ -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 04d4ed321f1..3a13c1a7598 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-2022 +# 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 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 82a37873d5f..5cb0fef91ba 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-2022 +# 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 @@ -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 6636dc3d6e2..33b58f17887 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/asl.py b/pyomo/contrib/pynumero/asl.py index a28741fb230..55ecc7fd0ee 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/build.py b/pyomo/contrib/pynumero/build.py index 08b5c512ab7..bb8443640d5 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/dependencies.py b/pyomo/contrib/pynumero/dependencies.py index d386bbc3dda..d323bd43e84 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-2022 +# 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 @@ -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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/examples/callback/__init__.py b/pyomo/contrib/pynumero/examples/callback/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/pynumero/examples/callback/__init__.py +++ b/pyomo/contrib/pynumero/examples/callback/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/pynumero/examples/callback/cyipopt_callback.py b/pyomo/contrib/pynumero/examples/callback/cyipopt_callback.py index 6bd86c006a1..f66374f6213 100644 --- a/pyomo/contrib/pynumero/examples/callback/cyipopt_callback.py +++ b/pyomo/contrib/pynumero/examples/callback/cyipopt_callback.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo from pyomo.contrib.pynumero.examples.callback.reactor_design import model as m import logging diff --git a/pyomo/contrib/pynumero/examples/callback/cyipopt_callback_halt.py b/pyomo/contrib/pynumero/examples/callback/cyipopt_callback_halt.py index 18fad2bbcd8..9e88f8d4964 100644 --- a/pyomo/contrib/pynumero/examples/callback/cyipopt_callback_halt.py +++ b/pyomo/contrib/pynumero/examples/callback/cyipopt_callback_halt.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo from pyomo.contrib.pynumero.examples.callback.reactor_design import model as m diff --git a/pyomo/contrib/pynumero/examples/callback/cyipopt_functor_callback.py b/pyomo/contrib/pynumero/examples/callback/cyipopt_functor_callback.py index ca452f33c90..4befc816e1b 100644 --- a/pyomo/contrib/pynumero/examples/callback/cyipopt_functor_callback.py +++ b/pyomo/contrib/pynumero/examples/callback/cyipopt_functor_callback.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo from pyomo.contrib.pynumero.examples.callback.reactor_design import model as m from pyomo.common.dependencies import pandas as pd diff --git a/pyomo/contrib/pynumero/examples/callback/reactor_design.py b/pyomo/contrib/pynumero/examples/callback/reactor_design.py index 927b25f9bc9..3d9e19a446e 100644 --- a/pyomo/contrib/pynumero/examples/callback/reactor_design.py +++ b/pyomo/contrib/pynumero/examples/callback/reactor_design.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ from pyomo.core import * diff --git a/pyomo/contrib/pynumero/examples/external_grey_box/__init__.py b/pyomo/contrib/pynumero/examples/external_grey_box/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/pynumero/examples/external_grey_box/__init__.py +++ b/pyomo/contrib/pynumero/examples/external_grey_box/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 e69de29bb2d..a4a626013c4 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 @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 5bf0defbb8d..65bb2c82de8 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,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo import numpy.random as rnd import pyomo.contrib.pynumero.examples.external_grey_box.param_est.models as pm 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 a8b9befb188..c6560b4f9c5 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,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as pyo from pyomo.contrib.pynumero.interfaces.external_grey_box import ( ExternalGreyBoxModel, 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 f27192f9281..142b47f8172 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,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 sys import pyomo.environ as pyo import numpy.random as rnd 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 e69de29bb2d..a4a626013c4 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 @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 9f683b146fe..e6afd8995a2 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-2022 +# 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 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 26d70c7921e..415b58bee54 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-2022 +# 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 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 6e6c997880b..ef8b2783237 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-2022 +# 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 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 69a79425750..bc5a2ca4ce4 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/examples/feasibility.py b/pyomo/contrib/pynumero/examples/feasibility.py index 94baabb7bec..59e4edcc9ec 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/examples/mumps_example.py b/pyomo/contrib/pynumero/examples/mumps_example.py index 938fab99279..588ce58bc12 100644 --- a/pyomo/contrib/pynumero/examples/mumps_example.py +++ b/pyomo/contrib/pynumero/examples/mumps_example.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 numpy as np import scipy.sparse as sp from scipy.linalg import hilbert diff --git a/pyomo/contrib/pynumero/examples/nlp_interface.py b/pyomo/contrib/pynumero/examples/nlp_interface.py index 730e0fbda47..556b8ec0713 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/examples/nlp_interface_2.py b/pyomo/contrib/pynumero/examples/nlp_interface_2.py index ecd63d28c49..4a288a178b1 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/examples/parallel_matvec.py b/pyomo/contrib/pynumero/examples/parallel_matvec.py index 26a2ec9a632..cd77bcfabc9 100644 --- a/pyomo/contrib/pynumero/examples/parallel_matvec.py +++ b/pyomo/contrib/pynumero/examples/parallel_matvec.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 numpy as np from pyomo.common.dependencies import mpi4py from pyomo.contrib.pynumero.sparse.mpi_block_vector import MPIBlockVector diff --git a/pyomo/contrib/pynumero/examples/parallel_vector_ops.py b/pyomo/contrib/pynumero/examples/parallel_vector_ops.py index 4b155ce7493..fe49ff29e59 100644 --- a/pyomo/contrib/pynumero/examples/parallel_vector_ops.py +++ b/pyomo/contrib/pynumero/examples/parallel_vector_ops.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 numpy as np from pyomo.common.dependencies import mpi4py from pyomo.contrib.pynumero.sparse.mpi_block_vector import MPIBlockVector diff --git a/pyomo/contrib/pynumero/examples/sensitivity.py b/pyomo/contrib/pynumero/examples/sensitivity.py index a3927d637b3..0bb0fb3a740 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/examples/sqp.py b/pyomo/contrib/pynumero/examples/sqp.py index 7d321676817..925cab4c20b 100644 --- a/pyomo/contrib/pynumero/examples/sqp.py +++ b/pyomo/contrib/pynumero/examples/sqp.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.pynumero.interfaces.nlp import NLP from pyomo.contrib.pynumero.sparse import BlockVector, BlockMatrix from pyomo.contrib.pynumero.linalg.ma27_interface import MA27 diff --git a/pyomo/contrib/pynumero/examples/tests/__init__.py b/pyomo/contrib/pynumero/examples/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/pynumero/examples/tests/__init__.py +++ b/pyomo/contrib/pynumero/examples/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/pynumero/examples/tests/test_cyipopt_examples.py b/pyomo/contrib/pynumero/examples/tests/test_cyipopt_examples.py index 167b0601f7a..f735e98b026 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-2022 +# 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 @@ -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 5c7993ebbb6..d1494bab557 100644 --- a/pyomo/contrib/pynumero/examples/tests/test_examples.py +++ b/pyomo/contrib/pynumero/examples/tests/test_examples.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.pynumero.dependencies import numpy_available, scipy_available import pyomo.common.unittest as unittest diff --git a/pyomo/contrib/pynumero/examples/tests/test_mpi_examples.py b/pyomo/contrib/pynumero/examples/tests/test_mpi_examples.py index 68fe907a8ef..1ee02bb70ca 100644 --- a/pyomo/contrib/pynumero/examples/tests/test_mpi_examples.py +++ b/pyomo/contrib/pynumero/examples/tests/test_mpi_examples.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest from pyomo.contrib.pynumero.dependencies import ( diff --git a/pyomo/contrib/pynumero/exceptions.py b/pyomo/contrib/pynumero/exceptions.py index dc2167d75d2..6b46dd2d9a7 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/interfaces/__init__.py b/pyomo/contrib/pynumero/interfaces/__init__.py index debe453e175..e2de0dd25cc 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/interfaces/ampl_nlp.py b/pyomo/contrib/pynumero/interfaces/ampl_nlp.py index f5bd56696cf..30258b3e685 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-2022 +# 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 @@ -27,10 +27,8 @@ from pyomo.common.deprecation import deprecated from pyomo.contrib.pynumero.interfaces.nlp import ExtendedNLP -__all__ = ['AslNLP', 'AmplNLP'] - -# ToDo: need to add support for modifying bounds. +# TODO: need to add support for modifying bounds. # support for changing variable bounds seems possible. # support for changing inequality bounds would require more work. (this is less frequent?) # TODO: check performance impacts of caching - memory and computational time. diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index fc9c45c6d1a..98916e11b48 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-2022 +# 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 @@ -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 642fd3bf310..68e652575cc 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-2022 +# 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 @@ -18,7 +18,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 @@ -316,7 +316,7 @@ def evaluate_jacobian_outputs(self): # -class ExternalGreyBoxBlockData(_BlockData): +class ExternalGreyBoxBlockData(BlockData): def set_external_model(self, external_grey_box_model, inputs=None, outputs=None): """ Parameters @@ -424,7 +424,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 d0e6c21fa64..bae3e0b8159 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/interfaces/nlp.py b/pyomo/contrib/pynumero/interfaces/nlp.py index 95c05f06a61..4acdf1122b6 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-2022 +# 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 @@ -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 @@ -50,9 +55,8 @@ .. rubric:: Contents """ -import abc -__all__ = ['NLP'] +import abc class NLP(object, metaclass=abc.ABCMeta): diff --git a/pyomo/contrib/pynumero/interfaces/nlp_projections.py b/pyomo/contrib/pynumero/interfaces/nlp_projections.py index 68cb0eef15f..4be3cd28dd5 100644 --- a/pyomo/contrib/pynumero/interfaces/nlp_projections.py +++ b/pyomo/contrib/pynumero/interfaces/nlp_projections.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.pynumero.interfaces.nlp import NLP, ExtendedNLP import numpy as np import scipy.sparse as sp diff --git a/pyomo/contrib/pynumero/interfaces/pyomo_grey_box_nlp.py b/pyomo/contrib/pynumero/interfaces/pyomo_grey_box_nlp.py index 945e9a05f51..66cf99ea862 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-2022 +# 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 @@ -31,6 +31,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__ @@ -226,13 +227,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): diff --git a/pyomo/contrib/pynumero/interfaces/pyomo_nlp.py b/pyomo/contrib/pynumero/interfaces/pyomo_nlp.py index 8017c642854..725435619ad 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-2022 +# 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 @@ -22,15 +22,14 @@ 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 -__all__ = ['PyomoNLP'] - - # TODO: There are todos in the code below class PyomoNLP(AslNLP): def __init__(self, pyomo_model, nl_file_options=None): @@ -95,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) @@ -306,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 @@ -615,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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/interfaces/tests/compare_utils.py b/pyomo/contrib/pynumero/interfaces/tests/compare_utils.py index d30cfb8f56a..8296ea2d1af 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-2022 +# 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 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 e65e9a7eb5c..b81731b209e 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/external_grey_box_models.py +++ b/pyomo/contrib/pynumero/interfaces/tests/external_grey_box_models.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.contrib.pynumero.dependencies import ( numpy as np, numpy_available, diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py index f28b7b9b549..b8cfa4058bf 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-2022 +# 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 @@ -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 ddd56afb5b4..5b8a8d688dd 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-2022 +# 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 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 88a4024aeeb..9ca0aef4187 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-2022 +# 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 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 58e08a409f0..1ea17b5e223 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-2022 +# 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 @@ -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 7e250b9194e..1807f24afdd 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-2022 +# 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 @@ -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 390d0b6fe63..5e7ea023166 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-2022 +# 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 @@ -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 38d44473a67..a291ef1151a 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-2022 +# 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 @@ -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 7bf693b1eb6..2fada5f679a 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-2022 +# 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 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 52536dd9c06..053c9aba4ea 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-2022 +# 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 @@ -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, @@ -2495,24 +2495,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) diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_utils.py b/pyomo/contrib/pynumero/interfaces/tests/test_utils.py index dafe89ca2c7..474d26836b9 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/interfaces/utils.py b/pyomo/contrib/pynumero/interfaces/utils.py index c7bd04eb002..2aa30fc5946 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/intrinsic.py b/pyomo/contrib/pynumero/intrinsic.py index 5a2dccb64e7..34054e7ffa2 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-2022 +# 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 @@ -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 09bccd7449b..c1d9ff38825 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/linalg/base.py b/pyomo/contrib/pynumero/linalg/base.py index 2b4eeaef451..21565b052a5 100644 --- a/pyomo/contrib/pynumero/linalg/base.py +++ b/pyomo/contrib/pynumero/linalg/base.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 abc import ABCMeta, abstractmethod import enum from typing import Optional, Union, Tuple diff --git a/pyomo/contrib/pynumero/linalg/ma27.py b/pyomo/contrib/pynumero/linalg/ma27.py index 21c137e837b..40a7d0e1064 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/linalg/ma27_interface.py b/pyomo/contrib/pynumero/linalg/ma27_interface.py index 1ae02fe3290..42ac6e73154 100644 --- a/pyomo/contrib/pynumero/linalg/ma27_interface.py +++ b/pyomo/contrib/pynumero/linalg/ma27_interface.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 .base import DirectLinearSolverInterface, LinearSolverStatus, LinearSolverResults from .ma27 import MA27Interface from scipy.sparse import isspmatrix_coo, tril, spmatrix diff --git a/pyomo/contrib/pynumero/linalg/ma57.py b/pyomo/contrib/pynumero/linalg/ma57.py index 1be6c8abcf7..baaa3f34100 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/linalg/ma57_interface.py b/pyomo/contrib/pynumero/linalg/ma57_interface.py index ef80ac653cf..93004406612 100644 --- a/pyomo/contrib/pynumero/linalg/ma57_interface.py +++ b/pyomo/contrib/pynumero/linalg/ma57_interface.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 .base import DirectLinearSolverInterface, LinearSolverStatus, LinearSolverResults from .ma57 import MA57Interface from scipy.sparse import isspmatrix_coo, tril, spmatrix diff --git a/pyomo/contrib/pynumero/linalg/mumps_interface.py b/pyomo/contrib/pynumero/linalg/mumps_interface.py index baab5562716..8735994f16c 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/linalg/scipy_interface.py b/pyomo/contrib/pynumero/linalg/scipy_interface.py index 819e22ff1aa..025cc539245 100644 --- a/pyomo/contrib/pynumero/linalg/scipy_interface.py +++ b/pyomo/contrib/pynumero/linalg/scipy_interface.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 .base import ( DirectLinearSolverInterface, LinearSolverStatus, diff --git a/pyomo/contrib/pynumero/linalg/tests/__init__.py b/pyomo/contrib/pynumero/linalg/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/pynumero/linalg/tests/__init__.py +++ b/pyomo/contrib/pynumero/linalg/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/pynumero/linalg/tests/test_linear_solvers.py b/pyomo/contrib/pynumero/linalg/tests/test_linear_solvers.py index 8d19127dde6..d2fa955434c 100644 --- a/pyomo/contrib/pynumero/linalg/tests/test_linear_solvers.py +++ b/pyomo/contrib/pynumero/linalg/tests/test_linear_solvers.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import unittest from pyomo.contrib.pynumero.dependencies import numpy_available, scipy_available diff --git a/pyomo/contrib/pynumero/linalg/tests/test_ma27.py b/pyomo/contrib/pynumero/linalg/tests/test_ma27.py index 5a02871306a..979be6f747a 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/linalg/tests/test_ma57.py b/pyomo/contrib/pynumero/linalg/tests/test_ma57.py index 86dbbd3ca50..de245172f96 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/linalg/tests/test_mumps_interface.py b/pyomo/contrib/pynumero/linalg/tests/test_mumps_interface.py index 9b0aba96be1..8e5b924fb65 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/linalg/utils.py b/pyomo/contrib/pynumero/linalg/utils.py index 2b7a9e99142..adec9ae5f35 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/plugins.py b/pyomo/contrib/pynumero/plugins.py index 06bb0a5a059..c6890cbbb4d 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/sparse/__init__.py b/pyomo/contrib/pynumero/sparse/__init__.py index e72d1cd7b2d..ee8196566db 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/sparse/base_block.py b/pyomo/contrib/pynumero/sparse/base_block.py index 4f2ae385a7e..1baa10e1f73 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-2022 +# 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 @@ -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 97e090fec4c..02ad584928b 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-2022 +# 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 @@ -31,8 +31,6 @@ import logging import warnings -__all__ = ['BlockMatrix', 'NotFullyDefinedBlockMatrixError'] - logger = logging.getLogger(__name__) diff --git a/pyomo/contrib/pynumero/sparse/block_vector.py b/pyomo/contrib/pynumero/sparse/block_vector.py index 00733a71752..a9a8875ead1 100644 --- a/pyomo/contrib/pynumero/sparse/block_vector.py +++ b/pyomo/contrib/pynumero/sparse/block_vector.py @@ -1,33 +1,194 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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. # ___________________________________________________________________________ -""" -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 - -__all__ = ['BlockVector', 'NotFullyDefinedBlockVectorError'] +from .base_block import ( + BaseBlockVector, + vec_unary_ufuncs, + vec_binary_ufuncs, + vec_associative_reductions, +) class NotFullyDefinedBlockVectorError(Exception): @@ -40,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, @@ -109,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) @@ -230,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) @@ -248,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): @@ -267,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) @@ -310,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): @@ -586,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): """ @@ -727,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) @@ -1594,86 +1665,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 ee045464dec..41495c83565 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-2022 +# 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 @@ -32,8 +32,6 @@ from scipy.sparse import coo_matrix import operator -__all__ = ['MPIBlockMatrix'] - def assert_block_structure(mat: MPIBlockMatrix): if mat.has_undefined_row_sizes() or mat.has_undefined_col_sizes(): @@ -150,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 0f57f0eb41e..85acee1051d 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-2022 +# 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 @@ -11,14 +11,17 @@ 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 import operator -__all__ = ['MPIBlockVector'] - def assert_block_structure(vec): if vec.has_none: @@ -26,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 @@ -138,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( @@ -213,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: @@ -370,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]) @@ -1442,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'): @@ -1449,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 d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/sparse/tests/test_block_matrix.py b/pyomo/contrib/pynumero/sparse/tests/test_block_matrix.py index 7402881a285..48c1d3dc77e 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-2022 +# 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 diff --git a/pyomo/contrib/pynumero/sparse/tests/test_block_vector.py b/pyomo/contrib/pynumero/sparse/tests/test_block_vector.py index 780a8bc2609..397dac04113 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-2022 +# 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 @@ -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 0768442c2c4..ef0a5142849 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-2022 +# 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 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 1415636c50d..6c8c649136f 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-2022 +# 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 @@ -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 cd37b7543a2..1754bb47432 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-2022 +# 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 @@ -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 26053a9611b..805955f7671 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-2022 + * 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 diff --git a/pyomo/contrib/pynumero/src/AmplInterface.hpp b/pyomo/contrib/pynumero/src/AmplInterface.hpp index 259cf88d895..bedc6d4f669 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-2022 + * 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 diff --git a/pyomo/contrib/pynumero/src/AssertUtils.hpp b/pyomo/contrib/pynumero/src/AssertUtils.hpp index ba2e5dc887f..061442eb6e9 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-2022 + * 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 diff --git a/pyomo/contrib/pynumero/src/ma27Interface.cpp b/pyomo/contrib/pynumero/src/ma27Interface.cpp index 624c7edd6f3..4816e1274e3 100644 --- a/pyomo/contrib/pynumero/src/ma27Interface.cpp +++ b/pyomo/contrib/pynumero/src/ma27Interface.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include #include #include diff --git a/pyomo/contrib/pynumero/src/ma57Interface.cpp b/pyomo/contrib/pynumero/src/ma57Interface.cpp index 99b98ef6215..fa9cf4e6811 100644 --- a/pyomo/contrib/pynumero/src/ma57Interface.cpp +++ b/pyomo/contrib/pynumero/src/ma57Interface.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include #include #include diff --git a/pyomo/contrib/pynumero/src/tests/simple_test.cpp b/pyomo/contrib/pynumero/src/tests/simple_test.cpp index 4edbbb67a35..9f39fbbd8ff 100644 --- a/pyomo/contrib/pynumero/src/tests/simple_test.cpp +++ b/pyomo/contrib/pynumero/src/tests/simple_test.cpp @@ -1,3 +1,15 @@ +/**___________________________________________________________________________ + * + * 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. + * ___________________________________________________________________________ +**/ + #include #include "AmplInterface.hpp" diff --git a/pyomo/contrib/pynumero/tests/__init__.py b/pyomo/contrib/pynumero/tests/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/pynumero/tests/__init__.py +++ b/pyomo/contrib/pynumero/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/pyros/CHANGELOG.txt b/pyomo/contrib/pyros/CHANGELOG.txt index 7d4678f0ba3..d5e13b72e57 100644 --- a/pyomo/contrib/pyros/CHANGELOG.txt +++ b/pyomo/contrib/pyros/CHANGELOG.txt @@ -2,6 +2,86 @@ PyROS CHANGELOG =============== + +------------------------------------------------------------------------------- +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 +------------------------------------------------------------------------------- +- Update argument resolution and validation routines of `PyROS.solve()` +- Use methods of `common.config` for docstring of `PyROS.solve()` + + ------------------------------------------------------------------------------- PyROS 1.2.9 15 Dec 2023 ------------------------------------------------------------------------------- @@ -14,6 +94,7 @@ PyROS 1.2.9 15 Dec 2023 - Refactor DR polishing routine; initialize auxiliary variables to values they are meant to represent + ------------------------------------------------------------------------------- PyROS 1.2.8 12 Oct 2023 ------------------------------------------------------------------------------- diff --git a/pyomo/contrib/pyros/__init__.py b/pyomo/contrib/pyros/__init__.py index aeb92eb13fd..54f3d1623c6 100644 --- a/pyomo/contrib/pyros/__init__.py +++ b/pyomo/contrib/pyros/__init__.py @@ -1,5 +1,16 @@ +# ___________________________________________________________________________ +# +# 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.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 new file mode 100644 index 00000000000..fb1e2001e8b --- /dev/null +++ b/pyomo/contrib/pyros/config.py @@ -0,0 +1,874 @@ +""" +Interfaces for managing PyROS solver options. +""" + +import logging + +from pyomo.common.config import ( + ConfigDict, + ConfigValue, + In, + IsInstance, + NonNegativeFloat, + InEnum, + 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.opt import SolverFactory +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() + + +def logger_domain(obj): + """ + 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) + + +logger_domain.domain_name = "None, str or logging.Logger" + + +def positive_int_or_minus_one(obj): + """ + 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 + + +positive_int_or_minus_one.domain_name = "positive int or -1" + + +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. + + 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 uncertain_param_data_validator(uncertain_obj): + """ + Validator for component data object specified as an + uncertain parameter. + + Parameters + ---------- + uncertain_obj : ParamData or VarData + Object on which to perform checks. + + Raises + ------ + ValueError + If `uncertain_obj` is a VarData object + that is not fixed explicitly via VarData.fixed + or implicitly via bounds. + """ + 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 is_fixed_var: + raise ValueError( + f"{type(uncertain_obj).__name__} object with name " + f"{uncertain_obj.name!r} is not fixed." + ) + + +class InputDataStandardizer(object): + """ + Domain validator for an object that is castable to + a list of Pyomo component data objects. + + Parameters + ---------- + 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 object + entries in final list to which argument is cast, + False otherwise. + + Attributes + ---------- + 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__( + self, + ctype, + cdatatype, + ctype_validator=None, + cdatatype_validator=None, + allow_repeats=False, + ): + """Initialize self (see class docstring).""" + self.ctype = ctype + self.cdatatype = cdatatype + self.ctype_validator = ctype_validator + self.cdatatype_validator = cdatatype_validator + self.allow_repeats = allow_repeats + + def __call__(self, obj, from_iterable=None, allow_repeats=None): + """ + Cast object to a flat list of Pyomo component data type + entries. + + Parameters + ---------- + obj : object + Object to be cast. + from_iterable : Iterable or None, optional + Iterable from which `obj` obtained, if any. + allow_repeats : bool or None, optional + True if list can contain repeated entries, + False otherwise. + + Returns + ------- + list of ComponentData + Each entry is an instance of ``self.cdatatype``. + """ + 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.""" + 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): + """ + Exception type for failure to cast an object to a Pyomo solver. + """ + + +class SolverResolvable(object): + """ + Callable for casting an object (such as a str) + to a Pyomo solver. + + Parameters + ---------- + require_available : bool, optional + True if `available()` method of a standardized solver + object obtained through `self` must return `True`, + False otherwise. + solver_desc : str, optional + Descriptor for the solver obtained through `self`, + such as 'local solver' + or 'global solver'. This argument is used + for constructing error/exception messages. + + Attributes + ---------- + require_available + solver_desc + """ + + def __init__(self, require_available=True, solver_desc="solver"): + """Initialize self (see class docstring).""" + self.require_available = require_available + self.solver_desc = solver_desc + + @staticmethod + def is_solver_type(obj): + """ + Return True if object is considered a Pyomo solver, + False otherwise. + + An object is considered a Pyomo solver provided that + it has callable attributes named 'solve' and + 'available'. + """ + return callable(getattr(obj, "solve", None)) and callable( + getattr(obj, "available", None) + ) + + def __call__(self, obj, require_available=None, solver_desc=None): + """ + Cast object to a Pyomo solver. + + If `obj` is a string, then ``SolverFactory(obj.lower())`` + is returned. If `obj` is a Pyomo solver type, then + `obj` is returned. + + Parameters + ---------- + obj : object + Object to be cast to Pyomo solver type. + require_available : bool or None, optional + True if `available()` method of the resolved solver + object must return True, False otherwise. + If `None` is passed, then ``self.require_available`` + is used. + solver_desc : str or None, optional + Brief description of the solver, such as 'local solver' + or 'backup global solver'. This argument is used + for constructing error/exception messages. + If `None` is passed, then ``self.solver_desc`` + is used. + + Returns + ------- + Solver + Pyomo solver. + + Raises + ------ + SolverNotResolvable + If `obj` cannot be cast to a Pyomo solver because + it is neither a str nor a Pyomo solver type. + ApplicationError + In event that solver is not available, the + method `available(exception_flag=True)` of the + solver to which `obj` is cast should raise an + exception of this type. The present method + will also emit a more detailed error message + through the default PyROS logger. + """ + # resort to defaults if necessary + if require_available is None: + require_available = self.require_available + if solver_desc is None: + solver_desc = self.solver_desc + + # perform casting + if isinstance(obj, str): + solver = SolverFactory(obj.lower()) + elif self.is_solver_type(obj): + solver = obj + else: + raise SolverNotResolvable( + f"Cannot cast object `{obj!r}` to a Pyomo optimizer for use as " + f"{solver_desc}, as the object is neither a str nor a " + f"Pyomo Solver type (got type {type(obj).__name__})." + ) + + # availability check, if so desired + if require_available: + try: + solver.available(exception_flag=True) + except ApplicationError: + default_pyros_solver_logger.exception( + f"Output of `available()` method for {solver_desc} " + f"with repr {solver!r} resolved from object {obj} " + "is not `True`. " + "Check solver and any required dependencies " + "have been set up properly." + ) + raise + + return solver + + def domain_name(self): + """Return str briefly describing domain encompassed by self.""" + return "str or Solver" + + +class SolverIterable(object): + """ + Callable for casting an iterable (such as a list of strs) + to a list of Pyomo solvers. + + Parameters + ---------- + require_available : bool, optional + True if `available()` method of a standardized solver + object obtained through `self` must return `True`, + False otherwise. + filter_by_availability : bool, optional + True to remove standardized solvers for which `available()` + does not return True, False otherwise. + solver_desc : str, optional + Descriptor for the solver obtained through `self`, + such as 'backup local solver' + or 'backup global solver'. + """ + + def __init__( + self, require_available=True, filter_by_availability=True, solver_desc="solver" + ): + """Initialize self (see class docstring).""" + self.require_available = require_available + self.filter_by_availability = filter_by_availability + self.solver_desc = solver_desc + + def __call__( + self, obj, require_available=None, filter_by_availability=None, solver_desc=None + ): + """ + Cast iterable object to a list of Pyomo solver objects. + + Parameters + ---------- + obj : str, Solver, or Iterable of str/Solver + Object of interest. + require_available : bool or None, optional + True if `available()` method of each solver + object must return True, False otherwise. + If `None` is passed, then ``self.require_available`` + is used. + solver_desc : str or None, optional + Descriptor for the solver, such as 'backup local solver' + or 'backup global solver'. This argument is used + for constructing error/exception messages. + If `None` is passed, then ``self.solver_desc`` + is used. + + Returns + ------- + solvers : list of solver type + List of solver objects to which obj is cast. + + Raises + ------ + TypeError + If `obj` is a str. + """ + if require_available is None: + require_available = self.require_available + if filter_by_availability is None: + filter_by_availability = self.filter_by_availability + if solver_desc is None: + solver_desc = self.solver_desc + + solver_resolve_func = SolverResolvable() + + if isinstance(obj, str) or solver_resolve_func.is_solver_type(obj): + # single solver resolvable is cast to singleton list. + # perform explicit check for str, otherwise this method + # would attempt to resolve each character. + obj_as_list = [obj] + else: + obj_as_list = list(obj) + + solvers = [] + for idx, val in enumerate(obj_as_list): + solver_desc_str = f"{solver_desc} " f"(index {idx})" + opt = solver_resolve_func( + obj=val, + require_available=require_available, + solver_desc=solver_desc_str, + ) + if filter_by_availability and not opt.available(exception_flag=False): + default_pyros_solver_logger.warning( + f"Output of `available()` method for solver object {opt} " + f"resolved from object {val} of sequence {obj_as_list} " + f"to be used as {self.solver_desc} " + "is not `True`. " + "Removing from list of standardized solvers." + ) + else: + solvers.append(opt) + + return solvers + + def domain_name(self): + """Return str briefly describing domain encompassed by self.""" + return "str, solver type, or Iterable of str/solver type" + + +def pyros_config(): + CONFIG = ConfigDict('PyROS') + + # ================================================ + # === Options common to all solvers + # ================================================ + CONFIG.declare( + 'time_limit', + ConfigValue( + default=None, + domain=NonNegativeFloat, + doc=( + """ + Wall time limit for the execution of the PyROS solver + in seconds (including time spent by subsolvers). + If `None` is provided, then no time limit is enforced. + """ + ), + ), + ) + CONFIG.declare( + 'keepfiles', + ConfigValue( + default=False, + domain=bool, + description=( + """ + Export subproblems with a non-acceptable termination status + for debugging purposes. + If True is provided, then the argument + `subproblem_file_directory` must also be specified. + """ + ), + ), + ) + CONFIG.declare( + 'tee', + ConfigValue( + default=False, + domain=bool, + description="Output subordinate solver logs for all subproblems.", + ), + ) + CONFIG.declare( + 'load_solution', + ConfigValue( + default=True, + domain=bool, + description=( + """ + Load final solution(s) found by PyROS to the deterministic + model provided. + """ + ), + ), + ) + 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 + # ================================================ + CONFIG.declare( + "first_stage_variables", + ConfigValue( + default=[], + domain=InputDataStandardizer(Var, VarData, allow_repeats=False), + description="First-stage (or design) variables.", + visibility=1, + ), + ) + CONFIG.declare( + "second_stage_variables", + ConfigValue( + default=[], + domain=InputDataStandardizer(Var, VarData, allow_repeats=False), + description="Second-stage (or control) variables.", + visibility=1, + ), + ) + CONFIG.declare( + "uncertain_params", + ConfigValue( + default=[], + domain=InputDataStandardizer( + ctype=(Param, Var), + cdatatype=(ParamData, VarData), + ctype_validator=uncertain_param_validator, + cdatatype_validator=uncertain_param_data_validator, + allow_repeats=False, + ), + description=( + """ + Uncertain model parameters. + Of every constituent `Param` object, + the `mutable` attribute must be set to True. + All constituent `Var`/`VarData` objects should be + fixed. + """ + ), + visibility=1, + ), + ) + CONFIG.declare( + "uncertainty_set", + ConfigValue( + default=None, + domain=IsInstance(UncertaintySet), + description=( + """ + Uncertainty set against which the + final solution(s) returned by PyROS should be certified + to be robust. + """ + ), + visibility=1, + ), + ) + CONFIG.declare( + "local_solver", + ConfigValue( + default=None, + domain=SolverResolvable(solver_desc="local solver", require_available=True), + description="Subordinate local NLP solver.", + visibility=1, + ), + ) + CONFIG.declare( + "global_solver", + ConfigValue( + default=None, + domain=SolverResolvable( + solver_desc="global solver", require_available=True + ), + description="Subordinate global NLP solver.", + visibility=1, + ), + ) + # ================================================ + # === Optional User Inputs + # ================================================ + CONFIG.declare( + "objective_focus", + ConfigValue( + default=ObjectiveType.nominal, + domain=InEnum(ObjectiveType), + description=( + """ + Choice of objective focus to optimize in the master problems. + Choices are: `ObjectiveType.worst_case`, + `ObjectiveType.nominal`. + """ + ), + doc=( + """ + Objective focus for the master problems: + + - `ObjectiveType.nominal`: + Optimize the objective function subject to the nominal + uncertain parameter realization. + - `ObjectiveType.worst_case`: + Optimize the objective function subject to the worst-case + uncertain parameter realization. + + By default, `ObjectiveType.nominal` is chosen. + + A worst-case objective focus is required for certification + of robust optimality of the final solution(s) returned + by PyROS. + If a nominal objective focus is chosen, then only robust + feasibility is guaranteed. + """ + ), + ), + ) + CONFIG.declare( + "nominal_uncertain_param_vals", + ConfigValue( + default=[], + domain=list, + doc=( + """ + Nominal uncertain parameter realization. + Entries should be provided in an order consistent with the + entries of the argument `uncertain_params`. + If an empty list is provided, then the values of the `Param` + objects specified through `uncertain_params` are chosen. + """ + ), + ), + ) + CONFIG.declare( + "decision_rule_order", + ConfigValue( + default=0, + domain=In([0, 1, 2]), + description=( + """ + Order (or degree) of the polynomial decision rule functions + used for approximating the adjustability of the second stage + variables with respect to the uncertain parameters. + """ + ), + doc=( + """ + Order (or degree) of the polynomial decision rule functions + for approximating the adjustability of the second stage + variables with respect to the uncertain parameters. + + Choices are: + + - 0: static recourse + - 1: affine recourse + - 2: quadratic recourse + """ + ), + ), + ) + CONFIG.declare( + "solve_master_globally", + ConfigValue( + default=False, + domain=bool, + doc=( + """ + True to solve all master problems with the subordinate + global solver, False to solve all master problems with + the subordinate local solver. + Along with a worst-case objective focus + (see argument `objective_focus`), + solving the master problems to global optimality is required + for certification + of robust optimality of the final solution(s) returned + by PyROS. Otherwise, only robust feasibility is guaranteed. + """ + ), + ), + ) + CONFIG.declare( + "max_iter", + ConfigValue( + default=-1, + domain=positive_int_or_minus_one, + description=( + """ + Iteration limit. If -1 is provided, then no iteration + limit is enforced. + """ + ), + ), + ) + CONFIG.declare( + "robust_feasibility_tolerance", + ConfigValue( + default=1e-4, + domain=NonNegativeFloat, + description=( + """ + Relative tolerance for assessing maximal inequality + constraint violations during the GRCS separation step. + """ + ), + ), + ) + CONFIG.declare( + "separation_priority_order", + ConfigValue( + default={}, + domain=dict, + doc=( + """ + Mapping from model inequality constraint names + to positive integers specifying the priorities + of their corresponding separation subproblems. + A higher integer value indicates a higher priority. + Constraints not referenced in the `dict` assume + a priority of 0. + Separation subproblems are solved in order of decreasing + priority. + """ + ), + ), + ) + CONFIG.declare( + "progress_logger", + ConfigValue( + default=default_pyros_solver_logger, + domain=logger_domain, + doc=( + """ + Logger (or name thereof) used for reporting PyROS solver + progress. If `None` or a `str` is provided, then + ``progress_logger`` + is cast to ``logging.getLogger(progress_logger)``. + In the default case, `progress_logger` is set to + a :class:`pyomo.contrib.pyros.util.PreformattedLogger` + object of level ``logging.INFO``. + """ + ), + ), + ) + CONFIG.declare( + "backup_local_solvers", + ConfigValue( + default=[], + domain=SolverIterable( + solver_desc="backup local solver", + require_available=False, + filter_by_availability=True, + ), + doc=( + """ + Additional subordinate local NLP optimizers to invoke + in the event the primary local NLP optimizer fails + to solve a subproblem to an acceptable termination condition. + """ + ), + ), + ) + CONFIG.declare( + "backup_global_solvers", + ConfigValue( + default=[], + domain=SolverIterable( + solver_desc="backup global solver", + require_available=False, + filter_by_availability=True, + ), + doc=( + """ + Additional subordinate global NLP optimizers to invoke + in the event the primary global NLP optimizer fails + to solve a subproblem to an acceptable termination condition. + """ + ), + ), + ) + CONFIG.declare( + "subproblem_file_directory", + ConfigValue( + default=None, + domain=Path(), + description=( + """ + Directory to which to export subproblems not successfully + solved to an acceptable termination condition. + In the event ``keepfiles=True`` is specified, a str or + path-like referring to an existing directory must be + provided. + """ + ), + ), + ) + + # ================================================ + # === Advanced Options + # ================================================ + CONFIG.declare( + "bypass_local_separation", + ConfigValue( + default=False, + domain=bool, + description=( + """ + This is an advanced option. + Solve all separation subproblems with the subordinate global + solver(s) only. + This option is useful for expediting PyROS + in the event that the subordinate global optimizer(s) provided + can quickly solve separation subproblems to global optimality. + """ + ), + ), + ) + CONFIG.declare( + "bypass_global_separation", + ConfigValue( + default=False, + domain=bool, + doc=( + """ + This is an advanced option. + Solve all separation subproblems with the subordinate local + solver(s) only. + If `True` is chosen, then robustness of the final solution(s) + returned by PyROS is not guaranteed, and a warning will + be issued at termination. + This option is useful for expediting PyROS + in the event that the subordinate global optimizer provided + cannot tractably solve separation subproblems to global + optimality. + """ + ), + ), + ) + CONFIG.declare( + "p_robustness", + ConfigValue( + default={}, + domain=dict, + doc=( + """ + This is an advanced option. + Add p-robustness constraints to all master subproblems. + If an empty dict is provided, then p-robustness constraints + are not added. + Otherwise, the dict must map a `str` of value ``'rho'`` + to a non-negative `float`. PyROS automatically + specifies ``1 + p_robustness['rho']`` + as an upper bound for the ratio of the + objective function value under any PyROS-sampled uncertain + parameter realization to the objective function under + the nominal parameter realization. + """ + ), + visibility=1, + ), + ) + + return CONFIG diff --git a/pyomo/contrib/pyros/master_problem_methods.py b/pyomo/contrib/pyros/master_problem_methods.py index e2ce74a493e..f66b452a795 100644 --- a/pyomo/contrib/pyros/master_problem_methods.py +++ b/pyomo/contrib/pyros/master_problem_methods.py @@ -1,162 +1,215 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ -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_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 +def construct_initial_master_problem(model_data): """ - 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): - """ - 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 master_model - return iter_state_var_map +def add_scenario_block_to_master_problem( + master_model, + scenario_idx, + param_realization, + from_block, + clone_first_stage_components, +): + """ + Add new scenario block to the master model. -def construct_master_feasibility_problem(model_data, config): + Parameters + ---------- + 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 new_blk.first_stage.equality_cons.values(): + con.deactivate() + + +def construct_master_feasibility_problem(master_data): """ - Construct a slack-variable based master feasibility model. - Initialize all model variables appropriately, and scale slack variables - as well. + 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 ---------- - model_data : MasterProblemData + 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 @@ -167,7 +220,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 @@ -176,26 +228,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 - - # 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 + return slack_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 @@ -204,20 +242,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)}" @@ -228,31 +265,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, @@ -274,7 +298,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" @@ -282,23 +306,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 ------- @@ -313,112 +334,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 - ) - ) - - # deactivate master problem objective - polishing_model.obj.deactivate() + nominal_eff_var_partitioning = nominal_polishing_block.effective_var_partitioning - 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) ) @@ -426,16 +485,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 ------- @@ -445,10 +502,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 @@ -464,28 +521,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 @@ -503,7 +550,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" @@ -515,102 +562,30 @@ 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. @@ -624,35 +599,31 @@ def get_master_dr_degree(model_data, config): 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: + 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(master_data.config.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 ---- @@ -662,186 +633,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. @@ -857,13 +826,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 = ( @@ -872,24 +841,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}" ) @@ -897,44 +872,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 829184fc70c..d43c8ef5690 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-2022 +# 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 @@ -10,46 +10,30 @@ # ___________________________________________________________________________ # pyros.py: Generalized Robust Cutting-Set Algorithm for Pyomo +from datetime import datetime, timezone import logging -from textwrap import indent, dedent, wrap -from pyomo.common.collections import Bunch, ComponentSet -from pyomo.common.config import ConfigDict, ConfigValue, In, NonNegativeFloat -from pyomo.core.base.block import Block + +from pyomo.common.config import document_kwargs_from_configdict from pyomo.core.expr import value -from pyomo.core.base.var import Var, _VarData -from pyomo.core.base.param import Param, _ParamData -from pyomo.core.base.objective import Objective, maximize -from pyomo.contrib.pyros.util import a_logger, time_code, get_main_elapsed_time -from pyomo.common.modeling import unique_component_name from pyomo.opt import SolverFactory + +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 ( - model_is_valid, - recast_to_min_obj, - add_decision_rule_constraints, - add_decision_rule_variables, load_final_solution, pyrosTerminationCondition, - ValidEnum, - ObjectiveType, - validate_uncertainty_set, - identify_objective_functions, - validate_kwarg_inputs, - transform_to_standard_form, - turn_bounds_to_constraints, - replace_uncertain_bounds_with_constraints, + validate_pyros_inputs, + 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.contrib.pyros.uncertainty_sets import uncertainty_sets -from pyomo.core.base import Constraint -from datetime import datetime - -__version__ = "1.2.9" +__version__ = "1.3.5" default_pyros_solver_logger = setup_pyros_logger() @@ -77,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" @@ -85,590 +76,6 @@ def _get_pyomo_version_info(): return {"Pyomo version": pyomo_version, "Commit hash": commit_hash} -def NonNegIntOrMinusOne(obj): - ''' - if obj is a non-negative int, return the non-negative int - if obj is -1, return -1 - else, error - ''' - ans = int(obj) - if ans != float(obj) or (ans < 0 and ans != -1): - raise ValueError("Expected non-negative int, but received %s" % (obj,)) - return ans - - -def PositiveIntOrMinusOne(obj): - ''' - if obj is a positive int, return the int - if obj is -1, return -1 - else, error - ''' - ans = int(obj) - if ans != float(obj) or (ans <= 0 and ans != -1): - raise ValueError("Expected positive int, but received %s" % (obj,)) - return ans - - -class SolverResolvable(object): - def __call__(self, obj): - ''' - if obj is a string, return the Solver object for that solver name - if obj is a Solver object, return a copy of the Solver - if obj is a list, and each element of list is solver resolvable, return list of solvers - ''' - if isinstance(obj, str): - return SolverFactory(obj.lower()) - elif callable(getattr(obj, "solve", None)): - return obj - elif isinstance(obj, list): - return [self(o) for o in obj] - else: - raise ValueError( - "Expected a Pyomo solver or string object, " - "instead received {1}".format(obj.__class__.__name__) - ) - - -class InputDataStandardizer(object): - def __init__(self, ctype, cdatatype): - self.ctype = ctype - self.cdatatype = cdatatype - - def __call__(self, obj): - if isinstance(obj, self.ctype): - return list(obj.values()) - if isinstance(obj, self.cdatatype): - return [obj] - ans = [] - for item in obj: - ans.extend(self.__call__(item)) - for _ in ans: - assert isinstance(_, self.cdatatype) - return ans - - -class PyROSConfigValue(ConfigValue): - """ - Subclass of ``common.collections.ConfigValue``, - with a few attributes added to facilitate documentation - of the PyROS solver. - An instance of this class is used for storing and - documenting an argument to the PyROS solver. - - Attributes - ---------- - is_optional : bool - Argument is optional. - document_default : bool, optional - Document the default value of the argument - in any docstring generated from this instance, - or a `ConfigDict` object containing this instance. - dtype_spec_str : None or str, optional - String documenting valid types for this argument. - If `None` is provided, then this string is automatically - determined based on the `domain` argument to the - constructor. - - NOTES - ----- - Cleaner way to access protected attributes - (particularly _doc, _description) inherited from ConfigValue? - - """ - - def __init__( - self, - default=None, - domain=None, - description=None, - doc=None, - visibility=0, - is_optional=True, - document_default=True, - dtype_spec_str=None, - ): - """Initialize self (see class docstring).""" - - # initialize base class attributes - super(self.__class__, self).__init__( - default=default, - domain=domain, - description=description, - doc=doc, - visibility=visibility, - ) - - self.is_optional = is_optional - self.document_default = document_default - - if dtype_spec_str is None: - self.dtype_spec_str = self.domain_name() - # except AttributeError: - # self.dtype_spec_str = repr(self._domain) - else: - self.dtype_spec_str = dtype_spec_str - - -def pyros_config(): - CONFIG = ConfigDict('PyROS') - - # ================================================ - # === Options common to all solvers - # ================================================ - CONFIG.declare( - 'time_limit', - PyROSConfigValue( - default=None, - domain=NonNegativeFloat, - doc=( - """ - Wall time limit for the execution of the PyROS solver - in seconds (including time spent by subsolvers). - If `None` is provided, then no time limit is enforced. - """ - ), - is_optional=True, - document_default=False, - dtype_spec_str="None or NonNegativeFloat", - ), - ) - CONFIG.declare( - 'keepfiles', - PyROSConfigValue( - default=False, - domain=bool, - description=( - """ - Export subproblems with a non-acceptable termination status - for debugging purposes. - If True is provided, then the argument `subproblem_file_directory` - must also be specified. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - CONFIG.declare( - 'tee', - PyROSConfigValue( - default=False, - domain=bool, - description="Output subordinate solver logs for all subproblems.", - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - CONFIG.declare( - 'load_solution', - PyROSConfigValue( - default=True, - domain=bool, - description=( - """ - Load final solution(s) found by PyROS to the deterministic model - provided. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - - # ================================================ - # === Required User Inputs - # ================================================ - CONFIG.declare( - "first_stage_variables", - PyROSConfigValue( - default=[], - domain=InputDataStandardizer(Var, _VarData), - description="First-stage (or design) variables.", - is_optional=False, - dtype_spec_str="list of Var", - ), - ) - CONFIG.declare( - "second_stage_variables", - PyROSConfigValue( - default=[], - domain=InputDataStandardizer(Var, _VarData), - description="Second-stage (or control) variables.", - is_optional=False, - dtype_spec_str="list of Var", - ), - ) - CONFIG.declare( - "uncertain_params", - PyROSConfigValue( - default=[], - domain=InputDataStandardizer(Param, _ParamData), - description=( - """ - Uncertain model parameters. - The `mutable` attribute for all uncertain parameter - objects should be set to True. - """ - ), - is_optional=False, - dtype_spec_str="list of Param", - ), - ) - CONFIG.declare( - "uncertainty_set", - PyROSConfigValue( - default=None, - domain=uncertainty_sets, - description=( - """ - Uncertainty set against which the - final solution(s) returned by PyROS should be certified - to be robust. - """ - ), - is_optional=False, - dtype_spec_str="UncertaintySet", - ), - ) - CONFIG.declare( - "local_solver", - PyROSConfigValue( - default=None, - domain=SolverResolvable(), - description="Subordinate local NLP solver.", - is_optional=False, - dtype_spec_str="Solver", - ), - ) - CONFIG.declare( - "global_solver", - PyROSConfigValue( - default=None, - domain=SolverResolvable(), - description="Subordinate global NLP solver.", - is_optional=False, - dtype_spec_str="Solver", - ), - ) - # ================================================ - # === Optional User Inputs - # ================================================ - CONFIG.declare( - "objective_focus", - PyROSConfigValue( - default=ObjectiveType.nominal, - domain=ValidEnum(ObjectiveType), - description=( - """ - Choice of objective focus to optimize in the master problems. - Choices are: `ObjectiveType.worst_case`, - `ObjectiveType.nominal`. - """ - ), - doc=( - """ - Objective focus for the master problems: - - - `ObjectiveType.nominal`: - Optimize the objective function subject to the nominal - uncertain parameter realization. - - `ObjectiveType.worst_case`: - Optimize the objective function subject to the worst-case - uncertain parameter realization. - - By default, `ObjectiveType.nominal` is chosen. - - A worst-case objective focus is required for certification - of robust optimality of the final solution(s) returned - by PyROS. - If a nominal objective focus is chosen, then only robust - feasibility is guaranteed. - """ - ), - is_optional=True, - document_default=False, - dtype_spec_str="ObjectiveType", - ), - ) - CONFIG.declare( - "nominal_uncertain_param_vals", - PyROSConfigValue( - default=[], - domain=list, - doc=( - """ - Nominal uncertain parameter realization. - Entries should be provided in an order consistent with the - entries of the argument `uncertain_params`. - If an empty list is provided, then the values of the `Param` - objects specified through `uncertain_params` are chosen. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str="list of float", - ), - ) - CONFIG.declare( - "decision_rule_order", - PyROSConfigValue( - default=0, - domain=In([0, 1, 2]), - description=( - """ - Order (or degree) of the polynomial decision rule functions used - for approximating the adjustability of the second stage - variables with respect to the uncertain parameters. - """ - ), - doc=( - """ - Order (or degree) of the polynomial decision rule functions used - for approximating the adjustability of the second stage - variables with respect to the uncertain parameters. - - Choices are: - - - 0: static recourse - - 1: affine recourse - - 2: quadratic recourse - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - CONFIG.declare( - "solve_master_globally", - PyROSConfigValue( - default=False, - domain=bool, - doc=( - """ - True to solve all master problems with the subordinate - global solver, False to solve all master problems with - the subordinate local solver. - Along with a worst-case objective focus - (see argument `objective_focus`), - solving the master problems to global optimality is required - for certification - of robust optimality of the final solution(s) returned - by PyROS. Otherwise, only robust feasibility is guaranteed. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - CONFIG.declare( - "max_iter", - PyROSConfigValue( - default=-1, - domain=PositiveIntOrMinusOne, - description=( - """ - Iteration limit. If -1 is provided, then no iteration - limit is enforced. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str="int", - ), - ) - CONFIG.declare( - "robust_feasibility_tolerance", - PyROSConfigValue( - default=1e-4, - domain=NonNegativeFloat, - description=( - """ - Relative tolerance for assessing maximal inequality - constraint violations during the GRCS separation step. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - CONFIG.declare( - "separation_priority_order", - PyROSConfigValue( - default={}, - domain=dict, - doc=( - """ - Mapping from model inequality constraint names - to positive integers specifying the priorities - of their corresponding separation subproblems. - A higher integer value indicates a higher priority. - Constraints not referenced in the `dict` assume - a priority of 0. - Separation subproblems are solved in order of decreasing - priority. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - CONFIG.declare( - "progress_logger", - PyROSConfigValue( - default=default_pyros_solver_logger, - domain=a_logger, - doc=( - """ - Logger (or name thereof) used for reporting PyROS solver - progress. If a `str` is specified, then ``progress_logger`` - is cast to ``logging.getLogger(progress_logger)``. - In the default case, `progress_logger` is set to - a :class:`pyomo.contrib.pyros.util.PreformattedLogger` - object of level ``logging.INFO``. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str="str or logging.Logger", - ), - ) - CONFIG.declare( - "backup_local_solvers", - PyROSConfigValue( - default=[], - domain=SolverResolvable(), - doc=( - """ - Additional subordinate local NLP optimizers to invoke - in the event the primary local NLP optimizer fails - to solve a subproblem to an acceptable termination condition. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str="list of Solver", - ), - ) - CONFIG.declare( - "backup_global_solvers", - PyROSConfigValue( - default=[], - domain=SolverResolvable(), - doc=( - """ - Additional subordinate global NLP optimizers to invoke - in the event the primary global NLP optimizer fails - to solve a subproblem to an acceptable termination condition. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str="list of Solver", - ), - ) - CONFIG.declare( - "subproblem_file_directory", - PyROSConfigValue( - default=None, - domain=str, - description=( - """ - Directory to which to export subproblems not successfully - solved to an acceptable termination condition. - In the event ``keepfiles=True`` is specified, a str or - path-like referring to an existing directory must be - provided. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str="None, str, or path-like", - ), - ) - - # ================================================ - # === Advanced Options - # ================================================ - CONFIG.declare( - "bypass_local_separation", - PyROSConfigValue( - default=False, - domain=bool, - description=( - """ - This is an advanced option. - Solve all separation subproblems with the subordinate global - solver(s) only. - This option is useful for expediting PyROS - in the event that the subordinate global optimizer(s) provided - can quickly solve separation subproblems to global optimality. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - CONFIG.declare( - "bypass_global_separation", - PyROSConfigValue( - default=False, - domain=bool, - doc=( - """ - This is an advanced option. - Solve all separation subproblems with the subordinate local - solver(s) only. - If `True` is chosen, then robustness of the final solution(s) - returned by PyROS is not guaranteed, and a warning will - be issued at termination. - This option is useful for expediting PyROS - in the event that the subordinate global optimizer provided - cannot tractably solve separation subproblems to global - optimality. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - CONFIG.declare( - "p_robustness", - PyROSConfigValue( - default={}, - domain=dict, - doc=( - """ - This is an advanced option. - Add p-robustness constraints to all master subproblems. - If an empty dict is provided, then p-robustness constraints - are not added. - Otherwise, the dict must map a `str` of value ``'rho'`` - to a non-negative `float`. PyROS automatically - specifies ``1 + p_robustness['rho']`` - as an upper bound for the ratio of the - objective function value under any PyROS-sampled uncertain - parameter realization to the objective function under - the nominal parameter realization. - """ - ), - is_optional=True, - document_default=True, - dtype_spec_str=None, - ), - ) - - return CONFIG - - @SolverFactory.register( "pyros", doc="Robust optimization (RO) solver implementing " @@ -733,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, ) @@ -836,6 +244,48 @@ def _log_config(self, logger, config, exclude_options=None, **log_kwargs): logger.log(msg=f" {key}={val!r}", **log_kwargs) logger.log(msg="-" * self._LOG_LINE_LENGTH, **log_kwargs) + def _resolve_and_validate_pyros_args(self, model, **kwds): + """ + Resolve and validate arguments to ``self.solve()``. + + Parameters + ---------- + model : ConcreteModel + Deterministic model object passed to ``self.solve()``. + **kwds : dict + All other arguments to ``self.solve()``. + + Returns + ------- + config : ConfigDict + Standardized arguments. + user_var_partitioning : util.VarPartitioning + User-based partitioning of the in-scope model variables. + + Note + ---- + This method can be broken down into three steps: + + 1. Cast arguments to ConfigDict. Argument-wise + validation is performed automatically. + Note that arguments specified directly take + precedence over arguments specified indirectly + through direct argument 'options'. + 2. Inter-argument validation. + """ + config = self.CONFIG(kwds.pop("options", {})) + config = config(kwds) + user_var_partitioning = validate_pyros_inputs(model, config) + + return config, user_var_partitioning + + @document_kwargs_from_configdict( + config=CONFIG, + section="Keyword Arguments", + indent_spacing=4, + width=72, + visibility=0, + ) def solve( self, model, @@ -853,21 +303,27 @@ def solve( ---------- model: ConcreteModel The deterministic model. - first_stage_variables: list of Var + first_stage_variables: VarData, Var, or iterable of VarData/Var First-stage model variables (or design variables). - second_stage_variables: list of Var + second_stage_variables: VarData, Var, or iterable of VarData/Var Second-stage model variables (or control variables). - uncertain_params: list of Param + uncertain_params: (iterable of) Param, Var, ParamData, or VarData Uncertain model parameters. - The `mutable` attribute for every 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. - local_solver: Solver + local_solver: str or solver type Subordinate local NLP solver. - global_solver: Solver + If a `str` is passed, then the `str` is cast to + ``SolverFactory(local_solver)``. + global_solver: str or solver type Subordinate global NLP solver. + If a `str` is passed, then the `str` is cast to + ``SolverFactory(global_solver)``. Returns ------- @@ -875,148 +331,53 @@ def solve( Summary of PyROS termination outcome. """ - - # === Add the explicit arguments to the config - config = self.CONFIG(kwds.pop('options', {})) - config.first_stage_variables = first_stage_variables - config.second_stage_variables = second_stage_variables - config.uncertain_params = uncertain_params - config.uncertainty_set = uncertainty_set - config.local_solver = local_solver - config.global_solver = global_solver - - dev_options = kwds.pop('dev_options', {}) - config.set_value(kwds) - config.set_value(dev_options) - - model = model - - # === Validate kwarg inputs - validate_kwarg_inputs(model, config) - - # === Validate ability of grcs RO solver to handle this model - if not model_is_valid(model): - raise AttributeError( - "This model structure is not currently handled by the ROSolver." - ) - - # === Define nominal point if not specified - if len(config.nominal_uncertain_param_vals) == 0: - config.nominal_uncertain_param_vals = list( - p.value for p in config.uncertain_params - ) - elif len(config.nominal_uncertain_param_vals) != len(config.uncertain_params): - raise AttributeError( - "The nominal_uncertain_param_vals list must be the same length" - "as the uncertain_params list" - ) - - # === 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.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 - - # === Validate uncertainty set happens here, requires util block for Cardinality and FactorModel sets - validate_uncertainty_set(config=config) - - # === 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 - - # === Every non-fixed variable that is neither first-stage - # nor second-stage is taken to be a state variable - fsv = ComponentSet(model_data.working_model.util.first_stage_variables) - ssv = ComponentSet(model_data.working_model.util.second_stage_variables) - sv = ComponentSet() - model_data.working_model.util.state_vars = [] - for v in model_data.working_model.component_data_objects(Var): - if not v.fixed and v not in fsv | ssv | sv: - model_data.working_model.util.state_vars.append(v) - sv.add(v) - - # Bounds on second stage variables and state variables are separation objectives, - # they are brought in this was as explicit constraints - 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( @@ -1024,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 = ( @@ -1085,131 +443,3 @@ def solve( config.progress_logger.info("=" * self._LOG_LINE_LENGTH) return return_soln - - -def _generate_filtered_docstring(): - """ - Add Numpy-style 'Keyword arguments' section to `PyROS.solve()` - docstring. - """ - cfg = PyROS.CONFIG() - - # mandatory args already documented - exclude_args = [ - "first_stage_variables", - "second_stage_variables", - "uncertain_params", - "uncertainty_set", - "local_solver", - "global_solver", - ] - - indent_by = 8 - width = 72 - before = PyROS.solve.__doc__ - section_name = "Keyword Arguments" - - indent_str = ' ' * indent_by - wrap_width = width - indent_by - cfg = pyros_config() - - arg_docs = [] - - def wrap_doc(doc, indent_by, width): - """ - Wrap a string, accounting for paragraph - breaks ('\n\n') and bullet points (paragraphs - which, when dedented, are such that each line - starts with '- ' or ' '). - """ - paragraphs = doc.split("\n\n") - wrapped_pars = [] - for par in paragraphs: - lines = dedent(par).split("\n") - has_bullets = all( - line.startswith("- ") or line.startswith(" ") - for line in lines - if line != "" - ) - if has_bullets: - # obtain strings of each bullet point - # (dedented, bullet dash and bullet indent removed) - bullet_groups = [] - new_group = False - group = "" - for line in lines: - new_group = line.startswith("- ") - if new_group: - bullet_groups.append(group) - group = "" - new_line = line[2:] - group += f"{new_line}\n" - if group != "": - # ensure last bullet not skipped - bullet_groups.append(group) - - # first entry is just ''; remove - bullet_groups = bullet_groups[1:] - - # wrap each bullet point, then add bullet - # and indents as necessary - wrapped_groups = [] - for group in bullet_groups: - wrapped_groups.append( - "\n".join( - f"{'- ' if idx == 0 else ' '}{line}" - for idx, line in enumerate( - wrap(group, width - 2 - indent_by) - ) - ) - ) - - # now combine bullets into single 'paragraph' - wrapped_pars.append( - indent("\n".join(wrapped_groups), prefix=' ' * indent_by) - ) - else: - wrapped_pars.append( - indent( - "\n".join(wrap(dedent(par), width=width - indent_by)), - prefix=' ' * indent_by, - ) - ) - - return "\n\n".join(wrapped_pars) - - section_header = indent(f"{section_name}\n" + "-" * len(section_name), indent_str) - for key, itm in cfg._data.items(): - if key in exclude_args: - continue - arg_name = key - arg_dtype = itm.dtype_spec_str - - if itm.is_optional: - if itm.document_default: - optional_str = f", default={repr(itm._default)}" - else: - optional_str = ", optional" - else: - optional_str = "" - - arg_header = f"{indent_str}{arg_name} : {arg_dtype}{optional_str}" - - # dedented_doc_str = dedent(itm.doc).replace("\n", ' ').strip() - if itm._doc is not None: - raw_arg_desc = itm._doc - else: - raw_arg_desc = itm._description - - arg_description = wrap_doc( - raw_arg_desc, width=wrap_width, indent_by=indent_by + 4 - ) - - arg_docs.append(f"{arg_header}\n{arg_description}") - - kwargs_section_doc = "\n".join([section_header] + arg_docs) - - return f"{before}\n{kwargs_section_doc}\n" - - -PyROS.solve.__doc__ = _generate_filtered_docstring() diff --git a/pyomo/contrib/pyros/pyros_algorithm_methods.py b/pyomo/contrib/pyros/pyros_algorithm_methods.py index 4ae033b9498..ba2ba2362c6 100644 --- a/pyomo/contrib/pyros/pyros_algorithm_methods.py +++ b/pyomo/contrib/pyros/pyros_algorithm_methods.py @@ -1,596 +1,174 @@ -''' -Methods for the execution of the grcs 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 +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +""" +Methods for execution of the main PyROS cutting set algorithm. +""" + +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, @@ -603,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] @@ -788,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, @@ -811,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 @@ -859,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 @@ -898,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 b9659f044f4..e5357902e33 100644 --- a/pyomo/contrib/pyros/separation_problem_methods.py +++ b/pyomo/contrib/pyros/separation_problem_methods.py @@ -1,251 +1,205 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ -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 math +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_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, + PARAM_IS_CERTAIN_ABS_TOL, + PARAM_IS_CERTAIN_REL_TOL, ) -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) + + # preprocess uncertain parameters which have been fixed by bounds + # in order to simplify the separation problems + for param_var, nomval in zip(param_var_list, config.nominal_uncertain_param_vals): + bounds_close = math.isclose( + a=param_var.lb, + b=param_var.ub, + rel_tol=PARAM_IS_CERTAIN_REL_TOL, + abs_tol=PARAM_IS_CERTAIN_ABS_TOL, ) - 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 bounds_close: + param_var.fix(nomval) - 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 separation_model.first_stage.equality_cons.values(): + 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?" ) @@ -253,41 +207,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) @@ -297,7 +253,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( @@ -307,37 +263,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 @@ -347,7 +304,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 @@ -361,7 +320,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 @@ -372,106 +333,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 @@ -480,42 +418,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() @@ -524,24 +468,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 ---------- @@ -549,15 +499,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 @@ -565,37 +509,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}") - - final_qual_str = f" ({', '.join(qual_strs)})" if qual_strs else "" + qual_str = f" (mapped to objective {separation_obj.name!r})" - return f"{con.name!r}{final_qual_str}" + return f"{con.index()!r}{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. @@ -605,30 +538,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: @@ -637,21 +571,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 = ( @@ -664,7 +599,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 @@ -678,78 +613,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 ) @@ -758,13 +699,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}" @@ -774,17 +715,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 @@ -796,12 +739,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 @@ -813,41 +757,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 = ( @@ -857,127 +806,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 @@ -985,17 +883,10 @@ 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=})" ) @@ -1004,25 +895,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. @@ -1031,32 +927,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, @@ -1065,58 +958,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}." + f"separation of second-stage inequality constraint {con_name_repr} " + f"in iteration {separation_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, - ) - 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 @@ -1127,13 +999,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 @@ -1142,9 +1012,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() @@ -1152,9 +1028,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}") @@ -1172,15 +1048,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 = ( @@ -1189,8 +1065,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()]}.)" @@ -1204,7 +1080,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 @@ -1213,27 +1093,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 @@ -1242,23 +1122,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) @@ -1266,33 +1146,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 40a52757bae..476197c3868 100644 --- a/pyomo/contrib/pyros/solve_data.py +++ b/pyomo/contrib/pyros/solve_data.py @@ -1,5 +1,16 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ -Objects to contain all model data and solve results for the ROSolver +Containers for PyROS subproblem solve results. """ @@ -22,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. """ @@ -72,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: @@ -136,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. @@ -167,6 +178,7 @@ class SeparationSolveCallResults: results_list scaled_violations violating_param_realizations + auxiliary_param_values variable_values found_violation time_out @@ -180,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, @@ -191,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 @@ -217,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: """ @@ -257,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): @@ -291,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: @@ -334,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 @@ -379,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 @@ -411,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 @@ -437,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 @@ -456,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: """ @@ -495,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): @@ -589,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): @@ -625,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. """ @@ -649,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): @@ -743,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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/pyros/tests/__init__.py +++ b/pyomo/contrib/pyros/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/pyros/tests/test_config.py b/pyomo/contrib/pyros/tests/test_config.py new file mode 100644 index 00000000000..73bf9145b3a --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_config.py @@ -0,0 +1,769 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +""" +Test objects for construction of PyROS ConfigDict. +""" + +import logging +import pyomo.common.unittest as unittest + +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.contrib.pyros.config import ( + InputDataStandardizer, + uncertain_param_validator, + uncertain_param_data_validator, + logger_domain, + SolverNotResolvable, + positive_int_or_minus_one, + pyros_config, + SolverIterable, + SolverResolvable, +) +from pyomo.contrib.pyros.util import ObjectiveType +from pyomo.opt import SolverFactory, SolverResults + + +class TestInputDataStandardizer(unittest.TestCase): + """ + Test standardizer method for Pyomo component-type inputs. + """ + + def test_single_component_data(self): + """ + Test standardizer works for single component + data-type entry. + """ + mdl = ConcreteModel() + mdl.v = Var([0, 1]) + + standardizer_func = InputDataStandardizer(Var, VarData) + + standardizer_input = mdl.v[0] + standardizer_output = standardizer_func(standardizer_input) + + 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), + 1, + msg="Length of standardizer output is not as expected.", + ) + self.assertIs( + standardizer_output[0], + mdl.v[0], + msg=( + f"Entry {standardizer_output[0]} (id {id(standardizer_output[0])}) " + "is not identical to " + f"input component data object {mdl.v[0]} " + f"(id {id(mdl.v[0])})" + ), + ) + + def test_standardizer_indexed_component(self): + """ + Test component standardizer works on indexed component. + """ + mdl = ConcreteModel() + mdl.v = Var([0, 1]) + + standardizer_func = InputDataStandardizer(Var, VarData) + + standardizer_input = mdl.v + standardizer_output = standardizer_func(standardizer_input) + + 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), + 2, + msg="Length of standardizer output is not as expected.", + ) + enum_zip = enumerate(zip(standardizer_input.values(), 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_multiple_components(self): + """ + Test standardizer works on sequence of components. + """ + mdl = ConcreteModel() + mdl.v = Var([0, 1]) + mdl.x = Var(["a", "b"]) + + standardizer_func = InputDataStandardizer(Var, VarData) + + standardizer_input = [mdl.v[0], mdl.x] + standardizer_output = standardizer_func(standardizer_input) + expected_standardizer_output = [mdl.v[0], mdl.x["a"], mdl.x["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_invalid_duplicates(self): + """ + Test standardizer raises exception if input contains duplicates + and duplicates are not allowed. + """ + mdl = ConcreteModel() + mdl.v = Var([0, 1]) + mdl.x = Var(["a", "b"]) + + standardizer_func = InputDataStandardizer(Var, VarData, allow_repeats=False) + + exc_str = r"Standardized.*list.*contains duplicate entries\." + with self.assertRaisesRegex(ValueError, exc_str): + standardizer_func([mdl.x, mdl.v, mdl.x]) + + def test_standardizer_invalid_type(self): + """ + Test standardizer raises exception as expected + when input is of invalid type. + """ + standardizer_func = InputDataStandardizer(Var, VarData) + + exc_str = r"Input object .*is not of valid component type.*" + with self.assertRaisesRegex(TypeError, exc_str): + standardizer_func(2) + + def test_standardizer_iterable_with_invalid_type(self): + """ + Test standardizer raises exception as expected + when input is an iterable with entries of invalid type. + """ + mdl = ConcreteModel() + mdl.v = Var([0, 1]) + 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): + standardizer_func([mdl.v, 2]) + + 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) + + exc_str = r"Input object .*is not of valid component type.*" + with self.assertRaisesRegex(TypeError, exc_str): + standardizer_func("abcd") + + def test_standardizer_invalid_uninitialized_params(self): + """ + Test standardizer raises exception when Param with + uninitialized entries passed. + """ + standardizer_func = InputDataStandardizer( + ctype=Param, cdatatype=ParamData, ctype_validator=uncertain_param_validator + ) + + mdl = ConcreteModel() + mdl.p = Param([0, 1]) + + exc_str = r"Length of .*does not match that of.*index set" + with self.assertRaisesRegex(ValueError, exc_str): + standardizer_func(mdl.p) + + def test_standardizer_invalid_immutable_params(self): + """ + Test standardizer raises exception when immutable + Param object(s) passed. + """ + standardizer_func = InputDataStandardizer( + ctype=Param, cdatatype=ParamData, ctype_validator=uncertain_param_validator + ) + + mdl = ConcreteModel() + mdl.p = Param([0, 1], initialize=1) + + exc_str = r"Param object with name .*immutable" + 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 + of valid mutable Param objects. + """ + mdl = ConcreteModel() + mdl.p1 = Param([0, 1], initialize=0, mutable=True) + mdl.p2 = Param(["a", "b"], initialize=1, mutable=True) + + standardizer_func = InputDataStandardizer( + ctype=Param, cdatatype=ParamData, ctype_validator=uncertain_param_validator + ) + + standardizer_input = [mdl.p1[0], mdl.p2] + standardizer_output = standardizer_func(standardizer_input) + expected_standardizer_output = [mdl.p1[0], mdl.p2["a"], mdl.p2["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_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" + + +class AvailableSolver: + """ + Perennially available placeholder solver. + """ + + def available(self, exception_flag=False): + """ + Check solver available. + """ + return True + + def solve(self, model, **kwds): + """ + Return SolverResults object with 'unknown' termination + condition. Model remains unchanged. + """ + return SolverResults() + + +class UnavailableSolver: + def available(self, exception_flag=True): + if exception_flag: + raise ApplicationError(f"Solver {self.__class__} not available") + return False + + def solve(self, model, *args, **kwargs): + return SolverResults() + + +class TestSolverResolvable(unittest.TestCase): + """ + Test PyROS standardizer for solver-type objects. + """ + + def setUp(self): + SolverFactory.register(AVAILABLE_SOLVER_TYPE_NAME)(AvailableSolver) + + def tearDown(self): + SolverFactory.unregister(AVAILABLE_SOLVER_TYPE_NAME) + + def test_solver_resolvable_valid_str(self): + """ + Test solver resolvable class is valid for string + type. + """ + solver_str = AVAILABLE_SOLVER_TYPE_NAME + standardizer_func = SolverResolvable() + solver = standardizer_func(solver_str) + expected_solver_type = type(SolverFactory(solver_str)) + + self.assertIsInstance( + solver, + type(SolverFactory(solver_str)), + msg=( + "SolverResolvable object should be of type " + f"{expected_solver_type.__name__}, " + f"but got object of type {solver.__class__.__name__}." + ), + ) + + def test_solver_resolvable_valid_solver_type(self): + """ + Test solver resolvable class is valid for string + type. + """ + solver = SolverFactory(AVAILABLE_SOLVER_TYPE_NAME) + standardizer_func = SolverResolvable() + standardized_solver = standardizer_func(solver) + + self.assertIs( + solver, + standardized_solver, + msg=( + f"Test solver {solver} and standardized solver " + f"{standardized_solver} are not identical." + ), + ) + + def test_solver_resolvable_invalid_type(self): + """ + Test solver resolvable object raises expected + exception when invalid entry is provided. + """ + invalid_object = 2 + standardizer_func = SolverResolvable(solver_desc="local solver") + + exc_str = ( + r"Cannot cast object `2` to a Pyomo optimizer.*" + r"local solver.*got type int.*" + ) + with self.assertRaisesRegex(SolverNotResolvable, exc_str): + standardizer_func(invalid_object) + + def test_solver_resolvable_unavailable_solver(self): + """ + Test solver standardizer fails in event solver is + unavailable. + """ + unavailable_solver = UnavailableSolver() + standardizer_func = SolverResolvable( + solver_desc="local solver", require_available=True + ) + + exc_str = r"Solver.*UnavailableSolver.*not available" + with self.assertRaisesRegex(ApplicationError, exc_str): + with LoggingIntercept(level=logging.ERROR) as LOG: + standardizer_func(unavailable_solver) + + error_msgs = LOG.getvalue()[:-1] + self.assertRegex( + error_msgs, r"Output of `available\(\)` method.*local solver.*" + ) + + +class TestSolverIterable(unittest.TestCase): + """ + Test standardizer method for iterable of solvers, + used to validate `backup_local_solvers` and `backup_global_solvers` + arguments. + """ + + def setUp(self): + SolverFactory.register(AVAILABLE_SOLVER_TYPE_NAME)(AvailableSolver) + + def tearDown(self): + SolverFactory.unregister(AVAILABLE_SOLVER_TYPE_NAME) + + def test_solver_iterable_valid_list(self): + """ + Test solver type standardizer works for list of valid + objects castable to solver. + """ + solver_list = [ + AVAILABLE_SOLVER_TYPE_NAME, + SolverFactory(AVAILABLE_SOLVER_TYPE_NAME), + ] + expected_solver_types = [AvailableSolver] * 2 + standardizer_func = SolverIterable() + + standardized_solver_list = standardizer_func(solver_list) + + # check list of solver types returned + for idx, standardized_solver in enumerate(standardized_solver_list): + self.assertIsInstance( + standardized_solver, + expected_solver_types[idx], + msg=( + f"Standardized solver {standardized_solver} " + f"(index {idx}) expected to be of type " + f"{expected_solver_types[idx].__name__}, " + f"but is of type {standardized_solver.__class__.__name__}" + ), + ) + + # second entry of standardized solver list should be the same + # object as that of input list, since the input solver is a Pyomo + # solver type + self.assertIs( + standardized_solver_list[1], + solver_list[1], + msg=( + f"Test solver {solver_list[1]} and standardized solver " + f"{standardized_solver_list[1]} should be identical." + ), + ) + + def test_solver_iterable_valid_str(self): + """ + Test SolverIterable raises exception when str passed. + """ + solver_str = AVAILABLE_SOLVER_TYPE_NAME + standardizer_func = SolverIterable() + + solver_list = standardizer_func(solver_str) + self.assertEqual( + len(solver_list), 1, "Standardized solver list is not of expected length" + ) + + def test_solver_iterable_unavailable_solver(self): + """ + Test SolverIterable addresses unavailable solvers appropriately. + """ + solvers = (AvailableSolver(), UnavailableSolver()) + + standardizer_func = SolverIterable( + require_available=True, + filter_by_availability=True, + solver_desc="example solver list", + ) + exc_str = r"Solver.*UnavailableSolver.* not available" + with self.assertRaisesRegex(ApplicationError, exc_str): + standardizer_func(solvers) + with self.assertRaisesRegex(ApplicationError, exc_str): + standardizer_func(solvers, filter_by_availability=False) + + standardized_solver_list = standardizer_func( + solvers, filter_by_availability=True, require_available=False + ) + self.assertEqual( + len(standardized_solver_list), + 1, + msg=("Length of filtered standardized solver list not as " "expected."), + ) + self.assertIs( + standardized_solver_list[0], + solvers[0], + msg="Entry of filtered standardized solver list not as expected.", + ) + + standardized_solver_list = standardizer_func( + solvers, filter_by_availability=False, require_available=False + ) + self.assertEqual( + len(standardized_solver_list), + 2, + msg=("Length of filtered standardized solver list not as " "expected."), + ) + self.assertEqual( + standardized_solver_list, + list(solvers), + msg="Entry of filtered standardized solver list not as expected.", + ) + + def test_solver_iterable_invalid_list(self): + """ + Test SolverIterable raises exception if iterable contains + at least one invalid object. + """ + invalid_object = [AVAILABLE_SOLVER_TYPE_NAME, 2] + standardizer_func = SolverIterable(solver_desc="backup solver") + + exc_str = ( + r"Cannot cast object `2` to a Pyomo optimizer.*" + r"backup solver.*index 1.*got type int.*" + ) + with self.assertRaisesRegex(SolverNotResolvable, exc_str): + standardizer_func(invalid_object) + + +class TestPyROSConfig(unittest.TestCase): + """ + Test PyROS ConfigDict behaves as expected. + """ + + CONFIG = pyros_config() + + def test_config_objective_focus(self): + """ + Test config parses objective focus as expected. + """ + config = self.CONFIG() + + for obj_focus_name in ["nominal", "worst_case"]: + config.objective_focus = obj_focus_name + self.assertEqual( + config.objective_focus, + ObjectiveType[obj_focus_name], + msg="Objective focus not set as expected.", + ) + + for obj_focus in ObjectiveType: + config.objective_focus = obj_focus + self.assertEqual( + config.objective_focus, + obj_focus, + msg="Objective focus not set as expected.", + ) + + invalid_focus = "test_example" + exc_str = f".*{invalid_focus!r} is not a valid ObjectiveType" + with self.assertRaisesRegex(ValueError, exc_str): + config.objective_focus = invalid_focus + + +class TestPositiveIntOrMinusOne(unittest.TestCase): + """ + Test validator for -1 or positive int works as expected. + """ + + def test_positive_int_or_minus_one(self): + """ + Test positive int or -1 validator works as expected. + """ + standardizer_func = positive_int_or_minus_one + ans = standardizer_func(1.0) + self.assertEqual( + ans, + 1, + 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( + ans, + -1, + 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.*" + with self.assertRaisesRegex(ValueError, exc_str): + standardizer_func(1.5) + with self.assertRaisesRegex(ValueError, exc_str): + standardizer_func(0) + + +class TestLoggerDomain(unittest.TestCase): + """ + Test logger type domain validator. + """ + + def test_logger_type(self): + """ + Test logger type validator. + """ + standardizer_func = logger_domain + mylogger = logging.getLogger("example") + self.assertIs( + standardizer_func(mylogger), + mylogger, + msg=f"{standardizer_func.__name__} output not as expected", + ) + self.assertIs( + standardizer_func(mylogger.name), + mylogger, + msg=f"{standardizer_func.__name__} output not as expected", + ) + + exc_str = r"A logger name must be a string" + with self.assertRaisesRegex(Exception, exc_str): + standardizer_func(2) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/pyros/tests/test_grcs.py b/pyomo/contrib/pyros/tests/test_grcs.py index 8de1c2666b9..58246f6d1a7 100644 --- a/pyomo/contrib/pyros/tests/test_grcs.py +++ b/pyomo/contrib/pyros/tests/test_grcs.py @@ -1,63 +1,40 @@ -''' -Unit tests for the grcs API -One class per function being tested, minimum one test per class -''' +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +""" +Tests for the PyROS solver. +""" +import logging +import math +import time + +from parameterized import parameterized 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, - model_is_valid, - 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, @@ -69,7 +46,6 @@ Reals, Set, Block, - ConstraintList, ConcreteModel, Constraint, Expression, @@ -77,24 +53,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' @@ -120,6 +108,9 @@ scip_license_is_valid = False scip_version = (0, 0, 0) +_ipopt = SolverFactory("ipopt") +ipopt_available = _ipopt.available(exception_flag=False) + # @SolverFactory.register("time_delay_solver") class TimeDelaySolver(object): @@ -137,7 +128,7 @@ def __init__(self, calls_to_sleep, max_time, sub_solver): self.num_calls = 0 self.options = Bunch() - def available(self): + def available(self, exception_flag=True): return True def license_is_valid(self): @@ -200,4716 +191,3556 @@ 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)) -class testAddDecisionRuleVars(unittest.TestCase): - """ - Test method for adding decision rule variables to working model. - The number of decision rule variables per control variable - should depend on: + 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) - - the number of uncertain parameters in the model - - the decision rule order specified by the user. - """ + m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) - def make_simple_test_model(self): - """ - Make simple test model for DR variable - declaration testing. - """ - m = ConcreteModel() + return m - # uncertain parameters - m.p = Param(range(3), initialize=0, mutable=True) - # second-stage variables - m.z = Var([0, 1], initialize=0) +def build_leyffer_two_cons_two_params(): + """ + Build extended Leyffer problem with two uncertain parameters. + """ + m = ConcreteModel() - # 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()) + m.u1 = Param(initialize=1.125, mutable=True) + m.u2 = Param(initialize=1, mutable=True) - return m + m.x1 = Var(initialize=0, bounds=(0, None)) + m.x2 = Var(initialize=0, bounds=(0, None)) + m.x3 = Var(initialize=0, bounds=(None, None)) - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - 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 = ROSolveResults() - model_data.working_model = m = self.make_simple_test_model() + 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) - config = Bunch() - config.decision_rule_order = 0 + m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) - add_decision_rule_variables(model_data=model_data, config=config) + return m - 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." - ), - ) - 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." - ), - ) +class TestPyROSSolveFactorModelSet(unittest.TestCase): + """ + Test PyROS successfully solves model with factor model uncertainty. + """ - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - def test_correct_num_dr_vars_affine(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): """ - Test DR variable setup routines declare the correct - number of DR coefficient variables, affine DR case. + Test two-stage model with `FactorModelSet` + as the uncertainty set. """ - model_data = ROSolveResults() - model_data.working_model = m = self.make_simple_test_model() + m = build_leyffer_two_cons_two_params() + + # 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]] + ) - config = Bunch() - config.decision_rule_order = 1 + # 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('baron') + global_subsolver = SolverFactory("baron") - 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." - ), - ) + # 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( - 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.", ) - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - def test_correct_num_dr_vars_quadratic(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, quadratic 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 = 2 + # 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") - 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 + # 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, + }, ) - 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." - ), - ) + # 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).", ) -class testAddDecisionRuleConstraints(unittest.TestCase): +class TestPyROSSolveDiscreteSet(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. + Test PyROS solves models with discrete uncertainty sets. """ - def make_simple_test_model(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_two_stg_model_discrete_set_single_scenario(self): """ - Make simple model for DR constraint testing. + Test two-stage model under discrete uncertainty with + a single scenario. """ - m = ConcreteModel() + m = build_leyffer_two_cons_two_params() - # uncertain parameters - m.p = Param(range(3), initialize=0, mutable=True) + # uncertainty set + discrete_set = DiscreteScenarioSet(scenarios=[(1.125, 1)]) - # second-stage variables - m.z = Var([0, 1], initialize=0) + # Instantiate 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], + 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.", + ) - @unittest.skipIf(not scipy_available, 'Scipy is not available.') - def test_num_dr_eqns_added_correct(self): + # only one iteration required + self.assertEqual( + results.iterations, + 1, + msg=( + "PyROS was unable to solve a singleton discrete set instance " + " successfully within a single iteration." + ), + ) + + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_two_stg_model_discrete_set(self): """ - Check that number of DR equality constraints added - by constraint declaration routines matches the number - of second-stage variables in the model. + 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() - # === 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] + discrete_set = DiscreteScenarioSet(scenarios=[[0.25], [1.125], [2]]) - # set up simple config-like object - config = Bunch() - config.decision_rule_order = 0 + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - add_decision_rule_constraints(model_data=model_data, config=config) + 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, + ) 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.", + res.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg=( + "Failed to solve discrete set multiple scenarios instance to " + "robust optimality" + ), ) - @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. +class TestPyROSRobustInfeasible(unittest.TestCase): + @unittest.skipUnless(baron_available, "BARON is not available and licensed") + def test_pyros_robust_infeasible(self): """ - # 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]), - ] + 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, + ) - dr_zip = zip( - m.util.second_stage_variables, - m.util.decision_rule_vars, - m.util.decision_rule_eqns, + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, ) - for ss_var, indexed_dr_var, dr_eq in dr_zip: - dr_eq_terms = dr_eq.body.args + self.assertEqual(results.iterations, 1) + # since x was not initialized + self.assertEqual(results.final_objective_value, None) - # 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." - ), - ) +global_solver = "baron" - # 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}" - ), - ) +# === regression test for the solver +@unittest.skipUnless(baron_available, "Global NLP solver is not available.") +class RegressionTest(unittest.TestCase): + """ + Collection of regression tests. + """ - # 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)})." - ), - ) + def build_regression_test_model(self): + """ + Create model used for regression tests. + """ + m = ConcreteModel() + m.name = "s381" - # 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}." - ), - ) + m.set_params = Set(initialize=list(range(4))) + m.p = Param(m.set_params, initialize=2, mutable=True) + 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 testModelIsValid(unittest.TestCase): - def test_model_is_valid_via_possible_inputs(self): - m = ConcreteModel() - m.x = Var() - m.obj1 = Objective(expr=m.x**2) - self.assertTrue(model_is_valid(m)) - m.obj2 = Objective(expr=m.x) - self.assertFalse(model_is_valid(m)) - m.obj2.deactivate() - self.assertTrue(model_is_valid(m)) - m.del_component("obj1") - m.del_component("obj2") - self.assertFalse(model_is_valid(m)) + m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) + m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) -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.", + m.decision_vars = [m.x1, m.x2, m.x3] + + m.uncertain_params = [m.p] + + return m + + @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() + + 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}, ) - 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.", + self.assertTrue( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) - 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.', + @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() + + 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.assertEqual( - svars_obj, - vars_in_active_obj, - msg='Mismatch of variables participating in activated objectives.', + self.assertTrue( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) - # replace bounds in model with performance constraints - uncertain_params = [mod_2.p, mod_2.r] - replace_uncertain_bounds_with_constraints(mod_2, uncertain_params) + @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() - # 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.', + 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.assertTrue( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) - 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() + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_identifying_violating_param_realization(self): + m = build_leyffer_two_cons() - 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 - ] - ) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - 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, - ) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - 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).", + # 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, + }, ) - 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 - ) + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", ) - self.assertEqual( - number_of_non_standard_form_inequalities, + self.assertGreater( + results.iterations, 0, - msg="All inequality constraints were not transformed to standard form.", + msg="Robust infeasible model terminated in 0 iterations (nominal case).", ) + @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 = build_leyffer_two_cons() -# === 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)] - + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) -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. - ''' + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - 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 + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('baron') + global_subsolver = SolverFactory("baron") - _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)) + # 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, + "max_iter": 1, + "decision_rule_order": 2, + }, ) 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)) + results.pyros_termination_condition, + pyrosTerminationCondition.max_iter, + msg="Returned termination condition is not return max_iter.", ) self.assertEqual( - len(variables_in_constr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - "variable expression.", + results.iterations, + 1, + msg=( + f"Number of iterations in results object is {results.iterations}, " + f"but expected value 1." + ), ) + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_terminate_with_time_limit(self): + m = build_leyffer_two_cons() -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" - ) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - # check attributes update - new_center = [-1, -3] - new_shape_matrix = [[2, 1], [1, 3]] - new_scale = 1 + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - eset.center = new_center - eset.shape_matrix = new_shape_matrix - eset.scale = new_scale + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('baron') + global_subsolver = SolverFactory("baron") - 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" + # 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, ) - 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 + # validate termination condition + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.time_out, + msg="Returned termination condition is not return time_out.", + ) - 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 + # 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" + ), + ) - exc_str = r".*must be a non-negative real \(provided.*-1\)" + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_pyros_backup_solvers(self): + m = ConcreteModel() + m.name = "s381" - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - EllipsoidalSet(center, shape_matrix, neg_scale) + class BadSolver: + def __init__(self, max_num_calls): + self.max_num_calls = max_num_calls + self.num_calls = 0 - # construct a valid EllipsoidalSet - eset = EllipsoidalSet(center, shape_matrix, scale=2) + def available(self, exception_flag=True): + return True - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - eset.scale = neg_scale + 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 - 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 + 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) - exc_str = r".*must be a square matrix of size 2.*\(provided.*shape \(1, 2\)\)" + # === State Vars = [x13] + # === Decision Vars === + m.decision_vars = [m.x1, m.x2, m.x3] - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - EllipsoidalSet(center, invalid_shape_matrix, scale) + # === 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] - # construct a valid EllipsoidalSet - eset = EllipsoidalSet(center, [[1, 0], [0, 1]], scale) + m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) + m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - eset.shape_matrix = invalid_shape_matrix + 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.assertTrue( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, + ) - def test_error_on_invalid_shape_matrix(self): + @unittest.skipUnless( + SolverFactory('baron').license_is_valid(), + "Global NLP solver is not available and licensed.", + ) + def test_separation_terminate_time_limit(self): """ - Test exceptional cases of invalid square shape matrix - arguments + Test PyROS time limit status returned in event + separation problem times out. """ - 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) + m = build_leyffer_two_cons() - # 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]] + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 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 + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - _set = EllipsoidalSet(center=[0, 0], shape_matrix=cov, scale=s) - m.uncertainty_set_contr = _set.set_as_constraint( - uncertain_params=m.uncertain_param_vars + # Define subsolvers utilized in the algorithm + local_subsolver = TimeDelaySolver( + calls_to_sleep=0, sub_solver=SolverFactory("baron"), max_time=1 ) - 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)) + 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, + objective_focus=ObjectiveType.worst_case, + solve_master_globally=True, + time_limit=1, ) 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.", + results.pyros_termination_condition, + pyrosTerminationCondition.time_out, + msg="Returned termination condition is not return time_out.", ) - 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 + @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): + """ + Check that PyROS does not ultimately alter state of + subordinate solver options due to time limit adjustments. + """ + m = build_leyffer_two_cons() - _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)) - ) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - self.assertEqual( - len(variables_in_constr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - 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." - ) + # 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;"] - 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)), + # 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=( - 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." + "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." ), ) - -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): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_terminate_with_application_error(self): """ - Test ValueError if half lengths for AxisAlignedEllipsoidalSet - contains a negative value. + Check that PyROS correctly raises ApplicationError + in event of abnormal IPOPT termination. """ - 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)) - ) + 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) - 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.", - ) + solver = SolverFactory("ipopt") + solver.options["halt_on_ampl_error"] = "yes" + baron = SolverFactory("baron") - 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)) - ) + 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( - 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", + len(list(solver.options.keys())), + 1, + msg=(f"Local subsolver {solver} options were changed by PyROS"), ) - - 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, + solver.options["halt_on_ampl_error"], + "yes", msg=( - "Constraint list for this `AxisAlignedEllipsoidalSet` should" - f" be of length 3, but is of length {len(conlist)}" + f"Local subsolver {solver} option " + "'halt_on_ampl_error' was changed by PyROS" ), ) 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" - ), + len(list(baron.options.keys())), + 0, + msg=(f"Global subsolver {baron} options were changed by PyROS"), ) @unittest.skipUnless( baron_license_is_valid, "Global NLP solver is not available and licensed." ) - def test_two_stg_mod_with_axis_aligned_set(self): + def test_master_subsolver_error(self): """ - Test two-stage model with `AxisAlignedEllipsoidalSet` - as the uncertainty set. + Test PyROS on a two-stage problem with a subsolver error + termination in the initial master problem. """ - # 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.q = Param(initialize=1, mutable=True) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) + m.x1 = Var(initialize=1, bounds=(0, 1)) - # Define the uncertainty set - # we take the parameter `u2` to be 'fixed' - ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[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, m.q)) - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") + m.obj = Objective(expr=log(m.x1) + m.x2) - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + box_set = BoxSet(bounds=[(0, 1)]) - # Call the PyROS solver - results = pyros_solver.solve( + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") + + res = 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, - }, + 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, ) - - # 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).", + res.pyros_termination_condition, + pyrosTerminationCondition.subsolver_error, + msg=( + f"Returned termination condition for separation error" + "test is not {pyrosTerminationCondition.subsolver_error}.", + ), ) - -class testPolyhedralUncertaintySetClass(unittest.TestCase): - """ - Unit tests for the Polyhedral set. - """ - - def test_normal_construction_and_update(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_separation_subsolver_error(self): """ - Test PolyhedralSet constructor and attribute setters work - appropriately. + Test PyROS on a two-stage problem with a subsolver error + termination in separation. """ - lhs_coefficients_mat = [[1, 2, 3], [4, 5, 6]] - rhs_vec = [1, 3] + m = ConcreteModel() + + m.q = Param(initialize=1, mutable=True) - pset = PolyhedralSet(lhs_coefficients_mat, rhs_vec) + m.x1 = Var(initialize=1, bounds=(0, 1)) - # check attributes are as expected - np.testing.assert_allclose(lhs_coefficients_mat, pset.coefficients_mat) - np.testing.assert_allclose(rhs_vec, pset.rhs_vec) + # 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))) - # update the set - pset.coefficients_mat = [[1, 0, 1], [1, 1, 1.5]] - pset.rhs_vec = [3, 4] + m.obj = Objective(expr=m.x1 + m.x2) - # 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) + box_set = BoxSet(bounds=[(0, 1)]) - 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]) + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - exc_str = ( - r".*must have 3 columns to match set dimension \(provided.*2 columns\)" + 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( + res.pyros_termination_condition, + pyrosTerminationCondition.subsolver_error, + msg=( + "Returned termination condition for separation error" + f"test is not {pyrosTerminationCondition.subsolver_error}." + ), ) - # 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): + @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): """ - Check ValueError raised if nonemptiness check performed - at construction returns a negative result. + Test PyROS for two-stage problem with discrete type set, + subsolver error status. """ - 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] + class BadSeparationSolver: + def __init__(self, solver): + self.solver = solver - exc_str = r".*all entries zero in columns at indexes: 0, 1.*" + def available(self, exception_flag=False): + return self.solver.available(exception_flag=exception_flag) - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - PolyhedralSet(invalid_col_mat, rhs_vec) + 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 - # construct a valid budget set - pset = PolyhedralSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], rhs_vec) + m = ConcreteModel() - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - pset.coefficients_mat = invalid_col_mat + 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) - 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) + discrete_set = DiscreteScenarioSet(scenarios=[(1,), (0,)]) - 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.", - ) + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - 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)) + 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( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", + res.pyros_termination_condition, pyrosTerminationCondition.subsolver_error ) + self.assertEqual(res.iterations, 1) - 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] - + @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() - 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.q = Param(initialize=1, mutable=True) + m.x1 = Var(initialize=1, bounds=(0, 1)) - 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.", - ) + # 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))) - @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) + m.obj = Objective(expr=m.x1 + m.x2, sense=maximize) - A = [[1, 0], [0, 1]] - b = [0, 0] + discrete_set = DiscreteScenarioSet(scenarios=[(1,), (0,)]) - polyhedral_set = PolyhedralSet(lhs_coefficients_mat=A, rhs_vec=b) - config = Block() - config.uncertainty_set = polyhedral_set - config.global_solver = SolverFactory("baron") + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - PolyhedralSet.add_bounds_on_uncertain_parameters(model=m, config=config) + 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, + ) - 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", + err_str = LOG.getvalue() + self.assertRegex( + err_str, "Optimizer.*exception.*separation problem.*iteration 0" ) - -class testBudgetUncertaintySetClass(unittest.TestCase): - ''' - Budget uncertainty sets. - Required inputs are matrix budget_membership_mat, rhs_vec. - ''' - - def test_normal_budget_construction_and_update(self): + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + def test_pyros_nl_and_ampl_writer_tol(self): """ - Test BudgetSet constructor and attribute setters work - appropriately. + Test PyROS subsolver call routine behavior + with respect to the NL and AMPL writer tolerances is as + expected. """ - 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) + 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) - # update origin - buset.origin = [1, 0, -1.5] - np.testing.assert_allclose([1, 0, -1.5], buset.origin) + # fixed just inside the PyROS-specified NL writer tolerance. + m.x1.fix(m.x1.upper + 9.9e-5) - 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) + current_nl_writer_tol = pyomo_nl_writer.TOL, pyomo_ampl_repn.TOL + ipopt_solver = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") - # error on budget incidence matrix update - exc_str = ( - r".*must have 3 columns to match set dimension \(provided.*1 columns\)" + 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, ) - 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\)" + self.assertEqual( + (pyomo_nl_writer.TOL, pyomo_ampl_repn.TOL), + current_nl_writer_tol, + msg="Pyomo writer tolerances not restored as expected.", ) - 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) + # fixed just outside the PyROS-specified writer tolerances. + # this should be exceptional. + m.x1.fix(m.x1.upper + 1.01e-4) - exc_str = ( - r".*must have 2 rows to match shape of attribute 'budget_rhs_vec' " - r"\(provided.*1 rows\)" + err_msg = ( + "model contains a trivially infeasible variable.*x1" + ".*fixed.*outside bounds" ) - with self.assertRaisesRegex(ValueError, exc_str): - bu_set.budget_membership_mat = [[1, 0, 1]] + 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, + ) - exc_str = ( - r".*must have 2 entries to match shape of attribute " - r"'budget_membership_mat' \(provided.*1 entries\)" + 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." + ), ) - with self.assertRaisesRegex(ValueError, exc_str): - bu_set.budget_rhs_vec = [1] - def test_error_on_neg_budget_rhs_vec_entry(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_pyros_math_domain_error(self): """ - Test ValueError raised if budget RHS vec has entry - with negative value entry. + Test PyROS on a two-stage problem, discrete + set type with a math domain error evaluating + second-stage inequality constraint expressions in separation. """ - 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*" + 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.obj = Objective(expr=m.x1 + m.x2) - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(budget_mat, neg_val_rhs_vec) + box_set = BoxSet(bounds=[[0, 1]]) - # construct a valid budget set - buset = BudgetSet(budget_mat, [1, 1]) + local_solver = SolverFactory("baron") + global_solver = SolverFactory("baron") + pyros_solver = SolverFactory("pyros") - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_rhs_vec = neg_val_rhs_vec + 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, + ) - def test_error_on_non_bool_budget_mat_entry(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_pyros_no_perf_cons(self): """ - Test ValueError raised if budget membership mat has - entry which is not a 0-1 value. + Ensure PyROS properly accommodates models with no + second-stage inequality constraints + (such as effectively deterministic models). """ - 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) + m = ConcreteModel() + m.x = Var(bounds=(0, 1)) + m.q = Param(mutable=True, initialize=1) - # construct a valid budget set - buset = BudgetSet([[1, 0, 1], [1, 1, 0]], budget_rhs_vec) + m.obj = Objective(expr=m.x * m.q) - # 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 + 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}.", + ), + ) - def test_error_on_budget_mat_all_zero_columns(self): + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_nominal_focus_robust_feasible(self): """ - Test ValueError raised if budget membership mat - has a column with all zeros. + Test problem under nominal objective focus terminates + successfully. """ - invalid_col_mat = [[0, 0, 1], [0, 0, 1], [0, 0, 1]] - budget_rhs_vec = [1, 1, 2] + m = build_leyffer_two_cons() - exc_str = r".*all entries zero in columns at indexes: 0, 1.*" + # singleton set, guaranteed robust feasibility + discrete_scenarios = DiscreteScenarioSet(scenarios=[[1.125]]) - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(invalid_col_mat, budget_rhs_vec) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - # construct a valid budget set - buset = BudgetSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], budget_rhs_vec) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('baron') + global_subsolver = SolverFactory("baron") - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_membership_mat = invalid_col_mat + # 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=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, + }, + ) + # check for robust feasible termination + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, + msg="Returned termination condition is not return robust_optimal.", + ) @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_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] - ) + def test_discrete_separation(self): + m = build_leyffer_two_cons() - # 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) + # Define the uncertainty set + discrete_scenarios = DiscreteScenarioSet(scenarios=[[0.25], [2.0], [1.125]]) - 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})" - ), - ) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - 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()) - ] + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('baron') + global_subsolver = SolverFactory("baron") - _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 + # 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=discrete_scenarios, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, ) - 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.", + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Returned termination condition is not return robust_optimal.", ) - 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)) - ) + @unittest.skipUnless( + scip_available and scip_license_is_valid, "SCIP is not available and licensed." + ) + def test_higher_order_decision_rules(self): + m = build_leyffer_two_cons() - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) - 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. - ''' + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - 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) - ] + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory("scip") + global_subsolver = SolverFactory("scip") - 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 + # 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, + "decision_rule_order": 2, + }, ) 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" - ), + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Returned termination condition is not return robust_optimal.", ) - 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." + @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 ) - 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()) - ] + interval = BoxSet(bounds=[(0.25, 2)]) - budget_set = BudgetSet( - budget_membership_mat=budget_membership_mat, rhs_vec=rhs_vec - ) - config = Block() - config.uncertainty_set = budget_set + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - BudgetSet.add_bounds_on_uncertain_parameters(model=m, config=config) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('scip') + global_subsolver = SolverFactory("scip") - 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", + # 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.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for BudgetSet", + + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg=( + "Non-optimal termination condition from robust" + "feasible coefficient matching problem." + ), ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for BudgetSet", + self.assertAlmostEqual( + results.final_objective_value, + 6.0394, + 2, + msg="Incorrect objective function value.", ) - -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): + def build_mitsos_4_3(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. + Create instance of Problem 4_3 from Mitsos (2011)'s + Test Set of semi-infinite programs. """ - 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\)" + # 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.obj = Objective(expr=m.x1 + m.x2 / 2 + m.x3 / 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 + return m - def test_error_on_cardinality_set_dim_change(self): + @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): """ - Dimension is considered immutable. - Test ValueError raised when attempting to alter the - set dimension (i.e. number of entries of `origin`). + 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. """ - # 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) + m = self.build_mitsos_4_3() - _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) + # instantiate BARON subsolver and PyROS solver + baron = SolverFactory("baron") + scip = SolverFactory("scip") + pyros_solver = SolverFactory("pyros") - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) + # 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, + # 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" + ), + ) - 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) + @unittest.skipUnless( + scip_available and scip_license_is_valid, "SCIP is not available and licensed." + ) + 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.build_mitsos_4_3() - 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) + global_solver = SolverFactory("scip") + pyros_solver = SolverFactory("pyros") - _set = CardinalitySet( - origin=center, positive_deviation=positive_deviation, gamma=gamma - ) + # 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=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" + ), + ) - self.assertTrue( - _set.point_in_set([0, 0]), msg="Point is not in the CardinalitySet." + @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 ) - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0.5) + interval = BoxSet(bounds=[(0.25, 2)]) - 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) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - cardinality_set = CardinalitySet( - origin=center, positive_deviation=positive_deviation, gamma=gamma - ) - config = Block() - config.uncertainty_set = cardinality_set + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory("baron") + global_subsolver = SolverFactory("baron") - CardinalitySet.add_bounds_on_uncertain_parameters(model=m, config=config) + # Call the PyROS solver - 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", + 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.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for CardinalitySet", + + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_infeasible, + msg="Robust infeasible problem not identified via coefficient matching.", ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for CardinalitySet", + self.assertEqual( + results.iterations, 0, msg="Number of PyROS iterations not as expected." ) + @unittest.skipUnless(ipopt_available, "IPOPT not available") + def test_coefficient_matching_robust_infeasible_param_only_con(self): + """ + Test robust infeasibility reported due to equality + constraint depending only on uncertain params. + """ + m = build_leyffer() + m.robust_infeasible_eq_con = Constraint(expr=m.u == 1) -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 - + box_set = BoxSet(bounds=[(0.25, 2)]) -class testBoxUncertaintySetClass(unittest.TestCase): - """ - Unit tests for the box uncertainty set (BoxSet). - """ + ipopt = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") - 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" + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.u], + uncertainty_set=box_set, + local_solver=ipopt, + global_solver=ipopt, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, ) - # 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" + self.assertEqual( + 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." ) - 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): + @unittest.skipUnless(ipopt_available, "IPOPT not available.") + def test_coefficient_matching_nonlinear_expr(self): """ - Test exception raised when an LB exceeds a UB. + Test behavior of PyROS solver for model with + equality constraint that cannot be reformulated via + coefficient matching due to nonlinearity. """ - bad_bounds = [[1, 2], [4, 3]] + m = build_leyffer() + m.eq_con = Constraint(expr=m.u**2 * (m.x2 - 1) == 0) - exc_str = r"Lower bound 4 exceeds upper bound 3" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(bad_bounds) + interval = BoxSet(bounds=[(0.25, 2)]) - # construct a valid box set - bset = BoxSet([[1, 2], [3, 4]]) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = bad_bounds + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory("ipopt") + global_subsolver = SolverFactory("ipopt") - def test_error_on_ragged_bounds_array(self): - """ - Test ValueError raised on attempting to set BoxSet bounds - to a ragged array. + # 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, + }, + ) - 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 + pyros_log = LOG.getvalue() + self.assertRegex( + pyros_log, r".*Equality constraint '.*eq_con.*'.*cannot be written.*" ) - # 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.*\)" + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, ) - # 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) +@unittest.skipUnless(ipopt_available, "IPOPT not available.") +class TestPyROSVarsAsUncertainParams(unittest.TestCase): + """ + Test PyROS solver treatment of Var/VarData + objects passed as uncertain parameters. + """ - # construct a valid box set - bset = BoxSet([[1, 2], [3, 4]]) + 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]]) - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = three_col_arr + return mdl1, mdl2, box_set - def test_error_on_empty_last_dimension(self): + def test_pyros_unfixed_vars_as_uncertain_params(self): """ - Check ValueError raised when last dimension of BoxSet bounds is - empty. + Test PyROS raises exception if unfixed Vars are + passed to the argument `uncertain_params`. """ - empty_2d_arr = [[], [], []] - exc_str = ( - r"Last dimension of argument `bounds` must be non-empty " - r"\(detected shape \(3, 0\)\)" - ) + _, mdl2, box_set = self.build_model_objects() + mdl2.uvar.unfix() - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(bounds=empty_2d_arr) + ipopt_solver = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") - # create a valid box set - bset = BoxSet([[1, 2], [3, 4]]) + 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, + ) - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = empty_2d_arr + 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_error_on_non_numeric_bounds(self): + def test_pyros_vars_as_uncertain_params_correct(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). + Test PyROS solver result is invariant to the type used + in argument `uncertain_params`. """ - # 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 + mdl1, mdl2, box_set = self.build_model_objects() - 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) + # explicitly fixed + mdl2.uvar.fix() - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = bad_bounds + # 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) - 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) + ipopt_solver = SolverFactory("ipopt") + pyros_solver = SolverFactory("pyros") - 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.", + res1 = pyros_solver.solve( + model=mdl1, + first_stage_variables=[mdl1.x1, mdl1.x2], + second_stage_variables=[], + uncertain_params=[mdl1.u1, mdl1.u2], + uncertainty_set=box_set, + local_solver=ipopt_solver, + global_solver=ipopt_solver, ) - - 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.", + res1.pyros_termination_condition, pyrosTerminationCondition.robust_feasible ) - 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 + 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}." + ), + ) - 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", - ) +@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 = build_leyffer_two_cons() + # Define the uncertainty set + interval = BoxSet(bounds=[(0.25, 2)]) -class testDiscreteUncertaintySetClass(unittest.TestCase): - ''' - Discrete uncertainty sets. Required inputis a scenarios list. - ''' + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - 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]] + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('ipopt') + global_subsolver = SolverFactory("scip") - # normal construction should work - dset = DiscreteScenarioSet(scenarios) + # Call the PyROS solver + with LoggingIntercept(level=logging.WARNING) 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": True, + "decision_rule_order": 0, + "bypass_global_separation": True, + }, + ) - # check scenarios added appropriately - np.testing.assert_allclose( - scenarios, dset.scenarios, err_msg="BoxSet bounds not as expected" + # check termination robust optimal + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Returned termination condition is not return robust_optimal.", ) - # 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" + # since robust optimal, we also expect warning-level logger + # message about bypassing of global separation subproblems + warning_msgs = LOG.getvalue() + self.assertRegex( + warning_msgs, + ( + r".*Option to bypass global separation was chosen\. " + r"Robust feasibility and optimality of the reported " + r"solution are not guaranteed\." + ), ) - def test_error_on_discrete_set_dim_change(self): + +@unittest.skipUnless( + baron_available and baron_license_is_valid, + "Global NLP solver is not available and licensed.", +) +class testUninitializedVars(unittest.TestCase): + def test_uninitialized_vars(self): """ - Test ValueError raised when attempting to update - DiscreteScenarioSet dimension. + Test a simple PyROS model instance with uninitialized + first-stage and second-stage variables. """ - scenarios = [[1, 2], [3, 4]] - dset = DiscreteScenarioSet(scenarios) # 2-dimensional set + m = ConcreteModel() - 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]] + # parameters + m.ell0 = Param(initialize=1) + m.u0 = Param(initialize=3) + m.ell = Param(initialize=1) + m.u = Param(initialize=5) + m.p = Param(initialize=m.u0, mutable=True) + m.r = Param(initialize=0.1) - 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) + # variables + m.x = Var(bounds=(m.ell0, m.u0)) + m.z = Var(bounds=(m.ell0, m.p)) + m.t = Var(initialize=1, bounds=(0, m.r)) + m.w = Var(bounds=(0, 1)) - 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.", - ) + # objectives + m.obj = Objective(expr=-m.x**2 + m.z**2) - 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) + # auxiliary constraints + m.t_lb_con = Constraint(expr=m.x - m.z <= m.t) + m.t_ub_con = Constraint(expr=-m.t <= m.x - m.z) - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", - ) + # other constraints + m.con1 = Constraint(expr=m.x - m.z >= 0.1) + m.eq_con = Constraint(expr=m.w == 0.5 * m.t) - 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) + box_set = BoxSet(bounds=((value(m.ell), value(m.u)),)) - 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." - ) + # solvers + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("baron") - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0) + # pyros setup + pyros_solver = SolverFactory("pyros") - scenarios = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0)] - _set = DiscreteScenarioSet(scenarios=scenarios) - config = Block() - config.uncertainty_set = _set + # solve for different decision rule orders + for dr_order in [0, 1, 2]: + model = m.clone() - DiscreteScenarioSet.add_bounds_on_uncertain_parameters(model=m, config=config) + # degree of freedom partitioning + fsv = [model.x] + ssv = [model.z, model.t] + uncertain_params = [model.p] - 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", - ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for DiscreteScenarioSet", - ) + res = pyros_solver.solve( + model=model, + first_stage_variables=fsv, + second_stage_variables=ssv, + uncertain_params=uncertain_params, + uncertainty_set=box_set, + local_solver=local_solver, + global_solver=global_solver, + objective_focus=ObjectiveType.worst_case, + decision_rule_order=2, + solve_master_globally=True, + ) - @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() + self.assertEqual( + res.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg=( + "Returned termination condition for solve with" + f"decision rule order {dr_order} is not return " + "robust_optimal." + ), + ) - # 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)) +@unittest.skipUnless(scip_available, "Global NLP solver is not available.") +class testModelMultipleObjectives(unittest.TestCase): + """ + This class contains tests for models with multiple + Objective attributes. + """ - # 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) + def test_multiple_objs(self): + """Test bypassing of global separation solve calls.""" + m = build_leyffer_two_cons() + m.obj2 = Objective(expr=m.obj.expr / 2) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2) + # add block, with another objective + m.b = Block() + m.b.obj = Objective(expr=m.obj.expr / 2) - # 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 - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + local_subsolver = SolverFactory('ipopt') + global_subsolver = SolverFactory("scip") - # Call the PyROS solver - results = pyros_solver.solve( + solve_kwargs = dict( model=m, first_stage_variables=[m.x1], second_stage_variables=[m.x2], - uncertain_params=[m.u1, m.u2], - uncertainty_set=discrete_set, + 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": 0, }, ) + # check validation error raised due to multiple objectives + with self.assertRaisesRegex( + ValueError, r"Expected model with exactly 1 active objective.*has 3" + ): + pyros_solver.solve(**solve_kwargs) + + # check validation error raised due to multiple objectives + m.b.obj.deactivate() + with self.assertRaisesRegex( + ValueError, r"Expected model with exactly 1 active objective.*has 2" + ): + pyros_solver.solve(**solve_kwargs) + + # now solve with only one active obj, # check successful termination - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg="Did not identify robust optimal solution to problem instance.", + m.obj2.deactivate() + res = pyros_solver.solve(**solve_kwargs) + self.assertIs( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal ) - # only one iteration required - self.assertEqual( - results.iterations, - 1, + # check active objectives + self.assertEqual(len(list(m.component_data_objects(Objective, active=True))), 1) + self.assertTrue(m.obj.active) + + # swap to maximization objective. + # and solve again + m.obj_max = Objective(expr=-m.obj.expr, sense=pyo_max) + m.obj.deactivate() + max_obj_res = pyros_solver.solve(**solve_kwargs) + + # check active objectives + self.assertEqual(len(list(m.component_data_objects(Objective, active=True))), 1) + self.assertTrue(m.obj_max.active) + + self.assertTrue( + math.isclose( + res.final_objective_value, + -max_obj_res.final_objective_value, + abs_tol=2e-4, # 2x the default robust feasibility tolerance + ), msg=( - "PyROS was unable to solve a singleton discrete set instance " - " successfully within a single iteration." + f"Robust optimal objective value {res.final_objective_value} " + "for problem with minimization objective not close to " + f"negative of value {max_obj_res.final_objective_value} " + "of equivalent maximization objective." ), ) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_two_stg_model_discrete_set(self): + +class TestMasterFeasibilityUnitConsistency(unittest.TestCase): + """ + Test cases for models with unit-laden model components. + """ + + @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 PyROS successfully solves two-stage model with - multiple scenarios. + Test two-stage model with `AxisAlignedEllipsoidalSet` + as the uncertainty set. """ 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) + m.x1 = Var(initialize=0, bounds=(0, None)) + m.x2 = Var(initialize=0, bounds=(0, None), units=u.m) + m.x3 = Var(initialize=0, bounds=(None, None)) + m.u1 = Param(initialize=1.125, mutable=True, units=u.s) + m.u2 = Param(initialize=1, mutable=True, units=u.m**2) - discrete_set = DiscreteScenarioSet(scenarios=[[0.25], [1.125], [2]]) + 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) - global_solver = SolverFactory("baron") + 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") - res = pyros_solver.solve( + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory("scip") + global_subsolver = SolverFactory("scip") + + # Call the PyROS solver + # note: second-stage variable and uncertain params have units + 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, + 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 + # and that more than one iteration required self.assertEqual( - res.pyros_termination_condition, + results.pyros_termination_condition, pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", + ) + self.assertGreater( + results.iterations, + 1, msg=( - "Failed to solve discrete set multiple scenarios instance to " - "robust optimality" + "PyROS requires no more than one iteration to solve the model." + " Hence master feasibility problem construction not tested." + " Consider implementing a more challenging model for this" + " test case." ), ) -class testFactorModelUncertaintySetClass(unittest.TestCase): - ''' - FactorModelSet uncertainty sets. Required inputs are psi_matrix, number_of_factors, origin and beta. - ''' +class TestSubsolverTiming(unittest.TestCase): + """ + Tests to confirm that the PyROS subsolver timing routines + work appropriately. + """ - def test_normal_factor_model_construction_and_update(self): + def simple_nlp_model(self): """ - Test FactorModelSet constructor and setter work normally - when attribute values are appropriate. + Create simple NLP for the unit tests defined + within this class """ - # 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): + return build_leyffer_two_cons_two_params() + + @unittest.skipUnless( + SolverFactory('appsi_ipopt').available(exception_flag=False), + "Local NLP solver is not available.", + ) + def test_pyros_appsi_ipopt(self): """ - Test ValueError raised when attempting to change FactorModelSet - dimension (by changing number of entries in origin - or number of rows of psi_mat). + Test PyROS usage with solver appsi ipopt + works without exceptions. """ - 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) + m = self.simple_nlp_model() - # 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]] + # Define the uncertainty set + # we take the parameter `u2` to be 'fixed' + ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[1, 0]) - # 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] + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - 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) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('appsi_ipopt') + global_subsolver = SolverFactory("appsi_ipopt") - fset = FactorModelSet( - origin=[0], number_of_factors=2, psi_mat=[[1, 1]], beta=0.1 + # Call the PyROS solver + # note: second-stage variable and uncertain params have units + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.u1, m.u2], + uncertainty_set=ellipsoid, + local_solver=local_subsolver, + global_solver=global_subsolver, + objective_focus=ObjectiveType.worst_case, + solve_master_globally=False, + bypass_global_separation=True, + ) + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, + msg="Did not identify robust optimal solution to problem instance.", + ) + self.assertFalse( + math.isnan(results.time), + msg=( + "PyROS solve time is nan (expected otherwise since subsolver" + "time estimates are made using TicTocTimer" + ), ) - - 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 @unittest.skipUnless( - SolverFactory("cbc").available(exception_flag=False), - "LP solver CBC not available", + SolverFactory('gams:ipopt').available(exception_flag=False), + "Local NLP solver GAMS/IPOPT is not available.", ) - def test_factor_model_parameter_bounds_correct(self): + def test_pyros_gams_ipopt(self): """ - If LP solver is available, test parameter bounds method - for factor model set is correct (check against - results from an LP solver). + Test PyROS usage with solver GAMS ipopt + works without exceptions. """ - 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 - ) - - # 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) + m = self.simple_nlp_model() - 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}." - ), - ) + # Define the uncertainty set + # we take the parameter `u2` to be 'fixed' + ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[1, 0]) - @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) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - 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) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('gams:ipopt') + global_subsolver = SolverFactory("gams:ipopt") - self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", + # Call the PyROS solver + # note: second-stage variable and uncertain params have units + results = pyros_solver.solve( + model=m, + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], + uncertain_params=[m.u1, m.u2], + uncertainty_set=ellipsoid, + local_solver=local_subsolver, + global_solver=global_subsolver, + objective_focus=ObjectiveType.worst_case, + solve_master_globally=False, + bypass_global_separation=True, ) - - 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.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_feasible, + msg="Did not identify robust optimal solution to problem instance.", ) - self.assertTrue( - _set.point_in_set([0, 0]), msg="Point is not in the FactorModelSet." + self.assertFalse( + math.isnan(results.time), + msg=( + "PyROS solve time is nan (expected otherwise since subsolver" + "time estimates are made using TicTocTimer" + ), ) - def test_add_bounds_on_uncertain_parameters(self): - m = ConcreteModel() - m.util = Block() - m.util.uncertain_param_vars = Var([0, 1], initialize=0) + @unittest.skipUnless( + 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. + """ + m = self.simple_nlp_model() - 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 + # construct the IntersectionSet + ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[1, 0]) + bset = BoxSet(bounds=[[1, 2], [0.5, 1.5]]) + iset = IntersectionSet(ellipsoid=ellipsoid, bset=bset) + + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - FactorModelSet.add_bounds_on_uncertain_parameters(model=m, config=config) + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory("scip") + global_subsolver = SolverFactory("scip") - 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", + # 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=iset, + local_solver=local_subsolver, + global_solver=global_subsolver, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": True, + }, ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].lb, - None, - "Bounds not added correctly for FactorModelSet", + + # check successful termination + self.assertEqual( + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", ) - self.assertNotEqual( - m.util.uncertain_param_vars[1].ub, - None, - "Bounds not added correctly for FactorModelSet", + self.assertGreater( + results.iterations, + 0, + msg="Robust infeasible model terminated in 0 iterations (nominal case).", ) -class testIntersectionSetClass(unittest.TestCase): +class TestIterationLogRecord(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. + Test the PyROS `IterationLogRecord` class. """ - 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_log_header(self): + """Test method for logging iteration log table header.""" + ans = ( + "------------------------------------------------------------------------------\n" + "Itn Objective 1-Stg Shift 2-Stg Shift #CViol Max Viol Wall Time (s)\n" + "------------------------------------------------------------------------------\n" ) + with LoggingIntercept(level=logging.INFO) as LOG: + IterationLogRecord.log_header(logger.info) - 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" + self.assertEqual( + LOG.getvalue(), + ans, + msg="Messages logged for iteration table header do not match expected result", + ) - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - IntersectionSet(box_set=bset, axis_set=aset, wrong_set=wrong_aset) + def test_log_standard_iter_record(self): + """Test logging function for PyROS IterationLogRecord.""" - # 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'\)" + # for some fields, we choose floats with more than four + # four decimal points to ensure rounding also matches + iter_record = IterationLogRecord( + iteration=4, + objective=1.234567, + first_stage_var_shift=2.3456789e-8, + second_stage_var_shift=3.456789e-7, + dr_var_shift=1.234567e-7, + num_violated_cons=10, + max_violation=7.654321e-3, + elapsed_time=21.2, + dr_polishing_success=True, + all_sep_problems_solved=True, + global_separation=False, ) - # 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) + # now check record logged as expected + ans = ( + "4 1.2346e+00 2.3457e-08 3.4568e-07 10 7.6543e-03 " + "21.200 \n" + ) + with LoggingIntercept(level=logging.INFO) as LOG: + iter_record.log(logger.info) + result = LOG.getvalue() - 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]) + self.assertEqual( + ans, + result, + msg="Iteration log record message does not match expected result", + ) - # construct the set - iset = IntersectionSet(box_set=bset, axis_set=aset) + def test_log_iter_record_polishing_failed(self): + """Test iteration log record in event of polishing failure.""" + # for some fields, we choose floats with more than four + # four decimal points to ensure rounding also matches + iter_record = IterationLogRecord( + iteration=4, + objective=1.234567, + first_stage_var_shift=2.3456789e-8, + second_stage_var_shift=3.456789e-7, + dr_var_shift=1.234567e-7, + num_violated_cons=10, + max_violation=7.654321e-3, + elapsed_time=21.2, + dr_polishing_success=False, + all_sep_problems_solved=True, + global_separation=False, + ) - exc_str = r"Attempting to set.*dimension 2 to a sequence.* of dimension 1" + # now check record logged as expected + ans = ( + "4 1.2346e+00 2.3457e-08 3.4568e-07* 10 7.6543e-03 " + "21.200 \n" + ) + with LoggingIntercept(level=logging.INFO) as LOG: + iter_record.log(logger.info) + result = LOG.getvalue() - # 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])] + self.assertEqual( + ans, + result, + msg="Iteration log record message does not match expected result", + ) - def test_error_on_too_few_sets(self): + def test_log_iter_record_global_separation(self): """ - Check ValueError raised if too few sets are passed - to the intersection set. + Test iteration log record in event global separation performed. + In this case, a 'g' should be appended to the max violation + reported. Useful in the event neither local nor global separation + was bypassed. """ - 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]])) + # for some fields, we choose floats with more than four + # four decimal points to ensure rounding also matches + iter_record = IterationLogRecord( + iteration=4, + objective=1.234567, + first_stage_var_shift=2.3456789e-8, + second_stage_var_shift=3.456789e-7, + dr_var_shift=1.234567e-7, + num_violated_cons=10, + max_violation=7.654321e-3, + elapsed_time=21.2, + dr_polishing_success=True, + all_sep_problems_solved=True, + global_separation=True, + ) - # construct a valid intersection set - iset = IntersectionSet( - box_set=BoxSet([[1, 2]]), axis_set=AxisAlignedEllipsoidalSet([0], [1]) + # now check record logged as expected + ans = ( + "4 1.2346e+00 2.3457e-08 3.4568e-07 10 7.6543e-03g " + "21.200 \n" ) + with LoggingIntercept(level=logging.INFO) as LOG: + iter_record.log(logger.info) + result = LOG.getvalue() - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - # attempt to set to 1-dimensional sets - iset.all_sets = [BoxSet([[1, 1]])] + self.assertEqual( + ans, + result, + msg="Iteration log record message does not match expected result", + ) - def test_intersection_uncertainty_set_list_behavior(self): + def test_log_iter_record_not_all_sep_solved(self): """ - Test the 'all_sets' attribute of the IntersectionSet - class behaves like a regular Python list. + Test iteration log record in event not all separation problems + were solved successfully. This may have occurred if the PyROS + 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 second-stage + inequality constraints found to be violated. """ - 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( - SolverFactory('ipopt').available(exception_flag=False), - "Local NLP solver 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() - solver = SolverFactory("ipopt") - config.declare("global_solver", ConfigValue(default=solver)) + # for some fields, we choose floats with more than four + # four decimal points to ensure rounding also matches + iter_record = IterationLogRecord( + iteration=4, + objective=1.234567, + first_stage_var_shift=2.3456789e-8, + second_stage_var_shift=3.456789e-7, + dr_var_shift=1.234567e-7, + num_violated_cons=10, + max_violation=7.654321e-3, + elapsed_time=21.2, + dr_polishing_success=True, + all_sep_problems_solved=False, + global_separation=False, + ) - m.uncertainty_set_contr = Q.set_as_constraint( - uncertain_params=m.uncertain_param_vars, config=config + # now check record logged as expected + ans = ( + "4 1.2346e+00 2.3457e-08 3.4568e-07 10+ 7.6543e-03 " + "21.200 \n" ) - 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) + with LoggingIntercept(level=logging.INFO) as LOG: + iter_record.log(logger.info) + result = LOG.getvalue() 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.", + ans, + result, + msg="Iteration log record message does not match expected result", ) - @unittest.skipUnless( - SolverFactory('ipopt').available(exception_flag=False), - "Local NLP solver 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 + def test_log_iter_record_all_special(self): + """ + Test iteration log record in event DR polishing and global + separation failed. + """ + # for some fields, we choose floats with more than four + # four decimal points to ensure rounding also matches + iter_record = IterationLogRecord( + iteration=4, + objective=1.234567, + first_stage_var_shift=2.3456789e-8, + second_stage_var_shift=3.456789e-7, + dr_var_shift=1.234567e-7, + num_violated_cons=10, + max_violation=7.654321e-3, + elapsed_time=21.2, + dr_polishing_success=False, + all_sep_problems_solved=False, + global_separation=True, ) - 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 + # now check record logged as expected + ans = ( + "4 1.2346e+00 2.3457e-08 3.4568e-07* 10+ 7.6543e-03g " + "21.200 \n" ) - 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) + with LoggingIntercept(level=logging.INFO) as LOG: + iter_record.log(logger.info) + result = LOG.getvalue() self.assertEqual( - len(vars_in_expr), - 0, - msg="Uncertainty set constraint contains no Var objects, consists of a not potentially" - " variable expression.", + ans, + result, + msg="Iteration log record message does not match expected result", ) - 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)] - 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." + def test_log_iter_record_attrs_none(self): + """ + Test logging of iteration record in event some + attributes are of value `None`. In this case, a '-' + should be printed in lieu of a numerical value. + Example where this occurs: the first iteration, + in which there is no first-stage shift or DR shift. + """ + # for some fields, we choose floats with more than four + # four decimal points to ensure rounding also matches + iter_record = IterationLogRecord( + iteration=0, + objective=-1.234567, + first_stage_var_shift=None, + second_stage_var_shift=None, + dr_var_shift=None, + num_violated_cons=10, + max_violation=7.654321e-3, + elapsed_time=21.2, + dr_polishing_success=True, + all_sep_problems_solved=False, + global_separation=True, ) - @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]) + # now check record logged as expected + ans = ( + "0 -1.2346e+00 - - 10+ 7.6543e-03g " + "21.200 \n" + ) + with LoggingIntercept(level=logging.INFO) as LOG: + iter_record.log(logger.info) + result = LOG.getvalue() self.assertEqual( - len(master_data.master_model.scenarios), - 2, - msg="Scenario not added to master correctly. Expected 2 scenarios.", + ans, + result, + msg="Iteration log record message does not match expected result", ) -global_solver = "baron" - +class TestROSolveResults(unittest.TestCase): + """ + Test PyROS solver results object. + """ -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 - ), + def test_ro_solve_results_str(self): + """ + Test string representation of RO solve results object. + """ + res = ROSolveResults( + config=SolverFactory("pyros").CONFIG(), + iterations=4, + final_objective_value=123.456789, + time=300.34567, + pyros_termination_condition=pyrosTerminationCondition.robust_optimal, + ) + ans = ( + "Termination stats:\n" + " Iterations : 4\n" + " Solve time (wall s) : 300.346\n" + " Final objective value : 1.2346e+02\n" + " Termination condition : pyrosTerminationCondition.robust_optimal" ) - 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( + str(res), + ans, + msg=( + "String representation of PyROS results object does not " + "match expected value" + ), ) - 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." - ), - ) + def test_ro_solve_results_str_attrs_none(self): + """ + Test string representation of PyROS solve results in event + one of the printed attributes is of value `None`. + This may occur at instantiation or, for example, + whenever the PyROS solver confirms robust infeasibility through + coefficient matching. + """ + res = ROSolveResults( + config=SolverFactory("pyros").CONFIG(), + iterations=0, + final_objective_value=None, + time=300.34567, + pyros_termination_condition=pyrosTerminationCondition.robust_optimal, + ) + ans = ( + "Termination stats:\n" + " Iterations : 0\n" + " Solve time (wall s) : 300.346\n" + " Final objective value : None\n" + " Termination condition : pyrosTerminationCondition.robust_optimal" + ) + self.assertEqual( + str(res), + ans, + msg=( + "String representation of PyROS results object does not " + "match expected value" + ), + ) -# === regression test for the solver -class coefficientMatchingTests(unittest.TestCase): - def test_coefficient_matching_correct_num_constraints_added(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) +class TestPyROSSolverLogIntros(unittest.TestCase): + """ + Test logging of introductory information by PyROS solver. + """ - 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 + def test_log_config(self): + """ + Test method for logging PyROS solver config dict. + """ + pyros_solver = SolverFactory("pyros") + config = pyros_solver.CONFIG(dict(nominal_uncertain_param_vals=[0.5])) + with LoggingIntercept(level=logging.INFO) as LOG: + pyros_solver._log_config(logger=logger, config=config, level=logging.INFO) + + ans = ( + "Solver options:\n" + " time_limit=None\n" + " 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" + " solve_master_globally=False\n" + " max_iter=-1\n" + " robust_feasibility_tolerance=0.0001\n" + " separation_priority_order={}\n" + " progress_logger=\n" + " backup_local_solvers=[]\n" + " backup_global_solvers=[]\n" + " subproblem_file_directory=None\n" + " bypass_local_separation=False\n" + " bypass_global_separation=False\n" + " p_robustness={}\n" + "-" * 78 + "\n" ) - 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)] + logged_str = LOG.getvalue() + self.assertEqual( + logged_str, + ans, + msg=( + "Logger output for PyROS solver config (default case) " + "does not match expected result." + ), + ) - m.util = Block() - m.util.first_stage_variables = [m.x1, m.x2] - m.util.second_stage_variables = [] - m.util.uncertain_params = [m.u] + def test_log_intro(self): + """ + Test logging of PyROS solver introductory messages. + """ + pyros_solver = SolverFactory("pyros") + with capture_output(capture_fd=True) as OUT: + with LoggingIntercept(level=logging.INFO) as LOG: + pyros_solver._log_intro(logger=logger, level=logging.INFO) - config.decision_rule_order = 0 + # ensure git repo commit check error messages suppressed + err_msgs = OUT.getvalue() + self.assertEqual(err_msgs, "") - m.util.h_x_q_constraints = ComponentSet() + intro_msgs = LOG.getvalue() - coeff_matching_success, robust_infeasible = coefficient_matching( - m, m.eq_con, [m.u], config - ) + # last character should be newline; disregard it + intro_msg_lines = intro_msgs.split("\n")[:-1] + # check number of lines is as expected 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).", + len(intro_msg_lines), + 14, + msg=( + "PyROS solver introductory message does not contain" + "the expected number of lines." + ), ) - self.assertEqual( - len(m.coefficient_matching_constraints), - 2, - msg="Coefficient matching produced incorrect number of h(x,q)=0 constraints.", + + # first and last lines of the introductory section + self.assertEqual(intro_msg_lines[0], "=" * 78) + self.assertEqual(intro_msg_lines[-1], "=" * 78) + + # check regex main text + self.assertRegex( + " ".join(intro_msg_lines[1:-1]), + r"PyROS: The Pyomo Robust Optimization Solver, v.* \(IDAES\)\.", ) - config.decision_rule_order = 1 - model_data = Block() - model_data.working_model = m + def test_log_disclaimer(self): + """ + Test logging of PyROS solver disclaimer messages. + """ + pyros_solver = SolverFactory("pyros") + with LoggingIntercept(level=logging.INFO) as LOG: + pyros_solver._log_disclaimer(logger=logger, level=logging.INFO) - m.util.first_stage_variables = [m.x1] - m.util.second_stage_variables = [m.x2] + disclaimer_msgs = LOG.getvalue() - add_decision_rule_variables(model_data=model_data, config=config) - add_decision_rule_constraints(model_data=model_data, config=config) + # last character should be newline; disregard it + disclaimer_msg_lines = disclaimer_msgs.split("\n")[:-1] - 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.", - ) + # check number of lines is as expected self.assertEqual( - robust_infeasible, - False, - msg="Coefficient matching is not successful, " - "but should not be proven robust infeasible.", + len(disclaimer_msg_lines), + 5, + msg=( + "PyROS solver disclaimer message does not contain" + "the expected number of lines." + ), ) - 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) + # regex first line of disclaimer section + self.assertRegex(disclaimer_msg_lines[0], r"=.* DISCLAIMER .*=") + # check last line of disclaimer section + self.assertEqual(disclaimer_msg_lines[-1], "=" * 78) - 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 + # check regex main text + self.assertRegex( + " ".join(disclaimer_msg_lines[1:-1]), + r"PyROS is still under development.*ticket at.*", ) - 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] +class UnavailableSolver: + def available(self, exception_flag=True): + if exception_flag: + raise ApplicationError(f"Solver {self.__class__} not available") + return False - config.decision_rule_order = 0 + def solve(self, model, *args, **kwargs): + return SolverResults() - m.util.h_x_q_constraints = ComponentSet() - coeff_matching_success, robust_infeasible = coefficient_matching( - m, m.eq_con, [m.u], config - ) +class TestPyROSUnavailableSubsolvers(unittest.TestCase): + """ + Check that appropriate exceptionsa are raised if + PyROS is invoked with unavailable subsolvers. + """ - self.assertEqual( - coeff_matching_success, - False, - msg="Coefficient matching should have been unsuccessful.", - ) - self.assertEqual( - robust_infeasible, - True, - msg="Coefficient matching should be proven robust infeasible.", - ) + def test_pyros_unavailable_subsolver(self): + """ + Test PyROS raises expected error message when + unavailable subsolver is passed. + """ + m = ConcreteModel() + m.p = Param(range(3), initialize=0, mutable=True) + m.z = Var([0, 1], initialize=0) + m.con = Constraint(expr=m.z[0] + m.z[1] >= m.p[0]) + m.obj = Objective(expr=m.z[0] + m.z[1]) + pyros_solver = SolverFactory("pyros") -# === 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" + exc_str = r".*Solver.*UnavailableSolver.*not available" + with self.assertRaisesRegex(ValueError, exc_str): + # note: ConfigDict interface raises ValueError + # once any exception is triggered, + # so we check for that instead of ApplicationError + with LoggingIntercept(level=logging.ERROR) as LOG: + pyros_solver.solve( + model=m, + first_stage_variables=[m.z[0]], + second_stage_variables=[m.z[1]], + uncertain_params=[m.p[0]], + uncertainty_set=BoxSet([[0, 1]]), + local_solver=SimpleTestSolver(), + global_solver=UnavailableSolver(), + ) - 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) + error_msgs = LOG.getvalue()[:-1] + self.assertRegex( + error_msgs, r"Output of `available\(\)` method.*global solver.*" + ) - # === State Vars = [x13] - # === Decision Vars === - m.decision_vars = [m.x1, m.x2, m.x3] + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + def test_pyros_unavailable_backup_subsolver(self): + """ + Test PyROS raises expected error message when + unavailable backup subsolver is passed. + """ + m = ConcreteModel() + m.p = Param(range(3), initialize=0, mutable=True) + m.z = Var([0, 1], initialize=0) + m.con = Constraint(expr=m.z[0] + m.z[1] >= m.p[0]) + m.obj = Objective(expr=m.z[0] + m.z[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] + 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) + # note: ConfigDict interface raises ValueError + # once any exception is triggered, + # so we check for that instead of ApplicationError + with LoggingIntercept(level=logging.WARNING) as LOG: + pyros_solver.solve( + model=m, + first_stage_variables=[m.z[0]], + second_stage_variables=[m.z[1]], + uncertain_params=[m.p[0]], + uncertainty_set=BoxSet([[0, 1]]), + local_solver=SolverFactory("ipopt"), + global_solver=SolverFactory("ipopt"), + backup_global_solvers=[UnavailableSolver()], + bypass_global_separation=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}, - ) - self.assertTrue( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, + error_msgs = LOG.getvalue()[:-1] + self.assertRegex( + error_msgs, + r"Output of `available\(\)` method.*backup global solver.*" + r"Removing from list.*", ) - def regression_test_affine_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) +class TestPyROSResolveKwargs(unittest.TestCase): + """ + Test PyROS resolves kwargs as expected. + """ - # === State Vars = [x13] - # === Decision Vars === - m.decision_vars = [m.x1, m.x2, m.x3] + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) + def test_pyros_kwargs_with_overlap(self): + """ + Test PyROS works as expected when there is overlap between + keyword arguments passed explicitly and implicitly + through `options`. + """ + m = build_leyffer_two_cons_two_params() - # === 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] + # Define the uncertainty set + # we take the parameter `u2` to be 'fixed' + ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[1, 0]) - m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) - m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) + # Instantiate the PyROS solver + pyros_solver = SolverFactory("pyros") - box_set = BoxSet(bounds=[(1.8, 2.2)]) - solver = SolverFactory("baron") - pyros = SolverFactory("pyros") - results = pyros.solve( + # Define subsolvers utilized in the algorithm + local_subsolver = SolverFactory('ipopt') + global_subsolver = SolverFactory("baron") + + # Call the PyROS solver + results = pyros_solver.solve( model=m, - first_stage_variables=m.decision_vars, + first_stage_variables=[m.x1, m.x2], 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, + uncertain_params=[m.u1, m.u2], + uncertainty_set=ellipsoid, + local_solver=local_subsolver, + global_solver=global_subsolver, + bypass_local_separation=True, + solve_master_globally=True, + options={ + "objective_focus": ObjectiveType.worst_case, + "solve_master_globally": False, + "max_iter": 1, + "time_limit": 1000, }, ) - self.assertTrue( + + # check termination status as expected + self.assertEqual( results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, + pyrosTerminationCondition.max_iter, + msg="Termination condition not as expected", + ) + self.assertEqual( + results.iterations, 1, msg="Number of iterations not as expected" ) - def regression_test_quad_drs(self): - model = m = ConcreteModel() - m.name = "s381" + # check config resolved as expected + config = results.config + self.assertEqual( + config.bypass_local_separation, + True, + msg="Resolved value of kwarg `bypass_local_separation` not as expected.", + ) + self.assertEqual( + config.solve_master_globally, + True, + msg="Resolved value of kwarg `solve_master_globally` not as expected.", + ) + self.assertEqual( + config.max_iter, + 1, + msg="Resolved value of kwarg `max_iter` not as expected.", + ) + self.assertEqual( + config.objective_focus, + ObjectiveType.worst_case, + msg="Resolved value of kwarg `objective_focus` not as expected.", + ) + self.assertEqual( + config.time_limit, + 1e3, + msg="Resolved value of kwarg `time_limit` not as expected.", + ) - 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] +class SimpleTestSolver: + """ + Simple test solver class with no actual solve() + functionality. Written to test unrelated aspects + of PyROS functionality. + """ - # === 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] + def available(self, exception_flag=False): + """ + Check solver available. + """ + return True - m.obj = Objective(expr=(m.x1 - 1) * 2, sense=minimize) - m.con1 = Constraint(expr=m.p[1] * m.x1 + m.x2 + m.x3 <= 2) + def solve(self, model, **kwds): + """ + Return SolverResults object with 'unknown' termination + condition. Model remains unchanged. + """ + res = SolverResults() + res.solver.termination_condition = TerminationCondition.unknown + + return res + + +class TestPyROSSolverAdvancedValidation(unittest.TestCase): + """ + 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. + """ + return build_leyffer() + + def test_pyros_invalid_model_type(self): + """ + Test PyROS fails if model is not of correct class. + """ + mdl = self.build_simple_test_model() + + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() - 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.assertTrue( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, - ) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_minimize_dr_norm(self): - 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." + exc_str = "Model should be of type.*but is of type.*" + with self.assertRaisesRegex(TypeError, exc_str): + pyros.solve( + model=2, + first_stage_variables=[mdl.x1], + second_stage_variables=[mdl.x2], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, ) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_identifying_violating_param_realization(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) + def test_pyros_multiple_objectives(self): + """ + Test PyROS raises exception if input model has multiple + objectives. + """ + mdl = self.build_simple_test_model() + mdl.obj2 = Objective(expr=(mdl.x1 + mdl.x2)) - 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) + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + pyros = SolverFactory("pyros") - # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) + exc_str = "Expected model with exactly 1 active.*but.*has 2" + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[mdl.x1], + second_stage_variables=[mdl.x2], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, + ) - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") + def test_pyros_empty_dof_vars(self): + """ + Test PyROS solver raises exception raised if there are no + first-stage variables or second-stage variables. + """ + # build model + mdl = self.build_simple_test_model() - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() - # 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, - }, + # perform checks + exc_str = ( + "Arguments `first_stage_variables` and " + "`second_stage_variables` are both empty lists." ) + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[], + second_stage_variables=[], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, + ) - 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).", - ) + def test_pyros_overlap_dof_vars(self): + """ + Test PyROS solver raises exception raised if there are Vars + passed as both first-stage and second-stage. + """ + # build model + mdl = self.build_simple_test_model() - @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) + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() - 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) + # perform checks + exc_str = ( + "Arguments `first_stage_variables` and `second_stage_variables` " + "contain at least one common Var object." + ) + with LoggingIntercept(level=logging.ERROR) as LOG: + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[mdl.x1], + second_stage_variables=[mdl.x1, mdl.x2], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, + ) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + # 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=( + "The following Vars were found in both `first_stage_variables`" + "and `second_stage_variables`.*" + ), + ) + 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.", + ) - # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) + @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() - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") + 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 [] - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() - # 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, - "max_iter": 1, - "decision_rule_order": 2, - }, + # 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( - results.pyros_termination_condition, - pyrosTerminationCondition.max_iter, - msg="Returned termination condition is not return max_iter.", + len(log_msgs), 3, "Error message does not contain expected number of lines." ) - - self.assertEqual( - results.iterations, - 1, - msg=( - f"Number of iterations in results object is {results.iterations}, " - f"but expected value 1." + 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.", + ) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver 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) + def test_pyros_vars_not_in_model(self): + """ + Test PyROS appropriately raises exception if there are + variables not included in active model objective + or constraints which are not descended from model. + """ + # set up model + mdl = self.build_simple_test_model() + mdl.name = "model1" + mdl2 = self.build_simple_test_model() + mdl2.name = "model2" - 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) + # set up solvers + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() + pyros = SolverFactory("pyros") - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + mdl.bad_con = Constraint(expr=mdl.x1 + mdl2.x2 >= 1) + mdl2.x3 = Var(initialize=1) - # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) + # now perform checks + 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, + ) - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") + 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}" + ) - # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') - global_subsolver = SolverFactory("baron") + def test_pyros_non_continuous_vars(self): + """ + Test PyROS raises exception if model contains + non-continuous variables. + """ + # build model; make one variable discrete + mdl = self.build_simple_test_model() + mdl.x2.domain = NonNegativeIntegers + mdl.name = "test_model" - # 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, - ) + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() - # validate termination condition + # perform checks + exc_str = "Model with name 'test_model' contains non-continuous Vars." + with LoggingIntercept(level=logging.ERROR) as LOG: + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[mdl.x1], + second_stage_variables=[mdl.x2], + uncertain_params=[mdl.u], + 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( - results.pyros_termination_condition, - pyrosTerminationCondition.time_out, - msg="Returned termination condition is not return time_out.", + len(log_msgs), 3, "Error message does not contain expected number of lines." + ) + self.assertRegex( + text=log_msgs[0], + expected_regex=( + "The following Vars of model with name 'test_model' " + "are non-continuous:" + ), + ) + self.assertRegex(text=log_msgs[1], expected_regex=" 'x2'") + self.assertRegex( + text=log_msgs[2], + expected_regex=( + "Ensure all model variables passed to " "PyROS solver are continuous." + ), ) - # 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): + def test_pyros_uncertainty_dimension_mismatch(self): """ - Test PyROS time limit status returned in event - separation problem times out. + Test PyROS solver raises exception if uncertainty + set dimension does not match the number + of uncertain parameters. """ - 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) + # build model + mdl = self.build_simple_test_model() - 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) + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SimpleTestSolver() + global_solver = SimpleTestSolver() - # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) + # perform checks + exc_str = ( + r"Length of argument `uncertain_params` does not match dimension " + r"of argument `uncertainty_set` \(1 != 2\)." + ) + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[mdl.x1], + second_stage_variables=[mdl.x2], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2], [0, 1]]), + local_solver=local_solver, + global_solver=global_solver, + ) - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + def test_pyros_nominal_point_not_in_set(self): + """ + Test PyROS raises exception if nominal point is not in the + uncertainty set. - # Define subsolvers utilized in the algorithm - local_subsolver = TimeDelaySolver( - calls_to_sleep=0, sub_solver=SolverFactory("baron"), max_time=1 - ) - global_subsolver = SolverFactory("baron") + NOTE: need executable solvers to solve set bounding problems + for validity checks. + """ + # build model + mdl = self.build_simple_test_model() - # 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, - ) + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("ipopt") - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.time_out, - msg="Returned termination condition is not return time_out.", + # perform checks + exc_str = ( + r"Nominal uncertain parameter realization \[0\] " + "is not a point in the uncertainty set.*" ) + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[mdl.x1], + second_stage_variables=[mdl.x2], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, + nominal_uncertain_param_vals=[0], + ) - @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): + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + def test_pyros_nominal_point_len_mismatch(self): """ - Test PyROS time limit status returned in event - separation problem times out. + Test PyROS raises exception if there is mismatch between length + of nominal uncertain parameter specification and number + of uncertain parameters. """ - 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) + # build model + mdl = self.build_simple_test_model() - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("ipopt") - # Define the uncertainty set - interval = BoxSet(bounds=[(0.25, 2)]) + # perform checks + exc_str = ( + r"Lengths of arguments `uncertain_params` " + r"and `nominal_uncertain_param_vals` " + r"do not match \(1 != 2\)." + ) + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[mdl.x1], + second_stage_variables=[mdl.x2], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, + nominal_uncertain_param_vals=[0, 1], + ) - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + def test_pyros_invalid_bypass_separation(self): + """ + Test PyROS raises exception if both local and + global separation are set to be bypassed. + """ + # build model + mdl = self.build_simple_test_model() - # 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 + # prepare solvers + pyros = SolverFactory("pyros") + local_solver = SolverFactory("ipopt") + global_solver = SolverFactory("ipopt") - # Call the PyROS solver - for idx, opt in enumerate(local_subsolvers): - 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=global_subsolver, - objective_focus=ObjectiveType.worst_case, - solve_master_globally=True, - time_limit=100, + # perform checks + exc_str = ( + r"Arguments `bypass_local_separation` and `bypass_global_separation` " + r"cannot both be True." + ) + with self.assertRaisesRegex(ValueError, exc_str): + pyros.solve( + model=mdl, + first_stage_variables=[mdl.x1], + second_stage_variables=[mdl.x2], + uncertain_params=[mdl.u], + uncertainty_set=BoxSet([[1 / 4, 2]]), + local_solver=local_solver, + global_solver=global_solver, + bypass_local_separation=True, + bypass_global_separation=True, ) - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg=( - f"Returned termination condition with local " - "subsolver {idx + 1} of 2 is not robust_optimal." - ), - ) + @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) - # 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" - ), + 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, ) - # check global subsolver settings unchanged + # 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( - 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" - ), + res.pyros_termination_condition, pyrosTerminationCondition.robust_infeasible ) + self.assertEqual(res.iterations, 2) - # 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" - ), - ) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_terminate_with_application_error(self): +@unittest.skipUnless(ipopt_available, "IPOPT not available.") +class TestResolveAndValidatePyROSInputs(unittest.TestCase): + def test_validate_pyros_inputs_config(self): """ - Check that PyROS correctly raises ApplicationError - in event of abnormal IPOPT termination. + Test PyROS solver input validation sets up the + final config (options) as expected. """ - 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") + model = build_leyffer_two_cons() + box_set = BoxSet(bounds=[[0.25, 2]]) - box_set = BoxSet(bounds=[(1, 2)]) + ipopt_solver = SolverFactory("ipopt") 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( - len(list(solver.options.keys())), - 1, - msg=(f"Local subsolver {solver} options were changed by PyROS"), - ) - self.assertEqual( - solver.options["halt_on_ampl_error"], - "yes", - msg=( - f"Local subsolver {solver} option " - "'halt_on_ampl_error' was changed by 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( - len(list(baron.options.keys())), - 0, - msg=(f"Global subsolver {baron} options were changed by PyROS"), + 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]) - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." - ) - def test_master_subsolver_error(self): + def test_validate_pyros_inputs_user_var_partitioning_obj_only(self): """ - Test PyROS on a two-stage problem with a subsolver error - termination in the initial master problem. + Test PyROS solver input validation sets up the user + variable partitioning/scope as expected. """ - m = ConcreteModel() + 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]]) - m.q = Param(initialize=1, mutable=True) + 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) - 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)) +# @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 + """ - m.obj = Objective(expr=log(m.x1) + m.x2) + def __init__(self, sub_solver, all_fail): + self.sub_solver = sub_solver + self.all_fail = all_fail - box_set = BoxSet(bounds=[(0, 1)]) + self.failed_flag = False + self.options = Bunch() - local_solver = SolverFactory("ipopt") - global_solver = SolverFactory("baron") - pyros_solver = SolverFactory("pyros") + def available(self, exception_flag=True): + return True - 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( - res.pyros_termination_condition, - pyrosTerminationCondition.subsolver_error, - msg=( - f"Returned termination condition for separation error" - "test is not {pyrosTerminationCondition.subsolver_error}.", - ), - ) - - @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.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, log(m.q))) - - m.obj = Objective(expr=m.x1 + m.x2) - - box_set = BoxSet(bounds=[(0, 1)]) - d_set = DiscreteScenarioSet(scenarios=[(1,), (0,)]) + def license_is_valid(self): + return True - local_solver = SolverFactory("ipopt") - global_solver = SolverFactory("baron") - pyros_solver = SolverFactory("pyros") + def __enter__(self): + return self - 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( - res.pyros_termination_condition, - pyrosTerminationCondition.subsolver_error, - msg=( - "Returned termination condition for separation error" - f"test is not {pyrosTerminationCondition.subsolver_error}." - ), - ) + def __exit__(self, et, ev, tb): + pass - # 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. + def solve(self, model, **kwargs): """ - m = ConcreteModel() - - 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))) + 'Solve' a model. - m.obj = Objective(expr=m.x1 + m.x2, sense=maximize) + Parameters + ---------- + model : ConcreteModel + Model of interest. - discrete_set = DiscreteScenarioSet(scenarios=[(1,), (0,)]) + Returns + ------- + results : SolverResults + Solver results. + """ - local_solver = SolverFactory("ipopt") - global_solver = SolverFactory("baron") - pyros_solver = SolverFactory("pyros") + # ensure only one active objective + active_objs = [ + obj for obj in model.component_data_objects(Objective, active=True) + ] + assert len(active_objs) == 1 - 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=local_solver, - global_solver=global_solver, - decision_rule_order=1, - tee=True, - ) - self.assertEqual( - res.pyros_termination_condition, - pyrosTerminationCondition.subsolver_error, - msg=( - "Returned termination condition for separation error" - f"test is not {pyrosTerminationCondition.subsolver_error}." - ), - ) + # 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() - @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. - """ - 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.obj = Objective(expr=m.x1 + m.x2) + results.solver.termination_condition = TerminationCondition.error + results.solver.status = SolverStatus.error - box_set = BoxSet(bounds=[[0, 1]]) + # record that a failure has been produced + self.failed_flag = True - local_solver = SolverFactory("baron") - global_solver = SolverFactory("baron") - pyros_solver = SolverFactory("pyros") + return results - 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, - ) + # invoke subsolver + results = self.sub_solver.solve(model, **kwargs) - @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) + return results - m.obj = Objective(expr=m.x * m.q) - 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}.", - ), - ) +@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. + """ - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver is not available and licensed." + @parameterized.expand( + [ + ("failed_but_recovered_local", 7, False), + ("failed_and_terminated_local", 10, False), + ("failed_and_terminated_global", 7, True), + ] ) - 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) + 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() - 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]]) + # 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") + # 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, m.x2], - second_stage_variables=[], + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], uncertain_params=[m.u], - uncertainty_set=discrete_scenarios, + uncertainty_set=interval, local_solver=local_subsolver, global_solver=global_subsolver, - solve_master_globally=False, - bypass_local_separation=True, options={ - "objective_focus": ObjectiveType.nominal, + "objective_focus": ObjectiveType.worst_case, "solve_master_globally": True, }, ) - # check for robust feasible termination - self.assertEqual( - results.pyros_termination_condition, - 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." - ) - 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) + 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.", + ) - m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - 1) ** 2) + @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_scenarios = DiscreteScenarioSet(scenarios=[[0.25], [2.0], [1.125]]) + discrete_set = DiscreteScenarioSet(scenarios=[[0.25], [1.125], [2]]) # Instantiate the PyROS solver pyros_solver = SolverFactory("pyros") # Define subsolvers utilized in the algorithm - local_subsolver = SolverFactory('baron') + # 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, m.x2], - second_stage_variables=[], + first_stage_variables=[m.x1], + second_stage_variables=[m.x2], uncertain_params=[m.u], - uncertainty_set=discrete_scenarios, - local_solver=local_subsolver, - global_solver=global_subsolver, - options={ - "objective_focus": ObjectiveType.worst_case, - "solve_master_globally": True, - }, - ) - - self.assertEqual( - results.pyros_termination_condition, - 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." - ) - @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( - 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": 2, - }, - ) - - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg="Returned termination condition is not return robust_optimal.", - ) - - @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) - - 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) - - interval = BoxSet(bounds=[(0.25, 2)]) - - # Instantiate the PyROS solver - pyros_solver = SolverFactory("pyros") - - # Define subsolvers utilized in the algorithm - 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=[], - uncertain_params=[m.u], - uncertainty_set=interval, + uncertainty_set=discrete_set, local_solver=local_subsolver, global_solver=global_subsolver, options={ @@ -4918,1389 +3749,21 @@ def test_coefficient_matching_solve(self): }, ) - self.assertEqual( - results.pyros_termination_condition, - 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.", - ) - - def create_mitsos_4_3(self): - """ - Create instance of Problem 4_3 from Mitsos (2011)'s - Test Set of semi-infinite programs. - """ - # 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.obj = Objective(expr=m.x1 + m.x2 / 2 + m.x3 / 3) - - 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() - - # instantiate BARON subsolver and PyROS solver - baron = SolverFactory("baron") - scip = SolverFactory("scip") - pyros_solver = SolverFactory("pyros") - - # 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, - ) + if 'recovered' in name: + # check successful termination 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" - ), - ) - - @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() - - # 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, + results.pyros_termination_condition, + pyrosTerminationCondition.robust_optimal, + msg="Did not identify robust optimal solution to problem instance.", ) + else: + # check unsuccessful termination 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" - ), - ) - - def test_coefficient_matching_raises_error_4_3(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. - """ - m = self.create_mitsos_4_3() - - # instantiate BARON subsolver and PyROS solver - baron = SolverFactory("baron") - 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." - ), - ) - 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.*" - ), + results.pyros_termination_condition, + pyrosTerminationCondition.subsolver_error, + msg="Did not report subsolver error to problem instance.", ) - 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) - - 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) - - 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( - 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 - ) - 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, - }, - ) - - -@unittest.skipUnless(scip_available, "Global NLP solver is not available.") -class testBypassingSeparation(unittest.TestCase): - 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) - - # 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('ipopt') - global_subsolver = SolverFactory("scip") - - # Call the PyROS solver - with LoggingIntercept(level=logging.WARNING) 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": True, - "decision_rule_order": 0, - "bypass_global_separation": True, - }, - ) - - # check termination robust optimal - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg="Returned termination condition is not return robust_optimal.", - ) - - # since robust optimal, we also expect warning-level logger - # message about bypassing of global separation subproblems - warning_msgs = LOG.getvalue() - self.assertRegex( - warning_msgs, - ( - r".*Option to bypass global separation was chosen\. " - r"Robust feasibility and optimality of the reported " - r"solution are not guaranteed\." - ), - ) - - -@unittest.skipUnless( - baron_available and baron_license_is_valid, - "Global NLP solver is not available and licensed.", -) -class testUninitializedVars(unittest.TestCase): - def test_uninitialized_vars(self): - """ - Test a simple PyROS model instance with uninitialized - first-stage and second-stage variables. - """ - m = ConcreteModel() - - # parameters - m.ell0 = Param(initialize=1) - m.u0 = Param(initialize=3) - m.ell = Param(initialize=1) - m.u = Param(initialize=5) - m.p = Param(initialize=m.u0, mutable=True) - m.r = Param(initialize=0.1) - - # variables - m.x = Var(bounds=(m.ell0, m.u0)) - m.z = Var(bounds=(m.ell0, m.p)) - m.t = Var(initialize=1, bounds=(0, m.r)) - m.w = Var(bounds=(0, 1)) - - # objectives - m.obj = Objective(expr=-m.x**2 + m.z**2) - - # auxiliary constraints - m.t_lb_con = Constraint(expr=m.x - m.z <= m.t) - m.t_ub_con = Constraint(expr=-m.t <= m.x - m.z) - - # other constraints - m.con1 = Constraint(expr=m.x - m.z >= 0.1) - m.eq_con = Constraint(expr=m.w == 0.5 * m.t) - - box_set = BoxSet(bounds=((value(m.ell), value(m.u)),)) - - # solvers - local_solver = SolverFactory("ipopt") - global_solver = SolverFactory("baron") - - # pyros setup - pyros_solver = SolverFactory("pyros") - - # solve for different decision rule orders - for dr_order in [0, 1, 2]: - model = m.clone() - - # degree of freedom partitioning - fsv = [model.x] - ssv = [model.z, model.t] - uncertain_params = [model.p] - - res = pyros_solver.solve( - model=model, - first_stage_variables=fsv, - second_stage_variables=ssv, - uncertain_params=uncertain_params, - uncertainty_set=box_set, - local_solver=local_solver, - global_solver=global_solver, - objective_focus=ObjectiveType.worst_case, - decision_rule_order=2, - solve_master_globally=True, - ) - - self.assertEqual( - res.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg=( - "Returned termination condition for solve with" - f"decision rule order {dr_order} is not return " - "robust_optimal." - ), - ) - - -@unittest.skipUnless(scip_available, "Global NLP solver is not available.") -class testModelMultipleObjectives(unittest.TestCase): - """ - This class contains tests for models with multiple - Objective attributes. - """ - - 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.obj2 = Objective(expr=m.obj.expr / 2) - - # add block, with another objective - m.b = Block() - m.b.obj = Objective(expr=m.obj.expr / 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('ipopt') - global_subsolver = SolverFactory("scip") - - solve_kwargs = dict( - 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": 0, - }, - ) - - # check validation error raised due to multiple objectives - with self.assertRaisesRegex( - AttributeError, - "This model structure is not currently handled by the ROSolver.", - ): - pyros_solver.solve(**solve_kwargs) - - # check validation error raised due to multiple objectives - m.b.obj.deactivate() - with self.assertRaisesRegex( - AttributeError, - "This model structure is not currently handled by the ROSolver.", - ): - pyros_solver.solve(**solve_kwargs) - - # now solve with only one active obj, - # check successful termination - m.obj2.deactivate() - res = pyros_solver.solve(**solve_kwargs) - self.assertIs( - res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal - ) - - # check active objectives - self.assertEqual(len(list(m.component_data_objects(Objective, active=True))), 1) - self.assertTrue(m.obj.active) - - # swap to maximization objective. - # and solve again - m.obj_max = Objective(expr=-m.obj.expr, sense=pyo_max) - m.obj.deactivate() - max_obj_res = pyros_solver.solve(**solve_kwargs) - - # check active objectives - self.assertEqual(len(list(m.component_data_objects(Objective, active=True))), 1) - self.assertTrue(m.obj_max.active) - - self.assertTrue( - math.isclose( - res.final_objective_value, - -max_obj_res.final_objective_value, - abs_tol=2e-4, # 2x the default robust feasibility tolerance - ), - msg=( - f"Robust optimal objective value {res.final_objective_value} " - "for problem with minimization objective not close to " - f"negative of value {max_obj_res.final_objective_value} " - "of equivalent maximization objective." - ), - ) - - -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): - """ - 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" - ) - 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) - m.x3 = Var(initialize=0, bounds=(None, None)) - m.u1 = Param(initialize=1.125, mutable=True, units=u.s) - m.u2 = Param(initialize=1, mutable=True, units=u.m**2) - - 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 - # note: second-stage variable and uncertain params have units - results = pyros_solver.solve( - model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - 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 - # and that more than one iteration required - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_optimal, - msg="Did not identify robust optimal solution to problem instance.", - ) - self.assertGreater( - results.iterations, - 1, - msg=( - "PyROS requires no more than one iteration to solve the model." - " Hence master feasibility problem construction not tested." - " Consider implementing a more challenging model for this" - " test case." - ), - ) - - -class TestSubsolverTiming(unittest.TestCase): - """ - Tests to confirm that the PyROS subsolver timing routines - work appropriately. - """ - - 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 - - @unittest.skipUnless( - SolverFactory('appsi_ipopt').available(exception_flag=False), - "Local NLP solver is not available.", - ) - def test_pyros_appsi_ipopt(self): - """ - Test PyROS usage with solver appsi ipopt - works without exceptions. - """ - m = self.simple_nlp_model() - - # 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('appsi_ipopt') - global_subsolver = SolverFactory("appsi_ipopt") - - # Call the PyROS solver - # note: second-stage variable and uncertain params have units - results = pyros_solver.solve( - model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - uncertain_params=[m.u1, m.u2], - uncertainty_set=ellipsoid, - local_solver=local_subsolver, - global_solver=global_subsolver, - objective_focus=ObjectiveType.worst_case, - solve_master_globally=False, - bypass_global_separation=True, - ) - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, - msg="Did not identify robust optimal solution to problem instance.", - ) - self.assertFalse( - math.isnan(results.time), - msg=( - "PyROS solve time is nan (expected otherwise since subsolver" - "time estimates are made using TicTocTimer" - ), - ) - - @unittest.skipUnless( - SolverFactory('gams:ipopt').available(exception_flag=False), - "Local NLP solver GAMS/IPOPT is not available.", - ) - def test_pyros_gams_ipopt(self): - """ - Test PyROS usage with solver GAMS ipopt - works without exceptions. - """ - m = self.simple_nlp_model() - - # 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('gams:ipopt') - global_subsolver = SolverFactory("gams:ipopt") - - # Call the PyROS solver - # note: second-stage variable and uncertain params have units - results = pyros_solver.solve( - model=m, - first_stage_variables=[m.x1], - second_stage_variables=[m.x2], - uncertain_params=[m.u1, m.u2], - uncertainty_set=ellipsoid, - local_solver=local_subsolver, - global_solver=global_subsolver, - objective_focus=ObjectiveType.worst_case, - solve_master_globally=False, - bypass_global_separation=True, - ) - self.assertEqual( - results.pyros_termination_condition, - pyrosTerminationCondition.robust_feasible, - msg="Did not identify robust optimal solution to problem instance.", - ) - self.assertFalse( - math.isnan(results.time), - msg=( - "PyROS solve time is nan (expected otherwise since subsolver" - "time estimates are made using TicTocTimer" - ), - ) - - @unittest.skipUnless( - baron_license_is_valid, "Global NLP solver 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) - - # construct the IntersectionSet - ellipsoid = AxisAlignedEllipsoidalSet(center=[1.125, 1], half_lengths=[1, 0]) - bset = BoxSet(bounds=[[1, 2], [0.5, 1.5]]) - iset = IntersectionSet(ellipsoid=ellipsoid, bset=bset) - - # 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=iset, - 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 TestIterationLogRecord(unittest.TestCase): - """ - Test the PyROS `IterationLogRecord` class. - """ - - def test_log_header(self): - """Test method for logging iteration log table header.""" - ans = ( - "------------------------------------------------------------------------------\n" - "Itn Objective 1-Stg Shift 2-Stg Shift #CViol Max Viol Wall Time (s)\n" - "------------------------------------------------------------------------------\n" - ) - with LoggingIntercept(level=logging.INFO) as LOG: - IterationLogRecord.log_header(logger.info) - - self.assertEqual( - LOG.getvalue(), - ans, - msg="Messages logged for iteration table header do not match expected result", - ) - - def test_log_standard_iter_record(self): - """Test logging function for PyROS IterationLogRecord.""" - - # for some fields, we choose floats with more than four - # four decimal points to ensure rounding also matches - iter_record = IterationLogRecord( - iteration=4, - objective=1.234567, - first_stage_var_shift=2.3456789e-8, - second_stage_var_shift=3.456789e-7, - dr_var_shift=1.234567e-7, - num_violated_cons=10, - max_violation=7.654321e-3, - elapsed_time=21.2, - dr_polishing_success=True, - all_sep_problems_solved=True, - global_separation=False, - ) - - # now check record logged as expected - ans = ( - "4 1.2346e+00 2.3457e-08 3.4568e-07 10 7.6543e-03 " - "21.200 \n" - ) - with LoggingIntercept(level=logging.INFO) as LOG: - iter_record.log(logger.info) - result = LOG.getvalue() - - self.assertEqual( - ans, - result, - msg="Iteration log record message does not match expected result", - ) - - def test_log_iter_record_polishing_failed(self): - """Test iteration log record in event of polishing failure.""" - # for some fields, we choose floats with more than four - # four decimal points to ensure rounding also matches - iter_record = IterationLogRecord( - iteration=4, - objective=1.234567, - first_stage_var_shift=2.3456789e-8, - second_stage_var_shift=3.456789e-7, - dr_var_shift=1.234567e-7, - num_violated_cons=10, - max_violation=7.654321e-3, - elapsed_time=21.2, - dr_polishing_success=False, - all_sep_problems_solved=True, - global_separation=False, - ) - - # now check record logged as expected - ans = ( - "4 1.2346e+00 2.3457e-08 3.4568e-07* 10 7.6543e-03 " - "21.200 \n" - ) - with LoggingIntercept(level=logging.INFO) as LOG: - iter_record.log(logger.info) - result = LOG.getvalue() - - self.assertEqual( - ans, - result, - msg="Iteration log record message does not match expected result", - ) - - def test_log_iter_record_global_separation(self): - """ - Test iteration log record in event global separation performed. - In this case, a 'g' should be appended to the max violation - reported. Useful in the event neither local nor global separation - was bypassed. - """ - # for some fields, we choose floats with more than four - # four decimal points to ensure rounding also matches - iter_record = IterationLogRecord( - iteration=4, - objective=1.234567, - first_stage_var_shift=2.3456789e-8, - second_stage_var_shift=3.456789e-7, - dr_var_shift=1.234567e-7, - num_violated_cons=10, - max_violation=7.654321e-3, - elapsed_time=21.2, - dr_polishing_success=True, - all_sep_problems_solved=True, - global_separation=True, - ) - - # now check record logged as expected - ans = ( - "4 1.2346e+00 2.3457e-08 3.4568e-07 10 7.6543e-03g " - "21.200 \n" - ) - with LoggingIntercept(level=logging.INFO) as LOG: - iter_record.log(logger.info) - result = LOG.getvalue() - - self.assertEqual( - ans, - result, - msg="Iteration log record message does not match expected result", - ) - - def test_log_iter_record_not_all_sep_solved(self): - """ - Test iteration log record in event not all separation problems - were solved successfully. This may have occurred if the PyROS - 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. - """ - # for some fields, we choose floats with more than four - # four decimal points to ensure rounding also matches - iter_record = IterationLogRecord( - iteration=4, - objective=1.234567, - first_stage_var_shift=2.3456789e-8, - second_stage_var_shift=3.456789e-7, - dr_var_shift=1.234567e-7, - num_violated_cons=10, - max_violation=7.654321e-3, - elapsed_time=21.2, - dr_polishing_success=True, - all_sep_problems_solved=False, - global_separation=False, - ) - - # now check record logged as expected - ans = ( - "4 1.2346e+00 2.3457e-08 3.4568e-07 10+ 7.6543e-03 " - "21.200 \n" - ) - with LoggingIntercept(level=logging.INFO) as LOG: - iter_record.log(logger.info) - result = LOG.getvalue() - - self.assertEqual( - ans, - result, - msg="Iteration log record message does not match expected result", - ) - - def test_log_iter_record_all_special(self): - """ - Test iteration log record in event DR polishing and global - separation failed. - """ - # for some fields, we choose floats with more than four - # four decimal points to ensure rounding also matches - iter_record = IterationLogRecord( - iteration=4, - objective=1.234567, - first_stage_var_shift=2.3456789e-8, - second_stage_var_shift=3.456789e-7, - dr_var_shift=1.234567e-7, - num_violated_cons=10, - max_violation=7.654321e-3, - elapsed_time=21.2, - dr_polishing_success=False, - all_sep_problems_solved=False, - global_separation=True, - ) - - # now check record logged as expected - ans = ( - "4 1.2346e+00 2.3457e-08 3.4568e-07* 10+ 7.6543e-03g " - "21.200 \n" - ) - with LoggingIntercept(level=logging.INFO) as LOG: - iter_record.log(logger.info) - result = LOG.getvalue() - - self.assertEqual( - ans, - result, - msg="Iteration log record message does not match expected result", - ) - - def test_log_iter_record_attrs_none(self): - """ - Test logging of iteration record in event some - attributes are of value `None`. In this case, a '-' - should be printed in lieu of a numerical value. - Example where this occurs: the first iteration, - in which there is no first-stage shift or DR shift. - """ - # for some fields, we choose floats with more than four - # four decimal points to ensure rounding also matches - iter_record = IterationLogRecord( - iteration=0, - objective=-1.234567, - first_stage_var_shift=None, - second_stage_var_shift=None, - dr_var_shift=None, - num_violated_cons=10, - max_violation=7.654321e-3, - elapsed_time=21.2, - dr_polishing_success=True, - all_sep_problems_solved=False, - global_separation=True, - ) - - # now check record logged as expected - ans = ( - "0 -1.2346e+00 - - 10+ 7.6543e-03g " - "21.200 \n" - ) - with LoggingIntercept(level=logging.INFO) as LOG: - iter_record.log(logger.info) - result = LOG.getvalue() - - self.assertEqual( - ans, - result, - msg="Iteration log record message does not match expected result", - ) - - -class TestROSolveResults(unittest.TestCase): - """ - Test PyROS solver results object. - """ - - def test_ro_solve_results_str(self): - """ - Test string representation of RO solve results object. - """ - res = ROSolveResults( - config=SolverFactory("pyros").CONFIG(), - iterations=4, - final_objective_value=123.456789, - time=300.34567, - pyros_termination_condition=pyrosTerminationCondition.robust_optimal, - ) - ans = ( - "Termination stats:\n" - " Iterations : 4\n" - " Solve time (wall s) : 300.346\n" - " Final objective value : 1.2346e+02\n" - " Termination condition : pyrosTerminationCondition.robust_optimal" - ) - self.assertEqual( - str(res), - ans, - msg=( - "String representation of PyROS results object does not " - "match expected value" - ), - ) - - def test_ro_solve_results_str_attrs_none(self): - """ - Test string representation of PyROS solve results in event - one of the printed attributes is of value `None`. - This may occur at instantiation or, for example, - whenever the PyROS solver confirms robust infeasibility through - coefficient matching. - """ - res = ROSolveResults( - config=SolverFactory("pyros").CONFIG(), - iterations=0, - final_objective_value=None, - time=300.34567, - pyros_termination_condition=pyrosTerminationCondition.robust_optimal, - ) - ans = ( - "Termination stats:\n" - " Iterations : 0\n" - " Solve time (wall s) : 300.346\n" - " Final objective value : None\n" - " Termination condition : pyrosTerminationCondition.robust_optimal" - ) - self.assertEqual( - str(res), - ans, - msg=( - "String representation of PyROS results object does not " - "match expected value" - ), - ) - - -class TestPyROSSolverLogIntros(unittest.TestCase): - """ - Test logging of introductory information by PyROS solver. - """ - - def test_log_config(self): - """ - Test method for logging PyROS solver config dict. - """ - pyros_solver = SolverFactory("pyros") - config = pyros_solver.CONFIG(dict(nominal_uncertain_param_vals=[0.5])) - with LoggingIntercept(level=logging.INFO) as LOG: - pyros_solver._log_config(logger=logger, config=config, level=logging.INFO) - - ans = ( - "Solver options:\n" - " time_limit=None\n" - " keepfiles=False\n" - " tee=False\n" - " load_solution=True\n" - " objective_focus=\n" - " nominal_uncertain_param_vals=[0.5]\n" - " decision_rule_order=0\n" - " solve_master_globally=False\n" - " max_iter=-1\n" - " robust_feasibility_tolerance=0.0001\n" - " separation_priority_order={}\n" - " progress_logger=\n" - " backup_local_solvers=[]\n" - " backup_global_solvers=[]\n" - " subproblem_file_directory=None\n" - " bypass_local_separation=False\n" - " bypass_global_separation=False\n" - " p_robustness={}\n" + "-" * 78 + "\n" - ) - - logged_str = LOG.getvalue() - self.assertEqual( - logged_str, - ans, - msg=( - "Logger output for PyROS solver config (default case) " - "does not match expected result." - ), - ) - - 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) - - intro_msgs = LOG.getvalue() - - # last character should be newline; disregard it - intro_msg_lines = intro_msgs.split("\n")[:-1] - - # check number of lines is as expected - self.assertEqual( - len(intro_msg_lines), - 14, - msg=( - "PyROS solver introductory message does not contain" - "the expected number of lines." - ), - ) - - # first and last lines of the introductory section - self.assertEqual(intro_msg_lines[0], "=" * 78) - self.assertEqual(intro_msg_lines[-1], "=" * 78) - - # check regex main text - self.assertRegex( - " ".join(intro_msg_lines[1:-1]), - r"PyROS: The Pyomo Robust Optimization Solver, v.* \(IDAES\)\.", - ) - - def test_log_disclaimer(self): - """ - Test logging of PyROS solver disclaimer messages. - """ - pyros_solver = SolverFactory("pyros") - with LoggingIntercept(level=logging.INFO) as LOG: - pyros_solver._log_disclaimer(logger=logger, level=logging.INFO) - - disclaimer_msgs = LOG.getvalue() - - # last character should be newline; disregard it - disclaimer_msg_lines = disclaimer_msgs.split("\n")[:-1] - - # check number of lines is as expected - self.assertEqual( - len(disclaimer_msg_lines), - 5, - msg=( - "PyROS solver disclaimer message does not contain" - "the expected number of lines." - ), - ) - - # regex first line of disclaimer section - self.assertRegex(disclaimer_msg_lines[0], r"=.* DISCLAIMER .*=") - # check last line of disclaimer section - self.assertEqual(disclaimer_msg_lines[-1], "=" * 78) - - # check regex main text - self.assertRegex( - " ".join(disclaimer_msg_lines[1:-1]), - r"PyROS is still under development.*ticket at.*", - ) - 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..b87495cbcd8 --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_master.py @@ -0,0 +1,718 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +""" +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.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, + 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(), + ) + 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( + master_model.scenarios[0, 0].first_stage.equality_cons.values(), + master_model.scenarios[0, 1].first_stage.equality_cons.values(), + ) + 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..04331dee95c --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_preprocessor.py @@ -0,0 +1,2908 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +""" +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 +from pyomo.core.expr.compare import assertExpressionsEqual + +from pyomo.contrib.pyros.util import ( + ModelData, + ObjectiveType, + 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.x1 = Var(bounds=(2, 2)) + m.x2 = Var() + m.z = Var() + m.y = Var(range(1, 5)) + m.q = Param(mutable=True, initialize=1) + + m.c0 = Constraint(expr=m.q + m.x1 + m.z == 0) + m.c1 = Constraint(expr=(0, m.x1 - m.z, 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] + + 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.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] + + 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.c1 = Constraint(expr=m.x1 <= 1) + m.c2 = Constraint(expr=(1, m.x1, 2)) + 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.c11.deactivate() + + model_data.working_model.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), 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)) + + # 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) + + # first-stage equalities + m.eq1 = Constraint(expr=m.x1 + log(m.p) == 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] + + model_data.working_model.first_stage = Block() + model_data.working_model.first_stage.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.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) == 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.obj1 = Objective( + expr=( + 10 + m.p + m.q + m.p * 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] + + 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.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.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(3), 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()) + + 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.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.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(3), 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()) + + 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): + """ + Set up simple test model for testing the reformulation + routine. + """ + model_data = Bunch() + model_data.config = Bunch() + 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.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.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] + + working_model.first_stage = Block() + working_model.first_stage.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" + ), + ) + + first_stage_eq_cons = model_data.working_model.first_stage.equality_cons + self.assertEqual( + len(first_stage_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, + first_stage_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, + first_stage_eq_cons["coeff_matching_eq_con_coeff_2"].expr, + m.x2 - 1 == 0, + ) + assertExpressionsEqual( + self, + first_stage_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" + ), + ) + + # 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.equality_cons), 1) + + 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( + wm.first_stage.equality_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.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.x1) * m.x2) + - (-m.u) * (m.x1 + 2) + <= 0.0 + ), + ) + assertExpressionsEqual( + self, + wm.first_stage.equality_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_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.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) + + # 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) == 0) + # ranged constraints with identical bounds are considered equalities + # this makes z1 nonadjustable + m.eq2 = Constraint(expr=m.x1 - m.z1 == 0) + # pretriangular: makes z2 nonadjustable, so first-stage + m.eq3 = Constraint(expr=m.x1**2 + m.x2 + 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)) + # 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) + + 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()) + + # 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() + om = model_data.original_model + config = model_data.config + config.update( + dict( + uncertain_params=[om.q, om.q2var], + nominal_uncertain_param_vals=[om.q.value, om.q2var.value], + 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() + om = model_data.original_model + config = model_data.config + config.update( + dict( + uncertain_params=[om.q], + 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() + om = model_data.original_model + model_data.config.update( + dict( + uncertain_params=[om.q, om.q2var], + nominal_uncertain_param_vals=[om.q.value, om.q2var.value], + 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.equality_cons), + ["eq_con_eq2", "eq_con_eq3"] + 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 working_model.first_stage.equality_cons.values(): + 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), 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.equality_cons["eq_con_eq2"].expr, m.x1 - m.z1 == 0 + ) + assertExpressionsEqual( + self, + fs.equality_cons["eq_con_eq3"].expr, + m.x1**2 + m.x2 + 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() + om = model_data.original_model + config = model_data.config + config.update( + dict( + uncertain_params=[om.q, om.q2var], + nominal_uncertain_param_vals=[om.q.value, om.q2var.value], + 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 + fs_eqs = working_model.first_stage.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, + fs_eqs["coeff_matching_var_z5_uncertain_eq_bound_con_coeff_0"].expr, + fs.decision_rule_vars[1][0] == 0, + ) + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_var_z5_uncertain_eq_bound_con_coeff_1"].expr, + fs.decision_rule_vars[1][1] - 1 == 0, + ) + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_var_z5_uncertain_eq_bound_con_coeff_2"].expr, + fs.decision_rule_vars[1][2] == 0, + ) + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_eq_con_eq1_coeff_1"].expr, + fs.decision_rule_vars[0][0] + m.x2 == 0, + ) + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_eq_con_eq1_coeff_2"].expr, + fs.decision_rule_vars[0][1] == 0, + ) + assertExpressionsEqual( + self, + 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)) <= 0.0, + ) + assertExpressionsEqual( + self, + ss_ineqs["reform_upper_bound_from_eq_con_eq1"].expr, + m.q * (m.z3 + m.x2) <= 0.0, + ) + + # check coefficient matching constraint expressions + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_var_z5_uncertain_eq_bound_con_coeff_0"].expr, + fs.decision_rule_vars[1][0] == 0, + ) + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_var_z5_uncertain_eq_bound_con_coeff_1"].expr, + fs.decision_rule_vars[1][1] - 1 == 0, + ) + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_var_z5_uncertain_eq_bound_con_coeff_2"].expr, + fs.decision_rule_vars[1][2] == 0, + ) + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_var_z5_uncertain_eq_bound_con_coeff_3"].expr, + fs.decision_rule_vars[1][3] == 0, + ) + assertExpressionsEqual( + self, + fs_eqs["coeff_matching_var_z5_uncertain_eq_bound_con_coeff_4"].expr, + fs.decision_rule_vars[1][4] == 0, + ) + assertExpressionsEqual( + self, + 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() + om = model_data.original_model + config = model_data.config + config.update( + dict( + uncertain_params=[om.q, om.q2var], + nominal_uncertain_param_vals=[om.q.value, om.q2var.value], + 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) + 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() + om = model_data.original_model + config = model_data.config + config.update( + dict( + uncertain_params=[om.q, om.q2var], + nominal_uncertain_param_vals=[om.q.value, om.q2var.value], + 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 : 2 + 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() + om = model_data.original_model + config = model_data.config + config.update( + dict( + uncertain_params=[om.q, om.q2var], + nominal_uncertain_param_vals=[om.q.value, om.q2var.value], + 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 : 2 + 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..3fcebfa063a --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_separation.py @@ -0,0 +1,606 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +""" +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] + u2_var = separation_model.uncertainty.uncertain_param_var_list[1] + assertExpressionsEqual( + self, + separation_model.second_stage.decision_rule_eqns[0].expr, + ( + separation_model.first_stage.decision_rule_vars[0][0] + + u1_var * separation_model.first_stage.decision_rule_vars[0][1] + + u2_var * separation_model.first_stage.decision_rule_vars[0][2] + - 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) + self.assertTrue(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") + model_data.config.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)) + + +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_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..e0e5bb7d137 --- /dev/null +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -0,0 +1,2502 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +""" +Tests for the PyROS UncertaintySet class and subclasses. +""" + +import itertools as it +import pyomo.common.unittest as unittest + +from pyomo.common.dependencies import ( + attempt_import, + numpy as np, + numpy_available, + scipy as sp, + scipy_available, +) +from pyomo.environ import SolverFactory +from pyomo.core.base import ConcreteModel, Param, Var +from pyomo.core.expr import RangedExpression +from pyomo.core.expr.compare import assertExpressionsEqual + +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)) + + +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)) + + +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)) + + +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]) + + +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)) + + +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)) + + +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)) + + +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)) + + +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)) + + +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) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 1b51e41fcaf..a4b6ba6aa1a 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1,66 +1,200 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +""" +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. """ -Abstract and pre-defined classes for representing uncertainty sets (or -uncertain parameter spaces) of two-stage nonlinear robust optimization -models. -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, + 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( @@ -146,11 +280,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 @@ -181,7 +328,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( @@ -206,7 +369,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. @@ -233,6 +402,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) @@ -256,9 +434,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}" ) @@ -272,19 +456,6 @@ def generate_shape_str(shape, required_shape): ) -def uncertainty_sets(obj): - if not isinstance(obj, UncertaintySet): - raise ValueError( - "Expected an UncertaintySet object, instead received %s" % (obj,) - ) - return obj - - -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. @@ -334,33 +505,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): @@ -397,32 +561,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): """ @@ -438,21 +589,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 @@ -477,55 +635,124 @@ 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 + ) + + return is_in_set - # === Generate constraint for set - set_constraint = self.set_as_constraint(uncertain_params=the_params) + def _compute_parameter_bounds(self, solver): + """ + Compute coordinate value bounds for every dimension + of `self` by solving a bounding model. + """ + bounding_model = self._create_bounding_model() + param_bounds = [] + for idx, obj in bounding_model.param_var_objectives.items(): + # activate objective for corresponding dimension + obj.activate() + bounds = [] - # === value() returns True if the constraint is satisfied, False else. - is_in_set = all(value(con.expr) for con in set_constraint.values()) + # 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)) - return is_in_set + # add parameter bounds for current dimension + param_bounds.append(tuple(bounds)) - @staticmethod - def add_bounds_on_uncertain_parameters(**kwargs): + # 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__}." + ) class UncertaintySetList(MutableSequence): @@ -721,7 +948,7 @@ def dim(self): class BoxSet(UncertaintySet): """ - A hyper-rectangle (a.k.a. "box"). + A hyper-rectangle (i.e., "box"). Parameters ---------- @@ -832,40 +1059,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 ---------- @@ -1043,49 +1262,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) + + 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) - conlist.add(sum(model.util.cassi[i] for i in set_i) <= self.gamma) + 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 conlist + return aux_space_pt def point_in_set(self, point): """ @@ -1101,17 +1329,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): @@ -1174,7 +1398,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), ) @@ -1182,7 +1406,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: @@ -1315,68 +1539,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. - - Parameters - ---------- - model : ConcreteModel - Model of interest (parent model of the uncertain parameter - objects for which to specify bounds). - config : ConfigDict - PyROS solver config. + 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) - 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): @@ -1658,64 +1838,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 ---------- @@ -1723,14 +1853,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 @@ -1745,7 +1878,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 @@ -1753,10 +1886,10 @@ 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 """ @@ -1808,13 +1941,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 @@ -1835,10 +1970,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 @@ -1865,13 +2002,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 @@ -1886,7 +2025,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 @@ -1971,57 +2110,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): """ @@ -2037,18 +2179,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): @@ -2195,63 +2332,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): @@ -2263,31 +2374,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: @@ -2305,13 +2422,42 @@ 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): + 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): @@ -2345,7 +2491,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}" ) @@ -2424,7 +2570,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})" @@ -2447,12 +2593,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): @@ -2493,54 +2667,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): @@ -2656,41 +2830,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): """ @@ -2707,14 +2867,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): @@ -2838,61 +3004,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 ---------- @@ -2901,113 +3019,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 e2986ae18c7..a3206b2ccbb 100644 --- a/pyomo/contrib/pyros/util.py +++ b/pyomo/contrib/pyros/util.py @@ -1,48 +1,65 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + ''' 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 @@ -51,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: @@ -188,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) @@ -219,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. @@ -249,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: @@ -278,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 @@ -286,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 @@ -333,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 @@ -348,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): @@ -434,51 +494,6 @@ def setup_pyros_logger(name=DEFAULT_LOGGER_NAME): return logger -def a_logger(str_or_logger): - """ - Standardize a string or logger object to a logger object. - - Parameters - ---------- - str_or_logger : str or logging.Logger - String or logger object to normalize. - - 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. In the event `str_or_logger` is - the name of the default PyROS logger, the logger level - is set to `logging.INFO`, and a `PreformattedLogger` - instance is returned in lieu of a standard `Logger` - instance. - """ - if isinstance(str_or_logger, logging.Logger): - return logging.getLogger(str_or_logger.name) - else: - return logging.getLogger(str_or_logger) - - -def ValidEnum(enum_class): - ''' - Python 3 dependent format string - ''' - - def fcn(obj): - if obj not in enum_class: - raise ValueError( - "Expected an {0} object, " - "instead received {1}".format( - enum_class.__name__, obj.__class__.__name__ - ) - ) - return obj - - return fcn - - class pyrosTerminationCondition(Enum): """Enumeration of all possible PyROS termination conditions.""" @@ -536,782 +551,2181 @@ 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 - + 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. -def model_is_valid(model): - """ - Assess whether model is valid on basis of the number of active - Objectives. A valid model must contain exactly one active Objective. + Returns + ------- + list of ComponentData + The ComponentData objects derived from `obj`. + Note: If `obj` is a valid ComponentData type, + then ``[obj]`` is returned. + + 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`. """ - return len(list(model.component_data_objects(Objective, active=True))) == 1 - - -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 + 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__})." + ) + 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: - lb_args = (lb,) + 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"{valid_ctype.__name__} or component data type " + f"{valid_cdatatype.__name__} (got type {type(obj).__name__})." + ) - if isinstance(ub, NPV_MinExpression): - ub_args = ub.args - else: - ub_args = (ub,) + # 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." + ) - 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) + return ans -def get_time_from_solver(results): +def check_components_descended_from_model(model, components, components_name, config): """ - Obtain solver time from a Pyomo `SolverResults` object. + Check all members in a provided sequence of Pyomo component + objects are descended from a given ConcreteModel object. - 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. + Parameters + ---------- + model : ConcreteModel + Model from which components should all be descended. + components : Iterable of Component + Components of interest. + components_name : str + Brief description or name for the sequence of components. + Used for constructing error messages. + config : ConfigDict + PyROS solver options. - 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 + Raises + ------ + ValueError + If at least one entry of `components` is not descended + from `model`. + """ + components_not_in_model = [comp for comp in components if comp.model() is not model] + if components_not_in_model: + comp_names_str = "\n ".join( + f"{comp.name!r}, from model with name {comp.model().name!r}" + for comp in components_not_in_model + ) + config.progress_logger.error( + f"The following {components_name} " + "are not descended from the " + f"input deterministic model with name {model.name!r}:\n " + f"{comp_names_str}" + ) + raise ValueError( + f"Found {components_name} " + "not descended from input model. " + "Check logger output messages." + ) - return float("nan") if solve_time is None else solve_time +def check_variables_continuous(model, vars, config): + """ + Check that all DOF and state variables of the model + are continuous. + + Parameters + ---------- + model : ConcreteModel + Input deterministic model. + config : ConfigDict + PyROS solver options. -def validate_uncertainty_set(config): - ''' - Confirm expression output from uncertainty set function references all q in q. - Typecheck the uncertainty_set.q is Params referenced inside of m. - Give warning that the nominal point (default value in the model) is not in the specified uncertainty set. - :param config: solver config - ''' - # === Check that q in UncertaintySet object constraint expression is referencing q in model.uncertain_params - uncertain_params = config.uncertain_params + Raises + ------ + ValueError + If at least one variable is found to not be continuous. - # === Non-zero number of uncertain parameters - if len(uncertain_params) == 0: - raise AttributeError( - "Must provide uncertain params, uncertain_params list length is 0." + Note + ---- + A variable is considered continuous if the `is_continuous()` + method returns True. + """ + non_continuous_vars = [var for var in vars if not var.is_continuous()] + if non_continuous_vars: + non_continuous_vars_str = "\n ".join( + f"{var.name!r}" for var in non_continuous_vars ) - # === No duplicate parameters - if len(uncertain_params) != len(ComponentSet(uncertain_params)): - raise AttributeError("No duplicates allowed for uncertain param objects.") - # === Ensure nominal point is in the set - if not config.uncertainty_set.point_in_set( - point=config.nominal_uncertain_param_vals - ): - raise AttributeError( - "Nominal point for uncertain parameters must be in the uncertainty set." + config.progress_logger.error( + f"The following Vars of model with name {model.name!r} " + f"are non-continuous:\n {non_continuous_vars_str}\n" + "Ensure all model variables passed to PyROS solver are continuous." ) - # === Check set validity via boundedness and non-emptiness - if not config.uncertainty_set.is_valid(config=config): - raise AttributeError( - "Invalid uncertainty set detected. Check the uncertainty set object to " - "ensure non-emptiness and boundedness." + raise ValueError( + f"Model with name {model.name!r} contains non-continuous Vars." ) - return - - -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 - ) - 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, - ), - ) +def validate_model(model, config): + """ + Validate deterministic model passed to PyROS solver. - 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) - ) - bounding_model.add_component( - "ub_obj_" + str(idx), Objective(expr=param, sense=maximize) - ) + Parameters + ---------- + model : ConcreteModel + Deterministic model. Should have only one active Objective. + config : ConfigDict + PyROS solver options. - for o in bounding_model.component_data_objects(Objective): - o.deactivate() + Returns + ------- + ComponentSet + The variables participating in the active Objective + and Constraint expressions of `model`. + + Raises + ------ + TypeError + If model is not of type ConcreteModel. + ValueError + If model does not have exactly one active Objective + component. + """ + # note: only support ConcreteModel. no support for Blocks + if not isinstance(model, ConcreteModel): + raise TypeError( + f"Model should be of type {ConcreteModel.__name__}, " + f"but is of type {type(model).__name__}." + ) - 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) + # active objectives check + active_objs_list = list( + model.component_data_objects(Objective, active=True, descend_into=True) + ) + if len(active_objs_list) != 1: + raise ValueError( + "Expected model with exactly 1 active objective, but " + f"model provided has {len(active_objs_list)}." + ) - # === 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 +VariablePartitioning = namedtuple( + "VariablePartitioning", + ("first_stage_variables", "second_stage_variables", "state_variables"), +) -def transform_to_standard_form(model): +def validate_variable_partitioning(model, config): """ - 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. + Check that the partitioning of the in-scope variables of the + model is valid. Parameters ---------- model : ConcreteModel - The model to search for constraints. This will descend into all - active Blocks and sub-Blocks as well. + Input deterministic model. + config : ConfigDict + PyROS solver options. - 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) + Returns + ------- + list of VarData + State variables of the model. + + Raises + ------ + ValueError + If first-stage variables and second-stage variables + overlap, or there are no first-stage variables + and no second-stage variables. + """ + # at least one DOF required + if not config.first_stage_variables and not config.second_stage_variables: + raise ValueError( + "Arguments `first_stage_variables` and " + "`second_stage_variables` are both empty lists." + ) + + # ensure no overlap between DOF var sets + overlapping_vars = ComponentSet(config.first_stage_variables) & ComponentSet( + config.second_stage_variables ) - 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() + if overlapping_vars: + overlapping_var_list = "\n ".join(f"{var.name!r}" for var in overlapping_vars) + config.progress_logger.error( + "The following Vars were found in both `first_stage_variables`" + f"and `second_stage_variables`:\n {overlapping_var_list}" + "\nEnsure no Vars are included in both arguments." + ) + raise ValueError( + "Arguments `first_stage_variables` and `second_stage_variables` " + "contain at least one common Var object." + ) + # 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, + ) + check_variables_continuous(model, active_model_vars, config) -def get_vars_from_component(block, ctype): - """Determine all variables used in active components within a block. + 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) - 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 VariablePartitioning( + list(first_stage_vars), list(second_stage_vars), list(state_vars) + ) - """ - return get_vars_from_components(block, ctype, active=True, descend_into=True) +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. -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. + Parameters + ---------- + var_or_param_data : VarData or ParamData + Object to be evaluated. - :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 + Returns + ------- + object + Value of the VarData/ParamData object. + The value is typically of a numeric type. """ - 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, - ) + 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__}." + ) - # 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)) - - # 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 value(expr_to_evaluate, exception=True) -def validate_kwarg_inputs(model, config): - ''' - Confirm kwarg inputs satisfy PyROS requirements. - :param model: the deterministic model - :param config: the config for this PyROS instance - :return: - ''' +def validate_uncertainty_specification(model, config): + """ + Validate specification of uncertain parameters and uncertainty + set. - # === Check if model is ConcreteModel object - if not isinstance(model, ConcreteModel): - raise ValueError("Model passed to PyROS solver must be a ConcreteModel object.") + Parameters + ---------- + model : ConcreteModel + Input deterministic model. + config : ConfigDict + PyROS solver options. - first_stage_variables = config.first_stage_variables - second_stage_variables = config.second_stage_variables - uncertain_params = config.uncertain_params + Raises + ------ + 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 + true. + - nominal parameter realization is not in the uncertainty set. + """ + check_components_descended_from_model( + model=model, + components=config.uncertain_params, + components_name="uncertain parameters", + config=config, + ) - if not config.first_stage_variables and not config.second_stage_variables: - # Must have non-zero DOF - raise ValueError( - "first_stage_variables and " - "second_stage_variables cannot both be empty lists." + 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 ComponentSet(first_stage_variables) != ComponentSet( - config.first_stage_variables - ): + if len(config.uncertain_params) != config.uncertainty_set.dim: raise ValueError( - "All elements in first_stage_variables must be Var members of the model object." + "Length of argument `uncertain_params` does not match dimension " + "of argument `uncertainty_set` " + f"({len(config.uncertain_params)} != {config.uncertainty_set.dim})." ) - if ComponentSet(second_stage_variables) != ComponentSet( - config.second_stage_variables - ): + # validate uncertainty set + if not config.uncertainty_set.is_valid(config=config): raise ValueError( - "All elements in second_stage_variables must be Var members of the model object." + f"Uncertainty set {config.uncertainty_set} is invalid, " + "as it is either empty or unbounded." ) - if any( - v in ComponentSet(second_stage_variables) - for v in ComponentSet(first_stage_variables) - ): + # fill-in nominal point as necessary, if not provided. + # otherwise, check length matches uncertainty dimension + if not config.nominal_uncertain_param_vals: + config.nominal_uncertain_param_vals = [ + # 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( - "No common elements allowed between first_stage_variables and second_stage_variables." + "Lengths of arguments `uncertain_params` and " + "`nominal_uncertain_param_vals` " + "do not match " + f"({len(config.uncertain_params)} != " + f"{len(config.nominal_uncertain_param_vals)})." ) - if ComponentSet(uncertain_params) != ComponentSet(config.uncertain_params): + # uncertainty set should contain nominal point + nominal_point_in_set = config.uncertainty_set.point_in_set( + point=config.nominal_uncertain_param_vals + ) + if not nominal_point_in_set: raise ValueError( - "uncertain_params must be mutable Param members of the model object." + "Nominal uncertain parameter realization " + f"{config.nominal_uncertain_param_vals} " + "is not a point in the uncertainty set " + f"{config.uncertainty_set!r}." ) - if not config.uncertainty_set: - raise ValueError( - "An UncertaintySet object must be provided to the PyROS solver." - ) - non_mutable_params = [] - for p in config.uncertain_params: - if not ( - not p.is_constant() and p.is_fixed() and not p.is_potentially_variable() - ): - non_mutable_params.append(p) - if non_mutable_params: - raise ValueError( - "Param objects which are uncertain must have attribute mutable=True. " - "Offending Params: %s" % [p.name for p in non_mutable_params] - ) +def validate_separation_problem_options(model, config): + """ + Validate separation problem arguments to the PyROS solver. - # === Solvers provided check - if not config.local_solver or not config.global_solver: - raise ValueError( - "User must designate both a local and global optimization solver via the local_solver" - " and global_solver options." - ) + Parameters + ---------- + model : ConcreteModel + Input deterministic model. + config : ConfigDict + PyROS solver options. + Raises + ------ + ValueError + If options `bypass_local_separation` and + `bypass_global_separation` are set to False. + """ if config.bypass_local_separation and config.bypass_global_separation: raise ValueError( - "User cannot simultaneously enable options " - "'bypass_local_separation' and " - "'bypass_global_separation'." - ) - - # === Degrees of freedom provided check - if len(config.first_stage_variables) + len(config.second_stage_variables) == 0: - raise ValueError( - "User must designate at least one first- and/or second-stage variable." + "Arguments `bypass_local_separation` " + "and `bypass_global_separation` " + "cannot both be True." ) - # === Uncertain params provided check - if len(config.uncertain_params) == 0: - raise ValueError("User must designate at least one uncertain parameter.") - - return - - -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) +def validate_pyros_inputs(model, config): + """ + Perform advanced validation of PyROS solver arguments. - return model.dr_substituted_constraints[ - max(model.dr_substituted_constraints.keys()) - ] + Parameters + ---------- + model : ConcreteModel + 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) + user_var_partitioning = validate_variable_partitioning(model, config) + validate_uncertainty_specification(model, config) + validate_separation_problem_options(model, config) -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 - - # === 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 - ) + return user_var_partitioning - 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) - ) - 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 - - 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 - - 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 - - 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 - - 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, - ) - ) +class ModelData: + """ + Container for modeling objects from which the PyROS + subproblems are constructed. - swapped = model.swapped_constraints[max(model.swapped_constraints.keys())] + Parameters + ---------- + original_model : ConcreteModel + Original user-provided model. + timing : TimingData + Main timing data object. - val = generate_standard_repn(swapped.body, compute_values=False) + 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. + """ - 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 + 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 - if successful_matching: - model.util.h_x_q_constraints.add(constraint) + def preprocess(self, user_var_partitioning): + """ + Preprocess model data. - for i in range(len(list(variables_in_constraint))): - model.del_component("p_%s" % i) + See :meth:`~preprocess_model_data`. - for i in range(len(list(params_in_constraint))): - model.del_component("x_%s" % i) + Returns + ------- + bool + True if robust infeasibility detected, False otherwise. + """ + return preprocess_model_data(self, user_var_partitioning) - model.del_component("swapped_constraints") - model.del_component("swapped_constraints_index") - return successful_matching, robust_infeasible +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 -def selective_clone(block, first_stage_vars): +class BoundType: """ - 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: + Indicator for whether a bound on a variable/constraint + is a lower bound, "equality" bound, or upper bound. """ - 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 - return new_block + LOWER = "lower" + EQ = "eq" + UPPER = "upper" -def add_decision_rule_variables(model_data, config): +def get_var_bound_pairs(var): """ - Add variables for polynomial decision rules to the working - model. + Get the domain and declared lower/upper + bound pairs of a variable data object. Parameters ---------- - model_data : ROSolveResults - Model data. - config : config_dict - PyROS solver options. + var : VarData + Variable data object of interest. - Note + 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 + + # ensure state of variable object is ultimately left unchanged + var.domain = orig_var_domain + + return domain_bounds, declared_bounds + + +def determine_certain_and_uncertain_bound( + domain_bound, declared_bound, uncertain_params, bound_type +): + """ + Determine the certain and uncertain lower or upper + bound for a variable object, based on the specified + domain and declared bound. + + Parameters + ---------- + 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. + + 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. + """ + 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}'." + ) + + 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 + + if not uncertain_params_in_declared_bound: + uncertain_bound = None + + 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 + + +BoundTriple = namedtuple( + "BoundTriple", (BoundType.LOWER, BoundType.EQ, BoundType.UPPER) +) + + +def rearrange_bound_pair_to_triple(lower_bound, upper_bound): + """ + 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 + ---------- + lower_bound : numeric type, NumericExpression, or None + Lower bound. + upper_bound : numeric type, NumericExpression, or None + Upper bound. + + 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 ---- - 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. + 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() + + # 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.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") + + uncertain_params_set = ComponentSet(working_model.uncertain_params) + + # 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)) & 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.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.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.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.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.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.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): """ - 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 = [] + 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.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 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.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_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_state_var_independent_eq_cons(model_data): + """ + Reformulate second-stage equality constraints that are + independent of the state variables. + + The state variable-independent second-stage equality + constraints that can be rewritten as polynomials + in terms of the uncertain parameters + are reformulated to first-stage equalities + through matching of the polynomial coefficients. + Hence, this reformulation technique is referred to as + coefficient matching. + In some cases, matching of the coefficients may lead to + a certificate of robust infeasibility. + + All other state variable-independent second-stage equality + constraints are recast to pairs of opposing second-stage inequality + constraints, as they would otherwise over-constrain the uncertain + parameters in the separation subproblems. + + 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) + 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 + ssvar_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.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() + } + + # copy the items iterable, + # as we will be modifying the constituents of the constraint + # in place + working_model.first_stage.coefficient_matching_cons = 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: + con_expr_after_dr_substitution = replace_expressions( + expr=con.body - con.upper, substitution_map=ssvar_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 + # 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 {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=con.body, bound=con.upper, bound_type=bound_type + ) + new_con_name = f"reform_{bound_type}_bound_from_{con_idx}" + 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 {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. + return robust_infeasible + + else: + # coefficient is dependent on model first-stage + # and DR variables. add matching constraint + new_con_name = f"coeff_matching_{con_idx}_coeff_{coeff_idx}" + working_model.first_stage.equality_cons[new_con_name] = ( + coeff_expr == 0 + ) + new_con = working_model.first_stage.equality_cons[new_con_name] + coefficient_matching_cons.append(new_con) + + config.progress_logger.debug( + f"Derived from constraint {con.name!r} a coefficient " + f"matching constraint named {new_con_name!r} " + "with expression: \n " + f"{new_con.expr}." + ) + + # remove rather than deactivate to facilitate: + # - we no longer need this constraint anywhere + # - facilitates accurate counting of active constraints + del working_model.second_stage.equality_cons[con_idx] + + # we no longer need these auxiliary components + working_model.del_component(temp_param_vars) + working_model.del_component(temp_param_vars.index_set()) + + return False + + +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) + + # 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) + + # constraints + num_cons = len(list(working_model.component_data_objects(Constraint, active=True))) + + # # equality constraints + num_eq_cons = ( + len(working_model.first_stage.equality_cons) + + len(working_model.second_stage.equality_cons) + + len(working_model.second_stage.decision_rule_eqns) + ) + num_first_stage_eq_cons = len(working_model.first_stage.equality_cons) + 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}") + + 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 second-stage - # variable depends on DR order and uncertainty set dimension + # 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.util.uncertain_params) + num_uncertain_params = len(model_data.working_model.uncertain_params) num_dr_vars = sp.special.comb( N=num_uncertain_params + degree, k=degree, exact=True, repetition=False ) - for idx, ss_var in enumerate(second_stage_variables): - # declare DR coefficients for current second-stage variable + 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.add_component( + model_data.working_model.first_stage.add_component( f"decision_rule_var_{idx}", indexed_dr_var ) @@ -1319,36 +2733,47 @@ def add_decision_rule_variables(model_data, config): # 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)) + indexed_dr_var[0].set_value(value(eff_ss_var, exception=False)) # 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 + eff_ss_var_to_dr_var_map[eff_ss_var] = indexed_dr_var -def add_decision_rule_constraints(model_data, config): +def add_decision_rule_constraints(model_data): """ Add decision rule equality constraints to the working model. Parameters ---------- - model_data : ROSolveResults - Model data. - config : ConfigDict - PyROS solver options. + model_data : model data object + Main model data object. """ - - 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 + 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.uncertain_params degree = config.decision_rule_order - # keeping track of degree of monomial in which each - # DR coefficient participates will be useful for later - dr_var_to_exponent_map = ComponentMap() + 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 @@ -1358,15 +2783,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)})" ) @@ -1380,18 +2806,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 @@ -1406,159 +2825,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 + 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), + ) - has_args = hasattr(expr_to_split, "args") - is_sum = isinstance(expr_to_split, SumExpression) + 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) - # 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] - # initialize first and second-stage cost expressions - first_stage_cost_expr = 0 - second_stage_cost_expr = 0 +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. - first_stage_var_set = ComponentSet(model.util.first_stage_variables) - uncertain_param_set = ComponentSet(model.util.uncertain_params) + 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. - 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 + 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. + + 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, ) - uncertain_params_in_term = ComponentSet( - param - for param in identify_mutable_parameters(term) - if param in uncertain_param_set + 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 - if non_first_stage_vars_in_term or uncertain_params_in_term: - second_stage_cost_expr += term - else: - first_stage_cost_expr += term - - model.first_stage_objective = Expression(expr=first_stage_cost_expr) - model.second_stage_objective = Expression(expr=second_stage_cost_expr) - - -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: @@ -1583,7 +2979,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, @@ -1594,7 +2990,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. @@ -1622,7 +3018,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, @@ -1633,7 +3029,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. @@ -1763,3 +3159,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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/satsolver/__init__.py +++ b/pyomo/contrib/satsolver/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/satsolver/satsolver.py b/pyomo/contrib/satsolver/satsolver.py index 139b5218169..b5004d6a611 100644 --- a/pyomo/contrib/satsolver/satsolver.py +++ b/pyomo/contrib/satsolver/satsolver.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 math from pyomo.common.dependencies import attempt_import diff --git a/pyomo/contrib/satsolver/tests/__init__.py b/pyomo/contrib/satsolver/tests/__init__.py new file mode 100644 index 00000000000..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/satsolver/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ 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 7ac7aaff03f..3c03d22d1a0 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-2022 +# 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 @@ -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 cac6562157e..a20cbc389d7 100644 --- a/pyomo/contrib/sensitivity_toolbox/__init__.py +++ b/pyomo/contrib/sensitivity_toolbox/__init__.py @@ -1,7 +1,18 @@ +# ___________________________________________________________________________ +# +# 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/examples/HIV_Transmission.py b/pyomo/contrib/sensitivity_toolbox/examples/HIV_Transmission.py index 2c8996c95ca..8d43dea26b2 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/examples/__init__.py b/pyomo/contrib/sensitivity_toolbox/examples/__init__.py index 5223f39bbc1..a408b878891 100644 --- a/pyomo/contrib/sensitivity_toolbox/examples/__init__.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/__init__.py @@ -1,7 +1,18 @@ +# ___________________________________________________________________________ +# +# 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/examples/feedbackController.py b/pyomo/contrib/sensitivity_toolbox/examples/feedbackController.py index 1112a0c82b3..d973bedf5ba 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/examples/parameter.py b/pyomo/contrib/sensitivity_toolbox/examples/parameter.py index 3ed1628f2c2..85d31d3303e 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/examples/parameter_kaug.py b/pyomo/contrib/sensitivity_toolbox/examples/parameter_kaug.py index f54e7903442..c5e61307046 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/examples/rangeInequality.py b/pyomo/contrib/sensitivity_toolbox/examples/rangeInequality.py index 39e4d26f695..b06cc8390d2 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/examples/rooney_biegler.py b/pyomo/contrib/sensitivity_toolbox/examples/rooney_biegler.py index f058e8189dc..895f69338c7 100644 --- a/pyomo/contrib/sensitivity_toolbox/examples/rooney_biegler.py +++ b/pyomo/contrib/sensitivity_toolbox/examples/rooney_biegler.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + ############################################################################## # Institute for the Design of Advanced Energy Systems Process Systems # Engineering Framework (IDAES PSE Framework) Copyright (c) 2018-2019, by the @@ -11,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 8d739506492..a7fc10569fe 100644 --- a/pyomo/contrib/sensitivity_toolbox/k_aug.py +++ b/pyomo/contrib/sensitivity_toolbox/k_aug.py @@ -1,7 +1,18 @@ +# ___________________________________________________________________________ +# +# 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/sens.py b/pyomo/contrib/sensitivity_toolbox/sens.py index e1c69d75974..d0f943b5876 100644 --- a/pyomo/contrib/sensitivity_toolbox/sens.py +++ b/pyomo/contrib/sensitivity_toolbox/sens.py @@ -1,13 +1,14 @@ -# ______________________________________________________________________________ +# ___________________________________________________________________________ # -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 -# ______________________________________________________________________________ +# 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, @@ -23,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 @@ -33,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') @@ -229,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 ---------- @@ -266,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. @@ -320,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 @@ -673,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 557846ee521..53f447ece43 100644 --- a/pyomo/contrib/sensitivity_toolbox/tests/__init__.py +++ b/pyomo/contrib/sensitivity_toolbox/tests/__init__.py @@ -1,7 +1,18 @@ +# ___________________________________________________________________________ +# +# 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-2022 +# 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 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 8c14cfc91d0..1887d5847da 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,18 @@ +# ___________________________________________________________________________ +# +# 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-2022 +# 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 @@ -9,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 f4b3fb5548c..69cf0303987 100644 --- a/pyomo/contrib/sensitivity_toolbox/tests/test_sens.py +++ b/pyomo/contrib/sensitivity_toolbox/tests/test_sens.py @@ -1,7 +1,18 @@ +# ___________________________________________________________________________ +# +# 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-2022 +# 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 diff --git a/pyomo/contrib/sensitivity_toolbox/tests/test_sens_unit.py b/pyomo/contrib/sensitivity_toolbox/tests/test_sens_unit.py index 05faada3007..9f4bcb2b497 100644 --- a/pyomo/contrib/sensitivity_toolbox/tests/test_sens_unit.py +++ b/pyomo/contrib/sensitivity_toolbox/tests/test_sens_unit.py @@ -1,7 +1,18 @@ +# ___________________________________________________________________________ +# +# 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-2022 +# 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 diff --git a/pyomo/contrib/simplemodel/__init__.py b/pyomo/contrib/simplemodel/__init__.py deleted file mode 100644 index 4fa4fa2dd16..00000000000 --- a/pyomo/contrib/simplemodel/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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..c6111ddcb89 --- /dev/null +++ b/pyomo/contrib/simplification/__init__.py @@ -0,0 +1,12 @@ +# ___________________________________________________________________________ +# +# 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 .simplify import Simplifier diff --git a/pyomo/contrib/simplification/build.py b/pyomo/contrib/simplification/build.py new file mode 100644 index 00000000000..d7fac9522dc --- /dev/null +++ b/pyomo/contrib/simplification/build.py @@ -0,0 +1,209 @@ +# ___________________________________________________________________________ +# +# 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 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..6896bec12c4 --- /dev/null +++ b/pyomo/contrib/simplification/ginac/__init__.py @@ -0,0 +1,52 @@ +# ___________________________________________________________________________ +# +# 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.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..9b05baf71ca --- /dev/null +++ b/pyomo/contrib/simplification/ginac/src/ginac_interface.cpp @@ -0,0 +1,332 @@ +// ___________________________________________________________________________ +// +// 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. +// ___________________________________________________________________________ + +#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..6b08f7be4d7 --- /dev/null +++ b/pyomo/contrib/simplification/plugins.py @@ -0,0 +1,17 @@ +# ___________________________________________________________________________ +# +# 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.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..874b5b1e801 --- /dev/null +++ b/pyomo/contrib/simplification/simplify.py @@ -0,0 +1,75 @@ +# ___________________________________________________________________________ +# +# 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 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..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/simplification/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/simplification/tests/test_simplification.py b/pyomo/contrib/simplification/tests/test_simplification.py new file mode 100644 index 00000000000..1ff9f5a3cc4 --- /dev/null +++ b/pyomo/contrib/simplification/tests/test_simplification.py @@ -0,0 +1,123 @@ +# ___________________________________________________________________________ +# +# 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 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 pe + + +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 = pe.ConcreteModel() + x = m.x = pe.Var(bounds=(0, None)) + e = x * pe.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 = pe.ConcreteModel() + x = m.x = pe.Var() + e = 2 * x + e2 = self.simp.simplify(e) + expected = 2.0 * x + assertExpressionsEqual(self, expected, e2) + + def test_sum(self): + m = pe.ConcreteModel() + x = m.x = pe.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 = pe.ConcreteModel() + x = m.x = pe.Var() + e = -pe.log(x) + e2 = self.simp.simplify(e) + self.compare_against_possible_results( + e2, [(-1.0) * pe.log(x), pe.log(x) * (-1.0), -pe.log(x)] + ) + + def test_pow(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + e = x**2.0 + e2 = self.simp.simplify(e) + assertExpressionsEqual(self, e, e2) + + def test_div(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + y = m.y = pe.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 = pe.ConcreteModel() + x = m.x = pe.Var() + func_list = [pe.log, pe.sin, pe.cos, pe.tan, pe.asin, pe.acos, pe.atan] + for func in func_list: + e = func(x) + e2 = self.simp.simplify(e) + assertExpressionsEqual(self, e, e2) + + def test_param(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + p = m.p = pe.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 new file mode 100644 index 00000000000..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/solver/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/base.py b/pyomo/contrib/solver/base.py new file mode 100644 index 00000000000..818f403718f --- /dev/null +++ b/pyomo/contrib/solver/base.py @@ -0,0 +1,648 @@ +# ___________________________________________________________________________ +# +# 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, 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.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 name defined when the solver was registered + # in the SolverFactory or the class name (all lowercase), whichever is + # applicable + 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) + + # + # 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(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[VarData]] = 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[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' + ) + + @abc.abstractmethod + def set_instance(self, model): + """ + Set an instance of the model + """ + + @abc.abstractmethod + def set_objective(self, obj: ObjectiveData): + """ + Set current objective for the model + """ + + @abc.abstractmethod + def add_variables(self, variables: List[VarData]): + """ + 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[ConstraintData]): + """ + 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[VarData]): + """ + 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[ConstraintData]): + """ + 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[VarData]): + """ + 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. + """ + + 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, t, v, 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." + ) + elif 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.solution_status] + 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 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, + # 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) + + 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 + ) + + 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): + from pyomo.scripting.solve_config import default_config_block + + return default_config_block(self, init)[0] + + def set_options(self, options): + 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/config.py new file mode 100644 index 00000000000..7397184903b --- /dev/null +++ b/pyomo/contrib/solver/config.py @@ -0,0 +1,406 @@ +# ___________________________________________________________________________ +# +# 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 io +import logging +import sys + +from collections.abc import Sequence +from typing import Optional, List, TextIO + +from pyomo.common.config import ( + ConfigDict, + ConfigValue, + NonNegativeFloat, + NonNegativeInt, + ADVANCED_OPTION, + Bool, + Path, +) +from pyomo.common.log import LogStream +from pyomo.common.numeric_types import native_logical_types +from pyomo.common.timing import HierarchicalTimer + + +def TextIO_or_Logger(val): + ans = [] + if not isinstance(val, Sequence): + val = [val] + for v in val: + if v.__class__ in native_logical_types: + if v: + ans.append(sys.stdout) + elif isinstance(v, io.TextIOBase): + ans.append(v) + elif isinstance(v, logging.Logger): + ans.append(LogStream(level=logging.INFO, logger=v)) + else: + raise ValueError( + f"Expected bool, TextIOBase, or Logger, but received {v.__class__}" + ) + return ans + + +class SolverConfig(ConfigDict): + """ + Common configuration options for all solver interfaces + """ + + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.tee: List[TextIO] = self.declare( + 'tee', + ConfigValue( + domain=TextIO_or_Logger, + default=False, + description="""``tee`` accepts :py:class:`bool`, + :py:class:`io.TextIOBase`, or :py:class:`logging.Logger` + (or a list of these types). ``True`` is mapped to + ``sys.stdout``. The solver log will be printed to each of + these streams / destinations.""", + ), + ) + self.working_dir: Optional[Path] = self.declare( + 'working_dir', + ConfigValue( + domain=Path(), + default=None, + description="The directory in which generated files should be saved. " + "This replaces the `keepfiles` option.", + ), + ) + self.load_solutions: bool = self.declare( + 'load_solutions', + ConfigValue( + domain=Bool, + default=True, + description="If True, the values of the primal variables will be loaded into the model.", + ), + ) + self.raise_exception_on_nonoptimal_result: bool = self.declare( + 'raise_exception_on_nonoptimal_result', + ConfigValue( + domain=Bool, + default=True, + description="If False, the `solve` method will continue processing " + "even if the returned result is nonoptimal.", + ), + ) + self.symbolic_solver_labels: bool = self.declare( + 'symbolic_solver_labels', + ConfigValue( + domain=Bool, + default=False, + description="If True, the names given to the solver will reflect the names of the Pyomo components. " + "Cannot be changed after set_instance is called.", + ), + ) + self.timer: Optional[HierarchicalTimer] = self.declare( + 'timer', + ConfigValue( + default=None, + description="A timer object for recording relevant process timing data.", + ), + ) + self.threads: Optional[int] = self.declare( + 'threads', + ConfigValue( + domain=NonNegativeInt, + description="Number of threads to be used by a solver.", + default=None, + ), + ) + self.time_limit: Optional[float] = self.declare( + 'time_limit', + ConfigValue( + domain=NonNegativeFloat, + description="Time limit applied to the solver (in seconds).", + ), + ) + self.solver_options: ConfigDict = self.declare( + 'solver_options', + ConfigDict(implicit=True, description="Options to pass to the solver."), + ) + + +class BranchAndBoundConfig(SolverConfig): + """ + Base config for all direct MIP solver interfaces + + Attributes + ---------- + rel_gap: float + The relative value of the gap in relation to the best bound + abs_gap: float + The absolute value of the difference between the incumbent and best bound + """ + + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.rel_gap: Optional[float] = self.declare( + 'rel_gap', + ConfigValue( + domain=NonNegativeFloat, + description="Optional termination condition; the relative value of the " + "gap in relation to the best bound", + ), + ) + self.abs_gap: Optional[float] = self.declare( + 'abs_gap', + ConfigValue( + domain=NonNegativeFloat, + description="Optional termination condition; the absolute value of the " + "difference between the incumbent and best bound", + ), + ) + + +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 + """ + + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + if doc is None: + doc = 'Configuration options to detect changes in model between solves' + super().__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.check_for_new_or_removed_constraints: bool = self.declare( + 'check_for_new_or_removed_constraints', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, new/old constraints will not be automatically detected on subsequent + solves. Use False only when manually updating the solver with opt.add_constraints() + and opt.remove_constraints() or when you are certain constraints are not being + added to/removed from the model.""", + ), + ) + self.check_for_new_or_removed_vars: bool = self.declare( + 'check_for_new_or_removed_vars', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, new/old variables will not be automatically detected on subsequent + solves. Use False only when manually updating the solver with opt.add_variables() and + opt.remove_variables() or when you are certain variables are not being added to / + removed from the model.""", + ), + ) + self.check_for_new_or_removed_params: bool = self.declare( + 'check_for_new_or_removed_params', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, new/old parameters will not be automatically detected on subsequent + solves. Use False only when manually updating the solver with opt.add_parameters() and + opt.remove_parameters() or when you are certain parameters are not being added to / + removed from the model.""", + ), + ) + self.check_for_new_objective: bool = self.declare( + 'check_for_new_objective', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, new/old objectives will not be automatically detected on subsequent + solves. Use False only when manually updating the solver with opt.set_objective() or + when you are certain objectives are not being added to / removed from the model.""", + ), + ) + self.update_constraints: bool = self.declare( + 'update_constraints', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, changes to existing constraints will not be automatically detected on + subsequent solves. This includes changes to the lower, body, and upper attributes of + constraints. Use False only when manually updating the solver with + opt.remove_constraints() and opt.add_constraints() or when you are certain constraints + are not being modified.""", + ), + ) + self.update_vars: bool = self.declare( + 'update_vars', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, changes to existing variables will not be automatically detected on + subsequent solves. This includes changes to the lb, ub, domain, and fixed + attributes of variables. Use False only when manually updating the solver with + opt.update_variables() or when you are certain variables are not being modified.""", + ), + ) + self.update_parameters: bool = self.declare( + 'update_parameters', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, changes to parameter values will not be automatically detected on + subsequent solves. Use False only when manually updating the solver with + opt.update_parameters() or when you are certain parameters are not being modified.""", + ), + ) + self.update_named_expressions: bool = self.declare( + 'update_named_expressions', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, changes to Expressions will not be automatically detected on + subsequent solves. Use False only when manually updating the solver with + opt.remove_constraints() and opt.add_constraints() or when you are certain + Expressions are not being modified.""", + ), + ) + self.update_objective: bool = self.declare( + 'update_objective', + ConfigValue( + domain=bool, + default=True, + description=""" + If False, changes to objectives will not be automatically detected on + subsequent solves. This includes the expr and sense attributes of objectives. Use + False only when manually updating the solver with opt.set_objective() or when you are + 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): + """ + Base config for all persistent solver interfaces + """ + + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.auto_updates: AutoUpdateConfig = self.declare( + 'auto_updates', AutoUpdateConfig() + ) + + +class PersistentBranchAndBoundConfig(BranchAndBoundConfig): + """ + Base config for all persistent MIP solver interfaces + """ + + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__( + description=description, + doc=doc, + implicit=implicit, + 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/factory.py new file mode 100644 index 00000000000..d3ca1329af3 --- /dev/null +++ b/pyomo/contrib/solver/factory.py @@ -0,0 +1,41 @@ +# ___________________________________________________________________________ +# +# 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.opt.base.solvers import LegacySolverFactory +from pyomo.common.factory import Factory +from pyomo.contrib.solver.base import LegacySolverWrapper + + +class SolverFactoryClass(Factory): + def register(self, name, legacy_name=None, doc=None): + if legacy_name is None: + legacy_name = name + + def decorator(cls): + self._cls[name] = cls + self._doc[name] = doc + + class LegacySolver(LegacySolverWrapper, cls): + pass + + LegacySolverFactory.register(legacy_name, doc + " (new interface)")( + LegacySolver + ) + + # Preserve the preferred name, as registered in the Factory + cls.name = name + return cls + + return decorator + + +SolverFactory = SolverFactoryClass() diff --git a/pyomo/contrib/solver/gurobi.py b/pyomo/contrib/solver/gurobi.py new file mode 100644 index 00000000000..1fdaed98a15 --- /dev/null +++ b/pyomo/contrib/solver/gurobi.py @@ -0,0 +1,1505 @@ +# ___________________________________________________________________________ +# +# 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 collections.abc import Iterable +import logging +import math +from typing import List, Optional +from pyomo.common.collections import ComponentSet, ComponentMap, OrderedSet +from pyomo.common.dependencies import attempt_import +from pyomo.common.errors import PyomoException +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 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.core.staleflag import StaleFlagManager +import sys +import datetime +import io + +logger = logging.getLogger(__name__) + + +def _import_gurobipy(): + try: + import gurobipy + except ImportError: + Gurobi._available = Gurobi.Availability.NotFound + raise + if gurobipy.GRB.VERSION_MAJOR < 7: + Gurobi._available = Gurobi.Availability.BadVersion + raise ImportError('The APPSI Gurobi interface requires gurobipy>=7.0.0') + return gurobipy + + +gurobipy, gurobipy_available = attempt_import('gurobipy', importer=_import_gurobipy) + + +class DegreeError(PyomoException): + pass + + +class GurobiConfig(PersistentBranchAndBoundConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super(GurobiConfig, self).__init__( + 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.", + ), + ) + + +class GurobiSolutionLoader(PersistentSolutionLoader): + def load_vars(self, vars_to_load=None, solution_number=0): + self._assert_solution_still_valid() + self._solver._load_vars( + vars_to_load=vars_to_load, solution_number=solution_number + ) + + def get_primals(self, vars_to_load=None, solution_number=0): + self._assert_solution_still_valid() + return self._solver._get_primals( + vars_to_load=vars_to_load, solution_number=solution_number + ) + + +class _MutableLowerBound(object): + def __init__(self, expr): + self.var = None + self.expr = expr + + def update(self): + self.var.setAttr('lb', value(self.expr)) + + +class _MutableUpperBound(object): + def __init__(self, expr): + self.var = None + self.expr = expr + + def update(self): + self.var.setAttr('ub', value(self.expr)) + + +class _MutableLinearCoefficient(object): + def __init__(self): + self.expr = None + self.var = None + self.con = None + self.gurobi_model = None + + def update(self): + self.gurobi_model.chgCoeff(self.con, self.var, value(self.expr)) + + +class _MutableRangeConstant(object): + def __init__(self): + self.lhs_expr = None + self.rhs_expr = None + self.con = None + self.slack_name = None + self.gurobi_model = None + + def update(self): + rhs_val = value(self.rhs_expr) + lhs_val = value(self.lhs_expr) + self.con.rhs = rhs_val + slack = self.gurobi_model.getVarByName(self.slack_name) + slack.ub = rhs_val - lhs_val + + +class _MutableConstant(object): + def __init__(self): + self.expr = None + self.con = None + + def update(self): + self.con.rhs = value(self.expr) + + +class _MutableQuadraticConstraint(object): + def __init__( + self, gurobi_model, gurobi_con, constant, linear_coefs, quadratic_coefs + ): + self.con = gurobi_con + self.gurobi_model = gurobi_model + self.constant = constant + self.last_constant_value = value(self.constant.expr) + self.linear_coefs = linear_coefs + self.last_linear_coef_values = [value(i.expr) for i in self.linear_coefs] + self.quadratic_coefs = quadratic_coefs + self.last_quadratic_coef_values = [value(i.expr) for i in self.quadratic_coefs] + + def get_updated_expression(self): + gurobi_expr = self.gurobi_model.getQCRow(self.con) + for ndx, coef in enumerate(self.linear_coefs): + current_coef_value = value(coef.expr) + incremental_coef_value = ( + current_coef_value - self.last_linear_coef_values[ndx] + ) + gurobi_expr += incremental_coef_value * coef.var + self.last_linear_coef_values[ndx] = current_coef_value + for ndx, coef in enumerate(self.quadratic_coefs): + current_coef_value = value(coef.expr) + incremental_coef_value = ( + current_coef_value - self.last_quadratic_coef_values[ndx] + ) + gurobi_expr += incremental_coef_value * coef.var1 * coef.var2 + self.last_quadratic_coef_values[ndx] = current_coef_value + return gurobi_expr + + def get_updated_rhs(self): + return value(self.constant.expr) + + +class _MutableObjective(object): + def __init__(self, gurobi_model, constant, linear_coefs, quadratic_coefs): + self.gurobi_model = gurobi_model + self.constant = constant + self.linear_coefs = linear_coefs + self.quadratic_coefs = quadratic_coefs + self.last_quadratic_coef_values = [value(i.expr) for i in self.quadratic_coefs] + + def get_updated_expression(self): + for ndx, coef in enumerate(self.linear_coefs): + coef.var.obj = value(coef.expr) + self.gurobi_model.ObjCon = value(self.constant.expr) + + gurobi_expr = None + for ndx, coef in enumerate(self.quadratic_coefs): + if value(coef.expr) != self.last_quadratic_coef_values[ndx]: + if gurobi_expr is None: + self.gurobi_model.update() + gurobi_expr = self.gurobi_model.getObjective() + current_coef_value = value(coef.expr) + incremental_coef_value = ( + current_coef_value - self.last_quadratic_coef_values[ndx] + ) + gurobi_expr += incremental_coef_value * coef.var1 * coef.var2 + self.last_quadratic_coef_values[ndx] = current_coef_value + return gurobi_expr + + +class _MutableQuadraticCoefficient(object): + def __init__(self): + self.expr = None + self.var1 = None + self.var2 = None + + +class Gurobi(PersistentSolverUtils, PersistentSolverBase): + """ + Interface to Gurobi + """ + + CONFIG = GurobiConfig() + + _available = None + _num_instances = 0 + + def __init__(self, **kwds): + PersistentSolverUtils.__init__(self) + PersistentSolverBase.__init__(self, **kwds) + Gurobi._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._range_constraints = OrderedSet() + self._mutable_helpers = dict() + self._mutable_bounds = dict() + self._mutable_quadratic_helpers = dict() + self._mutable_objective = None + self._needs_updated = True + self._callback = None + self._callback_func = None + 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() + if gurobipy_available: + with capture_output(capture_fd=True): + gurobipy.disposeDefaultEnv() + + def __del__(self): + if not python_is_shutting_down(): + Gurobi._num_instances -= 1 + if Gurobi._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 + timer = config.timer + ostreams = [io.StringIO()] + config.tee + + with capture_output(TeeStream(*ostreams), capture_fd=False): + options = config.solver_options + + self._solver_model.setParam('LogToConsole', 1) + + if config.threads is not None: + self._solver_model.setParam('Threads', config.threads) + if config.time_limit is not None: + self._solver_model.setParam('TimeLimit', config.time_limit) + if config.rel_gap is not None: + self._solver_model.setParam('MIPGap', config.rel_gap) + if config.abs_gap is not None: + self._solver_model.setParam('MIPGapAbs', config.abs_gap) + + if config.use_mipstart: + for ( + pyomo_var_id, + gurobi_var, + ) in self._pyomo_var_to_solver_var_map.items(): + pyomo_var = self._vars[pyomo_var_id][0] + if pyomo_var.is_integer() and pyomo_var.value is not None: + self.set_var_attr(pyomo_var, 'Start', pyomo_var.value) + + for key, option in options.items(): + self._solver_model.setParam(key, option) + + timer.start('optimize') + self._solver_model.optimize(self._callback) + timer.stop('optimize') + + self._needs_updated = False + res = self._postsolve(timer) + res.solver_configuration = 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 + ): + _v, _lb, _ub, _fixed, _domain_interval, _value = self._vars[id(var)] + lb, ub, step = _domain_interval + if lb is None: + lb = -gurobipy.GRB.INFINITY + if ub is None: + ub = gurobipy.GRB.INFINITY + if step == 0: + vtype = gurobipy.GRB.CONTINUOUS + elif step == 1: + if lb == 0 and ub == 1: + vtype = gurobipy.GRB.BINARY + else: + vtype = gurobipy.GRB.INTEGER + 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: + if not is_constant(_lb): + mutable_bound = _MutableLowerBound(NPV_MaxExpression((_lb, lb))) + if gurobipy_var is None: + mutable_lbs[ndx] = mutable_bound + else: + mutable_bound.var = gurobipy_var + self._mutable_bounds[var_id, 'lb'] = (var, mutable_bound) + lb = max(value(_lb), lb) + if _ub is not None: + if not is_constant(_ub): + mutable_bound = _MutableUpperBound(NPV_MinExpression((_ub, ub))) + if gurobipy_var is None: + mutable_ubs[ndx] = mutable_bound + else: + mutable_bound.var = gurobipy_var + self._mutable_bounds[var_id, 'ub'] = (var, mutable_bound) + ub = min(value(_ub), ub) + + return lb, ub, vtype + + def _add_variables(self, variables: List[VarData]): + var_names = list() + vtypes = list() + lbs = list() + ubs = list() + mutable_lbs = dict() + mutable_ubs = dict() + for ndx, var in enumerate(variables): + varname = self._symbol_map.getSymbol(var, self._labeler) + lb, ub, vtype = self._process_domain_and_bounds( + var, id(var), mutable_lbs, mutable_ubs, ndx, None + ) + var_names.append(varname) + vtypes.append(vtype) + lbs.append(lb) + ubs.append(ub) + + gurobi_vars = self._solver_model.addVars( + len(variables), lb=lbs, ub=ubs, vtype=vtypes, name=var_names + ) + + for ndx, pyomo_var in enumerate(variables): + gurobi_var = gurobi_vars[ndx] + self._pyomo_var_to_solver_var_map[id(pyomo_var)] = gurobi_var + for ndx, mutable_bound in mutable_lbs.items(): + mutable_bound.var = gurobi_vars[ndx] + for ndx, mutable_bound in mutable_ubs.items(): + mutable_bound.var = gurobi_vars[ndx] + self._vars_added_since_update.update(variables) + self._needs_updated = True + + def _add_parameters(self, params: List[ParamData]): + pass + + def _reinit(self): + saved_config = self.config + saved_tmp_config = self._config + self.__init__() + self.config = saved_config + self._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( + f'Solver {c.__module__}.{c.__qualname__} is not available ' + f'({self.available()}).' + ) + self._reinit() + self._model = model + + if self.config.symbolic_solver_labels: + self._labeler = TextLabeler() + else: + self._labeler = NumericLabeler('x') + + if model.name is not None: + self._solver_model = gurobipy.Model(model.name) + else: + self._solver_model = gurobipy.Model() + + self.add_block(model) + if self._objective is None: + self.set_objective(None) + + def _get_expr_from_pyomo_expr(self, expr): + mutable_linear_coefficients = list() + mutable_quadratic_coefficients = list() + 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) + ) + + if len(repn.linear_vars) > 0: + linear_coef_vals = list() + for ndx, coef in enumerate(repn.linear_coefs): + if not is_constant(coef): + mutable_linear_coefficient = _MutableLinearCoefficient() + mutable_linear_coefficient.expr = coef + mutable_linear_coefficient.var = self._pyomo_var_to_solver_var_map[ + id(repn.linear_vars[ndx]) + ] + mutable_linear_coefficients.append(mutable_linear_coefficient) + linear_coef_vals.append(value(coef)) + new_expr = gurobipy.LinExpr( + linear_coef_vals, + [self._pyomo_var_to_solver_var_map[id(i)] for i in repn.linear_vars], + ) + else: + new_expr = 0.0 + + for ndx, v in enumerate(repn.quadratic_vars): + x, y = v + gurobi_x = self._pyomo_var_to_solver_var_map[id(x)] + gurobi_y = self._pyomo_var_to_solver_var_map[id(y)] + coef = repn.quadratic_coefs[ndx] + if not is_constant(coef): + mutable_quadratic_coefficient = _MutableQuadraticCoefficient() + mutable_quadratic_coefficient.expr = coef + mutable_quadratic_coefficient.var1 = gurobi_x + mutable_quadratic_coefficient.var2 = gurobi_y + mutable_quadratic_coefficients.append(mutable_quadratic_coefficient) + coef_val = value(coef) + new_expr += coef_val * gurobi_x * gurobi_y + + return ( + new_expr, + repn.constant, + mutable_linear_coefficients, + mutable_quadratic_coefficients, + ) + + def _add_constraints(self, cons: List[ConstraintData]): + for con in cons: + conname = self._symbol_map.getSymbol(con, self._labeler) + ( + gurobi_expr, + repn_constant, + mutable_linear_coefficients, + mutable_quadratic_coefficients, + ) = self._get_expr_from_pyomo_expr(con.body) + + if ( + gurobi_expr.__class__ in {gurobipy.LinExpr, gurobipy.Var} + or gurobi_expr.__class__ in native_numeric_types + ): + if con.equality: + rhs_expr = con.lower - repn_constant + rhs_val = value(rhs_expr) + gurobipy_con = self._solver_model.addLConstr( + gurobi_expr, gurobipy.GRB.EQUAL, rhs_val, name=conname + ) + if not is_constant(rhs_expr): + mutable_constant = _MutableConstant() + mutable_constant.expr = rhs_expr + mutable_constant.con = gurobipy_con + self._mutable_helpers[con] = [mutable_constant] + elif con.has_lb() and con.has_ub(): + lhs_expr = con.lower - repn_constant + rhs_expr = con.upper - repn_constant + lhs_val = value(lhs_expr) + rhs_val = value(rhs_expr) + gurobipy_con = self._solver_model.addRange( + gurobi_expr, lhs_val, rhs_val, name=conname + ) + self._range_constraints.add(con) + if not is_constant(lhs_expr) or not is_constant(rhs_expr): + mutable_range_constant = _MutableRangeConstant() + mutable_range_constant.lhs_expr = lhs_expr + mutable_range_constant.rhs_expr = rhs_expr + mutable_range_constant.con = gurobipy_con + mutable_range_constant.slack_name = 'Rg' + conname + mutable_range_constant.gurobi_model = self._solver_model + self._mutable_helpers[con] = [mutable_range_constant] + elif con.has_lb(): + rhs_expr = con.lower - repn_constant + rhs_val = value(rhs_expr) + gurobipy_con = self._solver_model.addLConstr( + gurobi_expr, gurobipy.GRB.GREATER_EQUAL, rhs_val, name=conname + ) + if not is_constant(rhs_expr): + mutable_constant = _MutableConstant() + mutable_constant.expr = rhs_expr + mutable_constant.con = gurobipy_con + self._mutable_helpers[con] = [mutable_constant] + elif con.has_ub(): + rhs_expr = con.upper - repn_constant + rhs_val = value(rhs_expr) + gurobipy_con = self._solver_model.addLConstr( + gurobi_expr, gurobipy.GRB.LESS_EQUAL, rhs_val, name=conname + ) + if not is_constant(rhs_expr): + mutable_constant = _MutableConstant() + mutable_constant.expr = rhs_expr + mutable_constant.con = gurobipy_con + self._mutable_helpers[con] = [mutable_constant] + else: + raise ValueError( + "Constraint does not have a lower " + "or an upper bound: {0} \n".format(con) + ) + for tmp in mutable_linear_coefficients: + tmp.con = gurobipy_con + tmp.gurobi_model = self._solver_model + if len(mutable_linear_coefficients) > 0: + if con not in self._mutable_helpers: + self._mutable_helpers[con] = mutable_linear_coefficients + else: + self._mutable_helpers[con].extend(mutable_linear_coefficients) + elif gurobi_expr.__class__ is gurobipy.QuadExpr: + if con.equality: + rhs_expr = con.lower - repn_constant + rhs_val = value(rhs_expr) + gurobipy_con = self._solver_model.addQConstr( + gurobi_expr, gurobipy.GRB.EQUAL, rhs_val, name=conname + ) + elif con.has_lb() and con.has_ub(): + raise NotImplementedError( + 'Quadratic range constraints are not supported' + ) + elif con.has_lb(): + rhs_expr = con.lower - repn_constant + rhs_val = value(rhs_expr) + gurobipy_con = self._solver_model.addQConstr( + gurobi_expr, gurobipy.GRB.GREATER_EQUAL, rhs_val, name=conname + ) + elif con.has_ub(): + rhs_expr = con.upper - repn_constant + rhs_val = value(rhs_expr) + gurobipy_con = self._solver_model.addQConstr( + gurobi_expr, gurobipy.GRB.LESS_EQUAL, rhs_val, name=conname + ) + else: + raise ValueError( + "Constraint does not have a lower " + "or an upper bound: {0} \n".format(con) + ) + if ( + len(mutable_linear_coefficients) > 0 + or len(mutable_quadratic_coefficients) > 0 + or not is_constant(repn_constant) + ): + mutable_constant = _MutableConstant() + mutable_constant.expr = rhs_expr + mutable_quadratic_constraint = _MutableQuadraticConstraint( + self._solver_model, + gurobipy_con, + mutable_constant, + mutable_linear_coefficients, + mutable_quadratic_coefficients, + ) + self._mutable_quadratic_helpers[con] = mutable_quadratic_constraint + else: + raise ValueError( + 'Unrecognized Gurobi expression type: ' + str(gurobi_expr.__class__) + ) + + self._pyomo_con_to_solver_con_map[con] = gurobipy_con + self._solver_con_to_pyomo_con_map[id(gurobipy_con)] = con + self._constraints_added_since_update.update(cons) + self._needs_updated = True + + def _add_sos_constraints(self, cons: List[SOSConstraintData]): + for con in cons: + conname = self._symbol_map.getSymbol(con, self._labeler) + level = con.level + if level == 1: + sos_type = gurobipy.GRB.SOS_TYPE1 + elif level == 2: + sos_type = gurobipy.GRB.SOS_TYPE2 + else: + raise ValueError( + "Solver does not support SOS level {0} constraints".format(level) + ) + + gurobi_vars = [] + weights = [] + + for v, w in con.get_items(): + v_id = id(v) + gurobi_vars.append(self._pyomo_var_to_solver_var_map[v_id]) + weights.append(w) + + gurobipy_con = self._solver_model.addSOS(sos_type, gurobi_vars, weights) + self._pyomo_sos_to_solver_sos_map[con] = gurobipy_con + self._constraints_added_since_update.update(cons) + self._needs_updated = True + + def _remove_constraints(self, cons: List[ConstraintData]): + for con in cons: + if con in self._constraints_added_since_update: + self._update_gurobi_model() + solver_con = self._pyomo_con_to_solver_con_map[con] + self._solver_model.remove(solver_con) + self._symbol_map.removeSymbol(con) + del self._pyomo_con_to_solver_con_map[con] + del self._solver_con_to_pyomo_con_map[id(solver_con)] + self._range_constraints.discard(con) + self._mutable_helpers.pop(con, None) + self._mutable_quadratic_helpers.pop(con, None) + self._needs_updated = True + + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + for con in cons: + if con in self._constraints_added_since_update: + self._update_gurobi_model() + solver_sos_con = self._pyomo_sos_to_solver_sos_map[con] + self._solver_model.remove(solver_sos_con) + self._symbol_map.removeSymbol(con) + del self._pyomo_sos_to_solver_sos_map[con] + self._needs_updated = True + + def _remove_variables(self, variables: List[VarData]): + for var in variables: + v_id = id(var) + if var in self._vars_added_since_update: + self._update_gurobi_model() + solver_var = self._pyomo_var_to_solver_var_map[v_id] + self._solver_model.remove(solver_var) + self._symbol_map.removeSymbol(var) + del self._pyomo_var_to_solver_var_map[v_id] + self._mutable_bounds.pop(v_id, None) + self._needs_updated = True + + def _remove_parameters(self, params: List[ParamData]): + pass + + 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 + ) + ) + self._mutable_bounds.pop((var_id, 'lb'), None) + self._mutable_bounds.pop((var_id, 'ub'), None) + gurobipy_var = self._pyomo_var_to_solver_var_map[var_id] + lb, ub, vtype = self._process_domain_and_bounds( + var, var_id, None, None, None, gurobipy_var + ) + gurobipy_var.setAttr('lb', lb) + gurobipy_var.setAttr('ub', ub) + gurobipy_var.setAttr('vtype', vtype) + self._needs_updated = True + + def update_parameters(self): + 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 con, helper in self._mutable_quadratic_helpers.items(): + if con in self._constraints_added_since_update: + self._update_gurobi_model() + gurobi_con = helper.con + new_gurobi_expr = helper.get_updated_expression() + new_rhs = helper.get_updated_rhs() + new_sense = gurobi_con.qcsense + pyomo_con = self._solver_con_to_pyomo_con_map[id(gurobi_con)] + name = self._symbol_map.getSymbol(pyomo_con, self._labeler) + self._solver_model.remove(gurobi_con) + new_con = self._solver_model.addQConstr( + new_gurobi_expr, new_sense, new_rhs, name=name + ) + self._pyomo_con_to_solver_con_map[id(pyomo_con)] = new_con + del self._solver_con_to_pyomo_con_map[id(gurobi_con)] + self._solver_con_to_pyomo_con_map[id(new_con)] = pyomo_con + helper.con = new_con + self._constraints_added_since_update.add(con) + + helper = self._mutable_objective + pyomo_obj = self._objective + new_gurobi_expr = helper.get_updated_expression() + if new_gurobi_expr is not None: + if pyomo_obj.sense == minimize: + sense = gurobipy.GRB.MINIMIZE + else: + sense = gurobipy.GRB.MAXIMIZE + self._solver_model.setObjective(new_gurobi_expr, sense=sense) + + def _set_objective(self, obj): + if obj is None: + sense = gurobipy.GRB.MINIMIZE + gurobi_expr = 0 + repn_constant = 0 + mutable_linear_coefficients = list() + mutable_quadratic_coefficients = list() + 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) + ) + + ( + gurobi_expr, + repn_constant, + mutable_linear_coefficients, + mutable_quadratic_coefficients, + ) = self._get_expr_from_pyomo_expr(obj.expr) + + mutable_constant = _MutableConstant() + mutable_constant.expr = repn_constant + mutable_objective = _MutableObjective( + self._solver_model, + mutable_constant, + mutable_linear_coefficients, + mutable_quadratic_coefficients, + ) + self._mutable_objective = mutable_objective + + # These two lines are needed as a workaround + # see PR #2454 + self._solver_model.setObjective(0) + self._solver_model.update() + + self._solver_model.setObjective(gurobi_expr + value(repn_constant), sense=sense) + self._needs_updated = True + + def _postsolve(self, timer: HierarchicalTimer): + config = self._config + + gprob = self._solver_model + grb = gurobipy.GRB + status = gprob.Status + + results = Results() + results.solution_loader = GurobiSolutionLoader(self) + results.timing_info.gurobi_time = gprob.Runtime + + if gprob.SolCount > 0: + if status == grb.OPTIMAL: + results.solution_status = SolutionStatus.optimal + else: + results.solution_status = SolutionStatus.feasible + else: + results.solution_status = SolutionStatus.noSolution + + if status == grb.LOADED: # problem is loaded, but no solution + results.termination_condition = TerminationCondition.unknown + elif status == grb.OPTIMAL: # optimal + results.termination_condition = ( + TerminationCondition.convergenceCriteriaSatisfied + ) + elif status == grb.INFEASIBLE: + results.termination_condition = TerminationCondition.provenInfeasible + elif status == grb.INF_OR_UNBD: + results.termination_condition = TerminationCondition.infeasibleOrUnbounded + elif status == grb.UNBOUNDED: + results.termination_condition = TerminationCondition.unbounded + elif status == grb.CUTOFF: + results.termination_condition = TerminationCondition.objectiveLimit + elif status == grb.ITERATION_LIMIT: + results.termination_condition = TerminationCondition.iterationLimit + elif status == grb.NODE_LIMIT: + results.termination_condition = TerminationCondition.iterationLimit + elif status == grb.TIME_LIMIT: + results.termination_condition = TerminationCondition.maxTimeLimit + elif status == grb.SOLUTION_LIMIT: + results.termination_condition = TerminationCondition.unknown + elif status == grb.INTERRUPTED: + results.termination_condition = TerminationCondition.interrupted + elif status == grb.NUMERIC: + results.termination_condition = TerminationCondition.unknown + elif status == grb.SUBOPTIMAL: + results.termination_condition = TerminationCondition.unknown + elif status == grb.USER_OBJ_LIMIT: + results.termination_condition = TerminationCondition.objectiveLimit + else: + results.termination_condition = TerminationCondition.unknown + + if ( + results.termination_condition + != 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.' + ) + + results.incumbent_objective = None + results.objective_bound = None + if self._objective is not None: + try: + results.incumbent_objective = gprob.ObjVal + except (gurobipy.GurobiError, AttributeError): + results.incumbent_objective = None + try: + results.objective_bound = gprob.ObjBound + except (gurobipy.GurobiError, AttributeError): + if self._objective.sense == minimize: + results.objective_bound = -math.inf + else: + results.objective_bound = math.inf + + if results.incumbent_objective is not None and not math.isfinite( + results.incumbent_objective + ): + results.incumbent_objective = None + + results.iteration_count = gprob.getAttr('IterCount') + + timer.start('load solution') + if config.load_solutions: + 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.' + ) + timer.stop('load solution') + + return results + + def _load_suboptimal_mip_solution(self, vars_to_load, solution_number): + if ( + self.get_model_attr('NumIntVars') == 0 + and self.get_model_attr('NumBinVars') == 0 + ): + raise ValueError( + 'Cannot obtain suboptimal solutions for a continuous model' + ) + var_map = self._pyomo_var_to_solver_var_map + ref_vars = self._referenced_variables + original_solution_number = self.get_gurobi_param_info('SolutionNumber')[2] + self.set_gurobi_param('SolutionNumber', solution_number) + gurobi_vars_to_load = [var_map[pyomo_var] for pyomo_var in vars_to_load] + vals = self._solver_model.getAttr("Xn", 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 + self.set_gurobi_param('SolutionNumber', original_solution_number) + return res + + def _load_vars(self, vars_to_load=None, solution_number=0): + for v, val in self._get_primals( + vars_to_load=vars_to_load, solution_number=solution_number + ).items(): + v.set_value(val, skip_validation=True) + StaleFlagManager.mark_all_as_stale(delayed=True) + + def _get_primals(self, vars_to_load=None, solution_number=0): + if self._needs_updated: + 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.' + ) + + var_map = self._pyomo_var_to_solver_var_map + ref_vars = self._referenced_variables + if vars_to_load is None: + vars_to_load = self._pyomo_var_to_solver_var_map.keys() + else: + vars_to_load = [id(v) for v in vars_to_load] + + if 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 + + 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.' + ) + + var_map = self._pyomo_var_to_solver_var_map + ref_vars = self._referenced_variables + res = ComponentMap() + if vars_to_load is None: + vars_to_load = self._pyomo_var_to_solver_var_map.keys() + else: + vars_to_load = [id(v) for v in vars_to_load] + + gurobi_vars_to_load = [var_map[pyomo_var_id] for pyomo_var_id in vars_to_load] + vals = self._solver_model.getAttr("Rc", gurobi_vars_to_load) + + 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_duals(self, cons_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 duals. Please ' + 'check the termination condition.' + ) + + con_map = self._pyomo_con_to_solver_con_map + reverse_con_map = self._solver_con_to_pyomo_con_map + dual = dict() + + if cons_to_load is None: + linear_cons_to_load = self._solver_model.getConstrs() + quadratic_cons_to_load = self._solver_model.getQConstrs() + else: + gurobi_cons_to_load = OrderedSet( + [con_map[pyomo_con] for pyomo_con in cons_to_load] + ) + linear_cons_to_load = list( + gurobi_cons_to_load.intersection( + OrderedSet(self._solver_model.getConstrs()) + ) + ) + quadratic_cons_to_load = list( + gurobi_cons_to_load.intersection( + OrderedSet(self._solver_model.getQConstrs()) + ) + ) + linear_vals = self._solver_model.getAttr("Pi", linear_cons_to_load) + quadratic_vals = self._solver_model.getAttr("QCPi", quadratic_cons_to_load) + + for gurobi_con, val in zip(linear_cons_to_load, linear_vals): + pyomo_con = reverse_con_map[id(gurobi_con)] + dual[pyomo_con] = val + for gurobi_con, val in zip(quadratic_cons_to_load, quadratic_vals): + pyomo_con = reverse_con_map[id(gurobi_con)] + dual[pyomo_con] = val + + return dual + + def update(self, timer: HierarchicalTimer = None): + if self._needs_updated: + self._update_gurobi_model() + super(Gurobi, self).update(timer=timer) + self._update_gurobi_model() + + def _update_gurobi_model(self): + self._solver_model.update() + self._constraints_added_since_update = OrderedSet() + self._vars_added_since_update = ComponentSet() + self._needs_updated = False + + def get_model_attr(self, attr): + """ + Get the value of an attribute on the Gurobi model. + + Parameters + ---------- + attr: str + The attribute to get. See Gurobi documentation for descriptions of the attributes. + """ + if self._needs_updated: + self._update_gurobi_model() + return self._solver_model.getAttr(attr) + + def write(self, filename): + """ + Write the model to a file (e.g., and lp file). + + Parameters + ---------- + filename: str + Name of the file to which the model should be written. + """ + self._solver_model.write(filename) + self._constraints_added_since_update = OrderedSet() + self._vars_added_since_update = ComponentSet() + self._needs_updated = False + + def set_linear_constraint_attr(self, con, attr, val): + """ + Set the value of an attribute on a gurobi linear constraint. + + Parameters + ---------- + con: pyomo.core.base.constraint.ConstraintData + The pyomo constraint for which the corresponding gurobi constraint attribute + should be modified. + attr: str + The attribute to be modified. Options are: + CBasis + DStart + Lazy + val: any + See gurobi documentation for acceptable values. + """ + 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.' + ) + self._pyomo_con_to_solver_con_map[con].setAttr(attr, val) + self._needs_updated = True + + def set_var_attr(self, var, attr, val): + """ + Set the value of an attribute on a gurobi variable. + + Parameters + ---------- + var: pyomo.core.base.var.VarData + The pyomo var for which the corresponding gurobi var attribute + should be modified. + attr: str + The attribute to be modified. Options are: + Start + VarHintVal + VarHintPri + BranchPriority + VBasis + PStart + val: any + See gurobi documentation for acceptable values. + """ + 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.' + ) + if attr == 'Obj': + raise ValueError( + 'Var attr Obj cannot be set with' + + ' 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 + + def get_var_attr(self, var, attr): + """ + Get the value of an attribute on a gurobi var. + + Parameters + ---------- + var: pyomo.core.base.var.VarData + The pyomo var for which the corresponding gurobi var attribute + should be retrieved. + attr: str + The attribute to get. See gurobi documentation + """ + if self._needs_updated: + self._update_gurobi_model() + return self._pyomo_var_to_solver_var_map[id(var)].getAttr(attr) + + def get_linear_constraint_attr(self, con, attr): + """ + Get the value of an attribute on a gurobi linear constraint. + + Parameters + ---------- + con: pyomo.core.base.constraint.ConstraintData + The pyomo constraint for which the corresponding gurobi constraint attribute + should be retrieved. + attr: str + The attribute to get. See the Gurobi documentation + """ + if self._needs_updated: + self._update_gurobi_model() + return self._pyomo_con_to_solver_con_map[con].getAttr(attr) + + def get_sos_attr(self, con, attr): + """ + Get the value of an attribute on a gurobi sos constraint. + + Parameters + ---------- + con: pyomo.core.base.sos.SOSConstraintData + The pyomo SOS constraint for which the corresponding gurobi SOS constraint attribute + should be retrieved. + attr: str + The attribute to get. See the Gurobi documentation + """ + if self._needs_updated: + self._update_gurobi_model() + return self._pyomo_sos_to_solver_sos_map[con].getAttr(attr) + + def get_quadratic_constraint_attr(self, con, attr): + """ + Get the value of an attribute on a gurobi quadratic constraint. + + Parameters + ---------- + con: pyomo.core.base.constraint.ConstraintData + The pyomo constraint for which the corresponding gurobi constraint attribute + should be retrieved. + attr: str + The attribute to get. See the Gurobi documentation + """ + if self._needs_updated: + self._update_gurobi_model() + return self._pyomo_con_to_solver_con_map[con].getAttr(attr) + + def set_gurobi_param(self, param, val): + """ + Set a gurobi parameter. + + Parameters + ---------- + param: str + The gurobi parameter to set. Options include any gurobi parameter. + Please see the Gurobi documentation for options. + val: any + The value to set the parameter to. See Gurobi documentation for possible values. + """ + self._solver_model.setParam(param, val) + + def get_gurobi_param_info(self, param): + """ + Get information about a gurobi parameter. + + Parameters + ---------- + param: str + The gurobi parameter to get info for. See Gurobi documentation for possible options. + + Returns + ------- + six-tuple containing the parameter name, type, value, minimum value, maximum value, and default value. + """ + return self._solver_model.getParamInfo(param) + + def _intermediate_callback(self): + def f(gurobi_model, where): + self._callback_func(self._model, self, where) + + return f + + def set_callback(self, func=None): + """ + Specify a callback for gurobi to use. + + Parameters + ---------- + func: function + The function to call. The function should have three arguments. The first will be the pyomo model being + solved. The second will be the GurobiPersistent instance. The third will be an enum member of + gurobipy.GRB.Callback. This will indicate where in the branch and bound algorithm gurobi is at. For + example, suppose we want to solve + + .. math:: + + min 2*x + y + + s.t. + + y >= (x-2)**2 + + 0 <= x <= 4 + + y >= 0 + + y integer + + as an MILP using extended cutting planes in callbacks. + + >>> from gurobipy import GRB # doctest:+SKIP + >>> import pyomo.environ as pe + >>> 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 + >>> + >>> def _add_cut(xval): + ... # a function to generate the cut + ... m.x.value = xval + ... return m.cons.add(m.y >= taylor_series_expansion((m.x - 2)**2)) + ... + >>> _c = _add_cut(0) # start with 2 cuts at the bounds of x + >>> _c = _add_cut(4) # this is an arbitrary choice + >>> + >>> opt = appsi.solvers.Gurobi() + >>> opt.config.stream_solver = True + >>> opt.set_instance(m) # doctest:+SKIP + >>> opt.gurobi_options['PreCrush'] = 1 + >>> opt.gurobi_options['LazyConstraints'] = 1 + >>> + >>> def my_callback(cb_m, cb_opt, cb_where): + ... if cb_where == GRB.Callback.MIPSOL: + ... cb_opt.cbGetSolution(vars=[m.x, m.y]) + ... if m.y.value < (m.x.value - 2)**2 - 1e-6: + ... cb_opt.cbLazy(_add_cut(m.x.value)) + ... + >>> opt.set_callback(my_callback) + >>> res = opt.solve(m) # doctest:+SKIP + + """ + if func is not None: + self._callback_func = func + self._callback = self._intermediate_callback() + else: + self._callback = None + self._callback_func = None + + def cbCut(self, con): + """ + Add a cut within a callback. + + Parameters + ---------- + con: pyomo.core.base.constraint.ConstraintData + The cut to add + """ + if not con.active: + raise ValueError('cbCut expected an active constraint.') + + if is_fixed(con.body): + raise ValueError('cbCut expected a non-trivial constraint') + + ( + gurobi_expr, + repn_constant, + mutable_linear_coefficients, + mutable_quadratic_coefficients, + ) = self._get_expr_from_pyomo_expr(con.body) + + if con.has_lb(): + 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) + ) + if con.has_ub(): + if not is_fixed(con.upper): + raise ValueError( + 'Upper bound of constraint {0} is not constant.'.format(con) + ) + + if con.equality: + self._solver_model.cbCut( + lhs=gurobi_expr, + sense=gurobipy.GRB.EQUAL, + rhs=value(con.lower - repn_constant), + ) + elif con.has_lb() and (value(con.lower) > -float('inf')): + self._solver_model.cbCut( + lhs=gurobi_expr, + sense=gurobipy.GRB.GREATER_EQUAL, + rhs=value(con.lower - repn_constant), + ) + elif con.has_ub() and (value(con.upper) < float('inf')): + self._solver_model.cbCut( + lhs=gurobi_expr, + sense=gurobipy.GRB.LESS_EQUAL, + rhs=value(con.upper - repn_constant), + ) + else: + raise ValueError( + 'Constraint does not have a lower or an upper bound {0} \n'.format(con) + ) + + def cbGet(self, what): + return self._solver_model.cbGet(what) + + def cbGetNodeRel(self, vars): + """ + Parameters + ---------- + vars: 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] + var_values = self._solver_model.cbGetNodeRel(gurobi_vars) + for i, v in enumerate(vars): + v.set_value(var_values[i], skip_validation=True) + + def cbGetSolution(self, vars): + """ + Parameters + ---------- + vars: 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] + var_values = self._solver_model.cbGetSolution(gurobi_vars) + for i, v in enumerate(vars): + v.set_value(var_values[i], skip_validation=True) + + def cbLazy(self, con): + """ + Parameters + ---------- + con: pyomo.core.base.constraint.ConstraintData + The lazy constraint to add + """ + if not con.active: + raise ValueError('cbLazy expected an active constraint.') + + if is_fixed(con.body): + raise ValueError('cbLazy expected a non-trivial constraint') + + ( + gurobi_expr, + repn_constant, + mutable_linear_coefficients, + mutable_quadratic_coefficients, + ) = self._get_expr_from_pyomo_expr(con.body) + + if con.has_lb(): + 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) + ) + if con.has_ub(): + if not is_fixed(con.upper): + raise ValueError( + 'Upper bound of constraint {0} is not constant.'.format(con) + ) + + if con.equality: + self._solver_model.cbLazy( + lhs=gurobi_expr, + sense=gurobipy.GRB.EQUAL, + rhs=value(con.lower - repn_constant), + ) + elif con.has_lb() and (value(con.lower) > -float('inf')): + self._solver_model.cbLazy( + lhs=gurobi_expr, + sense=gurobipy.GRB.GREATER_EQUAL, + rhs=value(con.lower - repn_constant), + ) + elif con.has_ub() and (value(con.upper) < float('inf')): + self._solver_model.cbLazy( + lhs=gurobi_expr, + sense=gurobipy.GRB.LESS_EQUAL, + rhs=value(con.upper - repn_constant), + ) + else: + raise ValueError( + 'Constraint does not have a lower or an upper bound {0} \n'.format(con) + ) + + 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] + self._solver_model.cbSetSolution(gurobi_vars, solution) + + def cbUseSolution(self): + return self._solver_model.cbUseSolution() + + def reset(self): + self._solver_model.reset() diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py new file mode 100644 index 00000000000..04561ea5b1a --- /dev/null +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -0,0 +1,432 @@ +# ___________________________________________________________________________ +# +# 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 datetime +import io +import math +import operator +import os + +from pyomo.common.config import ConfigValue +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.dependencies import attempt_import +from pyomo.common.enums import ObjectiveSense +from pyomo.common.errors import MouseTrap +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.contrib.solver.base import SolverBase +from pyomo.contrib.solver.config import BranchAndBoundConfig +from pyomo.contrib.solver.results import Results, SolutionStatus, TerminationCondition +from pyomo.contrib.solver.solution import SolutionLoaderBase + +from pyomo.core.staleflag import StaleFlagManager + +from pyomo.repn.plugins.standard_form import LinearStandardFormCompiler + +gurobipy, gurobipy_available = attempt_import('gurobipy') + + +class GurobiConfig(BranchAndBoundConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super(GurobiConfig, self).__init__( + 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 current values of the integer variables " + "will be passed to Gurobi.", + ), + ) + + +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 RuntimeError( + 'Solver does not currently have a valid solution. Please ' + 'check the termination condition.' + ) + + 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 RuntimeError( + 'Solver does not currently have a valid solution. Please ' + 'check the termination condition.' + ) + + 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 RuntimeError( + 'Solver does not currently have valid duals. Please ' + 'check the termination condition.' + ) + + 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 RuntimeError( + 'Solver does not currently have valid reduced costs. Please ' + 'check the termination condition.' + ) + + 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 GurobiDirect(SolverBase): + CONFIG = GurobiConfig() + + _available = None + _num_instances = 0 + _tc_map = None + + def __init__(self, **kwds): + super().__init__(**kwds) + GurobiDirect._num_instances += 1 + + 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() + avail = True + except gurobipy.GurobiError: + avail = False + + if avail: + if self._available is None: + self._available = GurobiDirect._check_full_license(m) + return self._available + else: + return self.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 cls.Availability.FullLicense + except gurobipy.GurobiError: + return cls.Availability.LimitedLicense + + def __del__(self): + if not python_is_shutting_down(): + GurobiDirect._num_instances -= 1 + if GurobiDirect._num_instances == 0: + self.release_license() + + @staticmethod + def release_license(): + if gurobipy_available: + with capture_output(capture_fd=True): + gurobipy.disposeDefaultEnv() + + def version(self): + version = ( + gurobipy.GRB.VERSION_MAJOR, + gurobipy.GRB.VERSION_MINOR, + gurobipy.GRB.VERSION_TECHNICAL, + ) + return version + + def solve(self, model, **kwds) -> Results: + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + config = self.config(value=kwds, preserve_implicit=True) + 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 ValueError( + 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() + + try: + orig_cwd = os.getcwd() + 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') + x = gurobi_model.addMVar( + len(repn.columns), + lb=lb, + ub=ub, + obj=repn.c.todense()[0] if repn.c.shape[0] else 0, + vtype=vtype, + ) + 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) + + res = self._postsolve( + timer, + config, + GurobiDirectSolutionLoader( + gurobi_model, A, x, repn.rows, repn.columns, repn.objectives + ), + ) + + res.solver_configuration = 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 RuntimeError( + 'Solver did not find the optimal solution. Set ' + 'opt.config.raise_exception_on_nonoptimal_result=False ' + 'to bypass this error.' + ) + + 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 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.' + ) + 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/ipopt.py b/pyomo/contrib/solver/ipopt.py new file mode 100644 index 00000000000..a49bd0e58a2 --- /dev/null +++ b/pyomo/contrib/solver/ipopt.py @@ -0,0 +1,551 @@ +# ___________________________________________________________________________ +# +# 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 os +import subprocess +import datetime +import io +from typing import Mapping, Optional, Sequence + +from pyomo.common import Executable +from pyomo.common.config import ConfigValue, document_kwargs_from_configdict, ConfigDict +from pyomo.common.errors import ( + PyomoException, + DeveloperError, + InfeasibleConstraintException, +) +from pyomo.common.tempfiles import TempfileManager +from pyomo.common.timing import HierarchicalTimer +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.results import Results, TerminationCondition, SolutionStatus +from pyomo.contrib.solver.sol_reader import parse_sol_file +from pyomo.contrib.solver.solution import SolSolutionLoader +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 + +logger = logging.getLogger(__name__) + + +class IpoptSolverError(PyomoException): + """General exception to catch solver system errors""" + + +class IpoptConfig(SolverConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.executable: Executable = self.declare( + 'executable', + ConfigValue( + default=Executable('ipopt'), + description="Preferred executable for ipopt. Defaults to searching the " + "``PATH`` for the first available ``ipopt``.", + ), + ) + self.writer_config: ConfigDict = self.declare( + 'writer_config', NLWriter.CONFIG() + ) + + +class IpoptSolutionLoader(SolSolutionLoader): + def get_reduced_costs( + 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.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." + ) + if self._nl_info.scaling is None: + scale_list = [1] * len(self._nl_info.variables) + obj_scale = 1 + else: + scale_list = self._nl_info.scaling.variables + obj_scale = self._nl_info.scaling.objectives[0] + sol_data = self._sol_data + 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() + for ndx, v in enumerate(nl_info.variables): + scale = scale_list[ndx] + v_id = id(v) + rc[v_id] = (v, 0) + if ndx in zl_map: + zl = zl_map[ndx] * scale / obj_scale + if abs(zl) > abs(rc[v_id][1]): + rc[v_id] = (v, zl) + if ndx in zu_map: + zu = zu_map[ndx] * scale / obj_scale + if abs(zu) > abs(rc[v_id][1]): + rc[v_id] = (v, zu) + + if vars_to_load is None: + res = ComponentMap(rc.values()) + for v, _ in nl_info.eliminated_vars: + res[v] = 0 + else: + res = ComponentMap() + for v in vars_to_load: + if id(v) in rc: + res[v] = rc[id(v)][1] + else: + # eliminated vars + res[v] = 0 + return res + + +ipopt_command_line_options = { + 'acceptable_compl_inf_tol', + 'acceptable_constr_viol_tol', + 'acceptable_dual_inf_tol', + 'acceptable_tol', + 'alpha_for_y', + 'bound_frac', + 'bound_mult_init_val', + 'bound_push', + 'bound_relax_factor', + 'compl_inf_tol', + 'constr_mult_init_max', + 'constr_viol_tol', + 'diverging_iterates_tol', + 'dual_inf_tol', + 'expect_infeasible_problem', + 'file_print_level', + 'halt_on_ampl_error', + 'hessian_approximation', + 'honor_original_bounds', + 'linear_scaling_on_demand', + 'linear_solver', + 'linear_system_scaling', + 'ma27_pivtol', + 'ma27_pivtolmax', + 'ma57_pivot_order', + 'ma57_pivtol', + 'ma57_pivtolmax', + 'max_cpu_time', + 'max_iter', + 'max_refinement_steps', + 'max_soc', + 'maxit', + 'min_refinement_steps', + 'mu_init', + 'mu_max', + 'mu_oracle', + 'mu_strategy', + 'nlp_scaling_max_gradient', + 'nlp_scaling_method', + 'obj_scaling_factor', + 'option_file_name', + 'outlev', + 'output_file', + 'pardiso_matching_strategy', + 'print_level', + 'print_options_documentation', + 'print_user_options', + 'required_infeasibility_reduction', + 'slack_bound_frac', + 'slack_bound_push', + 'tol', + 'wantsol', + 'warm_start_bound_push', + 'warm_start_init_point', + 'warm_start_mult_bound_push', + 'watchdog_shortened_iter_trigger', +} + + +class Ipopt(SolverBase): + CONFIG = IpoptConfig() + + def __init__(self, **kwds): + super().__init__(**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: + config = self.config + 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) + else: + self._available_cache = (pth, self.Availability.FullLicense) + return self._available_cache[1] + + def version(self, config=None): + if config is None: + config = self.config + pth = config.executable.path() + if self._version_cache is None or self._version_cache[0] != pth: + if pth is None: + self._version_cache = (None, None) + else: + results = subprocess.run( + [str(pth), '--version'], + timeout=self._version_timeout, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + ) + version = results.stdout.splitlines()[0] + version = version.split(' ')[1].strip() + version = tuple(int(i) for i in version.split('.')) + 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 + opt_file_exists = False + if not options: + return False + # If it has options in it, parse them and write them to a file. + # If they are command line options, ignore them; they will be + # parsed during _create_command_line + 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: + opt_file.write(str(k) + ' ' + str(val) + '\n') + return opt_file_exists + + def _create_command_line(self, basename: str, config: IpoptConfig, opt_file: bool): + cmd = [str(config.executable), basename + '.nl', '-AMPL'] + if opt_file: + cmd.append('option_file_name=' + basename + '.opt') + if 'option_file_name' in config.solver_options: + raise ValueError( + 'Pyomo generates the ipopt options file as part of the `solve` method. ' + 'Add all options to ipopt.config.solver_options instead.' + ) + if ( + config.time_limit is not None + and 'max_cpu_time' not in config.solver_options + ): + config.solver_options['max_cpu_time'] = config.time_limit + for k, val in config.solver_options.items(): + if k in ipopt_command_line_options: + cmd.append(str(k) + '=' + str(val)) + return cmd + + @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 + config: IpoptConfig = self.config(value=kwds, preserve_implicit=True) + # Check if solver is available + avail = self.available(config) + if not avail: + raise IpoptSolverError( + 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__}.", + ) + if config.timer is None: + timer = HierarchicalTimer() + else: + timer = config.timer + StaleFlagManager.mark_all_as_stale() + with TempfileManager.new_context() as tempfile: + if config.working_dir is None: + dname = tempfile.mkdtemp() + else: + dname = config.working_dir + if not os.path.exists(dname): + os.mkdir(dname) + basename = os.path.join(dname, model.name) + if os.path.exists(basename + '.nl'): + raise RuntimeError( + f"NL file with the same name {basename + '.nl'} already exists!" + ) + # 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') as nl_file, open( + basename + '.row', 'w' + ) as row_file, open(basename + '.col', 'w') as col_file: + timer.start('write_nl_file') + self._writer.config.set_value(config.writer_config) + 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 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) + # 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( + filename=basename, options=config.solver_options + ) + # Call ipopt - passing the files via the subprocess + cmd = self._create_command_line( + basename=basename, config=config, opt_file=opt_file + ) + # this seems silly, but we have to give the subprocess slightly longer to finish than + # ipopt + if config.time_limit is not None: + timeout = config.time_limit + min( + max(1.0, 0.01 * config.time_limit), 100 + ) + else: + timeout = None + + ostreams = [io.StringIO()] + config.tee + with TeeStream(*ostreams) as t: + timer.start('subprocess') + process = subprocess.run( + cmd, + timeout=timeout, + env=env, + universal_newlines=True, + stdout=t.STDOUT, + stderr=t.STDERR, + ) + 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) = ( + self._parse_ipopt_output(ostreams[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 + results.solution_loader = SolSolutionLoader(None, None) + else: + results = Results() + results.termination_condition = ( + TerminationCondition.convergenceCriteriaSatisfied + ) + results.solution_status = SolutionStatus.optimal + results.solution_loader = SolSolutionLoader(None, nl_info=nl_info) + results.iteration_count = 0 + results.timing_info.total_seconds = 0 + else: + if os.path.isfile(basename + '.sol'): + with open(basename + '.sol', 'r') as sol_file: + timer.start('parse_sol') + results = self._parse_solution(sol_file, nl_info) + timer.stop('parse_sol') + else: + results = Results() + if process.returncode != 0: + results.extra_info.return_code = process.returncode + results.termination_condition = TerminationCondition.error + results.solution_loader = SolSolutionLoader(None, None) + else: + results.iteration_count = iters + if ipopt_time_nofunc is not None: + results.timing_info.ipopt_excluding_nlp_functions = ( + ipopt_time_nofunc + ) + + if ipopt_time_func is not None: + results.timing_info.nlp_function_evaluations = ipopt_time_func + if ipopt_total_time is not None: + results.timing_info.total_seconds = ipopt_total_time + if ( + 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.' + ) + + 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: + results.solution_loader.load_vars() + if ( + hasattr(model, 'dual') + and isinstance(model.dual, Suffix) + and model.dual.import_enabled() + ): + model.dual.update(results.solution_loader.get_duals()) + if ( + hasattr(model, 'rc') + and isinstance(model.rc, Suffix) + and model.rc.import_enabled() + ): + model.rc.update(results.solution_loader.get_reduced_costs()) + + if ( + results.solution_status in {SolutionStatus.feasible, SolutionStatus.optimal} + and len(nl_info.objectives) > 0 + ): + if config.load_solutions: + results.incumbent_objective = value(nl_info.objectives[0]) + else: + results.incumbent_objective = value( + replace_expressions( + nl_info.objectives[0].expr, + substitution_map={ + id(v): val + for v, val in results.solution_loader.get_primals().items() + }, + descend_into_named_expressions=True, + remove_named_expressions=True, + ) + ) + + results.solver_configuration = config + if not proven_infeasible and len(nl_info.variables) > 0: + results.solver_log = ostreams[0].getvalue() + + # Capture/record end-time / wall-time + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + results.timing_info.start_timestamp = start_timestamp + results.timing_info.wall_time = ( + end_timestamp - start_timestamp + ).total_seconds() + results.timing_info.timer = timer + return results + + def _parse_ipopt_output(self, stream: io.StringIO): + """ + Parse an IPOPT output file and return: + + * number of iterations + * time in IPOPT + + """ + + iters = None + nofunc_time = None + func_time = None + total_time = None + # parse the output stream to get the iteration count and solver time + for line in stream.getvalue().splitlines(): + if line.startswith("Number of Iterations....:"): + tokens = line.split() + iters = int(tokens[-1]) + elif line.startswith( + "Total seconds in IPOPT =" + ): + # Newer versions of IPOPT no longer separate timing into + # two different values. This is so we have compatibility with + # both new and old versions + tokens = line.split() + total_time = float(tokens[-1]) + elif line.startswith( + "Total CPU secs in IPOPT (w/o function evaluations) =" + ): + tokens = line.split() + nofunc_time = float(tokens[-1]) + elif line.startswith( + "Total CPU secs in NLP function evaluations =" + ): + tokens = line.split() + func_time = float(tokens[-1]) + + return iters, nofunc_time, func_time, total_time + + def _parse_solution(self, instream: io.TextIOBase, nl_info: NLWriterInfo): + results = Results() + res, sol_data = parse_sol_file( + sol_file=instream, nl_info=nl_info, result=results + ) + + if res.solution_status == SolutionStatus.noSolution: + res.solution_loader = SolSolutionLoader(None, None) + else: + res.solution_loader = IpoptSolutionLoader( + sol_data=sol_data, nl_info=nl_info + ) + + return res diff --git a/pyomo/contrib/solver/persistent.py b/pyomo/contrib/solver/persistent.py new file mode 100644 index 00000000000..65da81a0c08 --- /dev/null +++ b/pyomo/contrib/solver/persistent.py @@ -0,0 +1,496 @@ +# ___________________________________________________________________________ +# +# 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 List + +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.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 + + +class PersistentSolverUtils(abc.ABC): + def __init__(self): + self._model = None + self._active_constraints = {} # maps constraint to (lower, body, upper) + self._vars = {} # maps var id to (var, lb, ub, fixed, domain, value) + self._params = {} # maps param id to param + self._objective = None + self._objective_expr = None + self._objective_sense = None + self._named_expressions = ( + {} + ) # maps constraint to list of tuples (named_expr, named_expr.expr) + self._external_functions = ComponentMap() + self._obj_named_expressions = [] + self._referenced_variables = ( + {} + ) # var_id: [dict[constraints, None], dict[sos constraints, None], None or objective] + self._vars_referenced_by_con = {} + self._vars_referenced_by_obj = [] + self._expr_types = None + + def set_instance(self, model): + saved_config = self.config + self.__init__() + self.config = saved_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[VarData]): + pass + + 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) + ) + self._referenced_variables[id(v)] = [{}, {}, None] + self._vars[id(v)] = ( + v, + v._lb, + v._ub, + v.fixed, + v.domain.get_interval(), + v.value, + ) + self._add_variables(variables) + + @abc.abstractmethod + def _add_parameters(self, params: List[ParamData]): + pass + + 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[ConstraintData]): + pass + + def _check_for_new_vars(self, variables: List[VarData]): + new_vars = {} + for v in variables: + v_id = id(v) + if v_id not in self._referenced_variables: + new_vars[v_id] = v + self.add_variables(list(new_vars.values())) + + def _check_to_remove_vars(self, variables: List[VarData]): + vars_to_remove = {} + for v in variables: + v_id = id(v) + ref_cons, ref_sos, ref_obj = self._referenced_variables[v_id] + if len(ref_cons) == 0 and len(ref_sos) == 0 and ref_obj is None: + vars_to_remove[v_id] = v + self.remove_variables(list(vars_to_remove.values())) + + 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.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] + if len(external_functions) > 0: + self._external_functions[con] = external_functions + 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: + for v in fixed_vars: + v.unfix() + all_fixed_vars[id(v)] = v + self._add_constraints(cons) + for v in all_fixed_vars.values(): + v.fix() + + @abc.abstractmethod + def _add_sos_constraints(self, cons: List[SOSConstraintData]): + pass + + 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) + ) + self._active_constraints[con] = tuple() + variables = con.get_variables() + self._check_for_new_vars(variables) + self._named_expressions[con] = [] + self._vars_referenced_by_con[con] = variables + for v in variables: + self._referenced_variables[id(v)][1][con] = None + self._add_sos_constraints(cons) + + @abc.abstractmethod + def _set_objective(self, obj: ObjectiveData): + pass + + 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 + self._check_to_remove_vars(self._vars_referenced_by_obj) + self._external_functions.pop(self._objective, None) + if obj is not None: + self._objective = obj + self._objective_expr = obj.expr + self._objective_sense = obj.sense + tmp = collect_vars_and_named_exprs(obj.expr) + named_exprs, variables, fixed_vars, external_functions = tmp + self._check_for_new_vars(variables) + self._obj_named_expressions = [(i, i.expr) for i in named_exprs] + if len(external_functions) > 0: + self._external_functions[obj] = external_functions + 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: + for v in fixed_vars: + v.unfix() + self._set_objective(obj) + for v in fixed_vars: + v.fix() + else: + self._vars_referenced_by_obj = [] + self._objective = None + self._objective_expr = None + self._objective_sense = None + self._obj_named_expressions = [] + self._set_objective(obj) + + def add_block(self, block): + param_dict = {} + for p in block.component_objects(Param, descend_into=True): + if p.mutable: + for _p in p.values(): + param_dict[id(_p)] = _p + self.add_parameters(list(param_dict.values())) + self.add_constraints( + list( + block.component_data_objects(Constraint, descend_into=True, active=True) + ) + ) + self.add_sos_constraints( + list( + block.component_data_objects( + SOSConstraint, descend_into=True, active=True + ) + ) + ) + obj = get_objective(block) + if obj is not None: + self.set_objective(obj) + + @abc.abstractmethod + def _remove_constraints(self, cons: List[ConstraintData]): + pass + + 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 + ) + ) + for v in self._vars_referenced_by_con[con]: + self._referenced_variables[id(v)][0].pop(con) + self._check_to_remove_vars(self._vars_referenced_by_con[con]) + del self._active_constraints[con] + del self._named_expressions[con] + self._external_functions.pop(con, None) + del self._vars_referenced_by_con[con] + + @abc.abstractmethod + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + pass + + 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 + ) + ) + for v in self._vars_referenced_by_con[con]: + self._referenced_variables[id(v)][1].pop(con) + self._check_to_remove_vars(self._vars_referenced_by_con[con]) + del self._active_constraints[con] + del self._named_expressions[con] + del self._vars_referenced_by_con[con] + + @abc.abstractmethod + def _remove_variables(self, variables: List[VarData]): + pass + + 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 + ) + ) + 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 + ) + ) + del self._referenced_variables[v_id] + del self._vars[v_id] + + @abc.abstractmethod + def _remove_parameters(self, params: List[ParamData]): + pass + + def remove_parameters(self, params: List[ParamData]): + self._remove_parameters(params) + for p in params: + del self._params[id(p)] + + def remove_block(self, block): + self.remove_constraints( + list( + block.component_data_objects( + ctype=Constraint, descend_into=True, active=True + ) + ) + ) + self.remove_sos_constraints( + list( + block.component_data_objects( + ctype=SOSConstraint, descend_into=True, active=True + ) + ) + ) + self.remove_parameters( + list( + dict( + (id(p), p) + for p in block.component_data_objects( + ctype=Param, descend_into=True + ) + ).values() + ) + ) + + @abc.abstractmethod + def _update_variables(self, variables: List[VarData]): + pass + + def update_variables(self, variables: List[VarData]): + for v in variables: + self._vars[id(v)] = ( + v, + v._lb, + v._ub, + v.fixed, + v.domain.get_interval(), + v.value, + ) + self._update_variables(variables) + + @abc.abstractmethod + def update_parameters(self): + pass + + def update(self, timer: HierarchicalTimer = None): + if timer is None: + timer = HierarchicalTimer() + config = self.config.auto_updates + new_vars = [] + old_vars = [] + new_params = [] + old_params = [] + new_cons = [] + old_cons = [] + old_sos = [] + new_sos = [] + current_vars_dict = {} + current_cons_dict = {} + current_sos_dict = {} + timer.start('vars') + if config.update_vars: + start_vars = {v_id: v_tuple[0] for v_id, v_tuple in self._vars.items()} + timer.stop('vars') + timer.start('params') + if config.check_for_new_or_removed_params: + current_params_dict = {} + for p in self._model.component_objects(Param, descend_into=True): + if p.mutable: + for _p in p.values(): + current_params_dict[id(_p)] = _p + for p_id, p in current_params_dict.items(): + if p_id not in self._params: + new_params.append(p) + for p_id, p in self._params.items(): + if p_id not in current_params_dict: + old_params.append(p) + timer.stop('params') + timer.start('cons') + if config.check_for_new_or_removed_constraints or config.update_constraints: + current_cons_dict = { + c: None + for c in self._model.component_data_objects( + Constraint, descend_into=True, active=True + ) + } + current_sos_dict = { + c: None + for c in self._model.component_data_objects( + SOSConstraint, descend_into=True, active=True + ) + } + for c in current_cons_dict.keys(): + if c not in self._vars_referenced_by_con: + new_cons.append(c) + 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(): + 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, ConstraintData) + ): + old_cons.append(c) + else: + assert (c.ctype is SOSConstraint) or ( + c.ctype is None and isinstance(c, SOSConstraintData) + ) + old_sos.append(c) + self.remove_constraints(old_cons) + self.remove_sos_constraints(old_sos) + timer.stop('cons') + timer.start('params') + self.remove_parameters(old_params) + + # sticking this between removal and addition + # is important so that we don't do unnecessary work + if config.update_parameters: + self.update_parameters() + + self.add_parameters(new_params) + timer.stop('params') + timer.start('vars') + self.add_variables(new_vars) + timer.stop('vars') + timer.start('cons') + self.add_constraints(new_cons) + 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: + for c in current_cons_dict.keys(): + 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) + self.remove_sos_constraints(sos_to_update) + self.add_sos_constraints(sos_to_update) + timer.stop('cons') + timer.start('vars') + if config.update_vars: + end_vars = {v_id: v_tuple[0] for v_id, v_tuple in self._vars.items()} + vars_to_check = [v for v_id, v in end_vars.items() if v_id in start_vars] + if config.update_vars: + vars_to_update = [] + for v in vars_to_check: + _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: + 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: + need_to_set_objective = True + elif lb is not v._lb: + vars_to_update.append(v) + elif ub is not v._ub: + vars_to_update.append(v) + elif domain_interval != v.domain.get_interval(): + vars_to_update.append(v) + self.update_variables(vars_to_update) + timer.stop('vars') + timer.start('cons') + cons_to_remove_and_add = list(cons_to_remove_and_add.keys()) + self.remove_constraints(cons_to_remove_and_add) + self.add_constraints(cons_to_remove_and_add) + timer.stop('cons') + timer.start('named expressions') + if config.update_named_expressions: + cons_to_update = [] + for c, expr_list in self._named_expressions.items(): + if c in new_cons_set: + continue + for named_expr, old_expr in expr_list: + if named_expr.expr is not old_expr: + cons_to_update.append(c) + break + self.remove_constraints(cons_to_update) + self.add_constraints(cons_to_update) + for named_expr, old_expr in self._obj_named_expressions: + if named_expr.expr is not old_expr: + need_to_set_objective = True + break + timer.stop('named expressions') + timer.start('objective') + if self.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 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: + # we can definitely do something faster here than resetting the whole objective + need_to_set_objective = True + if need_to_set_objective: + self.set_objective(pyomo_obj) + timer.stop('objective') + + # this has to be done after the objective and constraints in case the + # old objective/constraints use old variables + timer.start('vars') + self.remove_variables(old_vars) + timer.stop('vars') diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py new file mode 100644 index 00000000000..82c10a32fd8 --- /dev/null +++ b/pyomo/contrib/solver/plugins.py @@ -0,0 +1,30 @@ +# ___________________________________________________________________________ +# +# 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 .factory import SolverFactory +from .ipopt import Ipopt +from .gurobi import Gurobi +from .gurobi_direct import GurobiDirect + + +def load(): + SolverFactory.register( + name='ipopt', legacy_name='ipopt_v2', doc='The IPOPT NLP solver' + )(Ipopt) + SolverFactory.register( + name='gurobi', legacy_name='gurobi_v2', doc='Persistent interface to Gurobi' + )(Gurobi) + SolverFactory.register( + name='gurobi_direct', + legacy_name='gurobi_direct_v2', + doc='Direct (scipy-based) interface to Gurobi', + )(GurobiDirect) diff --git a/pyomo/contrib/solver/results.py b/pyomo/contrib/solver/results.py new file mode 100644 index 00000000000..99a9e0a0faf --- /dev/null +++ b/pyomo/contrib/solver/results.py @@ -0,0 +1,344 @@ +# ___________________________________________________________________________ +# +# 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 enum +from typing import Optional, Tuple +from datetime import datetime + +from pyomo.common.config import ( + ConfigDict, + ConfigValue, + IsInstance, + NonNegativeInt, + In, + NonNegativeFloat, + ADVANCED_OPTION, +) +from pyomo.opt.results.solution import SolutionStatus as LegacySolutionStatus +from pyomo.opt.results.solver import ( + TerminationCondition as LegacyTerminationCondition, + SolverStatus as LegacySolverStatus, +) + + +class TerminationCondition(enum.Enum): + """ + An Enum that enumerates all possible exit statuses for a solver call. + + """ + + 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. + + """ + + 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) + """ + + +class Results(ConfigDict): + """Base class for all solver results + + Attributes + ---------- + solution_loader: .SolutionLoaderBase + Object for loading the solution back into the model. + termination_condition: TerminationCondition + The reason the solver exited. This is a member of the + TerminationCondition enum. + solution_status: 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 three 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 + + Specific solvers may add other relevant timing information, as appropriate. + 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. + + """ + + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.solution_loader = self.declare( + 'solution_loader', + ConfigValue( + description="Object for loading the solution back into the model." + ), + ) + self.termination_condition: TerminationCondition = self.declare( + 'termination_condition', + ConfigValue( + domain=In(TerminationCondition), + default=TerminationCondition.unknown, + description="The reason the solver exited. This is a member of the " + "TerminationCondition enum.", + ), + ) + self.solution_status: SolutionStatus = self.declare( + 'solution_status', + ConfigValue( + domain=In(SolutionStatus), + default=SolutionStatus.noSolution, + description="The result of the solve call. This is a member of " + "the SolutionStatus enum.", + ), + ) + self.incumbent_objective: Optional[float] = self.declare( + 'incumbent_objective', + ConfigValue( + domain=float, + default=None, + description="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.", + ), + ) + self.objective_bound: Optional[float] = self.declare( + 'objective_bound', + ConfigValue( + domain=float, + default=None, + description="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)", + ), + ) + self.solver_name: Optional[str] = self.declare( + 'solver_name', + ConfigValue(domain=str, description="The name of the solver in use."), + ) + self.solver_version: Optional[Tuple[int, ...]] = self.declare( + 'solver_version', + ConfigValue( + domain=tuple, + description="A tuple representing the version of the solver in use.", + ), + ) + self.iteration_count: Optional[int] = self.declare( + 'iteration_count', + ConfigValue( + domain=NonNegativeInt, + default=None, + description="The total number of iterations.", + ), + ) + self.timing_info: ConfigDict = self.declare( + 'timing_info', ConfigDict(implicit=True) + ) + + self.timing_info.start_timestamp: datetime = self.timing_info.declare( + 'start_timestamp', + ConfigValue( + domain=IsInstance(datetime), + description="UTC timestamp of when run was initiated.", + ), + ) + self.timing_info.wall_time: Optional[float] = self.timing_info.declare( + 'wall_time', + ConfigValue( + domain=NonNegativeFloat, + description="Elapsed wall clock time for entire process.", + ), + ) + self.extra_info: ConfigDict = self.declare( + 'extra_info', ConfigDict(implicit=True) + ) + self.solver_configuration: ConfigDict = self.declare( + 'solver_configuration', + ConfigValue( + description="A copy of the config object used in the solve call.", + visibility=ADVANCED_OPTION, + ), + ) + self.solver_log: str = self.declare( + 'solver_log', + ConfigValue( + domain=str, + default=None, + visibility=ADVANCED_OPTION, + description="Any solver log messages.", + ), + ) + + def display( + self, content_filter=None, indent_spacing=2, ostream=None, visibility=0 + ): + return super().display(content_filter, indent_spacing, ostream, visibility) + + +# Everything below here preserves backwards compatibility + +legacy_termination_condition_map = { + TerminationCondition.unknown: LegacyTerminationCondition.unknown, + TerminationCondition.maxTimeLimit: LegacyTerminationCondition.maxTimeLimit, + TerminationCondition.iterationLimit: LegacyTerminationCondition.maxIterations, + TerminationCondition.objectiveLimit: LegacyTerminationCondition.minFunctionValue, + TerminationCondition.minStepLength: LegacyTerminationCondition.minStepLength, + TerminationCondition.convergenceCriteriaSatisfied: LegacyTerminationCondition.optimal, + TerminationCondition.unbounded: LegacyTerminationCondition.unbounded, + TerminationCondition.provenInfeasible: LegacyTerminationCondition.infeasible, + TerminationCondition.locallyInfeasible: LegacyTerminationCondition.infeasible, + TerminationCondition.infeasibleOrUnbounded: LegacyTerminationCondition.infeasibleOrUnbounded, + TerminationCondition.error: LegacyTerminationCondition.error, + TerminationCondition.interrupted: LegacyTerminationCondition.resourceInterrupt, + TerminationCondition.licensingProblems: LegacyTerminationCondition.licensingProblems, +} + + +legacy_solver_status_map = { + TerminationCondition.unknown: LegacySolverStatus.unknown, + TerminationCondition.maxTimeLimit: LegacySolverStatus.aborted, + TerminationCondition.iterationLimit: LegacySolverStatus.aborted, + TerminationCondition.objectiveLimit: LegacySolverStatus.aborted, + TerminationCondition.minStepLength: LegacySolverStatus.error, + TerminationCondition.convergenceCriteriaSatisfied: LegacySolverStatus.ok, + TerminationCondition.unbounded: LegacySolverStatus.error, + TerminationCondition.provenInfeasible: LegacySolverStatus.error, + TerminationCondition.locallyInfeasible: LegacySolverStatus.error, + TerminationCondition.infeasibleOrUnbounded: LegacySolverStatus.error, + TerminationCondition.error: LegacySolverStatus.error, + TerminationCondition.interrupted: LegacySolverStatus.aborted, + TerminationCondition.licensingProblems: LegacySolverStatus.error, +} + + +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, +} diff --git a/pyomo/contrib/solver/sol_reader.py b/pyomo/contrib/solver/sol_reader.py new file mode 100644 index 00000000000..41d840f8d07 --- /dev/null +++ b/pyomo/contrib/solver/sol_reader.py @@ -0,0 +1,207 @@ +# ___________________________________________________________________________ +# +# 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 typing import Tuple, Dict, Any, List +import io + +from pyomo.common.errors import DeveloperError, PyomoException +from pyomo.repn.plugins.nl_writer import NLWriterInfo +from pyomo.contrib.solver.results import Results, SolutionStatus, TerminationCondition + + +class SolFileData: + 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() + + +def parse_sol_file( + sol_file: io.TextIOBase, nl_info: NLWriterInfo, result: Results +) -> Tuple[Results, SolFileData]: + sol_data = SolFileData() + + # + # Some solvers (minto) do not write a message. We will assume + # all non-blank lines up to the 'Options' line is the message. + # 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. + message = [] + for line in sol_file: + if not line: + break + line = line.strip() + if "Options" in 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: + raise PyomoException("ERROR READING `sol` FILE. No 'Options' line found.") + # 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] + assert number_of_cons == len(nl_info.constraints) + assert number_of_vars == len(nl_info.variables) + + duals = [float(sol_file.readline()) for i in range(number_of_cons)] + variable_vals = [float(sol_file.readline()) for i in range(number_of_vars)] + + # Parse the exit code line and capture it + exit_code = [0, 0] + line = sol_file.readline() + if line and ('objno' in line): + exit_code_line = line.split() + if len(exit_code_line) != 3: + raise PyomoException( + f"ERROR READING `sol` FILE. Expected two numbers in `objno` line; received {line}." + ) + exit_code = [int(exit_code_line[1]), int(exit_code_line[2])] + else: + raise PyomoException( + f"ERROR READING `sol` FILE. Expected `objno`; received {line}." + ) + result.extra_info.solver_message = message.strip().replace('\n', '; ') + exit_code_message = '' + if (exit_code[1] >= 0) and (exit_code[1] <= 99): + result.solution_status = SolutionStatus.optimal + result.termination_condition = TerminationCondition.convergenceCriteriaSatisfied + elif (exit_code[1] >= 100) and (exit_code[1] <= 199): + exit_code_message = "Optimal solution indicated, but ERROR LIKELY!" + result.solution_status = SolutionStatus.feasible + result.termination_condition = TerminationCondition.error + elif (exit_code[1] >= 200) and (exit_code[1] <= 299): + exit_code_message = "INFEASIBLE SOLUTION: constraints cannot be satisfied!" + result.solution_status = SolutionStatus.infeasible + result.termination_condition = TerminationCondition.locallyInfeasible + elif (exit_code[1] >= 300) and (exit_code[1] <= 399): + exit_code_message = ( + "UNBOUNDED PROBLEM: the objective can be improved without limit!" + ) + result.solution_status = SolutionStatus.noSolution + result.termination_condition = TerminationCondition.unbounded + elif (exit_code[1] >= 400) and (exit_code[1] <= 499): + exit_code_message = ( + "EXCEEDED MAXIMUM NUMBER OF ITERATIONS: the solver " + "was stopped by a limit that you set!" + ) + result.solution_status = SolutionStatus.infeasible + result.termination_condition = ( + TerminationCondition.iterationLimit + ) # this is not always correct + elif (exit_code[1] >= 500) and (exit_code[1] <= 599): + exit_code_message = ( + "FAILURE: the solver stopped by an error condition " + "in the solver routines!" + ) + result.termination_condition = TerminationCondition.error + + if result.extra_info.solver_message: + if exit_code_message: + result.extra_info.solver_message += '; ' + exit_code_message + else: + result.extra_info.solver_message = exit_code_message + + if result.solution_status != SolutionStatus.noSolution: + sol_data.primals = variable_vals + sol_data.duals = duals + ### Read suffixes ### + line = sol_file.readline() + while line: + line = line.strip() + if line == "": + continue + line = line.split() + # Some sort of garbage we tag onto the solver message, assuming we are past the suffixes + if line[0] != 'suffix': + # We assume this is the start of a + # section like kestrel_option, which + # comes after all suffixes. + remaining = "" + line = sol_file.readline() + while line: + remaining += line.strip() + "; " + line = sol_file.readline() + result.extra_info.solver_message += remaining + break + read_data_type = int(line[1]) + data_type = read_data_type & 3 # 0-var, 1-con, 2-obj, 3-prob + convert_function = int + if (read_data_type & 4) == 4: + convert_function = float + number_of_entries = int(line[2]) + # The third entry is name length, and it is length+1. This is unnecessary + # except for data validation. + # The fourth entry is table "length", e.g., memory size. + number_of_string_lines = int(line[5]) + suffix_name = sol_file.readline().strip() + # Add any arbitrary string lines to the "other" list + 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() + for cnt in range(number_of_entries): + suf_line = sol_file.readline().split() + var_ndx = int(suf_line[0]) + sol_data.var_suffixes[suffix_name][var_ndx] = convert_function( + suf_line[1] + ) + elif data_type == 1: # Con + sol_data.con_suffixes[suffix_name] = dict() + for cnt in range(number_of_entries): + suf_line = sol_file.readline().split() + con_ndx = int(suf_line[0]) + sol_data.con_suffixes[suffix_name][con_ndx] = convert_function( + suf_line[1] + ) + elif data_type == 2: # Obj + sol_data.obj_suffixes[suffix_name] = dict() + for cnt in range(number_of_entries): + suf_line = sol_file.readline().split() + obj_ndx = int(suf_line[0]) + sol_data.obj_suffixes[suffix_name][obj_ndx] = convert_function( + suf_line[1] + ) + elif data_type == 3: # Prob + sol_data.problem_suffixes[suffix_name] = list() + for cnt in range(number_of_entries): + suf_line = sol_file.readline().split() + sol_data.problem_suffixes[suffix_name].append( + convert_function(suf_line[1]) + ) + line = sol_file.readline() + + return result, sol_data diff --git a/pyomo/contrib/solver/solution.py b/pyomo/contrib/solver/solution.py new file mode 100644 index 00000000000..a3e66475982 --- /dev/null +++ b/pyomo/contrib/solver/solution.py @@ -0,0 +1,237 @@ +# ___________________________________________________________________________ +# +# 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 ConstraintData +from pyomo.core.base.var import VarData +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[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 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[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 + """ + + 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): + 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 + + +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[VarData]] = 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[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.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[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.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/tests/__init__.py b/pyomo/contrib/solver/tests/__init__.py new file mode 100644 index 00000000000..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/solver/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/tests/solvers/__init__.py b/pyomo/contrib/solver/tests/solvers/__init__.py new file mode 100644 index 00000000000..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py new file mode 100644 index 00000000000..5992b435b55 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py @@ -0,0 +1,715 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest +import pyomo.environ as pe +from pyomo.contrib.solver.gurobi import Gurobi +from pyomo.contrib.solver.results import SolutionStatus +from pyomo.core.expr.taylor_series import taylor_series_expansion + + +opt = Gurobi() +if not opt.available(): + raise unittest.SkipTest +import gurobipy + + +def create_pmedian_model(): + d_dict = { + (1, 1): 1.777356642700564, + (1, 2): 1.6698255595592497, + (1, 3): 1.099139603924817, + (1, 4): 1.3529705111901453, + (1, 5): 1.467907742900842, + (1, 6): 1.5346837414708774, + (2, 1): 1.9783090609123972, + (2, 2): 1.130315350158659, + (2, 3): 1.6712434682302661, + (2, 4): 1.3642294159473756, + (2, 5): 1.4888357071619858, + (2, 6): 1.2030122107340537, + (3, 1): 1.6661983755713592, + (3, 2): 1.227663031206932, + (3, 3): 1.4580640582967632, + (3, 4): 1.0407223975549575, + (3, 5): 1.9742897953778287, + (3, 6): 1.4874760742689066, + (4, 1): 1.4616138636373597, + (4, 2): 1.7141471558082002, + (4, 3): 1.4157281494999725, + (4, 4): 1.888011688001529, + (4, 5): 1.0232934487237717, + (4, 6): 1.8335062677845464, + (5, 1): 1.468494740997508, + (5, 2): 1.8114798126442795, + (5, 3): 1.9455914886158723, + (5, 4): 1.983088378194899, + (5, 5): 1.1761820755785306, + (5, 6): 1.698655759576308, + (6, 1): 1.108855711312383, + (6, 2): 1.1602637342062019, + (6, 3): 1.0928602740245892, + (6, 4): 1.3140620798928404, + (6, 5): 1.0165386843386672, + (6, 6): 1.854049125736362, + (7, 1): 1.2910160386456968, + (7, 2): 1.7800475863350327, + (7, 3): 1.5480965161255695, + (7, 4): 1.1943306766997612, + (7, 5): 1.2920382721805297, + (7, 6): 1.3194527773994338, + (8, 1): 1.6585982235379078, + (8, 2): 1.2315210354122292, + (8, 3): 1.6194303369953538, + (8, 4): 1.8953386098022103, + (8, 5): 1.8694342085696831, + (8, 6): 1.2938069356684523, + (9, 1): 1.4582048085805495, + (9, 2): 1.484979797871119, + (9, 3): 1.2803882693587225, + (9, 4): 1.3289569463506004, + (9, 5): 1.9842424240265042, + (9, 6): 1.0119441379208745, + (10, 1): 1.1429007682932852, + (10, 2): 1.6519772165446711, + (10, 3): 1.0749931799469326, + (10, 4): 1.2920787022811089, + (10, 5): 1.7934429721917704, + (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.x = pe.Var(model.Locations, model.Customers, bounds=(0.0, 1.0)) + model.y = pe.Var(model.Locations, within=pe.Binary) + + def rule(model): + return sum( + model.d[n, m] * model.x[n, m] + for n in model.Locations + for m in model.Customers + ) + + model.obj = pe.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) + + 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) + + def rule(model): + return (sum(model.y[n] for n in model.Locations) - model.P, 0.0) + + model.num_facilities = pe.Constraint(rule=rule) + + return model + + +class TestGurobiPersistentSimpleLPUpdates(unittest.TestCase): + def setUp(self): + self.m = pe.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) + + def get_solution(self): + try: + import numpy as np + except: + raise unittest.SkipTest('numpy is not available') + p1 = self.m.p1.value + p2 = self.m.p2.value + p3 = self.m.p3.value + p4 = self.m.p4.value + A = np.array([[1, -p1], [1, -p3]]) + rhs = np.array([p2, p4]) + sol = np.linalg.solve(A, rhs) + x = float(sol[1]) + y = float(sol[0]) + return x, y + + def set_params(self, p1, p2, p3, p4): + self.m.p1.value = p1 + self.m.p2.value = p2 + self.m.p3.value = p3 + self.m.p4.value = p4 + + def test_lp(self): + self.set_params(-1, -2, 0.1, -2) + x, y = self.get_solution() + opt = Gurobi() + res = opt.solve(self.m) + self.assertAlmostEqual(x + y, res.incumbent_objective) + self.assertAlmostEqual(x + y, res.objective_bound) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertTrue(res.incumbent_objective is not None) + self.assertAlmostEqual(x, self.m.x.value) + self.assertAlmostEqual(y, self.m.y.value) + + self.set_params(-1.25, -1, 0.5, -2) + opt.config.load_solutions = False + res = opt.solve(self.m) + self.assertAlmostEqual(x, self.m.x.value) + self.assertAlmostEqual(y, self.m.y.value) + x, y = self.get_solution() + self.assertNotAlmostEqual(x, self.m.x.value) + self.assertNotAlmostEqual(y, self.m.y.value) + res.solution_loader.load_vars() + self.assertAlmostEqual(x, self.m.x.value) + self.assertAlmostEqual(y, self.m.y.value) + + +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 + # + # 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 = 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() + 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) + 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 + # + # This is a fragile test because it could fail if Gurobi's + # algorithms change + # + # Update: as of Gurobi 11, this test no longer tests the + # intended behavior (the solver has improved) + 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() + opt.config.solver_options['nonconvex'] = 2 + opt.config.solver_options['MIPGap'] = 0.5 + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, -4) + 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() + opt.set_instance(m) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -1) + + m.xl.value = -3 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -3) + + del m.obj + m.obj = pe.Objective(expr=m.x, sense=pe.maximize) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + + m.xu.value = 3 + res = opt.solve(m) + 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() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -m.b.value / (2 * m.a.value)) + self.assertAlmostEqual( + m.y.value, m.a.value * m.x.value**2 + m.b.value * m.x.value + m.c.value + ) + + m.a.value = 2 + m.b.value = 4 + m.c.value = -1 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -m.b.value / (2 * m.a.value)) + self.assertAlmostEqual( + m.y.value, m.a.value * m.x.value**2 + m.b.value * m.x.value + m.c.value + ) + + 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() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -m.b.value / (2 * m.a.value)) + self.assertAlmostEqual( + res.incumbent_objective, + m.a.value * m.x.value**2 + m.b.value * m.x.value + m.c.value, + ) + + m.a.value = 2 + m.b.value = 4 + m.c.value = -1 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -m.b.value / (2 * m.a.value)) + self.assertAlmostEqual( + res.incumbent_objective, + m.a.value * m.x.value**2 + m.b.value * m.x.value + m.c.value, + ) + + def test_var_bounds(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.obj = pe.Objective(expr=m.x) + + opt = Gurobi() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -1) + + m.x.setlb(-3) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -3) + + del m.obj + m.obj = pe.Objective(expr=m.x, sense=pe.maximize) + + opt = Gurobi() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + + m.x.setub(3) + res = opt.solve(m) + 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.x.fix(1) + opt = Gurobi() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 3) + + m.x.value = 2 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 2) + self.assertAlmostEqual(m.y.value, 7) + + m.x.unfix() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -m.b.value / (2 * m.a.value)) + self.assertAlmostEqual( + m.y.value, m.a.value * m.x.value**2 + m.b.value * m.x.value + m.c.value + ) + + 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) + + opt = Gurobi() + 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) + + 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) + + opt = Gurobi() + 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() + + def _add_cut(xval): + m.x.value = xval + return m.cons.add(m.y >= taylor_series_expansion((m.x - 2) ** 2)) + + _add_cut(0) + _add_cut(4) + + opt = Gurobi() + 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]) + if m.y.value < (m.x.value - 2) ** 2 - 1e-6: + cb_opt.cbLazy(_add_cut(m.x.value)) + + opt.set_callback(_my_callback) + opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 1) + + 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() + opt.config.solver_options['nonconvex'] = 2 + opt.solve(m) + self.assertAlmostEqual(m.x.value, -0.3660254037844423, 2) + self.assertAlmostEqual(m.y.value, -0.13397459621555508, 2) + + 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() + opt.config.solver_options['nonconvex'] = 2 + opt.solve(m) + self.assertAlmostEqual(m.x.value, -0.3660254037844423, 2) + self.assertAlmostEqual(m.y.value, -0.13397459621555508, 2) + + def test_solution_number(self): + m = create_pmedian_model() + opt = Gurobi() + 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) + res.solution_loader.load_vars(solution_number=1) + self.assertAlmostEqual(pe.value(m.obj.expr), 6.584793218502477) + res.solution_loader.load_vars(solution_number=2) + self.assertAlmostEqual(pe.value(m.obj.expr), 6.592304628123309) + + def test_zero_time_limit(self): + m = create_pmedian_model() + opt = Gurobi() + opt.config.time_limit = 0 + opt.config.load_solutions = False + opt.config.raise_exception_on_nonoptimal_result = False + res = opt.solve(m) + num_solutions = opt.get_model_attr('SolCount') + + # Behavior is different on different platforms, so + # we have to see if there are any solutions + # This means that there is no guarantee we are testing + # what we are trying to test. Unfortunately, I'm + # not sure of a good way to guarantee that + if num_solutions == 0: + self.assertIsNone(res.incumbent_objective) + + +class TestManualModel(unittest.TestCase): + def setUp(self): + opt = Gurobi() + 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 + opt.config.auto_updates.update_parameters = False + opt.config.auto_updates.update_vars = False + opt.config.auto_updates.update_constraints = False + opt.config.auto_updates.update_named_expressions = False + 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) + + opt = self.opt + opt.set_instance(m) + + self.assertEqual(opt.get_model_attr('NumVars'), 2) + self.assertEqual(opt.get_model_attr('NumConstrs'), 1) + self.assertEqual(opt.get_model_attr('NumQConstrs'), 0) + self.assertEqual(opt.get_var_attr(m.x, 'LB'), -10) + self.assertEqual(opt.get_var_attr(m.x, 'UB'), 10) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -0.4) + self.assertAlmostEqual(m.y.value, 0.2) + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -0.4) + + m.c2 = pe.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) + self.assertEqual(opt.get_model_attr('NumQConstrs'), 0) + + opt.config.load_solutions = False + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -0.4) + self.assertAlmostEqual(m.y.value, 0.2) + res.solution_loader.load_vars() + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + + opt.remove_constraints([m.c2]) + m.del_component(m.c2) + self.assertEqual(opt.get_model_attr('NumVars'), 2) + self.assertEqual(opt.get_model_attr('NumConstrs'), 1) + self.assertEqual(opt.get_model_attr('NumQConstrs'), 0) + + self.assertEqual(opt.get_gurobi_param_info('FeasibilityTol')[2], 1e-6) + opt.config.solver_options['FeasibilityTol'] = 1e-7 + opt.config.load_solutions = True + res = opt.solve(m) + self.assertEqual(opt.get_gurobi_param_info('FeasibilityTol')[2], 1e-7) + self.assertAlmostEqual(m.x.value, -0.4) + self.assertAlmostEqual(m.y.value, 0.2) + + m.x.setlb(-5) + m.x.setub(5) + opt.update_variables([m.x]) + self.assertEqual(opt.get_var_attr(m.x, 'LB'), -5) + self.assertEqual(opt.get_var_attr(m.x, 'UB'), 5) + + m.x.fix(0) + opt.update_variables([m.x]) + self.assertEqual(opt.get_var_attr(m.x, 'LB'), 0) + self.assertEqual(opt.get_var_attr(m.x, 'UB'), 0) + + m.x.unfix() + opt.update_variables([m.x]) + 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) + opt.add_constraints([m.c2]) + self.assertEqual(opt.get_model_attr('NumVars'), 2) + self.assertEqual(opt.get_model_attr('NumConstrs'), 1) + self.assertEqual(opt.get_model_attr('NumQConstrs'), 1) + + opt.remove_constraints([m.c2]) + m.del_component(m.c2) + self.assertEqual(opt.get_model_attr('NumVars'), 2) + self.assertEqual(opt.get_model_attr('NumConstrs'), 1) + self.assertEqual(opt.get_model_attr('NumQConstrs'), 0) + + m.z = pe.Var() + opt.add_variables([m.z]) + self.assertEqual(opt.get_model_attr('NumVars'), 3) + opt.remove_variables([m.z]) + del m.z + 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) + + opt = self.opt + opt.set_instance(m) + self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) + + opt.remove_constraints([m.c1]) + opt.update() + self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 0) + + opt.add_constraints([m.c1]) + self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 0) + opt.update() + 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) + + opt = self.opt + opt.config.symbolic_solver_labels = True + opt.set_instance(m) + self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) + + opt.remove_constraints([m.c2]) + opt.update() + self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 0) + + opt.add_constraints([m.c2]) + self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 0) + opt.update() + 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) + + 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) + opt.add_constraints([m.c2]) + self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) + opt.remove_constraints([m.c2]) + opt.update() + 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) + + 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) + opt.add_constraints([m.c2]) + self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) + opt.remove_constraints([m.c2]) + opt.update() + 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) + + opt = self.opt + opt.set_instance(m) + self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) + + opt.remove_sos_constraints([m.c1]) + opt.update() + self.assertEqual(opt._solver_model.getAttr('NumSOS'), 0) + + opt.add_sos_constraints([m.c1]) + self.assertEqual(opt._solver_model.getAttr('NumSOS'), 0) + opt.update() + 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) + + 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) + opt.add_sos_constraints([m.c2]) + self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) + opt.remove_sos_constraints([m.c2]) + opt.update() + self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py new file mode 100644 index 00000000000..d5d82981ed8 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -0,0 +1,57 @@ +# ___________________________________________________________________________ +# +# 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 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 + + +""" +TODO: + - Test unique configuration options + - Test unique results options + - Ensure that `*.opt` file is only created when needed + - Ensure options are correctly parsing to env or opt file + - Failures at appropriate times +""" + + +class TestIpopt(unittest.TestCase): + def create_model(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5) + model.y = pyo.Var(initialize=1.5) + + def rosenbrock(m): + return (1.0 - m.x) ** 2 + 100.0 * (m.y - m.x**2) ** 2 + + model.obj = pyo.Objective(rule=rosenbrock, sense=pyo.minimize) + return model + + def test_ipopt_config(self): + # Test default initialization + config = IpoptConfig() + self.assertTrue(config.load_solutions) + self.assertIsInstance(config.solver_options, ConfigDict) + self.assertIsInstance(config.executable, ExecutableData) + + # Test custom initialization + solver = SolverFactory('ipopt', executable='/path/to/exe') + self.assertFalse(solver.config.tee) + self.assertTrue(solver.config.executable.startswith('/path')) + + # Change value on a solve call + # model = self.create_model() + # result = solver.solve(model, tee=True) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py new file mode 100644 index 00000000000..f91de2287b7 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -0,0 +1,1682 @@ +# ___________________________________________________________________________ +# +# 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 random +import math +from typing import Type + +import pyomo.environ as pe +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.gurobi_direct import GurobiDirect +from pyomo.core.expr.numeric_expr import LinearExpression + + +np, numpy_available = attempt_import('numpy') +parameterized, param_available = attempt_import('parameterized') +parameterized = parameterized.parameterized + + +if not param_available: + raise unittest.SkipTest('Parameterized is not available.') + +all_solvers = [('gurobi', Gurobi), ('gurobi_direct', GurobiDirect), ('ipopt', Ipopt)] +mip_solvers = [('gurobi', Gurobi), ('gurobi_direct', GurobiDirect)] +nlp_solvers = [('ipopt', Ipopt)] +qcp_solvers = [('gurobi', Gurobi), ('ipopt', Ipopt)] +miqcqp_solvers = [('gurobi', Gurobi)] +nl_solvers = [('ipopt', Ipopt)] +nl_solvers_set = {i[0] for i in nl_solvers} + + +def _load_tests(solver_list): + res = list() + for solver_name, solver in solver_list: + if solver_name in nl_solvers_set: + test_name = f"{solver_name}_presolve" + res.append((test_name, solver, True)) + test_name = f"{solver_name}" + res.append((test_name, solver, False)) + else: + test_name = f"{solver_name}" + res.append((test_name, solver, None)) + return res + + +@unittest.skipUnless(numpy_available, 'numpy is not available') +class TestSolvers(unittest.TestCase): + @parameterized.expand(input=all_solvers) + def test_config_overwrite(self, name: str, opt_class: Type[SolverBase]): + self.assertIsNot(SolverBase.CONFIG, opt_class.CONFIG) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_remove_variable_and_objective( + self, name: str, opt_class: Type[SolverBase], use_presolve + ): + # this test is for issue #2888 + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + 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) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 2) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_stale_vars( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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.x.value = 1 + m.y.value = 1 + m.z.value = 1 + self.assertFalse(m.x.stale) + self.assertFalse(m.y.stale) + self.assertFalse(m.z.stale) + + res = opt.solve(m) + self.assertFalse(m.x.stale) + self.assertFalse(m.y.stale) + self.assertTrue(m.z.stale) + + opt.config.load_solutions = False + res = opt.solve(m) + self.assertTrue(m.x.stale) + self.assertTrue(m.y.stale) + self.assertTrue(m.z.stale) + res.solution_loader.load_vars() + self.assertFalse(m.x.stale) + self.assertFalse(m.y.stale) + self.assertTrue(m.z.stale) + + res = opt.solve(m) + self.assertTrue(m.x.stale) + self.assertTrue(m.y.stale) + self.assertTrue(m.z.stale) + res.solution_loader.load_vars([m.y]) + self.assertFalse(m.y.stale) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_range_constraint( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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)) + 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 + 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) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_reduced_costs( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, -1) + self.assertAlmostEqual(m.y.value, -2) + rc = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], 3) + self.assertAlmostEqual(rc[m.y], 4) + m.obj.expr *= -1 + res = opt.solve(m) + rc = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], -3) + self.assertAlmostEqual(rc[m.y], -4) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_reduced_costs2( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + 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 + 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) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_param_changes( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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)) + + params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] + for a1, a2, b1, b2 in params_to_test: + m.a1.value = a1 + m.a2.value = a2 + m.b1.value = b1 + m.b2.value = b2 + res: Results = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + self.assertAlmostEqual(res.incumbent_objective, m.y.value) + if res.objective_bound is None: + bound = -math.inf + else: + bound = res.objective_bound + self.assertTrue(bound <= m.y.value) + duals = res.solution_loader.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)) + def test_immutable_param( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + """ + This test is important because component_data_objects returns immutable params as floats. + We want to make sure we process these correctly. + """ + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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)) + + params_to_test = [(1, 2, 1), (1, 2, 1), (1, 3, 1)] + for a1, b1, b2 in params_to_test: + a2 = m.a2.value + m.a1.value = a1 + m.b1.value = b1 + m.b2.value = b2 + res: Results = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + self.assertAlmostEqual(res.incumbent_objective, m.y.value) + if res.objective_bound is None: + bound = -math.inf + else: + bound = res.objective_bound + self.assertTrue(bound <= m.y.value) + duals = res.solution_loader.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)) + 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.') + check_duals = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + opt.config.writer_config.linear_presolve = True + 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) + + params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] + for a1, a2, b1, b2 in params_to_test: + m.a1.value = a1 + m.a2.value = a2 + m.b1.value = b1 + m.b2.value = b2 + res: Results = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + self.assertAlmostEqual(res.incumbent_objective, m.y.value) + if res.objective_bound is None: + bound = -math.inf + else: + bound = res.objective_bound + self.assertTrue(bound <= m.y.value) + if check_duals: + duals = res.solution_loader.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)) + def test_linear_expression( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + e = LinearExpression( + constant=m.b1, linear_coefs=[-1, m.a1], linear_vars=[m.y, m.x] + ) + m.c1 = pe.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) + + params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] + for a1, a2, b1, b2 in params_to_test: + m.a1.value = a1 + m.a2.value = a2 + m.b1.value = b1 + m.b2.value = b2 + res: Results = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + self.assertAlmostEqual(res.incumbent_objective, m.y.value) + if res.objective_bound is None: + bound = -math.inf + else: + bound = res.objective_bound + self.assertTrue(bound <= m.y.value) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_no_objective( + 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.') + check_duals = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + check_duals = False + 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) + + params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] + for a1, a2, b1, b2 in params_to_test: + m.a1.value = a1 + m.a2.value = a2 + m.b1.value = b1 + m.b2.value = b2 + res: Results = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + self.assertEqual(res.incumbent_objective, None) + self.assertEqual(res.objective_bound, None) + if check_duals: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], 0) + self.assertAlmostEqual(duals[m.c2], 0) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_add_remove_cons( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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() + 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) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + self.assertAlmostEqual(res.incumbent_objective, m.y.value) + if res.objective_bound is None: + bound = -math.inf + else: + bound = res.objective_bound + self.assertTrue(bound <= m.y.value) + duals = res.solution_loader.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) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, (b3 - b1) / (a1 - a3)) + self.assertAlmostEqual(m.y.value, a1 * (b3 - b1) / (a1 - a3) + b1) + self.assertAlmostEqual(res.incumbent_objective, m.y.value) + self.assertTrue(res.objective_bound is None or res.objective_bound <= m.y.value) + duals = res.solution_loader.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) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + self.assertAlmostEqual(res.incumbent_objective, m.y.value) + self.assertTrue(res.objective_bound is None or res.objective_bound <= m.y.value) + duals = res.solution_loader.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)) + def test_results_infeasible( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + with self.assertRaises(Exception): + res = opt.solve(m) + opt.config.load_solutions = False + opt.config.raise_exception_on_nonoptimal_result = False + res = opt.solve(m) + self.assertNotEqual(res.solution_status, SolutionStatus.optimal) + if isinstance(opt, Ipopt): + acceptable_termination_conditions = { + TerminationCondition.locallyInfeasible, + TerminationCondition.unbounded, + } + else: + acceptable_termination_conditions = { + TerminationCondition.provenInfeasible, + TerminationCondition.infeasibleOrUnbounded, + } + self.assertIn(res.termination_condition, acceptable_termination_conditions) + self.assertAlmostEqual(m.x.value, None) + self.assertAlmostEqual(m.y.value, None) + self.assertTrue(res.incumbent_objective is None) + + if not isinstance(opt, Ipopt): + # ipopt can return the values of the variables/duals at the last iterate + # 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.*' + ): + 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() + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_duals(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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 1) + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], 0.5) + self.assertAlmostEqual(duals[m.c2], 0.5) + + duals = res.solution_loader.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)) + def test_mutable_quadratic_coefficient( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0.41024548525899274, 4) + self.assertAlmostEqual(m.y.value, 0.34781038127030117, 4) + m.a.value = 2 + m.b.value = -0.5 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0.10256137418973625, 4) + self.assertAlmostEqual(m.y.value, 0.0869525991355825, 4) + + @parameterized.expand(input=_load_tests(qcp_solvers)) + def test_mutable_quadratic_objective( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0.2719178742733325, 4) + self.assertAlmostEqual(m.y.value, 0.5301035741688002, 4) + m.c.value = 3.5 + m.d.value = -1 + res = opt.solve(m) + + self.assertAlmostEqual(m.x.value, 0.6962249634573562, 4) + self.assertAlmostEqual(m.y.value, 0.09227926676152151, 4) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_fixed_vars( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + 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 + ) + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + opt.config.writer_config.linear_presolve = True + else: + opt.config.writer_config.linear_presolve = False + m = pe.ConcreteModel() + m.x = pe.Var() + m.x.fix(0) + m.y = pe.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) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 2) + m.x.unfix() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + m.x.fix(0) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 2) + m.x.value = 2 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 2) + self.assertAlmostEqual(m.y.value, 3) + m.x.value = 0 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 2) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_fixed_vars_2( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if opt.is_persistent(): + opt.config.auto_updates.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): + if use_presolve: + opt.config.writer_config.linear_presolve = True + else: + opt.config.writer_config.linear_presolve = False + m = pe.ConcreteModel() + m.x = pe.Var() + m.x.fix(0) + m.y = pe.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) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 2) + m.x.unfix() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + m.x.fix(0) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 2) + m.x.value = 2 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 2) + self.assertAlmostEqual(m.y.value, 3) + m.x.value = 0 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 2) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_fixed_vars_3( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if opt.is_persistent(): + opt.config.auto_updates.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): + if use_presolve: + 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.y.fix(1) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 3) + self.assertAlmostEqual(m.x.value, 2) + + @parameterized.expand(input=_load_tests(nlp_solvers)) + def test_fixed_vars_4( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if opt.is_persistent(): + opt.config.auto_updates.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): + if use_presolve: + 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.y.fix(1) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 2) + m.y.unfix() + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 2**0.5) + self.assertAlmostEqual(m.y.value, 2**0.5) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_mutable_param_with_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.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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)) + + np.random.seed(0) + params_to_test = [ + ( + np.random.uniform(0, 10), + np.random.uniform(-10, 0), + np.random.uniform(-5, 2.5), + np.random.uniform(-5, 2.5), + np.random.uniform(2.5, 10), + np.random.uniform(2.5, 10), + pe.minimize, + ), + ( + np.random.uniform(0, 10), + np.random.uniform(-10, 0), + np.random.uniform(-5, 2.5), + np.random.uniform(-5, 2.5), + np.random.uniform(2.5, 10), + np.random.uniform(2.5, 10), + pe.maximize, + ), + ( + np.random.uniform(0, 10), + np.random.uniform(-10, 0), + np.random.uniform(-5, 2.5), + np.random.uniform(-5, 2.5), + np.random.uniform(2.5, 10), + np.random.uniform(2.5, 10), + pe.minimize, + ), + ( + np.random.uniform(0, 10), + np.random.uniform(-10, 0), + np.random.uniform(-5, 2.5), + np.random.uniform(-5, 2.5), + np.random.uniform(2.5, 10), + np.random.uniform(2.5, 10), + pe.maximize, + ), + ] + for a1, a2, b1, b2, c1, c2, sense in params_to_test: + m.a1.value = float(a1) + m.a2.value = float(a2) + m.b1.value = float(b1) + m.b2.value = float(b2) + m.c1.value = float(c1) + m.c2.value = float(c2) + m.obj.sense = sense + res: Results = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + if sense is pe.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) + self.assertTrue( + res.objective_bound is None + or res.objective_bound <= m.y.value + 1e-12 + ) + duals = res.solution_loader.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.incumbent_objective, m.y.value, 6) + self.assertTrue( + res.objective_bound is None + or res.objective_bound >= m.y.value - 1e-12 + ) + duals = res.solution_loader.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)) + def test_add_and_remove_vars( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + if opt.is_persistent(): + opt.config.auto_updates.update_parameters = False + opt.config.auto_updates.update_vars = False + opt.config.auto_updates.update_constraints = False + opt.config.auto_updates.update_named_expressions = False + opt.config.auto_updates.check_for_new_or_removed_params = False + opt.config.auto_updates.check_for_new_or_removed_constraints = False + opt.config.auto_updates.check_for_new_or_removed_vars = False + opt.config.load_solutions = False + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + res.solution_loader.load_vars() + self.assertAlmostEqual(m.y.value, -1) + m.x = pe.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)) + if opt.is_persistent(): + opt.add_constraints([m.c1, m.c2]) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + res.solution_loader.load_vars() + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + m.c1.deactivate() + m.c2.deactivate() + if opt.is_persistent(): + opt.remove_constraints([m.c1, m.c2]) + m.x.value = None + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + res.solution_loader.load_vars() + self.assertEqual(m.x.value, None) + self.assertAlmostEqual(m.y.value, -1) + + @parameterized.expand(input=_load_tests(nlp_solvers)) + def test_exp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): + opt = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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)) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -0.42630274815985264) + self.assertAlmostEqual(m.y.value, 0.6529186341994245) + + @parameterized.expand(input=_load_tests(nlp_solvers)) + def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): + opt = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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)) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0.6529186341994245) + self.assertAlmostEqual(m.y.value, -0.42630274815985264) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_with_numpy( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + a1 = 1 + b1 = 3 + a2 = -2 + b2 = 1 + m.c1 = pe.Constraint( + expr=(np.float64(0), m.y - np.int64(1) * m.x - np.float32(3), None) + ) + m.c2 = pe.Constraint( + expr=(None, -m.y + np.int32(-2) * m.x + np.float64(1), np.float16(0)) + ) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) + self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_bounds_with_params( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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.y.setlb(m.p) + m.p.value = 1 + m.obj = pe.Objective(expr=m.y) + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, 1) + m.p.value = -1 + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, -1) + m.y.setlb(None) + m.y.setub(m.p) + m.obj.sense = pe.maximize + m.p.value = 5 + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, 5) + m.p.value = 4 + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, 4) + m.y.setub(None) + m.y.setlb(m.p) + m.obj.sense = pe.minimize + m.p.value = 3 + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, 3) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_solution_loader( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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)) + opt.config.load_solutions = False + res = opt.solve(m) + self.assertIsNone(m.x.value) + self.assertIsNone(m.y.value) + res.solution_loader.load_vars() + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 1) + m.x.value = None + m.y.value = None + res.solution_loader.load_vars([m.y]) + self.assertAlmostEqual(m.y.value, 1) + primals = res.solution_loader.get_primals() + self.assertIn(m.x, primals) + self.assertIn(m.y, primals) + self.assertAlmostEqual(primals[m.x], 1) + self.assertAlmostEqual(primals[m.y], 1) + primals = res.solution_loader.get_primals([m.y]) + 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) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_time_limit( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + opt.config.writer_config.linear_presolve = True + else: + opt.config.writer_config.linear_presolve = False + from sys import platform + + if platform == 'win32': + 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)) + + random.seed(0) + coefs = list() + lin_vars = list() + for j in m.jobs: + for t in m.tasks: + coefs.append(random.uniform(0, 10)) + lin_vars.append(m.x[j, t]) + obj_expr = LinearExpression( + linear_coefs=coefs, linear_vars=lin_vars, constant=0 + ) + m.obj = pe.Objective(expr=obj_expr, sense=pe.maximize) + + m.c1 = pe.Constraint(m.jobs) + m.c2 = pe.Constraint(m.tasks) + for j in m.jobs: + expr = LinearExpression( + linear_coefs=[1] * N, + linear_vars=[m.x[j, t] for t in m.tasks], + constant=0, + ) + m.c1[j] = expr == 1 + for t in m.tasks: + expr = LinearExpression( + linear_coefs=[1] * N, + linear_vars=[m.x[j, t] for j in m.jobs], + constant=0, + ) + m.c2[t] = expr == 1 + if isinstance(opt, Ipopt): + opt.config.time_limit = 1e-6 + else: + opt.config.time_limit = 0 + opt.config.load_solutions = False + opt.config.raise_exception_on_nonoptimal_result = False + res = opt.solve(m) + self.assertIn( + res.termination_condition, + {TerminationCondition.maxTimeLimit, TerminationCondition.iterationLimit}, + ) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_objective_changes( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 1) + del m.obj + m.obj = pe.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 + opt.config.raise_exception_on_nonoptimal_result = False + opt.config.load_solutions = False + res = opt.solve(m) + self.assertIn( + res.termination_condition, + { + TerminationCondition.unbounded, + TerminationCondition.infeasibleOrUnbounded, + }, + ) + m.obj.sense = pe.minimize + opt.config.load_solutions = True + del m.obj + m.obj = pe.Objective(expr=m.x * m.y) + m.x.fix(2) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 6, 6) + m.x.fix(3) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 12, 6) + m.x.unfix() + m.y.fix(2) + m.x.setlb(-3) + m.x.setub(5) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, -2, 6) + m.y.unfix() + m.x.setlb(None) + m.x.setub(None) + m.e = pe.Expression(expr=2) + del m.obj + m.obj = pe.Objective(expr=m.e * m.y) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 2) + m.e.expr = 3 + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 3) + if opt.is_persistent(): + opt.config.auto_updates.check_for_new_objective = False + m.e.expr = 4 + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 4) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_domain(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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 1) + m.x.setlb(-1) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 0) + m.x.setlb(1) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 1) + m.x.setlb(-1) + m.x.domain = pe.Reals + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, -1) + m.x.domain = pe.NonNegativeReals + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 0) + + @parameterized.expand(input=_load_tests(mip_solvers)) + def test_domain_with_integers( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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) + 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 + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, -5) + m.x.domain = pe.Binary + 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) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_fixed_binaries( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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.x.fix(0) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 0) + m.x.fix(1) + 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 + m.x.fix(0) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 0) + m.x.fix(1) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 1) + + @parameterized.expand(input=_load_tests(mip_solvers)) + def test_with_gdp(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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + opt.config.writer_config.linear_presolve = True + 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.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.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.disjunction = gdp.Disjunction(expr=[m.d2, m.d1]) + pe.TransformationFactory("gdp.bigm").apply_to(m) + + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 1) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + + opt: SolverBase = opt_class() + opt.use_extensions = True + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 1) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_variables_elsewhere( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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.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) + + res = opt.solve(m.b) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 1) + self.assertAlmostEqual(m.x.value, -1) + self.assertAlmostEqual(m.y.value, 1) + + m.x.setlb(0) + res = opt.solve(m.b) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 2) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 2) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_variables_elsewhere2( + 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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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.c3 = pe.Constraint(expr=m.y >= m.z + 1) + m.c4 = pe.Constraint(expr=m.y >= -m.z + 1) + + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 1) + sol = res.solution_loader.get_primals() + self.assertIn(m.x, sol) + self.assertIn(m.y, sol) + self.assertIn(m.z, sol) + + del m.c3 + del m.c4 + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 0) + sol = res.solution_loader.get_primals() + self.assertIn(m.x, sol) + self.assertIn(m.y, sol) + self.assertNotIn(m.z, sol) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_bug_1(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 any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + opt.config.writer_config.linear_presolve = True + 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.obj = pe.Objective(expr=m.y) + m.c = pe.Constraint(expr=m.y >= m.p * m.x) + + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 0) + + m.p.value = 1 + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 3) + + @parameterized.expand(input=_load_tests(all_solvers)) + def test_bug_2(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): + """ + This test is for a bug where an objective containing a fixed variable does + not get updated properly when the variable is unfixed. + """ + for fixed_var_option in [True, False]: + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + 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.x.fix(1) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 2, 5) + + m.x.unfix() + m.x.setlb(-9) + m.x.setub(9) + 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 = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.z = pe.Var() + m.obj = pe.Objective(expr=m.x**2 + m.y**2 + m.z**2) + m.c1 = pe.Constraint(expr=m.x == m.y + m.z + 1.5) + m.c2 = pe.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 = pe.ConcreteModel() + m.w = pe.Var() + m.x = pe.Var() + m.y = pe.Var() + m.z = pe.Var() + m.obj = pe.Objective(expr=m.x**2 + m.y**2 + m.z**2 + m.w**2) + m.c1 = pe.Constraint(expr=m.x + m.w == m.y + m.z) + m.c2 = pe.Constraint(expr=m.z == -m.y) + m.c3 = pe.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 = pe.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() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + check_duals = True + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + check_duals = False + 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 - 1) + 1) + m.c2 = pe.Constraint(expr=m.y >= -(m.x - 1) + 1) + m.scaling_factor = pe.Suffix(direction=pe.Suffix.EXPORT) + m.scaling_factor[m.x] = 0.5 + m.scaling_factor[m.y] = 2 + m.scaling_factor[m.c1] = 0.5 + m.scaling_factor[m.c2] = 2 + m.scaling_factor[m.obj] = 2 + + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 1) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 1) + primals = res.solution_loader.get_primals() + self.assertAlmostEqual(primals[m.x], 1) + self.assertAlmostEqual(primals[m.y], 1) + if check_duals: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -0.5) + self.assertAlmostEqual(duals[m.c2], -0.5) + rc = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], 0) + self.assertAlmostEqual(rc[m.y], 0) + + m.x.setlb(2) + res = opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, 2) + self.assertAlmostEqual(m.x.value, 2) + self.assertAlmostEqual(m.y.value, 2) + primals = res.solution_loader.get_primals() + self.assertAlmostEqual(primals[m.x], 2) + self.assertAlmostEqual(primals[m.y], 2) + if check_duals: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -1) + self.assertAlmostEqual(duals[m.c2], 0) + rc = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[m.x], 1) + self.assertAlmostEqual(rc[m.y], 0) + + +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') + 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) + + params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] + for a1, a2, b1, b2 in params_to_test: + m.a1.value = a1 + m.a2.value = a2 + m.b1.value = b1 + m.b2.value = b2 + res = opt.solve(m) + pe.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)) + + @parameterized.expand(input=all_solvers) + def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): + opt = pe.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) + res = opt.solve(m, load_solutions=False) + pe.assert_optimal_termination(res) + self.assertIsNone(m.x.value) + self.assertNotIn(m.c, m.dual) + m.solutions.load_from(res) + self.assertAlmostEqual(m.x.value, -1) + self.assertAlmostEqual(m.dual[m.c], 1) diff --git a/pyomo/contrib/solver/tests/unit/__init__.py b/pyomo/contrib/solver/tests/unit/__init__.py new file mode 100644 index 00000000000..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/tests/unit/sol_files/__init__.py b/pyomo/contrib/solver/tests/unit/sol_files/__init__.py new file mode 100644 index 00000000000..a4a626013c4 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/tests/unit/sol_files/bad_objno.sol b/pyomo/contrib/solver/tests/unit/sol_files/bad_objno.sol new file mode 100644 index 00000000000..a7eccfca388 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/bad_objno.sol @@ -0,0 +1,22 @@ +CONOPT 3.17A: Optimal; objective 1 +4 iterations; evals: nf = 2, ng = 0, nc = 2, nJ = 0, nH = 0, nHv = 0 + +Options +3 +1 +1 +0 +1 +1 +1 +1 +1 +1 +Xobjno 0 0 +suffix 0 1 8 0 0 +sstatus +0 1 +suffix 1 1 8 0 0 +sstatus +0 3 + diff --git a/pyomo/contrib/solver/tests/unit/sol_files/bad_objnoline.sol b/pyomo/contrib/solver/tests/unit/sol_files/bad_objnoline.sol new file mode 100644 index 00000000000..6abcacbb3c4 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/bad_objnoline.sol @@ -0,0 +1,22 @@ +CONOPT 3.17A: Optimal; objective 1 +4 iterations; evals: nf = 2, ng = 0, nc = 2, nJ = 0, nH = 0, nHv = 0 + +Options +3 +1 +1 +0 +1 +1 +1 +1 +1 +1 +objno 0 0 1 +suffix 0 1 8 0 0 +sstatus +0 1 +suffix 1 1 8 0 0 +sstatus +0 3 + diff --git a/pyomo/contrib/solver/tests/unit/sol_files/bad_options.sol b/pyomo/contrib/solver/tests/unit/sol_files/bad_options.sol new file mode 100644 index 00000000000..f59a2ffd3b4 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/bad_options.sol @@ -0,0 +1,22 @@ +CONOPT 3.17A: Optimal; objective 1 +4 iterations; evals: nf = 2, ng = 0, nc = 2, nJ = 0, nH = 0, nHv = 0 + +OXptions +3 +1 +1 +0 +1 +1 +1 +1 +1 +1 +objno 0 0 +suffix 0 1 8 0 0 +sstatus +0 1 +suffix 1 1 8 0 0 +sstatus +0 3 + diff --git a/pyomo/contrib/solver/tests/unit/sol_files/conopt_optimal.sol b/pyomo/contrib/solver/tests/unit/sol_files/conopt_optimal.sol new file mode 100644 index 00000000000..4ff14b50bc7 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/conopt_optimal.sol @@ -0,0 +1,22 @@ +CONOPT 3.17A: Optimal; objective 1 +4 iterations; evals: nf = 2, ng = 0, nc = 2, nJ = 0, nH = 0, nHv = 0 + +Options +3 +1 +1 +0 +1 +1 +1 +1 +1 +1 +objno 0 0 +suffix 0 1 8 0 0 +sstatus +0 1 +suffix 1 1 8 0 0 +sstatus +0 3 + diff --git a/pyomo/contrib/solver/tests/unit/sol_files/depr_solver.sol b/pyomo/contrib/solver/tests/unit/sol_files/depr_solver.sol new file mode 100644 index 00000000000..01ceb566334 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/depr_solver.sol @@ -0,0 +1,67 @@ +PICO Solver: final f = 88.200000 + +Options +3 +0 +0 +0 +24 +24 +32 +32 +0 +0 +0.12599999999999997 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +46.666666666666664 +0 +0 +0 +0 +0 +0 +933.3333333333336 +10000 +10000 +10000 +10000 +0 +100 +0 +100 +0 +100 +0 +100 +46.666666666666664 +53.333333333333336 +0 +100 +0 +100 +0 +100 diff --git a/pyomo/contrib/solver/tests/unit/sol_files/iis_no_variable_values.sol b/pyomo/contrib/solver/tests/unit/sol_files/iis_no_variable_values.sol new file mode 100644 index 00000000000..641a3162a8f --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/iis_no_variable_values.sol @@ -0,0 +1,34 @@ +CPLEX 12.8.0.0: integer infeasible. +0 MIP simplex iterations +0 branch-and-bound nodes +Returning an IIS of 2 variables and 1 constraints. +No basis. + +Options +3 +1 +1 +0 +1 +0 +2 +0 +objno 0 220 +suffix 0 2 4 181 11 +iis + +0 non not in the iis +1 low at lower bound +2 fix fixed +3 upp at upper bound +4 mem member +5 pmem possible member +6 plow possibly at lower bound +7 pupp possibly at upper bound +8 bug + +0 1 +1 1 +suffix 1 1 4 0 0 +iis +0 4 diff --git a/pyomo/contrib/solver/tests/unit/sol_files/infeasible1.sol b/pyomo/contrib/solver/tests/unit/sol_files/infeasible1.sol new file mode 100644 index 00000000000..9e7c47f2091 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/infeasible1.sol @@ -0,0 +1,491 @@ + +Ipopt 3.12: Converged to a locally infeasible point. Problem may be infeasible. + +Options +3 +1 +1 +0 +242 +242 +86 +86 +-3.5031247438024307e-14 +-3.5234584915901186e-14 +-3.5172095867741636e-14 +-3.530546013164763e-14 +-3.5172095867741636e-14 +-3.5305460131648396e-14 +-2.366093398247632e-13 +-2.3660933995816667e-13 +-2.366093403160036e-13 +-2.366093402111279e-13 +-2.366093403160036e-13 +-2.366093402111279e-13 +-3.230618014133495e-14 +-3.229008861611988e-14 +-3.2372291959738883e-14 +-3.233107904711923e-14 +-3.2372291959738883e-14 +-3.233107904711986e-14 +-2.366093402825742e-13 +-2.3660934046399004e-13 +-2.366093408240676e-13 +-2.3660934074259244e-13 +-2.366093408240676e-13 +-2.3660934074259244e-13 +-3.5337260190603076e-15 +-3.5384985959538063e-15 +-3.5360752870197467e-15 +-3.5401103667524204e-15 +-3.5360752870197475e-15 +-3.540110366752954e-15 +-1.1241014244910024e-13 +-7.229408362081387e-14 +-1.1241014257725814e-13 +-7.229408365067014e-14 +-1.1241014257725814e-13 +-7.229408365067014e-14 +-0.045045044618550245 +-2.2503048100082865e-13 +-0.04504504461894986 +-2.3019280438209537e-13 +-2.4246742873024166e-13 +-2.3089017630512727e-13 +-2.303517676239642e-13 +-2.3258460904987257e-13 +-2.2657149778091163e-13 +-2.3561210481068387e-13 +-2.260257681221233e-13 +-2.4196851090379605e-13 +-2.2609595226592818e-13 +-0.04504504461900244 +-2.249595193064585e-13 +-0.04504504461913233 +-2.2215413967954347e-13 +-0.045045044619133334 +1.4720100770836167e-13 +0.5405405354313707 +-1.1746366725687393e-13 +-8.181817954545458e-14 +3.3628105937413004e-10 +2.5420446367682183e-10 +-4.068865957494519e-10 +-3.3083656247909664e-10 +2.0162505532975142e-10 +1.3899803000287233e-10 +1.9264257030343367e-10 +1.5784707460270425e-10 +4.0453655296452274e-10 +1.8623815108786813e-10 +4.023012427968502e-10 +2.2427204843237042e-10 +4.285852894154949e-10 +2.7438151967949997e-10 +4.990725722952413e-10 +3.24233733037425e-10 +6.365790489375267e-10 +1.8786461752037693e-10 +9.36934851555115e-10 +1.9328729420874646e-10 +2.1302900967163764e-09 +1.9184434624295806e-10 +1.839058810801874e-10 +3.1045038304739125e-08 +2.033627397720737e-10 +1.965179362792721e-09 +3.9014568630621037e-10 +9.629991995490913e-10 +3.8529492862465446e-10 +6.543016210883198e-10 +3.1023232285992586e-10 +5.203524431666233e-10 +2.443053484937026e-10 +4.814394103716646e-10 +1.9839047821553417e-10 +2.29157081595439e-10 +1.6697733108860693e-10 +2.2885043298472609e-10 +1.4439699240241691e-10 +2.231817349184844e-10 +7.996844380007978e-07 +7.95878555840714e-07 +-6.161782990947841e-09 +-6.174783045271923e-09 +-6.180473110458713e-09 +-6.1838001759594465e-09 +-6.180473110458713e-09 +-6.183800175957144e-09 +-1.3264604647361279e-14 +-1.3437580361963064e-14 +-1.381614108205247e-14 +-1.3724139850276759e-14 +-1.381614108205247e-14 +-1.3724139850276584e-14 +-1.3264604647361279e-14 +-1.3437580361963064e-14 +-1.381614108205247e-14 +-1.3724139850276759e-14 +-1.381614108205247e-14 +-1.3724139850276584e-14 +-1.3264604647357383e-14 +-1.3264604647357383e-14 +-1.258629585661237e-14 +-1.2586303131773045e-14 +-1.2586307639008801e-14 +-1.2586311120145482e-14 +-1.2586314285443517e-14 +-1.258631748040718e-14 +-1.2586321221671653e-14 +-1.2741959563395428e-14 +-1.2741955464025058e-14 +-1.2741952925774324e-14 +-1.2741950138083889e-14 +-1.2741945491635486e-14 +-1.274193825746462e-14 +-1.3437580361959015e-14 +-1.3437580361959015e-14 +-1.3437580361959015e-14 +-1.3816141082048241e-14 +-1.3816141082048241e-14 +-1.3081851406508949e-14 +-1.308185926540242e-14 +-1.3081864134282786e-14 +-1.3081867894733614e-14 +-1.308187131400409e-14 +-1.308187476532053e-14 +-1.3081878806771144e-14 +-1.2999353684840647e-14 +-1.299934941829921e-14 +-1.2999346776539415e-14 +-1.2999343875167873e-14 +-1.2999339039238868e-14 +-1.2999331510061096e-14 +-1.3724139850272537e-14 +-1.3724139850272537e-14 +-1.3724139850272537e-14 +-1.3816141082048243e-14 +-1.3816141082048243e-14 +-1.3081851406508949e-14 +-1.3081859265402422e-14 +-1.3081864134282784e-14 +-1.3081867894733614e-14 +-1.308187131400409e-14 +-1.308187476532053e-14 +-1.3081878806771145e-14 +-1.299935368484049e-14 +-1.2999349418299049e-14 +-1.2999346776539257e-14 +-1.2999343875167712e-14 +-1.299933903923871e-14 +-1.2999331510060935e-14 +-1.3724139850272359e-14 +-1.3724139850272359e-14 +-1.3724139850272359e-14 +-0.39647376852165084 +-0.4455844823264693 +-0.3964737698727394 +-0.4455844904349083 +-0.04058112126213324 +-2.37392784926522e-13 +-0.04058112126182639 +-2.3739125313713354e-13 +-2.3738581599973924e-13 +-2.3739030469186293e-13 +-2.373886019673396e-13 +-2.3738926304868226e-13 +-2.3739032800906814e-13 +-2.373875268840388e-13 +-2.3739166112281285e-13 +-2.373848238523691e-13 +-2.3739287329689576e-13 +-0.04058112126709927 +-2.3739409684312144e-13 +-0.04058112126734901 +-2.3739552961585984e-13 +-0.040581121263560345 +-7.976233462779415e-11 +-8.149038165921345e-11 +-8.149038165921345e-11 +-8.022671984428942e-11 +-8.112229180405433e-11 +-8.112229180405698e-11 +-1.1362727144888948e-10 +-4.545363318183219e-10 +-1.5766054471383136e-10 +-999.9999999987843 +2.0239864420785628e-10 +3.6952311802810024e-10 +2.123373938372435e-10 +2.804864327332228e-10 +1.346149969721881e-10 +2.2070281853153174e-10 +1.3486437441647496e-10 +1.837701666832909e-10 +1.3214731344936636e-10 +1.59848684557641e-10 +1.2663217798563007e-10 +1.4670685236091518e-10 +1.2005152713943525e-10 +2.1846147211317584e-10 +1.1320656639453056e-10 +2.1155957764572616e-10 +1.0602947953081767e-10 +2.1331568061293854e-10 +2.2406981587244565e-10 +1.0144323269437438e-10 +2.0067712609010725e-10 +1.0647572138657723e-10 +1.3628795523686926e-10 +1.1283736217061156e-10 +1.3689006597815967e-10 +1.1944117806753888e-10 +1.4976540231691364e-10 +1.2533138246033542e-10 +1.7219937613078787e-10 +1.2782000199367948e-10 +2.0576625901474408e-10 +1.8061506448741275e-10 +2.5564782647515365e-10 +1.8080595589290967e-10 +3.3611540082361537e-10 +1.8450853640157845e-10 +-999.9999999992634 +500.00000267889834 +3700.000036997707 +3700.00003699796 +3700.000036997707 +3700.00003699796 +3700.000036977598 +3700.000036977598 +11.65620349374497 +11.697892989049905 +11.723721175743378 +11.743669409189184 +11.761807757832353 +11.780116092441125 +11.801554922843986 +11.760485435103986 +11.737564481489017 +11.723372263570411 +11.70778533743834 +11.68180544764916 +11.64135667458445 +3700.000036977598 +3700.000036977598 +3700.000036977598 +0.3151184672323908 +0.32392866804605874 +0.34244076638380455 +0.33803566597697493 +0.34244076638380455 +0.3380356659769663 +0.27110063090377123 +0.2699297687440479 +0.2929786728909554 +0.29344480424126584 +0.28838393432428394 +0.2893992806145764 +0.2710728789062779 +0.26993404119945896 +0.2934152392453943 +0.29361001971947676 +0.2884212793214469 +0.28944447549328195 +0.2710728789062779 +0.2699340411994531 +0.29341523924539437 +0.29361001971947087 +0.28842127932144684 +0.2894444754932388 +0.5508615869879336 +0.15398873818985254 +0.6718832432569866 +0.17589826345513584 +0.5247189958883286 +0.18810973351399282 +0.6259675738420305 +0.20533542867213556 +0.7121098490801165 +0.23131269225729922 +0.7821527320463884 +0.28037348913556315 +0.8428067559035302 +0.5838840489481971 +0.8970272395501521 +0.6703093152878702 +0.94267886174376 +0.7738465562949745 +0.8177198430399907 +0.9786900926762641 +0.6704296542151029 +0.9210489338249574 +0.3564282839324347 +0.8691777702202935 +0.2593618184144545 +0.8137154539828636 +0.21644752420062746 +0.7494805564573437 +0.1955192721716388 +0.6636009115148781 +0.1816326651938952 +0.7714724374833359 +0.16783059150769936 +0.6720038647474075 +0.15295832306009652 +0.5820927246947017 +0 +5.999999940000606 +3.2342062150876796 +9.747775650827162 +objno 0 200 +suffix 4 60 13 0 0 +ipopt_zU_out +22 -1.327369555645263e-09 +23 -1.3446671271054377e-09 +24 -1.382523199114386e-09 +25 -1.373323075936809e-09 +26 -1.382523199114386e-09 +27 -1.3733230759367915e-09 +28 -1.2472104315043693e-09 +29 -1.2452101972496192e-09 +30 -1.2858040647227637e-09 +31 -1.2866523403876923e-09 +32 -1.2775019286011434e-09 +33 -1.2793272952136163e-09 +34 -1.2471629472231613e-09 +35 -1.2452174844060395e-09 +36 -1.2865985041388369e-09 +37 -1.2869532717202986e-09 +38 -1.2775689743171436e-09 +39 -1.2794086668147935e-09 +40 -1.2471629472231613e-09 +41 -1.2452174844060298e-09 +42 -1.2865985041388369e-09 +43 -1.2869532717202878e-09 +44 -1.2775689743171434e-09 +45 -1.2794086668147155e-09 +46 -2.0240773556752306e-09 +47 -1.0745612255836558e-09 +48 -2.770632290509263e-09 +49 -1.103129453565228e-09 +50 -1.9127440056903688e-09 +51 -1.1197213910483093e-09 +52 -2.430513566198766e-09 +53 -1.1439932412498466e-09 +54 -3.1577699873109563e-09 +55 -1.182653712929702e-09 +56 -4.173065268467735e-09 +57 -1.2632815552706913e-09 +58 -5.783269227344645e-09 +59 -2.1847056932251413e-09 +60 -8.828459262787896e-09 +61 -2.7574054223382863e-09 +62 -1.5860201572267072e-08 +63 -4.019796745114287e-09 +64 -4.987327799213503e-09 +65 -4.128677327837785e-08 +66 -2.7584122571707027e-09 +67 -1.1514963264478648e-08 +68 -1.4125712376227499e-09 +69 -6.9490543282105264e-09 +70 -1.2274426584743552e-09 +71 -4.880119585077116e-09 +72 -1.160216995366489e-09 +73 -3.628823630675873e-09 +74 -1.13003440308759e-09 +75 -2.7024178093492304e-09 +76 -1.1108592195439713e-09 +77 -3.978035995523888e-09 +78 -1.0924348929579286e-09 +79 -2.7716511991201962e-09 +80 -1.073254036073809e-09 +81 -2.175341139896496e-09 +suffix 4 86 13 0 0 +ipopt_zL_out +0 2.457002432427315e-13 +1 2.457002432427147e-13 +2 2.457002432427315e-13 +3 2.457002432427147e-13 +4 2.457002432440668e-13 +5 2.457002432440668e-13 +6 7.799202448711829e-11 +7 7.771407288173584e-11 +8 7.754286328443318e-11 +9 7.741114609420585e-11 +10 7.72917673061454e-11 +11 7.717164255304123e-11 +12 7.703145172513595e-11 +13 7.730045781990877e-11 +14 7.7451409084917e-11 +15 7.754517112285163e-11 +16 7.76484093372809e-11 +17 7.782109643810629e-11 +18 7.809149171545744e-11 +19 2.457002432440668e-13 +20 2.457002432440668e-13 +21 2.457002432440668e-13 +22 2.88491781594494e-09 +23 2.806453922602062e-09 +24 2.6547390725285084e-09 +25 2.6893342144319893e-09 +26 2.6547390725285084e-09 +27 2.6893342144320575e-09 +28 3.3533336782625715e-09 +29 3.367879281546927e-09 +30 3.1029251008167857e-09 +31 3.0979961649984553e-09 +32 3.152363115331538e-09 +33 3.1413031705213295e-09 +34 3.353676987058653e-09 +35 3.3678259755079893e-09 +36 3.0983083240635833e-09 +37 3.096252910785026e-09 +38 3.1519549450665203e-09 +39 3.1408126764021113e-09 +40 3.353676987058653e-09 +41 3.367825975508062e-09 +42 3.0983083240635824e-09 +43 3.0962529107850877e-09 +44 3.151954945066521e-09 +45 3.140812676402579e-09 +46 1.6503072927322882e-09 +47 5.903619062223097e-09 +48 1.3530489183372102e-09 +49 5.168276510428202e-09 +50 1.7325290303934247e-09 +51 4.8327689212818915e-09 +52 1.4522971044995076e-09 +53 4.4273454737645e-09 +54 1.276616097383978e-09 +55 3.930138360770138e-09 +56 1.1622933223262232e-09 +57 3.242428123819113e-09 +58 1.0786469044524248e-09 +59 1.556971619947646e-09 +60 1.0134484872637181e-09 +61 1.356225961423535e-09 +62 9.643698375125132e-10 +63 1.174768939146355e-09 +64 1.1117388275802617e-09 +65 9.288986889801197e-10 +66 1.3559825252250914e-09 +67 9.870172368223874e-10 +68 2.55055764727633e-09 +69 1.0459205566343963e-09 +70 3.5051068618760334e-09 +71 1.1172098225860037e-09 +72 4.2000521577056155e-09 +73 1.212961283078632e-09 +74 4.649622902405193e-09 +75 1.3699361786951016e-09 +76 5.005106744564875e-09 +77 1.1783841562800436e-09 +78 5.416717299785639e-09 +79 1.3528060526165563e-09 +80 5.943389257560972e-09 +81 1.561763024323873e-09 +82 500.00000026951534 +83 1.515151527777625e-10 +84 2.8108595681091103e-10 +85 9.326135918021712e-11 diff --git a/pyomo/contrib/solver/tests/unit/sol_files/infeasible2.sol b/pyomo/contrib/solver/tests/unit/sol_files/infeasible2.sol new file mode 100644 index 00000000000..6fddb053745 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/sol_files/infeasible2.sol @@ -0,0 +1,13 @@ + + Couenne (C:\Users\SASCHA~1\AppData\Local\Temp\tmpvcmknhw0.pyomo.nl May 18 2015): Infeasible + +Options +3 +0 +1 +0 +242 +0 +86 +0 +objno 0 220 diff --git a/pyomo/contrib/solver/tests/unit/test_base.py b/pyomo/contrib/solver/tests/unit/test_base.py new file mode 100644 index 00000000000..e9ea717593f --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_base.py @@ -0,0 +1,363 @@ +# ___________________________________________________________________________ +# +# 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 +from pyomo.common.config import ConfigDict +from pyomo.contrib.solver import base + + +class _LegacyWrappedSolverBase(base.LegacySolverWrapper, base.SolverBase): + pass + + +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)) + + def test_class_method_list(self): + expected_list = [ + 'Availability', + '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) + + @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) + + @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) + ) + + @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') + + +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', + '_get_reduced_costs', + '_load_vars', + 'add_block', + 'add_constraints', + 'add_parameters', + 'add_variables', + 'available', + 'is_persistent', + 'remove_block', + 'remove_constraints', + 'remove_parameters', + 'remove_variables', + 'set_instance', + 'set_objective', + 'solve', + 'update_parameters', + 'update_variables', + 'version', + ] + method_list = [ + method + for method in dir(base.PersistentSolverBase) + if (method.startswith('__') or method.startswith('_abc')) is False + ] + 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) + + with self.assertRaises(NotImplementedError): + self.instance._get_primals() + + with self.assertRaises(NotImplementedError): + self.instance._get_duals() + + with self.assertRaises(NotImplementedError): + self.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) + + +class TestLegacySolverWrapper(unittest.TestCase): + def test_class_method_list(self): + expected_list = [ + 'available', + 'config_block', + 'license_is_valid', + 'set_options', + 'solve', + ] + method_list = [ + method + for method in dir(base.LegacySolverWrapper) + if method.startswith('_') is False + ] + self.assertEqual(sorted(expected_list), sorted(method_list)) + + @unittest.mock.patch.multiple(_LegacyWrappedSolverBase, __abstractmethods__=set()) + def test_context_manager(self): + with _LegacyWrappedSolverBase() as instance: + self.assertIsInstance(instance, _LegacyWrappedSolverBase) + self.assertFalse(instance.available(False)) + + @unittest.mock.patch.multiple(_LegacyWrappedSolverBase, __abstractmethods__=set()) + def test_map_config(self): + # Create a fake/empty config structure that can be added to an empty + # instance of LegacySolverWrapper + self.config = ConfigDict(implicit=True) + self.config.declare( + 'solver_options', + ConfigDict(implicit=True, description="Options to pass to the solver."), + ) + instance = _LegacyWrappedSolverBase() + instance.config = self.config + instance._map_config( + True, False, False, 20, True, False, None, None, None, False, None, None + ) + self.assertTrue(instance.config.tee) + self.assertFalse(instance.config.load_solutions) + self.assertEqual(instance.config.time_limit, 20) + 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): + print(instance.config.keepfiles) + # We haven't implemented solver_io, suffixes, or logfile + with self.assertRaises(NotImplementedError): + instance._map_config( + False, + False, + False, + 20, + False, + False, + None, + None, + '/path/to/bogus/file', + False, + None, + None, + ) + with self.assertRaises(NotImplementedError): + instance._map_config( + False, + False, + False, + 20, + False, + False, + None, + '/path/to/bogus/file', + None, + False, + None, + None, + ) + with self.assertRaises(NotImplementedError): + instance._map_config( + False, + False, + False, + 20, + False, + False, + '/path/to/bogus/file', + None, + None, + False, + None, + None, + ) + # If they ask for keepfiles, we redirect them to working_dir + instance._map_config( + False, False, False, 20, False, False, None, None, None, True, None, None + ) + self.assertEqual(instance.config.working_dir, os.getcwd()) + with self.assertRaises(AttributeError): + print(instance.config.keepfiles) + + @unittest.mock.patch.multiple(_LegacyWrappedSolverBase, __abstractmethods__=set()) + 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 + + def test_solution_handler(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 new file mode 100644 index 00000000000..354cfd8a37a --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_config.py @@ -0,0 +1,120 @@ +# ___________________________________________________________________________ +# +# 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 import unittest +from pyomo.contrib.solver.config import ( + SolverConfig, + BranchAndBoundConfig, + AutoUpdateConfig, + PersistentSolverConfig, +) + + +class TestSolverConfig(unittest.TestCase): + def test_interface_default_instantiation(self): + config = SolverConfig() + 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) + + def test_interface_custom_instantiation(self): + config = SolverConfig(description="A description") + config.tee = True + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertFalse(config.time_limit) + config.time_limit = 1.0 + self.assertEqual(config.time_limit, 1.0) + self.assertIsInstance(config.time_limit, float) + + +class TestBranchAndBoundConfig(unittest.TestCase): + def test_interface_default_instantiation(self): + config = BranchAndBoundConfig() + self.assertIsNone(config._description) + self.assertEqual(config._visibility, 0) + self.assertFalse(config.tee) + self.assertTrue(config.load_solutions) + self.assertFalse(config.symbolic_solver_labels) + self.assertIsNone(config.rel_gap) + self.assertIsNone(config.abs_gap) + + def test_interface_custom_instantiation(self): + config = BranchAndBoundConfig(description="A description") + config.tee = True + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertFalse(config.time_limit) + config.time_limit = 1.0 + self.assertEqual(config.time_limit, 1.0) + self.assertIsInstance(config.time_limit, float) + config.rel_gap = 2.5 + self.assertEqual(config.rel_gap, 2.5) + + +class TestAutoUpdateConfig(unittest.TestCase): + def test_interface_default_instantiation(self): + config = AutoUpdateConfig() + self.assertTrue(config.check_for_new_or_removed_constraints) + self.assertTrue(config.check_for_new_or_removed_vars) + self.assertTrue(config.check_for_new_or_removed_params) + self.assertTrue(config.check_for_new_objective) + self.assertTrue(config.update_constraints) + self.assertTrue(config.update_vars) + 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") + config.check_for_new_objective = False + self.assertEqual(config._description, "A description") + self.assertTrue(config.check_for_new_or_removed_constraints) + self.assertFalse(config.check_for_new_objective) + + +class TestPersistentSolverConfig(unittest.TestCase): + def test_interface_default_instantiation(self): + config = PersistentSolverConfig() + 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) + self.assertTrue(config.auto_updates.check_for_new_or_removed_constraints) + self.assertTrue(config.auto_updates.check_for_new_or_removed_vars) + self.assertTrue(config.auto_updates.check_for_new_or_removed_params) + self.assertTrue(config.auto_updates.check_for_new_objective) + self.assertTrue(config.auto_updates.update_constraints) + self.assertTrue(config.auto_updates.update_vars) + 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") + config.tee = True + config.auto_updates.check_for_new_objective = False + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertFalse(config.auto_updates.check_for_new_objective) diff --git a/pyomo/contrib/solver/tests/unit/test_ipopt.py b/pyomo/contrib/solver/tests/unit/test_ipopt.py new file mode 100644 index 00000000000..27a80feede0 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_ipopt.py @@ -0,0 +1,249 @@ +# ___________________________________________________________________________ +# +# 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', + '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) diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py new file mode 100644 index 00000000000..a15c9b87253 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_results.py @@ -0,0 +1,261 @@ +# ___________________________________________________________________________ +# +# 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 io import StringIO +from typing import Sequence, Dict, Optional, Mapping, MutableMapping + + +from pyomo.common import unittest +from pyomo.common.config import ConfigDict +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.core.base.var import Var + + +class SolutionLoaderExample(solution.SolutionLoaderBase): + """ + This is an example instantiation of a SolutionLoader that is used for + testing generated results. + """ + + def __init__( + self, + primals: Optional[MutableMapping], + duals: Optional[MutableMapping], + reduced_costs: Optional[MutableMapping], + ): + """ + Parameters + ---------- + primals: dict + maps id(Var) to (var, value) + duals: dict + maps Constraint to dual value + reduced_costs: dict + maps id(Var) to (var, reduced_cost) + """ + self._primals = primals + self._duals = duals + self._reduced_costs = reduced_costs + + def get_primals( + 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 ' + 'check the termination condition.' + ) + if vars_to_load is None: + return ComponentMap(self._primals.values()) + else: + primals = ComponentMap() + for v in vars_to_load: + primals[v] = self._primals[id(v)][1] + return primals + + def get_duals( + 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 ' + 'check the termination condition and ensure the solver returns duals ' + 'for the given problem type.' + ) + if cons_to_load is None: + duals = dict(self._duals) + else: + duals = {} + for c in cons_to_load: + duals[c] = self._duals[c] + return duals + + def get_reduced_costs( + 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 ' + 'check the termination condition and ensure the solver returns reduced ' + 'costs for the given problem type.' + ) + if vars_to_load is None: + rc = ComponentMap(self._reduced_costs.values()) + else: + rc = ComponentMap() + for v in vars_to_load: + rc[v] = self._reduced_costs[id(v)][1] + return rc + + +class TestTerminationCondition(unittest.TestCase): + def test_member_list(self): + member_list = results.TerminationCondition._member_names_ + expected_list = [ + 'unknown', + 'convergenceCriteriaSatisfied', + 'maxTimeLimit', + 'iterationLimit', + 'objectiveLimit', + 'minStepLength', + 'unbounded', + 'provenInfeasible', + 'locallyInfeasible', + 'infeasibleOrUnbounded', + 'error', + 'interrupted', + 'licensingProblems', + ] + self.assertEqual(member_list.sort(), expected_list.sort()) + + def test_codes(self): + self.assertEqual(results.TerminationCondition.unknown.value, 42) + self.assertEqual( + results.TerminationCondition.convergenceCriteriaSatisfied.value, 0 + ) + self.assertEqual(results.TerminationCondition.maxTimeLimit.value, 1) + self.assertEqual(results.TerminationCondition.iterationLimit.value, 2) + self.assertEqual(results.TerminationCondition.objectiveLimit.value, 3) + self.assertEqual(results.TerminationCondition.minStepLength.value, 4) + self.assertEqual(results.TerminationCondition.unbounded.value, 5) + self.assertEqual(results.TerminationCondition.provenInfeasible.value, 6) + self.assertEqual(results.TerminationCondition.locallyInfeasible.value, 7) + self.assertEqual(results.TerminationCondition.infeasibleOrUnbounded.value, 8) + self.assertEqual(results.TerminationCondition.error.value, 9) + self.assertEqual(results.TerminationCondition.interrupted.value, 10) + self.assertEqual(results.TerminationCondition.licensingProblems.value, 11) + + +class TestSolutionStatus(unittest.TestCase): + def test_member_list(self): + member_list = results.SolutionStatus._member_names_ + expected_list = ['noSolution', 'infeasible', 'feasible', 'optimal'] + self.assertEqual(member_list, expected_list) + + def test_codes(self): + self.assertEqual(results.SolutionStatus.noSolution.value, 0) + self.assertEqual(results.SolutionStatus.infeasible.value, 10) + self.assertEqual(results.SolutionStatus.feasible.value, 20) + self.assertEqual(results.SolutionStatus.optimal.value, 30) + + +class TestResults(unittest.TestCase): + def test_member_list(self): + res = results.Results() + expected_declared = { + 'extra_info', + 'incumbent_objective', + 'iteration_count', + 'objective_bound', + 'solution_loader', + 'solution_status', + 'solver_name', + 'solver_version', + 'termination_condition', + 'timing_info', + 'solver_log', + 'solver_configuration', + } + actual_declared = res._declared + self.assertEqual(expected_declared, actual_declared) + + def test_default_initialization(self): + res = results.Results() + self.assertIsNone(res.solution_loader) + self.assertIsNone(res.incumbent_objective) + self.assertIsNone(res.objective_bound) + self.assertEqual( + res.termination_condition, results.TerminationCondition.unknown + ) + self.assertEqual(res.solution_status, results.SolutionStatus.noSolution) + self.assertIsNone(res.solver_name) + self.assertIsNone(res.solver_version) + self.assertIsNone(res.iteration_count) + self.assertIsInstance(res.timing_info, ConfigDict) + self.assertIsInstance(res.extra_info, ConfigDict) + self.assertIsNone(res.timing_info.start_timestamp) + self.assertIsNone(res.timing_info.wall_time) + + def test_display(self): + res = results.Results() + stream = StringIO() + res.display(ostream=stream) + expected_print = """solution_loader: None +termination_condition: TerminationCondition.unknown +solution_status: SolutionStatus.noSolution +incumbent_objective: None +objective_bound: None +solver_name: None +solver_version: None +iteration_count: None +timing_info: + start_timestamp: None + wall_time: None +extra_info: +""" + out = stream.getvalue() + if 'null' in out: + out = out.replace('null', 'None') + self.assertEqual(expected_print, out) + + def test_generated_results(self): + m = pyo.ConcreteModel() + m.x = Var() + m.y = Var() + m.c1 = pyo.Constraint(expr=m.x == 1) + m.c2 = pyo.Constraint(expr=m.y == 2) + + primals = {} + primals[id(m.x)] = (m.x, 1) + primals[id(m.y)] = (m.y, 2) + duals = {} + duals[m.c1] = 3 + duals[m.c2] = 4 + rc = {} + rc[id(m.x)] = (m.x, 5) + rc[id(m.y)] = (m.y, 6) + + res = results.Results() + res.solution_loader = SolutionLoaderExample( + primals=primals, duals=duals, reduced_costs=rc + ) + + res.solution_loader.load_vars() + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 2) + + m.x.value = None + m.y.value = None + + res.solution_loader.load_vars([m.y]) + self.assertIsNone(m.x.value) + self.assertAlmostEqual(m.y.value, 2) + + duals2 = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], duals2[m.c1]) + self.assertAlmostEqual(duals[m.c2], duals2[m.c2]) + + duals2 = res.solution_loader.get_duals([m.c2]) + self.assertNotIn(m.c1, duals2) + self.assertAlmostEqual(duals[m.c2], duals2[m.c2]) + + rc2 = res.solution_loader.get_reduced_costs() + self.assertAlmostEqual(rc[id(m.x)][1], rc2[m.x]) + self.assertAlmostEqual(rc[id(m.y)][1], rc2[m.y]) + + rc2 = res.solution_loader.get_reduced_costs([m.y]) + self.assertNotIn(m.x, rc2) + self.assertAlmostEqual(rc[id(m.y)][1], rc2[m.y]) diff --git a/pyomo/contrib/solver/tests/unit/test_sol_reader.py b/pyomo/contrib/solver/tests/unit/test_sol_reader.py new file mode 100644 index 00000000000..d5602945e07 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_sol_reader.py @@ -0,0 +1,51 @@ +# ___________________________________________________________________________ +# +# 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 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 + +currdir = this_file_dir() + + +class TestSolFileData(unittest.TestCase): + def test_default_instantiation(self): + instance = SolFileData() + self.assertIsInstance(instance.primals, list) + self.assertIsInstance(instance.duals, list) + self.assertIsInstance(instance.var_suffixes, dict) + self.assertIsInstance(instance.con_suffixes, dict) + self.assertIsInstance(instance.obj_suffixes, dict) + self.assertIsInstance(instance.problem_suffixes, dict) + self.assertIsInstance(instance.other, list) + + +class TestSolParser(unittest.TestCase): + # I am not sure how to write these tests best since the sol parser requires + # not only a file but also the nl_info and results objects. + def setUp(self): + TempfileManager.push() + + def tearDown(self): + TempfileManager.pop(remove=True) + + def test_default_behavior(self): + pass + + def test_custom_behavior(self): + pass + + def test_infeasible1(self): + pass + + def test_infeasible2(self): + pass diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py new file mode 100644 index 00000000000..a5ee8a9e391 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -0,0 +1,88 @@ +# ___________________________________________________________________________ +# +# 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 import unittest +from pyomo.contrib.solver.solution 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 = [ + method + for method in dir(SolutionLoaderBase) + if method.startswith('_') is False + ] + 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_duals() + with self.assertRaises(NotImplementedError): + self.instance.get_reduced_costs() + + +class TestSolSolutionLoader(unittest.TestCase): + # I am currently unsure how to test this further because it relies heavily on + # SolFileData and NLWriterInfo + def test_member_list(self): + expected_list = ['load_vars', 'get_primals', 'get_duals', 'get_reduced_costs'] + method_list = [ + method + for method in dir(SolutionLoaderBase) + if method.startswith('_') is False + ] + self.assertEqual(sorted(expected_list), sorted(method_list)) + + +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', + 'get_primals', + 'get_duals', + 'get_reduced_costs', + 'invalidate', + ] + method_list = [ + method + for method in dir(PersistentSolutionLoader) + if method.startswith('_') is False + ] + self.assertEqual(sorted(expected_list), sorted(method_list)) + + def test_default_initialization(self): + # Realistically, a solver object should be passed into this. + # However, it works with a string. It'll just error loudly if you + # try to run get_primals, etc. + self.instance = PersistentSolutionLoader('ipopt') + self.assertTrue(self.instance._valid) + self.assertEqual(self.instance._solver, 'ipopt') + + def test_invalid(self): + self.instance = PersistentSolutionLoader('ipopt') + self.instance.invalidate() + with self.assertRaises(RuntimeError): + self.instance.get_primals() diff --git a/pyomo/contrib/solver/tests/unit/test_util.py b/pyomo/contrib/solver/tests/unit/test_util.py new file mode 100644 index 00000000000..f2e8ee707f4 --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_util.py @@ -0,0 +1,142 @@ +# ___________________________________________________________________________ +# +# 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 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.results import Results, SolutionStatus, TerminationCondition +from typing import Callable +from pyomo.common.gsl import find_GSL +from pyomo.opt.results import SolverResults + + +class TestGenericUtils(unittest.TestCase): + def basics_helper(self, collector: Callable, *args): + 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) + self.assertEqual([m.E], named_exprs) + self.assertEqual([m.x, m.y, m.z], var_list) + self.assertEqual([m.y], fixed_vars) + self.assertEqual([], external_funcs) + + def test_collect_vars_basics(self): + self.basics_helper(collect_vars_and_named_exprs) + + def external_func_helper(self, collector: Callable, *args): + DLL = find_GSL() + if not DLL: + self.skipTest('Could not find amplgsl.dll library') + + 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 = 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) + self.assertEqual([m.E], named_exprs) + self.assertEqual([m.z, m.x, m.y], var_list) + self.assertEqual([m.y], fixed_vars) + self.assertEqual([func], external_funcs) + + def test_collect_vars_external(self): + self.external_func_helper(collect_vars_and_named_exprs) + + def simple_model(self): + model = pyo.ConcreteModel() + model.x = pyo.Var([1, 2], domain=pyo.NonNegativeReals) + model.OBJ = pyo.Objective(expr=2 * model.x[1] + 3 * model.x[2]) + model.Constraint1 = pyo.Constraint(expr=3 * model.x[1] + 4 * model.x[2] >= 1) + return model + + def test_get_objective_success(self): + model = self.simple_model() + self.assertEqual(model.OBJ, get_objective(model)) + + def test_get_objective_raise(self): + model = self.simple_model() + model.OBJ2 = pyo.Objective(expr=model.x[1] - 4 * model.x[2]) + with self.assertRaises(ValueError): + get_objective(model) + + 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 + results.solution_status = SolutionStatus.noSolution + self.assertFalse(check_optimal_termination(results)) + + def test_check_optimal_termination_condition_legacy_interface(self): + results = SolverResults() + results.solver.status = SolverStatus.ok + results.solver.termination_condition = LegacyTerminationCondition.optimal + # Both items satisfied + self.assertTrue(check_optimal_termination(results)) + # Termination condition not satisfied + results.solver.termination_condition = LegacyTerminationCondition.unknown + self.assertFalse(check_optimal_termination(results)) + # Both not satisfied + results.solver.termination_condition = SolverStatus.aborted + self.assertFalse(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 + results.solution_status = SolutionStatus.noSolution + with self.assertRaises(RuntimeError): + assert_optimal_termination(results) + + def test_assert_optimal_termination_legacy_interface(self): + results = SolverResults() + results.solver.status = SolverStatus.ok + results.solver.termination_condition = LegacyTerminationCondition.optimal + assert_optimal_termination(results) + # Termination condition not satisfied + results.solver.termination_condition = LegacyTerminationCondition.unknown + with self.assertRaises(RuntimeError): + assert_optimal_termination(results) + # Both not satisfied + results.solver.termination_condition = SolverStatus.aborted + with self.assertRaises(RuntimeError): + assert_optimal_termination(results) diff --git a/pyomo/contrib/solver/util.py b/pyomo/contrib/solver/util.py new file mode 100644 index 00000000000..c6bbfbd22ad --- /dev/null +++ b/pyomo/contrib/solver/util.py @@ -0,0 +1,143 @@ +# ___________________________________________________________________________ +# +# 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 45e60df7658..ea3a8c746a4 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-2022 +# 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 @@ -35,7 +35,7 @@ logger = logging.getLogger('pyomo.contrib.trustregion') -__version__ = '0.2.0' +__version__ = (0, 2, 0) def trust_region_method(model, decision_variables, ext_fcn_surrogate_map_rule, config): diff --git a/pyomo/contrib/trustregion/__init__.py b/pyomo/contrib/trustregion/__init__.py index 62ba0892686..38b30839be3 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/examples/__init__.py b/pyomo/contrib/trustregion/examples/__init__.py index 62ba0892686..38b30839be3 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/examples/example1.py b/pyomo/contrib/trustregion/examples/example1.py index 19965ff1cb2..66df26d143f 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/examples/example2.py b/pyomo/contrib/trustregion/examples/example2.py index 0c506eb6891..ad648855410 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/filter.py b/pyomo/contrib/trustregion/filter.py index 2f0b20ee8f8..7e647a7f0c5 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/interface.py b/pyomo/contrib/trustregion/interface.py index f68f2fdb308..c62969b328a 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-2022 +# 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 @@ -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 59a11986f3c..d4ed22b9d2f 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/tests/__init__.py b/pyomo/contrib/trustregion/tests/__init__.py index 62ba0892686..38b30839be3 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/tests/test_TRF.py b/pyomo/contrib/trustregion/tests/test_TRF.py index e14a784b4af..e2b2b2b64ad 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/tests/test_examples.py b/pyomo/contrib/trustregion/tests/test_examples.py index a954b0851c7..5451cca5961 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/tests/test_filter.py b/pyomo/contrib/trustregion/tests/test_filter.py index 1b89d8d5cd1..18e833685f8 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/tests/test_interface.py b/pyomo/contrib/trustregion/tests/test_interface.py index a7e6457a5ca..d241576f3ba 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-2022 +# 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 @@ -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 3054c2c2bd5..bdc91744e61 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-2022 +# 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 diff --git a/pyomo/contrib/trustregion/util.py b/pyomo/contrib/trustregion/util.py index f27420a2bee..ff3f218fc27 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-2022 +# 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 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 8b137891791..a4a626013c4 100644 --- a/pyomo/contrib/viewer/__init__.py +++ b/pyomo/contrib/viewer/__init__.py @@ -1 +1,10 @@ - +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/viewer/model_browser.py b/pyomo/contrib/viewer/model_browser.py index 8379518a4cf..b4cb0c7e2b6 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-2022 +# 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 @@ -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 3c6c4ccdf17..c611b4e9a20 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-2022 +# 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 @@ -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 a8fec745af4..e4f75c86840 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-2022 +# 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 @@ -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 150fa3560f6..54156489d68 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-2022 +# 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 @@ -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 6f212b2fbc3..a28e0082212 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-2022 +# 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 @@ -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 73cf73847e5..c1172a7d6ce 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-2022 +# 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 @@ -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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/contrib/viewer/tests/__init__.py +++ b/pyomo/contrib/viewer/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/viewer/tests/test_data_model_item.py b/pyomo/contrib/viewer/tests/test_data_model_item.py index f3e7aaf9513..d780b315044 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-2022 +# 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 @@ -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 db745aee9ca..2e5c3592198 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-2022 +# 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 @@ -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 38a022b6668..ffa5f4d52b5 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-2022 +# 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 @@ -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 b496e2294ff..88044490a77 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-2022 +# 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 diff --git a/pyomo/contrib/viewer/ui.py b/pyomo/contrib/viewer/ui.py index 8a621534b31..ecbadeda3cf 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-2022 +# 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 @@ -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 8bbaac14e13..8d83be91e5f 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-2022 +# 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 @@ -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 5cbebcee9ec..8dbe254c0fd 100644 --- a/pyomo/core/__init__.py +++ b/pyomo/core/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -33,6 +33,8 @@ exactly, atleast, atmost, + all_different, + count_if, implies, lnot, xor, @@ -61,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 ( @@ -78,6 +78,7 @@ expr_errors, calculus, ) + from pyomo.core import expr, util, kernel from pyomo.core.expr.numvalue import ( @@ -99,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 @@ -119,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 055f6f8450a..cbe5468945c 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['Model', 'ConcreteModel', 'AbstractModel', 'global_option'] - import logging import sys from weakref import ref as weakref_ref @@ -20,7 +18,7 @@ from pyomo.common import timing from pyomo.common.collections import Bunch from pyomo.common.dependencies import pympler, pympler_available -from pyomo.common.deprecation import deprecated, deprecation_warning +from pyomo.common.deprecation import deprecated from pyomo.common.gc_manager import PauseGC from pyomo.common.log import is_debug_set from pyomo.common.numeric_types import value @@ -34,11 +32,10 @@ from pyomo.core.base.block import ScalarBlock from pyomo.core.base.set import Set from pyomo.core.base.componentuid import ComponentUID -from pyomo.core.base.transformation import TransformationFactory from pyomo.core.base.label import CNameLabeler, CuidLabeler from pyomo.dataportal.DataPortal import DataPortal -from pyomo.opt.results import SolverResults, Solution, SolverStatus, UndefinedData +from pyomo.opt.results import Solution, SolverStatus, UndefinedData from contextlib import nullcontext from io import StringIO @@ -53,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 @@ -789,7 +789,7 @@ def _load_model_data(self, modeldata, namespaces, **kwds): profile_memory = kwds.get('profile_memory', 0) if profile_memory >= 2 and pympler_available: - mem_used = pympler.muppy.get_size(muppy.get_objects()) + mem_used = pympler.muppy.get_size(pympler.muppy.get_objects()) print("") print( " Total memory = %d bytes prior to model " @@ -798,7 +798,7 @@ def _load_model_data(self, modeldata, namespaces, **kwds): if profile_memory >= 3: gc.collect() - mem_used = pympler.muppy.get_size(muppy.get_objects()) + mem_used = pympler.muppy.get_size(pympler.muppy.get_objects()) print( " Total memory = %d bytes prior to model " "construction (after garbage collection)" % mem_used diff --git a/pyomo/core/base/__init__.py b/pyomo/core/base/__init__.py index f7815f1676b..a2f0d948568 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-2022 +# 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 @@ -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 b54beab8584..d24d94fe05a 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['BuildAction'] - import logging import types @@ -24,7 +22,8 @@ @ModelComponentFactory.register( - "A component that performs arbitrary actions during model construction. The action rule is applied to every index value." + "A component that performs arbitrary actions during model construction. " + "The action rule is applied to every index value." ) class BuildAction(IndexedComponent): """A build action, which executes a rule for all valid indices. diff --git a/pyomo/core/base/block.py b/pyomo/core/base/block.py index 89e872ebbe5..47f398e5b60 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-2022 +# 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 @@ -9,31 +9,20 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = [ - 'Block', - 'TraversalStrategy', - 'SortComponents', - 'active_components', - 'components', - 'active_components_data', - 'components_data', - 'SimpleBlock', - 'ScalarBlock', -] - +from __future__ import annotations import copy -import enum import logging import sys import weakref import textwrap -from contextlib import contextmanager -from inspect import isclass +from collections import defaultdict +from contextlib import contextmanager +from inspect import isclass, currentframe +from io import StringIO from itertools import filterfalse, chain from operator import itemgetter, attrgetter -from io import StringIO -from pyomo.common.pyomo_typing import overload +from typing import Union, Any, Type from pyomo.common.autoslots import AutoSlots from pyomo.common.collections import Mapping @@ -41,7 +30,7 @@ from pyomo.common.formatting import StreamIndenter from pyomo.common.gc_manager import PauseGC from pyomo.common.log import is_debug_set -from pyomo.common.sorting import sorted_robust +from pyomo.common.pyomo_typing import overload from pyomo.common.timing import ConstructionTimer from pyomo.core.base.component import ( Component, @@ -51,12 +40,13 @@ from pyomo.core.base.enums import SortComponents, TraversalStrategy from pyomo.core.base.global_set import UnindexedComponent_index from pyomo.core.base.componentuid import ComponentUID -from pyomo.core.base.set import Any, GlobalSetBase, _SetDataBase +from pyomo.core.base.set import Any from pyomo.core.base.var import Var from pyomo.core.base.initializer import Initializer from pyomo.core.base.indexed_component import ( ActiveIndexedComponent, UnindexedComponent_set, + IndexedComponent, ) from pyomo.opt.base import ProblemFormat, guess_format @@ -170,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`) @@ -185,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(): @@ -264,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. @@ -371,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: @@ -497,7 +487,7 @@ def iteritems(self): return self.items() -class _BlockData(ActiveComponentData): +class BlockData(ActiveComponentData): """ This class holds the fundamental block data. """ @@ -547,11 +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 @@ -560,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. """ @@ -583,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 @@ -637,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 @@ -656,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 @@ -689,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): """ @@ -712,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 = {} @@ -784,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: @@ -797,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) ) @@ -813,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__,) ) @@ -846,47 +837,6 @@ def transfer_attributes_from(self, src): ): setattr(self, k, v) - def _add_implicit_sets(self, val): - """TODO: This method has known issues (see tickets) and needs to be - reviewed. [JDS 9/2014]""" - - _component_sets = getattr(val, '_implicit_subsets', None) - # - # FIXME: The name attribute should begin with "_", and None - # should replace "_unknown_" - # - if _component_sets is not None: - for ctr, tset in enumerate(_component_sets): - if tset.parent_component().parent_block() is None and not isinstance( - tset.parent_component(), GlobalSetBase - ): - self.add_component("%s_index_%d" % (val.local_name, ctr), tset) - if ( - getattr(val, '_index_set', None) is not None - and isinstance(val._index_set, _SetDataBase) - and val._index_set.parent_component().parent_block() is None - and not isinstance(val._index_set.parent_component(), GlobalSetBase) - ): - self.add_component( - "%s_index" % (val.local_name,), val._index_set.parent_component() - ) - if ( - getattr(val, 'initialize', None) is not None - and isinstance(val.initialize, _SetDataBase) - and val.initialize.parent_component().parent_block() is None - and not isinstance(val.initialize.parent_component(), GlobalSetBase) - ): - self.add_component( - "%s_index_init" % (val.local_name,), val.initialize.parent_component() - ) - if ( - getattr(val, 'domain', None) is not None - and isinstance(val.domain, _SetDataBase) - and val.domain.parent_block() is None - and not isinstance(val.domain, GlobalSetBase) - ): - self.add_component("%s_domain" % (val.local_name,), val.domain) - def collect_ctypes(self, active=None, descend_into=True): """ Count all component types stored on or under this @@ -928,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 @@ -971,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): @@ -1010,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. # @@ -1066,16 +1007,11 @@ def add_component(self, name, val): val._parent = weakref.ref(self) val._name = name # - # We want to add the temporary / implicit sets first so that - # they get constructed before this component - # - # FIXME: This is sloppy and wasteful (most components trigger - # this, even when there is no need for it). We should - # reconsider the whole _implicit_subsets logic to defer this - # kind of thing to an "update_parent()" method on the - # components. + # Update the context of any anonymous sets # - self._add_implicit_sets(val) + if getattr(val, '_anonymous_sets', None) is not None: + for _set in val._anonymous_sets: + _set._parent = val._parent # # Add the component to the underlying Component store # @@ -1090,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 # @@ -1103,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 @@ -1148,9 +1065,8 @@ def add_component(self, name, val): # added to the class by Block.__init__() # if getattr(_component, '_constructed', False): - # NB: we don't have to construct the temporary / implicit - # sets here: if necessary, that happens when - # _add_implicit_sets() calls add_component(). + # NB: we don't have to construct the anonymous sets here: if + # necessary, that happens in component.construct() if _BlockConstruction.data: data = _BlockConstruction.data.get(id(self), None) if data is not None: @@ -1162,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 @@ -1237,6 +1153,10 @@ def del_component(self, name_or_object): # Clear the _parent attribute obj._parent = None + # Update the context of any anonymous sets + if getattr(obj, '_anonymous_sets', None) is not None: + for _set in obj._anonymous_sets: + _set._parent = None # Now that this component is not in the _decl map, we can call # delattr as usual. @@ -1246,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 @@ -1451,7 +1371,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). @@ -1468,7 +1388,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 @@ -1504,7 +1424,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 @@ -1519,7 +1439,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 @@ -1625,7 +1545,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() @@ -2018,16 +1938,39 @@ 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): + mod = currentframe().f_back.f_globals['__name__'] + if scope is None: + scope = mod + elif not mod.startswith(scope): + raise ValueError( + "All keys in the 'private_data' dictionary must " + "be substrings of the caller's module name. " + "Received '%s' when calling private_data on Block " + "'%s'." % (scope, self.name) + ) + if self._private_data is None: + self._private_data = {} + if scope not in self._private_data: + self._private_data[scope] = Block._private_data_initializers[scope]() + 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." @@ -2042,7 +1985,19 @@ 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: @@ -2060,7 +2015,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 @@ -2123,7 +2077,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: @@ -2138,6 +2092,11 @@ def construct(self, data=None): """ Initialize the block """ + if self._constructed: + return + self._constructed = True + + timer = ConstructionTimer(self) if is_debug_set(logger): logger.debug( "Constructing %s '%s', from data=%s", @@ -2145,10 +2104,10 @@ def construct(self, data=None): self.name, str(data), ) - if self._constructed: - return - timer = ConstructionTimer(self) - self._constructed = True + + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() # Constructing blocks is tricky. Scalar blocks are already # partially constructed (they have _data[None] == self) in order @@ -2239,12 +2198,29 @@ 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): + mod = currentframe().f_back.f_globals['__name__'] + if scope is None: + scope = mod + elif not mod.startswith(scope): + raise ValueError( + "'private_data' scope must be substrings of the caller's module name. " + f"Received '{scope}' when calling register_private_data_initializer()." + ) + if scope in Block._private_data_initializers: + raise RuntimeError( + "Duplicate initializer registration for 'private_data' dictionary " + f"(scope={scope})" + ) + 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 @@ -2266,6 +2242,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. @@ -2321,101 +2302,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())) +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.""" - - 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 + def block_data_decorator(block_data): + # this is the decorator function that creates the block + # component classes - # 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, - } - - 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 21e6ac4db90..d91a5c85ac2 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-2022 +# 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 @@ -12,8 +12,6 @@ # the purpose of this file is to collect all utility methods that compute # attributes of blocks, based on their contents. -__all__ = ['has_discrete_variables'] - from pyomo.common import deprecated from pyomo.core.base import Var diff --git a/pyomo/core/base/boolean_var.py b/pyomo/core/base/boolean_var.py index 1945045abdd..65bd33fe739 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-2022 +# 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 @@ -68,27 +68,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.""" @@ -134,114 +161,7 @@ def __call__(self, exception=True): @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 +170,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 +191,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 +220,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 +269,7 @@ class BooleanVar(IndexedComponent): to True. """ - _ComponentDataClass = _GeneralBooleanVarData + _ComponentDataClass = BooleanVarData def __new__(cls, *args, **kwds): if cls != BooleanVar: @@ -385,8 +345,12 @@ def construct(self, data=None): timer = ConstructionTimer(self) self._constructed = True + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _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 @@ -497,11 +461,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 @@ -515,9 +479,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 " @@ -526,9 +490,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 " @@ -537,7 +500,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): """ @@ -545,7 +509,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 " @@ -555,7 +519,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 " @@ -599,6 +563,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 0e9d8e889b2..485d1a73b6b 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['BuildCheck'] - import logging import types diff --git a/pyomo/core/base/component.py b/pyomo/core/base/component.py index bb855bd6f8d..a5763264b14 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-2022 +# 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 @@ -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} @@ -501,7 +449,7 @@ def __init__(self, **kwds): # self._ctype = kwds.pop('ctype', None) self.doc = kwds.pop('doc', None) - self._name = kwds.pop('name', str(type(self).__name__)) + self._name = kwds.pop('name', None) if kwds: raise ValueError( "Unexpected keyword options found while constructing '%s':\n\t%s" @@ -625,6 +573,8 @@ def getname(self, fully_qualified=False, name_buffer=None, relative_to=None): Generate fully_qualified names relative to the specified block. """ local_name = self._name + if local_name is None: + local_name = type(self).__name__ if fully_qualified: pb = self.parent_block() if relative_to is None: @@ -655,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 @@ -777,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 @@ -800,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): @@ -874,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""" @@ -914,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 17d46c12fae..c2fa01f6ad5 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-2022 +# 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 diff --git a/pyomo/core/base/component_order.py b/pyomo/core/base/component_order.py index 0685571ccb0..9244828cbe5 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-2022 +# 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 @@ -9,9 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -__all__ = ['items', 'display_items', 'display_name'] - from pyomo.core.base.set import Set, RangeSet from pyomo.core.base.param import Param from pyomo.core.base.var import Var diff --git a/pyomo/core/base/componentuid.py b/pyomo/core/base/componentuid.py index 89f7e5f8320..a0009b1e1b7 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-2022 +# 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 @@ -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 4c6cc06f90c..14c00522673 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-2022 +# 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 diff --git a/pyomo/core/base/connector.py b/pyomo/core/base/connector.py index f3d4833b837..84fe5a80b9d 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['Connector'] - import logging import sys from weakref import ref as weakref_ref @@ -26,12 +24,11 @@ from pyomo.core.base.global_set import UnindexedComponent_index from pyomo.core.base.indexed_component import IndexedComponent from pyomo.core.base.misc import apply_indexed_rule -from pyomo.core.base.transformation import TransformationFactory logger = logging.getLogger('pyomo.core') -class _ConnectorData(ComponentData, NumericValue): +class ConnectorData(ComponentData, NumericValue): """Holds the actual connector information""" __slots__ = ('vars', 'aggregators') @@ -108,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." ) @@ -129,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): @@ -160,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): @@ -173,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) @@ -261,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 e391b4a5605..d27d8e0458f 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-2022 +# 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 @@ -9,23 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = [ - 'Constraint', - '_ConstraintData', - 'ConstraintList', - 'simple_constraint_rule', - 'simple_constraintlist_rule', -] - -import io +from __future__ import annotations import sys import logging -import math 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 @@ -37,6 +29,7 @@ as_numeric, is_fixed, native_numeric_types, + native_logical_types, native_types, ) from pyomo.core.expr import ( @@ -45,12 +38,14 @@ InequalityExpression, RangedExpression, ) +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 @@ -63,6 +58,8 @@ logger = logging.getLogger('pyomo.core') +TEMPLATIZE_CONSTRAINTS = False + _inf = float('inf') _nonfinite_values = {_inf, -_inf} _known_relational_expressions = { @@ -70,6 +67,7 @@ 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 @@ -88,20 +86,24 @@ 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(...)) """ - return rule_wrapper( - rule, - { - None: Constraint.Skip, - True: Constraint.Feasible, - False: Constraint.Infeasible, - }, - ) + map_types = set([type(None)]) | native_logical_types + result_map = {None: Constraint.Skip} + for l_type in native_logical_types: + result_map[l_type(True)] = Constraint.Feasible + result_map[l_type(False)] = Constraint.Infeasible + # Note: some logical types hash the same as bool (e.g., np.bool_), so + # we will pass the set of all logical types in addition to the + # result_map + return rule_wrapper(rule, result_map, map_types=map_types) def simple_constraintlist_rule(rule): @@ -113,348 +115,260 @@ def simple_constraintlist_rule(rule): Example use: - @simple_constraintlist_rule - def C_rule(model, i, j): - ... + .. code:: - model.c = ConstraintList(expr=simple_constraintlist_rule(...)) - """ - return rule_wrapper( - rule, - { - None: ConstraintList.End, - True: Constraint.Feasible, - False: Constraint.Infeasible, - }, - ) + @simple_constraintlist_rule + def C_rule(model, i, j): + # ... + model.c = ConstraintList(expr=simple_constraintlist_rule(...)) -# -# This class is a pure interface -# + """ + map_types = set([type(None)]) | native_logical_types + result_map = {None: ConstraintList.End} + for l_type in native_logical_types: + result_map[l_type(True)] = Constraint.Feasible + result_map[l_type(False)] = Constraint.Infeasible + # Note: some logical types hash the same as bool (e.g., np.bool_), so + # we will pass the set of all logical types in addition to the + # result_map + return rule_wrapper(rule, result_map, map_types=map_types) -class _ConstraintData(ActiveComponentData): - """ - This class defines the data for a single constraint. +class ConstraintData(ActiveComponentData): + """This class defines the data for a single algebraic constraint. - Constructor arguments: - component The Constraint object that owns this data. + Parameters + ---------- + expr : ExpressionBase + The Pyomo expression stored in this constraint. - 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 + component : Constraint + The Constraint object that owns this data. - 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) def __call__(self, exception=True): """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 - - def set_value(self, expr): - """Set the expression on this constraint.""" - raise NotImplementedError + body = self.to_bounded_expression()[1] + if body.__class__ not in native_numeric_types: + body = value(self.body, exception=exception) + return body - def get_value(self): - """Get 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) + 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. -class _GeneralConstraintData(_ConstraintData): - """ - This class defines the data for a single general constraint. + 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. - Constructor arguments: - component The Constraint object that owns this data. - expr The Pyomo expression stored in this constraint. + Parameters + ---------- + evaluate_bounds: bool - 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 + If True, then the lower and upper bounds will be evaluated + to a finite numeric constant or None. - Private class attributes: - _component The objective component. - _active A boolean that indicates whether this data is active - """ + Raises + ------ - __slots__ = ('_body', '_lower', '_upper', '_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 + ValueError: Raised if the expression cannot be mapped to this + form (i.e., :py:class:`RangedExpression` constraints with + variable lower or upper bounds. - self._body = None - self._lower = None - self._upper = None - self._expr = None - if expr is not None: - self.set_value(expr) + """ + expr = self._expr + 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 + 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) + return ans - @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 - - 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.""" @@ -462,15 +376,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 ( @@ -567,120 +488,89 @@ 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 + + @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.") @@ -717,8 +607,6 @@ class Constraint(ActiveIndexedComponent): A dictionary from the index set to component data objects _index The set of valid indices - _implicit_subsets - A tuple of set objects that represents the index set _model A weakref to the model that owns this component _parent @@ -727,7 +615,7 @@ class Constraint(ActiveIndexedComponent): The class type for the derived subclass """ - _ComponentDataClass = _GeneralConstraintData + _ComponentDataClass = ConstraintData class Infeasible(object): pass @@ -737,6 +625,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) @@ -771,6 +670,10 @@ def construct(self, data=None): if is_debug_set(logger): logger.debug("Constructing constraint %s" % (self.name)) + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + rule = self.rule try: # We do not (currently) accept data for constructing Constraints @@ -800,6 +703,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)) @@ -870,14 +785,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 @@ -888,12 +803,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 " @@ -901,11 +816,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 " @@ -913,11 +835,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 " @@ -925,11 +854,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 " @@ -937,11 +866,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 " @@ -949,11 +878,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 " @@ -961,7 +890,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 = {} @@ -996,6 +925,7 @@ class SimpleConstraint(metaclass=RenamedClass): { 'add', 'set_value', + 'to_bounded_expression', 'body', 'lower', 'upper', @@ -1025,6 +955,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): @@ -1044,8 +979,7 @@ def __init__(self, **kwargs): _rule = kwargs.pop('rule', None) self._starting_index = kwargs.pop('starting_index', 1) - args = (Set(dimen=1),) - super(ConstraintList, self).__init__(*args, **kwargs) + super(ConstraintList, self).__init__(Set(dimen=1), **kwargs) self.rule = Initializer( _rule, treat_sequences_as_mappings=False, allow_generators=True @@ -1067,7 +1001,9 @@ def construct(self, data=None): if is_debug_set(logger): logger.debug("Constructing constraint list %s" % (self.name)) - self.index_set().construct() + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() if self.rule is not None: _rule = self.rule(self.parent_block(), ()) diff --git a/pyomo/core/base/disable_methods.py b/pyomo/core/base/disable_methods.py index 61d63d0a385..ff8eb98487a 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-2022 +# 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 diff --git a/pyomo/core/base/enums.py b/pyomo/core/base/enums.py index ddcc66fdc4e..31f2212a661 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-2022 +# 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 diff --git a/pyomo/core/base/expression.py b/pyomo/core/base/expression.py index 780bc17c8a3..1cfef22c7dd 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-2022 +# 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 @@ -9,15 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['Expression', '_ExpressionData'] - import sys import logging from weakref import ref as weakref_ref from pyomo.common.pyomo_typing import overload from pyomo.common.log import is_debug_set -from pyomo.common.deprecation import deprecated, RenamedClass +from pyomo.common.deprecation import RenamedClass from pyomo.common.modeling import NOTSET from pyomo.common.formatting import tabular_writer from pyomo.common.timing import ConstructionTimer @@ -32,39 +30,48 @@ from pyomo.core.base.component import ComponentData, ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index from pyomo.core.base.indexed_component import IndexedComponent, UnindexedComponent_set -from pyomo.core.base.misc import apply_indexed_rule from pyomo.core.expr.numvalue import as_numeric from pyomo.core.base.initializer import Initializer 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): """Compute the value of this expression.""" - (arg,) = self._args_ + (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 @@ -76,7 +83,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): @@ -88,7 +95,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] @@ -103,7 +110,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() @@ -113,13 +120,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) @@ -128,58 +136,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: @@ -207,7 +163,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 @@ -215,70 +171,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 @@ -393,6 +373,10 @@ def construct(self, data=None): % (self.name, str(data)) ) + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + try: # We do not (currently) accept data for constructing Constraints assert data is None @@ -401,9 +385,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 @@ -426,7 +410,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 " @@ -444,7 +428,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 " @@ -454,7 +438,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 " @@ -464,7 +448,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 " @@ -508,6 +492,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 93fb69e8cf7..0fda004b664 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-2022 +# 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 @@ -31,20 +31,18 @@ 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 -__all__ = ('ExternalFunction',) - logger = logging.getLogger('pyomo.core') nan = float('nan') @@ -199,14 +197,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) @@ -493,7 +492,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 f4d97403308..b1bb98abee0 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-2022 +# 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 @@ -72,8 +72,11 @@ def _parent(self, val): class _UnindexedComponent_set(GlobalSetBase): local_name = 'UnindexedComponent_set' + _anonymous_sets = GlobalSetBase + def __init__(self, name): self.name = name + self._constructed = True def __contains__(self, val): return val is None @@ -180,6 +183,12 @@ def prev(self, item, step=1): def prevw(self, item, step=1): return self.nextw(item, -step) + def parent_block(self): + return None + + def parent_component(self): + return self + UnindexedComponent_set = _UnindexedComponent_set('UnindexedComponent_set') GlobalSets[UnindexedComponent_set.local_name] = UnindexedComponent_set diff --git a/pyomo/core/base/indexed_component.py b/pyomo/core/base/indexed_component.py index 86b210331bb..4fcbd30d1ff 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-2022 +# 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 @@ -9,21 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['IndexedComponent', 'ActiveIndexedComponent'] - -import enum import inspect import logging import sys import textwrap -from copy import deepcopy - import pyomo.core.expr as EXPR 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 @@ -31,9 +26,9 @@ from pyomo.core.pyomoobject import PyomoObject from pyomo.common import DeveloperError from pyomo.common.autoslots import fast_deepcopy -from pyomo.common.dependencies import numpy as np, numpy_available +from pyomo.common.collections import ComponentSet from pyomo.common.deprecation import deprecated, deprecation_warning -from pyomo.common.errors import DeveloperError, TemplateExpressionError +from pyomo.common.errors import TemplateExpressionError from pyomo.common.modeling import NOTSET from pyomo.common.numeric_types import native_types from pyomo.common.sorting import sorted_robust @@ -68,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) @@ -165,9 +162,12 @@ def _get_indexed_component_data_name(component, index): """ -def rule_result_substituter(result_map): +def rule_result_substituter(result_map, map_types): _map = result_map - _map_types = set(type(key) for key in result_map) + if map_types is None: + _map_types = set(type(key) for key in result_map) + else: + _map_types = map_types def rule_result_substituter_impl(rule, *args, **kwargs): if rule.__class__ in _map_types: @@ -208,7 +208,7 @@ def rule_result_substituter_impl(rule, *args, **kwargs): """ -def rule_wrapper(rule, wrapping_fcn, positional_arg_map=None): +def rule_wrapper(rule, wrapping_fcn, positional_arg_map=None, map_types=None): """Wrap a rule with another function This utility method provides a way to wrap a function (rule) with @@ -235,7 +235,7 @@ def rule_wrapper(rule, wrapping_fcn, positional_arg_map=None): """ if isinstance(wrapping_fcn, dict): - wrapping_fcn = rule_result_substituter(wrapping_fcn) + wrapping_fcn = rule_result_substituter(wrapping_fcn, map_types) if not inspect.isfunction(rule): return wrapping_fcn(rule) # Because some of our processing of initializer functions relies on @@ -255,8 +255,7 @@ def rule_wrapper(rule, wrapping_fcn, positional_arg_map=None): class IndexedComponent(Component): - """ - This is the base class for all indexed modeling components. + """This is the base class for all indexed modeling components. This class stores a dictionary, self._data, that maps indices to component data objects. The object self._index_set defines valid keys for this dictionary, and the dictionary keys may be a @@ -278,11 +277,16 @@ class IndexedComponent(Component): doc A text string describing this component Private class attributes: - _data A dictionary from the index set to - component data objects - _index_set The set of valid indices - _implicit_subsets A temporary data element that stores - sets that are transferred to the model + + _data: A dictionary from the index set to component data objects + + _index_set: The set of valid indices + + _anonymous_sets: A ComponentSet of "anonymous" sets used by this + component. Anonymous sets are Set / SetOperator / RangeSet + that compose attributes like _index_set, but are not + themselves explicitly assigned (and named) on any Block + """ class Skip(object): @@ -304,47 +308,43 @@ def __init__(self, *args, **kwds): # self._data = {} # - if len(args) == 0 or (len(args) == 1 and args[0] is UnindexedComponent_set): + if len(args) == 0 or (args[0] is UnindexedComponent_set and len(args) == 1): # # If no indexing sets are provided, generate a dummy index # - self._implicit_subsets = None self._index_set = UnindexedComponent_set + self._anonymous_sets = None elif len(args) == 1: # # If a single indexing set is provided, just process it. # - self._implicit_subsets = None - self._index_set = BASE.set.process_setarg(args[0]) + self._index_set, self._anonymous_sets = BASE.set.process_setarg(args[0]) else: # # If multiple indexing sets are provided, process them all, - # and store the cross-product of these sets. The individual - # sets need to stored in the Pyomo model, so the - # _implicit_subsets class data is used for this temporary - # storage. + # and store the cross-product of these sets. # - # Example: Pyomo allows things like - # "Param([1,2,3], range(100), initialize=0)". This - # needs to create *3* sets: two SetOf components and then - # the SetProduct. That means that the component needs to - # hold on to the implicit SetOf objects until the component - # is assigned to a model (where the implicit subsets can be - # "transferred" to the model). + # Example: Pyomo allows things like "Param([1,2,3], + # range(100), initialize=0)". This needs to create *3* + # sets: two SetOf components and then the SetProduct. As + # the user declined to name any of these sets, we will not + # make up names and instead store them on the model as + # "anonymous components" # - tmp = [BASE.set.process_setarg(x) for x in args] - self._implicit_subsets = tmp - self._index_set = tmp[0].cross(*tmp[1:]) + self._index_set = BASE.set.SetProduct(*args) + self._anonymous_sets = ComponentSet((self._index_set,)) + if self._index_set._anonymous_sets is not None: + self._anonymous_sets.update(self._index_set._anonymous_sets) 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 @@ -354,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 @@ -608,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. """ @@ -733,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!). @@ -749,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: @@ -983,11 +985,13 @@ def _processUnhashableIndex(self, idx): slice_dim -= 1 if normalize_index.flatten: set_dim = self.dim() - elif self._implicit_subsets is None: + elif not self.is_indexed(): # Scalar component. set_dim = 0 else: - set_dim = len(self._implicit_subsets) + set_dim = self.index_set().dimen + if set_dim is None: + set_dim = 1 structurally_valid = False if slice_dim == set_dim or set_dim is None: @@ -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 9779711a19b..37b3c452433 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-2022 +# 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 @@ -402,8 +402,7 @@ def __init__(self, component, fixed, sliced, ellipsis, iter_over_index, sort): self.last_index = () self.tuplize_unflattened_index = ( - self.component._implicit_subsets is None - or len(self.component._implicit_subsets) == 1 + len(list(self.component.index_set().subsets())) <= 1 ) if fixed is None and sliced is None and ellipsis is None: diff --git a/pyomo/core/base/initializer.py b/pyomo/core/base/initializer.py index 991feb0450d..c15e26855ae 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-2022 +# 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 @@ -16,6 +16,7 @@ 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 +38,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 +71,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 +240,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 @@ -316,6 +349,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 +487,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 +539,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 b11c0c18e11..5cd690b7ece 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['instance2dat'] - import types from pyomo.core.base import Set, Param, value diff --git a/pyomo/core/base/label.py b/pyomo/core/base/label.py index b642b834146..e22c1283138 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-2022 +# 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 @@ -9,17 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = [ - 'CounterLabeler', - 'NumericLabeler', - 'CNameLabeler', - 'TextLabeler', - 'AlphaNumericTextLabeler', - 'NameLabeler', - 'CuidLabeler', - 'ShortNameLabeler', -] - import re from pyomo.common.deprecation import deprecated diff --git a/pyomo/core/base/logical_constraint.py b/pyomo/core/base/logical_constraint.py index 6d553c66fed..5fdea45e562 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['LogicalConstraint', '_LogicalConstraintData', 'LogicalConstraintList'] - import inspect import sys import logging @@ -22,7 +20,6 @@ from pyomo.common.modeling import NOTSET from pyomo.common.timing import ConstructionTimer -from pyomo.core.base.constraint import Constraint 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 @@ -45,79 +42,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',) @@ -126,7 +73,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 @@ -137,6 +84,12 @@ def __init__(self, expr=None, component=None): if expr is not None: self.set_value(expr) + 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 # @@ -176,6 +129,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): """ @@ -210,8 +173,6 @@ class LogicalConstraint(ActiveIndexedComponent): A dictionary from the index set to component data objects _index_set The set of valid indices - _implicit_subsets - A tuple of set objects that represents the index set _model A weakref to the model that owns this component _parent @@ -220,7 +181,7 @@ class LogicalConstraint(ActiveIndexedComponent): The class type for the derived subclass """ - _ComponentDataClass = _GeneralLogicalConstraintData + _ComponentDataClass = LogicalConstraintData class Infeasible(object): pass @@ -280,6 +241,10 @@ def construct(self, data=None): timer = ConstructionTimer(self) self._constructed = True + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + _init_expr = self._init_expr _init_rule = self.rule # @@ -374,7 +339,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. # @@ -410,14 +375,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 @@ -437,7 +402,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 " @@ -451,7 +416,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. # @@ -516,22 +481,25 @@ class LogicalConstraintList(IndexedLogicalConstraint): def __init__(self, **kwargs): """Constructor""" - args = (Set(),) if 'expr' in kwargs: raise ValueError("LogicalConstraintList does not accept the 'expr' keyword") - LogicalConstraint.__init__(self, *args, **kwargs) + LogicalConstraint.__init__(self, Set(dimen=1), **kwargs) def construct(self, data=None): """ Construct the expression(s) for this logical constraint. """ + if self._constructed: + return + self._constructed = True + generate_debug_messages = is_debug_set(logger) if generate_debug_messages: logger.debug("Constructing logical constraint list %s" % self.name) - if self._constructed: - return - self._constructed = True + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() assert self._init_expr is None _init_rule = self.rule diff --git a/pyomo/core/base/matrix_constraint.py b/pyomo/core/base/matrix_constraint.py index 0c55dbc15d3..8dac7c3d24b 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-2022 +# 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 @@ -19,7 +19,7 @@ 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 +28,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 +104,7 @@ def __init__(self, index, component_ref): # # These lines represent in-lining of the # following constructors: - # - _ConstraintData, + # - ConstraintData, # - ActiveComponentData # - ComponentData self._component = component_ref @@ -209,7 +209,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 cf37ad48fea..456a4531e30 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-2022 +# 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 @@ -9,14 +9,10 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['display'] - import logging import sys -import types from pyomo.common.deprecation import relocated_module_attribute -from pyomo.core.expr import native_numeric_types logger = logging.getLogger('pyomo.core') diff --git a/pyomo/core/base/numvalue.py b/pyomo/core/base/numvalue.py index 11d45228bf5..75bceef7ebb 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-2022 +# 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 diff --git a/pyomo/core/base/objective.py b/pyomo/core/base/objective.py index 7fb495f3e5b..55c2247fd16 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-2022 +# 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 @@ -9,28 +9,21 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ( - 'Objective', - 'simple_objective_rule', - '_ObjectiveData', - 'minimize', - 'maximize', - 'simple_objectivelist_rule', - 'ObjectiveList', -) - import sys import logging from weakref import ref as weakref_ref 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.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 ( @@ -38,17 +31,18 @@ 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') +TEMPLATIZE_OBJECTIVES = False + _rule_returned_none_error = """Objective '%s': rule returned None. Objective rules must return either a valid expression, numeric value, or @@ -65,11 +59,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}) @@ -82,94 +79,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}) + @simple_objectivelist_rule + def O_rule(model, i, j): + # ... + model.o = ObjectiveList(expr=simple_objectivelist_rule(...)) -# -# This class is a pure interface -# - - -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: @@ -192,14 +151,48 @@ 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 + + @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.") @@ -242,8 +235,6 @@ class Objective(ActiveIndexedComponent): A dictionary from the index set to component data objects _index The set of valid indices - _implicit_subsets - A tuple of set objects that represents the index set _model A weakref to the model that owns this component _parent @@ -252,7 +243,7 @@ class Objective(ActiveIndexedComponent): The class type for the derived subclass """ - _ComponentDataClass = _GeneralObjectiveData + _ComponentDataClass = ObjectiveData NoObjective = ActiveIndexedComponent.Skip def __new__(cls, *args, **kwds): @@ -290,6 +281,10 @@ def construct(self, data=None): if is_debug_set(logger): logger.debug("Constructing objective %s" % (self.name)) + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + rule = self.rule try: # We do not (currently) accept data for constructing Objectives @@ -321,6 +316,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)) @@ -361,11 +370,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): @@ -397,14 +402,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 @@ -440,7 +445,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 " @@ -463,7 +468,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 " @@ -482,7 +487,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 # @@ -506,7 +511,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 " @@ -564,8 +569,7 @@ def __init__(self, **kwargs): _rule = kwargs.pop('rule', None) self._starting_index = kwargs.pop('starting_index', 1) - args = (Set(dimen=1),) - super().__init__(*args, **kwargs) + super().__init__(Set(dimen=1), **kwargs) self.rule = Initializer(_rule, allow_generators=True) # HACK to make the "counted call" syntax work. We wait until @@ -585,7 +589,9 @@ def construct(self, data=None): if is_debug_set(logger): logger.debug("Constructing objective list %s" % (self.name)) - self.index_set().construct() + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() if self.rule is not None: _rule = self.rule(self.parent_block(), ()) diff --git a/pyomo/core/base/param.py b/pyomo/core/base/param.py index ea4290d880d..45f25d2748d 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-2022 +# 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 @@ -9,13 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['Param'] - +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 @@ -118,7 +118,7 @@ def _parent(self, val): pass -class _ParamData(ComponentData, NumericValue): +class ParamData(ComponentData, NumericValue): """ This class defines the data for a mutable parameter. @@ -157,6 +157,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 @@ -164,16 +168,31 @@ def set_value(self, value, idx=NOTSET): # required to be mutable. # _comp = self.parent_component() - if type(value) in native_types: + if value.__class__ in native_types: # TODO: warn/error: check if this Param has units: assigning # a dimensionless value to a united param should be an error pass elif _comp._units is not None: _src_magnitude = expr_value(value) - _src_units = units.get_units(value) - value = units.convert_value( - num_value=_src_magnitude, from_units=_src_units, to_units=_comp._units - ) + # Note: expr_value() could have just registered a new numeric type + if value.__class__ in native_types: + value = _src_magnitude + else: + _src_units = units.get_units(value) + value = units.convert_value( + num_value=_src_magnitude, + from_units=_src_units, + to_units=_comp._units, + ) + # FIXME: we should call value() here [to ensure types get + # registered], but doing so breaks non-numeric Params (which we + # allow). The real fix will be to follow the precedent from + # GetItemExpression and have separate types based on which + # expression "system" the Param should participate in (numeric, + # logical, or structural). + # + # else: + # value = expr_value(value) old_value, self._value = self._value, value try: @@ -237,6 +256,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." ) @@ -270,7 +294,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 @@ -278,6 +302,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) @@ -330,7 +365,7 @@ def __init__(self, *args, **kwd): if _domain_rule is None: self.domain = _ImplicitAny(owner=self, name='Any') else: - self.domain = SetInitializer(_domain_rule)(self.parent_block(), None) + self.domain = SetInitializer(_domain_rule)(self.parent_block(), None, self) # After IndexedComponent.__init__ so we can call is_indexed(). self._rule = Initializer( _init, @@ -497,14 +532,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: @@ -512,7 +547,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: # @@ -575,9 +610,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 @@ -672,8 +707,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 @@ -710,14 +745,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: @@ -783,6 +818,10 @@ def construct(self, data=None): ) self._mutable = True + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + try: # # If the default value is a simple type, we check it versus @@ -859,8 +898,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), @@ -871,9 +914,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 @@ -953,7 +996,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 @@ -966,7 +1009,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 ef2fb9eefae..c7c19aad567 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-2022 +# 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 @@ -18,22 +18,19 @@ 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,...) -__all__ = ['Piecewise'] +""" import logging import math @@ -43,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 @@ -217,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 @@ -275,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 @@ -1021,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: @@ -1238,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 = ( @@ -1247,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 = ( @@ -1357,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 @@ -1544,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 @@ -1554,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 9df4828f550..2a959a302e1 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-2022 +# 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 @@ -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/reference.py b/pyomo/core/base/reference.py index 79ae83b97be..558ced64f1b 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-2022 +# 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 @@ -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 @@ -612,7 +612,7 @@ def Reference(reference, ctype=NOTSET): ... >>> m.r1 = Reference(m.b[:,:].x) >>> m.r1.pprint() - r1 : Size=4, Index=r1_index, ReferenceTo=b[:, :].x + r1 : Size=4, Index={1, 2}*{3, 4}, ReferenceTo=b[:, :].x Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 3) : 1 : None : 3 : False : True : Reals (1, 4) : 1 : None : 4 : False : True : Reals @@ -625,7 +625,7 @@ def Reference(reference, ctype=NOTSET): >>> m.r2 = Reference(m.b[:,3].x) >>> m.r2.pprint() - r2 : Size=2, Index=b_index_0, ReferenceTo=b[:, 3].x + r2 : Size=2, Index={1, 2}, ReferenceTo=b[:, 3].x Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : 1 : None : 3 : False : True : Reals 2 : 2 : None : 3 : False : True : Reals @@ -642,7 +642,7 @@ def Reference(reference, ctype=NOTSET): ... >>> m.r3 = Reference(m.b[:].x[:]) >>> m.r3.pprint() - r3 : Size=4, Index=r3_index, ReferenceTo=b[:].x[:] + r3 : Size=4, Index=ReferenceSet(b[:].x[:]), ReferenceTo=b[:].x[:] Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 3) : 1 : None : None : False : True : Reals (1, 4) : 1 : None : None : False : True : Reals @@ -657,7 +657,7 @@ def Reference(reference, ctype=NOTSET): >>> m.r3[1,4] = 10 >>> m.b[1].x.pprint() - x : Size=2, Index=b[1].x_index + x : Size=2, Index={3, 4} Key : Lower : Value : Upper : Fixed : Stale : Domain 3 : 1 : None : None : False : True : Reals 4 : 1 : 10 : None : False : False : Reals @@ -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 d820ae8d933..e420bae884c 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-2022 +# 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 @@ -9,18 +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 @@ -33,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, @@ -46,7 +56,7 @@ RangeDifferenceError, ) from pyomo.core.base.component import ( - _ComponentBase, + ComponentBase, Component, ComponentData, ModelComponentFactory, @@ -80,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)` @@ -98,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 @@ -124,9 +131,19 @@ def process_setarg(arg): - if isinstance(arg, _SetDataBase): - return arg - elif isinstance(arg, _ComponentBase): + if isinstance(arg, SetData): + if ( + getattr(arg, '_parent', None) is not None + or getattr(arg, '_anonymous_sets', None) is GlobalSetBase + or arg.parent_component()._parent is not None + ): + return arg, None + _anonymous = ComponentSet((arg,)) + if getattr(arg, '_anonymous_sets', None) is not None: + _anonymous.update(arg._anonymous_sets) + return arg, _anonymous + + elif isinstance(arg, ComponentBase): if isinstance(arg, IndexedComponent) and arg.is_indexed(): raise TypeError( "Cannot apply a Set operator to an " @@ -168,7 +185,7 @@ def process_setarg(arg): ) ): ans.construct() - return ans + return process_setarg(ans) # TBD: should lists/tuples be copied into Sets, or # should we preserve the reference using SetOf? @@ -188,19 +205,20 @@ def process_setarg(arg): # create the Set: # _defer_construct = False - if inspect.isgenerator(arg): - _ordered = True - _defer_construct = True - elif inspect.isfunction(arg): - _ordered = True - _defer_construct = True - elif not hasattr(arg, '__contains__'): - raise TypeError( - "Cannot create a Set from data that does not support " - "__contains__. Expected set-like object supporting " - "collections.abc.Collection interface, but received '%s'." - % (type(arg).__name__,) - ) + if not hasattr(arg, '__contains__'): + if inspect.isgenerator(arg): + _ordered = True + _defer_construct = True + elif inspect.isfunction(arg): + _ordered = True + _defer_construct = True + else: + raise TypeError( + "Cannot create a Set from data that does not support " + "__contains__. Expected set-like object supporting " + "collections.abc.Collection interface, but received '%s'." + % (type(arg).__name__,) + ) elif arg.__class__ is type: # This catches the (deprecated) RealSet API. return process_setarg(arg()) @@ -221,7 +239,10 @@ def process_setarg(arg): # Or we can do the simple thing and just use SetOf: # # ans = SetOf(arg) - return ans + _anonymous = ComponentSet((ans,)) + if getattr(ans, '_anonymous_sets', None) is not None: + _anonymous.update(_anonymous_sets) + return ans, _anonymous @deprecated( @@ -236,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] @@ -244,6 +269,7 @@ def B_index(model): @set_options(domain=Integers) def B_index(model): return range(10) + """ def decorator(func): @@ -259,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}) @@ -308,11 +338,22 @@ def intersect(self, other): else: self._set = SetIntersectInitializer(self._set, other) - def __call__(self, parent, idx): + def __call__(self, parent, idx, obj): if self._set is None: return Any - else: - return process_setarg(self._set(parent, idx)) + _ans, _anonymous = process_setarg(self._set(parent, idx)) + if _anonymous: + pc = obj.parent_component() + if getattr(pc, '_anonymous_sets', None) is None: + pc._anonymous_sets = _anonymous + else: + pc._anonymous_sets.update(_anonymous) + for _set in _anonymous: + _set._parent = pc._parent + if pc._constructed: + for _set in _anonymous: + _set.construct() + return _ans def constant(self): return self._set is None or self._set.constant() @@ -452,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) @@ -475,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 @@ -505,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)'.", @@ -543,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 @@ -563,6 +595,8 @@ def __eq__(self, other): # ranges (or no ranges). We will re-generate non-finite sets to # make sure we get an accurate "finiteness" flag. if hasattr(other, 'isfinite'): + if not other.parent_component().is_constructed(): + return False other_isfinite = other.isfinite() if not other_isfinite: try: @@ -863,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): @@ -1126,33 +1160,23 @@ def cross(self, *args): def __ror__(self, other): # See the discussion of Set vs SetOf in process_setarg above - # - # return SetOf(other) | self - return process_setarg(other) | self + return SetUnion(other, self) def __rand__(self, other): # See the discussion of Set vs SetOf in process_setarg above - # - # return SetOf(other) & self - return process_setarg(other) & self + return SetIntersection(other, self) def __rsub__(self, other): # See the discussion of Set vs SetOf in process_setarg above - # - # return SetOf(other) - self - return process_setarg(other) - self + return SetDifference(other, self) def __rxor__(self, other): # See the discussion of Set vs SetOf in process_setarg above - # - # return SetOf(other) ^ self - return process_setarg(other) ^ self + return SetSymmetricDifference(other, self) def __rmul__(self, other): # See the discussion of Set vs SetOf in process_setarg above - # - # return SetOf(other) * self - return process_setarg(other) * self + return SetProduct(other, self) def __lt__(self, other): """ @@ -1167,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__ = () @@ -1273,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): @@ -1318,7 +1350,7 @@ def __len__(self): return len(self._values) def __str__(self): - if self.parent_block() is not None: + if self.parent_component()._name is not None: return self.name if not self.parent_component()._constructed: return type(self).__name__ @@ -1344,107 +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 - _value = value - _d = None - 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): @@ -1518,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): """ @@ -1607,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. @@ -1628,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: @@ -1658,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 @@ -1677,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] @@ -1696,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: @@ -1706,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. @@ -1720,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( @@ -1728,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: @@ -1738,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): @@ -1753,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. @@ -1763,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' ############################################################################ @@ -1890,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 @@ -1932,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): @@ -1944,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) @@ -1956,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 @@ -1967,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: @@ -1984,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( {''} ) ) @@ -2001,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 @@ -2061,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( @@ -2084,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._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' ) @@ -2104,10 +2295,16 @@ def check_values(self): def construct(self, data=None): if self._constructed: return + self._constructed = True + timer = ConstructionTimer(self) if is_debug_set(logger): - logger.debug("Constructing Set, name=%s, from data=%r" % (self.name, data)) - self._constructed = True + logger.debug("Constructing Set, name=%s, from data=%r" % (self, data)) + + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + if data is not None: # Data supplied to construct() should override data provided # to the constructor @@ -2143,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, @@ -2162,11 +2359,22 @@ def _getitem_when_not_present(self, index): ) _d = None - domain = self._init_domain(_block, index) + domain = self._init_domain(_block, index, self) + if domain is not None: + 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) @@ -2174,85 +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 - if _d is not UnknownSetDimen: - obj._dimen = _d - if domain is not None: - obj._domain = domain - domain.parent_component().construct() - 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 @@ -2303,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: @@ -2323,7 +2461,7 @@ def _pprint(self): _ordered = "Sorted" else: _ordered = "{user}" - elif issubclass(_refClass, _InsertionOrderSetData): + elif issubclass(_refClass, InsertionOrderSetData): _ordered = "Insertion" return ( [ @@ -2347,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 @@ -2360,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) @@ -2375,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 @@ -2424,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) @@ -2445,30 +2588,30 @@ 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 + self.construct() def __str__(self): - if self.parent_block() is not None: + if self._name is not None: return self.name return str(self._ref) def construct(self, data=None): if self._constructed: return + self._constructed = True + timer = ConstructionTimer(self) if is_debug_set(logger): - logger.debug( - "Constructing SetOf, name=%s, from data=%r" % (self.name, data) - ) - self._constructed = True + logger.debug("Constructing SetOf, name=%s, from data=%r" % (self, data)) timer.report() @property def dimen(self): - if isinstance(self._ref, _SetData): + if isinstance(self._ref, SetData): return self._ref.dimen _iter = iter(self) try: @@ -2563,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 @@ -2575,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): @@ -2608,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__ = () @@ -2632,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 @@ -2643,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]) @@ -2709,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( @@ -2902,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) @@ -2932,14 +3085,12 @@ def __init__(self, *args, **kwds): pass def __str__(self): - if self.parent_block() is not None: + # Named components should return their name e.g., Reals + if self._name is not None: return self.name # Unconstructed floating components return their type if not self._constructed: return type(self).__name__ - # Named, constructed components should return their name e.g., Reals - if type(self).__name__ != self._name: - return self.name # Floating, unnamed constructed components return their ranges() ans = ' | '.join(str(_) for _ in self.ranges()) if ' | ' in ans: @@ -2952,11 +3103,16 @@ def __str__(self): def construct(self, data=None): if self._constructed: return + timer = ConstructionTimer(self) if is_debug_set(logger): - logger.debug( - "Constructing RangeSet, name=%s, from data=%r" % (self.name, data) - ) + logger.debug("Constructing RangeSet, name=%s, from data=%r" % (self, data)) + # Note: we cannot set the constructed flag until after we have + # generated the debug message: the debug message needs the name, + # which in turn may need ranges(), which has not been + # constructed. + self._constructed = True + if data is not None: raise ValueError( "RangeSet.construct() does not support the data= argument.\n" @@ -2964,19 +3120,9 @@ def construct(self, data=None): "as numbers, constants, or Params to the RangeSet() " "declaration" ) - self._constructed = True args, ranges = self._init_data - if any(not is_constant(arg) for arg in args): - logger.warning( - "Constructing RangeSet '%s' from non-constant data (e.g., " - "Var or mutable Param). The linkage between this RangeSet " - "and the original source data will be broken, so updating " - "the data value in the future will not be reflected in this " - "RangeSet. To suppress this warning, explicitly convert " - "the source data to a constant type (e.g., float, int, or " - "immutable Param)" % (self.name,) - ) + nonconstant_data_warning = any(not is_constant(arg) for arg in args) args = tuple(value(arg) for arg in args) if type(ranges) is not tuple: ranges = tuple(ranges) @@ -3061,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, @@ -3087,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]) @@ -3104,38 +3240,41 @@ 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 + logger.error( + "Exception raised while validating element '%s' " + "for Set %s" % (val, self.name) + ) + raise - 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) - ) + # Defer the warning about non-constant args until after the + # component has been constructed, so that the conversion of the + # component to a rational string will work (anonymous RangeSets + # will report their ranges, which aren't present until + # construction is over) + if nonconstant_data_warning: + logger.warning( + "Constructing RangeSet '%s' from non-constant data (e.g., " + "Var or mutable Param). The linkage between this RangeSet " + "and the original source data will be broken, so updating " + "the data value in the future will not be reflected in this " + "RangeSet. To suppress this warning, explicitly convert " + "the source data to a constant type (e.g., float, int, or " + "immutable Param)" % (self,) + ) timer.report() @@ -3169,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 @@ -3184,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 @@ -3224,37 +3363,43 @@ 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) - implicit = [] - sets = [] - for _set in args: - _new_set = process_setarg(_set) - sets.append(_new_set) - if _new_set is not _set or _new_set.parent_block() is None: - implicit.append(_new_set) - self._sets = tuple(sets) - self._implicit_subsets = tuple(implicit) - # We will implicitly construct all set operators if the operands - # are all constructed. + self._sets, _anonymous = zip(*(process_setarg(_set) for _set in args)) + _anonymous = tuple(filter(None, _anonymous)) + if _anonymous: + self._anonymous_sets = ComponentSet() + for _set in _anonymous: + self._anonymous_sets.update(_set) + # We will immediately construct all set operators if the operands + # are all themselves constructed. if all(_.parent_component()._constructed for _ in self._sets): self.construct() def construct(self, data=None): if self._constructed: return + self._constructed = True + timer = ConstructionTimer(self) if is_debug_set(logger): logger.debug( - "Constructing SetOperator, name=%s, from data=%r" % (self.name, data) + "Constructing SetOperator, name=%s, from data=%r" % (self, data) ) - for s in self._sets: - s.parent_component().construct() - super(SetOperator, self).construct() + + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + + # This ensures backwards compatibility by causing all scalar + # sets (including set operators) to be initialized (and + # potentially empty) after construct(). + self._getitem_when_not_present(None) + if data: deprecation_warning( "Providing construction data to SetOperator objects is " @@ -3275,7 +3420,7 @@ def construct(self, data=None): if fail: raise ValueError( "Constructing SetOperator %s with incompatible data " - "(data=%s}" % (self.name, data) + "(data=%s}" % (self, data) ) timer.report() @@ -3301,44 +3446,10 @@ def __len__(self): ) def __str__(self): - if self.parent_block() is not None: + if self._name is not None: 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: @@ -3406,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)) @@ -3895,7 +4006,7 @@ def bounds(self): @property def dimen(self): if not (FLATTEN_CROSS_PRODUCT and normalize_index.flatten): - return None + return len(self._sets) # By convention, "None" trumps UnknownSetDimen. That is, a set # product is "non-dimentioned" if any term is non-dimentioned, # even if we do not yet know the dimentionality of another term. @@ -4162,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 @@ -4173,6 +4284,7 @@ def __init__(self, **kwds): # accept (and ignore) this value. kwds.setdefault('domain', self) Set.__init__(self, **kwds) + self.construct() def get(self, val, default=None): return val if val is not Ellipsis else default @@ -4200,7 +4312,7 @@ def domain(self): return Any def __str__(self): - if self.parent_block() is not None: + if self._name is not None: return self.name return type(self).__name__ @@ -4217,10 +4329,11 @@ 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() def get(self, val, default=None): return default @@ -4245,7 +4358,7 @@ def domain(self): return EmptySet def __str__(self): - if self.parent_block() is not None: + if self._name is not None: return self.name return type(self).__name__ @@ -4348,7 +4461,11 @@ def __new__(cls, *args, **kwds): name = base_set.name else: name = cls_name - ans = RangeSet(ranges=list(range_init(None, None).ranges()), name=name) + tmp = Set() + ans = RangeSet( + ranges=list(range_init(None, None, tmp).ranges()), name=name + ) + ans._anonymous_sets = tmp._anonymous_sets if name_kwd is None and (cls_name is not None or bounds is not None): ans._name += str(ans.bounds()) else: @@ -4378,6 +4495,9 @@ def get_interval(self): # Cache the set bounds / interval _set._bounds = obj.bounds() _set._interval = obj.get_interval() + # Now that the set is constructed, override the _anonymous_sets to + # mark the set as a global set (used by process_setarg) + _set._anonymous_sets = GlobalSetBase return _set diff --git a/pyomo/core/base/set_types.py b/pyomo/core/base/set_types.py index db9fe0f796c..80c8a41ff2e 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-2022 +# 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 diff --git a/pyomo/core/base/sets.py b/pyomo/core/base/sets.py deleted file mode 100644 index cbaad33c0b8..00000000000 --- a/pyomo/core/base/sets.py +++ /dev/null @@ -1,35 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 - -__all__ = ['Set', 'set_options', 'simple_set_rule', 'SetOf'] - -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 98cc9d28c8f..afd52c111bc 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['SOSConstraint'] - import sys import logging @@ -30,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. @@ -103,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): """ @@ -514,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 @@ -551,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 160ae20f116..2c91fa65b65 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-2022 +# 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 @@ -9,18 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ('Suffix', 'active_export_suffix_generator', 'active_import_suffix_generator') - -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 @@ -93,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 @@ -106,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, @@ -343,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): """ @@ -353,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): """ @@ -363,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): """ @@ -373,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): """ @@ -411,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. @@ -426,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 @@ -460,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 e4e7f9d781c..189cce7646a 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-2022 +# 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 diff --git a/pyomo/core/base/symbolic.py b/pyomo/core/base/symbolic.py index 3fa5c168207..c1ee08dd584 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-2022 +# 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 diff --git a/pyomo/core/base/template_expr.py b/pyomo/core/base/template_expr.py deleted file mode 100644 index f8ff345a1e5..00000000000 --- a/pyomo/core/base/template_expr.py +++ /dev/null @@ -1,24 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 70d89af3798..31f5a251553 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-2022 +# 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 diff --git a/pyomo/core/base/units_container.py b/pyomo/core/base/units_container.py index dd6bb75aec9..6f2e097abd1 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-2022 +# 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 @@ -111,7 +111,7 @@ 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.numvalue import ( NumericValue, @@ -119,21 +119,11 @@ 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__) @@ -902,7 +892,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 +907,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 867a303395b..6a3885cedfb 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-2022 +# 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 diff --git a/pyomo/core/base/var.py b/pyomo/core/base/var.py index f54cea98a9e..38d1d38a864 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-2022 +# 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 @@ -9,12 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['Var', '_VarData', '_GeneralVarData', 'VarList', 'SimpleVar', 'ScalarVar'] - +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 @@ -29,7 +29,6 @@ value, is_potentially_variable, native_numeric_types, - native_types, ) from pyomo.core.base.component import ComponentData, ModelComponentFactory from pyomo.core.base.global_set import UnindexedComponent_index @@ -44,7 +43,6 @@ DefaultInitializer, BoundInitializer, ) -from pyomo.core.base.misc import apply_indexed_rule from pyomo.core.base.set import ( Reals, Binary, @@ -54,7 +52,6 @@ integer_global_set_ids, ) from pyomo.core.base.units_container import units -from pyomo.core.base.util import is_functor logger = logging.getLogger('pyomo.core') @@ -66,8 +63,7 @@ + [(_, False) for _ in integer_global_set_ids] ) _VARDATA_API = ( - # including 'domain' runs afoul of logic in Block._add_implicit_sets() - # 'domain', + 'domain', 'bounds', 'lower', 'upper', @@ -89,241 +85,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') @@ -333,7 +99,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 @@ -364,10 +130,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. @@ -390,17 +152,22 @@ def set_value(self, val, skip_validation=False): # # Check if this Var has units: assigning dimensionless # values to a variable with units should be an error - if type(val) not in native_numeric_types: - if self.parent_component()._units is not None: - _src_magnitude = value(val) + if val.__class__ in native_numeric_types: + pass + elif self.parent_component()._units is not None: + _src_magnitude = value(val) + # Note: value() could have just registered a new numeric type + if val.__class__ in native_numeric_types: + val = _src_magnitude + else: _src_units = units.get_units(val) val = units.convert_value( num_value=_src_magnitude, from_units=_src_units, to_units=self.parent_component()._units, ) - else: - val = value(val) + else: + val = value(val) if not skip_validation: if val not in self.domain: @@ -423,20 +190,28 @@ 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=True): + """Compute the value of this variable.""" + return self._value + @property def domain(self): + """Return (or set) the domain for this variable.""" return self._domain @domain.setter def domain(self, domain): try: - self._domain = SetInitializer(domain)(self.parent_block(), self.index()) + self._domain = SetInitializer(domain)( + self.parent_block(), self.index(), self + ) except: logger.error( "%s is not a valid domain. Variable domains must be an " @@ -445,9 +220,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 @@ -488,10 +296,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 @@ -513,10 +325,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 @@ -538,6 +354,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. @@ -594,8 +414,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 @@ -604,6 +453,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 @@ -613,11 +484,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 @@ -640,6 +570,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. @@ -665,7 +605,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: @@ -687,7 +636,7 @@ def __init__( dense=True, units=None, name=None, - doc=None + doc=None, ): ... def __init__(self, *args, **kwargs): @@ -763,7 +712,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 @@ -773,6 +722,10 @@ def construct(self, data=None): if is_debug_set(logger): logger.debug("Constructing Variable %s" % (self.name,)) + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + # Note: define 'index' to avoid 'variable referenced before # assignment' in the error message generated in the 'except:' # block below. @@ -818,7 +771,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: @@ -853,7 +806,7 @@ def construct(self, data=None): # We can directly set the attribute (not the # property) because the SetInitializer ensures # that the value is a proper Set. - obj._domain = self._rule_domain(block, index) + obj._domain = self._rule_domain(block, index, self) if call_bounds_rule: for index, obj in self._data.items(): obj.lower, obj.upper = self._rule_bounds(block, index) @@ -890,7 +843,7 @@ def _getitem_when_not_present(self, index): obj._index = index # We can directly set the attribute (not the property) because # the SetInitializer ensures that the value is a proper Set. - obj._domain = self._rule_domain(parent, index) + obj._domain = self._rule_domain(parent, index, self) if self._rule_bounds is not None: obj.lower, obj.upper = self._rule_bounds(parent, index) if self._rule_init is not None: @@ -936,11 +889,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 @@ -987,7 +940,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`. """ @@ -1012,17 +965,17 @@ def domain(self, domain): try: domain_rule = SetInitializer(domain) if domain_rule.constant(): - domain = domain_rule(self.parent_block(), None) + domain = domain_rule(self.parent_block(), None, self) for vardata in self.values(): vardata._domain = domain elif domain_rule.contains_indices(): parent = self.parent_block() for index in domain_rule.indices(): - self[index]._domain = domain_rule(parent, index) + self[index]._domain = domain_rule(parent, index, self) else: parent = self.parent_block() for index, vardata in self.items(): - vardata._domain = domain_rule(parent, index) + vardata._domain = domain_rule(parent, index, self) except: logger.error( "%s is not a valid domain. Variable domains must be an " @@ -1041,7 +994,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 d07668534c8..883e3f8448c 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-2022 +# 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 @@ -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 c987d0946a3..eedb3c45bf3 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-2022 +# 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 @@ -9,17 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = () - import logging from weakref import ref as weakref_ref 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 @@ -186,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): @@ -195,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): @@ -204,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): @@ -213,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 2c42dfa57c8..005bfc38a1f 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-2022 +# 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 @@ -9,17 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = () - import logging from weakref import ref as weakref_ref 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 @@ -234,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): @@ -243,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): @@ -252,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): @@ -261,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 5e30fceeeaa..6f200081741 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-2022 +# 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 @@ -9,14 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -# -# The definition of __all__ is a bit funky here, because we want to -# expose symbols in pyomo.core.expr.current that are not included in -# pyomo.core.expr. The idea is that pyomo.core.expr provides symbols -# that are used by general users, but pyomo.core.expr.current provides -# symbols that are used by developers. -# - from . import ( numvalue, visitor, @@ -56,6 +48,7 @@ # BooleanValue, BooleanConstant, + BooleanExpression, BooleanExpressionBase, # UnaryBooleanExpression, @@ -70,6 +63,8 @@ ExactlyExpression, AtMostExpression, AtLeastExpression, + AllDifferentExpression, + CountIfExpression, # land, lnot, @@ -79,6 +74,8 @@ exactly, atleast, atmost, + all_different, + count_if, implies, ) from .numeric_expr import ( @@ -205,3 +202,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 b74bbff4e3c..6e2066afcc5 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-2022 +# 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 @@ -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 b9c8ece29c8..002ec91be9d 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-2022 +# 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 diff --git a/pyomo/core/expr/calculus/__init__.py b/pyomo/core/expr/calculus/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/core/expr/calculus/__init__.py +++ b/pyomo/core/expr/calculus/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/core/expr/calculus/derivatives.py b/pyomo/core/expr/calculus/derivatives.py index c9787b0e309..69fe4969938 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-2022 +# 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 @@ -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 0e3ba3cc2b2..fe3eddf1490 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-2022 +# 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 diff --git a/pyomo/core/expr/calculus/diff_with_sympy.py b/pyomo/core/expr/calculus/diff_with_sympy.py index 32cf60547ec..ab62fa3c307 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-2022 +# 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 diff --git a/pyomo/core/expr/cnf_walker.py b/pyomo/core/expr/cnf_walker.py index a7bf61bef5a..8add9d23ef9 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-2022 +# 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 @@ -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 ec8d56896b8..fc4bf17ec03 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-2022 +# 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 @@ -66,6 +66,11 @@ 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 _generic_expression_handler(): return handle_expression @@ -79,6 +84,7 @@ def _generic_expression_handler(): handler[AbsExpression] = handle_unary_expression handler[NPV_AbsExpression] = handle_unary_expression handler[RangedExpression] = handle_expression +handler[list] = handle_sequence class PrefixVisitor(StreamBasedExpressionVisitor): @@ -97,19 +103,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 +174,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 +206,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 +226,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 +241,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 +276,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 +311,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 daf86c7afc8..b6e2e697982 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-2022 +# 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 @@ -9,9 +9,9 @@ # 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 TO_STRING_VERBOSE = False @@ -32,7 +32,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 +60,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,7 +76,7 @@ class OperatorAssociativity(enum.IntEnum): LEFT_TO_RIGHT = 1 -class ExpressionType(enum.Enum): +class ExpressionType(enums.Enum): NUMERIC = 0 RELATIONAL = 1 LOGICAL = 2 diff --git a/pyomo/core/expr/expr_errors.py b/pyomo/core/expr/expr_errors.py index e33a6cbbbd7..b0ad816d725 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-2022 +# 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 diff --git a/pyomo/core/expr/logical_expr.py b/pyomo/core/expr/logical_expr.py index f2d3e110166..9519b02a43b 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-2022 +# 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 @@ -10,7 +10,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - import types from itertools import islice @@ -36,6 +35,7 @@ from .base import ExpressionBase from .boolean_value import BooleanValue, BooleanConstant from .expr_common import _and, _or, _equiv, _inv, _xor, _impl, ExpressionType +from .numeric_expr import NumericExpression import operator @@ -183,12 +183,62 @@ def _flattened(args): yield arg +def _flattened_boolean_args(args): + """Flatten any potentially indexed arguments and check that they are + Boolean-valued.""" + for arg in args: + if arg.__class__ in native_types: + myiter = (arg,) + elif isinstance(arg, (types.GeneratorType, list)): + myiter = arg + elif arg.is_indexed(): + myiter = arg.values() + else: + myiter = (arg,) + for _argdata in myiter: + if _argdata.__class__ in native_logical_types: + yield _argdata + elif hasattr(_argdata, 'is_logical_type') and _argdata.is_logical_type(): + yield _argdata + elif isinstance(_argdata, BooleanValue): + yield _argdata + else: + raise ValueError( + "Non-Boolean-valued argument '%s' encountered when constructing " + "expression of Boolean arguments" % arg + ) + + +def _flattened_numeric_args(args): + """Flatten any potentially indexed arguments and check that they are + numeric.""" + for arg in args: + if arg.__class__ in native_types: + myiter = (arg,) + elif isinstance(arg, (types.GeneratorType, list)): + myiter = arg + elif arg.is_indexed(): + myiter = arg.values() + else: + myiter = (arg,) + for _argdata in myiter: + if _argdata.__class__ in native_numeric_types: + yield _argdata + elif hasattr(_argdata, 'is_numeric_type') and _argdata.is_numeric_type(): + yield _argdata + else: + raise ValueError( + "Non-numeric argument '%s' encountered when constructing " + "expression with numeric arguments" % arg + ) + + def land(*args): """ Construct an AndExpression between passed arguments. """ result = AndExpression([]) - for argdata in _flattened(args): + for argdata in _flattened_boolean_args(args): result = result.add(argdata) return result @@ -198,7 +248,7 @@ def lor(*args): Construct an OrExpression between passed arguments. """ result = OrExpression([]) - for argdata in _flattened(args): + for argdata in _flattened_boolean_args(args): result = result.add(argdata) return result @@ -211,7 +261,7 @@ def exactly(n, *args): Usage: exactly(2, m.Y1, m.Y2, m.Y3, ...) """ - result = ExactlyExpression([n] + list(_flattened(args))) + result = ExactlyExpression([n] + list(_flattened_boolean_args(args))) return result @@ -223,7 +273,7 @@ def atmost(n, *args): Usage: atmost(2, m.Y1, m.Y2, m.Y3, ...) """ - result = AtMostExpression([n] + list(_flattened(args))) + result = AtMostExpression([n] + list(_flattened_boolean_args(args))) return result @@ -235,10 +285,30 @@ def atleast(n, *args): Usage: atleast(2, m.Y1, m.Y2, m.Y3, ...) """ - result = AtLeastExpression([n] + list(_flattened(args))) + result = AtLeastExpression([n] + list(_flattened_boolean_args(args))) return result +def all_different(*args): + """Creates a new AllDifferentExpression + + Requires all of the arguments to take on a different value + + Usage: all_different(m.X1, m.X2, ...) + """ + return AllDifferentExpression(list(_flattened_numeric_args(args))) + + +def count_if(*args): + """Creates a new CountIfExpression + + Counts the number of True-valued arguments + + Usage: count_if(m.Y1, m.Y2, ...) + """ + return CountIfExpression(list(_flattened_boolean_args(args))) + + class UnaryBooleanExpression(BooleanExpression): """ Abstract class for single-argument logical expressions. @@ -511,4 +581,54 @@ def _apply_operation(self, result): return sum(result[1:]) >= result[0] +class AllDifferentExpression(NaryBooleanExpression): + """ + Logical expression that all of the N child statements have different values. + All arguments are expected to be discrete-valued. + """ + + __slots__ = () + + PRECEDENCE = None + + def getname(self, *arg, **kwd): + return 'all_different' + + def _to_string(self, values, verbose, smap): + return "all_different(%s)" % (", ".join(values)) + + def _apply_operation(self, result): + last = None + # we know these are integer-valued, so we can just sort them an make + # sure that no adjacent pairs have the same value. + for val in sorted(result): + if last == val: + return False + last = val + return True + + +class CountIfExpression(NumericExpression): + """ + Logical expression that returns the number of True child statements. + All arguments are expected to be Boolean-valued. + """ + + __slots__ = () + PRECEDENCE = None + + # NumericExpression assumes binary operator, so we have to override. + def nargs(self): + return len(self._args_) + + def getname(self, *arg, **kwd): + return 'count_if' + + def _to_string(self, values, verbose, smap): + return "count_if(%s)" % (", ".join(values)) + + def _apply_operation(self, result): + return sum(value(r) for r in result) + + special_boolean_atom_types = {ExactlyExpression, AtMostExpression, AtLeastExpression} diff --git a/pyomo/core/expr/ndarray.py b/pyomo/core/expr/ndarray.py index fcbe5477a08..41514c91153 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-2022 +# 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 diff --git a/pyomo/core/expr/numeric_expr.py b/pyomo/core/expr/numeric_expr.py index 0a300474790..7ee52263507 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-2022 +# 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 @@ -10,13 +10,13 @@ # ___________________________________________________________________________ import collections -import enum import logging import math import operator logger = logging.getLogger('pyomo.core') +from pyomo.common import enums from pyomo.common.dependencies import attempt_import from pyomo.common.deprecation import deprecated, relocated_module_attribute from pyomo.common.errors import PyomoException, DeveloperError @@ -216,14 +216,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) + return _base.getname(*args, **kwargs) else: return str(type(self)) @@ -722,7 +722,7 @@ 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 ' + 'longer supported (this violates the immutability ' 'promise for Pyomo5 expression trees).', version='6.4.3', ) @@ -1094,7 +1094,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): @@ -1234,9 +1234,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 +1255,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 @@ -1298,8 +1300,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 +1333,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 +1344,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 @@ -1631,7 +1638,7 @@ def _decompose_linear_terms(expr, multiplier=1): # ------------------------------------------------------- -class ARG_TYPE(enum.IntEnum): +class ARG_TYPE(enums.IntEnum): MUTABLE = -2 ASNUMERIC = -1 INVALID = 0 @@ -1820,7 +1827,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 +1878,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 +1936,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 +1979,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 +1991,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 +2038,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 +2081,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 +2288,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 +2304,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 +2385,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 +2401,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 +2485,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 +2501,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 diff --git a/pyomo/core/expr/numvalue.py b/pyomo/core/expr/numvalue.py index 6c605b080a3..31349b13c18 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-2022 +# 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 @@ -9,21 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ( - 'value', - 'is_constant', - 'is_fixed', - 'is_variable_type', - 'is_potentially_variable', - 'NumericValue', - 'ZeroConstant', - 'native_numeric_types', - 'native_types', - 'nonpyomo_leaf_types', - 'polynomial_degree', -) - -import collections import sys import logging @@ -34,7 +19,6 @@ ) from pyomo.core.expr.expr_common import ExpressionType from pyomo.core.expr.numeric_expr import NumericValue -import pyomo.common.numeric_types as _numeric_types # TODO: update Pyomo to import these objects from common.numeric_types # (and not from here) @@ -44,7 +28,7 @@ native_numeric_types, native_integer_types, native_logical_types, - pyomo_constant_types, + _pyomo_constant_types, check_if_numeric_type, value, ) @@ -60,6 +44,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', @@ -101,7 +95,7 @@ ##------------------------------------------------------------------------ -class NonNumericValue(object): +class NonNumericValue(PyomoObject): """An object that contains a non-numeric value Constructor Arguments: @@ -116,6 +110,18 @@ def __init__(self, value): def __str__(self): return str(self.value) + def __repr__(self): + return repr(self.value) + + def __call__(self, exception=None): + return self.value + + def is_constant(self): + return True + + def is_fixed(self): + return True + nonpyomo_leaf_types.add(NonNumericValue) @@ -426,7 +432,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 6e4831d5c0c..c80fdd4930a 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-2022 +# 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 diff --git a/pyomo/core/expr/symbol_map.py b/pyomo/core/expr/symbol_map.py index ab497c217a8..4364e54a608 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-2022 +# 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 @@ -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): diff --git a/pyomo/core/expr/sympy_tools.py b/pyomo/core/expr/sympy_tools.py index 7b494a610cd..d751ca35e5f 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-2022 +# 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 @@ -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 2c72f8bcfbc..7dc24f3ccf4 100644 --- a/pyomo/core/expr/taylor_series.py +++ b/pyomo/core/expr/taylor_series.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import identify_variables, value, differentiate import logging import math @@ -32,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 fd6294f2289..be5978e5fb1 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-2022 +# 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 @@ -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__) @@ -116,18 +121,10 @@ def _to_string(self, values, verbose, smap): 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): @@ -258,8 +255,8 @@ 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 @@ -273,7 +270,7 @@ def _to_string(self, values, verbose, smap): return "%s.%s" % (values[0], attr) def _resolve_template(self, args): - return getattr(*tuple(args)) + return getattr(*args) class Numeric_GetAttrExpression(GetAttrExpression, NumericExpression): @@ -471,6 +468,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,18 +503,26 @@ def _compute_polynomial_degree(self, result): def _apply_operation(self, result): return sum(result) - def _to_string(self, values, verbose, smap): + def to_string(self, verbose=None, smap=None): ans = '' - val = values[0] + 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) + iterGroup[0]._set.to_string(verbose=verbose, smap=smap) if hasattr(iterGroup[0]._set, 'to_string') - else str(iterGroup[0]._set) + else ( + smap.getSymbol(iterGroup[0]._set) + if smap is not None + else str(iterGroup[0]._set) + ) ), ) for iterGroup in self._iters @@ -521,7 +535,19 @@ def _to_string(self, values, verbose, smap): 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): @@ -621,7 +647,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,20 +678,8 @@ 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 @@ -676,7 +690,7 @@ def beforeChild(node, child, child_idx): else: return True, None - def exitNode(node, args): + 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)): @@ -686,12 +700,25 @@ 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): @@ -854,19 +881,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) diff --git a/pyomo/core/expr/visitor.py b/pyomo/core/expr/visitor.py index f1cd3b7bde6..98da9fb963e 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-2022 +# 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 @@ -679,6 +679,11 @@ def _nonrecursive_walker_loop(self, ptr): ptr = ptr[0] +@deprecated( + "The SimpleExpressionVisitor is deprecated. " + "Please use the StreamBasedExpressionVisitor instead.", + version='6.9.0.dev0', +) class SimpleExpressionVisitor(object): """ Note: @@ -736,6 +741,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 +907,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 +939,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 +949,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: @@ -1244,9 +1258,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 +1348,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 +1388,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 +1396,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. + + """ + 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 - if node.is_variable_type(): - 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 + 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 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): + +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 +1503,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 +1521,27 @@ def identify_variables(expr, include_fixed=True): # ===================================================== -class _MutableParamVisitor(SimpleExpressionVisitor): +class IdentifyMutableParamVisitor(IdentifyVariableVisitor): def __init__(self): - self.seen = set() - - def visit(self, node): - if node.__class__ in nonpyomo_leaf_types: - return + # Hide the IdentifyVariableVisitor API (not relevant here) + super().__init__() - # 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,9 +1555,10 @@ 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 +1660,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 +1669,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 +1709,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'): 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 28a329109fc..326a132b71d 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-2022 +# 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 @@ -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 2c0af56bc10..0653868e109 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-2022 +# 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 @@ -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 fd779578fc4..8ba332e5545 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-2022 +# 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 diff --git a/pyomo/core/kernel/component_map.py b/pyomo/core/kernel/component_map.py deleted file mode 100644 index 501854ad972..00000000000 --- a/pyomo/core/kernel/component_map.py +++ /dev/null @@ -1,19 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 b0eb3507347..00000000000 --- a/pyomo/core/kernel/component_set.py +++ /dev/null @@ -1,19 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 730c072d1b7..ca3765d686a 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-2022 +# 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 @@ -150,6 +150,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 +243,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 +355,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 +466,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 +596,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 +644,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 +660,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 +772,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 +905,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 +953,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 +967,28 @@ 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:: - 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 7c7969cb025..ed877e8af92 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-2022 +# 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 @@ -160,6 +160,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): """ diff --git a/pyomo/core/kernel/container_utils.py b/pyomo/core/kernel/container_utils.py index 7f3329aadb3..7ed2fd9e753 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-2022 +# 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 @@ -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 b86d9c5b8f2..ae23044f8ed 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-2022 +# 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 diff --git a/pyomo/core/kernel/expression.py b/pyomo/core/kernel/expression.py index b375a6a89fc..b25c2d65077 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-2022 +# 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 @@ -92,7 +92,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 43846673838..4783a2d3ec6 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-2022 +# 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 diff --git a/pyomo/core/kernel/homogeneous_container.py b/pyomo/core/kernel/homogeneous_container.py index 22a70e1edff..edec98e9736 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-2022 +# 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 diff --git a/pyomo/core/kernel/list_container.py b/pyomo/core/kernel/list_container.py index 05116797f3a..d60b0c7678d 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-2022 +# 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 diff --git a/pyomo/core/kernel/matrix_constraint.py b/pyomo/core/kernel/matrix_constraint.py index 1dc0fa7ddc3..ac0ec8e832d 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-2022 +# 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 diff --git a/pyomo/core/kernel/objective.py b/pyomo/core/kernel/objective.py index c25c86d3c09..ac6f22d07d3 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-2022 +# 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 @@ -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 1d22072435d..d4dd6336c69 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-2022 +# 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 diff --git a/pyomo/core/kernel/piecewise_library/__init__.py b/pyomo/core/kernel/piecewise_library/__init__.py index d275b52367e..605eaffba59 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-2022 +# 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 @@ -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 f00e57c199d..1443560025b 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-2022 +# 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 @@ -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 f1ea67e8d4b..b409f6dcddb 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-2022 +# 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 @@ -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 e65502b1a12..23975d87596 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-2022 +# 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 diff --git a/pyomo/core/kernel/set_types.py b/pyomo/core/kernel/set_types.py index efe5965946a..5915f0d64b3 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-2022 +# 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 diff --git a/pyomo/core/kernel/sos.py b/pyomo/core/kernel/sos.py index cb8d8ea4930..1845343f526 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-2022 +# 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 diff --git a/pyomo/core/kernel/suffix.py b/pyomo/core/kernel/suffix.py index 77079364703..56e13a371a3 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-2022 +# 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 diff --git a/pyomo/core/kernel/tuple_container.py b/pyomo/core/kernel/tuple_container.py index f717fe0350a..83aab49e5db 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-2022 +# 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 diff --git a/pyomo/core/kernel/variable.py b/pyomo/core/kernel/variable.py index ff54bcb2fca..61324b3dc0f 100644 --- a/pyomo/core/kernel/variable.py +++ b/pyomo/core/kernel/variable.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/core/plugins/__init__.py b/pyomo/core/plugins/__init__.py index f763881c50c..c01711f780f 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-2022 +# 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 @@ -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 7d37c706542..f4e05ccd36f 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-2022 +# 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 @@ -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 6906b033aab..31c1107d692 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-2022 +# 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 @@ -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 cfb1c5e144f..35729e76517 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-2022 +# 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 diff --git a/pyomo/core/plugins/transform/eliminate_fixed_vars.py b/pyomo/core/plugins/transform/eliminate_fixed_vars.py index 1048b957e08..934228afd7c 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-2022 +# 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 @@ -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 e0cc463e238..99291c2227c 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-2022 +# 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 @@ -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 8fe14318669..82ec546e593 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-2022 +# 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 @@ -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 a7667fc028a..86338d17f88 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-2022 +# 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 diff --git a/pyomo/core/plugins/transform/logical_to_linear.py b/pyomo/core/plugins/transform/logical_to_linear.py index f4107b8a32c..da69ca113bd 100644 --- a/pyomo/core/plugins/transform/logical_to_linear.py +++ b/pyomo/core/plugins/transform/logical_to_linear.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """Transformation from BooleanVar and LogicalConstraint to Binary and Constraints.""" @@ -18,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 ( @@ -89,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(): @@ -274,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 """ @@ -361,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..82f27a879ea --- /dev/null +++ b/pyomo/core/plugins/transform/lp_dual.py @@ -0,0 +1,260 @@ +# ___________________________________________________________________________ +# +# 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.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 99c1d21c9a0..f48f6a686fe 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-2022 +# 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 @@ -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 b32b7b1efc0..d123e68cb2e 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-2022 +# 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 diff --git a/pyomo/core/plugins/transform/radix_linearization.py b/pyomo/core/plugins/transform/radix_linearization.py index b7ff3375a76..3cfde28db3c 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-2022 +# 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 @@ -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 06dd2faba77..40cf74ddbcc 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-2022 +# 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 diff --git a/pyomo/core/plugins/transform/scaling.py b/pyomo/core/plugins/transform/scaling.py index 0883455f9de..d449d479475 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-2022 +# 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 @@ -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 54df13fc49d..b93ba98d9f4 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-2022 +# 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 @@ -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 bba8adfbc0f..9719b1f38d9 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-2022 +# 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 diff --git a/pyomo/core/pyomoobject.py b/pyomo/core/pyomoobject.py index 692db444f84..3bf6de37489 100644 --- a/pyomo/core/pyomoobject.py +++ b/pyomo/core/pyomoobject.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/core/staleflag.py b/pyomo/core/staleflag.py index 7d0dddef0dd..da90032a03c 100644 --- a/pyomo/core/staleflag.py +++ b/pyomo/core/staleflag.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/core/tests/__init__.py b/pyomo/core/tests/__init__.py index 0dc08cc5aea..761a6e6c44c 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-2022 +# 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 diff --git a/pyomo/core/tests/data/__init__.py b/pyomo/core/tests/data/__init__.py index 21b3abf0760..a73865ee112 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-2022 +# 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 diff --git a/pyomo/core/tests/data/test_odbc_ini.py b/pyomo/core/tests/data/test_odbc_ini.py index e7152181645..43584fe3ca9 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-2022 +# 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 diff --git a/pyomo/core/tests/diet/__init__.py b/pyomo/core/tests/diet/__init__.py index 3e98344ba07..717247051c4 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-2022 +# 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 diff --git a/pyomo/core/tests/diet/test_diet.py b/pyomo/core/tests/diet/test_diet.py index d92f0a024ba..9e11907179e 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/__init__.py b/pyomo/core/tests/examples/__init__.py index 602516fcb56..c5ecc4ee437 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/pmedian.py b/pyomo/core/tests/examples/pmedian.py index 5176f8bad18..c476f01bd17 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/pmedian1.py b/pyomo/core/tests/examples/pmedian1.py index 5aeec502f7c..8e11383116b 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/pmedian2.py b/pyomo/core/tests/examples/pmedian2.py index 8a908f7d661..88a9666fe41 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/pmedian4.py b/pyomo/core/tests/examples/pmedian4.py index 98dd90f3e8f..101ee3e7c46 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/pmedian_concrete.py b/pyomo/core/tests/examples/pmedian_concrete.py new file mode 100644 index 00000000000..a6a1859df23 --- /dev/null +++ b/pyomo/core/tests/examples/pmedian_concrete.py @@ -0,0 +1,70 @@ +# ___________________________________________________________________________ +# +# 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 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 fdb9cc571bf..72e3d2b4599 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/test_kernel_examples.py b/pyomo/core/tests/examples/test_kernel_examples.py index 0434d9127a3..61d0fa2527d 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/test_pyomo.py b/pyomo/core/tests/examples/test_pyomo.py index 64c195c0ab4..2d3a39ebdda 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-2022 +# 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 diff --git a/pyomo/core/tests/examples/test_tutorials.py b/pyomo/core/tests/examples/test_tutorials.py index 3a74c1ca142..c8de003007e 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-2022 +# 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 diff --git a/pyomo/core/tests/transform/__init__.py b/pyomo/core/tests/transform/__init__.py index df59aa21988..f34c7624e25 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-2022 +# 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 diff --git a/pyomo/core/tests/transform/test_add_slacks.py b/pyomo/core/tests/transform/test_add_slacks.py index a3698b7d529..b395237b8e4 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-2022 +# 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 @@ -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 1cb4e886956..94798535e9c 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-2022 +# 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 @@ -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 7c3f17fcfec..cd1f26417a7 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/__init__.py b/pyomo/core/tests/unit/__init__.py index 65e82b81c0c..85ece8d8cd5 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/__init__.py b/pyomo/core/tests/unit/kernel/__init__.py index ff387efbd03..e5231e0f859 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_block.py b/pyomo/core/tests/unit/kernel/test_block.py index a22ed4fb4b5..b21771653bb 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_component_map.py b/pyomo/core/tests/unit/kernel/test_component_map.py index 6d19743c3fe..3fb8b99a9a3 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_component_set.py b/pyomo/core/tests/unit/kernel/test_component_set.py index 30f2cf72716..38f17a702c1 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_conic.py b/pyomo/core/tests/unit/kernel/test_conic.py index 352976a2410..bd97c13fc2e 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-2022 +# 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 @@ -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 f2f219cc66f..97832dd8bca 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_dict_container.py b/pyomo/core/tests/unit/kernel/test_dict_container.py index e6b6f8d7aab..6ae25362bb2 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_expression.py b/pyomo/core/tests/unit/kernel/test_expression.py index 85f8c331a46..39d3eaa463c 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_kernel.py b/pyomo/core/tests/unit/kernel/test_kernel.py index fbff295881a..b34bcdeaadb 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_list_container.py b/pyomo/core/tests/unit/kernel/test_list_container.py index 9e3ada739b2..a4641f83295 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_matrix_constraint.py b/pyomo/core/tests/unit/kernel/test_matrix_constraint.py index c986e5eda96..24a2915f224 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_objective.py b/pyomo/core/tests/unit/kernel/test_objective.py index f60ff9bdb49..810218f1dc2 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_parameter.py b/pyomo/core/tests/unit/kernel/test_parameter.py index 04dc08f095f..469ed9fbe8c 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_piecewise.py b/pyomo/core/tests/unit/kernel/test_piecewise.py index 2c236c0dd12..e376bdce8b3 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-2022 +# 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 @@ -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 9410425d405..b1cb67a96f8 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_suffix.py b/pyomo/core/tests/unit/kernel/test_suffix.py index c4c75278d50..2a73888c2d3 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_tuple_container.py b/pyomo/core/tests/unit/kernel/test_tuple_container.py index 0b45c36b299..c016c5fc789 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/kernel/test_variable.py b/pyomo/core/tests/unit/kernel/test_variable.py index e360240f3b2..181eb15c972 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_action.py b/pyomo/core/tests/unit/test_action.py index 5db6f165854..3481c90a021 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_block.py b/pyomo/core/tests/unit/test_block.py index f68850d9421..36e779943a3 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-2022 +# 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 @@ -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')) @@ -2626,19 +2627,16 @@ def test_pprint(self): m = HierarchicalModel().model buf = StringIO() m.pprint(ostream=buf) - ref = """3 Set Declarations + ref = """2 Set Declarations a1_IDX : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 2 : {5, 4} a3_IDX : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 2 : {6, 7} - a_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} 3 Block Declarations - a : Size=3, Index=a_index, Active=True + a : Size=3, Index={1, 2, 3}, Active=True a[1] : Active=True 2 Block Declarations c : Size=2, Index=a1_IDX, Active=True @@ -2668,9 +2666,8 @@ def test_pprint(self): c : Size=1, Index=None, Active=True 0 Declarations: -6 Declarations: a1_IDX a3_IDX c a_index a b +5 Declarations: a1_IDX a3_IDX c a b """ - print(buf.getvalue()) self.assertEqual(ref, buf.getvalue()) @unittest.skipIf(not 'glpk' in solvers, "glpk solver is not available") @@ -2979,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.') @@ -3056,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 = [] @@ -3407,6 +3465,97 @@ def test_deduplicate_component_data_iterindex(self): ], ) + def test_private_data(self): + m = ConcreteModel() + m.b = Block() + m.b.b = Block([1, 2]) + + mfe = m.private_data() + self.assertIsInstance(mfe, dict) + self.assertEqual(len(mfe), 0) + self.assertEqual(len(m._private_data), 1) + self.assertIn('pyomo.core.tests.unit.test_block', m._private_data) + self.assertIs(mfe, m._private_data['pyomo.core.tests.unit.test_block']) + + with self.assertRaisesRegex( + ValueError, + "All keys in the 'private_data' dictionary must " + "be substrings of the caller's module name. " + "Received 'no mice here' when calling private_data on Block " + "'b'.", + ): + mfe2 = m.b.private_data('no mice here') + + mfe3 = m.b.b[1].private_data('pyomo.core.tests') + self.assertIsInstance(mfe3, dict) + self.assertEqual(len(mfe3), 0) + self.assertIsInstance(m.b.b[1]._private_data, dict) + self.assertEqual(len(m.b.b[1]._private_data), 1) + self.assertIn('pyomo.core.tests', m.b.b[1]._private_data) + self.assertIs(mfe3, m.b.b[1]._private_data['pyomo.core.tests']) + mfe3['there are cookies'] = 'but no mice' + + mfe4 = m.b.b[1].private_data('pyomo.core.tests') + self.assertIs(mfe4, mfe3) + + def test_register_private_data(self): + _save = Block._private_data_initializers + + Block._private_data_initializers = pdi = _save.copy() + pdi.clear() + try: + self.assertEqual(len(pdi), 0) + b = Block(concrete=True) + ps = b.private_data() + self.assertEqual(ps, {}) + self.assertEqual(len(pdi), 1) + finally: + Block._private_data_initializers = _save + + def init(): + return {'a': None, 'b': 1} + + Block._private_data_initializers = pdi = _save.copy() + pdi.clear() + try: + self.assertEqual(len(pdi), 0) + Block.register_private_data_initializer(init) + self.assertEqual(len(pdi), 1) + + b = Block(concrete=True) + ps = b.private_data() + self.assertEqual(ps, {'a': None, 'b': 1}) + self.assertEqual(len(pdi), 1) + finally: + Block._private_data_initializers = _save + + Block._private_data_initializers = pdi = _save.copy() + pdi.clear() + try: + Block.register_private_data_initializer(init) + self.assertEqual(len(pdi), 1) + Block.register_private_data_initializer(init, 'pyomo') + self.assertEqual(len(pdi), 2) + + with self.assertRaisesRegex( + RuntimeError, + r"Duplicate initializer registration for 'private_data' " + r"dictionary \(scope=pyomo.core.tests.unit.test_block\)", + ): + Block.register_private_data_initializer(init) + + with self.assertRaisesRegex( + ValueError, + r"'private_data' scope must be substrings of the caller's " + r"module name. Received 'invalid' when calling " + r"register_private_data_initializer\(\).", + ): + Block.register_private_data_initializer(init, 'invalid') + + self.assertEqual(len(pdi), 2) + finally: + Block._private_data_initializers = _save + if __name__ == "__main__": unittest.main() diff --git a/pyomo/core/tests/unit/test_block_model.py b/pyomo/core/tests/unit/test_block_model.py index ed751e96fc5..b4cf34e7516 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_bounds.py b/pyomo/core/tests/unit/test_bounds.py index c2c6a69bdd2..23554f555c9 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_check.py b/pyomo/core/tests/unit/test_check.py index 5b2d5408fd5..e61e3998fb7 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_compare.py b/pyomo/core/tests/unit/test_compare.py index 8b8538a8656..7c3536bc084 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-2022 +# 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 @@ -165,17 +165,11 @@ def test_expr_if(self): 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, diff --git a/pyomo/core/tests/unit/test_component.py b/pyomo/core/tests/unit/test_component.py index b4408fe8c54..b12db9af047 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-2022 +# 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 @@ -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 1c9b3c444bf..1250a58b240 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-2022 +# 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 @@ -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): @@ -601,31 +597,26 @@ def test_generate_cuid_string_map(self): ComponentUID.generate_cuid_string_map(model, repr_version=1), ComponentUID.generate_cuid_string_map(model), ) - self.assertEqual(len(cuids[0]), 29) - self.assertEqual(len(cuids[1]), 29) + self.assertEqual(len(cuids[0]), 24) + self.assertEqual(len(cuids[1]), 24) for obj in [ model, model.x, model.y, - model.y_index, model.y[1], model.y[2], model.V, - model.V_index, model.V['a', 'b'], model.V[1, '2'], model.V[3, 4], model.b, model.b.z, - model.b.z_index, model.b.z[1], model.b.z['2'], getattr(model.b, '.H'), - getattr(model.b, '.H_index'), getattr(model.b, '.H')['a'], getattr(model.b, '.H')[2], model.B, - model.B_index, model.B['a'], getattr(model.B['a'], '.k'), model.B[2], @@ -642,23 +633,20 @@ def test_generate_cuid_string_map(self): ), ComponentUID.generate_cuid_string_map(model, descend_into=False), ) - self.assertEqual(len(cuids[0]), 18) - self.assertEqual(len(cuids[1]), 18) + self.assertEqual(len(cuids[0]), 15) + self.assertEqual(len(cuids[1]), 15) for obj in [ model, model.x, model.y, - model.y_index, model.y[1], model.y[2], model.V, - model.V_index, model.V['a', 'b'], model.V[1, '2'], model.V[3, 4], model.b, model.B, - model.B_index, model.B['a'], model.B[2], model.component('c tuple')[(1,)], @@ -1256,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 bd90972fee2..07c7eb3af8e 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-2022 +# 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 @@ -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 a9bd75f05c7..9083c5cf7f9 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_connector.py b/pyomo/core/tests/unit/test_connector.py index 1dde9f3af24..3871f5f372a 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-2022 +# 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 @@ -301,7 +301,7 @@ def test_expand_single_scalar(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=1, Index='c.expanded_index', Active=True + """c.expanded : Size=1, Index={1}, Active=True Key : Lower : Body : Upper : Active 1 : 1.0 : x : 1.0 : True """, @@ -336,7 +336,7 @@ def test_expand_scalar(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=2, Index='c.expanded_index', Active=True + """c.expanded : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 1.0 : x : 1.0 : True 2 : 1.0 : y : 1.0 : True @@ -372,7 +372,7 @@ def test_expand_expression(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=2, Index='c.expanded_index', Active=True + """c.expanded : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 1.0 : - x : 1.0 : True 2 : 1.0 : 1 + y : 1.0 : True @@ -408,7 +408,7 @@ def test_expand_indexed(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=3, Index='c.expanded_index', Active=True + """c.expanded : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 1.0 : x[1] : 1.0 : True 2 : 1.0 : x[2] : 1.0 : True @@ -451,7 +451,7 @@ def test_expand_empty_scalar(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=2, Index='c.expanded_index', Active=True + """c.expanded : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x - 'ECON.auto.x' : 0.0 : True 2 : 0.0 : y - 'ECON.auto.y' : 0.0 : True @@ -488,7 +488,7 @@ def test_expand_empty_expression(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=2, Index='c.expanded_index', Active=True + """c.expanded : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : - x - 'ECON.auto.x' : 0.0 : True 2 : 0.0 : 1 + y - 'ECON.auto.y' : 0.0 : True @@ -533,7 +533,7 @@ def test_expand_empty_indexed(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=3, Index='c.expanded_index', Active=True + """c.expanded : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x[1] - 'ECON.auto.x'[1] : 0.0 : True 2 : 0.0 : x[2] - 'ECON.auto.x'[2] : 0.0 : True @@ -590,7 +590,7 @@ def test_expand_multiple_empty_indexed(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=3, Index='c.expanded_index', Active=True + """c.expanded : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x[1] - 'ECON1.auto.x'[1] : 0.0 : True 2 : 0.0 : x[2] - 'ECON1.auto.x'[2] : 0.0 : True @@ -602,7 +602,7 @@ def test_expand_multiple_empty_indexed(self): m.component('d.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """d.expanded : Size=3, Index='d.expanded_index', Active=True + """d.expanded : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : 'ECON2.auto.x'[1] - 'ECON1.auto.x'[1] : 0.0 : True 2 : 0.0 : 'ECON2.auto.x'[2] - 'ECON1.auto.x'[2] : 0.0 : True @@ -653,7 +653,7 @@ def test_expand_multiple_indexed(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=3, Index='c.expanded_index', Active=True + """c.expanded : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x[1] - a2[1] : 0.0 : True 2 : 0.0 : x[2] - a2[2] : 0.0 : True @@ -665,7 +665,7 @@ def test_expand_multiple_indexed(self): m.component('d.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """d.expanded : Size=3, Index='d.expanded_index', Active=True + """d.expanded : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : a1[1] - a2[1] : 0.0 : True 2 : 0.0 : a1[2] - a2[2] : 0.0 : True @@ -734,7 +734,7 @@ def test_expand_implicit_indexed(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=3, Index='c.expanded_index', Active=True + """c.expanded : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x[1] - a2[1] : 0.0 : True 2 : 0.0 : x[2] - a2[2] : 0.0 : True @@ -746,7 +746,7 @@ def test_expand_implicit_indexed(self): m.component('d.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """d.expanded : Size=3, Index='d.expanded_index', Active=True + """d.expanded : Size=3, Index={1, 2, 3}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : 'ECON2.auto.x'[1] - x[1] : 0.0 : True 2 : 0.0 : 'ECON2.auto.x'[2] - x[2] : 0.0 : True @@ -789,7 +789,7 @@ def test_varlist_aggregator(self): m.component('c.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """c.expanded : Size=2, Index='c.expanded_index', Active=True + """c.expanded : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : flow[1] - 'ECON1.auto.flow' : 0.0 : True 2 : 0.0 : phase - 'ECON1.auto.phase' : 0.0 : True @@ -800,7 +800,7 @@ def test_varlist_aggregator(self): m.component('d.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """d.expanded : Size=2, Index='d.expanded_index', Active=True + """d.expanded : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : 'ECON2.auto.flow' - flow[2] : 0.0 : True 2 : 0.0 : 'ECON2.auto.phase' - phase : 0.0 : True @@ -844,7 +844,7 @@ def test_indexed_connector(self): m.component('eq.expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """eq.expanded : Size=1, Index='eq.expanded_index', Active=True + """eq.expanded : Size=1, Index={1}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x - y : 0.0 : True """, diff --git a/pyomo/core/tests/unit/test_deprecation.py b/pyomo/core/tests/unit/test_deprecation.py index 9adf2de26cd..7d718a4bd2a 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_derivs.py b/pyomo/core/tests/unit/test_derivs.py index 7db284cb29a..6a4fc6814b3 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_dict_objects.py b/pyomo/core/tests/unit/test_dict_objects.py index 7d3244f4d86..ef9f330bfff 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-2022 +# 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 @@ -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 4d6595e5fe8..618752aee85 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_enums.py b/pyomo/core/tests/unit/test_enums.py index 8f342e55188..cce908a87de 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_expr5.txt b/pyomo/core/tests/unit/test_expr5.txt index a5fc934bd77..2bf78cb4985 100644 --- a/pyomo/core/tests/unit/test_expr5.txt +++ b/pyomo/core/tests/unit/test_expr5.txt @@ -1,11 +1,8 @@ -2 Set Declarations +1 Set Declarations A : set A Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} - c3_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 1 : {1,} 2 Param Declarations B : param B @@ -49,8 +46,8 @@ 2 : -Inf : B[2]*x[2] : 1.0 : True 3 : -Inf : B[3]*x[3] : 1.0 : True c3 : con c3 - Size=1, Index=c3_index, Active=True + Size=1, Index={1}, Active=True Key : Lower : Body : Upper : Active 1 : -Inf : y : 0.0 : True -10 Declarations: A B C x y o c1 c2 c3_index c3 +9 Declarations: A B C x y o c1 c2 c3 diff --git a/pyomo/core/tests/unit/test_expr_misc.py b/pyomo/core/tests/unit/test_expr_misc.py index 4ec53521d6b..f4fd7556117 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-2022 +# 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 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..08fcfbd7061 --- /dev/null +++ b/pyomo/core/tests/unit/test_expr_numpy.py @@ -0,0 +1,95 @@ +# ___________________________________________________________________________ +# +# 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 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 8dca0062dd0..92cb245fa22 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-2022 +# 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 @@ -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,11 +769,11 @@ 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) -E : Size=2, Index=E_index + None : sum(x, 2) +E : Size=2, Index={1, 2} Key : Expression 1 : sum(pow(x, 2), 1) 2 : sum(pow(x, 2), 1) @@ -761,7 +792,7 @@ def test_pprint_oldStyle(self): e : Size=1, Index=None Key : Expression None : 1.0 -E : Size=2, Index=E_index +E : Size=2, Index={1, 2} Key : Expression 1 : 2.0 2 : sum(pow(x, 2), 1) @@ -780,7 +811,7 @@ def test_pprint_oldStyle(self): e : Size=1, Index=None Key : Expression None : Undefined -E : Size=2, Index=E_index +E : Size=2, Index={1, 2} Key : Expression 1 : Undefined 2 : sum(pow(x, 2), 1) @@ -806,7 +837,7 @@ def test_pprint_newStyle(self): e : Size=1, Index=None Key : Expression None : x + 2 -E : Size=2, Index=E_index +E : Size=2, Index={1, 2} Key : Expression 1 : x**2 + 1 2 : x**2 + 1 @@ -830,7 +861,7 @@ def test_pprint_newStyle(self): e : Size=1, Index=None Key : Expression None : 1.0 -E : Size=2, Index=E_index +E : Size=2, Index={1, 2} Key : Expression 1 : 2.0 2 : x**2 + 1 @@ -849,7 +880,7 @@ def test_pprint_newStyle(self): e : Size=1, Index=None Key : Expression None : Undefined -E : Size=2, Index=E_index +E : Size=2, Index={1, 2} Key : Expression 1 : Undefined 2 : 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 96c05b6b0b8..1d4a59647c1 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_indexed.py b/pyomo/core/tests/unit/test_indexed.py index 29bf22ceeb1..3480b653ea5 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_indexed_slice.py b/pyomo/core/tests/unit/test_indexed_slice.py index e89c48a6061..40aaad9fec9 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-2022 +# 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 @@ -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 b334a6b857b..2b1d44b422f 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-2022 +# 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 @@ -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) @@ -805,3 +858,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 117de5c5f4c..8186c3d6028 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-2022 +# 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 @@ -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 15c56b5390d..579abfd8b52 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_list_objects.py b/pyomo/core/tests/unit/test_list_objects.py index 442fa97b6d1..671a8429e06 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-2022 +# 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 @@ -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 ed8120da935..b1f37996018 100644 --- a/pyomo/core/tests/unit/test_logical_constraint.py +++ b/pyomo/core/tests/unit/test_logical_constraint.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest from pyomo.core.expr.sympy_tools import sympy_available diff --git a/pyomo/core/tests/unit/test_logical_expr_expanded.py b/pyomo/core/tests/unit/test_logical_expr_expanded.py index 95ae0494a48..6468a21e336 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-2022 +# 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 @@ -15,7 +15,7 @@ """ import operator -from itertools import product +from itertools import permutations, product import pyomo.common.unittest as unittest @@ -23,6 +23,8 @@ from pyomo.core.expr.sympy_tools import sympy_available from pyomo.core.expr.visitor import identify_variables from pyomo.environ import ( + all_different, + count_if, land, atleast, atmost, @@ -39,6 +41,8 @@ BooleanVar, lnot, xor, + Var, + Integers, ) @@ -234,12 +238,50 @@ def test_nary_atleast(self): ) self.assertEqual(value(atleast(ntrue, m.Y)), correct_value) + def test_nary_all_diff(self): + m = ConcreteModel() + m.x = Var(range(4), domain=Integers, bounds=(0, 3)) + for vals in permutations(range(4)): + self.assertTrue(value(all_different(*vals))) + for i, v in enumerate(vals): + m.x[i] = v + self.assertTrue(value(all_different(m.x))) + self.assertFalse(value(all_different(1, 1, 2, 3))) + m.x[0] = 1 + m.x[1] = 1 + m.x[2] = 2 + m.x[3] = 3 + self.assertFalse(value(all_different(m.x))) + + def test_count_if(self): + nargs = 3 + m = ConcreteModel() + m.s = RangeSet(nargs) + m.Y = BooleanVar(m.s) + m.x = Var(domain=Integers, bounds=(0, 3)) + for truth_combination in _generate_possible_truth_inputs(nargs): + for ntrue in range(nargs + 1): + m.Y.set_values(dict(enumerate(truth_combination, 1))) + correct_value = sum(truth_combination) + self.assertEqual(value(count_if(*(m.Y[i] for i in m.s))), correct_value) + self.assertEqual(value(count_if(m.Y)), correct_value) + m.x = 2 + self.assertEqual( + value(count_if([m.Y[i] for i in m.s] + [m.x == 3])), correct_value + ) + m.x = 3 + self.assertEqual( + value(count_if([m.Y[i] for i in m.s] + [m.x == 3])), correct_value + 1 + ) + def test_to_string(self): m = ConcreteModel() m.Y1 = BooleanVar() m.Y2 = BooleanVar() m.Y3 = BooleanVar() m.Y4 = BooleanVar() + m.int1 = Var(domain=Integers) + m.int2 = Var(domain=Integers) self.assertEqual(str(land(m.Y1, m.Y2, m.Y3)), "Y1 ∧ Y2 ∧ Y3") self.assertEqual(str(lor(m.Y1, m.Y2, m.Y3)), "Y1 ∨ Y2 ∨ Y3") @@ -249,6 +291,10 @@ def test_to_string(self): self.assertEqual(str(atleast(1, m.Y1, m.Y2)), "atleast(1: [Y1, Y2])") self.assertEqual(str(atmost(1, m.Y1, m.Y2)), "atmost(1: [Y1, Y2])") self.assertEqual(str(exactly(1, m.Y1, m.Y2)), "exactly(1: [Y1, Y2])") + self.assertEqual( + str(all_different(m.int1, m.int2)), "all_different(int1, int2)" + ) + self.assertEqual(str(count_if(m.Y1, m.Y2)), "count_if(Y1, Y2)") # Precedence checks self.assertEqual(str(m.Y1.implies(m.Y2).lor(m.Y3)), "(Y1 --> Y2) ∨ Y3") @@ -266,11 +312,16 @@ def test_node_types(self): m.Y1 = BooleanVar() m.Y2 = BooleanVar() m.Y3 = BooleanVar() + m.int1 = Var(domain=Integers) + m.int2 = Var(domain=Integers) + m.int3 = Var(domain=Integers) self.assertFalse(m.Y1.is_expression_type()) self.assertTrue(lnot(m.Y1).is_expression_type()) self.assertTrue(equivalent(m.Y1, m.Y2).is_expression_type()) self.assertTrue(atmost(1, [m.Y1, m.Y2, m.Y3]).is_expression_type()) + self.assertTrue(all_different(m.int1, m.int2, m.int3).is_expression_type()) + self.assertTrue(count_if(m.Y1, m.Y2, m.Y3).is_expression_type()) def test_numeric_invalid(self): m = ConcreteModel() diff --git a/pyomo/core/tests/unit/test_logical_to_linear.py b/pyomo/core/tests/unit/test_logical_to_linear.py index 22133f22ba2..e777259f8ce 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-2022 +# 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 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..5feacfe5c61 --- /dev/null +++ b/pyomo/core/tests/unit/test_lp_dual.py @@ -0,0 +1,400 @@ +# ___________________________________________________________________________ +# +# 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.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 d9b51de7bf6..993e2a18eb3 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_misc.py b/pyomo/core/tests/unit/test_misc.py index 261c94d96bd..440c8807358 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_model.py b/pyomo/core/tests/unit/test_model.py index 95ad17e97f4..9016f9937c0 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_mutable.py b/pyomo/core/tests/unit/test_mutable.py index 933ef1fe3dc..d10622d84c0 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_numeric_expr.py b/pyomo/core/tests/unit/test_numeric_expr.py index a4f3295441e..3701ea86bad 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-2022 +# 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 @@ -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 69cb43f3ad5..923f78af1be 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-2022 +# 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 @@ -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 3e9e160b1b1..bb7a291e67d 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-2022 +# 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 @@ -123,8 +123,6 @@ def setUp(self): self.mutable_l3 = _MutableNPVSumExpression([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,)) @@ -368,38 +366,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,7 +402,7 @@ def test_add_asbinary(self): ( self.asbinary, self.mutable_l1, - LinearExpression([self.mon_bin, self.mon_npv]), + LinearExpression([self.bin, self.mon_npv]), ), ( self.asbinary, @@ -416,13 +410,9 @@ def test_add_asbinary(self): SumExpression(self.mutable_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) @@ -462,7 +452,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 +461,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])), @@ -494,7 +484,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 +493,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])), @@ -530,7 +520,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 +529,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])), @@ -570,7 +560,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 +569,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])), @@ -605,7 +595,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 +609,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, @@ -674,37 +660,21 @@ 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), @@ -712,7 +682,7 @@ def test_add_var(self): ( self.var, self.mutable_l1, - LinearExpression([self.mon_var] + self.mutable_l1.args), + LinearExpression([self.var] + self.mutable_l1.args), ), ( self.var, @@ -720,13 +690,9 @@ def test_add_var(self): SumExpression(self.mutable_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 +703,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 +717,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, @@ -813,7 +775,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 +789,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, @@ -882,11 +840,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 +853,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, @@ -949,7 +903,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 +917,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, @@ -1134,7 +1084,7 @@ def test_add_mutable_l1(self): ( self.mutable_l1, self.asbinary, - LinearExpression(self.mutable_l1.args + [self.mon_bin]), + LinearExpression(self.mutable_l1.args + [self.bin]), ), (self.mutable_l1, self.zero, self.mon_npv), (self.mutable_l1, self.one, LinearExpression(self.mutable_l1.args + [1])), @@ -1159,7 +1109,7 @@ def test_add_mutable_l1(self): ( self.mutable_l1, self.var, - LinearExpression(self.mutable_l1.args + [self.mon_var]), + LinearExpression(self.mutable_l1.args + [self.var]), ), ( self.mutable_l1, @@ -1341,7 +1291,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 +1300,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])), @@ -1380,7 +1330,7 @@ def test_add_mutable_l3(self): ( self.mutable_l3, self.asbinary, - LinearExpression(self.mutable_l3.args + [self.mon_bin]), + LinearExpression(self.mutable_l3.args + [self.bin]), ), (self.mutable_l3, self.zero, self.npv), (self.mutable_l3, self.one, NPV_SumExpression(self.mutable_l3.args + [1])), @@ -1409,7 +1359,7 @@ def test_add_mutable_l3(self): ( self.mutable_l3, self.var, - LinearExpression(self.mutable_l3.args + [self.mon_var]), + LinearExpression(self.mutable_l3.args + [self.var]), ), ( self.mutable_l3, @@ -1515,32 +1465,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,7 +1501,7 @@ def test_sub_asbinary(self): ( self.asbinary, self.mutable_l1, - LinearExpression([self.mon_bin, self.minus_mon_npv]), + LinearExpression([self.bin, self.minus_mon_npv]), ), ( self.asbinary, @@ -1559,12 +1509,12 @@ def test_sub_asbinary(self): SumExpression([self.bin, self.minus_mutable_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) @@ -1837,35 +1787,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,7 +1825,7 @@ def test_sub_var(self): ( self.var, self.mutable_l1, - LinearExpression([self.mon_var, self.minus_mon_npv]), + LinearExpression([self.var, self.minus_mon_npv]), ), ( self.var, @@ -1887,13 +1833,9 @@ def test_sub_var(self): SumExpression([self.var, self.minus_mutable_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) @@ -6511,7 +6453,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 +6462,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])), @@ -6546,20 +6488,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, @@ -6592,7 +6534,7 @@ def test_mutable_nvp_iadd(self): _MutableSumExpression([10] + self.mutable_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 +6544,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 +6553,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])), @@ -6634,81 +6576,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.mutable_l1.args), ), ( mutable_lin, self.mutable_l2, - _MutableSumExpression([self.mon_bin] + self.mutable_l2.args), + _MutableSumExpression([self.bin] + self.mutable_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) @@ -6854,7 +6784,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 3000f644e80..19968640a21 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-2022 +# 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 @@ -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,7 +138,7 @@ def test_add_asbinary(self): ( self.asbinary, self.mutable_l1, - LinearExpression([self.mon_bin, self.mon_npv]), + LinearExpression([self.bin, self.mon_npv]), ), ( self.asbinary, @@ -150,13 +146,9 @@ def test_add_asbinary(self): SumExpression(self.mutable_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) @@ -196,7 +188,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 +197,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])), @@ -228,7 +220,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 +229,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])), @@ -264,7 +256,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 +265,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])), @@ -304,7 +296,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 +305,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])), @@ -339,7 +331,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 +345,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, @@ -408,37 +396,21 @@ 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), @@ -446,7 +418,7 @@ def test_add_var(self): ( self.var, self.mutable_l1, - LinearExpression([self.mon_var] + self.mutable_l1.args), + LinearExpression([self.var] + self.mutable_l1.args), ), ( self.var, @@ -454,13 +426,9 @@ def test_add_var(self): SumExpression(self.mutable_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 +439,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 +453,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, @@ -547,7 +511,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 +525,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, @@ -616,11 +576,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 +589,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, @@ -683,7 +639,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 +653,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, @@ -868,7 +820,7 @@ def test_add_mutable_l1(self): ( self.mutable_l1, self.asbinary, - LinearExpression(self.mutable_l1.args + [self.mon_bin]), + LinearExpression(self.mutable_l1.args + [self.bin]), ), (self.mutable_l1, self.zero, self.mon_npv), (self.mutable_l1, self.one, LinearExpression(self.mutable_l1.args + [1])), @@ -893,7 +845,7 @@ def test_add_mutable_l1(self): ( self.mutable_l1, self.var, - LinearExpression(self.mutable_l1.args + [self.mon_var]), + LinearExpression(self.mutable_l1.args + [self.var]), ), ( self.mutable_l1, @@ -1075,7 +1027,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 +1036,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])), @@ -1114,7 +1066,7 @@ def test_add_mutable_l3(self): ( self.mutable_l3, self.asbinary, - LinearExpression(self.mutable_l3.args + [self.mon_bin]), + LinearExpression(self.mutable_l3.args + [self.bin]), ), (self.mutable_l3, self.zero, self.npv), (self.mutable_l3, self.one, NPV_SumExpression(self.mutable_l3.args + [1])), @@ -1143,7 +1095,7 @@ def test_add_mutable_l3(self): ( self.mutable_l3, self.var, - LinearExpression(self.mutable_l3.args + [self.mon_var]), + LinearExpression(self.mutable_l3.args + [self.var]), ), ( self.mutable_l3, @@ -1249,32 +1201,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,7 +1237,7 @@ def test_sub_asbinary(self): ( self.asbinary, self.mutable_l1, - LinearExpression([self.mon_bin, self.minus_mon_npv]), + LinearExpression([self.bin, self.minus_mon_npv]), ), ( self.asbinary, @@ -1293,12 +1245,12 @@ def test_sub_asbinary(self): SumExpression([self.bin, self.minus_mutable_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) @@ -1571,35 +1523,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,7 +1561,7 @@ def test_sub_var(self): ( self.var, self.mutable_l1, - LinearExpression([self.mon_var, self.minus_mon_npv]), + LinearExpression([self.var, self.minus_mon_npv]), ), ( self.var, @@ -1621,13 +1569,9 @@ def test_sub_var(self): SumExpression([self.var, self.minus_mutable_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) @@ -6039,7 +5983,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 +5992,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])), @@ -6074,20 +6018,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, @@ -6120,7 +6064,7 @@ def test_mutable_nvp_iadd(self): _MutableSumExpression([10] + self.mutable_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 +6074,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 +6083,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])), @@ -6162,81 +6106,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.mutable_l1.args), ), ( mutable_lin, self.mutable_l2, - _MutableSumExpression([self.mon_bin] + self.mutable_l2.args), + _MutableSumExpression([self.bin] + self.mutable_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) diff --git a/pyomo/core/tests/unit/test_numpy_expr.py b/pyomo/core/tests/unit/test_numpy_expr.py index 8f58eb29e56..fb81dfe809f 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_numvalue.py b/pyomo/core/tests/unit/test_numvalue.py index 74df1d29522..4d39a42ed70 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-2022 +# 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 @@ -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,9 +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; 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): @@ -574,14 +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)') + 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 d73bf7d6dfd..dc2e320e63b 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-2022 +# 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 @@ -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 6ba1163e3c3..2980a26804e 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-2022 +# 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 @@ -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,70 @@ 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 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 861704a2f9c..fccc92bbfa2 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_piecewise.py b/pyomo/core/tests/unit/test_piecewise.py index aeb02b82624..7b8e01e6a45 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-2022 +# 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 @@ -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 d4c5ae75bb0..ce7924f3ac5 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_range.py b/pyomo/core/tests/unit/test_range.py index 8cd1e7ce46c..4b489f50d44 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_reference.py b/pyomo/core/tests/unit/test_reference.py index a7a470b1a3b..7370881612f 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-2022 +# 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 @@ -729,7 +729,6 @@ def test_component_data_reference(self): self.assertIs(m.r.ctype, Var) self.assertIsNot(m.r.index_set(), m.y.index_set()) - self.assertIs(m.y.index_set(), m.y_index) self.assertIs(m.r.index_set(), UnindexedComponent_ReferenceSet) self.assertEqual(len(m.r), 1) self.assertTrue(m.r.is_reference()) @@ -773,7 +772,7 @@ def test_reference_var_pprint(self): m.r.pprint(ostream=buf) self.assertEqual( buf.getvalue(), - """r : Size=2, Index=x_index, ReferenceTo=x + """r : Size=2, Index={1, 2}, ReferenceTo=x Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : None : 4 : None : False : False : Reals 2 : None : 8 : None : False : False : Reals @@ -784,7 +783,7 @@ def test_reference_var_pprint(self): m.s.pprint(ostream=buf) self.assertEqual( buf.getvalue(), - """s : Size=2, Index=x_index, ReferenceTo=x[:, ...] + """s : Size=2, Index={1, 2}, ReferenceTo=x[:, ...] Key : Lower : Value : Upper : Fixed : Stale : Domain 1 : None : 4 : None : False : False : Reals 2 : None : 8 : None : False : False : Reals @@ -799,10 +798,10 @@ def test_reference_indexedcomponent_pprint(self): m.r.pprint(ostream=buf) self.assertEqual( buf.getvalue(), - """r : Size=2, Index=x_index, ReferenceTo=x + """r : Size=2, Index={1, 2}, ReferenceTo=x Key : Object - 1 : - 2 : + 1 : + 2 : """, ) m.s = Reference(m.x[:, ...], ctype=IndexedComponent) @@ -810,10 +809,10 @@ def test_reference_indexedcomponent_pprint(self): m.s.pprint(ostream=buf) self.assertEqual( buf.getvalue(), - """s : Size=2, Index=x_index, ReferenceTo=x[:, ...] + """s : Size=2, Index={1, 2}, ReferenceTo=x[:, ...] Key : Object - 1 : - 2 : + 1 : + 2 : """, ) @@ -1281,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) @@ -1323,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) @@ -1360,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(), @@ -1380,7 +1377,7 @@ def b(b, i): self.assertEqual( buf.getvalue().strip(), """ -r : Size=4, Index=r_index, ReferenceTo=b[:].x[:] +r : Size=4, Index=ReferenceSet(b[:].x[:]), ReferenceTo=b[:].x[:] Key : Lower : Value : Upper : Fixed : Stale : Domain (1, 3) : 1 : None : None : False : True : Reals (1, 4) : 1 : None : None : False : True : Reals diff --git a/pyomo/core/tests/unit/test_relational_expr.py b/pyomo/core/tests/unit/test_relational_expr.py index f55bfff108c..d361bfcc83c 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_set.py b/pyomo/core/tests/unit/test_set.py index 72231bb08d7..6312aaf63c6 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-2022 +# 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 @@ -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, @@ -112,17 +113,19 @@ class Test_SetInitializer(unittest.TestCase): def test_single_set(self): + tmp = Set() # a placeholder to accumulate _anonymous_sets references + a = SetInitializer(None) self.assertIs(type(a), SetInitializer) self.assertIsNone(a._set) - self.assertIs(a(None, None), Any) + self.assertIs(a(None, None, tmp), Any) self.assertTrue(a.constant()) self.assertFalse(a.verified) a = SetInitializer(Reals) self.assertIs(type(a), SetInitializer) self.assertIs(type(a._set), ConstantInitializer) - self.assertIs(a(None, None), Reals) + self.assertIs(a(None, None, tmp), Reals) self.assertIs(a._set.val, Reals) self.assertTrue(a.constant()) self.assertFalse(a.verified) @@ -130,18 +133,20 @@ def test_single_set(self): a = SetInitializer({1: Reals}) self.assertIs(type(a), SetInitializer) self.assertIs(type(a._set), ItemInitializer) - self.assertIs(a(None, 1), Reals) + self.assertIs(a(None, 1, tmp), Reals) self.assertFalse(a.constant()) self.assertFalse(a.verified) def test_intersect(self): + tmp = Set() # a placeholder to accumulate _anonymous_sets references + a = SetInitializer(None) a.intersect(SetInitializer(None)) self.assertIs(type(a), SetInitializer) self.assertIsNone(a._set) self.assertTrue(a.constant()) self.assertFalse(a.verified) - self.assertIs(a(None, None), Any) + self.assertIs(a(None, None, tmp), Any) a = SetInitializer(None) a.intersect(SetInitializer(Reals)) @@ -150,7 +155,7 @@ def test_intersect(self): self.assertIs(a._set.val, Reals) self.assertTrue(a.constant()) self.assertFalse(a.verified) - self.assertIs(a(None, None), Reals) + self.assertIs(a(None, None, tmp), Reals) a = SetInitializer(None) a.intersect(BoundsInitializer(5, default_step=1)) @@ -158,7 +163,7 @@ def test_intersect(self): self.assertIs(type(a._set), BoundsInitializer) self.assertTrue(a.constant()) self.assertFalse(a.verified) - self.assertEqual(a(None, None), RangeSet(5)) + self.assertEqual(a(None, None, tmp), RangeSet(5)) a = SetInitializer(Reals) a.intersect(SetInitializer(None)) @@ -167,7 +172,7 @@ def test_intersect(self): self.assertIs(a._set.val, Reals) self.assertTrue(a.constant()) self.assertFalse(a.verified) - self.assertIs(a(None, None), Reals) + self.assertIs(a(None, None, tmp), Reals) a = SetInitializer(Reals) a.intersect(SetInitializer(Integers)) @@ -179,7 +184,7 @@ def test_intersect(self): self.assertIs(a._set._B.val, Integers) self.assertTrue(a.constant()) self.assertFalse(a.verified) - s = a(None, None) + s = a(None, None, tmp) self.assertIs(type(s), SetIntersection_InfiniteSet) self.assertIs(s._sets[0], Reals) self.assertIs(s._sets[1], Integers) @@ -195,7 +200,7 @@ def test_intersect(self): self.assertIs(a._set._A._B.val, Integers) self.assertTrue(a.constant()) self.assertFalse(a.verified) - s = a(None, None) + s = a(None, None, tmp) self.assertIs(type(s), SetIntersection_OrderedSet) self.assertIs(type(s._sets[0]), SetIntersection_InfiniteSet) self.assertIsInstance(s._sets[1], RangeSet) @@ -212,7 +217,7 @@ def test_intersect(self): self.assertIs(a._set._A._B.val, Integers) self.assertTrue(a.constant()) self.assertFalse(a.verified) - s = a(None, None) + s = a(None, None, tmp) self.assertIs(type(s), SetIntersection_InfiniteSet) p.construct() s.construct() @@ -236,8 +241,8 @@ def test_intersect(self): self.assertFalse(a.constant()) self.assertFalse(a.verified) with self.assertRaises(KeyError): - a(None, None) - s = a(None, 1) + a(None, None, tmp) + s = a(None, 1, tmp) self.assertIs(type(s), SetIntersection_InfiniteSet) p.construct() s.construct() @@ -304,15 +309,17 @@ def test_boundsinit(self): self.assertEqual(s, RangeSet(0, 5)) def test_setdefault(self): + tmp = Set() # a placeholder to accumulate _anonymous_sets references + a = SetInitializer(None) - self.assertIs(a(None, None), Any) + self.assertIs(a(None, None, tmp), Any) a.setdefault(Reals) - self.assertIs(a(None, None), Reals) + self.assertIs(a(None, None, tmp), Reals) a = SetInitializer(Integers) - self.assertIs(a(None, None), Integers) + self.assertIs(a(None, None, tmp), Integers) a.setdefault(Reals) - self.assertIs(a(None, None), Integers) + self.assertIs(a(None, None, tmp), Integers) a = BoundsInitializer(5, default_step=1) self.assertEqual(a(None, None), RangeSet(5)) @@ -321,9 +328,9 @@ def test_setdefault(self): a = SetInitializer(Reals) a.intersect(SetInitializer(Integers)) - self.assertIs(type(a(None, None)), SetIntersection_InfiniteSet) + self.assertIs(type(a(None, None, tmp)), SetIntersection_InfiniteSet) a.setdefault(RangeSet(5)) - self.assertIs(type(a(None, None)), SetIntersection_InfiniteSet) + self.assertIs(type(a(None, None, tmp)), SetIntersection_InfiniteSet) def test_indices(self): a = SetInitializer(None) @@ -993,9 +1000,7 @@ def __ge__(self, other): output = StringIO() with LoggingIntercept(output, 'pyomo.core', logging.DEBUG): i = SetOf([1, 2, 3]) - self.assertEqual(output.getvalue(), "") - i.construct() - ref = 'Constructing SetOf, name=OrderedSetOf, from data=None\n' + ref = 'Constructing SetOf, name=[1, 2, 3], from data=None\n' self.assertEqual(output.getvalue(), ref) # Calling construct() twice bypasses construction the second # time around @@ -1281,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() @@ -1815,7 +1820,7 @@ def test_check_values(self): class Test_SetOperator(unittest.TestCase): def test_construct(self): p = Param(initialize=3) - a = RangeSet(p) + a = RangeSet(p, name='a') output = StringIO() with LoggingIntercept(output, 'pyomo.core', logging.DEBUG): i = a * a @@ -1824,12 +1829,8 @@ def test_construct(self): with LoggingIntercept(output, 'pyomo.core', logging.DEBUG): i.construct() ref = ( - 'Constructing SetOperator, name=SetProduct_OrderedSet, ' - 'from data=None\n' - 'Constructing RangeSet, name=FiniteScalarRangeSet, ' - 'from data=None\n' - 'Constructing Set, name=SetProduct_OrderedSet, ' - 'from data=None\n' + 'Constructing SetOperator, name=a*a, from data=None\n' + 'Constructing RangeSet, name=a, from data=None\n' ) self.assertEqual(output.getvalue(), ref) # Calling construct() twice bypasses construction the second @@ -1941,8 +1942,8 @@ def test_domain_and_pprint(self): m.A.pprint(ostream=output) ref = """ A : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 1 : I | A_index_0 : 4 : {1, 2, 3, 4} + Key : Dimen : Domain : Size : Members + None : 1 : I | {3, 4} : 4 : {1, 2, 3, 4} """.strip() self.assertEqual(output.getvalue().strip(), ref) @@ -2217,8 +2218,8 @@ def test_domain_and_pprint(self): m.A.pprint(ostream=output) ref = """ A : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 1 : I & A_index_0 : 0 : {} + Key : Dimen : Domain : Size : Members + None : 1 : I & {3, 4} : 0 : {} """.strip() self.assertEqual(output.getvalue().strip(), ref) @@ -2495,8 +2496,8 @@ def test_domain_and_pprint(self): m.A.pprint(ostream=output) ref = """ A : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 1 : I - A_index_0 : 2 : {1, 2} + Key : Dimen : Domain : Size : Members + None : 1 : I - {3, 4} : 2 : {1, 2} """.strip() self.assertEqual(output.getvalue().strip(), ref) @@ -2724,8 +2725,8 @@ def test_domain_and_pprint(self): m.A.pprint(ostream=output) ref = """ A : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 1 : I ^ A_index_0 : 4 : {1, 2, 3, 4} + Key : Dimen : Domain : Size : Members + None : 1 : I ^ {3, 4} : 4 : {1, 2, 3, 4} """.strip() self.assertEqual(output.getvalue().strip(), ref) @@ -2986,8 +2987,8 @@ def test_domain_and_pprint(self): m.A.pprint(ostream=output) ref = """ A : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : I*A_index_0 : 4 : {(1, 3), (1, 4), (2, 3), (2, 4)} + Key : Dimen : Domain : Size : Members + None : 2 : I*{3, 4} : 4 : {(1, 3), (1, 4), (2, 3), (2, 4)} """.strip() self.assertEqual(output.getvalue().strip(), ref) @@ -3102,7 +3103,7 @@ def test_no_normalize_index(self): x = I * J normalize_index.flatten = False - self.assertIs(x.dimen, None) + self.assertIs(x.dimen, 2) self.assertIn(((1, 2), 3), x) self.assertIn((1, (2, 3)), x) # if we are not flattening, then lookup must match the @@ -3277,7 +3278,7 @@ def test_ordered_multidim_setproduct(self): ((3, 4), (7, 8)), ] self.assertEqual(list(x), ref) - self.assertEqual(x.dimen, None) + self.assertEqual(x.dimen, 2) finally: SetModule.FLATTEN_CROSS_PRODUCT = origFlattenCross @@ -3321,7 +3322,7 @@ def test_ordered_nondim_setproduct(self): (1, (2, 3), 5), ] self.assertEqual(list(x), ref) - self.assertEqual(x.dimen, None) + self.assertEqual(x.dimen, 3) finally: SetModule.FLATTEN_CROSS_PRODUCT = origFlattenCross @@ -3373,7 +3374,7 @@ def test_ordered_nondim_setproduct(self): self.assertEqual(list(x), ref) for i, v in enumerate(ref): self.assertEqual(x[i + 1], v) - self.assertEqual(x.dimen, None) + self.assertEqual(x.dimen, 4) finally: SetModule.FLATTEN_CROSS_PRODUCT = origFlattenCross @@ -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() @@ -4410,17 +4597,17 @@ def test_domain(self): self.assertEqual(list(m.I), [0, 2.0, 4]) with self.assertRaisesRegex( ValueError, - 'The value is not in the domain ' r'\(Integers & I_domain_index_0_index_1', + r'The value is not in the domain \(Integers & \[0:inf:2\]\) & \[0..9\]', ): m.I.add(1.5) with self.assertRaisesRegex( ValueError, - 'The value is not in the domain ' r'\(Integers & I_domain_index_0_index_1', + r'The value is not in the domain \(Integers & \[0:inf:2\]\) & \[0..9\]', ): m.I.add(1) with self.assertRaisesRegex( ValueError, - 'The value is not in the domain ' r'\(Integers & I_domain_index_0_index_1', + r'The value is not in the domain \(Integers & \[0:inf:2\]\) & \[0..9\]', ): m.I.add(10) @@ -4458,8 +4645,8 @@ def myFcn(x): Key : Dimen : Domain : Size : Members None : 2 : Any : 2 : {(3, 4), (1, 2)} M : Size=1, Index=None, Ordered=False - Key : Dimen : Domain : Size : Members - None : 1 : Reals - M_index_1 : Inf : ([-inf..0) | (0..inf]) + Key : Dimen : Domain : Size : Members + None : 1 : Reals - [0] : Inf : ([-inf..0) | (0..inf]) N : Size=1, Index=None, Ordered=False Key : Dimen : Domain : Size : Members None : 1 : Integers - Reals : Inf : [] @@ -4469,12 +4656,7 @@ def myFcn(x): Key : Finite : Members None : True : [1:3] -1 SetOf Declarations - M_index_1 : Dimen=1, Size=1, Bounds=(0, 0) - Key : Ordered : Members - None : True : [0] - -8 Declarations: I_index I J K L M_index_1 M N""".strip(), +7 Declarations: I_index I J K L M N""".strip(), ) def test_pickle(self): @@ -4548,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) @@ -4560,11 +4744,11 @@ def test_construction(self): ref = """ I : Size=0, Index=None, Ordered=Insertion Not constructed -II : Size=0, Index=II_index, Ordered=Insertion +II : Size=0, Index={1, 2, 3}, Ordered=Insertion Not constructed J : Size=0, Index=None, Ordered=Insertion Not constructed -JJ : Size=0, Index=JJ_index, Ordered=Insertion +JJ : Size=0, Index={1, 2, 3}, Ordered=Insertion Not constructed""".strip() self.assertEqual(output.getvalue().strip(), ref) @@ -4574,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]}, } } ) @@ -4581,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]) @@ -4588,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]}}) @@ -4831,7 +5020,7 @@ def _i_init(m, i): output = StringIO() m.I.pprint(ostream=output) ref = """ -I : Size=2, Index=I_index, Ordered=Insertion +I : Size=2, Index={1, 2, 3, 4, 5}, Ordered=Insertion Key : Dimen : Domain : Size : Members 2 : 1 : Any : 2 : {0, 1} 4 : 1 : Any : 4 : {0, 1, 2, 3} @@ -5252,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 @@ -5261,7 +5465,7 @@ def test_no_normalize_index(self): m.I = Set() self.assertIs(m.I._dimen, UnknownSetDimen) self.assertTrue(m.I.add((1, (2, 3)))) - self.assertIs(m.I._dimen, None) + self.assertIs(m.I._dimen, 2) self.assertNotIn(((1, 2), 3), m.I) self.assertIn((1, (2, 3)), m.I) self.assertNotIn((1, 2, 3), m.I) @@ -5302,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): @@ -5400,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() @@ -5408,7 +5612,7 @@ class FiniteMixin(_FiniteSetMixin, _SetData): s = FiniteMixin(m.I) # - # _SetData API + # SetData API # with self.assertRaises(DeveloperError): @@ -5525,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() @@ -5533,7 +5737,7 @@ class OrderedMixin(_OrderedSetMixin, _FiniteSetMixin, _SetData): s = OrderedMixin(m.I) # - # _SetData API + # SetData API # with self.assertRaises(DeveloperError): @@ -5820,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" ) @@ -6272,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)]) @@ -6305,14 +6508,11 @@ def objective_rule(model_arg): output = StringIO() m.pprint(ostream=output) ref = """ -3 Set Declarations +2 Set Declarations arc_keys : Set of arcs Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 2 : arc_keys_domain : 2 : {(0, 0), (0, 1)} - arc_keys_domain : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : 2 : node_keys*node_keys : 4 : {(0, 0), (0, 1), (1, 0), (1, 1)} + None : 2 : node_keys*node_keys : 2 : {(0, 0), (0, 1)} node_keys : Set of nodes Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members @@ -6329,7 +6529,7 @@ def objective_rule(model_arg): Key : Active : Sense : Expression None : True : minimize : arc_variables[0,0] + arc_variables[0,1] -5 Declarations: node_keys arc_keys_domain arc_keys arc_variables obj +4 Declarations: node_keys arc_keys arc_variables obj """.strip() self.assertEqual(output.getvalue().strip(), ref) @@ -6338,18 +6538,15 @@ def objective_rule(model_arg): output = StringIO() m.pprint(ostream=output) ref = """ -3 Set Declarations +2 Set Declarations arc_keys : Set of arcs Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : None : arc_keys_domain : 2 : {ArcKey(node_from=NodeKey(id=0), node_to=NodeKey(id=0)), ArcKey(node_from=NodeKey(id=0), node_to=NodeKey(id=1))} - arc_keys_domain : Size=1, Index=None, Ordered=True Key : Dimen : Domain : Size : Members - None : None : node_keys*node_keys : 4 : {(NodeKey(id=0), NodeKey(id=0)), (NodeKey(id=0), NodeKey(id=1)), (NodeKey(id=1), NodeKey(id=0)), (NodeKey(id=1), NodeKey(id=1))} + None : 2 : node_keys*node_keys : 2 : {ArcKey(node_from=NodeKey(id=0), node_to=NodeKey(id=0)), ArcKey(node_from=NodeKey(id=0), node_to=NodeKey(id=1))} node_keys : Set of nodes Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members - None : None : Any : 2 : {NodeKey(id=0), NodeKey(id=1)} + None : 1 : Any : 2 : {NodeKey(id=0), NodeKey(id=1)} 1 Var Declarations arc_variables : Size=2, Index=arc_keys @@ -6362,7 +6559,7 @@ def objective_rule(model_arg): Key : Active : Sense : Expression None : True : minimize : arc_variables[ArcKey(node_from=NodeKey(id=0), node_to=NodeKey(id=0))] + arc_variables[ArcKey(node_from=NodeKey(id=0), node_to=NodeKey(id=1))] -5 Declarations: node_keys arc_keys_domain arc_keys arc_variables obj +4 Declarations: node_keys arc_keys arc_variables obj """.strip() self.assertEqual(output.getvalue().strip(), ref) @@ -6400,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 90668a28e72..46d12172aed 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-2022 +# 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 @@ -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 2b9d2f192c0..69448916a04 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_sos.py b/pyomo/core/tests/unit/test_sos.py index 92a8a5eabaa..cacfcdf5d42 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_sos_v2.py b/pyomo/core/tests/unit/test_sos_v2.py index 8b6fab549a2..996dd10829d 100644 --- a/pyomo/core/tests/unit/test_sos_v2.py +++ b/pyomo/core/tests/unit/test_sos_v2.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # ***************************************************************************** # ***************************************************************************** diff --git a/pyomo/core/tests/unit/test_suffix.py b/pyomo/core/tests/unit/test_suffix.py index 1ec1af9d919..f56f84fc129 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-2022 +# 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 @@ -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 5f6416e2c8d..773e6d335f1 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_symbolic.py b/pyomo/core/tests/unit/test_symbolic.py index bbac4599363..91887f27bb7 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_taylor_series.py b/pyomo/core/tests/unit/test_taylor_series.py index d4fe5291b2d..4b36451d222 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_template_expr.py b/pyomo/core/tests/unit/test_template_expr.py index 4b4ea494b0e..80f5d90b60e 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-2022 +# 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 @@ -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 809db733cde..bda62835711 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_var.py b/pyomo/core/tests/unit/test_var.py index 33e46a79e9b..6b2e92be832 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/test_var_set_bounds.py b/pyomo/core/tests/unit/test_var_set_bounds.py index eb969c2ca73..1686ba4f1c6 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-2022 +# 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 @@ -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 086c57aa560..46aff4b052f 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-2022 +# 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 @@ -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 @@ -405,7 +479,6 @@ def test_replacement_walker0(self): ) del M.w - del M.w_index M.w = VarList() e = 2 * sum_product(M.z, M.x) walker = ReplacementWalkerTest1(M) @@ -438,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() @@ -688,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() @@ -887,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, @@ -931,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() @@ -1849,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 ae630586480..d0e74c8cae3 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/uninstantiated_model_linear.py b/pyomo/core/tests/unit/uninstantiated_model_linear.py index 387444b7bc5..417f7763d87 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/uninstantiated_model_quadratic.py b/pyomo/core/tests/unit/uninstantiated_model_quadratic.py index 572c6a43a14..350d96a85bb 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-2022 +# 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 diff --git a/pyomo/core/tests/unit/varpprint.txt b/pyomo/core/tests/unit/varpprint.txt index bd49b881417..a8c33c6b007 100644 --- a/pyomo/core/tests/unit/varpprint.txt +++ b/pyomo/core/tests/unit/varpprint.txt @@ -1,13 +1,7 @@ -3 Set Declarations +1 Set Declarations a : Size=1, Index=None, Ordered=Insertion Key : Dimen : Domain : Size : Members None : 1 : Any : 3 : {1, 2, 3} - cl_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 10 : {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} - o3_index : Size=1, Index=None, Ordered=True - Key : Dimen : Domain : Size : Members - None : 2 : a*a : 9 : {(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)} 2 Param Declarations A : Size=1, Index=None, Domain=Any, Default=-1, Mutable=True @@ -37,7 +31,7 @@ 1 : True : minimize : b[1] 2 : True : minimize : b[2] 3 : True : minimize : b[3] - o3 : Size=0, Index=o3_index, Active=True + o3 : Size=0, Index=a*a, Active=True Key : Active : Sense : Expression 19 Constraint Declarations @@ -97,7 +91,7 @@ c9b : Size=1, Index=None, Active=True Key : Lower : Body : Upper : Active None : -Inf : c : A + A : True - cl : Size=10, Index=cl_index, Active=True + cl : Size=10, Index={1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, Active=True Key : Lower : Body : Upper : Active 1 : -Inf : d - c : 0.0 : True 2 : -Inf : d - 2*c : 0.0 : True @@ -110,4 +104,4 @@ 9 : -Inf : d - 9*c : 0.0 : True 10 : -Inf : d - 10*c : 0.0 : True -30 Declarations: a b c d e A B o2 o3_index o3 c1 c2 c3 c4 c5 c6a c7a c7b c8 c9a c9b c10a c11 c15a c16a c12 c13a c14a cl_index cl +28 Declarations: a b c d e A B o2 o3 c1 c2 c3 c4 c5 c6a c7a c7b c8 c9a c9b c10a c11 c15a c16a c12 c13a c14a cl diff --git a/pyomo/core/util.py b/pyomo/core/util.py index 3f8a136e07d..03df3ed595c 100644 --- a/pyomo/core/util.py +++ b/pyomo/core/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -13,27 +13,12 @@ # Utility functions # -__all__ = [ - 'sum_product', - 'summation', - 'dot_product', - 'sequence', - 'prod', - 'quicksum', - 'target_list', -] - from pyomo.common.deprecation import deprecation_warning from pyomo.core.expr.numvalue import native_numeric_types -from pyomo.core.expr.numeric_expr import ( - mutable_expression, - nonlinear_expression, - NPV_SumExpression, -) -import pyomo.core.expr as EXPR +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__) @@ -236,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') @@ -253,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 8d07b184336..5860a129aa2 100644 --- a/pyomo/dae/__init__.py +++ b/pyomo/dae/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/dae/contset.py b/pyomo/dae/contset.py index ee4c9f79e89..9b4f11714df 100644 --- a/pyomo/dae/contset.py +++ b/pyomo/dae/contset.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -17,7 +17,6 @@ from pyomo.core.base.component import ModelComponentFactory logger = logging.getLogger('pyomo.dae') -__all__ = ['ContinuousSet'] @ModelComponentFactory.register( diff --git a/pyomo/dae/diffvar.py b/pyomo/dae/diffvar.py index 8d75b9ae148..b921107957f 100644 --- a/pyomo/dae/diffvar.py +++ b/pyomo/dae/diffvar.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -16,8 +16,6 @@ from pyomo.core.base.var import Var from pyomo.dae.contset import ContinuousSet -__all__ = ('DerivativeVar', 'DAE_Error') - def create_access_function(var): """ diff --git a/pyomo/dae/flatten.py b/pyomo/dae/flatten.py index 595f90b3dc7..3d90cc443c1 100644 --- a/pyomo/dae/flatten.py +++ b/pyomo/dae/flatten.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -200,8 +200,28 @@ def slice_component_along_sets(component, sets, context_slice=None, normalize=No # # Note that c_slice is not necessarily a slice. # We enter this loop even if no sets need slicing. - temp_slice = c_slice.duplicate() - next(iter(temp_slice)) + try: + next(iter(c_slice.duplicate())) + except IndexError: + if normalize_index.flatten: + raise + # There is an edge case where when we are not + # flattening indices the dimensionality of an + # index can change between a SetProduct and the + # member Sets: the member set can have dimen>1 + # (or even None!), but the dimen of that portion + # of the SetProduct is always 1. Since we are + # just checking that the c_slice isn't + # completely empty, we will allow matching with + # an Ellipsis + _empty = True + try: + next(iter(base_component[...])) + _empty = False + except: + pass + if _empty: + raise if (normalize is None and normalize_index.flatten) or normalize: # Most users probably want this index to be normalized, # so they can more conveniently use it as a key in a @@ -239,7 +259,7 @@ def generate_sliced_components( Parameters ---------- - b: _BlockData + b: BlockData Block whose components will be sliced index_stack: list @@ -247,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. @@ -423,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 @@ -526,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 c10ccb023d1..97928026de2 100644 --- a/pyomo/dae/initialization.py +++ b/pyomo/dae/initialization.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/dae/integral.py b/pyomo/dae/integral.py index 302e50a007d..8c9512d98dd 100644 --- a/pyomo/dae/integral.py +++ b/pyomo/dae/integral.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -14,15 +14,13 @@ from pyomo.core.base.indexed_component import rule_wrapper from pyomo.core.base.expression import ( Expression, - _GeneralExpressionData, + ExpressionData, ScalarExpression, IndexedExpression, ) from pyomo.dae.contset import ContinuousSet from pyomo.dae.diffvar import DAE_Error -__all__ = ('Integral',) - @ModelComponentFactory.register("Integral Expression in a DAE model.") class Integral(Expression): @@ -153,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 9b867bcfff4..dcb73f60c9e 100644 --- a/pyomo/dae/misc.py +++ b/pyomo/dae/misc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 96ab91b0ac0..4eaff9f1fd7 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-2022 +# 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 @@ -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 7f86e8bc2e2..81f1e4dd7ea 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-2022 +# 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 diff --git a/pyomo/dae/plugins/finitedifference.py b/pyomo/dae/plugins/finitedifference.py index 71bb2ffc9b6..6557a14e562 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-2022 +# 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 diff --git a/pyomo/dae/set_utils.py b/pyomo/dae/set_utils.py index 981954189b3..d7a1d9517d9 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-2022 +# 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 diff --git a/pyomo/dae/simulator.py b/pyomo/dae/simulator.py index b869592553a..72ba0c7331d 100644 --- a/pyomo/dae/simulator.py +++ b/pyomo/dae/simulator.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 @@ -6,20 +17,14 @@ # the U.S. Government retains certain rights in this software. # This software is distributed under the BSD License. # _________________________________________________________________________ -from pyomo.core.base import Constraint, Param, value, Suffix, Block +import logging +from pyomo.core.base import Constraint, Param, value, Suffix, Block from pyomo.dae import ContinuousSet, DerivativeVar from pyomo.dae.diffvar import DAE_Error - import pyomo.core.expr as EXPR from pyomo.core.expr.numvalue import native_numeric_types from pyomo.core.expr.template_expr import IndexTemplate, _GetItemIndexer - -import logging - -__all__ = ('Simulator',) -logger = logging.getLogger('pyomo.core') - from pyomo.common.dependencies import ( numpy as np, numpy_available, @@ -28,6 +33,8 @@ attempt_import, ) +logger = logging.getLogger('pyomo.core') + casadi_intrinsic = {} diff --git a/pyomo/dae/tests/__init__.py b/pyomo/dae/tests/__init__.py index 12bdccd0ef4..4638923595a 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-2022 +# 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 diff --git a/pyomo/dae/tests/test_colloc.py b/pyomo/dae/tests/test_colloc.py index 0786903f12e..e7e6b20d660 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-2022 +# 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 diff --git a/pyomo/dae/tests/test_contset.py b/pyomo/dae/tests/test_contset.py index ce13d53dfd5..e5f11b90e27 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-2022 +# 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 diff --git a/pyomo/dae/tests/test_diffvar.py b/pyomo/dae/tests/test_diffvar.py index 718781d5916..414e9341e19 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-2022 +# 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 @@ -69,7 +69,6 @@ def test_valid(self): del m.dv del m.dv2 del m.v - del m.v_index m.v = Var(m.x, m.t) m.dv = DerivativeVar(m.v, wrt=m.x) diff --git a/pyomo/dae/tests/test_finite_diff.py b/pyomo/dae/tests/test_finite_diff.py index adca8bf6a15..a1b842feccf 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-2022 +# 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 diff --git a/pyomo/dae/tests/test_flatten.py b/pyomo/dae/tests/test_flatten.py index a6ea824c3ef..1fc28f66bdf 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-2022 +# 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 @@ -49,6 +49,12 @@ class TestAssumedBehavior(unittest.TestCase): immediately obvious would be the case. """ + def setUp(self): + self._orig_flatten = normalize_index.flatten + + def tearDown(self): + normalize_index.flatten = self._orig_flatten + def test_cross(self): m = ConcreteModel() m.s1 = Set(initialize=[1, 2]) @@ -313,6 +319,12 @@ def c_rule(m, t): class TestFlatten(_TestFlattenBase, unittest.TestCase): + def setUp(self): + self._orig_flatten = normalize_index.flatten + + def tearDown(self): + normalize_index.flatten = self._orig_flatten + def _model1_1d_sets(self): # One-dimensional sets, no skipping. m = ConcreteModel() diff --git a/pyomo/dae/tests/test_initialization.py b/pyomo/dae/tests/test_initialization.py index 390b6ecc59e..8407ad2b2a4 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-2022 +# 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 diff --git a/pyomo/dae/tests/test_integral.py b/pyomo/dae/tests/test_integral.py index 77d6d4dd8a9..933bd97d7b4 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-2022 +# 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 diff --git a/pyomo/dae/tests/test_misc.py b/pyomo/dae/tests/test_misc.py index 11c4e44b7b0..48c1e48418d 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-2022 +# 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 diff --git a/pyomo/dae/tests/test_set_utils.py b/pyomo/dae/tests/test_set_utils.py index fa592e05181..8877dadf798 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-2022 +# 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 diff --git a/pyomo/dae/tests/test_simulator.py b/pyomo/dae/tests/test_simulator.py index e79bc7b23b6..76316b5571e 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-2022 +# 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 diff --git a/pyomo/dae/utilities.py b/pyomo/dae/utilities.py index e48c66e003d..ae4018a122e 100644 --- a/pyomo/dae/utilities.py +++ b/pyomo/dae/utilities.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/dataportal/DataPortal.py b/pyomo/dataportal/DataPortal.py index 8eb577af013..457bb1aacee 100644 --- a/pyomo/dataportal/DataPortal.py +++ b/pyomo/dataportal/DataPortal.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['DataPortal'] - import logging from pyomo.common.log import is_debug_set from pyomo.dataportal.factory import DataManagerFactory, UnknownDataManager diff --git a/pyomo/dataportal/TableData.py b/pyomo/dataportal/TableData.py index 1d428967449..f1500d09f9b 100644 --- a/pyomo/dataportal/TableData.py +++ b/pyomo/dataportal/TableData.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['TableData'] - from pyomo.common.collections import Bunch from pyomo.dataportal.process_data import _process_data diff --git a/pyomo/dataportal/__init__.py b/pyomo/dataportal/__init__.py index ca82614ef2a..ac5de0fe541 100644 --- a/pyomo/dataportal/__init__.py +++ b/pyomo/dataportal/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 f1c18dc05c9..479769137e2 100644 --- a/pyomo/dataportal/factory.py +++ b/pyomo/dataportal/factory.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['DataManagerFactory', 'UnknownDataManager'] - import logging from pyomo.common import Factory from pyomo.common.plugin_base import PluginError diff --git a/pyomo/dataportal/parse_datacmds.py b/pyomo/dataportal/parse_datacmds.py index be363fdb64b..481eed7ba9e 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['parse_data_commands'] - import bisect import sys import logging @@ -92,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)) @@ -116,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 @@ -141,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 c3387af9d1e..1a205846c57 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-2022 +# 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 @@ -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 6563a89df10..a52c8227695 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-2022 +# 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 diff --git a/pyomo/dataportal/plugins/datacommands.py b/pyomo/dataportal/plugins/datacommands.py index 068a551d8d2..a4231d5d7a1 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-2022 +# 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 @@ -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 682b87ab13e..71fd499f725 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-2022 +# 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 @@ -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 e42c040ad0b..8b41e9a1c7b 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-2022 +# 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 diff --git a/pyomo/dataportal/plugins/sheet.py b/pyomo/dataportal/plugins/sheet.py index 8672b9917da..773cce81116 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-2022 +# 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 diff --git a/pyomo/dataportal/plugins/text.py b/pyomo/dataportal/plugins/text.py index a9b169e27bd..9a86fd4481b 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-2022 +# 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 diff --git a/pyomo/dataportal/plugins/xml_table.py b/pyomo/dataportal/plugins/xml_table.py index 79245c6d24a..7e10b96312e 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-2022 +# 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 diff --git a/pyomo/dataportal/process_data.py b/pyomo/dataportal/process_data.py index 5eb15269e0c..f6f20d69f67 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-2022 +# 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 diff --git a/pyomo/dataportal/tests/__init__.py b/pyomo/dataportal/tests/__init__.py index 65e82b81c0c..85ece8d8cd5 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-2022 +# 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 diff --git a/pyomo/dataportal/tests/test_dat_parser.py b/pyomo/dataportal/tests/test_dat_parser.py index 0663279875d..43bf216525c 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-2022 +# 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 diff --git a/pyomo/dataportal/tests/test_dataportal.py b/pyomo/dataportal/tests/test_dataportal.py index 3171a118118..8496a8fa3f8 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-2022 +# 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 diff --git a/pyomo/duality/__init__.py b/pyomo/duality/__init__.py index 7f1c869670d..92d32367b0d 100644 --- a/pyomo/duality/__init__.py +++ b/pyomo/duality/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 a8b62cb8dfe..350ca058f82 100644 --- a/pyomo/duality/collect.py +++ b/pyomo/duality/collect.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/duality/lagrangian_dual.py b/pyomo/duality/lagrangian_dual.py index 1b27a3f93d4..78fb5a85d95 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-2022 +# 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 @@ -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 c8c84153975..0e89857ded1 100644 --- a/pyomo/duality/plugins.py +++ b/pyomo/duality/plugins.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/duality/tests/__init__.py b/pyomo/duality/tests/__init__.py index 0dc08cc5aea..761a6e6c44c 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-2022 +# 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 diff --git a/pyomo/duality/tests/test_linear_dual.py b/pyomo/duality/tests/test_linear_dual.py index ba3554bdc50..da8ba7a370c 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-2022 +# 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 diff --git a/pyomo/environ/__init__.py b/pyomo/environ/__init__.py index 51c68449247..07b3dfad680 100644 --- a/pyomo/environ/__init__.py +++ b/pyomo/environ/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -50,6 +50,8 @@ def _do_import(pkg_name): 'pyomo.contrib.multistart', 'pyomo.contrib.preprocessing', 'pyomo.contrib.pynumero', + 'pyomo.contrib.simplification', + 'pyomo.contrib.solver', 'pyomo.contrib.trustregion', ] @@ -114,6 +116,8 @@ def _import_packages(): exactly, atleast, atmost, + all_different, + count_if, implies, lnot, xor, diff --git a/pyomo/environ/tests/__init__.py b/pyomo/environ/tests/__init__.py index b1d721839c7..61e159c169b 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-2022 +# 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 diff --git a/pyomo/environ/tests/standalone_minimal_pyomo_driver.py b/pyomo/environ/tests/standalone_minimal_pyomo_driver.py index 88f8e9f8651..ee503032040 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-2022 +# 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 @@ -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 b223ba0e916..d7dcad76a1f 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-2022 +# 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 @@ -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 0bc8c55113a..47c6422a879 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-2022 +# 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 @@ -38,6 +38,7 @@ _NON_MODULE_DIRS = { join('contrib', 'ampl_function_demo', 'src'), join('contrib', 'appsi', 'cmodel', '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 new file mode 100644 index 00000000000..8f6af01f503 --- /dev/null +++ b/pyomo/future.py @@ -0,0 +1,116 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.environ as _environ + +__doc__ = """ +Preview capabilities through ``pyomo.__future__`` +================================================= + +This module provides a uniform interface for gaining access to future +("preview") capabilities that are either slightly incompatible with the +current official offering, or are still under development with the +intent to replace the current offering. + +Currently supported ``__future__`` offerings include: + +.. autosummary:: + + solver_factory + +""" + + +def __getattr__(name): + if name in ('solver_factory_v1', 'solver_factory_v2', 'solver_factory_v3'): + return solver_factory(int(name[-1])) + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") + + +def solver_factory(version=None): + """Get (or set) the active implementation of the SolverFactory + + This allows users to query / set the current implementation of the + SolverFactory that should be used throughout Pyomo. Valid options are: + + - ``1``: the original Pyomo SolverFactory + - ``2``: the SolverFactory from APPSI + - ``3``: the SolverFactory from pyomo.contrib.solver + + The current active version can be obtained by calling the method + with no arguments + + .. doctest:: + + >>> from pyomo.__future__ import solver_factory + >>> solver_factory() + 1 + + The active factory can be set either by passing the appropriate + version to this function: + + .. doctest:: + + >>> solver_factory(3) + + + or by importing the "special" name: + + .. doctest:: + + >>> from pyomo.__future__ import solver_factory_v3 + + .. doctest:: + :hide: + + >>> from pyomo.__future__ import solver_factory_v1 + + """ + import pyomo.opt.base.solvers as _solvers + import pyomo.contrib.solver.factory as _contrib + import pyomo.contrib.appsi.base as _appsi + + versions = { + 1: _solvers.LegacySolverFactory, + 2: _appsi.SolverFactory, + 3: _contrib.SolverFactory, + } + + current = getattr(solver_factory, '_active_version', None) + # First time through, _active_version is not defined. Go look and + # see what it was initialized to in pyomo.environ + if current is None: + for ver, cls in versions.items(): + if cls._cls is _environ.SolverFactory._cls: + solver_factory._active_version = ver + break + return solver_factory._active_version + # + # The user is just asking what the current SolverFactory is; tell them. + if version is None: + return solver_factory._active_version + # + # Update the current SolverFactory to be a shim around (shallow copy + # of) the new active factory + src = versions.get(version, None) + if version is not None: + solver_factory._active_version = version + for attr in ('_description', '_cls', '_doc'): + setattr(_environ.SolverFactory, attr, getattr(src, attr)) + else: + raise ValueError( + "Invalid value for target solver factory version; expected {1, 2, 3}, " + f"received {version}" + ) + return src + + +solver_factory._active_version = solver_factory() diff --git a/pyomo/gdp/__init__.py b/pyomo/gdp/__init__.py index 6fc2d4b7351..d204369cdba 100644 --- a/pyomo/gdp/__init__.py +++ b/pyomo/gdp/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 69313ac2b1b..56a19e2a0f2 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-2022 +# 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 diff --git a/pyomo/gdp/disjunct.py b/pyomo/gdp/disjunct.py index eca6d93d732..bfaada8f3de 100644 --- a/pyomo/gdp/disjunct.py +++ b/pyomo/gdp/disjunct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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: @@ -700,6 +704,10 @@ def construct(self, data=None): timer = ConstructionTimer(self) self._constructed = True + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + _self_parent = self.parent_block() if not self.is_indexed(): if self._init_rule is not None: @@ -759,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 @@ -772,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 1222ce500f1..e419d833e2e 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-2022 +# 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 @@ -11,14 +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 + 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 fad783d595d..8f57164334e 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-2022 +# 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 diff --git a/pyomo/gdp/plugins/bigm.py b/pyomo/gdp/plugins/bigm.py index e554d5593ab..7fae0876a90 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-2022 +# 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 @@ -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,13 +229,12 @@ 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) - rhs = 1 if parent_disjunct is None else parent_disjunct.binary_indicator_var if obj.xor: - xorConstraint[index] = or_expr == rhs + xorConstraint[index] = or_expr == 1 else: - xorConstraint[index] = or_expr >= rhs + xorConstraint[index] = or_expr >= 1 # Mark the DisjunctionData as transformed by mapping it to its XOR # constraint. obj._algebraic_constraint = weakref_ref(xorConstraint[index]) @@ -236,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. @@ -248,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 @@ -269,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() @@ -347,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 @@ -410,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 ( @@ -465,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: @@ -480,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): @@ -500,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 a4df641c8c6..1c3fcb2c64a 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-2022 +# 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 @@ -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 feacaaddefc..bc91836ea9c 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-2022 +# 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 @@ -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 new file mode 100644 index 00000000000..bea33580ed6 --- /dev/null +++ b/pyomo/gdp/plugins/binary_multiplication.py @@ -0,0 +1,176 @@ +# ___________________________________________________________________________ +# +# 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 .gdp_to_mip_transformation import GDP_to_MIP_Transformation +from pyomo.common.config import ConfigDict, ConfigValue +from pyomo.core.base import TransformationFactory +from pyomo.core.util import target_list +from pyomo.gdp import Disjunction +from weakref import ref as weakref_ref +import logging + + +logger = logging.getLogger(__name__) + + +@TransformationFactory.register( + 'gdp.binary_multiplication', + doc="Reformulate the GDP as an MINLP by multiplying f(x) <= 0 by y to get " + "f(x) * y <= 0 where y is the binary corresponding to the Boolean indicator " + "var of the Disjunct containing f(x) <= 0.", +) +class GDPBinaryMultiplicationTransformation(GDP_to_MIP_Transformation): + CONFIG = ConfigDict("gdp.binary_multiplication") + CONFIG.declare( + 'targets', + ConfigValue( + default=None, + domain=target_list, + description="target or list of targets that will be transformed", + doc=""" + + This specifies the list of components to transform. 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.""", + ), + ) + + transformation_name = 'binary_multiplication' + + def __init__(self): + super().__init__(logger) + + def _apply_to(self, instance, **kwds): + try: + self._apply_to_impl(instance, **kwds) + finally: + self._restore_state() + + def _apply_to_impl(self, instance, **kwds): + self._process_arguments(instance, **kwds) + + # filter out inactive targets and handle case where targets aren't + # specified. + targets = self._filter_targets(instance) + # transform logical constraints based on targets + self._transform_logical_constraints(instance, targets) + # we need to preprocess targets to make sure that if there are any + # disjunctions in targets that their disjuncts appear before them in + # the list. + gdp_tree = self._get_gdp_tree_from_targets(instance, targets) + preprocessed_targets = gdp_tree.reverse_topological_sort() + + for t in preprocessed_targets: + if t.ctype is Disjunction: + self._transform_disjunctionData( + t, + t.index(), + parent_disjunct=gdp_tree.parent(t), + root_disjunct=gdp_tree.root_disjunct(t), + ) + + def _transform_disjunctionData( + self, obj, index, parent_disjunct=None, root_disjunct=None + ): + (transBlock, xorConstraint) = self._setup_transform_disjunctionData( + obj, root_disjunct + ) + + # add or (or xor) constraint + or_expr = 0 + for disjunct in obj.disjuncts: + or_expr += disjunct.binary_indicator_var + self._transform_disjunct(disjunct, transBlock) + + if obj.xor: + xorConstraint[index] = or_expr == 1 + else: + xorConstraint[index] = or_expr >= 1 + # Mark the DisjunctionData as transformed by mapping it to its XOR + # constraint. + obj._algebraic_constraint = weakref_ref(xorConstraint[index]) + + # and deactivate for the writers + obj.deactivate() + + def _transform_disjunct(self, obj, transBlock): + # 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. + if not obj.active: + return + + relaxationBlock = self._get_disjunct_transformation_block(obj, transBlock) + + # Transform each component within this disjunct + self._transform_block_components(obj, obj) + + # deactivate disjunct to keep the writers happy + obj._deactivate_without_fixing_indicator() + + def _transform_constraint(self, obj, disjunct): + # add constraint to the transformation block, we'll transform it there. + transBlock = disjunct._transformation_block() + constraint_map = transBlock.private_data('pyomo.gdp') + + disjunctionRelaxationBlock = transBlock.parent_block() + + # 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 + # products is kind of expensive (and redundant since we have the + # original model) + newConstraint = transBlock.transformedConstraints + + for i in sorted(obj.keys()): + c = obj[i] + if not c.active: + continue + + self._add_constraint_expressions( + 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, 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 + # transformedConstraints is guaranteed to be unique. We just grab the + # current length of the list here since that will be monotonically + # increasing and hence unique. We'll append it to the + # slightly-more-human-readable constraint name for something familiar + # but unique. (Note that we really could do this outside of the loop + # over the constraint indices, but I don't think it matters a lot.) + unique = len(newConstraint) + name = c.local_name + "_%s" % unique + 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']) + 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']) + 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']) + 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 56a39115f34..7c90c24d869 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-2022 +# 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 diff --git a/pyomo/gdp/plugins/chull.py b/pyomo/gdp/plugins/chull.py deleted file mode 100644 index d226c57aae7..00000000000 --- a/pyomo/gdp/plugins/chull.py +++ /dev/null @@ -1,20 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 7a6a927a316..4cef098eba9 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-2022 +# 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 @@ -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 d0f59ce87ce..172363caab7 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-2022 +# 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 @@ -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 0aa5ec163b6..8dcd22b292a 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-2022 +# 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 @@ -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 @@ -213,21 +225,26 @@ def _setup_transform_disjunctionData(self, obj, root_disjunct): "likely indicative of a modeling error." % obj.name ) - # Create or fetch the transformation block + # We always need to create or fetch a transformation block on the parent block. + trans_block, new_block = self._add_transformation_block(obj.parent_block()) + # This is where we put exactly_one/or constraint + algebraic_constraint = self._add_xor_constraint( + obj.parent_component(), trans_block + ) + + # If requested, create or fetch the transformation block above the + # nested hierarchy if root_disjunct is not None: - # We want to put all the transformed things on the root - # Disjunct's parent's block so that they do not get - # re-transformed - transBlock, new_block = self._add_transformation_block( + # We want to put some transformed things on the root Disjunct's + # parent's block so that they do not get re-transformed. (Note this + # is never true for hull, but it calls this method with + # root_disjunct=None. BigM can't put the exactly-one constraint up + # here, but it can put everything else.) + trans_block, new_block = self._add_transformation_block( root_disjunct.parent_block() ) - else: - # This isn't nested--just put it on the parent block. - transBlock, new_block = self._add_transformation_block(obj.parent_block()) - - xorConstraint = self._add_xor_constraint(obj.parent_component(), transBlock) - return transBlock, xorConstraint + return trans_block, algebraic_constraint def _get_disjunct_transformation_block(self, disjunct, transBlock): if disjunct.transformation_block is not None: @@ -238,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 df659670bf4..7b1df0bb68f 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-2022 +# 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 @@ -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 a600ef76bc7..b7c244dc0bc 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-2022 +# 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 @@ -11,13 +11,16 @@ import logging +from collections import defaultdict + +from pyomo.common.autoslots import AutoSlots import pyomo.common.config as cfg from pyomo.common import deprecated -from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.collections import ComponentMap, ComponentSet, DefaultComponentMap 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, @@ -39,6 +42,7 @@ Binary, ) from pyomo.gdp import Disjunct, Disjunction, GDP_Error +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 ( @@ -47,11 +51,30 @@ _warn_for_active_disjunct, ) from pyomo.core.util import target_list +from pyomo.util.vars_from_expressions import get_vars_from_components from weakref import ref as weakref_ref logger = logging.getLogger('pyomo.gdp.hull') +class _HullTransformationData(AutoSlots.Mixin): + __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) + + @TransformationFactory.register( 'gdp.hull', doc="Relax disjunctive model by forming the hull reformulation." ) @@ -63,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 @@ -71,40 +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". - The block will have a dictionary "_disaggregatedVarMap: - 'srcVar': ComponentMap(:), - 'disaggregatedVar': ComponentMap(:) - - It will also have a ComponentMap "_bigMConstraintMap": - - : - - Last, it 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 - 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') @@ -204,33 +205,40 @@ def __init__(self): super().__init__(logger) self._targets = set() - def _add_local_vars(self, block, local_var_dict): + def _collect_local_vars_from_block(self, block, local_var_dict): localVars = block.component('LocalVars') - if type(localVars) is Suffix: + if localVars is not None and localVars.ctype is Suffix: for disj, var_list in localVars.items(): - if local_var_dict.get(disj) is None: - local_var_dict[disj] = ComponentSet(var_list) - else: - local_var_dict[disj].update(var_list) - - def _get_local_var_suffixes(self, block, local_var_dict): - # You can specify suffixes on any block (disjuncts included). This - # method starts from a Disjunct (presumably) and checks for a LocalVar - # suffixes going both up and down the tree, adding them into the - # dictionary that is the second argument. - - # first look beneath where we are (there could be Blocks on this - # disjunct) - for b in block.component_data_objects( - Block, descend_into=(Block), active=True, sort=SortComponents.deterministic - ): - self._add_local_vars(b, local_var_dict) - # now traverse upwards and get what's above - while block is not None: - self._add_local_vars(block, local_var_dict) - block = block.parent_block() - - return local_var_dict + local_var_dict[disj].update(var_list) + + def _get_user_defined_local_vars(self, targets): + user_defined_local_vars = defaultdict(ComponentSet) + seen_blocks = set() + # we go through the targets looking both up and down the hierarchy, but + # we cache what Blocks/Disjuncts we've already looked on so that we + # don't duplicate effort. + for t in targets: + if t.ctype is Disjunct: + # first look beneath where we are (there could be Blocks on this + # disjunct) + for b in t.component_data_objects( + Block, + descend_into=Block, + active=True, + sort=SortComponents.deterministic, + ): + if b not in seen_blocks: + self._collect_local_vars_from_block(b, user_defined_local_vars) + seen_blocks.add(b) + # now look up in the tree + blk = t + while blk is not None: + if blk in seen_blocks: + break + self._collect_local_vars_from_block(blk, user_defined_local_vars) + seen_blocks.add(blk) + blk = blk.parent_block() + return user_defined_local_vars def _apply_to(self, instance, **kwds): try: @@ -239,7 +247,6 @@ def _apply_to(self, instance, **kwds): self._restore_state() self._transformation_blocks.clear() self._algebraic_constraints.clear() - self._targets_set = set() def _apply_to_impl(self, instance, **kwds): self._process_arguments(instance, **kwds) @@ -253,16 +260,17 @@ def _apply_to_impl(self, instance, **kwds): # Preprocess in order to find what disjunctive components need # transformation gdp_tree = self._get_gdp_tree_from_targets(instance, targets) - preprocessed_targets = gdp_tree.topological_sort() - self._targets_set = set(preprocessed_targets) + # Transform from leaf to root: This is important for hull because for + # nested GDPs, we will introduce variables that need disaggregating into + # parent Disjuncts as we transform their child Disjunctions. + preprocessed_targets = gdp_tree.reverse_topological_sort() + # Get all LocalVars from Suffixes ahead of time + local_vars_by_disjunct = self._get_user_defined_local_vars(preprocessed_targets) for t in preprocessed_targets: if t.ctype is Disjunction: self._transform_disjunctionData( - t, - t.index(), - parent_disjunct=gdp_tree.parent(t), - root_disjunct=gdp_tree.root_disjunct(t), + t, t.index(), gdp_tree.parent(t), local_vars_by_disjunct ) # We skip disjuncts now, because we need information from the # disjunctions to transform them (which variables to disaggregate), @@ -274,23 +282,11 @@ def _add_transformation_block(self, to_block): return transBlock, new_block transBlock.lbub = Set(initialize=['lb', 'ub', 'eq']) - # Map between disaggregated variables and their - # originals - transBlock._disaggregatedVarMap = { - 'srcVar': ComponentMap(), - 'disaggregatedVar': ComponentMap(), - } - # Map between disaggregated variables and their lb*indicator <= var <= - # ub*indicator constraints - transBlock._bigMConstraintMap = ComponentMap() + # We will store all of the disaggregation constraints for any # 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) @@ -299,46 +295,55 @@ def _add_transformation_block(self, to_block): return transBlock, True def _transform_disjunctionData( - self, obj, index, parent_disjunct=None, root_disjunct=None + self, obj, index, parent_disjunct, local_vars_by_disjunct ): # Hull reformulation doesn't work if this is an OR constraint. So if # xor is false, give up if not obj.xor: raise GDP_Error( "Cannot do hull reformulation for " - "Disjunction '%s' with OR constraint. " + "Disjunction '%s' with OR constraint. " "Must be an XOR!" % obj.name ) - + # collect the Disjuncts we are going to transform now because we will + # change their active status when we transform them, but we still need + # this list after the fact. + active_disjuncts = [disj for disj in obj.disjuncts if disj.active] + + # We put *all* transformed things on the parent Block of this + # disjunction. We'll mark the disaggregated Vars as local, but beyond + # that, we actually need everything to get transformed again as we go up + # the nested hierarchy (if there is one) transBlock, xorConstraint = self._setup_transform_disjunctionData( - obj, root_disjunct + obj, root_disjunct=None ) disaggregationConstraint = transBlock.disaggregationConstraints - disaggregationConstraintMap = transBlock._disaggregationConstraintMap + disaggregationConstraintMap = ( + transBlock.private_data().disaggregation_constraint_map + ) disaggregatedVars = transBlock._disaggregatedVars disaggregated_var_bounds = transBlock._boundsConstraints - # We first go through and collect all the variables that we - # are going to disaggregate. - varOrder_set = ComponentSet() - varOrder = [] - varsByDisjunct = ComponentMap() - localVarsByDisjunct = ComponentMap() - include_fixed_vars = not self._config.assume_fixed_vars_permanent - for disjunct in obj.disjuncts: - if not disjunct.active: - continue - disjunctVars = varsByDisjunct[disjunct] = ComponentSet() + # We first go through and collect all the variables that we are going to + # disaggregate. We do this in its own pass because we want to know all + # the Disjuncts that each Var appears in since that will tell us exactly + # which diaggregated variables we need. + var_order = ComponentSet() + disjuncts_var_appears_in = ComponentMap() + # For each disjunct in the disjunction, we will store a list of Vars + # that need a disaggregated counterpart in that disjunct. + disjunct_disaggregated_var_map = {} + for disjunct in active_disjuncts: # create the key for each disjunct now - transBlock._disaggregatedVarMap['disaggregatedVar'][ - disjunct - ] = ComponentMap() - for cons in disjunct.component_data_objects( + disjunct_disaggregated_var_map[disjunct] = ComponentMap() + for var in get_vars_from_components( + disjunct, Constraint, + include_fixed=not self._config.assume_fixed_vars_permanent, active=True, sort=SortComponents.deterministic, - descend_into=(Block, Disjunct), + descend_into=Block, ): # [ESJ 02/14/2020] By default, we disaggregate fixed variables # on the philosophy that fixing is not a promise for the future @@ -347,189 +352,151 @@ def _transform_disjunctionData( # with their transformed model. However, the user may have set # assume_fixed_vars_permanent to True in which case we will skip # them - for var in EXPR.identify_variables( - cons.body, include_fixed=include_fixed_vars - ): - # Note the use of a list so that we will - # eventually disaggregate the vars in a - # deterministic order (the order that we found - # them) - disjunctVars.add(var) - if not var in varOrder_set: - varOrder.append(var) - varOrder_set.add(var) - - # check for LocalVars Suffix - localVarsByDisjunct = self._get_local_var_suffixes( - disjunct, localVarsByDisjunct - ) - # We will disaggregate all variables that are not explicitly declared as - # being local. Since we transform from leaf to root, we are implicitly - # treating our own disaggregated variables as local, so they will not be + # Note that, because ComponentSets are ordered, we will + # eventually disaggregate the vars in a deterministic order + # (the order that we found them) + if var not in var_order: + var_order.add(var) + disjuncts_var_appears_in[var] = ComponentSet([disjunct]) + else: + disjuncts_var_appears_in[var].add(disjunct) + + # Now, we will disaggregate all variables that are not explicitly + # declared as being local. If we are moving up in a nested tree, we have + # marked our own disaggregated variables as local, so they will not be # re-disaggregated. - varSet = [] - varSet = {disj: [] for disj in obj.disjuncts} - # Note that variables are local with respect to a Disjunct. We deal with - # them here to do some error checking (if something is obviously not - # local since it is used in multiple Disjuncts in this Disjunction) and - # also to get a deterministic order in which to process them when we - # transform the Disjuncts: Values of localVarsByDisjunct are - # ComponentSets, so we need this for determinism (we iterate through the - # localVars of a Disjunct later) - localVars = ComponentMap() - varsToDisaggregate = [] - disjunctsVarAppearsIn = ComponentMap() - for var in varOrder: - disjuncts = disjunctsVarAppearsIn[var] = [ - d for d in varsByDisjunct if var in varsByDisjunct[d] - ] + vars_to_disaggregate = {disj: ComponentSet() for disj in obj.disjuncts} + all_vars_to_disaggregate = ComponentSet() + # We will ignore variables declared as local in a Disjunct that don't + # actually appear in any Constraints on that Disjunct, but in order to + # do this, we will explicitly collect the set of local_vars in this + # loop. + local_vars = defaultdict(ComponentSet) + for var in var_order: + disjuncts = disjuncts_var_appears_in[var] # clearly not local if used in more than one disjunct if len(disjuncts) > 1: if self._generate_debug_messages: logger.debug( "Assuming '%s' is not a local var since it is" - "used in multiple disjuncts." - % var.getname(fully_qualified=True) + "used in multiple disjuncts." % var.name ) for disj in disjuncts: - varSet[disj].append(var) - varsToDisaggregate.append(var) - # disjuncts is a list of length 1 - elif localVarsByDisjunct.get(disjuncts[0]) is not None: - if var in localVarsByDisjunct[disjuncts[0]]: - localVars_thisDisjunct = localVars.get(disjuncts[0]) - if localVars_thisDisjunct is not None: - localVars[disjuncts[0]].append(var) - else: - localVars[disjuncts[0]] = [var] - else: - # It's not local to this Disjunct - varSet[disjuncts[0]].append(var) - varsToDisaggregate.append(var) - else: - # We don't even have have any local vars for this Disjunct. - varSet[disjuncts[0]].append(var) - varsToDisaggregate.append(var) + vars_to_disaggregate[disj].add(var) + all_vars_to_disaggregate.add(var) + else: # var only appears in one disjunct + disjunct = next(iter(disjuncts)) + # We check if the user declared it as local + if disjunct in local_vars_by_disjunct: + if var in local_vars_by_disjunct[disjunct]: + local_vars[disjunct].add(var) + continue + # It's not declared local to this Disjunct, so we + # disaggregate + vars_to_disaggregate[disjunct].add(var) + all_vars_to_disaggregate.add(var) # Now that we know who we need to disaggregate, we will do it # while we also transform the disjuncts. - local_var_set = self._get_local_var_set(obj) + + # Get the list of local variables for the parent Disjunct so that we can + # add the disaggregated variables we're about to make to it: + parent_local_var_list = self._get_local_var_list(parent_disjunct) or_expr = 0 for disjunct in obj.disjuncts: or_expr += disjunct.indicator_var.get_associated_binary() - self._transform_disjunct( - disjunct, - transBlock, - varSet[disjunct], - localVars.get(disjunct, []), - local_var_set, - ) - rhs = 1 if parent_disjunct is None else parent_disjunct.binary_indicator_var - xorConstraint.add(index, (or_expr, rhs)) + if disjunct.active: + self._transform_disjunct( + obj=disjunct, + transBlock=transBlock, + vars_to_disaggregate=vars_to_disaggregate[disjunct], + local_vars=local_vars[disjunct], + parent_local_var_suffix=parent_local_var_list, + parent_disjunct_local_vars=local_vars_by_disjunct[parent_disjunct], + disjunct_disaggregated_var_map=disjunct_disaggregated_var_map, + ) + xorConstraint.add(index, (or_expr, 1)) # map the DisjunctionData to its XOR constraint to mark it as # transformed obj._algebraic_constraint = weakref_ref(xorConstraint[index]) - # add the reaggregation constraints - for i, var in enumerate(varsToDisaggregate): + # Now add the reaggregation constraints + for var in all_vars_to_disaggregate: # There are two cases here: Either the var appeared in every # disjunct in the disjunction, or it didn't. If it did, there's # nothing special to do: All of the disaggregated variables have # been created, and we can just proceed and make this constraint. If # it didn't, we need one more disaggregated variable, correctly # defined. And then we can make the constraint. - if len(disjunctsVarAppearsIn[var]) < len(obj.disjuncts): + if len(disjuncts_var_appears_in[var]) < len(active_disjuncts): # create one more disaggregated var idx = len(disaggregatedVars) disaggregated_var = disaggregatedVars[idx] - # mark this as local because we won't re-disaggregate if this is - # a nested disjunction - if local_var_set is not None: - local_var_set.append(disaggregated_var) + # mark this as local because we won't re-disaggregate it if this + # is a nested disjunction + if parent_local_var_list is not None: + parent_local_var_list.append(disaggregated_var) + local_vars_by_disjunct[parent_disjunct].add(disaggregated_var) var_free = 1 - sum( disj.indicator_var.get_associated_binary() - for disj in disjunctsVarAppearsIn[var] + for disj in disjuncts_var_appears_in[var] ) self._declare_disaggregated_var_bounds( - var, - disaggregated_var, - obj, - disaggregated_var_bounds, - (idx, 'lb'), - (idx, 'ub'), - var_free, + original_var=var, + disaggregatedVar=disaggregated_var, + disjunct=obj, + bigmConstraint=disaggregated_var_bounds, + var_free_indicator=var_free, + var_idx=idx, ) - # maintain the mappings - for disj in obj.disjuncts: + 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. + for disj in active_disjuncts: # Because we called _transform_disjunct above, we know that # if this isn't transformed it is because it was cleanly # deactivated, and we can just skip it. if ( disj._transformation_block is not None - and disj not in disjunctsVarAppearsIn[var] + and disj not in disjuncts_var_appears_in[var] ): - relaxationBlock = disj._transformation_block().parent_block() - relaxationBlock._bigMConstraintMap[disaggregated_var] = ( - Reference(disaggregated_var_bounds[idx, :]) - ) - relaxationBlock._disaggregatedVarMap['srcVar'][ - disaggregated_var - ] = var - relaxationBlock._disaggregatedVarMap['disaggregatedVar'][disj][ - var - ] = disaggregated_var + disaggregated_var_map[disj][var] = disaggregated_var + # start the expression for the reaggregation constraint with + # this var disaggregatedExpr = disaggregated_var else: disaggregatedExpr = 0 - for disjunct in disjunctsVarAppearsIn[var]: - if disjunct._transformation_block is None: - # Because we called _transform_disjunct above, we know that - # if this isn't transformed it is because it was cleanly - # deactivated, and we can just skip it. - continue + for disjunct in disjuncts_var_appears_in[var]: + disaggregatedExpr += disjunct_disaggregated_var_map[disjunct][var] - disaggregatedVar = ( - disjunct._transformation_block() - .parent_block() - ._disaggregatedVarMap['disaggregatedVar'][disjunct][var] - ) - disaggregatedExpr += disaggregatedVar - - # We equate the sum of the disaggregated vars to var (the original) - # if parent_disjunct is None, else it needs to be the disaggregated - # var corresponding to var on the parent disjunct. This is the - # reason we transform from root to leaf: This constraint is now - # correct regardless of how nested something may have been. - parent_var = ( - var - if parent_disjunct is None - else self.get_disaggregated_var(var, parent_disjunct) - ) cons_idx = len(disaggregationConstraint) - disaggregationConstraint.add(cons_idx, parent_var == disaggregatedExpr) + # We always aggregate to the original var. If this is nested, this + # constraint will be transformed again. (And if it turns out + # everything in it is local, then that transformation won't actually + # change the mathematical expression, so it's okay. + disaggregationConstraint.add(cons_idx, var == disaggregatedExpr) # 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 disaggregationConstraintMap.get(var) is not None: - 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() - def _transform_disjunct(self, obj, transBlock, varSet, localVars, local_var_set): - # 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. - if not obj.active: - return - + def _transform_disjunct( + self, + obj, + transBlock, + vars_to_disaggregate, + local_vars, + parent_local_var_suffix, + parent_disjunct_local_vars, + disjunct_disaggregated_var_map, + ): relaxationBlock = self._get_disjunct_transformation_block(obj, transBlock) # Put the disaggregated variables all on their own block so that we can @@ -539,7 +506,7 @@ def _transform_disjunct(self, obj, transBlock, varSet, localVars, local_var_set) # add the disaggregated variables and their bigm constraints # to the relaxationBlock - for var in varSet: + for var in vars_to_disaggregate: disaggregatedVar = Var(within=Reals, initialize=var.value) # naming conflicts are possible here since this is a bunch # of variables from different blocks coming together, so we @@ -550,10 +517,13 @@ def _transform_disjunct(self, obj, transBlock, varSet, localVars, local_var_set) relaxationBlock.disaggregatedVars.add_component( disaggregatedVarName, disaggregatedVar ) - # mark this as local because we won't re-disaggregate if this is a - # nested disjunction - if local_var_set is not None: - local_var_set.append(disaggregatedVar) + # mark this as local via the Suffix in case this is a partial + # transformation: + if parent_local_var_suffix is not None: + parent_local_var_suffix.append(disaggregatedVar) + # Record that it's local for our own bookkeeping in case we're in a + # nested tree in *this* transformation + parent_disjunct_local_vars.add(disaggregatedVar) # add the bigm constraint bigmConstraint = Constraint(transBlock.lbub) @@ -562,19 +532,20 @@ def _transform_disjunct(self, obj, transBlock, varSet, localVars, local_var_set) ) self._declare_disaggregated_var_bounds( - var, - disaggregatedVar, - obj, - bigmConstraint, - 'lb', - 'ub', - obj.indicator_var.get_associated_binary(), - transBlock, + original_var=var, + disaggregatedVar=disaggregatedVar, + disjunct=obj, + bigmConstraint=bigmConstraint, + var_free_indicator=obj.indicator_var.get_associated_binary(), ) + # update the bigm constraint mappings + data_dict = disaggregatedVar.parent_block().private_data() + data_dict.bigm_constraint_map[disaggregatedVar][obj] = bigmConstraint + disjunct_disaggregated_var_map[obj][var] = disaggregatedVar - for var in localVars: - # we don't need to disaggregated, we can use this Var, but we do - # need to set up its bounds constraints. + for var in local_vars: + # we don't need to disaggregate, i.e., we can use this Var, but we + # do need to set up its bounds constraints. # naming conflicts are possible here since this is a bunch # of variables from different blocks coming together, so we @@ -585,36 +556,36 @@ def _transform_disjunct(self, obj, transBlock, varSet, localVars, local_var_set) bigmConstraint = Constraint(transBlock.lbub) relaxationBlock.add_component(conName, bigmConstraint) + parent_block = var.parent_block() + self._declare_disaggregated_var_bounds( - var, - var, - obj, - bigmConstraint, - 'lb', - 'ub', - obj.indicator_var.get_associated_binary(), - transBlock, + original_var=var, + disaggregatedVar=var, + disjunct=obj, + bigmConstraint=bigmConstraint, + var_free_indicator=obj.indicator_var.get_associated_binary(), ) + # update the bigm constraint mappings + data_dict = var.parent_block().private_data() + data_dict.bigm_constraint_map[var][obj] = bigmConstraint + disjunct_disaggregated_var_map[obj][var] = var var_substitute_map = dict( - (id(v), newV) - for v, newV in transBlock._disaggregatedVarMap['disaggregatedVar'][ - obj - ].items() + (id(v), newV) for v, newV in disjunct_disaggregated_var_map[obj].items() ) zero_substitute_map = dict( (id(v), ZeroConstant) - for v, newV in transBlock._disaggregatedVarMap['disaggregatedVar'][ - obj - ].items() + for v, newV in disjunct_disaggregated_var_map[obj].items() ) - zero_substitute_map.update((id(v), ZeroConstant) for v in localVars) # Transform each component within this disjunct self._transform_block_components( obj, obj, var_substitute_map, zero_substitute_map ) + # Anything that was local to this Disjunct is also local to the parent, + # and just got "promoted" up there, so to speak. + parent_disjunct_local_vars.update(local_vars) # deactivate disjunct so writers can be happy obj._deactivate_without_fixing_indicator() @@ -624,13 +595,16 @@ def _declare_disaggregated_var_bounds( disaggregatedVar, disjunct, bigmConstraint, - lb_idx, - ub_idx, var_free_indicator, - transBlock=None, + var_idx=None, ): - # If transBlock is None then this is a disaggregated variable for - # multiple Disjuncts and we will handle the mappings separately. + # 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: @@ -644,65 +618,51 @@ 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) + 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. - if transBlock is not None: - transBlock._disaggregatedVarMap['disaggregatedVar'][disjunct][ - original_var - ] = disaggregatedVar - transBlock._disaggregatedVarMap['srcVar'][disaggregatedVar] = original_var - transBlock._bigMConstraintMap[disaggregatedVar] = bigmConstraint - - def _get_local_var_set(self, disjunction): - # add Suffix to the relaxation block that disaggregated variables are - # local (in case this is nested in another Disjunct) - local_var_set = None - parent_disjunct = disjunction.parent_block() - while parent_disjunct is not None: - if parent_disjunct.ctype is Disjunct: - break - parent_disjunct = parent_disjunct.parent_block() + # the transformation block + disaggregated_var_map[disjunct][original_var] = disaggregatedVar + disaggregated_var_info.original_var_map[disaggregatedVar] = original_var + + def _get_local_var_list(self, parent_disjunct): + # Add or retrieve Suffix from parent_disjunct so that, if this is + # nested, we can use it to declare that the disaggregated variables are + # local. We return the list so that we can add to it. + local_var_list = None if parent_disjunct is not None: # This limits the cases that a user is allowed to name something # (other than a Suffix) 'LocalVars' on a Disjunct. But I am assuming # that the Suffix has to be somewhere above the disjunct in the # tree, so I can't put it on a Block that I own. And if I'm coopting # something of theirs, it may as well be here. - self._add_local_var_suffix(parent_disjunct) + self._get_local_var_suffix(parent_disjunct) if parent_disjunct.LocalVars.get(parent_disjunct) is None: parent_disjunct.LocalVars[parent_disjunct] = [] - local_var_set = parent_disjunct.LocalVars[parent_disjunct] + local_var_list = parent_disjunct.LocalVars[parent_disjunct] - return local_var_set - - def _warn_for_active_disjunct( - self, innerdisjunct, outerdisjunct, var_substitute_map, zero_substitute_map - ): - # We override the base class method because in hull, it might just be - # that we haven't gotten here yet. - disjuncts = ( - innerdisjunct.values() if innerdisjunct.is_indexed() else (innerdisjunct,) - ) - for disj in disjuncts: - if disj in self._targets_set: - # We're getting to this, have some patience. - continue - else: - # But if it wasn't in the targets after preprocessing, it - # doesn't belong in an active Disjunction that we are - # transforming and we should be confused. - _warn_for_active_disjunct(innerdisjunct, outerdisjunct) + return local_var_list def _transform_constraint( self, obj, disjunct, var_substitute_map, zero_substitute_map ): # 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 @@ -784,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 @@ -824,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: @@ -848,29 +808,21 @@ 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() - def _add_local_var_suffix(self, disjunct): + def _get_local_var_suffix(self, disjunct): # If the Suffix is there, we will borrow it. If not, we make it. If it's # something else, we complain. localSuffix = disjunct.component("LocalVars") @@ -885,7 +837,7 @@ def _add_local_var_suffix(self, disjunct): % (disjunct.getname(fully_qualified=True), localSuffix.ctype) ) - def get_disaggregated_var(self, v, disjunct): + def get_disaggregated_var(self, v, disjunct, raise_exception=True): """ Returns the disaggregated variable corresponding to the Var v and the Disjunct disjunct. @@ -899,15 +851,16 @@ def get_disaggregated_var(self, v, disjunct): """ if disjunct._transformation_block is None: raise GDP_Error("Disjunct '%s' has not been transformed" % disjunct.name) - transBlock = disjunct._transformation_block().parent_block() - try: - return transBlock._disaggregatedVarMap['disaggregatedVar'][disjunct][v] - except: - logger.error( - "It does not appear '%s' is a " - "variable that appears in disjunct '%s'" % (v.name, disjunct.name) - ) - raise + msg = ( + "It does not appear '%s' is a " + "variable that appears in disjunct '%s'" % (v.name, disjunct.name) + ) + disaggregated_var_map = v.parent_block().private_data().disaggregated_var_map + if v in disaggregated_var_map[disjunct]: + return disaggregated_var_map[disjunct][v] + else: + if raise_exception: + raise GDP_Error(msg) def get_src_var(self, disaggregated_var): """ @@ -916,35 +869,24 @@ def get_src_var(self, disaggregated_var): Parameters ---------- - disaggregated_var: a Var which was created by the hull + disaggregated_var: a Var that was created by the hull transformation as a disaggregated variable (and so appears on a transformation block of some Disjunct) """ - msg = ( + var_map = disaggregated_var.parent_block().private_data() + if disaggregated_var in var_map.original_var_map: + return var_map.original_var_map[disaggregated_var] + raise GDP_Error( "'%s' does not appear to be a " "disaggregated variable" % disaggregated_var.name ) - # There are two possibilities: It is declared on a Disjunct - # transformation Block, or it is declared on the parent of a Disjunct - # transformation block (if it is a single variable for multiple - # Disjuncts the original doesn't appear in) - transBlock = disaggregated_var.parent_block() - if not hasattr(transBlock, '_disaggregatedVarMap'): - try: - transBlock = transBlock.parent_block().parent_block() - except: - logger.error(msg) - raise - try: - return transBlock._disaggregatedVarMap['srcVar'][disaggregated_var] - except: - logger.error(msg) - raise # retrieves the disaggregation constraint for original_var resulting from # transforming disjunction - def get_disaggregation_constraint(self, original_var, disjunction): + def get_disaggregation_constraint( + self, original_var, disjunction, raise_exception=True + ): """ Returns the disaggregation (re-aggregation?) constraint (which links the disaggregated variables to their original) @@ -957,7 +899,7 @@ def get_disaggregation_constraint(self, original_var, disjunction): disjunction: a transformed Disjunction containing original_var """ for disjunct in disjunction.disjuncts: - transBlock = disjunct._transformation_block + transBlock = disjunct.transformation_block if transBlock is not None: break if transBlock is None: @@ -968,50 +910,69 @@ def get_disaggregation_constraint(self, original_var, disjunction): ) try: - return ( - transBlock() - .parent_block() - ._disaggregationConstraintMap[original_var][disjunction] + cons = ( + transBlock.parent_block() + .private_data() + .disaggregation_constraint_map[original_var][disjunction] ) except: - logger.error( - "It doesn't appear that '%s' is a variable that was " - "disaggregated by Disjunction '%s'" - % (original_var.name, disjunction.name) - ) - raise + if raise_exception: + logger.error( + "It doesn't appear that '%s' is a variable that was " + "disaggregated by Disjunction '%s'" + % (original_var.name, disjunction.name) + ) + raise + return None + while not cons.active: + cons = self.get_transformed_constraints(cons)[0] + return cons - def get_var_bounds_constraint(self, v): + 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 ---------- - v: a Var which was created by the hull transformation as a + v: a Var that was created by the hull transformation as a disaggregated variable (and so appears on a transformation block of some Disjunct) + disjunct: (For nested Disjunctions) Which Disjunct in the + hierarchy the bounds Constraint should correspond to. + Optional since for non-nested models this can be inferred. """ - msg = ( + info = v.parent_block().private_data() + if v in info.bigm_constraint_map: + if len(info.bigm_constraint_map[v]) == 1: + # Not nested, or it's at the top layer, so we're fine. + return list(info.bigm_constraint_map[v].values())[0] + elif disjunct is not None: + # This is nested, so we need to walk up to find the active ones + return info.bigm_constraint_map[v][disjunct] + else: + raise ValueError( + "It appears that the variable '%s' appears " + "within a nested GDP hierarchy, and no " + "'disjunct' argument was specified. Please " + "specify for which Disjunct the bounds " + "constraint for '%s' should be returned." % (v, v) + ) + raise GDP_Error( "Either '%s' is not a disaggregated variable, or " "the disjunction that disaggregates it has not " "been properly transformed." % v.name ) - # This can only go well if v is a disaggregated var - transBlock = v.parent_block() - if not hasattr(transBlock, '_bigMConstraintMap'): - try: - transBlock = transBlock.parent_block().parent_block() - except: - logger.error(msg) - raise - try: - return transBlock._bigMConstraintMap[v] - except: - logger.error(msg) - raise + + def get_transformed_constraints(self, cons): + cons = super().get_transformed_constraints(cons) + while not cons[0].active: + transformed_cons = [] + for con in cons: + transformed_cons += super().get_transformed_constraints(con) + cons = transformed_cons + return cons @TransformationFactory.register( diff --git a/pyomo/gdp/plugins/multiple_bigm.py b/pyomo/gdp/plugins/multiple_bigm.py index 85fb1e4aa6b..9ee5c9180ff 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-2022 +# 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 @@ -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 @@ -31,7 +31,6 @@ NonNegativeIntegers, Objective, Param, - RangeSet, Set, SetOf, SortComponents, @@ -60,6 +59,26 @@ logger = logging.getLogger('pyomo.gdp.mbigm') +_trusted_solvers = { + 'gurobi', + 'cplex', + 'cbc', + 'glpk', + 'scip', + 'xpress', + 'mosek', + 'baron', + 'highs', +} + + +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', @@ -116,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", ), @@ -201,9 +221,9 @@ class MultipleBigMTransformation(GDP_to_MIP_Transformation, _BigM_MixIn): def __init__(self): super().__init__(logger) - self.handlers[Suffix] = self._warn_for_active_suffix self._arg_list = {} self._set_up_expr_bound_visitor() + self.handlers[Suffix] = self._warn_for_active_suffix def _apply_to(self, instance, **kwds): self.used_args = ComponentMap() @@ -299,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( @@ -325,8 +348,7 @@ def _transform_disjunctionData(self, obj, index, parent_disjunct, root_disjunct) for disjunct in active_disjuncts: or_expr += disjunct.indicator_var.get_associated_binary() self._transform_disjunct(disjunct, transBlock, active_disjuncts, Ms) - rhs = 1 if parent_disjunct is None else parent_disjunct.binary_indicator_var - algebraic_constraint.add(index, (or_expr, rhs)) + algebraic_constraint.add(index, or_expr == 1) # map the DisjunctionData to its XOR constraint to mark it as # transformed obj._algebraic_constraint = weakref_ref(algebraic_constraint[index]) @@ -346,17 +368,10 @@ def _transform_disjunct(self, obj, transBlock, active_disjuncts, Ms): # deactivate disjunct so writers can be happy obj._deactivate_without_fixing_indicator() - def _warn_for_active_suffix(self, obj, disjunct, active_disjuncts, Ms): - raise GDP_Error( - "Found active Suffix '{0}' on Disjunct '{1}'. " - "The multiple bigM transformation does not currently " - "support Suffixes.".format(obj.name, disjunct.name) - ) - 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 @@ -375,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() @@ -394,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) @@ -424,11 +438,11 @@ 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 - c.deactivate() + # deactivate now that we have transformed + c.deactivate() def _transform_bound_constraints(self, active_disjuncts, transBlock, Ms): # first we're just going to find all of them @@ -493,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: @@ -524,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 @@ -597,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() @@ -631,40 +631,34 @@ def _calculate_missing_M_values( self.used_args[constraint, other_disjunct] = (lower_M, upper_M) else: (lower_M, upper_M) = (None, None) + unsuccessful_solve_msg = ( + "Unsuccessful solve to calculate M value to " + "relax constraint '%s' on Disjunct '%s' when " + "Disjunct '%s' is selected." + % (constraint.name, disjunct.name, other_disjunct.name) + ) if constraint.lower is not None and lower_M is None: # last resort: calculate if lower_M is None: scratch.obj.expr = constraint.body - constraint.lower scratch.obj.sense = minimize - results = self._config.solver.solve(other_disjunct) - if ( - results.solver.termination_condition - is not TerminationCondition.optimal - ): - raise GDP_Error( - "Unsuccessful solve to calculate M value to " - "relax constraint '%s' on Disjunct '%s' when " - "Disjunct '%s' is selected." - % (constraint.name, disjunct.name, other_disjunct.name) - ) - lower_M = value(scratch.obj.expr) + lower_M = self._solve_disjunct_for_M( + other_disjunct, + scratch, + unsuccessful_solve_msg, + active_disjuncts, + ) if constraint.upper is not None and upper_M is None: # last resort: calculate if upper_M is None: scratch.obj.expr = constraint.body - constraint.upper scratch.obj.sense = maximize - results = self._config.solver.solve(other_disjunct) - if ( - results.solver.termination_condition - is not TerminationCondition.optimal - ): - raise GDP_Error( - "Unsuccessful solve to calculate M value to " - "relax constraint '%s' on Disjunct '%s' when " - "Disjunct '%s' is selected." - % (constraint.name, disjunct.name, other_disjunct.name) - ) - upper_M = value(scratch.obj.expr) + upper_M = self._solve_disjunct_for_M( + 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) @@ -674,6 +668,70 @@ def _calculate_missing_M_values( return arg_Ms + def _solve_disjunct_for_M( + 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 + # need this check since we can actually determine from the + # termination condition whether or not the solver proved + # infeasibility or just terminated at local infeasiblity. For now, + # while this is not complete, it catches most of the solvers we + # trust, and, unless someone is so pathological as to *rename* an + # untrusted solver using a trusted solver name, it will never do the + # *wrong* thing. + if any(s in solver.name for s in _trusted_solvers): + logger.debug( + "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 + # 'infeasible' for local infeasibility, so we + # can't deactivate with confidence. To be + # conservative, we'll just complain about + # it. Post-solver-rewrite we will want to change + # this so that we check for 'proven_infeasible' + # and then we can abandon this hack + raise GDP_Error(unsuccessful_solve_msg) + elif results.solver.termination_condition is not TerminationCondition.optimal: + raise GDP_Error(unsuccessful_solve_msg) + else: + other_disjunct.solutions.load_from(results) + M = value(scratch_block.obj.expr) + return M + + def _warn_for_active_suffix(self, suffix, disjunct, active_disjuncts, Ms): + if suffix.local_name == 'BigM': + logger.debug( + "Found active 'BigM' Suffix on '{0}'. " + "The multiple bigM transformation does not currently " + "support specifying M's with Suffixes and is ignoring " + "this Suffix.".format(disjunct.name) + ) + elif suffix.local_name == 'LocalVars': + # This is fine, but this transformation doesn't need anything from it + pass + else: + raise GDP_Error( + "Found active Suffix '{0}' on Disjunct '{1}'. " + "The multiple bigM transformation does not " + "support this Suffix.".format(suffix.name, disjunct.name) + ) + # These are all functions to retrieve transformed components from # original ones and vice versa. diff --git a/pyomo/gdp/plugins/partition_disjuncts.py b/pyomo/gdp/plugins/partition_disjuncts.py index fbe25ed3ae1..3884adcc048 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-2022 +# 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 @@ -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 338f42c68da..3e20224ec3d 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-2022 +# 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 diff --git a/pyomo/gdp/tests/__init__.py b/pyomo/gdp/tests/__init__.py index c5e495e5aa3..a2a2c61779a 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-2022 +# 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 diff --git a/pyomo/gdp/tests/common_tests.py b/pyomo/gdp/tests/common_tests.py index b475334981b..50bc8b05f86 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-2022 +# 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 @@ -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 @@ -58,6 +58,24 @@ def check_linear_coef(self, repn, var, coef): self.assertAlmostEqual(repn.linear_coefs[var_id], coef) +def check_quadratic_coef(self, repn, v1, v2, coef): + if isinstance(v1, BooleanVar): + v1 = v1.get_associated_binary() + if isinstance(v2, BooleanVar): + v2 = v2.get_associated_binary() + + v1id = id(v1) + v2id = id(v2) + + qcoef_map = dict() + for (_v1, _v2), _coef in zip(repn.quadratic_vars, repn.quadratic_coefs): + qcoef_map[id(_v1), id(_v2)] = _coef + qcoef_map[id(_v2), id(_v1)] = _coef + + self.assertIn((v1id, v2id), qcoef_map) + self.assertAlmostEqual(qcoef_map[v1id, v2id], coef) + + def check_squared_term_coef(self, repn, var, coef): var_id = None for i, (v1, v2) in enumerate(repn.quadratic_vars): @@ -407,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) @@ -679,32 +692,29 @@ def check_indexedDisj_only_targets_transformed(self, transformation): trans.get_transformed_constraints(m.disjunct1[1, 0].c)[0] .parent_block() .parent_block(), - disjBlock[2], + disjBlock[0], ) self.assertIs( trans.get_transformed_constraints(m.disjunct1[1, 1].c)[0].parent_block(), - disjBlock[3], + disjBlock[1], ) # In the disaggregated var bounds self.assertIs( trans.get_transformed_constraints(m.disjunct1[2, 0].c)[0] .parent_block() .parent_block(), - disjBlock[0], + disjBlock[2], ) self.assertIs( trans.get_transformed_constraints(m.disjunct1[2, 1].c)[0].parent_block(), - disjBlock[1], + disjBlock[3], ) # This relies on the disjunctions being transformed in the same order # every time. These are the mappings between the indices of the original # disjuncts and the indices on the indexed block on the transformation # block. - if transformation == 'bigm': - pairs = [((1, 0), 0), ((1, 1), 1), ((2, 0), 2), ((2, 1), 3)] - elif transformation == 'hull': - pairs = [((2, 0), 0), ((2, 1), 1), ((1, 0), 2), ((1, 1), 3)] + pairs = [((1, 0), 0), ((1, 1), 1), ((2, 0), 2), ((2, 1), 3)] for i, j in pairs: self.assertIs(trans.get_src_disjunct(disjBlock[j]), m.disjunct1[i]) @@ -942,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) @@ -953,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) @@ -1694,26 +1702,78 @@ 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): disjunctionTransBlock = m.disj.algebraic_constraint.parent_block() transBlocks = disjunctionTransBlock.relaxedDisjuncts - self.assertEqual(len(transBlocks), 4) if transformation == 'bigm': + self.assertEqual(len(transBlocks), 4) self.assertIs(transBlocks[0], m.d1.d3.transformation_block) self.assertIs(transBlocks[1], m.d1.d4.transformation_block) self.assertIs(transBlocks[2], m.d1.transformation_block) self.assertIs(transBlocks[3], m.d2.transformation_block) if transformation == 'hull': - self.assertIs(transBlocks[2], m.d1.d3.transformation_block) - self.assertIs(transBlocks[3], m.d1.d4.transformation_block) - self.assertIs(transBlocks[0], m.d1.transformation_block) - self.assertIs(transBlocks[1], m.d2.transformation_block) + # This is a much more comprehensive test that doesn't depend on + # transformation Block structure, so just reuse it: + hull = TransformationFactory('gdp.hull') + d3 = hull.get_disaggregated_var(m.d1.d3.binary_indicator_var, m.d1) + d4 = hull.get_disaggregated_var(m.d1.d4.binary_indicator_var, m.d1) + self.check_transformed_model_nestedDisjuncts(m, d3, d4) + + # Check the 4 constraints that are unique to the case where we didn't + # declare d1.d3 and d1.d4 as local + d32 = hull.get_disaggregated_var(m.d1.d3.binary_indicator_var, m.d2) + d42 = hull.get_disaggregated_var(m.d1.d4.binary_indicator_var, m.d2) + # check the additional disaggregated indicator var bound constraints + cons = hull.get_var_bounds_constraint(d32) + self.assertEqual(len(cons), 1) + check_obj_in_active_tree(self, cons['ub']) + cons_expr = self.simplify_leq_cons(cons['ub']) + # Note that this comes out as d32 <= 1 - d1.ind_var because it's the + # "extra" disaggregated var that gets created when it need to be + # disaggregated for d1, but it's not used in d2 + assertExpressionsEqual( + self, cons_expr, d32 + m.d1.binary_indicator_var - 1 <= 0.0 + ) + + cons = hull.get_var_bounds_constraint(d42) + self.assertEqual(len(cons), 1) + check_obj_in_active_tree(self, cons['ub']) + cons_expr = self.simplify_leq_cons(cons['ub']) + # Note that this comes out as d42 <= 1 - d1.ind_var because it's the + # "extra" disaggregated var that gets created when it need to be + # disaggregated for d1, but it's not used in d2 + assertExpressionsEqual( + self, cons_expr, d42 + m.d1.binary_indicator_var - 1 <= 0.0 + ) + # check the aggregation constraints for the disaggregated indicator vars + cons = hull.get_disaggregation_constraint(m.d1.d3.binary_indicator_var, m.disj) + check_obj_in_active_tree(self, cons) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual( + self, cons_expr, m.d1.d3.binary_indicator_var - d32 - d3 == 0.0 + ) + cons = hull.get_disaggregation_constraint(m.d1.d4.binary_indicator_var, m.disj) + check_obj_in_active_tree(self, cons) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual( + self, cons_expr, m.d1.d4.binary_indicator_var - d42 - d4 == 0.0 + ) + + num_cons = len( + list(m.component_data_objects(Constraint, active=True, descend_into=Block)) + ) + # 30 total constraints in transformed model minus 10 trivial bounds + # (lower bounds of 0) gives us 20 constraints total: + self.assertEqual(num_cons, 20) + # (And this is 4 more than we test in + # self.check_transformed_model_nestedDisjuncts, so that's comforting + # too.) def check_nested_disjunction_target(self, transformation): @@ -1877,3 +1937,17 @@ def check_nested_disjuncts_in_flat_gdp(self, transformation): for t in m.T: self.assertTrue(value(m.disj1[t].indicator_var)) self.assertTrue(value(m.disj1[t].sub1.indicator_var)) + + +def check_do_not_assume_nested_indicators_local(self, transformation): + m = models.why_indicator_vars_are_not_always_local() + TransformationFactory(transformation).apply_to(m) + + results = SolverFactory('gurobi').solve(m) + self.assertEqual(results.solver.termination_condition, TerminationCondition.optimal) + self.assertAlmostEqual(value(m.obj), 9) + self.assertAlmostEqual(value(m.x), 9) + self.assertTrue(value(m.Y2.indicator_var)) + self.assertFalse(value(m.Y1.indicator_var)) + self.assertTrue(value(m.Z1.indicator_var)) + self.assertTrue(value(m.Z1.indicator_var)) diff --git a/pyomo/gdp/tests/jobshop_large_hull.lp b/pyomo/gdp/tests/jobshop_large_hull.lp index df3833bdee3..f0a9d3ccbf0 100644 --- a/pyomo/gdp/tests/jobshop_large_hull.lp +++ b/pyomo/gdp/tests/jobshop_large_hull.lp @@ -42,75 +42,75 @@ c_u_Feas(G)_: <= -17 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(0)_: -+1 t(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(G)_ ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(1)_: -+1 t(F) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(F)_ ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(A)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(2)_: -+1 t(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(G)_ ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(B)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(3)_: -+1 t(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(E)_ ++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(4)_: -+1 t(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(G)_ ++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(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(E)_ ++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(F) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(F)_ ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(D)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(7)_: -+1 t(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(E)_ ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(A)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(8)_: -+1 t(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(G)_ ++1 t(E) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(E)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(9)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(D)_ ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(A)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(10)_: -+1 t(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(G)_ ++1 t(E) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(E)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(11)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(D)_ ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(A)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(12)_: @@ -120,9 +120,9 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(12)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(13)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(D)_ ++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)_: @@ -132,81 +132,81 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(14)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(15)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(D)_ ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(A)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(16)_: -+1 t(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(E)_ ++1 t(G) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(G)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(17)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(D)_ ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(A)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(18)_: -+1 t(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(E)_ ++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(19)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(D)_ ++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(20)_: -+1 t(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(G)_ ++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(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(C)_ ++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(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(G)_ ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(D)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(23)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(C)_ ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(B)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(24)_: -+1 t(F) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(F)_ ++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(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(C)_ ++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(F) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(F)_ ++1 t(E) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(E)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(27)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(C)_ ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(B)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(28)_: @@ -216,33 +216,33 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(28)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(29)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(C)_ ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(B)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(30)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(D)_ ++1 t(F) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(F)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(31)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(C)_ ++1 t(B) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(B)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(32)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(D)_ ++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(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(C)_ ++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)_: @@ -258,27 +258,27 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(35)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(36)_: -+1 t(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(G)_ ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(D)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(37)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(B)_ ++1 t(C) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(C)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(38)_: -+1 t(F) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(F)_ ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(D)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(39)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(B)_ ++1 t(C) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(C)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(40)_: @@ -288,81 +288,81 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(40)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(41)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(B)_ ++1 t(C) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(C)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(42)_: -+1 t(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(E)_ ++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(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(B)_ ++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(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(E)_ ++1 t(F) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(F)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(45)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(B)_ ++1 t(C) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(C)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(46)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(D)_ ++1 t(G) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(G)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(47)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(B)_ ++1 t(C) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(C)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(48)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(D)_ ++1 t(G) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(G)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(49)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(B)_ ++1 t(C) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(C)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(C)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(50)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(C)_ ++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(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(B)_ ++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(G) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(G)_ ++1 t(E) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(E)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(53)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(A)_ ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(D)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(54)_: @@ -372,9 +372,9 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(54)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(55)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(A)_ ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(D)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(56)_: @@ -384,81 +384,81 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(56)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(57)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(A)_ ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(D)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(58)_: -+1 t(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(E)_ ++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(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(A)_ ++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(E) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(E)_ ++1 t(G) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(G)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(61)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(A)_ ++1 t(D) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(D)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(62)_: -+1 t(D) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(D)_ ++1 t(F) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(F)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(63)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(A)_ ++1 t(E) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(E)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(64)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(C)_ ++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(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(A)_ ++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(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(B)_ ++1 t(G) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(G)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(67)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(A)_ ++1 t(E) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(E)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(68)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(B)_ ++1 t(G) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(G)_ = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(69)_: -+1 t(A) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(A)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(A)_ ++1 t(F) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(F)_ = 0 c_e__pyomo_gdp_hull_reformulation_disj_xor(A_B_3)_: @@ -637,546 +637,544 @@ c_e__pyomo_gdp_hull_reformulation_disj_xor(F_G_4)_: = 1 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(F)_ -+6.0 NoClash(F_G_4_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(A)_ ++4.0 NoClash(A_B_3_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(G)_ --92 NoClash(F_G_4_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ +-92 NoClash(A_B_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(F)_ --92 NoClash(F_G_4_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(A)_ +-92 NoClash(A_B_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(F)_ -+6.0 NoClash(F_G_4_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(A)_ ++5.0 NoClash(A_B_3_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(G)_ --92 NoClash(F_G_4_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ +-92 NoClash(A_B_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(F)_ --92 NoClash(F_G_4_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(A)_ +-92 NoClash(A_B_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(E)_ -+7.0 NoClash(E_G_5_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ ++2.0 NoClash(A_B_5_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(G)_ --92 NoClash(E_G_5_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(B)_ +-92 NoClash(A_B_5_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(E)_ --92 NoClash(E_G_5_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(A)_ +-92 NoClash(A_B_5_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(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(E)_ --1 NoClash(E_G_5_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(B)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ ++3.0 NoClash(A_B_5_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(G)_ --92 NoClash(E_G_5_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(B)_ +-92 NoClash(A_B_5_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(E)_ --92 NoClash(E_G_5_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(A)_ +-92 NoClash(A_B_5_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(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(E)_ -+8.0 NoClash(E_G_2_0)_binary_indicator_var ++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(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(G)_ --92 NoClash(E_G_2_0)_binary_indicator_var +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(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(E)_ --92 NoClash(E_G_2_0)_binary_indicator_var +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(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(E)_ -+4.0 NoClash(E_G_2_1)_binary_indicator_var +-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(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(G)_ --92 NoClash(E_G_2_1)_binary_indicator_var +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(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(E)_ --92 NoClash(E_G_2_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(E)_ -+3.0 NoClash(E_F_3_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(A)_ ++10.0 NoClash(A_D_3_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(F)_ --92 NoClash(E_F_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(D)_ +-92 NoClash(A_D_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(E)_ --92 NoClash(E_F_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(A)_ +-92 NoClash(A_D_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(F)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(E)_ -+8.0 NoClash(E_F_3_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(D)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(A)_ <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(F)_ --92 NoClash(E_F_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(D)_ +-92 NoClash(A_D_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(E)_ --92 NoClash(E_F_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(A)_ +-92 NoClash(A_D_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(D)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(A)_ ++7.0 NoClash(A_E_3_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(G)_ --92 NoClash(D_G_4_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(E)_ +-92 NoClash(A_E_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(D)_ --92 NoClash(D_G_4_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(A)_ +-92 NoClash(A_E_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(D)_ -+6.0 NoClash(D_G_4_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(E)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(A)_ ++4.0 NoClash(A_E_3_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(G)_ --92 NoClash(D_G_4_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(E)_ +-92 NoClash(A_E_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(D)_ --92 NoClash(D_G_4_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(A)_ +-92 NoClash(A_E_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(D)_ -+8.0 NoClash(D_G_2_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(A)_ ++4.0 NoClash(A_E_5_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(G)_ --92 NoClash(D_G_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(E)_ +-92 NoClash(A_E_5_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(D)_ --92 NoClash(D_G_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(A)_ +-92 NoClash(A_E_5_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(D)_ -+8.0 NoClash(D_G_2_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(E)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(A)_ <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(G)_ --92 NoClash(D_G_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(E)_ +-92 NoClash(A_E_5_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(D)_ --92 NoClash(D_G_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(A)_ +-92 NoClash(A_E_5_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_transformedConstraints(c_0_ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(D)_ -+1 NoClash(D_F_4_0)_binary_indicator_var +-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(F)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(F)_ --92 NoClash(D_F_4_0)_binary_indicator_var +-92 NoClash(A_F_1_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(D)_ --92 NoClash(D_F_4_0)_binary_indicator_var +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(F)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(D)_ -+7.0 NoClash(D_F_4_1)_binary_indicator_var ++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(F)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(F)_ --92 NoClash(D_F_4_1)_binary_indicator_var +-92 NoClash(A_F_1_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(D)_ --92 NoClash(D_F_4_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_transformedConstraints(c_0_ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(D)_ --1 NoClash(D_F_3_0)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(A)_ ++4.0 NoClash(A_F_3_0)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)__t(F)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(F)_ --92 NoClash(D_F_3_0)_binary_indicator_var +-92 NoClash(A_F_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(D)_ --92 NoClash(D_F_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(A)_ +-92 NoClash(A_F_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_transformedConstraints(c_0_ub)_: -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(F)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(D)_ -+11.0 NoClash(D_F_3_1)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(A)_ ++6.0 NoClash(A_F_3_1)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)__t(F)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(F)_ --92 NoClash(D_F_3_1)_binary_indicator_var +-92 NoClash(A_F_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(D)_ --92 NoClash(D_F_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(A)_ +-92 NoClash(A_F_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(D)_ -+2.0 NoClash(D_E_3_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(A)_ ++9.0 NoClash(A_G_5_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(E)_ --92 NoClash(D_E_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(G)_ +-92 NoClash(A_G_5_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(D)_ --92 NoClash(D_E_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(A)_ +-92 NoClash(A_G_5_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(E)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(D)_ -+9.0 NoClash(D_E_3_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(A)_ +-3.0 NoClash(A_G_5_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(E)_ --92 NoClash(D_E_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(G)_ +-92 NoClash(A_G_5_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(D)_ --92 NoClash(D_E_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(A)_ +-92 NoClash(A_G_5_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(D)_ -+4.0 NoClash(D_E_2_0)_binary_indicator_var ++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(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(E)_ --92 NoClash(D_E_2_0)_binary_indicator_var +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(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(D)_ --92 NoClash(D_E_2_0)_binary_indicator_var +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(E)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(D)_ -+8.0 NoClash(D_E_2_1)_binary_indicator_var +-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(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(E)_ --92 NoClash(D_E_2_1)_binary_indicator_var +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(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(D)_ --92 NoClash(D_E_2_1)_binary_indicator_var +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(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(C)_ -+4.0 NoClash(C_G_4_0)_binary_indicator_var ++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(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(G)_ --92 NoClash(C_G_4_0)_binary_indicator_var +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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(C)_ --92 NoClash(C_G_4_0)_binary_indicator_var +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(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(C)_ -+7.0 NoClash(C_G_4_1)_binary_indicator_var +-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(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(G)_ --92 NoClash(C_G_4_1)_binary_indicator_var +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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(C)_ --92 NoClash(C_G_4_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(C)_ -+2.0 NoClash(C_G_2_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(B)_ ++10.0 NoClash(B_D_3_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(G)_ --92 NoClash(C_G_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(D)_ +-92 NoClash(B_D_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(C)_ --92 NoClash(C_G_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(B)_ +-92 NoClash(B_D_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(C)_ -+9.0 NoClash(C_G_2_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(D)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(B)_ +-1 NoClash(B_D_3_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(G)_ --92 NoClash(C_G_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(D)_ +-92 NoClash(B_D_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(C)_ --92 NoClash(C_G_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(B)_ +-92 NoClash(B_D_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(C)_ -+5.0 NoClash(C_F_4_0)_binary_indicator_var ++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(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(F)_ --92 NoClash(C_F_4_0)_binary_indicator_var +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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(C)_ --92 NoClash(C_F_4_0)_binary_indicator_var +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(F)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(C)_ -+8.0 NoClash(C_F_4_1)_binary_indicator_var +-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(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(F)_ --92 NoClash(C_F_4_1)_binary_indicator_var +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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(C)_ --92 NoClash(C_F_4_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(C)_ -+2.0 NoClash(C_F_1_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(B)_ ++7.0 NoClash(B_E_3_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(F)_ --92 NoClash(C_F_1_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(E)_ +-92 NoClash(B_E_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(C)_ --92 NoClash(C_F_1_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(B)_ +-92 NoClash(B_E_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(F)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(C)_ -+6.0 NoClash(C_F_1_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(E)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(B)_ ++3.0 NoClash(B_E_3_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(F)_ --92 NoClash(C_F_1_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(E)_ +-92 NoClash(B_E_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(C)_ --92 NoClash(C_F_1_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(B)_ +-92 NoClash(B_E_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_transformedConstraints(c_0_ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(C)_ --2.0 NoClash(C_E_2_0)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(B)_ ++5.0 NoClash(B_E_5_0)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)__t(E)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(E)_ --92 NoClash(C_E_2_0)_binary_indicator_var +-92 NoClash(B_E_5_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(C)_ --92 NoClash(C_E_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(B)_ +-92 NoClash(B_E_5_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_transformedConstraints(c_0_ub)_: -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(E)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(C)_ -+9.0 NoClash(C_E_2_1)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(B)_ <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)__t(E)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(E)_ --92 NoClash(C_E_2_1)_binary_indicator_var +-92 NoClash(B_E_5_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(C)_ --92 NoClash(C_E_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(B)_ +-92 NoClash(B_E_5_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(C)_ -+5.0 NoClash(C_D_4_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(B)_ ++4.0 NoClash(B_F_3_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(D)_ --92 NoClash(C_D_4_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(F)_ +-92 NoClash(B_F_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(C)_ --92 NoClash(C_D_4_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(B)_ +-92 NoClash(B_F_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(D)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(C)_ -+2.0 NoClash(C_D_4_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(F)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(B)_ ++5.0 NoClash(B_F_3_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(D)_ --92 NoClash(C_D_4_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(F)_ +-92 NoClash(B_F_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(C)_ --92 NoClash(C_D_4_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)__t(B)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(B)_ +-92 NoClash(B_F_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(C)_ -+2.0 NoClash(C_D_2_0)_binary_indicator_var ++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(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(D)_ --92 NoClash(C_D_2_0)_binary_indicator_var +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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(C)_ --92 NoClash(C_D_2_0)_binary_indicator_var +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(D)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(C)_ -+9.0 NoClash(C_D_2_1)_binary_indicator_var +-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(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(D)_ --92 NoClash(C_D_2_1)_binary_indicator_var +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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(C)_ --92 NoClash(C_D_2_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(34)_transformedConstraints(c_0_ub)_: @@ -1212,544 +1210,546 @@ c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(35)__t(B)_bounds_(ub)_: <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(B)_ -+8.0 NoClash(B_G_2_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(C)_ ++2.0 NoClash(C_D_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(G)_ --92 NoClash(B_G_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(D)_ +-92 NoClash(C_D_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(B)_ --92 NoClash(B_G_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(C)_ +-92 NoClash(C_D_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(B)_ -+3.0 NoClash(B_G_2_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(D)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(C)_ ++9.0 NoClash(C_D_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(G)_ --92 NoClash(B_G_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(D)_ +-92 NoClash(C_D_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(B)_ --92 NoClash(B_G_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(C)_ +-92 NoClash(C_D_2_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(B)_ -+4.0 NoClash(B_F_3_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(D)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(C)_ ++5.0 NoClash(C_D_4_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(F)_ --92 NoClash(B_F_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(D)_ +-92 NoClash(C_D_4_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(B)_ --92 NoClash(B_F_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(C)_ +-92 NoClash(C_D_4_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(F)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(B)_ -+5.0 NoClash(B_F_3_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(D)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(C)_ ++2.0 NoClash(C_D_4_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)__t(F)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(F)_ --92 NoClash(B_F_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(D)_ +-92 NoClash(C_D_4_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(B)_ --92 NoClash(B_F_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(C)_ +-92 NoClash(C_D_4_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_transformedConstraints(c_0_ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(B)_ -+5.0 NoClash(B_E_5_0)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(C)_ +-2.0 NoClash(C_E_2_0)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)__t(E)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(E)_ --92 NoClash(B_E_5_0)_binary_indicator_var +-92 NoClash(C_E_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(B)_ --92 NoClash(B_E_5_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(C)_ +-92 NoClash(C_E_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_transformedConstraints(c_0_ub)_: -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(E)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(B)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(C)_ ++9.0 NoClash(C_E_2_1)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)__t(E)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(E)_ --92 NoClash(B_E_5_1)_binary_indicator_var +-92 NoClash(C_E_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(B)_ --92 NoClash(B_E_5_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(C)_ +-92 NoClash(C_E_2_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(B)_ -+7.0 NoClash(B_E_3_0)_binary_indicator_var ++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(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(E)_ --92 NoClash(B_E_3_0)_binary_indicator_var +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(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(B)_ --92 NoClash(B_E_3_0)_binary_indicator_var +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(E)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(B)_ -+3.0 NoClash(B_E_3_1)_binary_indicator_var +-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(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(E)_ --92 NoClash(B_E_3_1)_binary_indicator_var +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(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(B)_ --92 NoClash(B_E_3_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(B)_ -+4.0 NoClash(B_E_2_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(C)_ ++5.0 NoClash(C_F_4_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(E)_ --92 NoClash(B_E_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(F)_ +-92 NoClash(C_F_4_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(B)_ --92 NoClash(B_E_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(C)_ +-92 NoClash(C_F_4_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(E)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(B)_ -+3.0 NoClash(B_E_2_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(F)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(C)_ ++8.0 NoClash(C_F_4_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(E)_ --92 NoClash(B_E_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(F)_ +-92 NoClash(C_F_4_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(B)_ --92 NoClash(B_E_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(C)_ +-92 NoClash(C_F_4_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(B)_ -+10.0 NoClash(B_D_3_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(C)_ ++2.0 NoClash(C_G_2_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(D)_ --92 NoClash(B_D_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(G)_ +-92 NoClash(C_G_2_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(B)_ --92 NoClash(B_D_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(C)_ +-92 NoClash(C_G_2_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(D)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(B)_ --1 NoClash(B_D_3_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(C)_ ++9.0 NoClash(C_G_2_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(D)_ --92 NoClash(B_D_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(G)_ +-92 NoClash(C_G_2_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(B)_ --92 NoClash(B_D_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(C)_ +-92 NoClash(C_G_2_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(B)_ -+8.0 NoClash(B_D_2_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(C)_ ++4.0 NoClash(C_G_4_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(D)_ --92 NoClash(B_D_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(G)_ +-92 NoClash(C_G_4_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(B)_ --92 NoClash(B_D_2_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(C)_ +-92 NoClash(C_G_4_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(D)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(B)_ -+3.0 NoClash(B_D_2_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(C)_ ++7.0 NoClash(C_G_4_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(D)_ --92 NoClash(B_D_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(G)_ +-92 NoClash(C_G_4_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(B)_ --92 NoClash(B_D_2_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)__t(C)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(C)_ +-92 NoClash(C_G_4_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(B)_ -+9.0 NoClash(B_C_2_0)_binary_indicator_var ++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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(C)_ --92 NoClash(B_C_2_0)_binary_indicator_var +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(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(B)_ --92 NoClash(B_C_2_0)_binary_indicator_var +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(C)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(B)_ --3.0 NoClash(B_C_2_1)_binary_indicator_var +-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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(C)_ --92 NoClash(B_C_2_1)_binary_indicator_var +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(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(B)_ --92 NoClash(B_C_2_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(G)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(A)_ -+9.0 NoClash(A_G_5_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(E)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(D)_ ++2.0 NoClash(D_E_3_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(G)_ --92 NoClash(A_G_5_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(E)_ +-92 NoClash(D_E_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(A)_ --92 NoClash(A_G_5_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(D)_ +-92 NoClash(D_E_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(G)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(A)_ --3.0 NoClash(A_G_5_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(E)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(D)_ ++9.0 NoClash(D_E_3_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)__t(G)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(G)_ --92 NoClash(A_G_5_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(E)_ +-92 NoClash(D_E_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(A)_ --92 NoClash(A_G_5_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(D)_ +-92 NoClash(D_E_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_transformedConstraints(c_0_ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(A)_ -+4.0 NoClash(A_F_3_0)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(D)_ +-1 NoClash(D_F_3_0)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)__t(F)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(F)_ --92 NoClash(A_F_3_0)_binary_indicator_var +-92 NoClash(D_F_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(A)_ --92 NoClash(A_F_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(D)_ +-92 NoClash(D_F_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_transformedConstraints(c_0_ub)_: -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(F)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(A)_ -+6.0 NoClash(A_F_3_1)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(D)_ ++11.0 NoClash(D_F_3_1)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)__t(F)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(F)_ --92 NoClash(A_F_3_1)_binary_indicator_var +-92 NoClash(D_F_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(A)_ --92 NoClash(A_F_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(D)_ +-92 NoClash(D_F_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_transformedConstraints(c_0_ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(F)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(A)_ -+2.0 NoClash(A_F_1_0)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(D)_ ++1 NoClash(D_F_4_0)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)__t(F)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(F)_ --92 NoClash(A_F_1_0)_binary_indicator_var +-92 NoClash(D_F_4_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(A)_ --92 NoClash(A_F_1_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(D)_ +-92 NoClash(D_F_4_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_transformedConstraints(c_0_ub)_: -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(F)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(A)_ -+3.0 NoClash(A_F_1_1)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(D)_ ++7.0 NoClash(D_F_4_1)_binary_indicator_var <= 0.0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)__t(F)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(F)_ --92 NoClash(A_F_1_1)_binary_indicator_var +-92 NoClash(D_F_4_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(A)_ --92 NoClash(A_F_1_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(D)_ +-92 NoClash(D_F_4_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(A)_ -+4.0 NoClash(A_E_5_0)_binary_indicator_var ++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(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(E)_ --92 NoClash(A_E_5_0)_binary_indicator_var +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(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(A)_ --92 NoClash(A_E_5_0)_binary_indicator_var +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(E)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(A)_ +-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(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(E)_ --92 NoClash(A_E_5_1)_binary_indicator_var +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(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(A)_ --92 NoClash(A_E_5_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(E)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(A)_ -+7.0 NoClash(A_E_3_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(D)_ <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(E)_ --92 NoClash(A_E_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(G)_ +-92 NoClash(D_G_4_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(A)_ --92 NoClash(A_E_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(D)_ +-92 NoClash(D_G_4_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(E)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(A)_ -+4.0 NoClash(A_E_3_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(D)_ ++6.0 NoClash(D_G_4_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)__t(E)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(E)_ --92 NoClash(A_E_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(G)_ +-92 NoClash(D_G_4_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(A)_ --92 NoClash(A_E_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)__t(D)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(D)_ +-92 NoClash(D_G_4_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(D)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(A)_ -+10.0 NoClash(A_D_3_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(F)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(E)_ ++3.0 NoClash(E_F_3_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(D)_ --92 NoClash(A_D_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(F)_ +-92 NoClash(E_F_3_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(A)_ --92 NoClash(A_D_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(E)_ +-92 NoClash(E_F_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(D)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(F)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(E)_ ++8.0 NoClash(E_F_3_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)__t(D)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(D)_ --92 NoClash(A_D_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(F)_ +-92 NoClash(E_F_3_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(A)_ --92 NoClash(A_D_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(E)_ +-92 NoClash(E_F_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(A)_ -+6.0 NoClash(A_C_1_0)_binary_indicator_var ++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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(C)_ --92 NoClash(A_C_1_0)_binary_indicator_var +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(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(A)_ --92 NoClash(A_C_1_0)_binary_indicator_var +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(C)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(A)_ -+3.0 NoClash(A_C_1_1)_binary_indicator_var +-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(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(C)_ --92 NoClash(A_C_1_1)_binary_indicator_var +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(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(A)_ --92 NoClash(A_C_1_1)_binary_indicator_var +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 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(A)_ -+2.0 NoClash(A_B_5_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(E)_ ++7.0 NoClash(E_G_5_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(B)_ --92 NoClash(A_B_5_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(G)_ +-92 NoClash(E_G_5_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(A)_ --92 NoClash(A_B_5_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(E)_ +-92 NoClash(E_G_5_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(B)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(A)_ -+3.0 NoClash(A_B_5_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(E)_ +-1 NoClash(E_G_5_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(B)_ --92 NoClash(A_B_5_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(G)_ +-92 NoClash(E_G_5_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(A)_ --92 NoClash(A_B_5_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)__t(E)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(E)_ +-92 NoClash(E_G_5_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(A)_ -+4.0 NoClash(A_B_3_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(G)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(F)_ ++6.0 NoClash(F_G_4_0)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(B)_ --92 NoClash(A_B_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(G)_ +-92 NoClash(F_G_4_0)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(A)_ --92 NoClash(A_B_3_0)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(F)_ +-92 NoClash(F_G_4_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(B)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(A)_ -+5.0 NoClash(A_B_3_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(G)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(F)_ ++6.0 NoClash(F_G_4_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)__t(B)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(B)_ --92 NoClash(A_B_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)__t(G)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(G)_ +-92 NoClash(F_G_4_1)_binary_indicator_var <= 0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)__t(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(A)_ --92 NoClash(A_B_3_1)_binary_indicator_var +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)__t(F)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(F)_ +-92 NoClash(F_G_4_1)_binary_indicator_var <= 0 bounds @@ -1761,146 +1761,146 @@ bounds 0 <= t(E) <= 92 0 <= t(F) <= 92 0 <= t(G) <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(6)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(11)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(B)_ <= 92 + 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(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 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(7)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(E)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(E)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(8)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(9)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(10)_disaggregatedVars__t(E)_ <= 92 + 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(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(12)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(13)_disaggregatedVars__t(D)_ <= 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(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(17)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(18)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(20)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(21)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(22)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(23)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(24)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(25)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(26)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(14)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(15)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(16)_disaggregatedVars__t(G)_ <= 92 + 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(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(19)_disaggregatedVars__t(C)_ <= 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(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 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(27)_disaggregatedVars__t(B)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(31)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(32)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(33)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(28)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(29)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(30)_disaggregatedVars__t(F)_ <= 92 + 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(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 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(35)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(B)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(36)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(37)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(38)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(39)_disaggregatedVars__t(C)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(E)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(40)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(41)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(42)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(43)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(44)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(49)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(50)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(51)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(G)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(52)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(A)_ <= 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(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 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(45)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(G)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(G)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(46)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(47)_disaggregatedVars__t(C)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(48)_disaggregatedVars__t(G)_ <= 92 + 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(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 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(53)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(A)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(54)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(55)_disaggregatedVars__t(D)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(F)_ <= 92 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(F)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(56)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(57)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(58)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(59)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(E)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(60)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(D)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(63)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(C)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(64)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(65)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(66)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(B)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(A)_ <= 92 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(A)_ <= 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(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 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(61)_disaggregatedVars__t(D)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(62)_disaggregatedVars__t(F)_ <= 92 + 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(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 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(67)_disaggregatedVars__t(E)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(G)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(G)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(68)_disaggregatedVars__t(F)_ <= 92 + 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(69)_disaggregatedVars__t(F)_ <= 92 0 <= NoClash(A_B_3_0)_binary_indicator_var <= 1 0 <= NoClash(A_B_3_1)_binary_indicator_var <= 1 0 <= NoClash(A_B_5_0)_binary_indicator_var <= 1 diff --git a/pyomo/gdp/tests/jobshop_small_hull.lp b/pyomo/gdp/tests/jobshop_small_hull.lp index c07b9cd048e..eccaa800600 100644 --- a/pyomo/gdp/tests/jobshop_small_hull.lp +++ b/pyomo/gdp/tests/jobshop_small_hull.lp @@ -22,17 +22,17 @@ c_u_Feas(C)_: <= -6 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(0)_: -+1 t(C) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(C)_ -= 0 - -c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(1)_: +1 t(B) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ = 0 +c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(1)_: ++1 t(A) +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(A)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(A)_ += 0 + c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(2)_: +1 t(C) -1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_disaggregatedVars__t(C)_ @@ -46,15 +46,15 @@ c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(3)_: = 0 c_e__pyomo_gdp_hull_reformulation_disaggregationConstraints(4)_: -+1 t(B) --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(B)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(B)_ ++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)_ ++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_disj_xor(A_B_3)_: @@ -73,35 +73,34 @@ c_e__pyomo_gdp_hull_reformulation_disj_xor(B_C_2)_: = 1 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_transformedConstraints(c_0_ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(C)_ --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ -+6.0 NoClash(B_C_2_0)_binary_indicator_var ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(A)_ <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(C)_ --19 NoClash(B_C_2_0)_binary_indicator_var -<= 0 - c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)__t(B)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ --19 NoClash(B_C_2_0)_binary_indicator_var +-19 NoClash(A_B_3_0)_binary_indicator_var +<= 0 + +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(A)_ +-19 NoClash(A_B_3_0)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_transformedConstraints(c_0_ub)_: --1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(C)_ -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ -+1 NoClash(B_C_2_1)_binary_indicator_var +-1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(A)_ ++5.0 NoClash(A_B_3_1)_binary_indicator_var <= 0.0 -c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)__t(C)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(C)_ --19 NoClash(B_C_2_1)_binary_indicator_var -<= 0 - c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)__t(B)_bounds_(ub)_: +1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(B)_ --19 NoClash(B_C_2_1)_binary_indicator_var +-19 NoClash(A_B_3_1)_binary_indicator_var +<= 0 + +c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)__t(A)_bounds_(ub)_: ++1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(A)_ +-19 NoClash(A_B_3_1)_binary_indicator_var <= 0 c_u__pyomo_gdp_hull_reformulation_relaxedDisjuncts(2)_transformedConstraints(c_0_ub)_: @@ -137,34 +136,35 @@ 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(B)_ --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(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)_ --19 NoClash(A_B_3_0)_binary_indicator_var +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(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ --19 NoClash(A_B_3_0)_binary_indicator_var +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(A)_ -+5.0 NoClash(A_B_3_1)_binary_indicator_var +-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)_ --19 NoClash(A_B_3_1)_binary_indicator_var +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(A)_bounds_(ub)_: -+1 _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ --19 NoClash(A_B_3_1)_binary_indicator_var +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 bounds @@ -172,18 +172,18 @@ bounds 0 <= t(A) <= 19 0 <= t(B) <= 19 0 <= t(C) <= 19 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(C)_ <= 19 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(1)_disaggregatedVars__t(C)_ <= 19 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(0)_disaggregatedVars__t(B)_ <= 19 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(C)_ <= 19 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(3)_disaggregatedVars__t(C)_ <= 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 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(4)_disaggregatedVars__t(A)_ <= 19 - 0 <= _pyomo_gdp_hull_reformulation_relaxedDisjuncts(5)_disaggregatedVars__t(A)_ <= 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 a52f08b790e..2995cacb450 100644 --- a/pyomo/gdp/tests/models.py +++ b/pyomo/gdp/tests/models.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 import ( Block, ConcreteModel, @@ -463,7 +474,7 @@ def makeNestedDisjunctions(): (makeNestedDisjunctions_NestedDisjuncts is a much simpler model. All this adds is that it has a nested disjunction on a DisjunctData as well - as on a SimpleDisjunct. So mostly it exists for historical reasons.) + as on a ScalarDisjunct. So mostly it exists for historical reasons.) """ m = ConcreteModel() m.x = Var(bounds=(-9, 9)) @@ -552,6 +563,44 @@ def makeNestedDisjunctions_NestedDisjuncts(): return m +def why_indicator_vars_are_not_always_local(): + m = ConcreteModel() + m.x = Var(bounds=(1, 10)) + + @m.Disjunct() + def Z1(d): + m = d.model() + d.c = Constraint(expr=m.x >= 1.1) + + @m.Disjunct() + def Z2(d): + m = d.model() + d.c = Constraint(expr=m.x >= 1.2) + + @m.Disjunct() + def Y1(d): + m = d.model() + d.c = Constraint(expr=(1.15, m.x, 8)) + d.disjunction = Disjunction(expr=[m.Z1, m.Z2]) + + @m.Disjunct() + def Y2(d): + m = d.model() + d.c = Constraint(expr=m.x == 9) + + m.disjunction = Disjunction(expr=[m.Y1, m.Y2]) + + m.logical_cons = LogicalConstraint( + expr=m.Y2.indicator_var.implies(m.Z1.indicator_var.land(m.Z2.indicator_var)) + ) + + # optimal value is 9, but it will be 8 if we wrongly assume that the nested + # indicator_vars are local. + m.obj = Objective(expr=m.x, sense=maximize) + + return m + + def makeTwoSimpleDisjunctions(): """Two SimpleDisjunctions on the same model.""" m = ConcreteModel() @@ -791,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 631611a2651..7e21c46da92 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-2022 +# 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 diff --git a/pyomo/gdp/tests/test_bigm.py b/pyomo/gdp/tests/test_bigm.py index 13ffe30f9f0..c27d7cbe0cb 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-2022 +# 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 @@ -19,20 +19,24 @@ 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, ) from pyomo.repn import generate_standard_repn +from pyomo.repn.linear import LinearRepnVisitor from pyomo.common.log import LoggingIntercept import logging @@ -154,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) @@ -655,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) @@ -1315,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 @@ -1764,22 +1757,19 @@ def test_transformation_block_structure(self): # we have the XOR constraints for both the outer and inner disjunctions self.assertIsInstance(transBlock.component("disjunction_xor"), Constraint) - def test_transformation_block_on_inner_disjunct_empty(self): - m = models.makeNestedDisjunctions() - TransformationFactory('gdp.bigm').apply_to(m) - self.assertIsNone(m.disjunct[1].component("_pyomo_gdp_bigm_reformulation")) - def test_mappings_between_disjunctions_and_xors(self): m = models.makeNestedDisjunctions() transform = TransformationFactory('gdp.bigm') transform.apply_to(m) transBlock1 = m.component("_pyomo_gdp_bigm_reformulation") + transBlock2 = m.disjunct[1].component("_pyomo_gdp_bigm_reformulation") + transBlock3 = m.simpledisjunct.component("_pyomo_gdp_bigm_reformulation") disjunctionPairs = [ (m.disjunction, transBlock1.disjunction_xor), - (m.disjunct[1].innerdisjunction[0], transBlock1.innerdisjunction_xor_4[0]), - (m.simpledisjunct.innerdisjunction, transBlock1.innerdisjunction_xor), + (m.disjunct[1].innerdisjunction[0], transBlock2.innerdisjunction_xor[0]), + (m.simpledisjunct.innerdisjunction, transBlock3.innerdisjunction_xor), ] # check disjunction mappings @@ -1892,26 +1882,38 @@ 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, inner_disjuncts - ): - self.assertIsNotNone(inner_disjunction.algebraic_constraint) - cons = inner_disjunction.algebraic_constraint - self.assertEqual(cons.lower, 0) - self.assertEqual(cons.upper, 0) - repn = generate_standard_repn(cons.body) - self.assertTrue(repn.is_linear()) - self.assertEqual(repn.constant, 0) - for disj in inner_disjuncts: - ct.check_linear_coef(self, repn, disj.binary_indicator_var, 1) - ct.check_linear_coef(self, repn, outer_disjunct.binary_indicator_var, -1) + def check_inner_xor_constraint(self, inner_disjunction, outer_disjunct, bigm): + inner_xor = inner_disjunction.algebraic_constraint + sum_indicators = sum( + d.binary_indicator_var for d in inner_disjunction.disjuncts + ) + assertExpressionsEqual(self, inner_xor.expr, sum_indicators == 1) + # this guy has been transformed + self.assertFalse(inner_xor.active) + cons = bigm.get_transformed_constraints(inner_xor) + self.assertEqual(len(cons), 2) + lb = cons[0] + ct.check_obj_in_active_tree(self, lb) + lb_expr = self.simplify_cons(lb, leq=False) + assertExpressionsEqual( + self, + lb_expr, + 1.0 <= sum_indicators - outer_disjunct.binary_indicator_var + 1.0, + ) + ub = cons[1] + ct.check_obj_in_active_tree(self, ub) + ub_expr = self.simplify_cons(ub, leq=True) + assertExpressionsEqual( + self, + ub_expr, + sum_indicators + outer_disjunct.binary_indicator_var - 1 <= 1.0, + ) def test_transformed_constraints(self): # We'll check all the transformed constraints to make sure @@ -1949,6 +1951,10 @@ def test_transformed_constraints(self): .binary_indicator_var, ) ), + 1, + EXPR.MonomialTermExpression( + (-1, m.disjunct[1].binary_indicator_var) + ), ] ), ) @@ -1958,61 +1964,76 @@ 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 # simpledisjunct.innerdisjunction is transformed. - cons5 = m.simpledisjunct.innerdisjunction.algebraic_constraint - self.assertIsNotNone(cons5) self.check_inner_xor_constraint( - m.simpledisjunct.innerdisjunction, - m.simpledisjunct, - [m.simpledisjunct.innerdisjunct0, m.simpledisjunct.innerdisjunct1], - ) - self.assertIsInstance(cons5, Constraint) - self.assertEqual(cons5.lower, 0) - self.assertEqual(cons5.upper, 0) - repn = generate_standard_repn(cons5.body) - self.assertTrue(repn.is_linear()) - self.assertEqual(repn.constant, 0) - ct.check_linear_coef( - self, repn, m.simpledisjunct.innerdisjunct0.binary_indicator_var, 1 + m.simpledisjunct.innerdisjunction, m.simpledisjunct, bigm ) - ct.check_linear_coef( - self, repn, m.simpledisjunct.innerdisjunct1.binary_indicator_var, 1 - ) - ct.check_linear_coef(self, repn, m.simpledisjunct.binary_indicator_var, -1) cons6 = bigm.get_transformed_constraints(m.disjunct[0].c) self.assertEqual(len(cons6), 2) @@ -2028,9 +2049,7 @@ def test_transformed_constraints(self): # now we check that the xor constraint from disjunct[1].innerdisjunction # is correct. self.check_inner_xor_constraint( - m.disjunct[1].innerdisjunction[0], - m.disjunct[1], - [m.disjunct[1].innerdisjunct[0], m.disjunct[1].innerdisjunct[1]], + m.disjunct[1].innerdisjunction[0], m.disjunct[1], bigm ) cons8 = bigm.get_transformed_constraints(m.disjunct[1].c) @@ -2107,34 +2126,18 @@ 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) + self.assertIsNone(repn.nonlinear) + if leq: + self.assertIsNone(cons.lower) + ub = cons.upper + return ub >= repn.to_expression(visitor) + else: + self.assertIsNone(cons.upper) + lb = cons.lower + return lb <= repn.to_expression(visitor) def check_hierarchical_nested_model(self, m, bigm): outer_xor = m.disjunction_block.disjunction.algebraic_constraint @@ -2142,55 +2145,82 @@ def check_hierarchical_nested_model(self, m, bigm): self, outer_xor, m.disj1, m.disjunct_block.disj2 ) - inner_xor = m.disjunct_block.disj2.disjunction.algebraic_constraint - self.assertEqual(inner_xor.lower, 0) - self.assertEqual(inner_xor.upper, 0) - repn = generate_standard_repn(inner_xor.body) - self.assertTrue(repn.is_linear()) - self.assertEqual(len(repn.linear_vars), 3) - self.assertEqual(repn.constant, 0) - ct.check_linear_coef( - self, - repn, - m.disjunct_block.disj2.disjunction_disjuncts[0].binary_indicator_var, - 1, - ) - ct.check_linear_coef( - self, - repn, - m.disjunct_block.disj2.disjunction_disjuncts[1].binary_indicator_var, - 1, - ) - ct.check_linear_coef( - self, repn, m.disjunct_block.disj2.binary_indicator_var, -1 + self.check_inner_xor_constraint( + m.disjunct_block.disj2.disjunction, m.disjunct_block.disj2, 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): @@ -2214,10 +2244,54 @@ def test_decl_order_opposite_instantiation_order(self): # the same check to make sure everything is transformed correctly. self.check_hierarchical_nested_model(m, bigm) + @unittest.skipUnless(gurobi_available, "Gurobi is not available") + 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') @@ -2282,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 new file mode 100644 index 00000000000..ae2c44b899e --- /dev/null +++ b/pyomo/gdp/tests/test_binary_multiplication.py @@ -0,0 +1,312 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest + +from pyomo.environ import ( + TransformationFactory, + Block, + Constraint, + ConcreteModel, + Var, + Any, + SolverFactory, +) +from pyomo.gdp import Disjunct, Disjunction +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.repn import generate_standard_repn +from pyomo.core.expr.compare import assertExpressionsEqual + +import pyomo.core.expr as EXPR +import pyomo.gdp.tests.models as models +import pyomo.gdp.tests.common_tests as ct + +import random + +gurobi_available = ( + SolverFactory('gurobi').available(exception_flag=False) + and SolverFactory('gurobi').license_is_valid() +) + + +class CommonTests: + def diff_apply_to_and_create_using(self, model): + ct.diff_apply_to_and_create_using(self, model, 'gdp.binary_multiplication') + + +class TwoTermDisj(unittest.TestCase, CommonTests): + def setUp(self): + # set seed so we can test name collisions predictably + random.seed(666) + + def test_new_block_created(self): + m = models.makeTwoTermDisj() + TransformationFactory('gdp.binary_multiplication').apply_to(m) + + # we have a transformation block + transBlock = m.component("_pyomo_gdp_binary_multiplication_reformulation") + self.assertIsInstance(transBlock, Block) + + disjBlock = transBlock.component("relaxedDisjuncts") + self.assertIsInstance(disjBlock, Block) + self.assertEqual(len(disjBlock), 2) + # it has the disjuncts on it + self.assertIs(m.d[0].transformation_block, disjBlock[0]) + self.assertIs(m.d[1].transformation_block, disjBlock[1]) + + def test_disjunction_deactivated(self): + ct.check_disjunction_deactivated(self, 'binary_multiplication') + + def test_disjunctDatas_deactivated(self): + ct.check_disjunctDatas_deactivated(self, 'binary_multiplication') + + def test_do_not_transform_twice_if_disjunction_reactivated(self): + ct.check_do_not_transform_twice_if_disjunction_reactivated( + self, 'binary_multiplication' + ) + + def test_xor_constraint_mapping(self): + ct.check_xor_constraint_mapping(self, 'binary_multiplication') + + def test_xor_constraint_mapping_two_disjunctions(self): + ct.check_xor_constraint_mapping_two_disjunctions(self, 'binary_multiplication') + + def test_disjunct_mapping(self): + ct.check_disjunct_mapping(self, 'binary_multiplication') + + def test_disjunct_and_constraint_maps(self): + """Tests the actual data structures used to store the maps.""" + m = models.makeTwoTermDisj() + binary_multiplication = TransformationFactory('gdp.binary_multiplication') + binary_multiplication.apply_to(m) + disjBlock = m._pyomo_gdp_binary_multiplication_reformulation.relaxedDisjuncts + oldblock = m.component("d") + + # we are counting on the fact that the disjuncts get relaxed in the + # same order every time. + for i in [0, 1]: + self.assertIs(oldblock[i].transformation_block, disjBlock[i]) + self.assertIs( + binary_multiplication.get_src_disjunct(disjBlock[i]), oldblock[i] + ) + + # check constraint dict has right mapping + c1_list = binary_multiplication.get_transformed_constraints(oldblock[1].c1) + # this is an equality + self.assertEqual(len(c1_list), 1) + self.assertIs(c1_list[0].parent_block(), disjBlock[1]) + self.assertIs( + binary_multiplication.get_src_constraint(c1_list[0]), oldblock[1].c1 + ) + + c2_list = binary_multiplication.get_transformed_constraints(oldblock[1].c2) + # just ub + self.assertEqual(len(c2_list), 1) + self.assertIs(c2_list[0].parent_block(), disjBlock[1]) + self.assertIs( + binary_multiplication.get_src_constraint(c2_list[0]), oldblock[1].c2 + ) + + c_list = binary_multiplication.get_transformed_constraints(oldblock[0].c) + # just lb + self.assertEqual(len(c_list), 1) + self.assertIs(c_list[0].parent_block(), disjBlock[0]) + self.assertIs( + binary_multiplication.get_src_constraint(c_list[0]), oldblock[0].c + ) + + def test_new_block_nameCollision(self): + ct.check_transformation_block_name_collision(self, 'binary_multiplication') + + def test_indicator_vars(self): + ct.check_indicator_vars(self, 'binary_multiplication') + + def test_xor_constraints(self): + ct.check_xor_constraint(self, 'binary_multiplication') + + def test_or_constraints(self): + m = models.makeTwoTermDisj() + m.disjunction.xor = False + TransformationFactory('gdp.binary_multiplication').apply_to(m) + + # check or constraint is an or (upper bound is None) + orcons = m._pyomo_gdp_binary_multiplication_reformulation.component( + "disjunction_xor" + ) + self.assertIsInstance(orcons, Constraint) + assertExpressionsEqual( + self, + orcons.body, + EXPR.LinearExpression( + [m.d[0].binary_indicator_var, m.d[1].binary_indicator_var] + ), + ) + self.assertEqual(orcons.lower, 1) + self.assertIsNone(orcons.upper) + + def test_deactivated_constraints(self): + ct.check_deactivated_constraints(self, 'binary_multiplication') + + def test_transformed_constraints(self): + m = models.makeTwoTermDisj() + binary_multiplication = TransformationFactory('gdp.binary_multiplication') + binary_multiplication.apply_to(m) + self.check_transformed_constraints(m, binary_multiplication, -3, 2, 7, 2) + + def test_do_not_transform_userDeactivated_disjuncts(self): + ct.check_user_deactivated_disjuncts(self, 'binary_multiplication') + + def test_improperly_deactivated_disjuncts(self): + ct.check_improperly_deactivated_disjuncts(self, 'binary_multiplication') + + def test_do_not_transform_userDeactivated_IndexedDisjunction(self): + ct.check_do_not_transform_userDeactivated_indexedDisjunction( + self, 'binary_multiplication' + ) + + def check_transformed_constraints( + self, model, binary_multiplication, cons1lb, cons2lb, cons2ub, cons3ub + ): + disjBlock = ( + model._pyomo_gdp_binary_multiplication_reformulation.relaxedDisjuncts + ) + + # first constraint + c = binary_multiplication.get_transformed_constraints(model.d[0].c) + self.assertEqual(len(c), 1) + c_lb = c[0] + self.assertTrue(c[0].active) + ind_var = model.d[0].indicator_var + assertExpressionsEqual( + self, c[0].body, (model.a - model.d[0].c.lower) * ind_var + ) + self.assertEqual(c[0].lower, 0) + self.assertIsNone(c[0].upper) + + # second constraint + c = binary_multiplication.get_transformed_constraints(model.d[1].c1) + self.assertEqual(len(c), 1) + c_eq = c[0] + self.assertTrue(c[0].active) + ind_var = model.d[1].indicator_var + assertExpressionsEqual(self, c[0].body, model.a * ind_var) + self.assertEqual(c[0].lower, 0) + self.assertEqual(c[0].upper, 0) + + # third constraint + c = binary_multiplication.get_transformed_constraints(model.d[1].c2) + self.assertEqual(len(c), 1) + c_ub = c[0] + self.assertTrue(c_ub.active) + assertExpressionsEqual( + self, c_ub.body, (model.x - model.d[1].c2.upper) * ind_var + ) + self.assertIsNone(c_ub.lower) + self.assertEqual(c_ub.upper, 0) + + def test_create_using(self): + m = models.makeTwoTermDisj() + self.diff_apply_to_and_create_using(m) + + def test_indexed_constraints_in_disjunct(self): + m = ConcreteModel() + m.I = [1, 2, 3] + m.x = Var(m.I, bounds=(0, 10)) + + def c_rule(b, i): + m = b.model() + return m.x[i] >= i + + def d_rule(d, j): + m = d.model() + d.c = Constraint(m.I[:j], rule=c_rule) + + m.d = Disjunct(m.I, rule=d_rule) + m.disjunction = Disjunction(expr=[m.d[i] for i in m.I]) + + TransformationFactory('gdp.binary_multiplication').apply_to(m) + transBlock = m._pyomo_gdp_binary_multiplication_reformulation + + # 2 blocks: the original Disjunct and the transformation block + self.assertEqual(len(list(m.component_objects(Block, descend_into=False))), 1) + self.assertEqual(len(list(m.component_objects(Disjunct))), 1) + + # Each relaxed disjunct should have 1 var (the reference to the + # indicator var), and i "d[i].c" Constraints + for i in [1, 2, 3]: + relaxed = transBlock.relaxedDisjuncts[i - 1] + self.assertEqual(len(list(relaxed.component_objects(Var))), 1) + self.assertEqual(len(list(relaxed.component_data_objects(Var))), 1) + self.assertEqual(len(list(relaxed.component_objects(Constraint))), 1) + self.assertEqual(len(list(relaxed.component_data_objects(Constraint))), i) + + def test_virtual_indexed_constraints_in_disjunct(self): + m = ConcreteModel() + m.I = [1, 2, 3] + m.x = Var(m.I, bounds=(0, 10)) + + def d_rule(d, j): + m = d.model() + d.c = Constraint(Any) + for k in range(j): + d.c[k + 1] = m.x[k + 1] >= k + 1 + + m.d = Disjunct(m.I, rule=d_rule) + m.disjunction = Disjunction(expr=[m.d[i] for i in m.I]) + + TransformationFactory('gdp.binary_multiplication').apply_to(m) + transBlock = m._pyomo_gdp_binary_multiplication_reformulation + + # 2 blocks: the original Disjunct and the transformation block + self.assertEqual(len(list(m.component_objects(Block, descend_into=False))), 1) + self.assertEqual(len(list(m.component_objects(Disjunct))), 1) + + # Each relaxed disjunct should have 1 var (the reference to the + # indicator var), and i "d[i].c" Constraints + for i in [1, 2, 3]: + relaxed = transBlock.relaxedDisjuncts[i - 1] + self.assertEqual(len(list(relaxed.component_objects(Var))), 1) + self.assertEqual(len(list(relaxed.component_data_objects(Var))), 1) + self.assertEqual(len(list(relaxed.component_objects(Constraint))), 1) + self.assertEqual(len(list(relaxed.component_data_objects(Constraint))), i) + + def test_local_var(self): + m = models.localVar() + binary_multiplication = TransformationFactory('gdp.binary_multiplication') + binary_multiplication.apply_to(m) + + # we just need to make sure that constraint was transformed correctly, + # which just means that the M values were correct. + transformedC = binary_multiplication.get_transformed_constraints(m.disj2.cons) + self.assertEqual(len(transformedC), 1) + eq = transformedC[0] + repn = generate_standard_repn(eq.body) + self.assertIsNone(repn.nonlinear_expr) + self.assertEqual(len(repn.linear_coefs), 1) + self.assertEqual(len(repn.quadratic_coefs), 2) + ct.check_linear_coef(self, repn, m.disj2.indicator_var, -3) + ct.check_quadratic_coef(self, repn, m.x, m.disj2.indicator_var, 1) + ct.check_quadratic_coef(self, repn, m.disj2.y, m.disj2.indicator_var, 1) + self.assertEqual(repn.constant, 0) + self.assertEqual(eq.lb, 0) + self.assertEqual(eq.ub, 0) + + +class TestNestedGDP(unittest.TestCase): + @unittest.skipUnless(gurobi_available, "Gurobi is not available") + def test_do_not_assume_nested_indicators_local(self): + ct.check_do_not_assume_nested_indicators_local( + self, 'gdp.binary_multiplication' + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/pyomo/gdp/tests/test_bound_pretransformation.py b/pyomo/gdp/tests/test_bound_pretransformation.py index 30ce76b7e31..68db64ce93b 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-2022 +# 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 diff --git a/pyomo/gdp/tests/test_cuttingplane.py b/pyomo/gdp/tests/test_cuttingplane.py index 827eac9aa6a..153e236942d 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-2022 +# 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 diff --git a/pyomo/gdp/tests/test_disjunct.py b/pyomo/gdp/tests/test_disjunct.py index 676b49a80cd..f93ac31fb0f 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-2022 +# 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 @@ -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 1b741f7a840..6f01e096e9d 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-2022 +# 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 diff --git a/pyomo/gdp/tests/test_gdp.py b/pyomo/gdp/tests/test_gdp.py index 5c810dcce18..b22a60bc04a 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-2022 +# 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 diff --git a/pyomo/gdp/tests/test_gdp_reclassification_error.py b/pyomo/gdp/tests/test_gdp_reclassification_error.py index a65ccac2d8f..556dc44eead 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-2022 +# 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 diff --git a/pyomo/gdp/tests/test_hull.py b/pyomo/gdp/tests/test_hull.py index 09f65765fe6..ec011fb802a 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-2022 +# 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 @@ -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, @@ -41,18 +46,14 @@ import pyomo.core.expr as EXPR from pyomo.core.base import constraint from pyomo.repn import generate_standard_repn +from pyomo.repn.linear import LinearRepnVisitor from pyomo.gdp import Disjunct, Disjunction, GDP_Error 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 @@ -402,19 +403,13 @@ def test_error_for_or(self): self.assertRaisesRegex( GDP_Error, "Cannot do hull reformulation for Disjunction " - "'disjunction' with OR constraint. Must be an XOR!*", + "'disjunction' with OR constraint. Must be an XOR!*", TransformationFactory('gdp.hull').apply_to, m, ) def check_disaggregation_constraint(self, cons, var, disvar1, disvar2): - repn = generate_standard_repn(cons.body) - self.assertEqual(cons.lower, 0) - self.assertEqual(cons.upper, 0) - self.assertEqual(len(repn.linear_vars), 3) - ct.check_linear_coef(self, repn, var, 1) - ct.check_linear_coef(self, repn, disvar1, -1) - ct.check_linear_coef(self, repn, disvar2, -1) + assertExpressionsEqual(self, cons.expr, var == disvar1 + disvar2) def test_disaggregation_constraint(self): m = models.makeTwoTermDisj_Nonlinear() @@ -426,8 +421,8 @@ def test_disaggregation_constraint(self): self.check_disaggregation_constraint( hull.get_disaggregation_constraint(m.w, m.disjunction), m.w, - disjBlock[1].disaggregatedVars.w, transBlock._disaggregatedVars[1], + disjBlock[1].disaggregatedVars.w, ) self.check_disaggregation_constraint( hull.get_disaggregation_constraint(m.x, m.disjunction), @@ -438,8 +433,8 @@ def test_disaggregation_constraint(self): self.check_disaggregation_constraint( hull.get_disaggregation_constraint(m.y, m.disjunction), m.y, - disjBlock[0].disaggregatedVars.y, transBlock._disaggregatedVars[0], + disjBlock[0].disaggregatedVars.y, ) def test_xor_constraint_mapping(self): @@ -510,10 +505,10 @@ def test_disaggregatedVar_mappings(self): for i in [0, 1]: mappings = ComponentMap() mappings[m.x] = disjBlock[i].disaggregatedVars.x - if i == 1: # this disjunct as x, w, and no y + if i == 1: # this disjunct has x, w, and no y mappings[m.w] = disjBlock[i].disaggregatedVars.w mappings[m.y] = transBlock._disaggregatedVars[0] - elif i == 0: # this disjunct as x, y, and no w + elif i == 0: # this disjunct has x, y, and no w mappings[m.y] = disjBlock[i].disaggregatedVars.y mappings[m.w] = transBlock._disaggregatedVars[1] @@ -534,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 @@ -549,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): @@ -668,17 +669,38 @@ def test_global_vars_local_to_a_disjunction_disaggregated(self): self.assertIs(hull.get_src_var(x), m.disj1.x) # there is a spare x on disjunction1's block - x2 = m.disjunction1.algebraic_constraint.parent_block()._disaggregatedVars[2] + x2 = m.disjunction1.algebraic_constraint.parent_block()._disaggregatedVars[0] self.assertIs(hull.get_disaggregated_var(m.disj1.x, m.disj2), x2) self.assertIs(hull.get_src_var(x2), m.disj1.x) + # What really matters is that the above matches this: + agg_cons = hull.get_disaggregation_constraint(m.disj1.x, m.disjunction1) + assertExpressionsEqual( + self, + agg_cons.expr, + m.disj1.x == x2 + hull.get_disaggregated_var(m.disj1.x, m.disj1), + ) # and both a spare x and y on disjunction2's block - x2 = m.disjunction2.algebraic_constraint.parent_block()._disaggregatedVars[0] - y1 = m.disjunction2.algebraic_constraint.parent_block()._disaggregatedVars[1] + x2 = m.disjunction2.algebraic_constraint.parent_block()._disaggregatedVars[1] + y1 = m.disjunction2.algebraic_constraint.parent_block()._disaggregatedVars[2] self.assertIs(hull.get_disaggregated_var(m.disj1.x, m.disj4), x2) self.assertIs(hull.get_src_var(x2), m.disj1.x) self.assertIs(hull.get_disaggregated_var(m.disj1.y, m.disj3), y1) self.assertIs(hull.get_src_var(y1), m.disj1.y) + # and again what really matters is that these align with the + # disaggregation constraints: + agg_cons = hull.get_disaggregation_constraint(m.disj1.x, m.disjunction2) + assertExpressionsEqual( + self, + agg_cons.expr, + m.disj1.x == x2 + hull.get_disaggregated_var(m.disj1.x, m.disj3), + ) + agg_cons = hull.get_disaggregation_constraint(m.disj1.y, m.disjunction2) + assertExpressionsEqual( + self, + agg_cons.expr, + m.disj1.y == y1 + hull.get_disaggregated_var(m.disj1.y, m.disj4), + ) def check_name_collision_disaggregated_vars(self, m, disj): hull = TransformationFactory('gdp.hull') @@ -880,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]) @@ -1101,7 +1115,7 @@ def check_trans_block_disjunctions_of_disjunct_datas(self, m): self.assertEqual(len(transBlock1.relaxedDisjuncts), 4) hull = TransformationFactory('gdp.hull') - firstTerm2 = transBlock1.relaxedDisjuncts[0] + firstTerm2 = transBlock1.relaxedDisjuncts[2] self.assertIs(firstTerm2, m.firstTerm[2].transformation_block) self.assertIsInstance(firstTerm2.disaggregatedVars.component("x"), Var) constraints = hull.get_transformed_constraints(m.firstTerm[2].cons) @@ -1115,7 +1129,7 @@ def check_trans_block_disjunctions_of_disjunct_datas(self, m): self.assertIs(cons.parent_block(), firstTerm2) self.assertEqual(len(cons), 2) - secondTerm2 = transBlock1.relaxedDisjuncts[1] + secondTerm2 = transBlock1.relaxedDisjuncts[3] self.assertIs(secondTerm2, m.secondTerm[2].transformation_block) self.assertIsInstance(secondTerm2.disaggregatedVars.component("x"), Var) constraints = hull.get_transformed_constraints(m.secondTerm[2].cons) @@ -1129,7 +1143,7 @@ def check_trans_block_disjunctions_of_disjunct_datas(self, m): self.assertIs(cons.parent_block(), secondTerm2) self.assertEqual(len(cons), 2) - firstTerm1 = transBlock1.relaxedDisjuncts[2] + firstTerm1 = transBlock1.relaxedDisjuncts[0] self.assertIs(firstTerm1, m.firstTerm[1].transformation_block) self.assertIsInstance(firstTerm1.disaggregatedVars.component("x"), Var) self.assertTrue(firstTerm1.disaggregatedVars.x.is_fixed()) @@ -1147,7 +1161,7 @@ def check_trans_block_disjunctions_of_disjunct_datas(self, m): self.assertIs(cons.parent_block(), firstTerm1) self.assertEqual(len(cons), 2) - secondTerm1 = transBlock1.relaxedDisjuncts[3] + secondTerm1 = transBlock1.relaxedDisjuncts[1] self.assertIs(secondTerm1, m.secondTerm[1].transformation_block) self.assertIsInstance(secondTerm1.disaggregatedVars.component("x"), Var) constraints = hull.get_transformed_constraints(m.secondTerm[1].cons) @@ -1243,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) @@ -1375,9 +1387,8 @@ def test_deactivated_disjunct_leaves_nested_disjuncts_active(self): ct.check_deactivated_disjunct_leaves_nested_disjunct_active(self, 'hull') def test_mappings_between_disjunctions_and_xors(self): - # This test is nearly identical to the one in bigm, but because of - # different transformation orders, the name conflict gets resolved in - # the opposite way. + # Tests that the XOR constraints are put on the parent block of the + # disjunction, and checks the mappings. m = models.makeNestedDisjunctions() transform = TransformationFactory('gdp.hull') transform.apply_to(m) @@ -1386,8 +1397,17 @@ def test_mappings_between_disjunctions_and_xors(self): disjunctionPairs = [ (m.disjunction, transBlock.disjunction_xor), - (m.disjunct[1].innerdisjunction[0], transBlock.innerdisjunction_xor[0]), - (m.simpledisjunct.innerdisjunction, transBlock.innerdisjunction_xor_4), + ( + m.disjunct[1].innerdisjunction[0], + m.disjunct[1] + .innerdisjunction[0] + .algebraic_constraint.parent_block() + .innerdisjunction_xor[0], + ), + ( + m.simpledisjunct.innerdisjunction, + m.simpledisjunct.innerdisjunction.algebraic_constraint.parent_block().innerdisjunction_xor, + ), ] # check disjunction mappings @@ -1427,16 +1447,16 @@ def test_relaxation_feasibility(self): solver = SolverFactory(linear_solvers[0]) cases = [ - (1, 1, 1, 1, None), - (0, 0, 0, 0, None), - (1, 0, 0, 0, None), - (0, 1, 0, 0, 1.1), - (0, 0, 1, 0, None), - (0, 0, 0, 1, None), - (1, 1, 0, 0, None), - (1, 0, 1, 0, 1.2), - (1, 0, 0, 1, 1.3), - (1, 0, 1, 1, None), + (True, True, True, True, None), + (False, False, False, False, None), + (True, False, False, False, None), + (False, True, False, False, 1.1), + (False, False, True, False, None), + (False, False, False, True, None), + (True, True, False, False, None), + (True, False, True, False, 1.2), + (True, False, False, True, 1.3), + (True, False, True, True, None), ] for case in cases: m.d1.indicator_var.fix(case[0]) @@ -1468,16 +1488,16 @@ def test_relaxation_feasibility_transform_inner_first(self): solver = SolverFactory(linear_solvers[0]) cases = [ - (1, 1, 1, 1, None), - (0, 0, 0, 0, None), - (1, 0, 0, 0, None), - (0, 1, 0, 0, 1.1), - (0, 0, 1, 0, None), - (0, 0, 0, 1, None), - (1, 1, 0, 0, None), - (1, 0, 1, 0, 1.2), - (1, 0, 0, 1, 1.3), - (1, 0, 1, 1, None), + (True, True, True, True, None), + (False, False, False, False, None), + (True, False, False, False, None), + (False, True, False, False, 1.1), + (False, False, True, False, None), + (False, False, False, True, None), + (True, True, False, False, None), + (True, False, True, False, 1.2), + (True, False, False, True, 1.3), + (True, False, True, True, None), ] for case in cases: m.d1.indicator_var.fix(case[0]) @@ -1550,149 +1570,190 @@ def check_transformed_constraint(self, cons, dis, lb, ind_var): def test_transformed_model_nestedDisjuncts(self): # This test tests *everything* for a simple nested disjunction case. m = models.makeNestedDisjunctions_NestedDisjuncts() + m.LocalVars = Suffix(direction=Suffix.LOCAL) + m.LocalVars[m.d1] = [ + m.d1.binary_indicator_var, + m.d1.d3.binary_indicator_var, + m.d1.d4.binary_indicator_var, + ] hull = TransformationFactory('gdp.hull') hull.apply_to(m) + self.check_transformed_model_nestedDisjuncts( + m, m.d1.d3.binary_indicator_var, m.d1.d4.binary_indicator_var + ) + + # Last, check that there aren't things we weren't expecting + all_cons = list( + m.component_data_objects(Constraint, active=True, descend_into=Block) + ) + # 2 disaggregation constraints for x 0,3 + # + 6 bounds constraints for x 6,8,9,13,14,16 + # + 2 bounds constraints for inner indicator vars 11, 12 + # + 2 exactly-one constraints 1,4 + # + 4 transformed constraints 2,5,7,15 + self.assertEqual(len(all_cons), 16) + + def check_transformed_model_nestedDisjuncts(self, m, d3, d4): + # This function checks all of the 16 constraint expressions from + # transforming models.makeNestedDisjunction_NestedDisjuncts when + # declaring the inner indicator vars (d3 and d4) as local. Note that it + # also is a correct test for the case where the inner indicator vars are + # *not* declared as local, but not a complete one, since there are + # additional constraints in that case (see + # check_transformation_blocks_nestedDisjunctions in common_tests.py). + hull = TransformationFactory('gdp.hull') transBlock = m._pyomo_gdp_hull_reformulation self.assertTrue(transBlock.active) - # outer xor should be on this block + # check outer xor xor = transBlock.disj_xor self.assertIsInstance(xor, Constraint) - self.assertTrue(xor.active) - self.assertEqual(xor.lower, 1) - self.assertEqual(xor.upper, 1) - repn = generate_standard_repn(xor.body) - self.assertTrue(repn.is_linear()) - self.assertEqual(repn.constant, 0) - ct.check_linear_coef(self, repn, m.d1.binary_indicator_var, 1) - ct.check_linear_coef(self, repn, m.d2.binary_indicator_var, 1) + ct.check_obj_in_active_tree(self, xor) + assertExpressionsEqual( + self, xor.expr, m.d1.binary_indicator_var + m.d2.binary_indicator_var == 1 + ) self.assertIs(xor, m.disj.algebraic_constraint) self.assertIs(m.disj, hull.get_src_disjunction(xor)) - # inner xor should be on this block + # check inner xor xor = m.d1.disj2.algebraic_constraint - self.assertIs(xor.parent_block(), transBlock) - self.assertIsInstance(xor, Constraint) - self.assertTrue(xor.active) - self.assertEqual(xor.lower, 0) - self.assertEqual(xor.upper, 0) - repn = generate_standard_repn(xor.body) - self.assertTrue(repn.is_linear()) - self.assertEqual(repn.constant, 0) - ct.check_linear_coef(self, repn, m.d1.d3.binary_indicator_var, 1) - ct.check_linear_coef(self, repn, m.d1.d4.binary_indicator_var, 1) - ct.check_linear_coef(self, repn, m.d1.binary_indicator_var, -1) self.assertIs(m.d1.disj2, hull.get_src_disjunction(xor)) - - # so should both disaggregation constraints - dis = transBlock.disaggregationConstraints - self.assertIsInstance(dis, Constraint) - self.assertTrue(dis.active) - self.assertEqual(len(dis), 2) - self.check_outer_disaggregation_constraint(dis[0], m.x, m.d1, m.d2) - self.assertIs(hull.get_disaggregation_constraint(m.x, m.disj), dis[0]) - self.check_outer_disaggregation_constraint( - dis[1], m.x, m.d1.d3, m.d1.d4, rhs=hull.get_disaggregated_var(m.x, m.d1) - ) - self.assertIs(hull.get_disaggregation_constraint(m.x, m.d1.disj2), dis[1]) - - # we should have four disjunct transformation blocks - disjBlocks = transBlock.relaxedDisjuncts - self.assertTrue(disjBlocks.active) - self.assertEqual(len(disjBlocks), 4) - - ## d1's transformation block - - disj1 = disjBlocks[0] - self.assertTrue(disj1.active) - self.assertIs(disj1, m.d1.transformation_block) - self.assertIs(m.d1, hull.get_src_disjunct(disj1)) - # check the disaggregated x is here - self.assertIsInstance(disj1.disaggregatedVars.x, Var) - self.assertEqual(disj1.disaggregatedVars.x.lb, 0) - self.assertEqual(disj1.disaggregatedVars.x.ub, 2) - self.assertIs(disj1.disaggregatedVars.x, hull.get_disaggregated_var(m.x, m.d1)) - self.assertIs(m.x, hull.get_src_var(disj1.disaggregatedVars.x)) - # check the bounds constraints - self.check_bounds_constraint_ub( - disj1.x_bounds, 2, disj1.disaggregatedVars.x, m.d1.indicator_var - ) - # transformed constraint x >= 1 - cons = hull.get_transformed_constraints(m.d1.c) - self.check_transformed_constraint( - cons, disj1.disaggregatedVars.x, 1, m.d1.indicator_var + xor = hull.get_transformed_constraints(xor) + self.assertEqual(len(xor), 1) + xor = xor[0] + ct.check_obj_in_active_tree(self, xor) + xor_expr = self.simplify_cons(xor) + assertExpressionsEqual( + self, xor_expr, d3 + d4 - m.d1.binary_indicator_var == 0.0 ) - ## d2's transformation block + # check disaggregation constraints + x_d3 = hull.get_disaggregated_var(m.x, m.d1.d3) + x_d4 = hull.get_disaggregated_var(m.x, m.d1.d4) + x_d1 = hull.get_disaggregated_var(m.x, m.d1) + x_d2 = hull.get_disaggregated_var(m.x, m.d2) + for x in [x_d1, x_d2, x_d3, x_d4]: + self.assertEqual(x.lb, 0) + self.assertEqual(x.ub, 2) + # Inner disjunction + cons = hull.get_disaggregation_constraint(m.x, m.d1.disj2) + ct.check_obj_in_active_tree(self, cons) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual(self, cons_expr, x_d1 - x_d3 - x_d4 == 0.0) + # Outer disjunction + cons = hull.get_disaggregation_constraint(m.x, m.disj) + ct.check_obj_in_active_tree(self, cons) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual(self, cons_expr, m.x - x_d1 - x_d2 == 0.0) - disj2 = disjBlocks[1] - self.assertTrue(disj2.active) - self.assertIs(disj2, m.d2.transformation_block) - self.assertIs(m.d2, hull.get_src_disjunct(disj2)) - # disaggregated var - x2 = disj2.disaggregatedVars.x - self.assertIsInstance(x2, Var) - self.assertEqual(x2.lb, 0) - self.assertEqual(x2.ub, 2) - self.assertIs(hull.get_disaggregated_var(m.x, m.d2), x2) - self.assertIs(hull.get_src_var(x2), m.x) - # bounds constraint - x_bounds = disj2.x_bounds - self.check_bounds_constraint_ub(x_bounds, 2, x2, m.d2.binary_indicator_var) - # transformed constraint x >= 1.1 - cons = hull.get_transformed_constraints(m.d2.c) - self.check_transformed_constraint(cons, x2, 1.1, m.d2.binary_indicator_var) - - ## d1.d3's transformation block - - disj3 = disjBlocks[2] - self.assertTrue(disj3.active) - self.assertIs(disj3, m.d1.d3.transformation_block) - self.assertIs(m.d1.d3, hull.get_src_disjunct(disj3)) - # disaggregated var - x3 = disj3.disaggregatedVars.x - self.assertIsInstance(x3, Var) - self.assertEqual(x3.lb, 0) - self.assertEqual(x3.ub, 2) - self.assertIs(hull.get_disaggregated_var(m.x, m.d1.d3), x3) - self.assertIs(hull.get_src_var(x3), m.x) - # bounds constraints - self.check_bounds_constraint_ub( - disj3.x_bounds, 2, x3, m.d1.d3.binary_indicator_var - ) - # transformed x >= 1.2 + ## Transformed constraints cons = hull.get_transformed_constraints(m.d1.d3.c) - self.check_transformed_constraint(cons, x3, 1.2, m.d1.d3.binary_indicator_var) - - ## d1.d4's transformation block - - disj4 = disjBlocks[3] - self.assertTrue(disj4.active) - self.assertIs(disj4, m.d1.d4.transformation_block) - self.assertIs(m.d1.d4, hull.get_src_disjunct(disj4)) - # disaggregated var - x4 = disj4.disaggregatedVars.x - self.assertIsInstance(x4, Var) - self.assertEqual(x4.lb, 0) - self.assertEqual(x4.ub, 2) - self.assertIs(hull.get_disaggregated_var(m.x, m.d1.d4), x4) - self.assertIs(hull.get_src_var(x4), m.x) - # bounds constraints - self.check_bounds_constraint_ub( - disj4.x_bounds, 2, x4, m.d1.d4.binary_indicator_var - ) - # transformed x >= 1.3 + self.assertEqual(len(cons), 1) + cons = cons[0] + ct.check_obj_in_active_tree(self, cons) + cons_expr = self.simplify_leq_cons(cons) + assertExpressionsEqual(self, cons_expr, 1.2 * d3 - x_d3 <= 0.0) + cons = hull.get_transformed_constraints(m.d1.d4.c) - self.check_transformed_constraint(cons, x4, 1.3, m.d1.d4.binary_indicator_var) + self.assertEqual(len(cons), 1) + cons = cons[0] + ct.check_obj_in_active_tree(self, cons) + cons_expr = self.simplify_leq_cons(cons) + assertExpressionsEqual(self, cons_expr, 1.3 * d4 - x_d4 <= 0.0) + + cons = hull.get_transformed_constraints(m.d1.c) + self.assertEqual(len(cons), 1) + cons = cons[0] + ct.check_obj_in_active_tree(self, cons) + cons_expr = self.simplify_leq_cons(cons) + assertExpressionsEqual( + self, cons_expr, 1.0 * m.d1.binary_indicator_var - x_d1 <= 0.0 + ) + + cons = hull.get_transformed_constraints(m.d2.c) + self.assertEqual(len(cons), 1) + cons = cons[0] + ct.check_obj_in_active_tree(self, cons) + cons_expr = self.simplify_leq_cons(cons) + assertExpressionsEqual( + self, cons_expr, 1.1 * m.d2.binary_indicator_var - x_d2 <= 0.0 + ) + + ## Bounds constraints + cons = hull.get_var_bounds_constraint(x_d1) + # the lb is trivial in this case, so we just have 1 + self.assertEqual(len(cons), 1) + ct.check_obj_in_active_tree(self, cons['ub']) + cons_expr = self.simplify_leq_cons(cons['ub']) + assertExpressionsEqual( + self, cons_expr, x_d1 - 2 * m.d1.binary_indicator_var <= 0.0 + ) + cons = hull.get_var_bounds_constraint(x_d2) + # the lb is trivial in this case, so we just have 1 + self.assertEqual(len(cons), 1) + ct.check_obj_in_active_tree(self, cons['ub']) + cons_expr = self.simplify_leq_cons(cons['ub']) + assertExpressionsEqual( + self, cons_expr, x_d2 - 2 * m.d2.binary_indicator_var <= 0.0 + ) + cons = hull.get_var_bounds_constraint(x_d3, m.d1.d3) + # the lb is trivial in this case, so we just have 1 + self.assertEqual(len(cons), 1) + # And we know it has actually been transformed again, so get that one + cons = hull.get_transformed_constraints(cons['ub']) + self.assertEqual(len(cons), 1) + ub = cons[0] + ct.check_obj_in_active_tree(self, ub) + cons_expr = self.simplify_leq_cons(ub) + assertExpressionsEqual(self, cons_expr, x_d3 - 2 * d3 <= 0.0) + cons = hull.get_var_bounds_constraint(x_d4, m.d1.d4) + # the lb is trivial in this case, so we just have 1 + self.assertEqual(len(cons), 1) + # And we know it has actually been transformed again, so get that one + cons = hull.get_transformed_constraints(cons['ub']) + self.assertEqual(len(cons), 1) + ub = cons[0] + ct.check_obj_in_active_tree(self, ub) + cons_expr = self.simplify_leq_cons(ub) + assertExpressionsEqual(self, cons_expr, x_d4 - 2 * d4 <= 0.0) + cons = hull.get_var_bounds_constraint(x_d3, m.d1) + self.assertEqual(len(cons), 1) + ub = cons['ub'] + ct.check_obj_in_active_tree(self, ub) + cons_expr = self.simplify_leq_cons(ub) + assertExpressionsEqual( + self, cons_expr, x_d3 - 2 * m.d1.binary_indicator_var <= 0.0 + ) + cons = hull.get_var_bounds_constraint(x_d4, m.d1) + self.assertEqual(len(cons), 1) + ub = cons['ub'] + ct.check_obj_in_active_tree(self, ub) + cons_expr = self.simplify_leq_cons(ub) + assertExpressionsEqual( + self, cons_expr, x_d4 - 2 * m.d1.binary_indicator_var <= 0.0 + ) + + # Bounds constraints for local vars + cons = hull.get_var_bounds_constraint(d3) + ct.check_obj_in_active_tree(self, cons['ub']) + assertExpressionsEqual(self, cons['ub'].expr, d3 <= m.d1.binary_indicator_var) + cons = hull.get_var_bounds_constraint(d4) + ct.check_obj_in_active_tree(self, cons['ub']) + assertExpressionsEqual(self, cons['ub'].expr, d4 <= m.d1.binary_indicator_var) @unittest.skipIf(not linear_solvers, "No linear solver available") def test_solve_nested_model(self): # This is really a test that our variable references have all been moved # up correctly. m = models.makeNestedDisjunctions_NestedDisjuncts() - + m.LocalVars = Suffix(direction=Suffix.LOCAL) + m.LocalVars[m.d1] = [ + m.d1.binary_indicator_var, + m.d1.d3.binary_indicator_var, + m.d1.d4.binary_indicator_var, + ] hull = TransformationFactory('gdp.hull') m_hull = hull.create_using(m) @@ -1722,10 +1783,10 @@ def test_disaggregated_vars_are_set_to_0_correctly(self): hull.apply_to(m) # this should be a feasible integer solution - m.d1.indicator_var.fix(0) - m.d2.indicator_var.fix(1) - m.d3.indicator_var.fix(0) - m.d4.indicator_var.fix(0) + m.d1.indicator_var.fix(False) + m.d2.indicator_var.fix(True) + m.d3.indicator_var.fix(False) + m.d4.indicator_var.fix(False) results = SolverFactory(linear_solvers[0]).solve(m) self.assertEqual( @@ -1739,10 +1800,10 @@ def test_disaggregated_vars_are_set_to_0_correctly(self): self.assertEqual(value(hull.get_disaggregated_var(m.x, m.d4)), 0) # and what if one of the inner disjuncts is true? - m.d1.indicator_var.fix(1) - m.d2.indicator_var.fix(0) - m.d3.indicator_var.fix(1) - m.d4.indicator_var.fix(0) + m.d1.indicator_var.fix(True) + m.d2.indicator_var.fix(False) + m.d3.indicator_var.fix(True) + m.d4.indicator_var.fix(False) results = SolverFactory(linear_solvers[0]).solve(m) self.assertEqual( @@ -1787,6 +1848,11 @@ def d_r(e): e.c1 = Constraint(expr=e.lambdas[1] + e.lambdas[2] == 1) e.c2 = Constraint(expr=m.x == 2 * e.lambdas[1] + 3 * e.lambdas[2]) + d.LocalVars = Suffix(direction=Suffix.LOCAL) + d.LocalVars[d] = [ + d.d_l.indicator_var.get_associated_binary(), + d.d_r.indicator_var.get_associated_binary(), + ] d.inner_disj = Disjunction(expr=[d.d_l, d.d_r]) m.disj = Disjunction(expr=[m.d_l, m.d_r]) @@ -1809,28 +1875,159 @@ def d_r(e): cons = hull.get_transformed_constraints(d.c1) self.assertEqual(len(cons), 1) convex_combo = cons[0] + convex_combo_expr = self.simplify_cons(convex_combo) assertExpressionsEqual( self, - convex_combo.expr, - lambda1 + lambda2 - (1 - d.indicator_var.get_associated_binary()) * 0.0 - == d.indicator_var.get_associated_binary(), + convex_combo_expr, + lambda1 + lambda2 - d.indicator_var.get_associated_binary() == 0.0, ) cons = hull.get_transformed_constraints(d.c2) self.assertEqual(len(cons), 1) get_x = cons[0] + get_x_expr = self.simplify_cons(get_x) assertExpressionsEqual( - self, - get_x.expr, - x - - (2 * lambda1 + 3 * lambda2) - - (1 - d.indicator_var.get_associated_binary()) * 0.0 - == 0.0 * d.indicator_var.get_associated_binary(), + self, get_x_expr, x - 2 * lambda1 - 3 * lambda2 == 0.0 ) cons = hull.get_disaggregation_constraint(m.x, m.disj) assertExpressionsEqual(self, cons.expr, m.x == x1 + x2) cons = hull.get_disaggregation_constraint(m.x, m.d_r.inner_disj) - assertExpressionsEqual(self, cons.expr, x2 == x3 + x4) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual(self, cons_expr, x2 - x3 - x4 == 0.0) + + def test_nested_with_var_that_does_not_appear_in_every_disjunct(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 10)) + m.y = Var(bounds=(-4, 5)) + m.parent1 = Disjunct() + m.parent2 = Disjunct() + m.parent2.c = Constraint(expr=m.x == 0) + m.parent_disjunction = Disjunction(expr=[m.parent1, m.parent2]) + m.child1 = Disjunct() + m.child1.c = Constraint(expr=m.x <= 8) + m.child2 = Disjunct() + m.child2.c = Constraint(expr=m.x + m.y <= 3) + m.child3 = Disjunct() + m.child3.c = Constraint(expr=m.x <= 7) + m.parent1.disjunction = Disjunction(expr=[m.child1, m.child2, m.child3]) + + hull = TransformationFactory('gdp.hull') + hull.apply_to(m) + + y_c2 = hull.get_disaggregated_var(m.y, m.child2) + self.assertEqual(y_c2.bounds, (-4, 5)) + other_y = hull.get_disaggregated_var(m.y, m.child1) + self.assertEqual(other_y.bounds, (-4, 5)) + other_other_y = hull.get_disaggregated_var(m.y, m.child3) + self.assertIs(other_y, other_other_y) + y_p1 = hull.get_disaggregated_var(m.y, m.parent1) + self.assertEqual(y_p1.bounds, (-4, 5)) + y_p2 = hull.get_disaggregated_var(m.y, m.parent2) + self.assertEqual(y_p2.bounds, (-4, 5)) + + y_cons = hull.get_disaggregation_constraint(m.y, m.parent1.disjunction) + # check that the disaggregated ys in the nested just sum to the original + y_cons_expr = self.simplify_cons(y_cons) + assertExpressionsEqual(self, y_cons_expr, y_p1 - other_y - y_c2 == 0.0) + y_cons = hull.get_disaggregation_constraint(m.y, m.parent_disjunction) + y_cons_expr = self.simplify_cons(y_cons) + assertExpressionsEqual(self, y_cons_expr, m.y - y_p2 - y_p1 == 0.0) + + x_c1 = hull.get_disaggregated_var(m.x, m.child1) + x_c2 = hull.get_disaggregated_var(m.x, m.child2) + x_c3 = hull.get_disaggregated_var(m.x, m.child3) + x_p1 = hull.get_disaggregated_var(m.x, m.parent1) + x_p2 = hull.get_disaggregated_var(m.x, m.parent2) + x_cons_parent = hull.get_disaggregation_constraint(m.x, m.parent_disjunction) + assertExpressionsEqual(self, x_cons_parent.expr, m.x == x_p1 + x_p2) + x_cons_child = hull.get_disaggregation_constraint(m.x, m.parent1.disjunction) + x_cons_child_expr = self.simplify_cons(x_cons_child) + assertExpressionsEqual( + self, x_cons_child_expr, x_p1 - x_c1 - x_c2 - x_c3 == 0.0 + ) + + def simplify_cons(self, cons): + visitor = LinearRepnVisitor({}, {}, {}, None) + lb = cons.lower + ub = cons.upper + self.assertEqual(cons.lb, cons.ub) + repn = visitor.walk_expression(cons.body) + self.assertIsNone(repn.nonlinear) + return repn.to_expression(visitor) == lb + + def simplify_leq_cons(self, cons): + visitor = LinearRepnVisitor({}, {}, {}, None) + self.assertIsNone(cons.lower) + ub = cons.upper + repn = visitor.walk_expression(cons.body) + self.assertIsNone(repn.nonlinear) + return repn.to_expression(visitor) <= ub + + def test_nested_with_var_that_skips_a_level(self): + m = ConcreteModel() + + m.x = Var(bounds=(-2, 9)) + m.y = Var(bounds=(-3, 8)) + + m.y1 = Disjunct() + m.y1.c1 = Constraint(expr=m.x >= 4) + m.y1.z1 = Disjunct() + m.y1.z1.c1 = Constraint(expr=m.y == 2) + m.y1.z1.w1 = Disjunct() + m.y1.z1.w1.c1 = Constraint(expr=m.x == 3) + m.y1.z1.w2 = Disjunct() + m.y1.z1.w2.c1 = Constraint(expr=m.x >= 1) + m.y1.z1.disjunction = Disjunction(expr=[m.y1.z1.w1, m.y1.z1.w2]) + m.y1.z2 = Disjunct() + m.y1.z2.c1 = Constraint(expr=m.y == 1) + m.y1.disjunction = Disjunction(expr=[m.y1.z1, m.y1.z2]) + m.y2 = Disjunct() + m.y2.c1 = Constraint(expr=m.x == 4) + m.disjunction = Disjunction(expr=[m.y1, m.y2]) + + hull = TransformationFactory('gdp.hull') + hull.apply_to(m) + + x_y1 = hull.get_disaggregated_var(m.x, m.y1) + x_y2 = hull.get_disaggregated_var(m.x, m.y2) + x_z1 = hull.get_disaggregated_var(m.x, m.y1.z1) + x_z2 = hull.get_disaggregated_var(m.x, m.y1.z2) + x_w1 = hull.get_disaggregated_var(m.x, m.y1.z1.w1) + x_w2 = hull.get_disaggregated_var(m.x, m.y1.z1.w2) + + y_z1 = hull.get_disaggregated_var(m.y, m.y1.z1) + y_z2 = hull.get_disaggregated_var(m.y, m.y1.z2) + y_y1 = hull.get_disaggregated_var(m.y, m.y1) + y_y2 = hull.get_disaggregated_var(m.y, m.y2) + + cons = hull.get_disaggregation_constraint(m.x, m.y1.z1.disjunction) + self.assertTrue(cons.active) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual(self, cons_expr, x_z1 - x_w1 - x_w2 == 0.0) + cons = hull.get_disaggregation_constraint(m.x, m.y1.disjunction) + self.assertTrue(cons.active) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual(self, cons_expr, x_y1 - x_z2 - x_z1 == 0.0) + cons = hull.get_disaggregation_constraint(m.x, m.disjunction) + self.assertTrue(cons.active) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual(self, cons_expr, m.x - x_y1 - x_y2 == 0.0) + cons = hull.get_disaggregation_constraint( + m.y, m.y1.z1.disjunction, raise_exception=False + ) + self.assertIsNone(cons) + cons = hull.get_disaggregation_constraint(m.y, m.y1.disjunction) + self.assertTrue(cons.active) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual(self, cons_expr, y_y1 - y_z1 - y_z2 == 0.0) + cons = hull.get_disaggregation_constraint(m.y, m.disjunction) + self.assertTrue(cons.active) + cons_expr = self.simplify_cons(cons) + assertExpressionsEqual(self, cons_expr, m.y - y_y2 - y_y1 == 0.0) + + @unittest.skipUnless(gurobi_available, "Gurobi is not available") + def test_do_not_assume_nested_indicators_local(self): + ct.check_do_not_assume_nested_indicators_local(self, 'gdp.hull') class TestSpecialCases(unittest.TestCase): @@ -2100,27 +2297,19 @@ def test_mapping_method_errors(self): hull = TransformationFactory('gdp.hull') hull.apply_to(m) - log = StringIO() - with LoggingIntercept(log, 'pyomo.gdp.hull', logging.ERROR): - self.assertRaisesRegex( - AttributeError, - "'NoneType' object has no attribute 'parent_block'", - hull.get_var_bounds_constraint, - m.w, - ) - self.assertRegex( - log.getvalue(), + with self.assertRaisesRegex( + GDP_Error, ".*Either 'w' is not a disaggregated variable, " "or the disjunction that disaggregates it has " "not been properly transformed.", - ) + ): + hull.get_var_bounds_constraint(m.w) log = StringIO() 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, @@ -2134,36 +2323,22 @@ def test_mapping_method_errors(self): r"Disjunction 'disjunction'", ) - log = StringIO() - with LoggingIntercept(log, 'pyomo.gdp.hull', logging.ERROR): - self.assertRaisesRegex( - AttributeError, - "'NoneType' object has no attribute 'parent_block'", - hull.get_src_var, - m.w, - ) - self.assertRegex( - log.getvalue(), ".*'w' does not appear to be a disaggregated variable" - ) + with self.assertRaisesRegex( + GDP_Error, ".*'w' does not appear to be a disaggregated variable" + ): + hull.get_src_var(m.w) - log = StringIO() - with LoggingIntercept(log, 'pyomo.gdp.hull', logging.ERROR): - self.assertRaisesRegex( - KeyError, - r".*_pyomo_gdp_hull_reformulation.relaxedDisjuncts\[1\]." - r"disaggregatedVars.w", - hull.get_disaggregated_var, - m.d[1].transformation_block.disaggregatedVars.w, - m.d[1], - ) - self.assertRegex( - log.getvalue(), + with self.assertRaisesRegex( + GDP_Error, r".*It does not appear " r"'_pyomo_gdp_hull_reformulation." r"relaxedDisjuncts\[1\].disaggregatedVars.w' " r"is a variable that appears in disjunct " r"'d\[1\]'", - ) + ): + hull.get_disaggregated_var( + m.d[1].transformation_block.disaggregatedVars.w, m.d[1] + ) m.random_disjunction = Disjunction(expr=[m.w == 2, m.w >= 7]) self.assertRaisesRegex( @@ -2398,12 +2573,12 @@ def OneCentroidPerPt(m, i): TransformationFactory('gdp.hull').apply_to(m) # fix an optimal solution - m.AssignPoint[1, 1].indicator_var.fix(1) - m.AssignPoint[1, 2].indicator_var.fix(0) - m.AssignPoint[2, 1].indicator_var.fix(0) - m.AssignPoint[2, 2].indicator_var.fix(1) - m.AssignPoint[3, 1].indicator_var.fix(1) - m.AssignPoint[3, 2].indicator_var.fix(0) + m.AssignPoint[1, 1].indicator_var.fix(True) + m.AssignPoint[1, 2].indicator_var.fix(False) + m.AssignPoint[2, 1].indicator_var.fix(False) + m.AssignPoint[2, 2].indicator_var.fix(True) + m.AssignPoint[3, 1].indicator_var.fix(True) + m.AssignPoint[3, 2].indicator_var.fix(False) m.cluster_center[1].fix(0.3059) m.cluster_center[2].fix(0.8043) @@ -2699,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 f067e1da5af..14a23160574 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-2022 +# 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 @@ -10,6 +10,7 @@ # ___________________________________________________________________________ from io import StringIO +import logging from os.path import join, normpath import pickle @@ -50,7 +51,25 @@ exdir = normpath(join(PYOMO_ROOT_DIR, 'examples', 'gdp')) -class LinearModelDecisionTreeExample(unittest.TestCase): +class CommonTests(unittest.TestCase): + def check_pretty_bound_constraints(self, cons, var, bounds, lb): + self.assertEqual(value(cons.upper), 0) + self.assertIsNone(cons.lower) + repn = generate_standard_repn(cons.body) + self.assertTrue(repn.is_linear()) + self.assertEqual(len(repn.linear_vars), len(bounds) + 1) + self.assertEqual(repn.constant, 0) + if lb: + check_linear_coef(self, repn, var, -1) + for disj, bnd in bounds.items(): + check_linear_coef(self, repn, disj.binary_indicator_var, bnd) + else: + check_linear_coef(self, repn, var, 1) + for disj, bnd in bounds.items(): + check_linear_coef(self, repn, disj.binary_indicator_var, -bnd) + + +class LinearModelDecisionTreeExample(CommonTests): def make_model(self): m = ConcreteModel() m.x1 = Var(bounds=(-10, 10)) @@ -333,6 +352,43 @@ def test_transformed_constraints_correct_Ms_specified(self): self.check_all_untightened_bounds_constraints(m, mbm) self.check_linear_func_constraints(m, mbm) + def test_local_var_suffix_ignored(self): + m = self.make_model() + m.y = Var(bounds=(2, 5)) + m.d1.another_thing = Constraint(expr=m.y == 3) + m.d1.LocalVars = Suffix(direction=Suffix.LOCAL) + m.d1.LocalVars[m.d1] = m.y + + mbigm = TransformationFactory('gdp.mbigm') + mbigm.apply_to( + m, reduce_bound_constraints=True, only_mbigm_bound_constraints=True + ) + + cons = mbigm.get_transformed_constraints(m.d1.x1_bounds) + self.check_pretty_bound_constraints( + cons[0], m.x1, {m.d1: 0.5, m.d2: 0.65, m.d3: 2}, lb=True + ) + self.check_pretty_bound_constraints( + cons[1], m.x1, {m.d1: 2, m.d2: 3, m.d3: 10}, lb=False + ) + + cons = mbigm.get_transformed_constraints(m.d1.x2_bounds) + self.check_pretty_bound_constraints( + cons[0], m.x2, {m.d1: 0.75, m.d2: 3, m.d3: 0.55}, lb=True + ) + self.check_pretty_bound_constraints( + cons[1], m.x2, {m.d1: 3, m.d2: 10, m.d3: 1}, lb=False + ) + + cons = mbigm.get_transformed_constraints(m.d1.another_thing) + self.assertEqual(len(cons), 2) + self.check_pretty_bound_constraints( + cons[0], m.y, {m.d1: 3, m.d2: 2, m.d3: 2}, lb=True + ) + self.check_pretty_bound_constraints( + cons[1], m.y, {m.d1: 3, m.d2: 5, m.d3: 5}, lb=False + ) + def test_pickle_transformed_model(self): m = self.make_model() TransformationFactory('gdp.mbigm').apply_to(m, bigM=self.get_Ms(m)) @@ -381,22 +437,6 @@ def test_algebraic_constraints(self): check_linear_coef(self, repn, m.d3.binary_indicator_var, 1) check_obj_in_active_tree(self, xor) - def check_pretty_bound_constraints(self, cons, var, bounds, lb): - self.assertEqual(value(cons.upper), 0) - self.assertIsNone(cons.lower) - repn = generate_standard_repn(cons.body) - self.assertTrue(repn.is_linear()) - self.assertEqual(len(repn.linear_vars), len(bounds) + 1) - self.assertEqual(repn.constant, 0) - if lb: - check_linear_coef(self, repn, var, -1) - for disj, bnd in bounds.items(): - check_linear_coef(self, repn, disj.binary_indicator_var, bnd) - else: - check_linear_coef(self, repn, var, 1) - for disj, bnd in bounds.items(): - check_linear_coef(self, repn, disj.binary_indicator_var, -bnd) - def test_bounds_constraints_correct(self): m = self.make_model() @@ -877,6 +917,25 @@ def test_declare_disjuncts_in_disjunction_rule(self): check_nested_disjuncts_in_flat_gdp(self, 'bigm') +class IndexedDisjunctiveConstraints(CommonTests): + def test_empty_constraint_container_on_Disjunct(self): + m = ConcreteModel() + m.d = Disjunct() + m.e = Disjunct() + m.d.c = Constraint(['s', 'i', 'l', 'L', 'y']) + m.x = Var(bounds=(2, 3)) + m.e.c = Constraint(expr=m.x == 2.7) + m.disjunction = Disjunction(expr=[m.d, m.e]) + + mbm = TransformationFactory('gdp.mbigm') + mbm.apply_to(m) + + cons = mbm.get_transformed_constraints(m.e.c) + self.assertEqual(len(cons), 2) + self.check_pretty_bound_constraints(cons[0], m.x, {m.d: 2, m.e: 2.7}, lb=True) + self.check_pretty_bound_constraints(cons[1], m.x, {m.d: 3, m.e: 2.7}, lb=False) + + @unittest.skipUnless(gurobi_available, "Gurobi is not available") class IndexedDisjunction(unittest.TestCase): def test_two_term_indexed_disjunction(self): @@ -930,3 +989,113 @@ def test_two_term_indexed_disjunction(self): self.assertEqual(len(cons_again), 2) self.assertIs(cons_again[0], cons[0]) self.assertIs(cons_again[1], cons[1]) + + +class EdgeCases(unittest.TestCase): + def make_infeasible_disjunct_model(self): + m = ConcreteModel() + m.x = Var(bounds=(1, 12)) + m.y = Var(bounds=(19, 22)) + m.disjunction = Disjunction( + expr=[ + [m.x >= 3 + m.y, m.y == 19.75], # infeasible given bounds + [m.y >= 21 + m.x], # unique solution + [m.x == m.y - 9], # x in interval [10, 12] + ] + ) + return m + + @unittest.skipUnless(gurobi_available, "Gurobi is not available") + def test_calculate_Ms_infeasible_Disjunct(self): + m = self.make_infeasible_disjunct_model() + out = StringIO() + mbm = TransformationFactory('gdp.mbigm') + with LoggingIntercept(out, 'pyomo.gdp.mbigm', logging.DEBUG): + mbm.apply_to(m, reduce_bound_constraints=False) + + # We mentioned the infeasibility at the DEBUG level + self.assertIn( + r"Disjunct 'disjunction_disjuncts[0]' is infeasible, deactivating", + out.getvalue().strip(), + ) + + # 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 <= 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) + assertExpressionsEqual( + self, + cons[0].expr, + -12.0 * m.disjunction_disjuncts[1].binary_indicator_var <= m.x - (m.y - 9), + ) + assertExpressionsEqual( + self, + cons[1].expr, + m.x - (m.y - 9) <= -12.0 * m.disjunction_disjuncts[1].binary_indicator_var, + ) + + @unittest.skipUnless( + SolverFactory('ipopt').available(exception_flag=False), "Ipopt is not available" + ) + def test_calculate_Ms_infeasible_Disjunct_local_solver(self): + m = self.make_infeasible_disjunct_model() + with self.assertRaisesRegex( + GDP_Error, + r"Unsuccessful solve to calculate M value to " + r"relax constraint 'disjunction_disjuncts\[1\].constraint\[1\]' " + r"on Disjunct 'disjunction_disjuncts\[1\]' when " + r"Disjunct 'disjunction_disjuncts\[0\]' is selected.", + ): + TransformationFactory('gdp.mbigm').apply_to( + m, solver=SolverFactory('ipopt'), reduce_bound_constraints=False + ) + + @unittest.skipUnless(gurobi_available, "Gurobi is not available") + def test_politely_ignore_BigM_Suffix(self): + m = self.make_infeasible_disjunct_model() + m.disjunction.disjuncts[0].deactivate() + m.disjunction.disjuncts[1].BigM = Suffix(direction=Suffix.LOCAL) + out = StringIO() + with LoggingIntercept(out, 'pyomo.gdp.mbigm', logging.DEBUG): + TransformationFactory('gdp.mbigm').apply_to( + m, reduce_bound_constraints=False + ) + warnings = out.getvalue() + self.assertIn( + r"Found active 'BigM' Suffix on 'disjunction_disjuncts[1]'. " + r"The multiple bigM transformation does not currently " + r"support specifying M's with Suffixes and is ignoring " + r"this Suffix.", + warnings, + ) + + @unittest.skipUnless(gurobi_available, "Gurobi is not available") + def test_complain_for_unrecognized_Suffix(self): + m = self.make_infeasible_disjunct_model() + m.disjunction.disjuncts[0].deactivate() + m.disjunction.disjuncts[1].HiThere = Suffix(direction=Suffix.LOCAL) + out = StringIO() + with self.assertRaisesRegex( + GDP_Error, + r"Found active Suffix 'disjunction_disjuncts\[1\].HiThere' " + r"on Disjunct 'disjunction_disjuncts\[1\]'. The multiple bigM " + r"transformation does not support this Suffix.", + ): + TransformationFactory('gdp.mbigm').apply_to( + m, reduce_bound_constraints=False + ) diff --git a/pyomo/gdp/tests/test_partition_disjuncts.py b/pyomo/gdp/tests/test_partition_disjuncts.py index b050bc5e653..dc5ae9f70ce 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-2022 +# 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 diff --git a/pyomo/gdp/tests/test_reclassify.py b/pyomo/gdp/tests/test_reclassify.py index fd98f8f0954..223c28c5c7a 100644 --- a/pyomo/gdp/tests/test_reclassify.py +++ b/pyomo/gdp/tests/test_reclassify.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: UTF-8 -*- """Tests disjunct reclassifier transformation.""" import pyomo.common.unittest as unittest diff --git a/pyomo/gdp/tests/test_transform_current_disjunctive_state.py b/pyomo/gdp/tests/test_transform_current_disjunctive_state.py index d257c3db8fb..54d80c910e5 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-2022 +# 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 diff --git a/pyomo/gdp/tests/test_util.py b/pyomo/gdp/tests/test_util.py index 90c63717b81..fa8e953f9f7 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-2022 +# 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 @@ -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 400f77a31f6..287d5ed1652 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-2022 +# 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 @@ -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 b460a3d691c..2fe8e9e1dee 100644 --- a/pyomo/gdp/util.py +++ b/pyomo/gdp/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 @@ -169,7 +168,10 @@ def parent_disjunct(self, u): Arg: u : A node in the forest """ - return self.parent(self.parent(u)) + if u.ctype is Disjunct: + return self.parent(self.parent(u)) + else: + return self.parent(u) def root_disjunct(self, u): """Returns the highest parent Disjunct in the hierarchy, or None if @@ -183,7 +185,7 @@ def root_disjunct(self, u): while True: if parent is None: return rootmost_disjunct - if isinstance(parent, _DisjunctData) or parent.ctype is Disjunct: + if parent.ctype is Disjunct: rootmost_disjunct = parent parent = self.parent(parent) @@ -243,7 +245,7 @@ def leaves(self): @property def disjunct_nodes(self): for v in self._vertices: - if isinstance(v, _DisjunctData) or v.ctype is Disjunct: + if v.ctype is Disjunct: yield v @@ -327,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: @@ -384,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: @@ -449,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 ( @@ -474,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 " @@ -521,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 6ecea6343cd..5618767a714 100644 --- a/pyomo/kernel/__init__.py +++ b/pyomo/kernel/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 5fba6a2c2d9..bdfd0939537 100644 --- a/pyomo/kernel/util.py +++ b/pyomo/kernel/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/mpec/__init__.py b/pyomo/mpec/__init__.py index 3989fe07b8e..a98ab94dc87 100644 --- a/pyomo/mpec/__init__.py +++ b/pyomo/mpec/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/mpec/complementarity.py b/pyomo/mpec/complementarity.py index df991ce9686..26968ef9fca 100644 --- a/pyomo/mpec/complementarity.py +++ b/pyomo/mpec/complementarity.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 @@ -357,13 +362,18 @@ def construct(self, data=None): """ Construct the expression(s) for this complementarity condition. """ - if is_debug_set(logger): - logger.debug("Constructing complementarity list %s", self.name) if self._constructed: return - timer = ConstructionTimer(self) self._constructed = True + timer = ConstructionTimer(self) + if is_debug_set(logger): + logger.debug("Constructing complementarity list %s", self.name) + + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + if self._init_rule is not None: _init = self._init_rule(self.parent_block(), ()) for cc in iter(_init): diff --git a/pyomo/mpec/plugins/__init__.py b/pyomo/mpec/plugins/__init__.py index 3317e1ce829..8557676e60c 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-2022 +# 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 @@ -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 ad6905158c7..5935569d370 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-2022 +# 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 diff --git a/pyomo/mpec/plugins/mpec2.py b/pyomo/mpec/plugins/mpec2.py index d019424ea4b..89d6c0814b2 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-2022 +# 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 diff --git a/pyomo/mpec/plugins/mpec3.py b/pyomo/mpec/plugins/mpec3.py index d681c305a2d..1b7eb58b021 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-2022 +# 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 diff --git a/pyomo/mpec/plugins/mpec4.py b/pyomo/mpec/plugins/mpec4.py index 5b32886711a..fa3e37b16fe 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-2022 +# 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 diff --git a/pyomo/mpec/plugins/pathampl.py b/pyomo/mpec/plugins/pathampl.py index 7875251c04b..23b1b393ef3 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-2022 +# 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 diff --git a/pyomo/mpec/plugins/solver1.py b/pyomo/mpec/plugins/solver1.py index 0ac1af85522..02659844f1c 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-2022 +# 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 diff --git a/pyomo/mpec/plugins/solver2.py b/pyomo/mpec/plugins/solver2.py index 491c8122d2e..5f5b6922e6f 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-2022 +# 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 diff --git a/pyomo/mpec/tests/__init__.py b/pyomo/mpec/tests/__init__.py index c5e495e5aa3..a2a2c61779a 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-2022 +# 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 diff --git a/pyomo/mpec/tests/cov2_None.txt b/pyomo/mpec/tests/cov2_None.txt index 2f7d59572a8..c3c0baeeb9e 100644 --- a/pyomo/mpec/tests/cov2_None.txt +++ b/pyomo/mpec/tests/cov2_None.txt @@ -1,2 +1,2 @@ -cc : Size=0, Index=cc_index, Active=True +cc : Size=0, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active diff --git a/pyomo/mpec/tests/cov2_mpec.nl.txt b/pyomo/mpec/tests/cov2_mpec.nl.txt index a526784344b..9b7b9ed53f4 100644 --- a/pyomo/mpec/tests/cov2_mpec.nl.txt +++ b/pyomo/mpec/tests/cov2_mpec.nl.txt @@ -1,8 +1,3 @@ -1 Set Declarations - cc_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {0, 1, 2} - 4 Var Declarations x1 : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain @@ -23,7 +18,7 @@ None : 0.5 : x1 : 0.5 : True 1 Block Declarations - cc : Size=0, Index=cc_index, Active=True + cc : Size=0, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active -7 Declarations: y x1 x2 x3 cc_index cc keep_var_con +6 Declarations: y x1 x2 x3 cc keep_var_con diff --git a/pyomo/mpec/tests/cov2_mpec.simple_disjunction.txt b/pyomo/mpec/tests/cov2_mpec.simple_disjunction.txt index 2f7d59572a8..c3c0baeeb9e 100644 --- a/pyomo/mpec/tests/cov2_mpec.simple_disjunction.txt +++ b/pyomo/mpec/tests/cov2_mpec.simple_disjunction.txt @@ -1,2 +1,2 @@ -cc : Size=0, Index=cc_index, Active=True +cc : Size=0, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active diff --git a/pyomo/mpec/tests/cov2_mpec.simple_nonlinear.txt b/pyomo/mpec/tests/cov2_mpec.simple_nonlinear.txt index 2f7d59572a8..c3c0baeeb9e 100644 --- a/pyomo/mpec/tests/cov2_mpec.simple_nonlinear.txt +++ b/pyomo/mpec/tests/cov2_mpec.simple_nonlinear.txt @@ -1,2 +1,2 @@ -cc : Size=0, Index=cc_index, Active=True +cc : Size=0, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active diff --git a/pyomo/mpec/tests/cov2_mpec.standard_form.txt b/pyomo/mpec/tests/cov2_mpec.standard_form.txt index 2f7d59572a8..c3c0baeeb9e 100644 --- a/pyomo/mpec/tests/cov2_mpec.standard_form.txt +++ b/pyomo/mpec/tests/cov2_mpec.standard_form.txt @@ -1,2 +1,2 @@ -cc : Size=0, Index=cc_index, Active=True +cc : Size=0, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active diff --git a/pyomo/mpec/tests/list1_None.txt b/pyomo/mpec/tests/list1_None.txt index 8e849242bcd..34c358a1521 100644 --- a/pyomo/mpec/tests/list1_None.txt +++ b/pyomo/mpec/tests/list1_None.txt @@ -1,4 +1,4 @@ -cc : Size=2, Index=cc_index, Active=True +cc : Size=2, Index={1, 2}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True diff --git a/pyomo/mpec/tests/list1_mpec.nl.txt b/pyomo/mpec/tests/list1_mpec.nl.txt index 16310c59317..62edc488b47 100644 --- a/pyomo/mpec/tests/list1_mpec.nl.txt +++ b/pyomo/mpec/tests/list1_mpec.nl.txt @@ -1,8 +1,3 @@ -1 Set Declarations - cc_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 2 : {1, 2} - 4 Var Declarations x1 : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain @@ -18,7 +13,7 @@ None : None : None : None : False : True : Reals 1 Block Declarations - cc : Size=2, Index=cc_index, Active=True + cc : Size=2, Index={1, 2}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True @@ -37,4 +32,4 @@ 1 Declarations: c -6 Declarations: y x1 x2 x3 cc_index cc +5 Declarations: y x1 x2 x3 cc diff --git a/pyomo/mpec/tests/list1_mpec.simple_disjunction.txt b/pyomo/mpec/tests/list1_mpec.simple_disjunction.txt index 816e56af56c..c2bfe5e0399 100644 --- a/pyomo/mpec/tests/list1_mpec.simple_disjunction.txt +++ b/pyomo/mpec/tests/list1_mpec.simple_disjunction.txt @@ -1,4 +1,4 @@ -cc : Size=2, Index=cc_index, Active=True +cc : Size=2, Index={1, 2}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True diff --git a/pyomo/mpec/tests/list1_mpec.simple_nonlinear.txt b/pyomo/mpec/tests/list1_mpec.simple_nonlinear.txt index 816e56af56c..c2bfe5e0399 100644 --- a/pyomo/mpec/tests/list1_mpec.simple_nonlinear.txt +++ b/pyomo/mpec/tests/list1_mpec.simple_nonlinear.txt @@ -1,4 +1,4 @@ -cc : Size=2, Index=cc_index, Active=True +cc : Size=2, Index={1, 2}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True diff --git a/pyomo/mpec/tests/list1_mpec.standard_form.txt b/pyomo/mpec/tests/list1_mpec.standard_form.txt index 816e56af56c..c2bfe5e0399 100644 --- a/pyomo/mpec/tests/list1_mpec.standard_form.txt +++ b/pyomo/mpec/tests/list1_mpec.standard_form.txt @@ -1,4 +1,4 @@ -cc : Size=2, Index=cc_index, Active=True +cc : Size=2, Index={1, 2}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True diff --git a/pyomo/mpec/tests/list2_None.txt b/pyomo/mpec/tests/list2_None.txt index cc84321fe3e..465bc347766 100644 --- a/pyomo/mpec/tests/list2_None.txt +++ b/pyomo/mpec/tests/list2_None.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : False diff --git a/pyomo/mpec/tests/list2_mpec.nl.txt b/pyomo/mpec/tests/list2_mpec.nl.txt index c8c461e08e8..6dc49cef8dd 100644 --- a/pyomo/mpec/tests/list2_mpec.nl.txt +++ b/pyomo/mpec/tests/list2_mpec.nl.txt @@ -1,8 +1,3 @@ -1 Set Declarations - cc_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - 4 Var Declarations x1 : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain @@ -18,7 +13,7 @@ None : None : None : None : False : True : Reals 1 Block Declarations - cc : Size=3, Index=cc_index, Active=True + cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : False @@ -40,4 +35,4 @@ 1 Declarations: c -6 Declarations: y x1 x2 x3 cc_index cc +5 Declarations: y x1 x2 x3 cc diff --git a/pyomo/mpec/tests/list2_mpec.simple_disjunction.txt b/pyomo/mpec/tests/list2_mpec.simple_disjunction.txt index 82688e8f017..c71d6461d22 100644 --- a/pyomo/mpec/tests/list2_mpec.simple_disjunction.txt +++ b/pyomo/mpec/tests/list2_mpec.simple_disjunction.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : False diff --git a/pyomo/mpec/tests/list2_mpec.simple_nonlinear.txt b/pyomo/mpec/tests/list2_mpec.simple_nonlinear.txt index 82688e8f017..c71d6461d22 100644 --- a/pyomo/mpec/tests/list2_mpec.simple_nonlinear.txt +++ b/pyomo/mpec/tests/list2_mpec.simple_nonlinear.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : False diff --git a/pyomo/mpec/tests/list2_mpec.standard_form.txt b/pyomo/mpec/tests/list2_mpec.standard_form.txt index 82688e8f017..c71d6461d22 100644 --- a/pyomo/mpec/tests/list2_mpec.standard_form.txt +++ b/pyomo/mpec/tests/list2_mpec.standard_form.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : False diff --git a/pyomo/mpec/tests/list5_None.txt b/pyomo/mpec/tests/list5_None.txt index 8e6ed9a8164..962ee6cbc3a 100644 --- a/pyomo/mpec/tests/list5_None.txt +++ b/pyomo/mpec/tests/list5_None.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : True diff --git a/pyomo/mpec/tests/list5_mpec.nl.txt b/pyomo/mpec/tests/list5_mpec.nl.txt index adb64af0457..93ee89f3389 100644 --- a/pyomo/mpec/tests/list5_mpec.nl.txt +++ b/pyomo/mpec/tests/list5_mpec.nl.txt @@ -1,8 +1,3 @@ -1 Set Declarations - cc_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {1, 2, 3} - 4 Var Declarations x1 : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain @@ -18,7 +13,7 @@ None : None : None : None : False : True : Reals 1 Block Declarations - cc : Size=3, Index=cc_index, Active=True + cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : True @@ -45,4 +40,4 @@ 1 Declarations: c -6 Declarations: y x1 x2 x3 cc_index cc +5 Declarations: y x1 x2 x3 cc diff --git a/pyomo/mpec/tests/list5_mpec.simple_disjunction.txt b/pyomo/mpec/tests/list5_mpec.simple_disjunction.txt index 69178523d96..15622fa84e1 100644 --- a/pyomo/mpec/tests/list5_mpec.simple_disjunction.txt +++ b/pyomo/mpec/tests/list5_mpec.simple_disjunction.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : True diff --git a/pyomo/mpec/tests/list5_mpec.simple_nonlinear.txt b/pyomo/mpec/tests/list5_mpec.simple_nonlinear.txt index 69178523d96..15622fa84e1 100644 --- a/pyomo/mpec/tests/list5_mpec.simple_nonlinear.txt +++ b/pyomo/mpec/tests/list5_mpec.simple_nonlinear.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : True diff --git a/pyomo/mpec/tests/list5_mpec.standard_form.txt b/pyomo/mpec/tests/list5_mpec.standard_form.txt index 69178523d96..15622fa84e1 100644 --- a/pyomo/mpec/tests/list5_mpec.standard_form.txt +++ b/pyomo/mpec/tests/list5_mpec.standard_form.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={1, 2, 3}, Active=True Key : Arg0 : Arg1 : Active 1 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 1 : True diff --git a/pyomo/mpec/tests/t10_None.txt b/pyomo/mpec/tests/t10_None.txt index afc38166ab3..7d6b4c429cc 100644 --- a/pyomo/mpec/tests/t10_None.txt +++ b/pyomo/mpec/tests/t10_None.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 1 : y + x3 : x1 + 2*x2 == 1 : False diff --git a/pyomo/mpec/tests/t10_mpec.nl.txt b/pyomo/mpec/tests/t10_mpec.nl.txt index a4a16713eaa..12db893ddba 100644 --- a/pyomo/mpec/tests/t10_mpec.nl.txt +++ b/pyomo/mpec/tests/t10_mpec.nl.txt @@ -1,8 +1,3 @@ -1 Set Declarations - cc_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {0, 1, 2} - 4 Var Declarations x1 : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain @@ -18,7 +13,7 @@ None : None : None : None : False : True : Reals 1 Block Declarations - cc : Size=3, Index=cc_index, Active=True + cc : Size=3, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 1 : y + x3 : x1 + 2*x2 == 1 : False @@ -40,4 +35,4 @@ 1 Declarations: c -6 Declarations: y x1 x2 x3 cc_index cc +5 Declarations: y x1 x2 x3 cc diff --git a/pyomo/mpec/tests/t10_mpec.simple_disjunction.txt b/pyomo/mpec/tests/t10_mpec.simple_disjunction.txt index c53c1b8e62b..37aaaafcf68 100644 --- a/pyomo/mpec/tests/t10_mpec.simple_disjunction.txt +++ b/pyomo/mpec/tests/t10_mpec.simple_disjunction.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 1 : y + x3 : x1 + 2*x2 == 1 : False diff --git a/pyomo/mpec/tests/t10_mpec.simple_nonlinear.txt b/pyomo/mpec/tests/t10_mpec.simple_nonlinear.txt index c53c1b8e62b..37aaaafcf68 100644 --- a/pyomo/mpec/tests/t10_mpec.simple_nonlinear.txt +++ b/pyomo/mpec/tests/t10_mpec.simple_nonlinear.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 1 : y + x3 : x1 + 2*x2 == 1 : False diff --git a/pyomo/mpec/tests/t10_mpec.standard_form.txt b/pyomo/mpec/tests/t10_mpec.standard_form.txt index c53c1b8e62b..37aaaafcf68 100644 --- a/pyomo/mpec/tests/t10_mpec.standard_form.txt +++ b/pyomo/mpec/tests/t10_mpec.standard_form.txt @@ -1,4 +1,4 @@ -cc : Size=3, Index=cc_index, Active=True +cc : Size=3, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 1 : y + x3 : x1 + 2*x2 == 1 : False diff --git a/pyomo/mpec/tests/t13_None.txt b/pyomo/mpec/tests/t13_None.txt index b2e24eb1166..fde3cc15a18 100644 --- a/pyomo/mpec/tests/t13_None.txt +++ b/pyomo/mpec/tests/t13_None.txt @@ -1,4 +1,4 @@ -cc : Size=2, Index=cc_index, Active=True +cc : Size=2, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True diff --git a/pyomo/mpec/tests/t13_mpec.nl.txt b/pyomo/mpec/tests/t13_mpec.nl.txt index dc47767efb7..9e709e35b6f 100644 --- a/pyomo/mpec/tests/t13_mpec.nl.txt +++ b/pyomo/mpec/tests/t13_mpec.nl.txt @@ -1,8 +1,3 @@ -1 Set Declarations - cc_index : Size=1, Index=None, Ordered=Insertion - Key : Dimen : Domain : Size : Members - None : 1 : Any : 3 : {0, 1, 2} - 4 Var Declarations x1 : Size=1, Index=None Key : Lower : Value : Upper : Fixed : Stale : Domain @@ -18,7 +13,7 @@ None : None : None : None : False : True : Reals 1 Block Declarations - cc : Size=2, Index=cc_index, Active=True + cc : Size=2, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True @@ -37,4 +32,4 @@ 1 Declarations: c -6 Declarations: y x1 x2 x3 cc_index cc +5 Declarations: y x1 x2 x3 cc diff --git a/pyomo/mpec/tests/t13_mpec.simple_disjunction.txt b/pyomo/mpec/tests/t13_mpec.simple_disjunction.txt index 1ff09babad8..9b361c7e503 100644 --- a/pyomo/mpec/tests/t13_mpec.simple_disjunction.txt +++ b/pyomo/mpec/tests/t13_mpec.simple_disjunction.txt @@ -1,4 +1,4 @@ -cc : Size=2, Index=cc_index, Active=True +cc : Size=2, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True diff --git a/pyomo/mpec/tests/t13_mpec.simple_nonlinear.txt b/pyomo/mpec/tests/t13_mpec.simple_nonlinear.txt index 1ff09babad8..9b361c7e503 100644 --- a/pyomo/mpec/tests/t13_mpec.simple_nonlinear.txt +++ b/pyomo/mpec/tests/t13_mpec.simple_nonlinear.txt @@ -1,4 +1,4 @@ -cc : Size=2, Index=cc_index, Active=True +cc : Size=2, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True diff --git a/pyomo/mpec/tests/t13_mpec.standard_form.txt b/pyomo/mpec/tests/t13_mpec.standard_form.txt index 1ff09babad8..9b361c7e503 100644 --- a/pyomo/mpec/tests/t13_mpec.standard_form.txt +++ b/pyomo/mpec/tests/t13_mpec.standard_form.txt @@ -1,4 +1,4 @@ -cc : Size=2, Index=cc_index, Active=True +cc : Size=2, Index={0, 1, 2}, Active=True Key : Arg0 : Arg1 : Active 0 : y + x3 : x1 + 2*x2 == 0 : True 2 : y + x3 : x1 + 2*x2 == 2 : True diff --git a/pyomo/mpec/tests/test_complementarity.py b/pyomo/mpec/tests/test_complementarity.py index 1eb0385c3e5..545104364cf 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-2022 +# 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 diff --git a/pyomo/mpec/tests/test_minlp.py b/pyomo/mpec/tests/test_minlp.py index 367a57b817e..965906f4235 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-2022 +# 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 diff --git a/pyomo/mpec/tests/test_nlp.py b/pyomo/mpec/tests/test_nlp.py index be5234136a1..a87d4ad2b09 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-2022 +# 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 diff --git a/pyomo/mpec/tests/test_path.py b/pyomo/mpec/tests/test_path.py index 5dd7178acf5..0501d19d2ac 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-2022 +# 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 diff --git a/pyomo/neos/__init__.py b/pyomo/neos/__init__.py index 73ac0c51216..9f910f4a302 100644 --- a/pyomo/neos/__init__.py +++ b/pyomo/neos/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -30,7 +30,7 @@ 'minos': 'SLC 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', diff --git a/pyomo/neos/kestrel.py b/pyomo/neos/kestrel.py index 44734294eb4..c917a6fe7d1 100644 --- a/pyomo/neos/kestrel.py +++ b/pyomo/neos/kestrel.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 85fad42d4b2..07e0f2e0265 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-2022 +# 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 @@ -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 323f96e9bdc..76428b40bec 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-2022 +# 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 @@ -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 49fb3809622..fecb98e0084 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-2022 +# 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 diff --git a/pyomo/neos/tests/__init__.py b/pyomo/neos/tests/__init__.py index 1cf642c0eac..83603e3d8ba 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-2022 +# 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 diff --git a/pyomo/neos/tests/model_min_lp.py b/pyomo/neos/tests/model_min_lp.py index 56e1b124cd4..eacf0451c94 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-2022 +# 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 diff --git a/pyomo/neos/tests/test_neos.py b/pyomo/neos/tests/test_neos.py index c43869e65cc..f55afb10439 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-2022 +# 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 @@ -98,6 +98,14 @@ 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_bonmin(self): @@ -149,8 +157,12 @@ 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 + # + # def test_octeract(self): + # self._run('octeract') def test_ooqp(self): if self.sense == pyo.maximize: @@ -161,10 +173,10 @@ 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 097471102be..6ccfb64f79c 100644 --- a/pyomo/network/__init__.py +++ b/pyomo/network/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/network/arc.py b/pyomo/network/arc.py index ff1874b0274..f2597b4c1bd 100644 --- a/pyomo/network/arc.py +++ b/pyomo/network/arc.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['Arc'] - from pyomo.network.port import Port from pyomo.core.base.component import ActiveComponentData, ModelComponentFactory from pyomo.core.base.indexed_component import ( @@ -54,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 @@ -248,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): """ @@ -269,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: @@ -296,14 +299,18 @@ def __init__(self, *args, **kwds): def construct(self, data=None): """Initialize the Arc""" - if is_debug_set(logger): - logger.debug("Constructing Arc %s" % self.name) - if self._constructed: return + self._constructed = True + + if is_debug_set(logger): + logger.debug("Constructing Arc %s" % self.name) timer = ConstructionTimer(self) - self._constructed = True + + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() if self._rule is None and self._init_vals is None: # No construction rule or values specified @@ -371,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 ae306766ae0..1ffb6a710ff 100644 --- a/pyomo/network/decomposition.py +++ b/pyomo/network/decomposition.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['SequentialDecomposition'] - from pyomo.network import Port, Arc from pyomo.network.foqus_graph import FOQUSGraph from pyomo.core import ( diff --git a/pyomo/network/foqus_graph.py b/pyomo/network/foqus_graph.py index e6fc34aaf62..7c6c05256d9 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-2022 +# 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 @@ -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 diff --git a/pyomo/network/plugins/__init__.py b/pyomo/network/plugins/__init__.py index 5e9677d2bc4..387c0639c3f 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-2022 +# 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 @@ -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 4f6185d3173..b1f915214eb 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-2022 +# 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 diff --git a/pyomo/network/port.py b/pyomo/network/port.py index 4afb0e23ed0..f6706dce644 100644 --- a/pyomo/network/port.py +++ b/pyomo/network/port.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['Port'] - import logging, sys from weakref import ref as weakref_ref @@ -38,7 +36,7 @@ logger = logging.getLogger('pyomo.network') -class _PortData(ComponentData): +class PortData(ComponentData): """ This class defines the data for a single Port @@ -287,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." ) @@ -341,21 +344,25 @@ 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 def construct(self, data=None): - if is_debug_set(logger): # pragma:nocover - logger.debug("Constructing Port, name=%s, from data=%s" % (self.name, data)) - if self._constructed: return + self._constructed = True timer = ConstructionTimer(self) - self._constructed = True - # Construct _PortData objects for all index values + if is_debug_set(logger): # pragma:nocover + logger.debug("Constructing Port, name=%s, from data=%s" % (self.name, data)) + + if self._anonymous_sets is not None: + for _set in self._anonymous_sets: + _set.construct() + + # Construct PortData objects for all index values if self.is_indexed(): self._initialize_members(self._index_set) else: @@ -761,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 1eb6d95e148..173fdc4e727 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-2022 +# 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 diff --git a/pyomo/network/tests/test_arc.py b/pyomo/network/tests/test_arc.py index cd340cace7a..8356bcce9d8 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-2022 +# 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 @@ -504,11 +504,11 @@ def test_expand_indexed(self): os.getvalue(), """c_expanded : Size=1, Index=None, Active=True 3 Constraint Declarations - a_equality : Size=2, Index=x_index, Active=True + a_equality : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x[1] - t[1] : 0.0 : True 2 : 0.0 : x[2] - t[2] : 0.0 : True - b_equality : Size=4, Index=y_index, Active=True + b_equality : Size=4, Index={1, 2}*{1, 2}, Active=True Key : Lower : Body : Upper : Active (1, 1) : 0.0 : y[1,1] - u[1,1] : 0.0 : True (1, 2) : 0.0 : y[1,2] - u[1,2] : 0.0 : True @@ -677,7 +677,7 @@ def test_expand_empty_indexed(self): os.getvalue(), """c_expanded : Size=1, Index=None, Active=True 2 Constraint Declarations - x_equality : Size=2, Index=x_index, Active=True + x_equality : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x[1] - EPRT_auto_x[1] : 0.0 : True 2 : 0.0 : x[2] - EPRT_auto_x[2] : 0.0 : True @@ -739,7 +739,7 @@ def test_expand_multiple_empty_indexed(self): os.getvalue(), """c_expanded : Size=1, Index=None, Active=True 2 Constraint Declarations - x_equality : Size=2, Index=x_index, Active=True + x_equality : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x[1] - EPRT1_auto_x[1] : 0.0 : True 2 : 0.0 : x[2] - EPRT1_auto_x[2] : 0.0 : True @@ -757,7 +757,7 @@ def test_expand_multiple_empty_indexed(self): os.getvalue(), """d_expanded : Size=1, Index=None, Active=True 2 Constraint Declarations - x_equality : Size=2, Index=x_index, Active=True + x_equality : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : EPRT2_auto_x[1] - EPRT1_auto_x[1] : 0.0 : True 2 : 0.0 : EPRT2_auto_x[2] - EPRT1_auto_x[2] : 0.0 : True @@ -812,7 +812,7 @@ def test_expand_multiple_indexed(self): os.getvalue(), """c_expanded : Size=1, Index=None, Active=True 2 Constraint Declarations - x_equality : Size=2, Index=x_index, Active=True + x_equality : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : x[1] - a1[1] : 0.0 : True 2 : 0.0 : x[2] - a1[2] : 0.0 : True @@ -830,7 +830,7 @@ def test_expand_multiple_indexed(self): os.getvalue(), """d_expanded : Size=1, Index=None, Active=True 2 Constraint Declarations - x_equality : Size=2, Index=x_index, Active=True + x_equality : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : a2[1] - a1[1] : 0.0 : True 2 : 0.0 : a2[2] - a1[2] : 0.0 : True @@ -903,7 +903,7 @@ def test_expand_implicit_indexed(self): os.getvalue(), """c_expanded : Size=1, Index=None, Active=True 2 Constraint Declarations - x_equality : Size=2, Index=a2_index, Active=True + x_equality : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : a2[1] - x[1] : 0.0 : True 2 : 0.0 : a2[2] - x[2] : 0.0 : True @@ -921,7 +921,7 @@ def test_expand_implicit_indexed(self): os.getvalue(), """d_expanded : Size=1, Index=None, Active=True 2 Constraint Declarations - x_equality : Size=2, Index=a2_index, Active=True + x_equality : Size=2, Index={1, 2}, Active=True Key : Lower : Body : Upper : Active 1 : 0.0 : EPRT2_auto_x[1] - x[1] : 0.0 : True 2 : 0.0 : EPRT2_auto_x[2] - x[2] : 0.0 : True @@ -964,7 +964,7 @@ def rule(m, i): m.component('eq_expanded').pprint(ostream=os) self.assertEqual( os.getvalue(), - """eq_expanded : Size=2, Index=eq_index, Active=True + """eq_expanded : Size=2, Index={1, 2}, Active=True eq_expanded[1] : Active=True 1 Constraint Declarations v_equality : Size=1, Index=None, Active=True diff --git a/pyomo/network/tests/test_decomposition.py b/pyomo/network/tests/test_decomposition.py index 4e4d0231d00..2db310217d0 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-2022 +# 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 diff --git a/pyomo/network/tests/test_port.py b/pyomo/network/tests/test_port.py index bc9a6fc527f..a417a832015 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-2022 +# 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 diff --git a/pyomo/network/util.py b/pyomo/network/util.py index be0fa2c84d1..4865218aca8 100644 --- a/pyomo/network/util.py +++ b/pyomo/network/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/opt/__init__.py b/pyomo/opt/__init__.py index 8c12d3fa201..77daa46db22 100644 --- a/pyomo/opt/__init__.py +++ b/pyomo/opt/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 9d29efc859d..0c85042ec8a 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-2022 +# 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 @@ -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 8d8bd78e2ee..28ad6727d3e 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['convert_problem'] - import copy import os diff --git a/pyomo/opt/base/error.py b/pyomo/opt/base/error.py index aa97469f6d0..b03fafd7037 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-2022 +# 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 diff --git a/pyomo/opt/base/formats.py b/pyomo/opt/base/formats.py index 2acd77b80e4..6e9d3958f48 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-2022 +# 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 @@ -9,11 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -# -# The formats that are supported by Pyomo -# -__all__ = ['ProblemFormat', 'ResultsFormat', 'guess_format'] - import enum diff --git a/pyomo/opt/base/opt_config.py b/pyomo/opt/base/opt_config.py index d93cfd77b3c..a4a626013c4 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-2022 +# 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 diff --git a/pyomo/opt/base/problem.py b/pyomo/opt/base/problem.py index 6be1d4d6db6..804a97e2e4c 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ["AbstractProblemWriter", "WriterFactory", "BranchDirection"] - from pyomo.common import Factory diff --git a/pyomo/opt/base/results.py b/pyomo/opt/base/results.py index 68999fae6e4..ea295a66315 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['AbstractResultsReader', 'ReaderFactory'] - from pyomo.common import Factory diff --git a/pyomo/opt/base/solvers.py b/pyomo/opt/base/solvers.py index b11e6393b02..4ffef7e7cac 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ('OptSolver', 'SolverFactory', 'UnknownSolver', 'check_available_solvers') - import re import sys import time @@ -18,12 +16,11 @@ import shlex from pyomo.common import Factory -from pyomo.common.config import ConfigDict from pyomo.common.errors import ApplicationError from pyomo.common.collections import Bunch from pyomo.opt.base.convert import convert_problem -from pyomo.opt.base.formats import ResultsFormat, ProblemFormat +from pyomo.opt.base.formats import ResultsFormat import pyomo.opt.base.results logger = logging.getLogger('pyomo.opt') @@ -181,7 +178,11 @@ def __call__(self, _name=None, **kwds): return opt +LegacySolverFactory = SolverFactoryClass('solver type') + SolverFactory = SolverFactoryClass('solver type') +SolverFactory._cls = LegacySolverFactory._cls +SolverFactory._doc = LegacySolverFactory._doc # @@ -469,8 +470,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: @@ -535,15 +536,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 " @@ -552,7 +553,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 ( diff --git a/pyomo/opt/parallel/__init__.py b/pyomo/opt/parallel/__init__.py index 9820f39afd4..daa0d2461ec 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-2022 +# 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 @@ -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 e9806b7125a..74e222e2241 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-2022 +# 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 @@ -9,9 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -__all__ = ['AsynchronousSolverManager', 'SolverManagerFactory'] - from pyomo.common import Factory from pyomo.opt.parallel.manager import AsynchronousActionManager diff --git a/pyomo/opt/parallel/local.py b/pyomo/opt/parallel/local.py index a7a80a7d33c..e130ea0407f 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-2022 +# 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 @@ -9,9 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -__all__ = () - import time from pyomo.common.collections import OrderedDict diff --git a/pyomo/opt/parallel/manager.py b/pyomo/opt/parallel/manager.py index a97f6ae1d27..203c348e119 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-2022 +# 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 @@ -9,16 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -__all__ = [ - 'ActionManagerError', - 'ActionHandle', - 'AsynchronousActionManager', - 'ActionStatus', - 'FailedActionHandle', - 'solve_all_instances', -] - import enum diff --git a/pyomo/opt/plugins/__init__.py b/pyomo/opt/plugins/__init__.py index 797147f5f69..30331d2938f 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-2022 +# 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 @@ -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 23757053beb..beaf5268fe9 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-2022 +# 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 @@ -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 25d25d5feb0..1f2fed261f6 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-2022 +# 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 @@ -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 297b1c87d06..efcb36877bd 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-2022 +# 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 @@ -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 1b1a5328beb..8199553247d 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-2022 +# 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 diff --git a/pyomo/opt/problem/ampl.py b/pyomo/opt/problem/ampl.py index 625c342f005..ed107cace60 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-2022 +# 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 @@ -14,8 +14,6 @@ can be optimized with the Acro COLIN optimizers. """ -__all__ = ['AmplModel'] - import os from pyomo.opt.base import ProblemFormat, convert_problem, guess_format diff --git a/pyomo/opt/results/__init__.py b/pyomo/opt/results/__init__.py index 8b2933adfe0..fb52575f134 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-2022 +# 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 @@ -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 98a68048b45..4bbaf44edf7 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-2022 +# 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 @@ -9,24 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = [ - 'UndefinedData', - 'undefined', - 'ignore', - 'ScalarData', - 'ListContainer', - 'MapContainer', - 'default_print_options', - 'ScalarType', -] - import copy - -from math import inf -from pyomo.common.collections import Bunch - import enum from io import StringIO +from math import inf + +from pyomo.common.collections import Bunch class ScalarType(str, enum.Enum): diff --git a/pyomo/opt/results/problem.py b/pyomo/opt/results/problem.py index 71fd748dd81..055c4bee132 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-2022 +# 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 @@ -9,24 +9,17 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['ProblemInformation', 'ProblemSense'] - -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 2852bb72e8a..0a045550517 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['SolverResults'] - import math import sys import copy @@ -18,7 +16,7 @@ import logging import os.path -from pyomo.common.dependencies import yaml, yaml_load_args, yaml_available +from pyomo.common.dependencies import yaml, yaml_load_args import pyomo.opt from pyomo.opt.results.container import undefined, ignore, ListContainer, MapContainer import pyomo.opt.results.solution diff --git a/pyomo/opt/results/solution.py b/pyomo/opt/results/solution.py index 0cb8e92e730..6dcd348ea72 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['SolutionStatus', 'Solution'] - import math import enum from pyomo.opt.results.container import MapContainer, ListContainer, ignore diff --git a/pyomo/opt/results/solver.py b/pyomo/opt/results/solver.py index 5f9ceb3b68e..d4cf46c38a9 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-2022 +# 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 @@ -9,14 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = [ - 'SolverInformation', - 'SolverStatus', - 'TerminationCondition', - 'check_optimal_termination', - 'assert_optimal_termination', -] - import enum from pyomo.opt.results.container import MapContainer, ScalarType diff --git a/pyomo/opt/solver/__init__.py b/pyomo/opt/solver/__init__.py index 961d7e0edbd..6da73d408fa 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-2022 +# 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 diff --git a/pyomo/opt/solver/ilmcmd.py b/pyomo/opt/solver/ilmcmd.py index d08feab7d9a..c956b2ed42f 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['ILMLicensedSystemCallSolver'] - import re import sys import os diff --git a/pyomo/opt/solver/shellcmd.py b/pyomo/opt/solver/shellcmd.py index 20892000066..baa0369e1d6 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['SystemCallSolver'] - import os import sys import time @@ -62,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 5d0d8ebd8d7..37ed419fbe3 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-2022 +# 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 diff --git a/pyomo/opt/testing/pyunit.py b/pyomo/opt/testing/pyunit.py index 527b72cec7a..bb96806d520 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-2022 +# 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 @@ -9,9 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -__all__ = ['TestCase'] - import sys import os import re diff --git a/pyomo/opt/tests/__init__.py b/pyomo/opt/tests/__init__.py index 65dc8785c9b..b333eb78878 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-2022 +# 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 diff --git a/pyomo/opt/tests/base/__init__.py b/pyomo/opt/tests/base/__init__.py index dbebb21e4f1..cde23945b56 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-2022 +# 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 diff --git a/pyomo/opt/tests/base/test_ampl.py b/pyomo/opt/tests/base/test_ampl.py index 1baffcbb0af..d37befcac57 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-2022 +# 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 diff --git a/pyomo/opt/tests/base/test_convert.py b/pyomo/opt/tests/base/test_convert.py index f8f0bef0fe4..30a8fb0d1fc 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-2022 +# 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 diff --git a/pyomo/opt/tests/base/test_factory.py b/pyomo/opt/tests/base/test_factory.py index ab2a64a6330..441ba245c5e 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-2022 +# 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 diff --git a/pyomo/opt/tests/base/test_sol.py b/pyomo/opt/tests/base/test_sol.py index ff233b42a43..fada795b925 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-2022 +# 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 diff --git a/pyomo/opt/tests/base/test_soln.py b/pyomo/opt/tests/base/test_soln.py index 0511b3ceb9c..d39baeab15f 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-2022 +# 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 diff --git a/pyomo/opt/tests/base/test_solver.py b/pyomo/opt/tests/base/test_solver.py index 73d6067efe4..919e9375f60 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-2022 +# 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 @@ -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 4c145a1b507..d27a8ab41d6 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-2022 +# 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 diff --git a/pyomo/opt/tests/solver/test_shellcmd.py b/pyomo/opt/tests/solver/test_shellcmd.py index f71fcf07c6d..b6cc264b8f7 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-2022 +# 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 diff --git a/pyomo/pysp/__init__.py b/pyomo/pysp/__init__.py deleted file mode 100644 index 3fb4abbbd42..00000000000 --- a/pyomo/pysp/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 -# National Technology and Engineering Solutions of 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 1b27071c404..842f4750127 100644 --- a/pyomo/repn/__init__.py +++ b/pyomo/repn/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/repn/ampl.py b/pyomo/repn/ampl.py new file mode 100644 index 00000000000..1e142fc16a4 --- /dev/null +++ b/pyomo/repn/ampl.py @@ -0,0 +1,1329 @@ +# ___________________________________________________________________________ +# +# 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 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 fd7fac1125a..04311bdd314 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-2022 +# 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 @@ -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 ff2d6857bd6..992e1810fec 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-2022 +# 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 @@ -9,12 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ( - "_LinearConstraintData", - "MatrixConstraint", - "compile_block_linear_constraints", -) - import time import logging import array @@ -30,7 +24,7 @@ Constraint, IndexedConstraint, ScalarConstraint, - _ConstraintData, + ConstraintData, ) from pyomo.core.expr.numvalue import native_numeric_types from pyomo.repn import generate_standard_repn @@ -253,7 +247,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: @@ -354,12 +348,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. @@ -399,7 +393,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 @@ -448,7 +442,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 @@ -590,9 +584,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 59bc0b58d99..3cb3106b9cb 100644 --- a/pyomo/repn/linear.py +++ b/pyomo/repn/linear.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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(): @@ -120,22 +129,14 @@ 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(): + 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: @@ -191,8 +192,6 @@ def to_expression(visitor, arg): return arg[1].to_expression(visitor) -_exit_node_handlers = {} - # # NEGATION handlers # @@ -207,32 +206,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', ) - return _, 0 - return _, arg1 * arg2 + return _CONSTANT, 0 + return _CONSTANT, ans def _handle_product_constant_ANY(visitor, node, arg1, arg2): @@ -283,19 +276,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 +286,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 +296,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) @@ -365,18 +332,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 +351,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 +369,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 +399,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 +426,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 +448,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 +475,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,37 +543,13 @@ 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() ans.linear[_id] = 1 return False, (_LINEAR, ans) @@ -641,7 +578,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,7 +586,7 @@ 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.", @@ -664,7 +601,6 @@ 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,7 +619,7 @@ 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.", @@ -696,7 +632,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 +640,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 +688,43 @@ 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, + ): 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 @@ -811,7 +767,7 @@ def initializeWalker(self, expr): return True, expr def beforeChild(self, node, child, child_idx): - return _before_child_dispatcher[child.__class__](self, child) + return self.before_child_dispatcher[child.__class__](self, child) def enterNode(self, node): # SumExpression are potentially large nary operators. Directly @@ -849,7 +805,7 @@ 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.", diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py new file mode 100644 index 00000000000..55cca7d6c81 --- /dev/null +++ b/pyomo/repn/linear_template.py @@ -0,0 +1,359 @@ +# ___________________________________________________________________________ +# +# 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 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 + +code_type = deepcopy.__class__ + + +class LinearTemplateRepn(LinearRepn): + __slots__ = ("linear_sum",) + + def __init__(self): + 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, + ): + 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 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) + # 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()) + ) + + 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 + 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 + try: + body, lb, ub = self.expanded_templates[id(template_info)] + except KeyError: + 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 + + 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..b09a74abff0 --- /dev/null +++ b/pyomo/repn/parameterized_linear.py @@ -0,0 +1,405 @@ +# ___________________________________________________________________________ +# +# 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 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..3a18a164fe2 --- /dev/null +++ b/pyomo/repn/parameterized_quadratic.py @@ -0,0 +1,419 @@ +# ___________________________________________________________________________ +# +# 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.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 56b221d3129..5e0a4e6f70a 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-2022 +# 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 @@ -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 493bc06d9c4..d935056c90b 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-2022 +# 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 diff --git a/pyomo/repn/plugins/ampl/ampl_.py b/pyomo/repn/plugins/ampl/ampl_.py index d1a11bf2f38..cc99e9cfdae 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-2022 +# 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 @@ -13,8 +13,6 @@ # AMPL Problem Writer Plugin # -__all__ = ['ProblemWriter_nl'] - import itertools import logging import operator @@ -35,7 +33,7 @@ from pyomo.core.base import ( SymbolMap, NameLabeler, - _ExpressionData, + NamedExpressionData, SortComponents, var, param, @@ -170,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] = "" @@ -726,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: @@ -735,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 0d684fcd1d2..861735dc973 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-2022 +# 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 @@ -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 cdcb4b42c3b..45f4279f8fe 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-2022 +# 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 @@ -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 719839fc8dd..f0a9eb7afef 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-2022 +# 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 @@ -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 be718ee696e..33264119313 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-2022 +# 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 @@ -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 f40c7666278..e1a0d2187fc 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-2022 +# 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 @@ -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 2d5eae151b0..bc7e703a1a7 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-2022 +# 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 @@ -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( @@ -214,7 +176,7 @@ class NLWriter(object): CONFIG.declare( 'skip_trivial_constraints', ConfigValue( - default=False, + default=True, domain=bool, description='Skip writing constraints whose body is constant', ), @@ -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) + """, ), ) @@ -338,6 +302,9 @@ def __call__(self, model, filename, solver_capability, io_options): config.scale_model = False config.linear_presolve = False + # just for backwards compatibility + config.skip_trivial_constraints = False + if config.symbolic_solver_labels: _open = lambda fname: open(fname, 'w') else: @@ -346,6 +313,18 @@ def __call__(self, model, filename, solver_capability, io_options): row_fname ) as ROWFILE, _open(col_fname) as COLFILE: info = self.write(model, FILE, ROWFILE, COLFILE, config=config) + if not info.variables: + # This exception is included for compatibility with the + # original NL writer v1. + os.remove(filename) + if config.symbolic_solver_labels: + os.remove(row_fname) + os.remove(col_fname) + raise ValueError( + "No variables appear in the Pyomo model constraints or" + " objective. This is not supported by the NL file interface" + ) + # Historically, the NL writer communicated the external function # libraries back to the ASL interface through the PYOMO_AMPLFUNC # environment variable. @@ -357,7 +336,9 @@ def __call__(self, model, filename, solver_capability, io_options): return filename, symbol_map @document_kwargs_from_configdict(CONFIG) - def write(self, model, ostream, rowstream=None, colstream=None, **options): + def write( + self, model, ostream, rowstream=None, colstream=None, **options + ) -> NLWriterInfo: """Write a model in NL format. Returns @@ -424,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()] @@ -449,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: @@ -491,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): @@ -520,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, @@ -543,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') @@ -603,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) @@ -626,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: @@ -683,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 @@ -704,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: @@ -721,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: @@ -747,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) # @@ -781,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. @@ -803,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 @@ -830,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()) @@ -854,13 +853,6 @@ def write(self, model): con_vars = con_vars_linear | con_vars_nonlinear all_vars = con_vars | obj_vars n_vars = len(all_vars) - if n_vars < 1: - # TODO: Remove this. This exception is included for - # compatibility with the original NL writer v1. - raise ValueError( - "No variables appear in the Pyomo model constraints or" - " objective. This is not supported by the NL file interface" - ) continuous_vars = set() binary_vars = set() @@ -872,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, " @@ -1026,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 @@ -1040,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] @@ -1064,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): @@ -1083,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 @@ -1239,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)), ) ) # @@ -1272,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)) @@ -1308,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)) ) # @@ -1418,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)) ) # @@ -1426,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: @@ -1440,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 ) ) @@ -1481,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) @@ -1522,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) @@ -1538,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 = [ @@ -1647,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 @@ -1692,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: @@ -1713,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: @@ -1721,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 @@ -1731,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 @@ -1750,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 @@ -1765,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 @@ -1795,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 @@ -1810,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, @@ -1821,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 @@ -1837,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) @@ -1848,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 @@ -1862,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: @@ -1876,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. @@ -1884,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..1d9c5a301b2 --- /dev/null +++ b/pyomo/repn/plugins/parameterized_standard_form.py @@ -0,0 +1,302 @@ +# ___________________________________________________________________________ +# +# 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.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 c72661daaf0..ac85d9a0656 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-2022 +# 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 @@ -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,14 @@ 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.util import ( FileDeterminism, FileDeterminism_to_SortComponents, + TemplateVarRecorder, categorize_valid_components, initialize_var_map_from_column_order, ordered_active_constraints, @@ -61,68 +65,95 @@ 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): + 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 @property def x(self): + "Alias for :attr:`columns`" return self.columns @property def b(self): + "Alias for :attr:`rhs`" return self.rhs @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): + 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( @@ -136,7 +167,25 @@ class LinearStandardFormCompiler(object): ConfigValue( default=False, domain=bool, - description='Add slack variables and return `min cTx s.t. Ax == b`', + description='Add slack variables and return ' + r':math:`\min c^Tx; s.t. Ax = b`', + ), + ) + CONFIG.declare( + 'mixed_form', + ConfigValue( + default=False, + domain=bool, + description='Return A in mixed form (the comparison operator is a ' + 'mix of <=, ==, and >=)', + ), + ) + CONFIG.declare( + 'set_sense', + ConfigValue( + default=ObjectiveSense.minimize, + domain=InEnum(ObjectiveSense), + description='If not None, map all objectives to the specified sense.', ), ) CONFIG.declare( @@ -156,10 +205,13 @@ class LinearStandardFormCompiler(object): 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) + """, ), ) @@ -170,7 +222,7 @@ class LinearStandardFormCompiler(object): 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.""", ), ) @@ -191,7 +243,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 +254,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,8 +269,21 @@ 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 + _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') @@ -255,7 +320,8 @@ 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() ), ) @@ -263,9 +329,10 @@ def write(self, model): 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)} - visitor = LinearRepnVisitor({}, var_map, var_order, sorter) + var_recorder = TemplateVarRecorder(var_map, None, sorter) + visitor = self._get_visitor({}, var_recorder=var_recorder) + template_visitor = LinearTemplateRepnVisitor({}, var_recorder=var_recorder) timer.toc('Initialized column order', level=logging.DEBUG) @@ -296,34 +363,45 @@ 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." + for obj in objectives: + if hasattr(obj, 'template_expr'): + offset, linear_index, linear_data, _, _ = ( + 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) - ) + N = len(linear_index) + obj_index.append(linear_index) + obj_data.append(linear_data) + obj_offset.append(offset) + 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) @@ -332,53 +410,93 @@ def write(self, model): # 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] last_parent = None for con in ordered_active_constraints(model, self.config): - if with_debug_timing and con.parent_component() is not last_parent: + 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 - repn = visitor.walk_expression(con.body) + if hasattr(con, 'template_expr'): + offset, linear_index, linear_data, lb, ub = ( + template_visitor.expand_expression(con, con.template_expr()) + ) + N = len(linear_data) + 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() 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: + # 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: + if lb == ub: + con_nnz += 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 + 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 + 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: if lb == ub: # TODO: add tolerance? rhs.append(ub - offset) else: # add slack variable + con_nnz += 1 v = Var(name=f'_slack_{len(rhs)}', bounds=(None, None)) v.construct() if lb is None: @@ -390,45 +508,51 @@ 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) 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) # 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() + c = self._create_csc(obj_data, obj_index, obj_index_ptr, obj_nnz, n_cols) + A = self._create_csc(con_data, con_index, con_index_ptr, con_nnz, n_cols) + + 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 @@ -437,72 +561,112 @@ def write(self, model): # 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)), + 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)], ) - ) - 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] - ) - 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] + # 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) + if self.config.nonnegative_vars: - c, A, columns, eliminated_vars = _csc_to_nonnegative_vars(c, A, columns) + c, A, columns, eliminated_vars = self._csc_to_nonnegative_vars( + c, A, columns + ) else: eliminated_vars = [] - info = LinearStandardFormInfo(c, A, rhs, rows, columns, eliminated_vars) + info = LinearStandardFormInfo( + c, np.array(obj_offset), A, rhs, rows, columns, objectives, eliminated_vars + ) timer.toc("Generated linear standard form representation", delta=False) return info - -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), - ) - ) - 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 + def _create_csc(self, data, index, index_ptr, nnz, n_cols): + 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() + + data = self._to_vector(itertools.chain.from_iterable(data), np.float64, nnz) + index = self._to_vector(itertools.chain.from_iterable(index), np.int32, nnz) + 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() + A.sum_duplicates() + A.eliminate_zeros() + return A + + 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 +674,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 2d11261de5d..3c741b084dc 100644 --- a/pyomo/repn/quadratic.py +++ b/pyomo/repn/quadratic.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -30,6 +30,7 @@ ) from pyomo.core.base.expression import Expression from . import linear +from . import util from .linear import _merge_dict, to_expression _CONSTANT = linear.ExprType.CONSTANT @@ -98,22 +99,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,22 +158,12 @@ 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): quadratic = {} + vo = visitor.var_recorder.var_order for vid1, coef1 in linear1.items(): for vid2, coef2 in linear2.items(): - if varOrder(vid1) < varOrder(vid2): + if vo[vid1] < vo[vid2]: key = vid1, vid2 else: key = vid2, vid1 @@ -194,9 +178,7 @@ 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.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 +234,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 +264,73 @@ 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, - } -) - -# -# 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, - } -) - - -# -# 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 - -# -# NAMED EXPRESSION handlers -# -_exit_node_handlers[Expression][(_QUADRATIC,)] = linear._handle_named_ANY - -# -# 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 - -# -# 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, - } -) +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) + return _exit_node_handlers class QuadraticRepnVisitor(linear.LinearRepnVisitor): Result = QuadraticRepn - exit_node_handlers = _exit_node_handlers 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 diff --git a/pyomo/repn/standard_aux.py b/pyomo/repn/standard_aux.py index 8704253eca3..403320c462c 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-2022 +# 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 @@ -10,9 +10,6 @@ # ___________________________________________________________________________ -__all__ = ['compute_standard_repn'] - - from pyomo.repn.standard_repn import ( preprocess_block_constraints, preprocess_block_objectives, diff --git a/pyomo/repn/standard_repn.py b/pyomo/repn/standard_repn.py index 53618d3eb50..b767ab727af 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-2022 +# 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 @@ -10,9 +10,6 @@ # ___________________________________________________________________________ -__all__ = ['StandardRepn', 'generate_standard_repn'] - - import sys import logging import itertools @@ -22,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 @@ -324,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 @@ -339,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 @@ -1117,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, } @@ -1517,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 5e413c0132c..a9e1a5bea47 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/__init__.py b/pyomo/repn/tests/ampl/__init__.py index e69de29bb2d..dd080df1b43 100644 --- a/pyomo/repn/tests/ampl/__init__.py +++ b/pyomo/repn/tests/ampl/__init__.py @@ -0,0 +1,23 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + +# +# 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 eb09afc37cc..2bf2198d20f 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small10_testCase.py b/pyomo/repn/tests/ampl/small10_testCase.py index f51aea76d3e..deb56f92a88 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small11_testCase.py b/pyomo/repn/tests/ampl/small11_testCase.py index 5874007e13c..11b61805d5e 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small12_testCase.py b/pyomo/repn/tests/ampl/small12_testCase.py index 63d4ba29cf6..b73a8f528f2 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small13_testCase.py b/pyomo/repn/tests/ampl/small13_testCase.py index 9814c979cc7..c24185bf8d7 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small14_testCase.py b/pyomo/repn/tests/ampl/small14_testCase.py index 3d896242243..fb2c2bc6c5e 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small15_testCase.py b/pyomo/repn/tests/ampl/small15_testCase.py index 8345621cecd..d4d5796aaa5 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small1_testCase.py b/pyomo/repn/tests/ampl/small1_testCase.py index 00e6dd322ed..06f5ad122d9 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small2_testCase.py b/pyomo/repn/tests/ampl/small2_testCase.py index 2df3aebb139..8a65779f55e 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small3_testCase.py b/pyomo/repn/tests/ampl/small3_testCase.py index f11137979b4..999143d9a0c 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small4_testCase.py b/pyomo/repn/tests/ampl/small4_testCase.py index 08d68c21f50..9736dd9bf3b 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small5_testCase.py b/pyomo/repn/tests/ampl/small5_testCase.py index 1e976820f9b..1f254b7f04d 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small6_testCase.py b/pyomo/repn/tests/ampl/small6_testCase.py index da9f1d58f9b..9d309c09fef 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small7_testCase.py b/pyomo/repn/tests/ampl/small7_testCase.py index 22a75a33394..485962dd211 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small8_testCase.py b/pyomo/repn/tests/ampl/small8_testCase.py index 554e27c0924..61a3e3ccce7 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/small9_testCase.py b/pyomo/repn/tests/ampl/small9_testCase.py index 3d7af602a88..7cb0913a762 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/test_ampl_comparison.py b/pyomo/repn/tests/ampl/test_ampl_comparison.py index eb5aff329e1..8210bbdd173 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/test_ampl_nl.py b/pyomo/repn/tests/ampl/test_ampl_nl.py index bd58c254bfd..38c9d5b9dd5 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-2022 +# 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 @@ -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 cf1a889006e..9c911540eb0 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-2022 +# 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 diff --git a/pyomo/repn/tests/ampl/test_nlv2.py b/pyomo/repn/tests/ampl/test_nlv2.py index 6422a2b0020..43327dd9ae7 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-2022 +# 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 @@ -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 e73060e7e8c..1372da68bdc 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-2022 +# 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 diff --git a/pyomo/repn/tests/baron/__init__.py b/pyomo/repn/tests/baron/__init__.py index 030f46eaca8..c693bb8accd 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-2022 +# 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 diff --git a/pyomo/repn/tests/baron/small14a_testCase.py b/pyomo/repn/tests/baron/small14a_testCase.py index 72190756dc7..b2cf5afcb72 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-2022 +# 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 diff --git a/pyomo/repn/tests/baron/test_baron.py b/pyomo/repn/tests/baron/test_baron.py index 348ad6036fb..6f22f26cd38 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-2022 +# 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 diff --git a/pyomo/repn/tests/baron/test_baron_comparison.py b/pyomo/repn/tests/baron/test_baron_comparison.py index 7c480321624..1b394f6a5b1 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-2022 +# 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 diff --git a/pyomo/repn/tests/cpxlp/__init__.py b/pyomo/repn/tests/cpxlp/__init__.py index 8ffbfd52054..f216a76f48b 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-2022 +# 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 diff --git a/pyomo/repn/tests/cpxlp/test_cpxlp.py b/pyomo/repn/tests/cpxlp/test_cpxlp.py index 28c9043a8de..567c5184517 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-2022 +# 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 diff --git a/pyomo/repn/tests/cpxlp/test_lpv2.py b/pyomo/repn/tests/cpxlp/test_lpv2.py index 336939a4d7d..42fead8da49 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-2022 +# 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 @@ -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 24188d46c86..c346f8c48b2 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-2022 +# 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 diff --git a/pyomo/repn/tests/gams/__init__.py b/pyomo/repn/tests/gams/__init__.py index 8d13c4ffb99..e548666fd72 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-2022 +# 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 diff --git a/pyomo/repn/tests/gams/small14a_testCase.py b/pyomo/repn/tests/gams/small14a_testCase.py index c7e3e0805ea..1efdd1baa25 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-2022 +# 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 diff --git a/pyomo/repn/tests/gams/test_gams.py b/pyomo/repn/tests/gams/test_gams.py index e6b729e5dfc..e3304e18491 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-2022 +# 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 diff --git a/pyomo/repn/tests/gams/test_gams_comparison.py b/pyomo/repn/tests/gams/test_gams_comparison.py index 4e530b10d43..42fa9f71dda 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-2022 +# 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 diff --git a/pyomo/repn/tests/lp_diff.py b/pyomo/repn/tests/lp_diff.py index 23b24f8b51b..2c119d72c6f 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-2022 +# 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 diff --git a/pyomo/repn/tests/mps/__init__.py b/pyomo/repn/tests/mps/__init__.py index 1a8a69a1409..effc182aa1c 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-2022 +# 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 diff --git a/pyomo/repn/tests/mps/test_mps.py b/pyomo/repn/tests/mps/test_mps.py index 9be45a17870..ff7981b391c 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-2022 +# 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 diff --git a/pyomo/repn/tests/nl_diff.py b/pyomo/repn/tests/nl_diff.py index e96d6f6357b..d94d50e82e6 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-2022 +# 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 @@ -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 0eec8a1541c..0f027099c89 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-2022 +# 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 @@ -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: @@ -1517,7 +1546,7 @@ def test_type_registrations(self): bcd.register_dispatcher(visitor, 5), (False, (linear._CONSTANT, 5)) ) self.assertEqual(len(bcd), 1) - self.assertIs(bcd[int], bcd._before_native) + self.assertIs(bcd[int], bcd._before_native_numeric) # complex type self.assertEqual( bcd.register_dispatcher(visitor, 5j), (False, (linear._CONSTANT, 5j)) @@ -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..d2bde4845ff --- /dev/null +++ b/pyomo/repn/tests/test_parameterized_linear.py @@ -0,0 +1,552 @@ +# ___________________________________________________________________________ +# +# 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.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..92e84ea0dff --- /dev/null +++ b/pyomo/repn/tests/test_parameterized_quadratic.py @@ -0,0 +1,1463 @@ +# ___________________________________________________________________________ +# +# 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 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..4a88ea4f883 --- /dev/null +++ b/pyomo/repn/tests/test_parameterized_standard_form.py @@ -0,0 +1,501 @@ +# ___________________________________________________________________________ +# +# 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.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..1152131f6b6 --- /dev/null +++ b/pyomo/repn/tests/test_plugins.py @@ -0,0 +1,50 @@ +# ___________________________________________________________________________ +# +# 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 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 605c859464a..137954dc1d0 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-2022 +# 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 @@ -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 b62d18e6eff..6c5a6e3e033 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-2022 +# 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 diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index d186f28dab8..4c66ae87c41 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-2022 +# 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 @@ -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 47cc6b1a63a..fc9d86f966f 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-2022 +# 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 @@ -19,6 +19,7 @@ from pyomo.common.errors import DeveloperError, InvalidValueError from pyomo.common.log import LoggingIntercept from pyomo.core.expr import ( + NumericExpression, ProductExpression, NPV_ProductExpression, SumExpression, @@ -241,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" @@ -292,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" @@ -671,16 +672,6 @@ def test_ExitNodeDispatcher_registration(self): self.assertEqual(len(end), 4) self.assertIn(NPV_ProductExpression, end) - class NewProductExpression(ProductExpression): - pass - - node = NewProductExpression((6, 7)) - with self.assertRaisesRegex( - DeveloperError, r".*Unexpected expression node type 'NewProductExpression'" - ): - end[node.__class__](None, node, *node.args) - self.assertEqual(len(end), 4) - end[SumExpression, 2] = lambda v, n, *d: 2 * sum(d) self.assertEqual(len(end), 5) @@ -708,8 +699,34 @@ class NewProductExpression(ProductExpression): self.assertEqual(end[node.__class__, 3, 4, 5, 6](None, node, *node.args), 6) self.assertEqual(len(end), 7) + # We don't cache etypes with more than 3 arguments self.assertNotIn((SumExpression, 3, 4, 5, 6), end) + class NewProductExpression(ProductExpression): + pass + + node = NewProductExpression((6, 7)) + self.assertEqual(end[node.__class__](None, node, *node.args), 42) + self.assertEqual(len(end), 8) + self.assertIn(NewProductExpression, end) + + class UnknownExpression(NumericExpression): + pass + + node = UnknownExpression((6, 7)) + with self.assertRaisesRegex( + DeveloperError, r".*Unexpected expression node type 'UnknownExpression'" + ): + end[node.__class__](None, node, *node.args) + 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), 8) + def test_BeforeChildDispatcher_registration(self): class BeforeChildDispatcherTester(BeforeChildDispatcher): @staticmethod @@ -734,15 +751,14 @@ def evaluate(self, node): node = 5 self.assertEqual(bcd[node.__class__](None, node), (False, (_CONSTANT, 5))) - self.assertIs(bcd[int], bcd._before_native) + self.assertIs(bcd[int], bcd._before_native_numeric) self.assertEqual(len(bcd), 1) node = 'string' ans = bcd[node.__class__](None, node) self.assertEqual(ans, (False, (_CONSTANT, InvalidNumber(node)))) self.assertEqual( - ''.join(ans[1][1].causes), - "'string' () is not a valid numeric type", + ''.join(ans[1][1].causes), "'string' (str) is not a valid numeric type" ) self.assertIs(bcd[str], bcd._before_string) self.assertEqual(len(bcd), 2) @@ -751,10 +767,9 @@ def evaluate(self, node): ans = bcd[node.__class__](None, node) self.assertEqual(ans, (False, (_CONSTANT, InvalidNumber(node)))) self.assertEqual( - ''.join(ans[1][1].causes), - "True () is not a valid numeric type", + ''.join(ans[1][1].causes), "True (bool) is not a valid numeric type" ) - self.assertIs(bcd[bool], bcd._before_invalid) + self.assertIs(bcd[bool], bcd._before_native_logical) self.assertEqual(len(bcd), 3) node = 1j @@ -771,14 +786,14 @@ class new_int(int): node = new_int(5) self.assertEqual(bcd[node.__class__](None, node), (False, (_CONSTANT, 5))) - self.assertIs(bcd[new_int], bcd._before_native) + self.assertIs(bcd[new_int], bcd._before_native_numeric) self.assertEqual(len(bcd), 5) node = [] ans = bcd[node.__class__](None, node) self.assertEqual(ans, (False, (_CONSTANT, InvalidNumber([])))) self.assertEqual( - ''.join(ans[1][1].causes), "[] () is not a valid numeric type" + ''.join(ans[1][1].causes), "[] (list) is not a valid numeric type" ) self.assertIs(bcd[list], bcd._before_invalid) self.assertEqual(len(bcd), 6) diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index b65aa9427d5..e4126ff4bfd 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 @@ -25,6 +25,7 @@ native_types, native_numeric_types, native_complex_types, + native_logical_types, ) from pyomo.core.pyomoobject import PyomoObject from pyomo.core.base import ( @@ -35,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 @@ -54,7 +56,7 @@ EXPR.NPV_SumExpression, } _named_subexpression_types = ( - _ExpressionData, + NamedExpressionData, kernel.expression.expression, kernel.objective.objective, ) @@ -64,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 @@ -80,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 @@ -92,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): @@ -155,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)) @@ -177,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 @@ -192,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) @@ -240,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 @@ -265,7 +294,9 @@ def __missing__(self, key): def register_dispatcher(self, visitor, child): child_type = type(child) if child_type in native_numeric_types: - self[child_type] = self._before_native + self[child_type] = self._before_native_numeric + elif child_type in native_logical_types: + self[child_type] = self._before_native_logical elif issubclass(child_type, str): self[child_type] = self._before_string elif child_type in native_types: @@ -275,14 +306,30 @@ def register_dispatcher(self, visitor, child): self[child_type] = self._before_invalid elif not hasattr(child, 'is_expression_type'): if check_if_numeric_type(child): - self[child_type] = self._before_native + self[child_type] = self._before_native_numeric 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() @@ -306,9 +353,18 @@ def _before_general_expression(visitor, child): return True, None @staticmethod - def _before_native(visitor, child): + def _before_native_numeric(visitor, child): return False, (_CONSTANT, child) + @staticmethod + def _before_native_logical(visitor, child): + return False, ( + _CONSTANT, + InvalidNumber( + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" + ), + ) + @staticmethod def _before_complex(visitor, child): return False, (_CONSTANT, complex_number_error(child, visitor, child)) @@ -318,7 +374,7 @@ def _before_invalid(visitor, child): return False, ( _CONSTANT, InvalidNumber( - child, f"{child!r} ({type(child)}) is not a valid numeric type" + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" ), ) @@ -327,7 +383,7 @@ def _before_string(visitor, child): return False, ( _CONSTANT, InvalidNumber( - child, f"{child!r} ({type(child)}) is not a valid numeric type" + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" ), ) @@ -345,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; @@ -362,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 """ @@ -387,42 +476,67 @@ def __init__(self, *args, **kwargs): super().__init__(None, *args, **kwargs) def __missing__(self, key): - return functools.partial(self.register_dispatcher, key=key) - - def register_dispatcher(self, visitor, node, *data, key=None): + if type(key) is tuple: + # 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__ + # Note: if we add an `etype`, then this special-case can be removed if ( - isinstance(node, _named_subexpression_types) - or type(node) is kernel.expression.noclone + issubclass(node_class, _named_subexpression_types) + or node_class is kernel.expression.noclone ): - base_type = Expression - elif not node.is_potentially_variable(): - base_type = node.potentially_variable_base_class() - else: - base_type = node.__class__ - 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: - fcn = self[base_type] - elif any((k[0] if k.__class__ is tuple else k) is base_type for k in self): - raise DeveloperError( - f"Base expression key '{base_key}' not found when inserting dispatcher" - f" for node '{type(node).__name__}' while walking expression tree." + bases = [Expression] + fcn = None + for base_type in bases: + 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] + break + if fcn is None: + partial_matches = set( + k[0] for k in self if type(k) is tuple and issubclass(node_class, k[0]) ) - else: - raise DeveloperError( - f"Unexpected expression node type '{type(node).__name__}' " - "found while walking expression tree." - ) - if cache: - self[key] = fcn - return fcn(visitor, node, *data) + 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, *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): @@ -469,7 +583,7 @@ def categorize_valid_components( Parameters ---------- - model: _BlockData + model: BlockData The model tree to walk active: True or None @@ -490,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. @@ -644,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 a3c2c1bb7ce..ee60988e1fb 100644 --- a/pyomo/scripting/__init__.py +++ b/pyomo/scripting/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 7782962c2c1..ef59d64b542 100644 --- a/pyomo/scripting/commands.py +++ b/pyomo/scripting/commands.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/scripting/convert.py b/pyomo/scripting/convert.py index 2f0c0e5b400..20f9ef6d382 100644 --- a/pyomo/scripting/convert.py +++ b/pyomo/scripting/convert.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['pyomo2lp', 'pyomo2nl', 'pyomo2dakota'] - import os import sys diff --git a/pyomo/scripting/driver_help.py b/pyomo/scripting/driver_help.py index 81970a6b5cc..4c0c1539f89 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-2022 +# 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 @@ -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 efb97470e43..a6ac425c8c0 100644 --- a/pyomo/scripting/interface.py +++ b/pyomo/scripting/interface.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 44e3956f314..f1fc43688d0 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-2022 +# 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 @@ -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 9ae63cbb8a1..5b4ac836a00 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-2022 +# 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 diff --git a/pyomo/scripting/plugins/convert.py b/pyomo/scripting/plugins/convert.py index 55290ed90ce..ea6742cec56 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-2022 +# 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 diff --git a/pyomo/scripting/plugins/download.py b/pyomo/scripting/plugins/download.py index 73a164ee708..afe56988009 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-2022 +# 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 @@ -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 4cf9e623212..2bd1c4a0803 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-2022 +# 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 diff --git a/pyomo/scripting/plugins/solve.py b/pyomo/scripting/plugins/solve.py index 69451a04e3c..b2a849e995b 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-2022 +# 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 diff --git a/pyomo/scripting/pyomo_command.py b/pyomo/scripting/pyomo_command.py index b652e95372a..8beec41a8b1 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-2022 +# 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 diff --git a/pyomo/scripting/pyomo_main.py b/pyomo/scripting/pyomo_main.py index 9acafea0471..6497206fdda 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-2022 +# 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 diff --git a/pyomo/scripting/pyomo_parser.py b/pyomo/scripting/pyomo_parser.py index 345d400a1aa..9294d46f85e 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['add_subparser', 'get_parser', 'subparsers'] - import argparse import sys diff --git a/pyomo/scripting/solve_config.py b/pyomo/scripting/solve_config.py index 3048431d443..2d8220195a1 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-2022 +# 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 @@ -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 88e18b19035..d9146f7eee4 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-2022 +# 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 diff --git a/pyomo/scripting/tests/test_cmds.py b/pyomo/scripting/tests/test_cmds.py index 960e0d4ada1..9a120c8c175 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-2022 +# 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 diff --git a/pyomo/scripting/util.py b/pyomo/scripting/util.py index 5bc65eb35ae..351e422a250 100644 --- a/pyomo/scripting/util.py +++ b/pyomo/scripting/util.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 d93cfd77b3c..a4a626013c4 100644 --- a/pyomo/solvers/__init__.py +++ b/pyomo/solvers/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/solvers/amplfunc_merge.py b/pyomo/solvers/amplfunc_merge.py new file mode 100644 index 00000000000..e49fd20e20f --- /dev/null +++ b/pyomo/solvers/amplfunc_merge.py @@ -0,0 +1,32 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + +def amplfunc_string_merge(amplfunc, pyomo_amplfunc): + """Merge two AMPLFUNC variable strings eliminating duplicate lines""" + # Assume that the strings amplfunc and pyomo_amplfunc don't contain duplicates + # Assume that the path separator is correct for the OS so we don't need to + # worry about comparing Unix and Windows paths. + amplfunc_lines = amplfunc.split("\n") + existing = set(amplfunc_lines) + for line in pyomo_amplfunc.split("\n"): + # Skip lines we already have + if line not in existing: + amplfunc_lines.append(line) + # Remove empty lines which could happen if one or both of the strings is + # empty or there are two new lines in a row for whatever reason. + amplfunc_lines = [s for s in amplfunc_lines if s != ""] + return "\n".join(amplfunc_lines) + + +def amplfunc_merge(env): + """Merge AMPLFUNC and PYOMO_AMPLFUNC in an environment var dict""" + return amplfunc_string_merge(env.get("AMPLFUNC", ""), env.get("PYOMO_AMPLFUNC", "")) diff --git a/pyomo/solvers/mockmip.py b/pyomo/solvers/mockmip.py index 9497a6dff9d..2c28b7a9be0 100644 --- a/pyomo/solvers/mockmip.py +++ b/pyomo/solvers/mockmip.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/solvers/plugins/__init__.py b/pyomo/solvers/plugins/__init__.py index 797ed5036bd..76d3c5b66d3 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-2022 +# 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 @@ -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 b6baf4f6682..51dcfdb1b1a 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-2022 +# 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 @@ -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 b718faf2d21..0798115a448 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-2022 +# 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 diff --git a/pyomo/solvers/plugins/converter/glpsol.py b/pyomo/solvers/plugins/converter/glpsol.py index a38892e3cf5..9b404567c4d 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-2022 +# 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 diff --git a/pyomo/solvers/plugins/converter/model.py b/pyomo/solvers/plugins/converter/model.py index 89a521d1521..817df157bf5 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-2022 +# 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 diff --git a/pyomo/solvers/plugins/converter/pico.py b/pyomo/solvers/plugins/converter/pico.py index 7fd0d11222b..e5d008da347 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-2022 +# 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 diff --git a/pyomo/solvers/plugins/solvers/ASL.py b/pyomo/solvers/plugins/solvers/ASL.py index debcd27f75e..c912f2a30ee 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-2022 +# 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 @@ -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 eb5ac0830c5..044cab27b86 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-2022 +# 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 diff --git a/pyomo/solvers/plugins/solvers/CBCplugin.py b/pyomo/solvers/plugins/solvers/CBCplugin.py index 86871dbc1ac..20876b07331 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['CBC', 'MockCBC'] - import os import re import time @@ -18,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 @@ -31,7 +30,6 @@ SolverStatus, TerminationCondition, SolutionStatus, - ProblemSense, Solution, ) from pyomo.opt.solver import SystemCallSolver @@ -445,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 @@ -457,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', @@ -541,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( @@ -553,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') @@ -565,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') @@ -580,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 = ( @@ -611,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', @@ -632,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', @@ -754,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 @@ -826,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) ) @@ -834,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 30e8ada11a1..3455eede67b 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-2022 +# 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 @@ -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 9755bc58614..e4a90611d9a 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-2022 +# 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 @@ -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 d0365d49078..035bd0b7603 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-2022 +# 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 @@ -36,12 +36,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 +197,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 +208,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: @@ -330,12 +329,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 +348,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 +360,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 +399,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 +421,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 +589,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 @@ -980,11 +982,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 a5b8ad9c019..c8d5bc14237 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-2022 +# 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 @@ -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 e0eddf008af..06ad3d275be 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-2022 +# 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 @@ -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 2b505adf49c..0de1a61266e 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-2022 +# 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 @@ -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 611180113c8..82dcfdb75a0 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-2022 +# 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 @@ -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/SAS.py b/pyomo/solvers/plugins/solvers/SAS.py new file mode 100644 index 00000000000..fd13d6c6b52 --- /dev/null +++ b/pyomo/solvers/plugins/solvers/SAS.py @@ -0,0 +1,815 @@ +# ___________________________________________________________________________ +# +# 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 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 9898b9cdd90..98dad4ca5fd 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-2022 +# 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 @@ -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 6ab51cfbbf3..2c16d971144 100644 --- a/pyomo/solvers/plugins/solvers/XPRESS.py +++ b/pyomo/solvers/plugins/solvers/XPRESS.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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.opt.base import OptSolver from pyomo.opt.base.solvers import SolverFactory import logging diff --git a/pyomo/solvers/plugins/solvers/__init__.py b/pyomo/solvers/plugins/solvers/__init__.py index c5fbfa97e42..61f92180abc 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-2022 +# 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 @@ -10,23 +10,26 @@ # ___________________________________________________________________________ # 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, +) diff --git a/pyomo/solvers/plugins/solvers/cplex_direct.py b/pyomo/solvers/plugins/solvers/cplex_direct.py index 308d3438329..b758453df7d 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-2022 +# 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 @@ -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 a7fdcc45ade..754dadc09e2 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-2022 +# 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 @@ -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 09bbfbda70f..de38a0372d0 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-2022 +# 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 @@ -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 4f90a753fe6..609a81b2018 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-2022 +# 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 @@ -15,7 +15,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 @@ -79,8 +79,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 +89,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) ) diff --git a/pyomo/solvers/plugins/solvers/gurobi_direct.py b/pyomo/solvers/plugins/solvers/gurobi_direct.py index 54ea9111508..cdb04b63dec 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-2022 +# 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 @@ -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 382cb7c4e6d..447d1de9b40 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-2022 +# 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 @@ -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 @@ -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 4c0718bfe74..5682ae69b2c 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-2022 +# 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 @@ -492,13 +492,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 +555,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 +1068,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 6eaad564781..efcbb7dd9dd 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-2022 +# 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 @@ -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 141621d0a31..ef883fe5496 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-2022 +# 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 @@ -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 8f72e630a3d..c3ec2eaf709 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-2022 +# 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 diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index aa5a4ba1b4e..764314deee9 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-2022 +# 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 @@ -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 56024bc0540..fbdc2866dcf 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-2022 +# 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 @@ -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 @@ -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 42c694b0170..4d8d45da724 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/__init__.py b/pyomo/solvers/tests/checks/__init__.py index 03a34303759..ccd3a0f98a4 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_BARON.py b/pyomo/solvers/tests/checks/test_BARON.py index 897f1e88a42..29c7ffb0148 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_CBCplugin.py b/pyomo/solvers/tests/checks/test_CBCplugin.py index fe01a89bb53..ad8846509ea 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-2022 +# 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 @@ -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 86e03d1024f..400d7ee5f75 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_CPLEXPersistent.py b/pyomo/solvers/tests/checks/test_CPLEXPersistent.py index d7f00d0f486..442212d4fbb 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-2022 +# 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 @@ -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 7aa952a6c69..1eef09819f7 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_MOSEKDirect.py b/pyomo/solvers/tests/checks/test_MOSEKDirect.py index 369cc08161a..2cf7034b80a 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_MOSEKPersistent.py b/pyomo/solvers/tests/checks/test_MOSEKPersistent.py index 59ea930c4f0..a4c0aa21666 100644 --- a/pyomo/solvers/tests/checks/test_MOSEKPersistent.py +++ b/pyomo/solvers/tests/checks/test_MOSEKPersistent.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest from pyomo.opt import ( diff --git a/pyomo/solvers/tests/checks/test_SAS.py b/pyomo/solvers/tests/checks/test_SAS.py new file mode 100644 index 00000000000..65f466508e8 --- /dev/null +++ b/pyomo/solvers/tests/checks/test_SAS.py @@ -0,0 +1,546 @@ +# ___________________________________________________________________________ +# +# 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 +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..2c819404d2f --- /dev/null +++ b/pyomo/solvers/tests/checks/test_amplfunc_merge.py @@ -0,0 +1,162 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest +from pyomo.solvers.amplfunc_merge import amplfunc_string_merge, 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 = amplfunc_string_merge(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 = amplfunc_string_merge(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 = amplfunc_string_merge(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 = amplfunc_string_merge(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, amplfunc_string_merge, "", 3) + self.assertRaises(AttributeError, amplfunc_string_merge, 3, "") + self.assertRaises(AttributeError, amplfunc_string_merge, 3, 3) + self.assertRaises(AttributeError, amplfunc_string_merge, None, "") + self.assertRaises(AttributeError, amplfunc_string_merge, "", None) + self.assertRaises(AttributeError, amplfunc_string_merge, 2.3, "") + self.assertRaises(AttributeError, amplfunc_string_merge, "", 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 = amplfunc_string_merge(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 = amplfunc_string_merge(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 = amplfunc_string_merge(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 0fd6e9f49a1..420de7cc61d 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_cplex.py b/pyomo/solvers/tests/checks/test_cplex.py index 44b82d2ad77..ff5ac5f17e1 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_gurobi.py b/pyomo/solvers/tests/checks/test_gurobi.py index f33a00ce8a2..580f6f3b714 100644 --- a/pyomo/solvers/tests/checks/test_gurobi.py +++ b/pyomo/solvers/tests/checks/test_gurobi.py @@ -1,8 +1,20 @@ +# ___________________________________________________________________________ +# +# 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 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 @@ -15,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 @@ -38,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 7c60b207a9f..1e3a366a37a 100644 --- a/pyomo/solvers/tests/checks/test_gurobi_direct.py +++ b/pyomo/solvers/tests/checks/test_gurobi_direct.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + """ Tests for working with Gurobi environments. Some require a single-use license and are skipped if this isn't the case. diff --git a/pyomo/solvers/tests/checks/test_gurobi_persistent.py b/pyomo/solvers/tests/checks/test_gurobi_persistent.py index 9d69c1dd920..812390c23a4 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-2022 +# 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 @@ -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..b7d00c35a6f --- /dev/null +++ b/pyomo/solvers/tests/checks/test_ipopt.py @@ -0,0 +1,42 @@ +# ___________________________________________________________________________ +# +# 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 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 9ba8e86a013..81a2d2bf297 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_pickle.py b/pyomo/solvers/tests/checks/test_pickle.py index d8551b34740..745320cb4eb 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_writers.py b/pyomo/solvers/tests/checks/test_writers.py index e406e07a4d6..55002c71357 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-2022 +# 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 diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index cd9c30fc73b..329ba38e164 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -1,8 +1,25 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest import pyomo.environ as pe +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 = pe.SolverFactory('xpress_persistent').available(False) class TestXpressPersistent(unittest.TestCase): @@ -23,10 +40,23 @@ 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 + 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 = pe.Constraint(expr=m.y >= -m.x + 1) opt.add_constraint(m.c2) self.assertEqual(opt.get_xpress_attribute('cols'), 2) @@ -251,7 +281,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 @@ -280,6 +310,9 @@ def test_nonconvexqp_locally_optimal(self): opt = pe.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) @@ -315,3 +348,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 c95d27d9497..707a8c4b7e5 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-2022 +# 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 diff --git a/pyomo/solvers/tests/mip/model.py b/pyomo/solvers/tests/mip/model.py index 389151160b8..83c1411fe6c 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-2022 +# 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 diff --git a/pyomo/solvers/tests/mip/test_asl.py b/pyomo/solvers/tests/mip/test_asl.py index 42b77df7d87..6f23a06eff2 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-2022 +# 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 diff --git a/pyomo/solvers/tests/mip/test_convert.py b/pyomo/solvers/tests/mip/test_convert.py index cd916da29f2..962b021c4ae 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-2022 +# 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 diff --git a/pyomo/solvers/tests/mip/test_factory.py b/pyomo/solvers/tests/mip/test_factory.py index 6960a0f8ced..f69fd198009 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-2022 +# 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 @@ -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 bccb4f2a27c..38c3b35d8a1 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-2022 +# 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 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 5d920b9085d..def2d8c91ec 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-2022 +# 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 @@ -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 7fffdc53c13..ad54daeddc0 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-2022 +# 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 @@ -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 8f756de220a..a0006d69eb7 100644 --- a/pyomo/solvers/tests/mip/test_scip_log_data.py +++ b/pyomo/solvers/tests/mip/test_scip_log_data.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 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 c0cc80c0316..f83bed2da32 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-2022 +# 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 diff --git a/pyomo/solvers/tests/mip/test_solver.py b/pyomo/solvers/tests/mip/test_solver.py index 90a7076cbca..bf3550a001d 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_block.py b/pyomo/solvers/tests/models/LP_block.py index 64c866faa9e..37b01dc1c2d 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_compiled.py b/pyomo/solvers/tests/models/LP_compiled.py index 686406e7ec6..960b8730e0c 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_constant_objective1.py b/pyomo/solvers/tests/models/LP_constant_objective1.py index 306a7a867a2..0c01cd7085f 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_constant_objective2.py b/pyomo/solvers/tests/models/LP_constant_objective2.py index 17da01bf209..07739c1f708 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_duals_maximize.py b/pyomo/solvers/tests/models/LP_duals_maximize.py index 61d827daa62..ed45e4eee29 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_duals_minimize.py b/pyomo/solvers/tests/models/LP_duals_minimize.py index 77471d0182c..3f97276a61e 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_inactive_index.py b/pyomo/solvers/tests/models/LP_inactive_index.py index d3fdd5b32ca..5e2b570a1e8 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_infeasible1.py b/pyomo/solvers/tests/models/LP_infeasible1.py index 28243574a37..8cba441a6c3 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_infeasible2.py b/pyomo/solvers/tests/models/LP_infeasible2.py index 383267c0e3c..7f417d9145c 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_piecewise.py b/pyomo/solvers/tests/models/LP_piecewise.py index f6350b38591..22ee9d08694 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_simple.py b/pyomo/solvers/tests/models/LP_simple.py index 3449a657f79..4f1e6dcbc7e 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_trivial_constraints.py b/pyomo/solvers/tests/models/LP_trivial_constraints.py index 096c9e71712..3958f2b4493 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_unbounded.py b/pyomo/solvers/tests/models/LP_unbounded.py index e3173e2ff07..03525f38ca0 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-2022 +# 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 @@ -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 624181eb27d..f5a4df6338d 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/LP_unused_vars.py b/pyomo/solvers/tests/models/LP_unused_vars.py index 5e6b40fa4bf..0062fc58463 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/MILP_discrete_var_bounds.py b/pyomo/solvers/tests/models/MILP_discrete_var_bounds.py index 8fef69ef76a..22876a7a291 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/MILP_infeasible1.py b/pyomo/solvers/tests/models/MILP_infeasible1.py index 2a0bf1bd188..e95fef92744 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/MILP_simple.py b/pyomo/solvers/tests/models/MILP_simple.py index fb157ea6555..488c7841024 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/MILP_unbounded.py b/pyomo/solvers/tests/models/MILP_unbounded.py index 364f3ffeb86..c5a166a6141 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/MILP_unused_vars.py b/pyomo/solvers/tests/models/MILP_unused_vars.py index 742d0f951a8..b6e06c8db0c 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/MIQCP_simple.py b/pyomo/solvers/tests/models/MIQCP_simple.py index 46c1293b23c..5946e83fadb 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/MIQP_simple.py b/pyomo/solvers/tests/models/MIQP_simple.py index 1d43d96ab8b..6922d6be97d 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/QCP_simple.py b/pyomo/solvers/tests/models/QCP_simple.py index 5f8405f1f00..5f4311a3ab9 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/QP_constant_objective.py b/pyomo/solvers/tests/models/QP_constant_objective.py index 2769fe07556..6ea34b69f51 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/QP_simple.py b/pyomo/solvers/tests/models/QP_simple.py index 5959cf1d8b1..c5f4f40c576 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/SOS1_simple.py b/pyomo/solvers/tests/models/SOS1_simple.py index e6156ad5c32..ba3c89e680b 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/SOS2_simple.py b/pyomo/solvers/tests/models/SOS2_simple.py index 4f192773ca4..2062611f8cf 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-2022 +# 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 diff --git a/pyomo/solvers/tests/models/__init__.py b/pyomo/solvers/tests/models/__init__.py index c6a550397d5..f67883e6718 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-2022 +# 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 @@ -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 106e8860145..25442611806 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-2022 +# 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 diff --git a/pyomo/solvers/tests/piecewise_linear/__init__.py b/pyomo/solvers/tests/piecewise_linear/__init__.py index bcaa157f6f4..79b33f0d427 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-2022 +# 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 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 38c840f9ed9..45270d7dc34 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-2022 +# 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 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 3aef735965e..cf28dc044eb 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-2022 +# 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 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 b77566e9d2d..cadbff305e8 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-2022 +# 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 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 642181deb7d..1e6e418acf0 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-2022 +# 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 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 b24f7e1bd72..8c870538c99 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-2022 +# 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 @@ -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 24c8beeba34..b40233168e9 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-2022 +# 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 @@ -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 4eedf7bdeb9..04fd3d461be 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-2022 +# 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 @@ -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 be013b62309..559bdd36329 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-2022 +# 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 @@ -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 8d00a99d49d..a1d2fb624d1 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-2022 +# 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 @@ -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 2892b759a65..969a171261b 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-2022 +# 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 @@ -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 bb4609be7c9..05f7f20a6d1 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-2022 +# 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 @@ -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 140d69dcb1a..4516965c5e5 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-2022 +# 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 @@ -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 3c587d694e1..56452e0cd19 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-2022 +# 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 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/piecewise_var.py b/pyomo/solvers/tests/piecewise_linear/problems/piecewise_var.py index 5b18842f81d..60c45a69e80 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-2022 +# 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 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/piecewise_vararray.py b/pyomo/solvers/tests/piecewise_linear/problems/piecewise_vararray.py index d35c308e172..9e53edb0c93 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-2022 +# 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 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/step_var.py b/pyomo/solvers/tests/piecewise_linear/problems/step_var.py index a0c1062c9d6..59cefdd39c9 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-2022 +# 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 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/step_vararray.py b/pyomo/solvers/tests/piecewise_linear/problems/step_vararray.py index 749df3b6d7f..e4853e666d6 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-2022 +# 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 diff --git a/pyomo/solvers/tests/piecewise_linear/problems/tester.py b/pyomo/solvers/tests/piecewise_linear/problems/tester.py index 02e04f5052e..56261f7cc38 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-2022 +# 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 diff --git a/pyomo/solvers/tests/piecewise_linear/test_examples.py b/pyomo/solvers/tests/piecewise_linear/test_examples.py index b151ffd2c0e..3454f62d56b 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-2022 +# 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 diff --git a/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear.py b/pyomo/solvers/tests/piecewise_linear/test_piecewise_linear.py index bfa206a987b..48472c2dabf 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-2022 +# 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 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 4137d9d3eed..20addb2b1eb 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-2022 +# 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 diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index 6bbfe08c7c7..918a801ae37 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-2022 +# 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 @@ -9,8 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -__all__ = ['test_solver_cases'] - import logging from pyomo.common.collections import Bunch diff --git a/pyomo/solvers/tests/testcases.py b/pyomo/solvers/tests/testcases.py index f5920ed6814..f0ea27136b6 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-2022 +# 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 @@ -355,7 +355,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 +381,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 +431,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 3b083f7a14f..ee167ce1cb0 100644 --- a/pyomo/solvers/wrappers.py +++ b/pyomo/solvers/wrappers.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/util/__init__.py b/pyomo/util/__init__.py index e69de29bb2d..a4a626013c4 100644 --- a/pyomo/util/__init__.py +++ b/pyomo/util/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/util/blockutil.py b/pyomo/util/blockutil.py index 52befea6ed5..9f043e64ab7 100644 --- a/pyomo/util/blockutil.py +++ b/pyomo/util/blockutil.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -12,8 +12,6 @@ # the purpose of this file is to collect all utility methods that compute # attributes of blocks, based on their contents. -__all__ = ['has_discrete_variables'] - import logging from pyomo.core import Var, Constraint, TraversalStrategy diff --git a/pyomo/util/calc_var_value.py b/pyomo/util/calc_var_value.py index 42d38f2f874..42ee3119361 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-2022 +# 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 @@ -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 diff --git a/pyomo/util/check_units.py b/pyomo/util/check_units.py index be72493af3f..b27f701c61a 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-2022 +# 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 @@ -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 02ef8a30f64..ffd68aad296 100644 --- a/pyomo/util/components.py +++ b/pyomo/util/components.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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..86a38bea37b --- /dev/null +++ b/pyomo/util/config_domains.py @@ -0,0 +1,72 @@ +# ___________________________________________________________________________ +# +# 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 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 d4b7974b9da..709a483f2ff 100644 --- a/pyomo/util/diagnostics.py +++ b/pyomo/util/diagnostics.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # -*- coding: UTF-8 -*- """Module with miscellaneous diagnostic tools""" from pyomo.core.base.block import TraversalStrategy, Block diff --git a/pyomo/util/infeasible.py b/pyomo/util/infeasible.py index 9c8196d1ff4..6a90a4c3773 100644 --- a/pyomo/util/infeasible.py +++ b/pyomo/util/infeasible.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 9575e327a74..1fdac357368 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-2022 +# 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 diff --git a/pyomo/util/report_scaling.py b/pyomo/util/report_scaling.py index 5b4a4df7c84..02b3710c334 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-2022 +# 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 @@ -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 0449acb3f2f..b3da3b4be2a 100644 --- a/pyomo/util/slices.py +++ b/pyomo/util/slices.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 673781def17..12f28e2b1b7 100644 --- a/pyomo/util/subsystems.py +++ b/pyomo/util/subsystems.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 e69de29bb2d..a4a626013c4 100644 --- a/pyomo/util/tests/__init__.py +++ b/pyomo/util/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/util/tests/test_blockutil.py b/pyomo/util/tests/test_blockutil.py index 06b75bd6b68..dfe4f482fb2 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-2022 +# 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 diff --git a/pyomo/util/tests/test_calc_var_value.py b/pyomo/util/tests/test_calc_var_value.py index 91f23dd5a5d..4bed4d5c843 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-2022 +# 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 @@ -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() diff --git a/pyomo/util/tests/test_check_units.py b/pyomo/util/tests/test_check_units.py index d2fb35c4f3b..9cde8d8dbae 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-2022 +# 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 diff --git a/pyomo/util/tests/test_components.py b/pyomo/util/tests/test_components.py index 92eb7dd5ef1..1027815ca6b 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-2022 +# 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 diff --git a/pyomo/util/tests/test_config_domains.py b/pyomo/util/tests/test_config_domains.py new file mode 100644 index 00000000000..9955950fcf5 --- /dev/null +++ b/pyomo/util/tests/test_config_domains.py @@ -0,0 +1,99 @@ +# ___________________________________________________________________________ +# +# 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.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 cefc129b41e..687a578e5c8 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-2022 +# 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 diff --git a/pyomo/util/tests/test_model_size.py b/pyomo/util/tests/test_model_size.py index 417ff7526e8..2380d272a24 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-2022 +# 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 diff --git a/pyomo/util/tests/test_report_scaling.py b/pyomo/util/tests/test_report_scaling.py index b010065d697..2eaed2d0ade 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-2022 +# 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 diff --git a/pyomo/util/tests/test_slices.py b/pyomo/util/tests/test_slices.py index db66a74b468..992bdc0a332 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-2022 +# 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 diff --git a/pyomo/util/tests/test_subsystems.py b/pyomo/util/tests/test_subsystems.py index a081b51cee9..089888bd6a9 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-2022 +# 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 @@ -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 8866ba980bd..c5dcd0ef0fd 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-2022 +# 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 @@ -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 08bcde304a6..acc92ff6b37 100644 --- a/pyomo/version/__init__.py +++ b/pyomo/version/__init__.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/pyomo/version/info.py b/pyomo/version/info.py index cedb30c2dd4..ee37183cd4a 100644 --- a/pyomo/version/info.py +++ b/pyomo/version/info.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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 = 0 releaselevel = 'invalid' # releaselevel = 'final' serial = 0 diff --git a/pyomo/version/tests/__init__.py b/pyomo/version/tests/__init__.py index 9fb4f531a5b..f013ccd3fa3 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-2022 +# 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 diff --git a/pyomo/version/tests/check.py b/pyomo/version/tests/check.py index ab3b45ffc6c..0fca9badb2f 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-2022 +# 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 diff --git a/pyomo/version/tests/test_version.py b/pyomo/version/tests/test_version.py index 253ee53137c..3b39bd71cb1 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-2022 +# 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 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..5a7b047d79a --- /dev/null +++ b/scripts/admin/bwom.py @@ -0,0 +1,134 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + + +""" +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 fe5d483f16d..ffc02059d6f 100644 --- a/scripts/admin/contributors.py +++ b/scripts/admin/contributors.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/scripts/get_pyomo.py b/scripts/get_pyomo.py index a97c0ba3a00..d90773f2315 100644 --- a/scripts/get_pyomo.py +++ b/scripts/get_pyomo.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/scripts/get_pyomo_extras.py b/scripts/get_pyomo_extras.py index d2aa097154a..6688f3c6dc4 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-2022 +# 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 diff --git a/scripts/performance/compare.py b/scripts/performance/compare.py index 5edef9bfadd..e62440fd6d9 100755 --- a/scripts/performance/compare.py +++ b/scripts/performance/compare.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/scripts/performance/compare_components.py b/scripts/performance/compare_components.py index f390fad8454..764b50217ef 100644 --- a/scripts/performance/compare_components.py +++ b/scripts/performance/compare_components.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # This script compares build time and memory usage for # various modeling objects. The output is organized into diff --git a/scripts/performance/expr_perf.py b/scripts/performance/expr_perf.py index 6566431b9f3..9abdd560887 100644 --- a/scripts/performance/expr_perf.py +++ b/scripts/performance/expr_perf.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ + # # This script runs performance tests on expressions # diff --git a/scripts/performance/main.py b/scripts/performance/main.py index 10349c0eb73..07dc38a11a7 100755 --- a/scripts/performance/main.py +++ b/scripts/performance/main.py @@ -2,7 +2,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 diff --git a/scripts/performance/simple.py b/scripts/performance/simple.py index 2990f13f413..c5fb836b64b 100644 --- a/scripts/performance/simple.py +++ b/scripts/performance/simple.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# 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 * import pyomo.core.expr.current as EXPR import timeit 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 e2d702db010..6f508c590db 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2022 +# 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 @@ -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. @@ -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,20 +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', '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 @@ -303,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="""