diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0e91db..89ad45b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ GAMSPy CHANGELOG ================ +GAMSPy 1.5.0 +------------ +- General + - Fix implicit parameter validation bug. + - Allow the usage of Container as a context manager. + - Allow propagating bounds to the output variable in `flatten_dims` method. + - Add piecewise linear function formulations. + - Migrate GAMSPy CLI to Typer. + - Threads can now create a container since we register the signal only to the main thread. + - Fix solver options bug in frozen solve. + - Synchronize after read. + - Upgrade gamspy_base and gamsapi dependencies. + - Add `--checkout-duration` and `--renew` options to `gamspy install license`. +- Testing + - Lower the number of dices in the interrupt test and put a time limit to the solve. + - Add tests for piecewise linear functions. +- Documentation + - Install dependencies in the first cell of the example transportation notebook. + - Add Formulations page to list piecewise linear functions and nn formulations. + GAMSPy 1.4.0 ------------ - General @@ -17,6 +37,7 @@ GAMSPy 1.4.0 - Remove non-negative variable type from the docs. - Add plausible.js for analytics. - Minor update in embedding nn documentation. + - Add descriptions and example code to formulations documentation. GAMSPy 1.3.1 diff --git a/docs/_static/switcher.json b/docs/_static/switcher.json index 0dc3a70a..01a76fc4 100644 --- a/docs/_static/switcher.json +++ b/docs/_static/switcher.json @@ -1,97 +1 @@ -[ - { - "name": "latest", - "version": "latest", - "url": "https://gamspy.readthedocs.io/en/latest/" - }, - { - "name": "1.4.0 (stable)", - "version": "v1.4.0", - "url": "https://gamspy.readthedocs.io/en/v1.4.0/" - }, - { - "name": "1.3.1", - "version": "v1.3.1", - "url": "https://gamspy.readthedocs.io/en/v1.3.1/" - }, - { - "name": "1.3.0", - "version": "v1.3.0", - "url": "https://gamspy.readthedocs.io/en/v1.3.0/" - }, - { - "name": "1.2.0", - "version": "v1.2.0", - "url": "https://gamspy.readthedocs.io/en/v1.2.0/" - }, - { - "name": "1.1.0", - "version": "v1.1.0", - "url": "https://gamspy.readthedocs.io/en/v1.1.0/" - }, - { - "name": "1.0.4", - "version": "v1.0.4", - "url": "https://gamspy.readthedocs.io/en/v1.0.4/" - }, - { - "name": "1.0.3", - "version": "v1.0.3", - "url": "https://gamspy.readthedocs.io/en/v1.0.3/" - }, - { - "name": "1.0.2", - "version": "v1.0.2", - "url": "https://gamspy.readthedocs.io/en/v1.0.2/" - }, - { - "name": "1.0.1", - "version": "v1.0.1", - "url": "https://gamspy.readthedocs.io/en/v1.0.1/" - }, - { - "name": "1.0.0", - "version": "v1.0.0", - "url": "https://gamspy.readthedocs.io/en/v1.0.0/" - }, - { - "name": "0.14.7", - "version": "v0.14.7", - "url": "https://gamspy.readthedocs.io/en/v0.14.7/" - }, - { - "name": "0.14.6", - "version": "v0.14.6", - "url": "https://gamspy.readthedocs.io/en/v0.14.6/" - }, - { - "name": "0.14.5", - "version": "v0.14.5", - "url": "https://gamspy.readthedocs.io/en/v0.14.5/" - }, - { - "name": "0.14.4", - "version": "v0.14.4", - "url": "https://gamspy.readthedocs.io/en/v0.14.4/" - }, - { - "name": "0.14.3", - "version": "v0.14.3", - "url": "https://gamspy.readthedocs.io/en/v0.14.3/" - }, - { - "name": "0.14.2", - "version": "v0.14.2", - "url": "https://gamspy.readthedocs.io/en/v0.14.2/" - }, - { - "name": "0.14.1", - "version": "v0.14.1", - "url": "https://gamspy.readthedocs.io/en/v0.14.1/" - }, - { - "name": "0.14.0", - "version": "v0.14.0", - "url": "https://gamspy.readthedocs.io/en/v0.14.0/" - } -] \ No newline at end of file +[{"name": "latest", "version": "latest", "url": "https://gamspy.readthedocs.io/en/latest/"}, {"name": "1.5.0 (stable)", "version": "v1.5.0", "url": "https://gamspy.readthedocs.io/en/v1.5.0/"}, {"name": "1.4.0", "version": "v1.4.0", "url": "https://gamspy.readthedocs.io/en/v1.4.0/"}, {"name": "1.3.1", "version": "v1.3.1", "url": "https://gamspy.readthedocs.io/en/v1.3.1/"}, {"name": "1.3.0", "version": "v1.3.0", "url": "https://gamspy.readthedocs.io/en/v1.3.0/"}, {"name": "1.2.0", "version": "v1.2.0", "url": "https://gamspy.readthedocs.io/en/v1.2.0/"}, {"name": "1.1.0", "version": "v1.1.0", "url": "https://gamspy.readthedocs.io/en/v1.1.0/"}, {"name": "1.0.4", "version": "v1.0.4", "url": "https://gamspy.readthedocs.io/en/v1.0.4/"}, {"name": "1.0.3", "version": "v1.0.3", "url": "https://gamspy.readthedocs.io/en/v1.0.3/"}, {"name": "1.0.2", "version": "v1.0.2", "url": "https://gamspy.readthedocs.io/en/v1.0.2/"}, {"name": "1.0.1", "version": "v1.0.1", "url": "https://gamspy.readthedocs.io/en/v1.0.1/"}, {"name": "1.0.0", "version": "v1.0.0", "url": "https://gamspy.readthedocs.io/en/v1.0.0/"}, {"name": "0.14.7", "version": "v0.14.7", "url": "https://gamspy.readthedocs.io/en/v0.14.7/"}, {"name": "0.14.6", "version": "v0.14.6", "url": "https://gamspy.readthedocs.io/en/v0.14.6/"}, {"name": "0.14.5", "version": "v0.14.5", "url": "https://gamspy.readthedocs.io/en/v0.14.5/"}, {"name": "0.14.4", "version": "v0.14.4", "url": "https://gamspy.readthedocs.io/en/v0.14.4/"}, {"name": "0.14.3", "version": "v0.14.3", "url": "https://gamspy.readthedocs.io/en/v0.14.3/"}, {"name": "0.14.2", "version": "v0.14.2", "url": "https://gamspy.readthedocs.io/en/v0.14.2/"}, {"name": "0.14.1", "version": "v0.14.1", "url": "https://gamspy.readthedocs.io/en/v0.14.1/"}, {"name": "0.14.0", "version": "v0.14.0", "url": "https://gamspy.readthedocs.io/en/v0.14.0/"}] \ No newline at end of file diff --git a/docs/cli/index.rst b/docs/cli/index.rst index 4df326d4..5ea058c2 100644 --- a/docs/cli/index.rst +++ b/docs/cli/index.rst @@ -6,7 +6,8 @@ gamspy ====== GAMSPy comes with a command-line interface (CLI) to allow users to -easily install solvers, licenses and much more. +easily install solvers, licenses and much more. Autocompletion can be +installed for the current shell with `--install-completion`. .. list-table:: :widths: 20 20 20 40 @@ -24,84 +25,44 @@ easily install solvers, licenses and much more. - -v - - Shows the version of GAMSPy, GAMS and gamspy_base + * - -\-install-completion + - + - + - Install completion for the current shell. + * - -\-show-completion + - + - + - Show completion for the current shell, to copy it or customize the installation. + +Example +------- -Example: :: +Show help message:: $ gamspy --help - usage: gamspy [-h] [-v] - gamspy install license or [--uses-port ] - gamspy uninstall license - gamspy install solver [--skip-pip-install] [--existing-solvers] [--install-all-solvers] - gamspy uninstall solver [--skip-pip-uninstall] [--uninstall-all-solvers] - gamspy list solvers [--all] - gamspy show license - gamspy show base - gamspy probe [-j ] - gamspy retrieve license [-i ] [-o ] - gamspy run miro [--path ] [--model ] + Usage: gamspy [OPTIONS] COMMAND [ARGS]... GAMSPy CLI - options: - -h, --help show this help message and exit - -v, --version Shows the version of GAMSPy, GAMS and gamspy_base - - gamspy install license or : - Options for installing a license. - - --uses-port USES_PORT - Interprocess communication starting port. - - gamspy uninstall license: - Command to uninstall user license. - - gamspy install solver : - Options for installing solvers - - --skip-pip-install, -s - If you already have the solver installed, skip pip install and update gamspy installed solver list. - - gamspy uninstall solver : - Options for uninstalling solvers - - --skip-pip-uninstall, -u - If you don't want to uninstall the package of the solver, skip uninstall and update gamspy installed solver list. - - gamspy list solvers: - `gamspy list solvers` options - - -a, --all Shows all available solvers. - - gamspy probe: - `gamspy probe` options - - --json-out JSON_OUT, -j JSON_OUT - Output path for the json file. - - gamspy retrieve license : - `gamspy retrieve license` options - - --output OUTPUT, -o OUTPUT - Output path for the license file. - --input INPUT, -i INPUT - json file path to retrieve a license based on node information. - - gamspy run miro: - `gamspy run miro` options + Options: + -h, --help Show this message and exit. + -v, --version Shows the version of GAMSPy, GAMS and gamspy_base - -g MODEL, --model MODEL - Path to the gamspy model - -m {config,base,deploy}, --mode {config,base,deploy} - Execution mode of MIRO - -p PATH, --path PATH Path to the MIRO executable (.exe on Windows, .app on macOS or .AppImage on Linux) - --skip-execution Whether to skip model execution + Commands: + install To install licenses and solvers. + list To list solvers. + probe To probe node information for license retrieval. + retrieve To retrieve a license with another node's information. + run To run your model with GAMS MIRO. + show To show your license and gamspy_base directory. + uninstall To uninstall licenses and solvers. -:: +Show version information:: $ gamspy --version - GAMSPy version: 0.14.6 - GAMS version: 47.4.1 - gamspy_base version: 47.4.1 + GAMSPy version: 1.4.0 + GAMS version: 48.5.0 + gamspy_base version: 48.5.0 List of Commands ---------------- diff --git a/docs/cli/install.rst b/docs/cli/install.rst index 070444c7..23fc7fab 100644 --- a/docs/cli/install.rst +++ b/docs/cli/install.rst @@ -5,12 +5,17 @@ gamspy install Installs add-on solvers or a license to the GAMSPy installation. +Install License +------------- + +Installs a new license using either an access code or a license file. + Usage ------ +~~~~~ :: - gamspy install solver [OPTIONS] + gamspy install license | [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -20,33 +25,41 @@ Usage - Short - Default - Description - * - -\-skip-pip-install - - -s - - - - Skips the pip install command in case the package was manually installed. - * - -\-install-all-solvers - - - - - - Installs all add-on solvers. - * - -\-existing-solvers - - + * - -\-uses-port - - - Installs add-on solvers that were previously installed with an older version of gamspy. + - None + - Interprocess communication starting port. Only relevant for local licenses that restrict concurrent use of GAMSPy. + * - -\-renew + - -r + - None + - Specify a file path to a license file to extend a session. + * - -\-checkout-duration + - -c + - None + - Specify a duration in hours to checkout a session. -Example 1: :: +Examples +~~~~~~~~ - $ gamspy install solver mosek conopt xpress +Install using access code:: -Example 2: :: + $ gamspy install license 876e5812-1222-4aba-819d-e1e91b7e2f52 - $ gamspy install solver --install-all-solvers +Install using license file:: + + $ gamspy install license /home/joe/gamslice.txt + +Install Solver +------------- + +Installs one or more solvers to the GAMSPy installation. Usage ------ +~~~~~ :: - gamspy install license | [OPTIONS] + gamspy install solver [solver_name(s)] [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -56,16 +69,34 @@ Usage - Short - Default - Description - * - -\-uses-port - - -u - - 33333 - - Interprocess communication starting port. Only relevant for local licenses that restrict concurrent use of GAMSPy. + * - -\-skip-pip-install + - -s + - False + - If you already have the solver installed, skip pip install and update gamspy installed solver list. + * - -\-install-all-solvers + - + - False + - Installs all available add-on solvers. + * - -\-existing-solvers + - + - False + - Reinstalls previously installed add-on solvers. +Examples +~~~~~~~~ -Example: :: +Install specific solvers:: - $ gamspy install license 876e5812-1222-4aba-819d-e1e91b7e2f52 + $ gamspy install solver mosek conopt xpress -:: +Install all available solvers:: - $ gamspy install license /home/joe/gamslice.txt + $ gamspy install solver --install-all-solvers + +Reinstall previously installed solvers:: + + $ gamspy install solver --existing-solvers + +Skip pip installation:: + + $ gamspy install solver mosek -s diff --git a/docs/cli/list.rst b/docs/cli/list.rst index 2c930180..20247dea 100644 --- a/docs/cli/list.rst +++ b/docs/cli/list.rst @@ -20,10 +20,17 @@ Usage - Description * - -\-all - -a - - + - False - Shows all available solvers that can be installed. + * - -\-defaults + - -d + - False + - Shows default solvers for each problem type. -Example: :: +Examples +-------- + +List installed solvers:: $ gamspy list solvers Installed Solvers @@ -31,36 +38,66 @@ Example: :: CONOPT, CONVERT, CPLEX, IPOPT, IPOPTH, KESTREL, NLPEC, PATH, SHOT Model types that can be solved with the installed solvers - ========================================================= - CONOPT : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - CONVERT : LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP, RMIQCP, EMP - CPLEX : LP, MIP, RMIP, QCP, MIQCP, RMIQCP - IPOPT : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - IPOPTH : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - KESTREL : LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP, RMIQCP, EMP - NLPEC : MCP, MPEC, RMPEC - PATH : MCP, CNS - SHOT : MINLP, MIQCP + ======================================================= + ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ Solver ┃ Problem Types ┃ + ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ + │ CONOPT │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ CONVERT │ LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP │ + │ CPLEX │ LP, MIP, RMIP, QCP, MIQCP, RMIQCP │ + │ IPOPT │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ IPOPTH │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ KESTREL │ LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP │ + │ NLPEC │ MCP, MPEC, RMPEC │ + │ PATH │ MCP, CNS │ + │ SHOT │ MINLP, MIQCP │ + └─────────┴────────────────────────────────────────────────────────────────────────────┘ -:: +List all available solvers:: - $ gamspy list solvers -a + $ gamspy list solvers --all Available Solvers ================= BARON, CBC, CONOPT, CONOPT3, CONVERT, COPT, CPLEX, DICOPT, EXAMINER, EXAMINER2, GUROBI, HIGHS, IPOPT, IPOPTH, KESTREL, KNITRO, MILES, MINOS, MOSEK, MPSGE, NLPEC, PATH, PATHNLP, SBB, SCIP, SHOT, SNOPT, SOPLEX, XPRESS - Model types that can be solved with the installed solvers: + Model types that can be solved with the installed solvers + ======================================================= + ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ Solver ┃ Problem Types ┃ + ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ + │ CONOPT │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ CONVERT │ LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP│ + │ CPLEX │ LP, MIP, RMIP, QCP, MIQCP, RMIQCP │ + │ IPOPT │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ IPOPTH │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ KESTREL │ LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP│ + │ NLPEC │ MCP, MPEC, RMPEC │ + │ PATH │ MCP, CNS │ + │ SHOT │ MINLP, MIQCP │ + └─────────┴───────────────────────────────────────────────────────────────────────────┘ + +List default solvers for each problem type:: - CONOPT : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - CONVERT : LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP, RMIQCP, EMP - CPLEX : LP, MIP, RMIP, QCP, MIQCP, RMIQCP - IPOPT : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - IPOPTH : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - KESTREL : LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP, RMIQCP, EMP - NLPEC : MCP, MPEC, RMPEC - PATH : MCP, CNS - SHOT : MINLP, MIQCP + $ gamspy list solvers --defaults + ┏━━━━━━━━━┳━━━━━━━━┓ + ┃ Problem ┃ Solver ┃ + ┡━━━━━━━━━╇━━━━━━━━┩ + │ LP │ CPLEX │ + │ MIP │ CPLEX │ + │ RMIP │ CPLEX │ + │ NLP │ CONOPT │ + │ MCP │ PATH │ + │ MPEC │ NLPEC │ + │ CNS │ PATH │ + │ DNLP │ CONOPT │ + │ RMINLP │ CONOPT │ + │ MINLP │ SHOT │ + │ QCP │ CPLEX │ + │ MIQCP │ CPLEX │ + │ RMIQCP │ CPLEX │ + └─────────┴────────┘ .. note:: - The possible model types for a solver become available after the solver has been installed. \ No newline at end of file + The possible model types for a solver become available after the solver has been installed. + For a complete list of solvers and their capabilities, visit: https://www.gams.com/latest/docs/S_MAIN.html#SOLVERS_MODEL_TYPES \ No newline at end of file diff --git a/docs/cli/probe.rst b/docs/cli/probe.rst index e58e7a48..dc7c00c1 100644 --- a/docs/cli/probe.rst +++ b/docs/cli/probe.rst @@ -4,11 +4,11 @@ gamspy probe Probes the node (computer) to get information about the node for fingerprinting the license. Usage ------ +~~~~~ :: - gamspy probe -j info.json + gamspy probe [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -20,12 +20,15 @@ Usage - Description * - -\-json-out - -j - - + - None - Output path to dump probed information. -Example: :: +Example +~~~~~~~ - $ gamspy probe -o info.json +:: + + $ gamspy probe -j info.json { "cpu_id": "27197016915918185882701231384169", "device_id": "18113801", @@ -45,4 +48,4 @@ Example: :: } .. note:: - The probed information is always written to standard output. The ``-o`` option will write a file in addition. \ No newline at end of file + The probed information is always written to standard output. The ``-j`` option will write the information to a JSON file. \ No newline at end of file diff --git a/docs/cli/retrieve.rst b/docs/cli/retrieve.rst index 1f3deb44..56f051dc 100644 --- a/docs/cli/retrieve.rst +++ b/docs/cli/retrieve.rst @@ -1,16 +1,21 @@ .. _gamspy_retrieve: gamspy retrieve -=============== +============== -Retrieves the license based on given probed information. +Retrieves a license with another node's information. + +Retrieve License +-------------- + +Retrieves a license using an access code and node information from a JSON file. Usage ------ +~~~~~ :: - gamspy retrieve license [-i ] [-o ] + gamspy retrieve license [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -20,43 +25,25 @@ Usage - Short - Default - Description - * - -\-input + * - -\-input - -i - - - - Input path to the file with probed node information, potentially from a different node not connected to the internet/ - * - -\-output + - None + - Input JSON file path to retrieve the license based on the node information. + * - -\-output - -o - - standard output - - Output path to write the license file. + - None + - Output path for the license file. -Example: :: +Examples +~~~~~~~~ - $ gamspy retrieve license 876e5812-1222-4aba-819d-e1e91b7e2f52 - Joe____________________________________________G240827+0003Ac-GEN - joe@my.mail.com__________________________________________________ - 07CPMK___________________________________________________________ - 0COCOC___________________________________________________________ - CLA100251_876e5812-1222-4aba-819d-e1e91b7e2f52_O_FREEACADEMIC____ - node:18113801____________________________________________________ - MEYCIQDXZ42fd7G8MCppt6NXluallrcGdSiZRqFg9gbPxYBq1QIhAIZ7SvetdxRGj - U0Piwc6zVAc0d/2pjm3iM70/mWToOSl__________________________________ +Retrieve a license with node information:: -:: + $ gamspy retrieve license 876e5812-1222-4aba-819d-e1e91b7e2f52 --input node_info.json + +Retrieve and save the license to a file:: - $ gamspy retrieve license 876e5812-1222-4aba-819d-e1e91b7e2f52 -i info.json -o gamslice.txt - Joe____________________________________________G240827+0003Ac-GEN - joe@my.mail.com__________________________________________________ - 07CPMK___________________________________________________________ - 0COCOC___________________________________________________________ - CLA100251_876e5812-1222-4aba-819d-e1e91b7e2f52_O_FREEACADEMIC____ - node:18113801____________________________________________________ - MEYCIQDXZ42fd7G8MCppt6NXluallrcGdSiZRqFg9gbPxYBq1QIhAIZ7SvetdxRGj - U0Piwc6zVAc0d/2pjm3iM70/mWToOSl__________________________________ + $ gamspy retrieve license 876e5812-1222-4aba-819d-e1e91b7e2f52 --input node_info.json --output license.txt .. note:: - The CLI tool ``gamspy retrieve license`` works together with ``gamspy probe`` and ``gamspy install license``. It's main purpose is to get a license - for a node (or machine or computer) that is not connected to the internet and not capable of reaching ``license.gams.com`` to retrieve the - license itself. In this case one runs ``gamspy probe -o info.json`` on the machine not connected to the internet, let's call this machine A. - Now, we bring the file ``info.json`` to a machine connected to the internet, let's call this machine B. On machine B, one runs now - ``gamspy retrieve license -i info.json -o gamslice.A``. Now we bring the file ``gamslice.A`` to machine A and run on machine A - ``gams install license /path/to/gamslice.A``. \ No newline at end of file + The input JSON file should contain the node information required for license retrieval. \ No newline at end of file diff --git a/docs/cli/run.rst b/docs/cli/run.rst index bcacb41a..0814e334 100644 --- a/docs/cli/run.rst +++ b/docs/cli/run.rst @@ -1,15 +1,19 @@ gamspy run -========== +========= -Runs the GAMS MIRO application. +Runs GAMSPy models with GAMS MIRO. + +Run with MIRO +------------ + +Runs a GAMSPy model with GAMS MIRO application. Usage ------ +~~~~~ :: - gamspy run miro [OPTIONS] - + gamspy run miro [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -19,23 +23,38 @@ Usage - Short - Default - Description - * - -\-path - - -p - - - - Path to your GAMS MIRO installation. - * - -\-model - - -g - - - - Path to your model. - * - -\-mode - - -m + * - -\-model + - -g + - None + - Path to the GAMSPy model. + * - -\-mode + - -m - base - - Execution mode of MIRO + - Execution mode of MIRO (config, base, or deploy). + * - -\-path + - -p + - None + - Path to the MIRO executable (.exe on Windows, .app on macOS or .AppImage on Linux). * - -\-skip-execution - - - - - - Whether to skip model execution + - + - False + - Whether to skip model execution. + +Examples +~~~~~~~~ + +Run a model with MIRO:: + + $ gamspy run miro --model transport.py + +Run a model with MIRO in configuration mode:: + + $ gamspy run miro --model transport.py --mode config + +Run a model with MIRO using a specific MIRO executable:: + + $ gamspy run miro --model transport.py --path /path/to/miro.exe -Example: :: +Run a model with MIRO skipping model execution:: - $ gamspy run miro -m config -p "/Applications/GAMS MIRO.app/Contents/MacOS/GAMS MIRO" -g ~/miro_apps/myapp.py \ No newline at end of file + $ gamspy run miro --model transport.py --skip-execution \ No newline at end of file diff --git a/docs/cli/show.rst b/docs/cli/show.rst index ce6962b0..7fd3964b 100644 --- a/docs/cli/show.rst +++ b/docs/cli/show.rst @@ -1,29 +1,42 @@ gamspy show -=========== +========== -Shows the license file or gamspy_base directory. +Shows information about your GAMSPy installation. + +Show License +----------- + +Shows the content of the current license. Usage ------ +~~~~~ :: - gamspy show + gamspy show license -Example: :: +Example:: $ gamspy show license - License found at: /home/joe/venvs/gamspy/lib/python3.12/site-packages/gamspy_base/gamslice.txt - + License found at: /home/user/.gamspy/gamspy_license.txt + License Content =============== - GAMS_Demo,_for_EULA_and_demo_limitations_see___G240530/0001CB-GEN - https://www.gams.com/latest/docs/UG%5FLicense.html_______________ - 1496631900_______________________________________________________ - 0801332905_______________________________________________________ - DC0000_______g_1_______________________________C_Eval____________ + [License content will be displayed here] + +Show Base Directory +----------------- + +Shows the path of the gamspy_base installation directory. + +Usage +~~~~~ :: + gamspy show base + +Example:: + $ gamspy show base - /home/joe/venvs/gamspy/lib/python3.12/site-packages/gamspy_base + /home/user/miniconda3/envs/gamspy/lib/python3.9/site-packages/gamspy_base diff --git a/docs/cli/uninstall.rst b/docs/cli/uninstall.rst index 22b89607..a7f60f32 100644 --- a/docs/cli/uninstall.rst +++ b/docs/cli/uninstall.rst @@ -1,14 +1,35 @@ gamspy uninstall -================ +=============== -Uninstalls an existing solver or a license from the GAMSPy installation. +Uninstalls solvers or the current license from the GAMSPy installation. + +Uninstall License +--------------- + +Uninstalls the current license. + +Usage +~~~~~ + +:: + + gamspy uninstall license + +Example:: + + $ gamspy uninstall license + +Uninstall Solver +-------------- + +Uninstalls one or more solvers from the GAMSPy installation. Usage ------ +~~~~~ :: - gamspy uninstall solver [OPTIONS] + gamspy uninstall solver [solver_name(s)] [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -18,28 +39,27 @@ Usage - Short - Default - Description - * - -\-skip-pip-uninstall - - -u - - - - Skips the pip uninstall command in case the package was manually deleted. + * - -\-skip-pip-install + - -s + - False + - If you already have the solver uninstalled, skip pip uninstall and update gamspy installed solver list. * - -\-uninstall-all-solvers - - -u - + - False - Uninstalls all add-on solvers. -Example: :: +Examples +~~~~~~~~ - gamspy uninstall solver mosek +Uninstall specific solvers:: -.. note:: - Default solvers cannot be uninstalled. + $ gamspy uninstall solver mosek conopt -Usage ------ +Uninstall all add-on solvers:: -:: + $ gamspy uninstall solver --uninstall-all-solvers - gamspy uninstall license +Skip pip uninstallation:: -This uninstalls a previously installed license and reinstates the GAMSPy demo license that comes with the GAMSPy installation. + $ gamspy uninstall solver mosek -s diff --git a/docs/release/index.rst b/docs/release/index.rst index 91964578..7044b1ad 100644 --- a/docs/release/index.rst +++ b/docs/release/index.rst @@ -7,6 +7,7 @@ Release Notes .. toctree:: :maxdepth: 1 + release_1.5.0 release_1.4.0 release_1.3.1 release_1.3.0 diff --git a/docs/release/release_1.5.0.rst b/docs/release/release_1.5.0.rst new file mode 100644 index 00000000..487d3a76 --- /dev/null +++ b/docs/release/release_1.5.0.rst @@ -0,0 +1,27 @@ +GAMSPy 1.5.0 +------------ + +Release Date: 10.01.2025 + +- General + + - Fix implicit parameter validation bug. + - Allow the usage of Container as a context manager. + - Allow propagating bounds to the output variable in `flatten_dims` method. + - Add piecewise linear function formulations. + - Migrate GAMSPy CLI to Typer. + - Threads can now create a container since we register the signal only to the main thread. + - Fix solver options bug in frozen solve. + - Synchronize after read. + - Upgrade gamspy_base and gamsapi dependencies. + +- Testing + + - Lower the number of dices in the interrupt test and put a time limit to the solve. + - Add tests for piecewise linear functions. + +- Documentation + + - Install dependencies in the first cell of the example transportation notebook. + - Add Formulations page to list piecewise linear functions and nn formulations. + \ No newline at end of file diff --git a/docs/user/advanced/advanced.rst b/docs/user/advanced/advanced.rst index 862ec842..13b414d4 100644 --- a/docs/user/advanced/advanced.rst +++ b/docs/user/advanced/advanced.rst @@ -19,3 +19,4 @@ fundamental GAMSPy ideas and philosophy. ./model_instance ./external_equations ./extrinsic_functions + ./formulations diff --git a/docs/user/advanced/formulations.rst b/docs/user/advanced/formulations.rst new file mode 100644 index 00000000..5876e82a --- /dev/null +++ b/docs/user/advanced/formulations.rst @@ -0,0 +1,160 @@ +.. _formulations: + +************ +Formulations +************ + +.. meta:: + :description: GAMSPy User Guide + :keywords: User, Guide, GAMSPy, gamspy, GAMS, gams, mathematical modeling, sparsity, performance, piecewise, linear, function + +Formulations in GAMSPy provide an intuitive and user-friendly way to define +complex relations without delving deeply into the underlying mathematical +details. You can focus on what you want to achieve rather than how it is +implemented. For those interested in exploring the mechanics, GAMSPy also +provides full visibility into the underlying formulation. We've implemented a +variety of versatile formulations, including piecewise linear functions and +neural network construction blocks, empowering you to seamlessly integrate +advanced concepts into your optimization workflows. + + +Neural Network Related Formulations +----------------------------------- + +GAMSPy supports following neural network related formulations: + +- :meth:`Linear ` +- :meth:`Conv2d ` +- :meth:`MaxPool2d ` +- :meth:`MinPool2d ` +- :meth:`AvgPool2d ` +- :meth:`flatten_dims ` + +You can find more info at :ref:`nn-formulations`. + + +Piecewise Linear Functions +-------------------------- + +Piecewise linear functions are a cornerstone of practical optimization, as they +naturally arise in countless real-world scenarios. Whether modeling cost +structures, approximating nonlinear relationships, or defining breakpoints in +decision processes, their versatility and prevalence make them indispensable. +Recognizing this, we implemented robust support for piecewise linear +formulations in GAMSPy, enabling users to seamlessly incorporate these +essential tools into their models. + +We currently support following two formulations for implementing piecewise +linear functions: + +- :meth:`pwl_interval_formulation ` +- :meth:`pwl_convexity_formulation ` + + +To define a piecewise linear function, you need to specify x and y coordinates +of the breakpoints. Let's imagine we want to model the following function: + +.. image:: ../images/pwl.png + :width: 300 + :align: center + +With using either formulation, we can do as following: + + +.. tabs:: + .. tab:: Interval formulation + .. code-block:: python + + import gamspy as gp + + m = gp.Container() + x = gp.Variable(m) + y, eqs = gp.formulations.pwl_interval_formulation( + x, + x_points=[0, 1, 3, 3, 4], + y_points=[2, 1, 1, 2, 3], + ) + + .. tab:: Convexity formulation + .. code-block:: python + + import gamspy as gp + + m = gp.Container() + x = gp.Variable(m) + y, eqs = gp.formulations.pwl_convexity_formulation( + x, + x_points=[0, 1, 3, 3, 4], + x_points=[2, 1, 1, 2, 3], + ) + +**Discontinuities** + +In the `x_points`, point 3 is repeated twice. It is because +when you have discontinuities in your piecewise linear function you can +represent them by repeating the x coordinate with a new y value. + + +**Variable Bounds** + +By default, x is limited to be in the range you defined, in this case betwen 0 +and 4. If you want x to be not limited in the range you defined, you can set +`bound_left` and/or `bound_right` to `False`. When either is set to `False`, it is assumed +that the corresponding line segments are extended. However, to accomplish +that new `SOS1` and `binary` type variables are introduced. + +.. image:: ../images/pwl_unbounded.png + :width: 300 + :align: center + + +.. code-block:: python + + import gamspy as gp + + m = gp.Container() + x = gp.Variable(m) + y, eqs = gp.formulations.pwl_interval_formulation( + x, + [0, 1, 3, 3, 4], + [2, 1, 1, 2, 3], + bound_left=False, + bound_right=False, + ) + + +**Excluded Ranges** + +You can exclude certain ranges in your piecewise linear function to ensure +x value never gets a value within that range. Let's say we want to ensure +x does not get a value between 1.5 and 2. You can do it by inserting `None` +between x values that you like to exclude. + + +.. image:: ../images/pwl_excluded.png + :width: 300 + :align: center + + +.. code-block:: python + + import gamspy as gp + + m = gp.Container() + x = gp.Variable(m) + y, eqs = gp.formulations.pwl_interval_formulation( + x, + [0, 1, 1.5, None, 2, 3, 3, 4], + [2, 1, 1, None, 1, 1, 2, 3], + ) + + +**Comparison between the interval and the convexity formulation** + +For detailed implementation insights, refer to :meth:`pwl_interval_formulation +` and :meth:`pwl_convexity_formulation +`. Our empirical analysis +suggests that the interval formulation often results in faster solve times. +However, since the formulations are designed to be easily interchangeable, we +encourage you to experiment with both to determine which works best for your +use case. diff --git a/docs/user/basics/container.rst b/docs/user/basics/container.rst index 03ab1acc..16b4770b 100644 --- a/docs/user/basics/container.rst +++ b/docs/user/basics/container.rst @@ -77,6 +77,36 @@ Explicit symbols names are useful when interacting with parts of the module wher ``setRecords`` function. ``setRecords`` ensures that the GAMSPy state is synchronized with GAMS execution system. +A container can also be used as a context manager. When a container is used as a context manager, there +is no need to specify the container when creating symbols since the context manager container will automatically +be used as the container for the symbols. + +.. tabs:: + .. group-tab:: With context manager + .. code-block:: python + + import gamspy as gp + + with gp.Container() as m: + i = gp.Set() + a = gp.Alias(alias_with=i) + p = gp.Parameter() + v = gp.Variable() + e = gp.Equation() + + .. group-tab:: Without context manager + .. code-block:: python + + import gamspy as gp + + m = gp.Container() + + i = gp.Set(m) + a = gp.Alias(m, alias_with=i) + p = gp.Parameter(m) + v = gp.Variable(m) + e = gp.Equation(m) + =========================== Reading and Writing Symbols =========================== diff --git a/docs/user/faq.rst b/docs/user/faq.rst index d74f81e5..76f147d8 100644 --- a/docs/user/faq.rst +++ b/docs/user/faq.rst @@ -278,4 +278,4 @@ which acts like a regular commandline script. This means that it cannot be signe sometimes thinks that it is probably a malware. Because of this issue, when you run commands such as `gamspy install license `, Windows Defender blocks the executable. A workaround is to run `python -m gamspy install license `. Another way is to whitelist ``gamspy.exe`` executable on your machine. Since GAMSPy is open source, to make sure about the safety of the executable, -one can check the following script which GAMSPy uses: `script `_. +one can check the following script which GAMSPy uses: `script `_. diff --git a/docs/user/images/pwl.png b/docs/user/images/pwl.png new file mode 100644 index 00000000..ab632de0 Binary files /dev/null and b/docs/user/images/pwl.png differ diff --git a/docs/user/images/pwl_excluded.png b/docs/user/images/pwl_excluded.png new file mode 100644 index 00000000..e16d63b5 Binary files /dev/null and b/docs/user/images/pwl_excluded.png differ diff --git a/docs/user/images/pwl_unbounded.png b/docs/user/images/pwl_unbounded.png new file mode 100644 index 00000000..4a5c36bd Binary files /dev/null and b/docs/user/images/pwl_unbounded.png differ diff --git a/docs/user/ml/formulations.rst b/docs/user/ml/formulations.rst index 6815ab07..5f4c26ce 100644 --- a/docs/user/ml/formulations.rst +++ b/docs/user/ml/formulations.rst @@ -18,13 +18,12 @@ Layer Formulations ================== GAMSPy provides several formulations to help you embed your neural network -structures into your into your optimization model. We started with formulations +structures into your optimization model. We started with formulations for computer vision-related structures such as convolution and pooling operations. -Convolution by definition requires no linearization, but it is tedious to write -down. Now you can use :meth:`Conv2d ` to easily -embed your convolutional layer into your optimization model. +Here is an example utilizing several different layers to easily +embed into your optimization model. .. code-block:: python @@ -71,13 +70,133 @@ embed your convolutional layer into your optimization model. Supported formulations: -- :meth:`Linear ` -- :meth:`Conv2d ` -- :meth:`MaxPool2d ` -- :meth:`MinPool2d ` -- :meth:`AvgPool2d ` -- :meth:`flatten_dims ` +:meth:`Linear ` +------------------------------------------------------- +Formulation generator for Linear layer in GAMS. It applies a linear mapping +with a transformation and bias to the input data, expressed as :math:`y = x A^T + b`. +.. code-block:: python + + import gamspy as gp + import numpy as np + from gamspy.math import dim + + m = gp.Container() + l1 = gp.formulations.Linear(m, 128, 64) + w = np.random.rand(64, 128) + b = np.random.rand(64) + l1.load_weights(w, b) + x = gp.Variable(m, "x", domain=dim([10, 128])) + y, set_y = l1(x) + + [d.name for d in y.domain] + # ['DenseDim10_1', 'DenseDim64_1'] + +:meth:`Conv2d ` +------------------------------------------------------- +Formulation generator for 2D Convolution symbol in GAMS. It applies a +2D convolution operation on an input signal consisting of multiple input planes. + +.. code-block:: python + + import gamspy as gp + import numpy as np + from gamspy.math import dim + + w1 = np.random.rand(2, 1, 3, 3) + b1 = np.random.rand(2) + m = gp.Container() + # in_channels=1, out_channels=2, kernel_size=3x3 + conv1 = gp.formulations.Conv2d(m, 1, 2, 3) + conv1.load_weights(w1, b1) + # 10 images, 1 channel, 24 by 24 + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = conv1(inp) + + type(out) + # + [len(x) for x in out.domain] + # [10, 2, 22, 22] + +:meth:`MaxPool2d ` +------------------------------------------------------- +Formulation generator for 2D Max Pooling in GAMS. It applies a 2D +max pooling on an input signal consisting of multiple input planes. + +.. code-block:: python + + import gamspy as gp + from gamspy.math import dim + + m = gp.Container() + # 2x2 max pooling + mp1 = gp.formulations.MaxPool2d(m, (2, 2)) + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = mp1(inp) + + type(out) + # + [len(x) for x in out.domain] + # [10, 1, 12, 12] + +:meth:`MinPool2d ` +------------------------------------------------------- +Formulation generator for 2D Min Pooling in GAMS. It applies a 2D +min pooling on an input signal consisting of multiple input planes. + +.. code-block:: python + + import gamspy as gp + from gamspy.math import dim + + m = gp.Container() + # 2x2 min pooling + mp1 = gp.formulations.MinPool2d(m, (2, 2)) + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = mp1(inp) + + type(out) + # + [len(x) for x in out.domain] + # [10, 1, 12, 12] + +:meth:`AvgPool2d ` +------------------------------------------------------- +Formulation generator for 2D Avg Pooling in GAMS. It applies a 2D +average pooling on an input signal consisting of multiple input planes. + +.. code-block:: python + + import gamspy as gp + from gamspy.math import dim + + m = gp.Container() + # 2x2 avg pooling + ap1 = gp.formulations.AvgPool2d(m, (2, 2)) + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = ap1(inp) + + type(out) + # + [len(x) for x in out.domain] + # [10, 1, 12, 12] + +:meth:`flatten_dims ` +------------------------------------------------------- +It combines the domains specified by dims into a single unified domain. + +.. code-block:: python + + import gamspy as gp + from gamspy.math import dim + m = gp.Container() + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = gp.formulations.flatten_dims(inp, [2, 3]) + + type(out) + # + [len(x) for x in out.domain] + # [10, 1, 576] .. _pooling-linearization: @@ -97,11 +216,11 @@ likely continuous, but there is no restriction. :math:`p` is the variable that i the output of the pooling operation on the blue region. Depending on the operation, it is either min or max of the corresponding input points. -| +| .. image:: ../images/pooling.png - :align: center - + :align: center | + The linearization of the :math:`p = \max(a,b,c,d)` is as follows: .. math:: @@ -151,17 +270,36 @@ integrating them into optimization models can be challenging. To assist you, we have started with a small list of commonly used activation functions. So far, we have implemented the following activation functions: -- :meth:`relu_with_binary_var ` -- :meth:`relu_with_complementarity_var ` -- :meth:`relu_with_sos1_var ` -- :meth:`softmax ` -- :meth:`log_softmax ` +:meth:`relu_with_binary_var ` +--------------------------------------------------------------- +Implements the ReLU activation function using binary variables. + +:meth:`relu_with_complementarity_var ` +--------------------------------------------------------------------------------- +Implements the ReLU activation function using complementarity conditions. + +:meth:`relu_with_sos1_var ` +----------------------------------------------------------- +Implements the ReLU activation function using `SOS1 `_ variables. +:meth:`softmax ` +------------------------------------- +Implements the softmax activation function. This function strictly +requires a GAMSPy Variable, y = softmax(x). + +:meth:`log_softmax ` +--------------------------------------------- +Implements the log_softmax activation function. This function strictly +requires a GAMSPy Variable, y = log_softmax(x). + + +Activation Functions Explanation +-------------------------------- Unlike other mathematical functions, these activation functions return a -variable instead of an expression. This is because ReLU cannot be represented -by a single expression. Directly writing ``y = max(x, 0)`` without reformulating -it would result in a Discontinuous Nonlinear Program (``DNLP``) model, which is -highly undesirable. Currently, you can either use +variable and a list of equations instead of an expression. This is because ReLU +cannot be representedby a single expression. Directly writing ``y = max(x, 0)`` +without reformulating it would result in a Discontinuous Nonlinear Program (``DNLP``) model, +which is highly undesirable. Currently, you can either use :meth:`relu_with_binary_var ` to introduce binary variables into your problem, or :meth:`relu_with_complementarity_var ` @@ -202,8 +340,13 @@ To read more about `classification of models Additionally, we offer our established functions that can also be used as activation functions: -- :meth:`tanh ` -- :meth:`sigmoid ` +:meth:`tanh ` +------------------------------- +It applies the Hyperbolic Tangent (Tanh) function element-wise. + +:meth:`sigmoid ` +------------------------------------- +It applies the Sigmoid function element-wise. These functions return expressions like the other math functions. So, you need to create equations and variables yourself. diff --git a/docs/user/ml/introduction.rst b/docs/user/ml/introduction.rst index 82a45c1c..851f01fe 100644 --- a/docs/user/ml/introduction.rst +++ b/docs/user/ml/introduction.rst @@ -30,7 +30,7 @@ is much easier than: .. code-block:: python - calc_mm_3[m, j] = z3[m, j] == Sum(k, a2[m, i] @ w2[i, j]) + calc_mm_3[m, j] = z3[m, j] == Sum(i, a2[m, i] @ w2[i, j]) In this context, ``m`` represents the batch dimension, ``i`` denotes the feature dimension of layer 2, and ``j`` represents the feature dimension of layer 3. diff --git a/docs/user/ml/nn.rst b/docs/user/ml/nn.rst index a2adc4b5..c49e36b1 100644 --- a/docs/user/ml/nn.rst +++ b/docs/user/ml/nn.rst @@ -8,7 +8,7 @@ Neural Network Example :keywords: Machine Learning, User, Guide, GAMSPy, gamspy, GAMS, gams, mathematical modeling, sparsity, performance Our goal in implementing ML-related features was to provide maximum flexibility. -Although GAMSPy’s primary purpose is not neural network training, we wanted to +Although GAMSPy’s primary purpose is not neural network training, we wanted to demonstrate the process for those who are curious or need it for research. Implementing a "Mini-Batch Gradient Descent" training process would be very time-consuming and result in a non-introductory example. Therefore, we @@ -18,8 +18,8 @@ stopping after one mini-batch. We will train a neural network to classify handwritten digits from MNIST dataset. For this example we will use a simple feed-forward network since it is easier to implement and demonstrate. Our neural network has flattened images -in the input layer, resulting in a 28x28 = 784 dimension. We will use a single -hidden layer with 20 neurons, and the output layer will have 10 neurons +in the input layer, resulting in a 28x28 = 784 dimension. We will use a single +hidden layer with 20 neurons, and the output layer will have 10 neurons corresponding to 10 digits. We start with the imports: @@ -442,11 +442,8 @@ However, guessing a bound is not trivial. We demonstrated the flexibility of GAMSPy by training a simple neural -network. If your primary goal is to train a neural network, using frameworks -like `PyTorch `_ or `TensorFlow `_ -would be easier and faster. However, for research purposes and curious users, -it is interesting to show how black-box solvers can handle neural network -training. +network. For research purposes and curious users, it is interesting to +see how black-box solvers can handle neural network training. Here are some points that can help with your research: diff --git a/docs/user/notebooks/trnsport_colab.ipynb b/docs/user/notebooks/trnsport_colab.ipynb index ac6f5feb..d0b1c4dd 100644 --- a/docs/user/notebooks/trnsport_colab.ipynb +++ b/docs/user/notebooks/trnsport_colab.ipynb @@ -56,6 +56,16 @@ "execution_count": 1, "metadata": {}, "outputs": [], + "source": [ + "# Install dependencies\n", + "! pip install -q gamspy" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], "source": [ "from gamspy import Container, Set, Parameter, Variable, Equation, Model, Sum, Sense, Options" ] @@ -71,7 +81,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -94,7 +104,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -103,7 +113,7 @@ "('i', 'j')" ] }, - "execution_count": 3, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -123,16 +133,16 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'sa1c133b2_7bce_4c25_b49b_283facdc969c'" + "'sa36353c4_2966_42cb_8b58_cca86e453ff8'" ] }, - "execution_count": 4, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -160,7 +170,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [], "source": [ @@ -195,7 +205,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -231,7 +241,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -261,7 +271,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "metadata": {}, "outputs": [], "source": [ @@ -277,7 +287,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ @@ -293,7 +303,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "metadata": {}, "outputs": [ { @@ -336,7 +346,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -363,7 +373,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -387,7 +397,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -424,7 +434,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 15, "metadata": {}, "outputs": [ { @@ -473,7 +483,7 @@ "1 san-diego " ] }, - "execution_count": 14, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -508,7 +518,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -532,8 +542,8 @@ " \n", " \n", " \n", - " i\n", - " j\n", + " from\n", + " to\n", " value\n", " \n", " \n", @@ -579,7 +589,7 @@ "" ], "text/plain": [ - " i j value\n", + " from to value\n", "0 seattle new-york 2.5\n", "1 seattle chicago 1.7\n", "2 seattle topeka 1.8\n", @@ -588,7 +598,7 @@ "5 san-diego topeka 1.4" ] }, - "execution_count": 15, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -637,7 +647,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 17, "metadata": {}, "outputs": [ { @@ -661,8 +671,8 @@ " \n", " \n", " \n", - " i\n", - " j\n", + " from\n", + " to\n", " value\n", " \n", " \n", @@ -708,7 +718,7 @@ "" ], "text/plain": [ - " i j value\n", + " from to value\n", "0 seattle new-york 0.225\n", "1 seattle chicago 0.153\n", "2 seattle topeka 0.162\n", @@ -717,7 +727,7 @@ "5 san-diego topeka 0.126" ] }, - "execution_count": 16, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -738,7 +748,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 18, "metadata": {}, "outputs": [ { @@ -818,7 +828,7 @@ "5 san-diego topeka 0.126" ] }, - "execution_count": 17, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -839,52 +849,53 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 19, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "--- Job _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms Start 08/28/24 17:42:46 47.4.1 4b675771 LEX-LEG x86 64bit/Linux\n", + "--- Job _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms Start 01/02/25 09:26:25 48.5.0 5f05ac2f LEX-LEG x86 64bit/Linux\n", "--- Applying:\n", - " /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/gmsprmun.txt\n", + " /home/muhammet/anaconda3/envs/py313/lib/python3.13/site-packages/gamspy_base/gmsprmun.txt\n", "--- GAMS Parameters defined\n", - " Input /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.gms\n", - " Output /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.lst\n", - " ScrDir /tmp/tmp_l67j_pp/tmp5owilr3v/\n", - " SysDir /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/\n", + " LP CPLEX\n", + " Input /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms\n", + " Output /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.lst\n", + " ScrDir /tmp/tmpdie1ewav/tmp7px2lpec/\n", + " SysDir /home/muhammet/anaconda3/envs/py313/lib/python3.13/site-packages/gamspy_base/\n", " LogOption 3\n", - " Trace /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.txt\n", - " License /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/user_license.txt\n", - " OptDir /tmp/tmp_l67j_pp/\n", + " Trace /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.txt\n", + " License /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", + " OptDir /tmp/tmpdie1ewav/\n", " LimRow 0\n", " LimCol 0\n", " TraceOpt 3\n", - " GDX /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980out.gdx\n", + " GDX /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfeout.gdx\n", " SolPrint 0\n", " PreviousWork 1\n", " gdxSymbols newOrChanged\n", "Licensee: GAMSPy Incremental Professional G240510+0003Cc-GEN\n", " GAMS DC0000\n", - " /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/user_license.txt\n", - " node:88412201 \n", - " Evaluation license: Not for commercial or production use\n", - " The evaluation period of the license will expire on May 14, 2029\n", + " /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", + " node:88412201 v:2 \n", + " Time-limited license with GAMSPy usage\n", + " The expiration date of time-limited license is May 14, 2029\n", "Processor information: 1 socket(s), 12 core(s), and 16 thread(s) available\n", "--- Starting compilation\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(67) 4 Mb\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(68) 4 Mb\n", "--- Starting execution: elapsed 0:00:00.001\n", "--- Generating LP model transport\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(129) 4 Mb\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(130) 4 Mb\n", "--- 6 rows 7 columns 19 non-zeroes\n", "--- Range statistics (absolute non-zero finite values)\n", "--- RHS [min, max] : [ 2.750E+02, 6.000E+02] - Zero values observed as well\n", "--- Bound [min, max] : [ NA, NA] - Zero values observed as well\n", "--- Matrix [min, max] : [ 1.260E-01, 1.000E+00]\n", - "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.002\n", + "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.003\n", "\n", - "IBM ILOG CPLEX 47.4.1 4b675771 Aug 13, 2024 LEG x86 64bit/Linux \n", + "IBM ILOG CPLEX 48.5.0 5f05ac2f Dec 20, 2024 LEG x86 64bit/Linux \n", "\n", "--- GAMS/CPLEX licensed for continuous and discrete problems.\n", "--- GMO setup time: 0.00s\n", @@ -918,11 +929,11 @@ "Objective: 153.675000\n", "\n", "--- Reading solution for model transport\n", - "--- Executing after solve: elapsed 0:00:00.015\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(191) 4 Mb\n", - "--- GDX File /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980out.gdx\n", + "--- Executing after solve: elapsed 0:00:00.040\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(192) 4 Mb\n", + "--- GDX File /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfeout.gdx\n", "*** Status: Normal completion\n", - "--- Job _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms Stop 08/28/24 17:42:46 elapsed 0:00:00.015\n" + "--- Job _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms Stop 01/02/25 09:26:25 elapsed 0:00:00.041\n" ] }, { @@ -966,7 +977,7 @@ " 7\n", " LP\n", " CPLEX\n", - " 0\n", + " 0.004\n", " \n", " \n", "\n", @@ -977,10 +988,10 @@ "0 Normal OptimalGlobal 153.675 6 7 \n", "\n", " Model Type Solver Solver Time \n", - "0 LP CPLEX 0 " + "0 LP CPLEX 0.004 " ] }, - "execution_count": 18, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } @@ -1008,53 +1019,54 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 20, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(191) 4 Mb\n", - "--- Job _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms Start 08/28/24 17:42:46 47.4.1 4b675771 LEX-LEG x86 64bit/Linux\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(192) 4 Mb\n", + "--- Job _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms Start 01/02/25 09:26:25 48.5.0 5f05ac2f LEX-LEG x86 64bit/Linux\n", "--- Applying:\n", - " /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/gmsprmun.txt\n", + " /home/muhammet/anaconda3/envs/py313/lib/python3.13/site-packages/gamspy_base/gmsprmun.txt\n", "--- GAMS Parameters defined\n", - " Input /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.gms\n", - " Output /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.lst\n", - " ScrDir /tmp/tmp_l67j_pp/tmp5owilr3v/\n", - " SysDir /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/\n", + " LP CPLEX\n", + " Input /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms\n", + " Output /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.lst\n", + " ScrDir /tmp/tmpdie1ewav/tmp7px2lpec/\n", + " SysDir /home/muhammet/anaconda3/envs/py313/lib/python3.13/site-packages/gamspy_base/\n", " LogOption 3\n", - " Trace /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.txt\n", - " License /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/user_license.txt\n", - " OptDir /tmp/tmp_l67j_pp/\n", + " Trace /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.txt\n", + " License /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", + " OptDir /tmp/tmpdie1ewav/\n", " LimRow 10\n", " LimCol 10\n", " TraceOpt 3\n", - " GDX /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980out.gdx\n", + " GDX /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfeout.gdx\n", " SolPrint 0\n", " PreviousWork 1\n", " gdxSymbols newOrChanged\n", "Licensee: GAMSPy Incremental Professional G240510+0003Cc-GEN\n", " GAMS DC0000\n", - " /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/user_license.txt\n", - " node:88412201 \n", - " Evaluation license: Not for commercial or production use\n", - " The evaluation period of the license will expire on May 14, 2029\n", + " /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", + " node:88412201 v:2 \n", + " Time-limited license with GAMSPy usage\n", + " The expiration date of time-limited license is May 14, 2029\n", "Processor information: 1 socket(s), 12 core(s), and 16 thread(s) available\n", "--- Starting compilation\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(67) 4 Mb\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(68) 4 Mb\n", "--- Starting execution: elapsed 0:00:00.001\n", "--- Generating LP model transport\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(196) 4 Mb\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(198) 4 Mb\n", "--- 6 rows 7 columns 19 non-zeroes\n", "--- Range statistics (absolute non-zero finite values)\n", "--- RHS [min, max] : [ 2.750E+02, 6.000E+02] - Zero values observed as well\n", "--- Bound [min, max] : [ NA, NA] - Zero values observed as well\n", "--- Matrix [min, max] : [ 1.260E-01, 1.000E+00]\n", - "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.001\n", + "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.002\n", "\n", - "IBM ILOG CPLEX 47.4.1 4b675771 Aug 13, 2024 LEG x86 64bit/Linux \n", + "IBM ILOG CPLEX 48.5.0 5f05ac2f Dec 20, 2024 LEG x86 64bit/Linux \n", "\n", "--- GAMS/CPLEX licensed for continuous and discrete problems.\n", "--- GMO setup time: 0.00s\n", @@ -1083,11 +1095,11 @@ "Objective: 153.675000\n", "\n", "--- Reading solution for model transport\n", - "--- Executing after solve: elapsed 0:00:00.013\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(258) 4 Mb\n", - "--- GDX File /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980out.gdx\n", + "--- Executing after solve: elapsed 0:00:00.016\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(260) 4 Mb\n", + "--- GDX File /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfeout.gdx\n", "*** Status: Normal completion\n", - "--- Job _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms Stop 08/28/24 17:42:46 elapsed 0:00:00.013\n" + "--- Job _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms Stop 01/02/25 09:26:25 elapsed 0:00:00.016\n" ] }, { @@ -1131,7 +1143,7 @@ " 7\n", " LP\n", " CPLEX\n", - " 0\n", + " 0.001\n", " \n", " \n", "\n", @@ -1142,10 +1154,10 @@ "0 Normal OptimalGlobal 153.675 6 7 \n", "\n", " Model Type Solver Solver Time \n", - "0 LP CPLEX 0 " + "0 LP CPLEX 0.001 " ] }, - "execution_count": 19, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -1163,7 +1175,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -1185,7 +1197,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 22, "metadata": {}, "outputs": [ { @@ -1248,7 +1260,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 23, "metadata": {}, "outputs": [ { @@ -1266,7 +1278,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 24, "metadata": {}, "outputs": [ { @@ -1327,7 +1339,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 25, "metadata": {}, "outputs": [ { @@ -1435,7 +1447,7 @@ "5 san-diego topeka 275.0 0.000 0.0 inf 1.0" ] }, - "execution_count": 24, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -1454,7 +1466,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 26, "metadata": {}, "outputs": [ { @@ -1463,7 +1475,7 @@ "153.675" ] }, - "execution_count": 25, + "execution_count": 26, "metadata": {}, "output_type": "execute_result" } @@ -1483,7 +1495,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 27, "metadata": {}, "outputs": [ { @@ -1515,7 +1527,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 28, "metadata": {}, "outputs": [ { @@ -1547,7 +1559,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.19" + "version": "3.13.0" } }, "nbformat": 4, diff --git a/pyproject.toml b/pyproject.toml index 80f7d7e6..7f9a3dc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gamspy" -version = "1.4.0" +version = "1.5.0" authors = [ { name = "GAMS Development Corporation", email = "support@gams.com" }, ] @@ -36,11 +36,12 @@ classifiers = [ "Operating System :: Microsoft :: Windows", ] dependencies = [ - "gamsapi[transfer,control] == 48.5.0", - "gamspy_base == 48.5.0", + "gamsapi[transfer,control] == 48.6.0", + "gamspy_base == 48.6.0", "pydantic >= 2.0", "certifi >= 2022.09.14", "urllib3 >= 2.0.7", + "typer >= 0.15.1", ] [project.urls] @@ -84,7 +85,7 @@ doc = [ ] [project.scripts] -gamspy = "gamspy._cli.cmdline:main" +gamspy = "gamspy._cli.cli:main" [tool.mypy] warn_unused_configs = true @@ -130,6 +131,7 @@ exclude = [ "node_modules", "site-packages", "venv", + "_cli", "_options.py" ] diff --git a/src/gamspy/__init__.py b/src/gamspy/__init__.py index 42b5ecfb..15be7123 100644 --- a/src/gamspy/__init__.py +++ b/src/gamspy/__init__.py @@ -36,6 +36,8 @@ from .version import __version__ +_ctx_managers: dict[tuple[int, int], Container] = dict() + __all__ = [ "Container", "Set", diff --git a/src/gamspy/__main__.py b/src/gamspy/__main__.py index f0cfdcd9..579ec619 100644 --- a/src/gamspy/__main__.py +++ b/src/gamspy/__main__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from gamspy._cli.cmdline import main +from gamspy._cli.cli import main if __name__ == "__main__": main() diff --git a/src/gamspy/_algebra/operation.py b/src/gamspy/_algebra/operation.py index 169f4bc8..df1c9d56 100644 --- a/src/gamspy/_algebra/operation.py +++ b/src/gamspy/_algebra/operation.py @@ -119,9 +119,9 @@ def _validate_operation( stack = control_stack + self.raw_domain if isinstance(self.rhs, expression.Expression): - self.rhs._validate_definition(stack) + self.rhs._validate_definition(utils._unpack(stack)) elif isinstance(self.rhs, Operation): - self.rhs._validate_operation(stack) + self.rhs._validate_operation(utils._unpack(stack)) def _get_index_str(self) -> str: if len(self.op_domain) == 1: diff --git a/src/gamspy/_cli/cli.py b/src/gamspy/_cli/cli.py new file mode 100644 index 00000000..1cf0493f --- /dev/null +++ b/src/gamspy/_cli/cli.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +import subprocess +from typing import Optional + +import typer + +from gamspy.exceptions import ValidationError +import gamspy.utils as utils + +from . import install, list, retrieve, run, show, uninstall + +app = typer.Typer( + rich_markup_mode="rich", + context_settings={"help_option_names": ["-h", "--help"]}, +) +app.add_typer(install.app, name="install") +app.add_typer(list.app, name="list") +app.add_typer(retrieve.app, name="retrieve") +app.add_typer(run.app, name="run") +app.add_typer(show.app, name="show") +app.add_typer(uninstall.app, name="uninstall") + + +def version_callback(value: bool): + if value: + import gams + + import gamspy + + print(f"GAMSPy version: {gamspy.__version__}") + print(f"GAMS version: {gams.__version__}") + + try: + import gamspy_base + + print(f"gamspy_base version: {gamspy_base.__version__}") + except ModuleNotFoundError: + ... + + raise typer.Exit() + + +@app.callback() +def callback( + version: Optional[bool] = typer.Option( + None, + "-v", + "--version", + help="Shows the version of gamspy, gamsapi, and gamspy_base.", + callback=version_callback, + ), +) -> None: + """ + GAMSPy CLI - The [bold]gamspy[/bold] command line app. 😎 + + Install solvers and licenses, run MIRO apps, and more. + + Read more in the docs: [link=https://gamspy.readthedocs.io/en/latest/cli/index.html]https://gamspy.readthedocs.io/en/latest/cli/index.html[/link]. + """ + ... + +@app.command(short_help="To probe node information.") +def probe( + json_out: Optional[str] = typer.Option( + None, + "--json-out", "-j", + help="Output path for the JSON file." + ), +): + gamspy_base_dir = utils._get_gamspy_base_directory() + process = subprocess.run( + [os.path.join(gamspy_base_dir, "gamsprobe")], + text=True, + capture_output=True, + ) + + if process.returncode: + raise ValidationError(process.stderr) + + print(process.stdout) + + if json_out: + with open(json_out, "w") as file: + file.write(process.stdout) + +def main(): + """ + Entry point for gamspy command line application. + """ + app() diff --git a/src/gamspy/_cli/cmdline.py b/src/gamspy/_cli/cmdline.py deleted file mode 100644 index d63c5f74..00000000 --- a/src/gamspy/_cli/cmdline.py +++ /dev/null @@ -1,852 +0,0 @@ -from __future__ import annotations - -import argparse -import importlib -import os -import platform -import shutil -import subprocess -import sys -from collections.abc import Iterable - -import gamspy.utils as utils -from gamspy.exceptions import GamspyException, ValidationError - -from .util import add_solver_entry, remove_solver_entry - -USAGE = """gamspy [-h] [-v] - gamspy install license or [--uses-port ] - gamspy uninstall license - gamspy install solver [--skip-pip-install] [--existing-solvers] [--install-all-solvers] - gamspy uninstall solver [--skip-pip-uninstall] [--uninstall-all-solvers] - gamspy list solvers [--all] [--defaults] - gamspy show license - gamspy show base - gamspy probe [-j ] - gamspy retrieve license [-i ] [-o ] - gamspy run miro [--path ] [--model ] -""" - - -def get_args(): - parser = argparse.ArgumentParser( - prog="gamspy", usage=USAGE, description="GAMSPy CLI" - ) - - parser.add_argument( - "command", - choices=[ - "install", - "list", - "probe", - "retrieve", - "run", - "show", - "update", - "uninstall", - ], - type=str, - nargs="?", - help=argparse.SUPPRESS, - ) - parser.add_argument( - "component", type=str, nargs="?", default=None, help=argparse.SUPPRESS - ) - parser.add_argument("name", type=str, nargs="*", help=argparse.SUPPRESS) - parser.add_argument( - "-v", - "--version", - action="store_true", - help="Shows the version of GAMSPy, GAMS and gamspy_base", - ) - - install_license_group = parser.add_argument_group( - "gamspy install license or ", - description="Options for installing a license.", - ) - install_license_group.add_argument( - "--uses-port", - help="Interprocess communication starting port.", - ) - - _ = parser.add_argument_group( - "gamspy uninstall license", - description="Command to uninstall user license.", - ) - - install_solver_group = parser.add_argument_group( - "gamspy install solver ", - description="Options for installing solvers", - ) - install_solver_group.add_argument( - "--skip-pip-install", - "-s", - action="store_true", - help=( - "If you already have the solver installed, skip pip install and" - " update gamspy installed solver list." - ), - ) - install_solver_group.add_argument( - "--existing-solvers", - "-e", - action="store_true", - help="Reinstalls previously installed add-on solvers.", - ) - install_solver_group.add_argument( - "--install-all-solvers", - action="store_true", - help="Installs all available add-on solvers.", - ) - - uninstall_solver_group = parser.add_argument_group( - "gamspy uninstall solver ", - description="Options for uninstalling solvers", - ) - uninstall_solver_group.add_argument( - "--skip-pip-uninstall", - "-u", - action="store_true", - help=( - "If you don't want to uninstall the package of the solver, skip" - " uninstall and update gamspy installed solver list." - ), - ) - install_solver_group.add_argument( - "--uninstall-all-solvers", - action="store_true", - help="Uninstalls all installed add-on solvers.", - ) - - list_group = parser.add_argument_group( - "gamspy list solvers", description="`gamspy list solvers` options" - ) - list_group.add_argument( - "-a", "--all", action="store_true", help="Shows all available solvers." - ) - list_group.add_argument( - "-d", "--defaults", action="store_true", help="Shows default solvers." - ) - - probe_group = parser.add_argument_group( - "gamspy probe", description="`gamspy probe` options" - ) - probe_group.add_argument( - "--json-out", "-j", help="Output path for the json file." - ) - - retrieve_group = parser.add_argument_group( - "gamspy retrieve license ", - description="`gamspy retrieve license` options", - ) - retrieve_group.add_argument( - "--output", - "-o", - help="Output path for the license file.", - ) - retrieve_group.add_argument( - "--input", - "-i", - help="json file path to retrieve a license based on node information.", - ) - - miro_group = parser.add_argument_group( - "gamspy run miro", description="`gamspy run miro` options" - ) - miro_group.add_argument( - "-g", - "--model", - type=str, - help="Path to the gamspy model", - default=None, - ) - miro_group.add_argument( - "-m", - "--mode", - type=str, - choices=["config", "base", "deploy"], - help="Execution mode of MIRO", - default="base", - ) - miro_group.add_argument( - "-p", - "--path", - type=str, - help=( - "Path to the MIRO executable (.exe on Windows, .app on macOS or" - " .AppImage on Linux)" - ), - default=None, - ) - miro_group.add_argument( - "--skip-execution", - help="Whether to skip model execution", - action="store_true", - ) - - return parser.parse_args() - - -def install_license(args: argparse.Namespace): - import json - from urllib.parse import urlencode - - import urllib3 - - os.makedirs(utils.DEFAULT_DIR, exist_ok=True) - - if not args.name or len(args.name) > 1: - raise ValidationError( - "License is missing: `gamspy install license or `" - ) - - license = args.name[0] - is_alp = not os.path.isfile(license) - - if is_alp and len(license) != 36: - raise ValidationError( - f"Access code is a 36 character string or an absolute path to the " - f"license file but {len(license)} character string ({license}) provided." - ) - - gamspy_base_dir = utils._get_gamspy_base_directory() - license_path = os.path.join(utils.DEFAULT_DIR, "gamspy_license.txt") - - if is_alp: - alp_id = license - encoded_args = urlencode({"access_token": alp_id}) - request = urllib3.request( - "GET", "https://license.gams.com/license-type?" + encoded_args - ) - if request.status != 200: - raise ValidationError( - f"License server did not respond in an expected way. Request status: {request.status}. Please try again." - ) - - data = request.data.decode("utf-8", errors="replace") - cmex_type = json.loads(data)["cmex_type"] - if not cmex_type.startswith("gamspy"): - raise ValidationError( - f"Given access code `{alp_id} ({cmex_type})` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - - command = [os.path.join(gamspy_base_dir, "gamsgetkey"), alp_id] - - if args.uses_port: - command.append("-u") - command.append(str(args.uses_port)) - - process = subprocess.run( - command, - text=True, - capture_output=True, - ) - if process.returncode: - raise ValidationError(process.stderr) - - license_text = process.stdout - lines = license_text.splitlines() - license_type = lines[0][54] - if license_type == "+": - if lines[2][:2] not in ["00", "07", "08", "09"]: - raise ValidationError( - f"Given access code `{alp_id}` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - else: - if lines[2][8:10] not in ["00", "07", "08", "09"]: - raise ValidationError( - f"Given access code `{alp_id}` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - - with open(license_path, "w", encoding="utf-8") as file: - file.write(license_text) - else: - with open(license) as file: - lines = file.read().splitlines() - - license_type = lines[0][54] - if license_type == "+": - if lines[2][:2] not in ["00", "07", "08", "09"]: - raise ValidationError( - f"Given license file `{license}` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - else: - if lines[2][8:10] not in ["00", "07", "08", "09"]: - raise ValidationError( - f"Given license file `{license}` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - - shutil.copy(license, license_path) - - -def uninstall_license(): - try: - os.unlink(os.path.join(utils.DEFAULT_DIR, "gamspy_license.txt")) - except FileNotFoundError: - ... - - -def install_solver(args: argparse.Namespace): - try: - import gamspy_base - except ModuleNotFoundError as e: - e.msg = "You must first install gamspy_base to use this functionality" - raise e - - addons_path = os.path.join(utils.DEFAULT_DIR, "solvers.txt") - os.makedirs(utils.DEFAULT_DIR, exist_ok=True) - - def install_addons(addons: Iterable[str]): - for item in addons: - solver_name = item.lower() - - if solver_name.upper() not in utils.getAvailableSolvers(): - raise ValidationError( - f'Given solver name ("{solver_name}") is not valid. Available' - f" solvers that can be installed: {utils.getAvailableSolvers()}" - ) - - if not args.skip_pip_install: - # install specified solver - try: - _ = subprocess.run( - [ - "pip", - "install", - f"gamspy-{solver_name}=={gamspy_base.__version__}", - "--force-reinstall", - ], - check=True, - stderr=subprocess.PIPE, - ) - except subprocess.CalledProcessError as e: - raise GamspyException( - f"Could not install gamspy-{solver_name}: {e.stderr.decode('utf-8')}" - ) from e - else: - try: - solver_lib = importlib.import_module( - f"gamspy_{solver_name}" - ) - except ModuleNotFoundError as e: - e.msg = f"You must install gamspy-{solver_name} first!" - raise e - - if solver_lib.__version__ != gamspy_base.__version__: - raise ValidationError( - f"gamspy_base version ({gamspy_base.__version__}) and solver" - f" version ({solver_lib.__version__}) must match! Run `gamspy" - " update` to update your solvers." - ) - - # copy solver files to gamspy_base - gamspy_base_dir = utils._get_gamspy_base_directory() - solver_lib = importlib.import_module(f"gamspy_{solver_name}") - - file_paths = solver_lib.file_paths - for file in file_paths: - shutil.copy(file, gamspy_base_dir) - - files = solver_lib.files - verbatims = [solver_lib.verbatim] - append_dist_info(files, gamspy_base_dir) - add_solver_entry(gamspy_base_dir, solver_name, verbatims) - - try: - with open(addons_path) as file: - installed = file.read().splitlines() - installed = [ - solver - for solver in installed - if solver != "" and solver != "\n" - ] - except FileNotFoundError: - installed = [] - - with open(addons_path, "w") as file: - if solver_name.upper() not in installed: - file.write( - "\n".join(installed) - + "\n" - + solver_name.upper() - + "\n" - ) - - if args.install_all_solvers: - available_solvers = utils.getAvailableSolvers() - installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) - diff = [] - for solver in available_solvers: - if solver not in installed_solvers: - diff.append(solver) - - install_addons(diff) - return - - if args.existing_solvers: - try: - with open(addons_path) as file: - solvers = file.read().splitlines() - install_addons(solvers) - return - except FileNotFoundError as e: - raise ValidationError("No existing add-on solvers found!") from e - - if not args.name: - raise ValidationError( - "Solver name is missing: `gamspy install solver `" - ) - - install_addons(args.name) - - -def append_dist_info(files, gamspy_base_dir: str): - """Updates dist-info/RECORD in site-packages for pip uninstall""" - import gamspy as gp - - gamspy_path: str = gp.__path__[0] - dist_info_path = f"{gamspy_path}-{gp.__version__}.dist-info" - - with open( - dist_info_path + os.sep + "RECORD", "a", encoding="utf-8" - ) as record: - gamspy_base_relative_path = os.sep.join( - gamspy_base_dir.split(os.sep)[-3:] - ) - - lines = [] - for file in files: - line = f"{gamspy_base_relative_path}{os.sep}{file},," - lines.append(line) - - record.write("\n".join(lines)) - - -def uninstall_solver(args: argparse.Namespace): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - addons_path = os.path.join(utils.DEFAULT_DIR, "solvers.txt") - - def remove_addons(addons: Iterable[str]): - for item in addons: - solver_name = item.lower() - - installed_solvers = utils.getInstalledSolvers( - gamspy_base.directory - ) - if solver_name.upper() not in installed_solvers: - raise ValidationError( - f'Given solver name ("{solver_name}") is not valid. Installed' - f" solvers solvers that can be uninstalled: {installed_solvers}" - ) - - if not args.skip_pip_uninstall: - # uninstall specified solver - try: - _ = subprocess.run( - ["pip", "uninstall", f"gamspy-{solver_name}", "-y"], - check=True, - ) - except subprocess.CalledProcessError as e: - raise GamspyException( - f"Could not uninstall gamspy-{solver_name}: {e.output}" - ) from e - - # do not delete files from gamspy_base as other solvers might depend on it - gamspy_base_dir = utils._get_gamspy_base_directory() - remove_solver_entry(gamspy_base_dir, solver_name) - - try: - with open(addons_path) as file: - installed = file.read().splitlines() - except FileNotFoundError: - installed = [] - - try: - installed.remove(solver_name.upper()) - except ValueError: - ... - - with open(addons_path, "w") as file: - file.write("\n".join(installed) + "\n") - - if args.uninstall_all_solvers: - installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) - solvers = [ - solver - for solver in installed_solvers - if solver not in gamspy_base.default_solvers - ] - remove_addons(solvers) - - # All add-on solvers are gone. - return - - if not args.name: - raise ValidationError( - "Solver name is missing: `gamspy uninstall solver `" - ) - - remove_addons(args.name) - - -def install(args: argparse.Namespace): - if args.component == "license": - install_license(args) - elif args.component == "solver": - install_solver(args) - else: - raise ValidationError( - "`gamspy install` requires a third argument (license or solver)." - ) - - -def update(): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - prev_installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) - - try: - _ = subprocess.run( - [ - "pip", - "install", - f"gamspy-base=={gamspy_base.__version__}", - "--force-reinstall", - ], - check=True, - ) - except subprocess.CalledProcessError as e: - raise GamspyException( - f"Could not uninstall gamspy-base: {e.output}" - ) from e - - new_installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) - - solvers_to_update = [] - for solver in prev_installed_solvers: - if solver not in new_installed_solvers: - solvers_to_update.append(solver) - - for solver in solvers_to_update: - try: - _ = subprocess.run( - [ - "gamspy", - "install", - "solver", - solver.lower(), - ], - check=True, - ) - except subprocess.CalledProcessError as e: - raise GamspyException( - "Could not uninstall" - f" gamspy-{solver.lower()}=={gamspy_base.version}: {e.output}" - ) from e - - -def list_solvers(args: argparse.Namespace): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - component = args.component - - if component == "solvers": - capabilities = utils.getSolverCapabilities(gamspy_base.directory) - if args.all: - solvers = utils.getAvailableSolvers() - print("Available Solvers") - print("=" * 17) - print(", ".join(solvers)) - print( - "\nModel types that can be solved with the installed solvers:\n" - ) - for solver in solvers: - try: - print(f"{solver:<10}: {', '.join(capabilities[solver])}") - except KeyError: - ... - elif args.defaults: - default_solvers = utils.getDefaultSolvers() - print("Default Solvers") - print("=" * 17) - for problem in default_solvers: - try: - print(f"{problem:<10}: {default_solvers[problem]}") - except KeyError: - ... - else: - solvers = utils.getInstalledSolvers(gamspy_base.directory) - print("Installed Solvers") - print("=" * 17) - print(", ".join(solvers)) - - print( - "\nModel types that can be solved with the installed solvers" - ) - print("=" * 57) - for solver in solvers: - try: - print(f"{solver:<10}: {', '.join(capabilities[solver])}") - except KeyError: - ... - else: - raise ValidationError( - "gamspy list requires a third argument (solvers)." - ) - - -def run(args: argparse.Namespace): - component = args.component - - if component == "miro": - model = os.path.abspath(args.model) - mode = args.mode - path = os.getenv("MIRO_PATH", None) - - if path is None: - path = args.path if args.path is not None else discover_miro() - - if model is None or path is None: - raise GamspyException( - "--model and --path must be provided to run MIRO" - ) - - if ( - platform.system() == "Darwin" - and os.path.splitext(path)[1] == ".app" - ): - path = os.path.join(path, "Contents", "MacOS", "GAMS MIRO") - - # Initialize MIRO - if not args.skip_execution: - subprocess_env = os.environ.copy() - subprocess_env["MIRO"] = "1" - - try: - subprocess.run( - [sys.executable, model], env=subprocess_env, check=True - ) - except subprocess.CalledProcessError: - return - - # Run MIRO - subprocess_env = os.environ.copy() - if mode == "deploy": - subprocess_env["MIRO_BUILD"] = "true" - mode = "base" - - subprocess_env["MIRO_MODEL_PATH"] = model - subprocess_env["MIRO_MODE"] = mode - subprocess_env["MIRO_DEV_MODE"] = "true" - subprocess_env["MIRO_USE_TMP"] = "false" - subprocess_env["PYTHON_EXEC_PATH"] = sys.executable - - subprocess.run([path], env=subprocess_env, check=True) - - return None - - -def discover_miro(): - system = platform.system() - if system == "Linux": - return None - - home = os.path.expanduser("~") - standard_locations = { - "Darwin": [ - os.path.join( - "/", - "Applications", - "GAMS MIRO.app", - "Contents", - "MacOS", - "GAMS MIRO", - ), - os.path.join( - home, - "Applications", - "GAMS MIRO.app", - "Contents", - "MacOS", - "GAMS MIRO", - ), - ], - "Windows": [ - os.path.join( - "C:\\", "Program Files", "GAMS MIRO", "GAMS MIRO.exe" - ), - os.path.join( - home, - "AppData", - "Local", - "Programs", - "GAMS MIRO", - "GAMS MIRO.exe", - ), - ], - } - - if system in ["Darwin", "Windows"]: - for location in standard_locations[system]: - if os.path.isfile(location): - return location - - return None - - -def show(args: argparse.Namespace): - if args.component == "license": - show_license() - elif args.component == "base": - show_base() - else: - raise ValidationError( - "`gamspy show` requires a third argument (license or base)." - ) - - -def show_license(): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - license_path = utils._get_license_path(gamspy_base.directory) - print(f"License found at: {license_path}\n") - print("License Content") - print("=" * 15) - with open(license_path, encoding="utf-8") as license_file: - print(license_file.read().strip()) - - -def show_base(): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - print(gamspy_base.directory) - - -def uninstall(args: argparse.Namespace): - if args.component == "license": - uninstall_license() - elif args.component == "solver": - uninstall_solver(args) - else: - raise ValidationError( - "`gamspy uninstall` requires a third argument (license or solver)." - ) - - -def print_version(): - import gams - - import gamspy - - print(f"GAMSPy version: {gamspy.__version__}") - print(f"GAMS version: {gams.__version__}") - - try: - import gamspy_base - - print(f"gamspy_base version: {gamspy_base.__version__}") - except ModuleNotFoundError: - ... - - -def probe(args: argparse.Namespace): - gamspy_base_dir = utils._get_gamspy_base_directory() - process = subprocess.run( - [os.path.join(gamspy_base_dir, "gamsprobe")], - text=True, - capture_output=True, - ) - - if process.returncode: - raise ValidationError(process.stderr) - - print(process.stdout) - - if args.json_out: - with open(args.json_out, "w") as file: - file.write(process.stdout) - - -def retrieve(args: argparse.Namespace): - if args.input is None or not os.path.isfile(args.input): - raise ValidationError( - f"Given path `{args.input}` is not a json file. Please use `gamspy retrieve license -i `" - ) - - if args.name is None: - raise ValidationError(f"Given licence id `{args.name}` is not valid!") - - gamspy_base_dir = utils._get_gamspy_base_directory() - process = subprocess.run( - [ - os.path.join(gamspy_base_dir, "gamsgetkey"), - args.name[0], - "-i", - args.input, - ], - text=True, - capture_output=True, - ) - - if process.returncode: - raise ValidationError(process.stderr) - - if args.output: - with open(args.output, "w") as file: - file.write(process.stdout) - - -def main(): - """ - Entry point for gamspy command line application. - """ - args = get_args() - if args.version: - print_version() - elif args.command == "install": - install(args) - elif args.command == "probe": - probe(args) - elif args.command == "retrieve": - retrieve(args) - elif args.command == "run": - run(args) - elif args.command == "show": - show(args) - elif args.command == "update": - update() - elif args.command == "list": - list_solvers(args) - elif args.command == "uninstall": - uninstall(args) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py new file mode 100644 index 00000000..6837205f --- /dev/null +++ b/src/gamspy/_cli/install.py @@ -0,0 +1,299 @@ +from __future__ import annotations +import importlib +import shutil + +from typing import Annotated, Iterable, Optional, Union + +import typer +from gamspy.exceptions import GamspyException, ValidationError +import gamspy.utils as utils +import os +import subprocess +from .util import add_solver_entry + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To install licenses and solvers.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy install license or | gamspy install solver ", + context_settings={"help_option_names": ["-h", "--help"]}, +) + +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy install license or ", + short_help="To install a new license" +) +def license( + license: Annotated[str, typer.Argument(help="access code or path to the license file.")], + checkout_duration: Optional[int] = typer.Option(None, "--checkout-duration", "-c", help="Specify a duration in hours to checkout a session."), + renew: Optional[str] = typer.Option(None, "--renew", "-r", help="Specify a file path to a license file to extend a session."), + output: Optional[str] = typer.Option(None, "--output", "-o", help="Specify a file path to write the license file."), + uses_port: Annotated[Union[int, None], typer.Option("--uses-port", help="Interprocess communication starting port.")] = None, +): + import json + from urllib.parse import urlencode + + import urllib3 + + os.makedirs(utils.DEFAULT_DIR, exist_ok=True) + + is_alp = not os.path.isfile(license) + + if is_alp and len(license) != 36: + raise ValidationError( + f"Access code is a 36 character string or an absolute path to the " + f"license file but {len(license)} character string ({license}) provided." + ) + + gamspy_base_dir = utils._get_gamspy_base_directory() + license_path = os.path.join(utils.DEFAULT_DIR, "gamspy_license.txt") + + if is_alp: + alp_id = license + encoded_args = urlencode({"access_token": alp_id}) + request = urllib3.request( + "GET", "https://license.gams.com/license-type?" + encoded_args + ) + if request.status != 200: + raise ValidationError( + f"License server did not respond in an expected way. Request status: {request.status}. Reason: {request.data.decode('utf-8', errors='replace')}" + ) + + data = request.data.decode("utf-8", errors="replace") + cmex_type = json.loads(data)["cmex_type"] + if cmex_type not in ["gamspy", "gamspy++", "gamsall"]: + raise ValidationError( + f"Given access code `{alp_id} ({cmex_type})` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + + command = [os.path.join(gamspy_base_dir, "gamsgetkey"), alp_id] + + if uses_port: + command.append("-u") + command.append(str(uses_port)) + + if checkout_duration: + command.append("-c") + command.append(str(checkout_duration)) + + if checkout_duration: + command.append("-r") + command.append(str(renew)) + + if output: + command.append("-o") + command.append(output) + + process = subprocess.run( + command, + text=True, + capture_output=True, + ) + if process.returncode: + raise ValidationError(process.stderr) + + if output: + with open(output) as file: + license_text = file.read() + else: + license_text = process.stdout + + lines = license_text.splitlines() + license_type = lines[0][54] + if license_type == "+": + if lines[2][:2] not in ["00", "07", "08", "09"]: + raise ValidationError( + f"Given access code `{alp_id}` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + else: + if lines[2][8:10] not in ["00", "07", "08", "09"]: + raise ValidationError( + f"Given access code `{alp_id}` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + + with open(license_path, "w", encoding="utf-8") as file: + file.write(license_text) + else: + with open(license) as file: + lines = file.read().splitlines() + + license_type = lines[0][54] + if license_type == "+": + if lines[2][:2] not in ["00", "07", "08", "09"]: + raise ValidationError( + f"Given license file `{license}` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + else: + if lines[2][8:10] not in ["00", "07", "08", "09"]: + raise ValidationError( + f"Given license file `{license}` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + + shutil.copy(license, license_path) + +def append_dist_info(files, gamspy_base_dir: str): + """Updates dist-info/RECORD in site-packages for pip uninstall""" + import gamspy as gp + + gamspy_path: str = gp.__path__[0] + dist_info_path = f"{gamspy_path}-{gp.__version__}.dist-info" + + with open( + dist_info_path + os.sep + "RECORD", "a", encoding="utf-8" + ) as record: + gamspy_base_relative_path = os.sep.join( + gamspy_base_dir.split(os.sep)[-3:] + ) + + lines = [] + for file in files: + line = f"{gamspy_base_relative_path}{os.sep}{file},," + lines.append(line) + + record.write("\n".join(lines)) + +@app.command( + short_help="To install solvers", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy install solver " +) +def solver( + solver: list[str] = typer.Argument( + None, + help="solver names to be installed", + autocompletion=lambda: [s.lower() for s in utils.getAvailableSolvers()] + ), + install_all_solvers: bool = typer.Option( + False, + "--install-all-solvers", + help="Installs all available add-on solvers." + ), + existing_solvers: bool = typer.Option( + False, + "--existing-solvers", + help="Reinstalls previously installed add-on solvers." + ), + skip_pip_install: bool = typer.Option( + False, + "--skip-pip-install", + "-s", + help="If you already have the solver installed, skip pip install and update gamspy installed solver list." + ) +): + try: + import gamspy_base + except ModuleNotFoundError as e: + e.msg = "You must first install gamspy_base to use this functionality" + raise e + + addons_path = os.path.join(utils.DEFAULT_DIR, "solvers.txt") + os.makedirs(utils.DEFAULT_DIR, exist_ok=True) + + def install_addons(addons: Iterable[str]): + for item in addons: + solver_name = item.lower() + + if solver_name.upper() not in utils.getAvailableSolvers(): + raise ValidationError( + f'Given solver name ("{solver_name}") is not valid. Available' + f" solvers that can be installed: {utils.getAvailableSolvers()}" + ) + + if not skip_pip_install: + # install specified solver + try: + _ = subprocess.run( + [ + "pip", + "install", + f"gamspy-{solver_name}=={gamspy_base.__version__}", + "--force-reinstall", + ], + check=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as e: + raise GamspyException( + f"Could not install gamspy-{solver_name}: {e.stderr.decode('utf-8')}" + ) from e + else: + try: + solver_lib = importlib.import_module( + f"gamspy_{solver_name}" + ) + except ModuleNotFoundError as e: + e.msg = f"You must install gamspy-{solver_name} first!" + raise e + + if solver_lib.__version__ != gamspy_base.__version__: + raise ValidationError( + f"gamspy_base version ({gamspy_base.__version__}) and solver" + f" version ({solver_lib.__version__}) must match! Run `gamspy" + " update` to update your solvers." + ) + + # copy solver files to gamspy_base + gamspy_base_dir = utils._get_gamspy_base_directory() + solver_lib = importlib.import_module(f"gamspy_{solver_name}") + + file_paths = solver_lib.file_paths + for file in file_paths: + shutil.copy(file, gamspy_base_dir) + + files = solver_lib.files + verbatims = [solver_lib.verbatim] + append_dist_info(files, gamspy_base_dir) + add_solver_entry(gamspy_base_dir, solver_name, verbatims) + + try: + with open(addons_path) as file: + installed = file.read().splitlines() + installed = [ + solver + for solver in installed + if solver != "" and solver != "\n" + ] + except FileNotFoundError: + installed = [] + + with open(addons_path, "w") as file: + if solver_name.upper() not in installed: + file.write( + "\n".join(installed) + + "\n" + + solver_name.upper() + + "\n" + ) + + if install_all_solvers: + available_solvers = utils.getAvailableSolvers() + installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) + diff = [] + for available_solver in available_solvers: + if available_solver not in installed_solvers: + diff.append(available_solver) + + install_addons(diff) + return + + if existing_solvers: + try: + with open(addons_path) as file: + solvers = file.read().splitlines() + install_addons(solvers) + return + except FileNotFoundError as e: + raise ValidationError("No existing add-on solvers found!") from e + + if solver is None: + raise ValidationError( + "Solver name is missing: `gamspy install solver `" + ) + + install_addons(solver) + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/list.py b/src/gamspy/_cli/list.py new file mode 100644 index 00000000..2bf3da1f --- /dev/null +++ b/src/gamspy/_cli/list.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import typer +from rich import print +from rich.console import Console +from rich.table import Table + +import gamspy.utils as utils +from gamspy.exceptions import ValidationError + +console = Console() +app = typer.Typer( + rich_markup_mode="rich", + short_help="To list solvers.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy list solvers --all | gamspy list solvers --defaults", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +@app.command() +def solvers( + all: bool = typer.Option( + False, + "--all", + "-a", + help="Shows all available solvers." + ), + defaults: bool = typer.Option( + False, + "--defaults", + "-d", + help="Shows default solvers." + ), +) -> None: + try: + import gamspy_base + except ModuleNotFoundError as e: + raise ValidationError( + "You must install gamspy_base to use this command!" + ) from e + + capabilities = utils.getSolverCapabilities(gamspy_base.directory) + if all: + solvers = utils.getAvailableSolvers() + print("[bold]Available Solvers[/bold]") + print("=" * 17) + print(", ".join(solvers)) + print("\n[bold]Model types that can be solved with the installed solvers[/bold]\n") + table = Table("Solver", "Problem Types") + for solver in solvers: + try: + table.add_row(solver, ", ".join(capabilities[solver])) + except KeyError: + ... + console.print(table) + + print( + "[bold]Full list can be found here[/bold]: https://www.gams.com/latest/docs/S_MAIN.html#SOLVERS_MODEL_TYPES" + ) + elif defaults: + default_solvers = utils.getDefaultSolvers() + table = Table("Problem", "Solver") + for problem in default_solvers: + try: + table.add_row(problem, default_solvers[problem]) + except KeyError: + ... + + console.print(table) + else: + solvers = utils.getInstalledSolvers(gamspy_base.directory) + print("[bold]Installed Solvers[/bold]") + print("=" * 17) + print(", ".join(solvers)) + + print("\n[bold]Model types that can be solved with the installed solvers[/bold]") + print("=" * 57) + table = Table("Solver", "Problem Types") + for solver in solvers: + try: + table.add_row(solver, ", ".join(capabilities[solver])) + except KeyError: + ... + + console.print(table) + + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/retrieve.py b/src/gamspy/_cli/retrieve.py new file mode 100644 index 00000000..33fbd067 --- /dev/null +++ b/src/gamspy/_cli/retrieve.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import subprocess + +import typer + +import gamspy.utils as utils +from gamspy.exceptions import ValidationError + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To retrieve a license with another node's information.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy retrieve license [--input .json] [--output .json]", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +@app.command( + short_help="Retrives the license with the given node information.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy retrieve license [--input .json] [--output .json]" +) +def license( + access_code: str = typer.Argument( + ..., + help="Access code of the license." + ), + input: str = typer.Option( + None, + "--input", + "-i", + help="Input json file path to retrieve the license based on the node information." + ), + output: str = typer.Option( + None, + "--output", + "-o", + help="Output path for the license file." + ), +) -> None: + if input is None or not os.path.isfile(input): + raise ValidationError( + f"Given path `{input}` is not a json file. Please use `gamspy retrieve license -i `" + ) + + if access_code is None: + raise ValidationError( + f"Given licence id `{access_code}` is not valid!" + ) + + gamspy_base_dir = utils._get_gamspy_base_directory() + process = subprocess.run( + [ + os.path.join(gamspy_base_dir, "gamsgetkey"), + access_code, + "-i", + input, + ], + text=True, + capture_output=True, + ) + + if process.returncode: + raise ValidationError(process.stderr) + + print(process.stdout) + if output: + with open(output, "w") as file: + file.write(process.stdout) + + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/run.py b/src/gamspy/_cli/run.py new file mode 100644 index 00000000..ba294f23 --- /dev/null +++ b/src/gamspy/_cli/run.py @@ -0,0 +1,148 @@ +from __future__ import annotations +import os +import platform +import subprocess +import sys +from enum import Enum + +import typer + +from gamspy.exceptions import GamspyException, ValidationError + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To run your model with GAMS MIRO.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy run miro [--path ] [--model ]", + context_settings={"help_option_names": ["-h", "--help"]}, +) + +class ModeEnum(Enum): + config = "config" + base = "base" + deploy = "deploy" + +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy run miro [--path ] [--model ]", + short_help="Runs a GAMSPY model with GAMS MIRO app." +) +def miro( + model: str = typer.Option( + None, + "--model", + "-g", + help="Path to the GAMSPy model." + ), + mode: ModeEnum = typer.Option( + "base", + "--mode", + "-m", + help="Execution mode of MIRO" + ), + path: str = typer.Option( + None, + "--path", + "-p", + help="Path to the MIRO executable (.exe on Windows, .app on macOS or .AppImage on Linux" + ), + skip_execution: bool = typer.Option( + False, + "--skip-execution", + help="Whether to skip model execution." + ) +) -> None: + if model is None: + raise ValidationError("--model must be provided to run MIRO") + + model = os.path.abspath(model) + execution_mode = mode.value + path = os.getenv("MIRO_PATH", None) + + if path is None: + path = path if path is not None else discover_miro() + + if path is None: + raise GamspyException( + "--path must be provided to run MIRO" + ) + + if ( + platform.system() == "Darwin" + and os.path.splitext(path)[1] == ".app" + ): + path = os.path.join(path, "Contents", "MacOS", "GAMS MIRO") + + # Initialize MIRO + if not skip_execution: + subprocess_env = os.environ.copy() + subprocess_env["MIRO"] = "1" + + try: + subprocess.run( + [sys.executable, model], env=subprocess_env, check=True + ) + except subprocess.CalledProcessError: + return + + # Run MIRO + subprocess_env = os.environ.copy() + if execution_mode == "deploy": + subprocess_env["MIRO_BUILD"] = "true" + execution_mode = "base" + + subprocess_env["MIRO_MODEL_PATH"] = model + subprocess_env["MIRO_MODE"] = execution_mode + subprocess_env["MIRO_DEV_MODE"] = "true" + subprocess_env["MIRO_USE_TMP"] = "false" + subprocess_env["PYTHON_EXEC_PATH"] = sys.executable + + subprocess.run([path], env=subprocess_env, check=True) + +def discover_miro(): + system = platform.system() + if system == "Linux": + return None + + home = os.path.expanduser("~") + standard_locations = { + "Darwin": [ + os.path.join( + "/", + "Applications", + "GAMS MIRO.app", + "Contents", + "MacOS", + "GAMS MIRO", + ), + os.path.join( + home, + "Applications", + "GAMS MIRO.app", + "Contents", + "MacOS", + "GAMS MIRO", + ), + ], + "Windows": [ + os.path.join( + "C:\\", "Program Files", "GAMS MIRO", "GAMS MIRO.exe" + ), + os.path.join( + home, + "AppData", + "Local", + "Programs", + "GAMS MIRO", + "GAMS MIRO.exe", + ), + ], + } + + if system in ["Darwin", "Windows"]: + for location in standard_locations[system]: + if os.path.isfile(location): + return location + + return None + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/show.py b/src/gamspy/_cli/show.py new file mode 100644 index 00000000..60498a03 --- /dev/null +++ b/src/gamspy/_cli/show.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import typer + +import gamspy.utils as utils +from gamspy.exceptions import ValidationError + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To show your license and gamspy_base directory.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy show license | gamspy show base", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +@app.command(short_help="Shows the license content.") +def license(): + try: + import gamspy_base + except ModuleNotFoundError as e: + raise ValidationError( + "You must install gamspy_base to use this command!" + ) from e + + license_path = utils._get_license_path(gamspy_base.directory) + print(f"License found at: {license_path}\n") + print("License Content") + print("=" * 15) + with open(license_path, encoding="utf-8") as license_file: + print(license_file.read().strip()) + + +@app.command(short_help="Shows the path of gamspy_base.") +def base(): + try: + import gamspy_base + except ModuleNotFoundError as e: + raise ValidationError( + "You must install gamspy_base to use this command!" + ) from e + + print(gamspy_base.directory) + + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py new file mode 100644 index 00000000..75e9c898 --- /dev/null +++ b/src/gamspy/_cli/uninstall.py @@ -0,0 +1,122 @@ +from __future__ import annotations +import os +import subprocess +from typing import Iterable +from gamspy.exceptions import GamspyException, ValidationError +import gamspy.utils as utils +from .util import remove_solver_entry + +import typer + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To uninstall licenses and solvers.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy uninstall license | gamspy uninstall solver ", + context_settings={"help_option_names": ["-h", "--help"]}, +) + +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy uninstall license", + short_help="To uninstall the current license" +) +def license(): + try: + os.unlink(os.path.join(utils.DEFAULT_DIR, "gamspy_license.txt")) + except FileNotFoundError: + ... + +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy uninstall solver ", + short_help="To uninstall solvers" +) +def solver( + solver: list[str] = typer.Argument( + None, + help="solver names to be uninstalled", + autocompletion=lambda: [s.lower() for s in utils.getInstalledSolvers(utils._get_gamspy_base_directory())] + ), + uninstall_all_solvers: bool = typer.Option( + False, + "--uninstall-all-solvers", + help="Uninstalls all add-on solvers." + ), + skip_pip_uninstall: bool = typer.Option( + False, + "--skip-pip-install", + "-s", + help="If you already have the solver uninstalled, skip pip uninstall and update gamspy installed solver list." + ) +): + try: + import gamspy_base + except ModuleNotFoundError as e: + raise ValidationError( + "You must install gamspy_base to use this command!" + ) from e + + addons_path = os.path.join(utils.DEFAULT_DIR, "solvers.txt") + + def remove_addons(addons: Iterable[str]): + for item in addons: + solver_name = item.lower() + + installed_solvers = utils.getInstalledSolvers( + gamspy_base.directory + ) + if solver_name.upper() not in installed_solvers: + raise ValidationError( + f'Given solver name ("{solver_name}") is not valid. Installed' + f" solvers solvers that can be uninstalled: {installed_solvers}" + ) + + if not skip_pip_uninstall: + # uninstall specified solver + try: + _ = subprocess.run( + ["pip", "uninstall", f"gamspy-{solver_name}", "-y"], + check=True, + ) + except subprocess.CalledProcessError as e: + raise GamspyException( + f"Could not uninstall gamspy-{solver_name}: {e.output}" + ) from e + + # do not delete files from gamspy_base as other solvers might depend on it + gamspy_base_dir = utils._get_gamspy_base_directory() + remove_solver_entry(gamspy_base_dir, solver_name) + + try: + with open(addons_path) as file: + installed = file.read().splitlines() + except FileNotFoundError: + installed = [] + + try: + installed.remove(solver_name.upper()) + except ValueError: + ... + + with open(addons_path, "w") as file: + file.write("\n".join(installed) + "\n") + + if uninstall_all_solvers: + installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) + solvers = [ + solver + for solver in installed_solvers + if solver not in gamspy_base.default_solvers + ] + remove_addons(solvers) + + # All add-on solvers are gone. + return + + if solver is None: + raise ValidationError( + "Solver name is missing: `gamspy uninstall solver `" + ) + + remove_addons(solver) + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_container.py b/src/gamspy/_container.py index d747558e..e73608c6 100644 --- a/src/gamspy/_container.py +++ b/src/gamspy/_container.py @@ -7,6 +7,7 @@ import socket import subprocess import tempfile +import threading import time import traceback import uuid @@ -92,7 +93,8 @@ def handler(signum, frame): if platform.system() != "Windows": os.kill(process.pid, signal.SIGINT) - signal.signal(signal.SIGINT, handler) + if threading.current_thread() is threading.main_thread(): + signal.signal(signal.SIGINT, handler) start = time.time() while True: @@ -263,7 +265,7 @@ def __init__( ) if load_from is not None: - self.read(load_from) + self._read(load_from) if not isinstance(load_from, gt.Container): self._unsaved_statements = [] self._clean_modified_symbols() @@ -271,6 +273,20 @@ def __init__( self._synch_with_gams(gams_to_gamspy=False) + def __enter__(self): + pid = os.getpid() + tid = threading.get_native_id() + gp._ctx_managers[(pid, tid)] = self + + def __exit__(self, exc_type, exc_val, exc_tb): + pid = os.getpid() + tid = threading.get_native_id() + + try: + del gp._ctx_managers[(pid, tid)] + except KeyError: + ... + def __repr__(self) -> str: return f"Container(system_directory='{self.system_directory}', working_directory='{self.working_directory}', debugging_level='{self._debugging_level}')" @@ -597,7 +613,7 @@ def _load_records_from_gdx( if updated_records is not None: self[name].domain_labels = self[name].domain_names else: - self.read(load_from, [name]) + self._read(load_from, [name]) if user_invoked: self[name].modified = True @@ -607,6 +623,17 @@ def _load_records_from_gdx( if user_invoked: self._synch_with_gams() + def _read( + self, + load_from: str | Container | gt.Container, + symbol_names: list[str] | None = None, + load_records: bool = True, + mode: str | None = None, + encoding: str | None = None, + ) -> None: + super().read(load_from, symbol_names, load_records, mode, encoding) + self._cast_symbols(symbol_names) + def read( self, load_from: str | Container | gt.Container, @@ -639,8 +666,8 @@ def read( True """ - super().read(load_from, symbol_names, load_records, mode, encoding) - self._cast_symbols(symbol_names) + self._read(load_from, symbol_names, load_records, mode, encoding) + self._synch_with_gams() def write( self, @@ -1176,7 +1203,7 @@ def copy(self, working_directory: str) -> Container: ) self.write(m._job + "in.gdx") - m.read(m._job + "in.gdx") + m._read(m._job + "in.gdx") # if already defined equations exist, add them to .gms file for equation in self.getEquations(): diff --git a/src/gamspy/_model.py b/src/gamspy/_model.py index dfcf07c6..f42b3e83 100644 --- a/src/gamspy/_model.py +++ b/src/gamspy/_model.py @@ -4,6 +4,7 @@ import io import logging import os +import threading import uuid from collections.abc import Iterable from enum import Enum @@ -231,7 +232,7 @@ class Model: def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, problem: Problem | str = Problem.MIP, equations: Iterable[Equation] = [], @@ -249,7 +250,14 @@ def __init__( else: self.name = self._auto_id - self.container = container + if container is not None: + self.container = container + else: + self.container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + + assert self.container is not None self._matches = matches self.problem, self.sense = validation.validate_model( equations, problem, sense @@ -275,8 +283,8 @@ def __init__( if not self.equations and not self._matches: raise ValidationError("Model requires at least one equation.") - self._external_module_file = None - self._external_module = None + self._external_module_file: str | None = None + self._external_module: str | None = None if external_module is not None: self.external_module = external_module @@ -1012,33 +1020,10 @@ def freeze( modifiables: list[Parameter | ImplicitParameter], options: Options | None = None, ) -> None: - """ - Freezes all symbols except modifiable symbols. - - Parameters - ---------- - modifiables : List[Parameter | ImplicitParameter] - freeze_options : dict, optional - - Examples - -------- - >>> import gamspy as gp - >>> m = gp.Container() - >>> a = gp.Parameter(m, name="a", records=10) - >>> x = gp.Variable(m, name="x") - >>> e = gp.Equation(m, name="e", definition= x <= a) - >>> my_model = gp.Model(m, name="my_model", equations=m.getEquations(), problem="LP", sense="max", objective=x) - >>> solved = my_model.solve() - >>> float(x.toValue()) - 10.0 - >>> my_model.freeze(modifiables=[a]) - >>> a.setRecords(35) - >>> solved = my_model.solve() - >>> float(x.toValue()) - 35.0 - - """ self._is_frozen = True + if options is None: + options = Options() + self.instance = ModelInstance( self.container, self, modifiables, options ) @@ -1129,13 +1114,9 @@ def solve( options._frame = frame if self._is_frozen: - options._set_solver_options( - working_directory=self.container.working_directory, - solver=solver, - problem=self.problem, - solver_options=solver_options, + self.instance.solve( + solver, model_instance_options, solver_options, output ) - self.instance.solve(solver, model_instance_options, output) return None self.container._add_statement(self.getDeclaration()) diff --git a/src/gamspy/_model_instance.py b/src/gamspy/_model_instance.py index eca9c940..522c34c1 100644 --- a/src/gamspy/_model_instance.py +++ b/src/gamspy/_model_instance.py @@ -81,7 +81,7 @@ def __init__( container: Container, model: Model, modifiables: list[Parameter | ImplicitParameter], - freeze_options: Options | None = None, + freeze_options: Options, ): self.container = container self.job_name = container._job @@ -106,10 +106,7 @@ def __init__( container.system_directory, debug=self._debugging_level, ) - self.checkpoint = GamsCheckpoint( - self.workspace, - self.save_file, - ) + self.checkpoint = GamsCheckpoint(self.workspace, self.save_file) self.instance = self.checkpoint.add_modelinstance() self.instantiate(model, freeze_options) @@ -143,7 +140,7 @@ def _create_restart_file(self): self.container._send_job(self.job_name, self.pf_file) - def instantiate(self, model: Model, options: Options | None = None): + def instantiate(self, model: Model, options: Options): modifiers = self._create_modifiers() solve_string = f"{model.name} using {model.problem}" @@ -155,16 +152,26 @@ def instantiate(self, model: Model, options: Options | None = None): solve_string += f" {model._objective_variable.gamsRepr()}" gams_options = self._prepare_gams_options(options) + gams_options.license = utils._get_license_path( + self.container.system_directory + ) + if self.container._network_license: + gams_options._netlicense = os.path.join( + self.container._process_directory, "gamslice.dat" + ) self.instance.instantiate(solve_string, modifiers, gams_options) def solve( self, solver: str | None, given_options: ModelInstanceOptions | None = None, + solver_options: dict | None = None, output: io.TextIOWrapper | None = None, ): # get options from dict - options, update_type = self._prepare_options(solver, given_options) + options, update_type = self._prepare_options( + solver, given_options, solver_options + ) # update sync_db self.container.write(self.instance.sync_db._gmd, eps_to_zero=False) @@ -195,7 +202,7 @@ def solve( # update model status self.model._status = gp.ModelStatus(self.instance.model_status) - self.model._solve_status = self.instance.solver_status + self.model._solve_status = gp.SolveStatus(self.instance.solver_status) def _init_modifiables( self, modifiables: list[Parameter | ImplicitParameter] @@ -253,13 +260,8 @@ def _init_modifiables( return will_be_modified - def _prepare_gams_options( - self, given_options: Options | None - ) -> GamsOptions: + def _prepare_gams_options(self, given_options: Options) -> GamsOptions: options = GamsOptions(self.workspace) - if given_options is None: - return options - options_dict = given_options._get_gams_compatible_options() for key, value in options_dict.items(): @@ -271,13 +273,26 @@ def _prepare_options( self, solver: str | None, given_options: ModelInstanceOptions | None, + solver_options: dict | None, ) -> tuple[GamsModelInstanceOpt | None, SymbolUpdateType]: update_type = SymbolUpdateType.BaseCase - options = GamsModelInstanceOpt() + opt_file = 1 if solver_options else -1 + options = GamsModelInstanceOpt(opt_file=opt_file) if solver is not None: options.solver = solver + if solver_options is not None: + solver_options_file_name = os.path.join( + self.container.working_directory, f"{solver.lower()}.opt" + ) + + with open( + solver_options_file_name, "w", encoding="utf-8" + ) as solver_file: + for key, value in solver_options.items(): + solver_file.write(f"{key} {value}\n") + if given_options is not None: for key, value in given_options.items(): setattr(options, key, value) diff --git a/src/gamspy/_symbols/alias.py b/src/gamspy/_symbols/alias.py index f739c58e..8163aa47 100644 --- a/src/gamspy/_symbols/alias.py +++ b/src/gamspy/_symbols/alias.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import threading import uuid from typing import TYPE_CHECKING @@ -75,11 +77,11 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, alias_with: Set | Alias | None = None, ): - if not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" @@ -98,20 +100,25 @@ def __new__( f"Name must of type `str` but found {type(name)}" ) try: - symobj = container[name] - if isinstance(symobj, cls): - return symobj + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + + symbol = container[name] + if isinstance(symbol, cls): + return symbol raise TypeError( f"Cannot overwrite symbol `{name}` in container" " because it is not an Alias object)" ) except KeyError: - return object.__new__(Alias) + return object.__new__(cls) def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, alias_with: Set | Alias = None, # type: ignore ): @@ -127,6 +134,15 @@ def __init__( self.modified = True self.alias_with = alias_with else: + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError("Alias requires a container.") from e + assert container is not None + if name is not None: name = validation.validate_name(name) else: diff --git a/src/gamspy/_symbols/equation.py b/src/gamspy/_symbols/equation.py index 2608c385..4882c6a9 100644 --- a/src/gamspy/_symbols/equation.py +++ b/src/gamspy/_symbols/equation.py @@ -2,6 +2,8 @@ import builtins import itertools +import os +import threading import uuid from enum import Enum from typing import TYPE_CHECKING, Any @@ -157,7 +159,7 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, type: str | EquationType = "regular", domain: list[Set | Alias | str] | Set | Alias | str | None = None, @@ -169,7 +171,7 @@ def __new__( is_miro_output: bool = False, definition_domain: list | None = None, ): - if not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( f"Container must of type `Container` but found {container}" ) @@ -182,6 +184,11 @@ def __new__( f"Name must of type `str` but found {builtins.type(name)}" ) try: + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -195,7 +202,7 @@ def __new__( def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, type: str | EquationType = "regular", domain: list[Set | Alias | str] | Set | Alias | str | None = None, @@ -272,6 +279,17 @@ def __init__( self.container._options.miro_protect = previous_state else: + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError( + "Equation requires a container." + ) from e + assert container is not None + type = cast_type(type) if name is not None: diff --git a/src/gamspy/_symbols/implicits/implicit_parameter.py b/src/gamspy/_symbols/implicits/implicit_parameter.py index d4b8647e..1cfbca8f 100644 --- a/src/gamspy/_symbols/implicits/implicit_parameter.py +++ b/src/gamspy/_symbols/implicits/implicit_parameter.py @@ -105,7 +105,7 @@ def __setitem__(self, indices: Iterable | str, rhs: Expression) -> None: rhs, ) - statement._validate_definition(domain) + statement._validate_definition(utils._unpack(domain)) self.container._add_statement(statement) self.parent._assignment = statement diff --git a/src/gamspy/_symbols/parameter.py b/src/gamspy/_symbols/parameter.py index 7b4ccdb1..3167bbb5 100644 --- a/src/gamspy/_symbols/parameter.py +++ b/src/gamspy/_symbols/parameter.py @@ -1,6 +1,8 @@ from __future__ import annotations import itertools +import os +import threading import uuid from collections.abc import Sequence from typing import TYPE_CHECKING, Any @@ -111,7 +113,7 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, domain: list[Set | Alias | str] | Set @@ -127,7 +129,7 @@ def __new__( is_miro_output: bool = False, is_miro_table: bool = False, ): - if not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" @@ -142,6 +144,11 @@ def __new__( ) try: + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -155,7 +162,7 @@ def __new__( def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, domain: list[Set | Alias | str] | Set @@ -228,6 +235,17 @@ def __init__( self.setRecords(records, uels_on_axes=uels_on_axes) self.container._options.miro_protect = previous_state else: + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError( + "Parameter requires a container." + ) from e + assert container is not None + if name is not None: name = validation.validate_name(name) diff --git a/src/gamspy/_symbols/set.py b/src/gamspy/_symbols/set.py index da0383f1..64e1fca6 100644 --- a/src/gamspy/_symbols/set.py +++ b/src/gamspy/_symbols/set.py @@ -1,6 +1,8 @@ from __future__ import annotations import itertools +import os +import threading import uuid from typing import TYPE_CHECKING, Any, Literal @@ -508,7 +510,7 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, domain: list[Set | Alias | str] | Set | Alias | str | None = None, is_singleton: bool = False, @@ -519,7 +521,7 @@ def __new__( is_miro_input: bool = False, is_miro_output: bool = False, ): - if not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" @@ -533,7 +535,13 @@ def __new__( f"Name must of type `str` but found {type(name)}" ) try: + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + symbol = container[name] + if isinstance(symbol, cls): return symbol @@ -546,7 +554,7 @@ def __new__( def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, domain: list[Set | Alias | str] | Set | Alias | str | None = None, is_singleton: bool = False, @@ -619,6 +627,15 @@ def __init__( self.container._options.miro_protect = previous_state else: + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError("Set requires a container.") from e + assert container is not None + self.where = condition.Condition(self) if name is not None: @@ -629,7 +646,6 @@ def __init__( name = "s" + str(uuid.uuid4()).replace("-", "_") self._singleton_check(is_singleton, records, domain) - previous_state = container._options.miro_protect container._options.miro_protect = False diff --git a/src/gamspy/_symbols/universe_alias.py b/src/gamspy/_symbols/universe_alias.py index 1dea7eb3..3e4015ba 100644 --- a/src/gamspy/_symbols/universe_alias.py +++ b/src/gamspy/_symbols/universe_alias.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import threading from typing import TYPE_CHECKING import gams.transfer as gt @@ -8,6 +10,7 @@ import gamspy as gp import gamspy._algebra.condition as condition import gamspy._validation as validation +from gamspy.exceptions import ValidationError if TYPE_CHECKING: from gamspy import Container @@ -66,8 +69,10 @@ def _constructor_bypass(cls, container: Container, name: str): return obj - def __new__(cls, container: Container, name: str = "universe"): - if not isinstance(container, gp.Container): + def __new__( + cls, container: Container | None = None, name: str = "universe" + ): + if container is not None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" @@ -77,6 +82,11 @@ def __new__(cls, container: Container, name: str = "universe"): raise TypeError(f"Name must of type `str` but found {type(name)}") try: + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -88,9 +98,20 @@ def __new__(cls, container: Container, name: str = "universe"): except KeyError: return object.__new__(cls) - def __init__(self, container: Container, name: str = "universe"): + def __init__( + self, container: Container | None = None, name: str = "universe" + ): # check if the name is a reserved word name = validation.validate_name(name) + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError( + "UniverseAlias requires a container." + ) from e super().__init__(container, name) diff --git a/src/gamspy/_symbols/variable.py b/src/gamspy/_symbols/variable.py index cb4a0968..7751ab2b 100644 --- a/src/gamspy/_symbols/variable.py +++ b/src/gamspy/_symbols/variable.py @@ -2,6 +2,8 @@ import builtins import itertools +import os +import threading import uuid from collections.abc import Sequence from enum import Enum @@ -145,7 +147,7 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, type: str = "free", domain: list[Set | Alias | str] @@ -160,7 +162,7 @@ def __new__( uels_on_axes: bool = False, is_miro_output: bool = False, ): - if not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): invalid_type = builtins.type(container) raise TypeError( f"Container must of type `Container` but found {invalid_type}" @@ -174,6 +176,11 @@ def __new__( f"Name must of type `str` but found {builtins.type(name)}" ) try: + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -187,7 +194,7 @@ def __new__( def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, type: str = "free", domain: list[Set | Alias | str] @@ -265,6 +272,17 @@ def __init__( self.container._options.miro_protect = previous_state else: + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError( + "Variable requires a container." + ) from e + assert container is not None + type = cast_type(type) if name is not None: diff --git a/src/gamspy/formulations/__init__.py b/src/gamspy/formulations/__init__.py index f3373d7e..a94d0bd9 100644 --- a/src/gamspy/formulations/__init__.py +++ b/src/gamspy/formulations/__init__.py @@ -1,4 +1,5 @@ import gamspy.formulations.nn as nn +import gamspy.formulations.piecewise as piecewise from gamspy.formulations.nn import ( AvgPool2d, Conv2d, @@ -6,14 +7,21 @@ MaxPool2d, MinPool2d, ) +from gamspy.formulations.piecewise import ( + pwl_convexity_formulation, + pwl_interval_formulation, +) from gamspy.formulations.shape import flatten_dims __all__ = [ "nn", + "piecewise", "Conv2d", "MaxPool2d", "MinPool2d", "AvgPool2d", "Linear", "flatten_dims", + "pwl_convexity_formulation", + "pwl_interval_formulation", ] diff --git a/src/gamspy/formulations/nn/linear.py b/src/gamspy/formulations/nn/linear.py index 988ce2b0..b6a5c167 100644 --- a/src/gamspy/formulations/nn/linear.py +++ b/src/gamspy/formulations/nn/linear.py @@ -205,7 +205,7 @@ def __call__( Otherwise, the output variable is unbounded. """ if not isinstance(propagate_bounds, bool): - raise TypeError("propagate_bounds should be a boolean.") + raise ValidationError("propagate_bounds should be a boolean.") if self.weight is None: raise ValidationError( @@ -250,6 +250,25 @@ def __call__( x_bounds[("0",) + tuple(input.domain)] = input.lo[...] x_bounds[("1",) + tuple(input.domain)] = input.up[...] + # If the bounds are all zeros (None in GAMSPy parameters); + # we skip matrix multiplication as it will result in zero values + if x_bounds.records is None: + out_bounds_array = np.zeros(out.shape) + + if self.use_bias: + out_bounds_array = out_bounds_array + self.bias_array + + out_bounds = gp.Parameter( + self.container, + out_bounds_name, + domain=dim(out.shape), + records=out_bounds_array, + ) + out.lo[...] = out_bounds + out.up[...] = out_bounds + + return out, [set_out] + x_lb, x_ub = x_bounds.toDense() # To deal with infinity values in the input bounds, we convert them into complex numbers diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py new file mode 100644 index 00000000..0df34df9 --- /dev/null +++ b/src/gamspy/formulations/piecewise.py @@ -0,0 +1,705 @@ +from __future__ import annotations + +import math +import typing + +import numpy as np + +import gamspy as gp +from gamspy._symbols.implicits import ( + ImplicitVariable, +) +from gamspy.exceptions import ValidationError + + +def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: + """ + Returns an n x n_bits NumPy array containing gray codes. + The bit difference between two consecutive rows is exactly + 1 bits. Required for the log piecewise linear formulation. + """ + a = np.arange(n) + b = a >> 1 + numbers = a ^ b + numbers_in_bit_array = ( + (numbers[:, None] & (1 << np.arange(n_bits))) > 0 + ).astype(int) + return numbers_in_bit_array + + +def _enforce_sos2_with_binary(lambda_var: gp.Variable) -> list[gp.Equation]: + """ + Enforces SOS2 constraints using binary variables. This function is not suitable + for generic SOS2 implementation since it restricts the lambda_var values to be + between 0 and 1. However, it is usually faster than using SOS2 variables. + + Based on paper: + `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints + `_ + """ + equations: list[gp.Equation] = [] + m = lambda_var.container + count_x = len(lambda_var.domain[-1]) + # edge case + lambda_var.lo[...] = 0 + lambda_var.up[...] = 1 + if count_x == 2: + # if there are only 2 elements, it is already sos2 + return equations + + J = lambda_var.domain[-1] + previous_domains = lambda_var.domain[:-1] + + l_len = math.ceil(math.log2(count_x - 1)) + I, L = gp.math._generate_dims( + m, + [ + count_x - 1, + l_len, + ], + ) + + J, I, L = gp.formulations.nn.utils._next_domains( + [J, I, L], previous_domains + ) + bin_var = m.addVariable(domain=[*previous_domains, L], type="binary") + gray_code = _generate_gray_code(count_x - 1, l_len) + + B = m.addParameter(domain=[I, L], records=gray_code) + + JI = m.addSet(domain=[J, I]) + JI[J, I].where[(gp.Ord(J) == gp.Ord(I)) | (gp.Ord(J) - 1 == gp.Ord(I))] = 1 + + use_set_1 = m.addSet(domain=[L, J]) + use_set_1[L, J].where[gp.Smin(JI[J, I], B[I, L]) == 1] = 1 + + use_set_2 = m.addSet(domain=[L, J]) + use_set_2[L, J].where[gp.Smax(JI[J, I], B[I, L]) == 0] = 1 + + sos2_eq_1 = m.addEquation(domain=[*previous_domains, L]) + sos2_eq_1[[*previous_domains, L]] = ( + gp.Sum(use_set_1[L, J], lambda_var[[*previous_domains, J]]) + <= bin_var[[*previous_domains, L]] + ) + equations.append(sos2_eq_1) + + sos2_eq_2 = m.addEquation(domain=[*previous_domains, L]) + sos2_eq_2[[*previous_domains, L]] = ( + gp.Sum(use_set_2[L, J], lambda_var[[*previous_domains, J]]) + <= 1 - bin_var[[*previous_domains, L]] + ) + equations.append(sos2_eq_2) + + return equations + + +def _enforce_discontinuity( + lambda_var: gp.Variable, + combined_indices: typing.Sequence[int], +) -> list[gp.Equation]: + equations: list[gp.Equation] = [] + + len_x_points = len(lambda_var.domain[-1]) + previous_domains = lambda_var.domain[:-1] + + m = lambda_var.container + J, J2, SB = gp.math._generate_dims( + m, [len_x_points, len_x_points, len(combined_indices)] + ) + + J, J2, SB = gp.formulations.nn.utils._next_domains( + [J, J2, SB], previous_domains + ) + + di_param = [ + (str(i), str(j), str(j + 1)) for i, j in enumerate(combined_indices) + ] + + select_set = m.addSet(domain=[SB, J, J2], records=di_param) + select_var = m.addVariable(domain=[*previous_domains, SB], type="binary") + + select_equation = m.addEquation(domain=[*previous_domains, SB, J, J2]) + select_equation[[*previous_domains, select_set[SB, J, J2]]] = ( + lambda_var[[*previous_domains, J]] + <= select_var[[*previous_domains, SB]] + ) + equations.append(select_equation) + + select_equation_2 = m.addEquation(domain=[*previous_domains, SB, J, J2]) + select_equation_2[[*previous_domains, select_set[SB, J, J2]]] = ( + lambda_var[[*previous_domains, J2]] + <= 1 - select_var[[*previous_domains, SB]] + ) + equations.append(select_equation_2) + + return equations + + +def _check_points( + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], +) -> tuple[list[int | float], list[int | float], list[int], list[int]]: + return_x = [] + return_y = [] + discontinuous_indices = [] + none_indices = [] + + if not isinstance(x_points, typing.Sequence): + raise ValidationError("x_points are expected to be a sequence") + + if not isinstance(y_points, typing.Sequence): + raise ValidationError("y_points are expected to be a sequence") + + if len(x_points) < 2: + raise ValidationError( + "piecewise linear functions require at least 2 points" + ) + + if len(y_points) != len(x_points): + raise ValidationError("x_points and y_points have different lenghts") + + if x_points[0] is None or x_points[-1] is None: + raise ValidationError("x_points cannot start or end with a None value") + + for x_p, y_p in zip(x_points, y_points): + if (x_p is None and y_p is not None) or ( + x_p is not None and y_p is None + ): + raise ValidationError( + "Both x and y must either be None or neither of them should be None" + ) + + if not isinstance(x_p, (float, int)) and x_p is not None: + raise ValidationError("x_points contains non-numerical items") + + if not isinstance(y_p, (float, int)) and y_p is not None: + raise ValidationError("y_points contains non-numerical items") + + for i in range(len(x_points) - 1): + if x_points[i] is None and x_points[i + 1] is None: + raise ValidationError( + "x_points cannot contain two consecutive None values" + ) + + if x_points[i] is None and x_points[i - 1] >= x_points[i + 1]: + raise ValidationError( + "A value following a None must be strictly greater than the value preceding the None" + ) + + if ( + (x_points[i] is not None) + and (x_points[i + 1] is not None) + and (x_points[i + 1] < x_points[i]) + ): + raise ValidationError( + "x_points should be in an non-decreasing order" + ) + + if x_points[i] is not None: + return_x.append(x_points[i]) + return_y.append(y_points[i]) + + if x_points[i] == x_points[i + 1]: + discontinuous_indices.append(len(return_x) - 1) + elif x_points[i] is None: + none_indices.append(len(return_x) - 1) + + return_x.append(x_points[-1]) + return_y.append(y_points[-1]) + return return_x, return_y, discontinuous_indices, none_indices + + +def _indicator( + indicator_var: gp.Variable, + indicator_val: typing.Literal[0, 1], + expr: gp.Expression, +) -> list[gp.Equation]: + # We will make this generic and public + if not isinstance(indicator_var, (gp.Variable, ImplicitVariable)): + raise ValidationError("indicator_var needs to be a variable") + + if indicator_var.type != "binary": + raise ValidationError("indicator_var needs to be a binary variable") + + if indicator_val != 0 and indicator_val != 1: + raise ValidationError("indicator_val needs to be 1 or 0") + + if not isinstance(expr, gp.Expression): + raise ValidationError("expr needs to be an expression") + + if expr.data not in {"=l=", "=e=", "=g="}: + raise ValidationError("expr needs to be inequality or equality") + + if len(expr.domain) != len(indicator_var.domain): + raise ValidationError( + "indicator_var and expr must have the same domain" + ) + + for i in range(len(expr.domain)): + if expr.domain[i].name != indicator_var.domain[i].name: + raise ValidationError( + "indicator_var and expr must have the same domain" + ) + + if expr.data == "=e=": + # sos1(bin_var, lhs - rhs) might be better + eqs1 = _indicator( + indicator_var, indicator_val, expr.left <= expr.right + ) + eqs2 = _indicator( + indicator_var, indicator_val, -expr.left <= -expr.right + ) + return [*eqs1, *eqs2] + + if expr.data == "=g=": + return _indicator( + indicator_var, indicator_val, -expr.left <= -expr.right + ) + + equations = [] + m = indicator_var.container + + slack_var = m.addVariable(domain=expr.domain, type="positive") + slack_eq = m.addEquation( + domain=expr.domain, definition=(expr.left - slack_var <= expr.right) + ) + equations.append(slack_eq) + + expr_domain = ... if len(expr.domain) == 0 else [*expr.domain] + + sos_dim = gp.math._generate_dims(m, [2])[0] + sos1_var = m.addVariable(domain=[*expr.domain, sos_dim], type="sos1") + sos1_eq_1 = m.addEquation(domain=expr.domain) + if indicator_val == 1: + sos1_eq_1[...] = ( + sos1_var[[*expr.domain, "0"]] == indicator_var[expr_domain] + ) + else: + sos1_eq_1[...] = ( + sos1_var[[*expr.domain, "0"]] == 1 - indicator_var[expr_domain] + ) + equations.append(sos1_eq_1) + + sos1_eq_2 = m.addEquation(domain=expr.domain) + sos1_eq_2[...] = sos1_var[[*expr.domain, "1"]] == slack_var[expr_domain] + equations.append(sos1_eq_2) + + return equations + + +def _generate_ray( + container: gp.Container, domain: list[gp.Set] +) -> tuple[gp.Variable, gp.Variable, list[gp.Equation]]: + # if b_var == 0 => x_var = 0 o.w x_var >= 0 + # effectively x_var <= bigM * b_var without bigM + x_var = container.addVariable(domain=domain, type="positive") + b_var = container.addVariable(domain=domain, type="binary") + eqs = _indicator(b_var, 0, x_var <= 0) + return x_var, b_var, eqs + + +def points_to_intervals( + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], + discontinuous_points: typing.Sequence[int], +) -> list[tuple[int | float, int | float, int | float, int | float]]: + result: list[ + tuple[int | float, int | float, int | float, int | float] + ] = [] + finished_at_disc = True + for i in range(len(x_points) - 1): + x1 = x_points[i] + x2 = x_points[i + 1] + y1 = y_points[i] + y2 = y_points[i + 1] + + if i in discontinuous_points: + if finished_at_disc: + result.append((x1, x1, 0, y1)) + + finished_at_disc = True + else: + finished_at_disc = False + slope = (y2 - y1) / (x2 - x1) + offset = y1 - (slope * x1) + result.append((x1, x2, slope, offset)) + + if finished_at_disc: + result.append((x2, x2, 0, y2)) + + return result + + +def _get_end_slopes( + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], +) -> tuple[float, float]: + if x_points[-1] != x_points[-2]: + m_pos = (y_points[-1] - y_points[-2]) / (x_points[-1] - x_points[-2]) + else: + m_pos = 0 + + if x_points[0] != x_points[1]: + m_neg = (y_points[0] - y_points[1]) / (x_points[0] - x_points[1]) + else: + m_neg = 0 + + return m_neg, m_pos + + +def pwl_interval_formulation( + input_x: gp.Variable, + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], + bound_left: bool = True, + bound_right: bool = True, +) -> tuple[gp.Variable, list[gp.Equation]]: + """ + This function implements a piecewise linear function using the intervals formulation. + Given an input (independent) variable `input_x`, along with the defining `x_points` + and corresponding `y_points` of the piecewise function, it constructs the dependent + variable `y` and formulates the equations necessary to define the function. + + Here is the interval formulation: + + .. math:: + \\lambda_i \\geq b_i * LB_i \\quad \\forall{i} + + \\lambda_i \\leq b_i * UB_i \\quad \\forall{i} + + \\sum_{i}{b_i} = 1 + + x = \\sum_{i}{\\lambda_i} + + y = \\sum_{i}{(\\lambda_i * slope_i) + (b_i * offset_i) } + + b_i \\in \\{0, 1\\} \\quad \\forall{i} + + The implementation handles discontinuities in the function. To represent a + discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` + array with corresponding values in `y_points`. For example, if `x_points` = + [1, 3, 3, 5] and `y_points` = [10, 30, 50, 70], the function allows y to take + either 30 or 50 when x = 3. Note that discontinuities introduce additional + binary variables. + + It is possible to disallow a specific range by including `None` in both + `x_points` and the corresponding `y_points`. For example, with + `x_points` = `[1, 3, None, 5, 7]` and `y_points` = `[10, 35, None, -20, 40]`, + the range between 3 and 5 is disallowed for `input_x`. + + However, `x_points` cannot start or end with a `None` value, and a `None` + value cannot be followed by another `None`. Additionally, if `x_i` is `None`, + then `y_i` must also be `None`. Similar to the discontinuities, disallowed + ranges always introduce additional binary variables. + + The input variable `input_x` is restricted to the range defined by + `x_points` unless `bound_left` or `bound_right` is set to False. Setting + either to True, creates SOS1 type of variables. When `input_x` is not bound, + you can assume as if the first and/or the last line segments are extended. + + Returns the dependent variable `y` and the equations required to model the + piecewise linear relationship. + + Parameters + ---------- + x : gp.Variable + Independent variable of the piecewise linear function + x_points: typing.Sequence[int | float] + Break points of the piecewise linear function in the x-axis + y_points: typing.Sequence[int | float] + Break points of the piecewise linear function in the y-axis + bound_left: bool = True + If input_x should be limited to start from x_points[0] + bound_right: bool = True + If input_x should be limited to end at x_points[-1] + + Returns + ------- + tuple[gp.Variable, list[Equation]] + + Examples + -------- + >>> from gamspy import Container, Variable, Set + >>> from gamspy.formulations import pwl_interval_formulation + >>> m = Container() + >>> x = Variable(m, "x") + >>> y, eqs = pwl_interval_formulation(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) + + """ + + if not isinstance(input_x, gp.Variable): + raise ValidationError("input_x is expected to be a Variable") + + if not isinstance(bound_left, bool): + raise ValidationError("bound_left is expected to be a boolean") + + if not isinstance(bound_right, bool): + raise ValidationError("bound_right is expected to be a boolean") + + x_points, y_points, discontinuous_indices, none_indices = _check_points( + x_points, y_points + ) + combined_indices = list({*discontinuous_indices, *none_indices}) + equations = [] + + intervals = points_to_intervals(x_points, y_points, combined_indices) + lowerbounds_input = [] + upperbounds_input = [] + slopes_input = [] + offsets_input = [] + for i, (lb, ub, slope, offset) in enumerate(intervals): + lowerbounds_input.append((str(i), lb)) + upperbounds_input.append((str(i), ub)) + slopes_input.append((str(i), slope)) + offsets_input.append((str(i), offset)) + + input_domain = input_x.domain + m = input_x.container + + J = gp.math._generate_dims(m, [len(intervals)])[0] + J = gp.formulations.nn.utils._next_domains([J], input_domain)[0] + + lowerbounds = m.addParameter(domain=[J], records=lowerbounds_input) + upperbounds = m.addParameter(domain=[J], records=upperbounds_input) + slopes = m.addParameter(domain=[J], records=slopes_input) + offsets = m.addParameter(domain=[J], records=offsets_input) + bin_var = m.addVariable(domain=[*input_domain, J], type="binary") + + lambda_var = m.addVariable(domain=[*input_domain, J]) + + set_lambda_lowerbound = m.addEquation(domain=lambda_var.domain) + set_lambda_lowerbound[...] = lowerbounds * bin_var <= lambda_var + equations.append(set_lambda_lowerbound) + + set_lambda_upperbound = m.addEquation(domain=lambda_var.domain) + set_lambda_upperbound[...] = upperbounds * bin_var >= lambda_var + equations.append(set_lambda_upperbound) + + out_y = m.addVariable(domain=input_domain) + + x_term = 0 + y_term = 0 + pick_one_term = 0 + + if bound_left is False or bound_right is False: + m_neg, m_pos = _get_end_slopes(x_points, y_points) + + if bound_left: + out_y.lo[...] = min(y_points) + else: + x_neg_inf, b_neg_inf, eqs_neg_inf = _generate_ray(m, input_domain) + equations.extend(eqs_neg_inf) + + x_term += -x_neg_inf + (b_neg_inf * x_points[0]) + y_term += -(m_neg * x_neg_inf) + (b_neg_inf * y_points[0]) + pick_one_term += b_neg_inf + + if bound_right: + out_y.up[...] = max(y_points) + else: + x_pos_inf, b_pos_inf, eqs_pos_inf = _generate_ray(m, input_domain) + equations.extend(eqs_pos_inf) + + x_term += x_pos_inf + (b_pos_inf * x_points[-1]) + y_term += (m_pos * x_pos_inf) + (b_pos_inf * y_points[-1]) + pick_one_term += b_pos_inf + + pick_one = m.addEquation(domain=input_domain) + pick_one[...] = gp.Sum(J, bin_var) + pick_one_term == 1 + equations.append(pick_one) + + set_x = m.addEquation(domain=input_domain) + set_x[...] = input_x == gp.Sum(J, lambda_var) + x_term + equations.append(set_x) + + set_y = m.addEquation(domain=input_domain) + set_y[...] = ( + out_y + == gp.Sum(J, lambda_var * slopes) + + gp.Sum(J, bin_var * offsets) + + y_term + ) + equations.append(set_y) + + return out_y, equations + + +def pwl_convexity_formulation( + input_x: gp.Variable, + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], + using: typing.Literal["binary", "sos2"] = "binary", + bound_left: bool = True, + bound_right: bool = True, +) -> tuple[gp.Variable, list[gp.Equation]]: + """ + This function implements a piecewise linear function using the convexity formulation. + Given an input (independent) variable `input_x`, along with the defining `x_points` + and corresponding `y_points` of the piecewise function, it constructs the dependent + variable `y` and formulates the equations necessary to define the function. + + Here is the convexity formulation: + + .. math:: + x = \\sum_{i}{x\\_points_i * \\lambda_i} + + y = \\sum_{i}{y\\_points_i * \\lambda_i} + + \\sum_{i}{\\lambda_i} = 1 + + \\lambda_i \\in SOS2 + + + By default, SOS2 variables are implemented using binary variables. + See + `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints + `_ + . However, you can switch to SOS2 (Special Ordered Set Type 2) by setting the + `using` parameter to `"sos2"`. + + The implementation handles discontinuities in the function. To represent a + discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` + array with corresponding values in `y_points`. For example, if `x_points` = + [1, 3, 3, 5] and `y_points` = [10, 30, 50, 70], the function allows y to take + either 30 or 50 when x = 3. Note that discontinuities always introduce + additional binary variables, regardless of the value of the using argument. + + It is possible to disallow a specific range by including `None` in both + `x_points` and the corresponding `y_points`. For example, with + `x_points` = `[1, 3, None, 5, 7]` and `y_points` = `[10, 35, None, -20, 40]`, + the range between 3 and 5 is disallowed for `input_x`. + + However, `x_points` cannot start or end with a `None` value, and a `None` + value cannot be followed by another `None`. Additionally, if `x_i` is `None`, + then `y_i` must also be `None`. Similar to the discontinuities, disallowed + ranges always introduce additional binary variables, regardless of the value + of the using argument. + + The input variable `input_x` is restricted to the range defined by + `x_points` unless `bound_left` or `bound_right` is set to False. Setting + either to True, creates SOS1 type of variables. When `input_x` is not bound, + you can assume as if the first and/or the last line segments are extended. + + Returns the dependent variable `y` and the equations required to model the + piecewise linear relationship. + + Parameters + ---------- + x : gp.Variable + Independent variable of the piecewise linear function + x_points: typing.Sequence[int | float] + Break points of the piecewise linear function in the x-axis + y_points: typing.Sequence[int| float] + Break points of the piecewise linear function in the y-axis + using: str = "binary" + What type of variable is used during implementing piecewise function + bound_left: bool = True + If input_x should be limited to start from x_points[0] + bound_right: bool = True + If input_x should be limited to end at x_points[-1] + + Returns + ------- + tuple[gp.Variable, list[Equation]] + + Examples + -------- + >>> from gamspy import Container, Variable, Set + >>> from gamspy.formulations import pwl_convexity_formulation + >>> m = Container() + >>> x = Variable(m, "x") + >>> y, eqs = pwl_convexity_formulation(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) + + """ + if using not in {"binary", "sos2"}: + raise ValidationError( + "Invalid value for the using argument." + "Possible values are 'binary' and 'sos2'" + ) + + if not isinstance(input_x, gp.Variable): + raise ValidationError("input_x is expected to be a Variable") + + if not isinstance(bound_left, bool): + raise ValidationError("bound_left is expected to be a boolean") + + if not isinstance(bound_right, bool): + raise ValidationError("bound_right is expected to be a boolean") + + x_points, y_points, discontinuous_indices, none_indices = _check_points( + x_points, y_points + ) + combined_indices = list({*discontinuous_indices, *none_indices}) + + m = input_x.container + input_domain = input_x.domain + out_y = m.addVariable(domain=input_domain) + equations = [] + + J = gp.math._generate_dims(m, [len(x_points)])[0] + J = gp.formulations.nn.utils._next_domains([J], input_domain)[0] + + x_par = m.addParameter(domain=[J], records=np.array(x_points)) + y_par = m.addParameter(domain=[J], records=np.array(y_points)) + + lambda_var = m.addVariable( + domain=[*input_domain, J], type="free" if using == "binary" else "sos2" + ) + + lambda_var.lo[...] = 0 + lambda_var.up[...] = 1 + + x_term = 0 + y_term = 0 + + if bound_left is False or bound_right is False: + m_neg, m_pos = _get_end_slopes(x_points, y_points) + + if bound_left: + out_y.lo[...] = min(y_points) + else: + x_neg_inf, b_neg_inf, eqs_neg_inf = _generate_ray(m, input_domain) + equations.extend(eqs_neg_inf) + + limit_b_neg_inf = m.addEquation(domain=b_neg_inf.domain) + limit_b_neg_inf[...] = b_neg_inf <= lambda_var[[*input_domain, "0"]] + equations.append(limit_b_neg_inf) + + x_term += -x_neg_inf + y_term += -(m_neg * x_neg_inf) + + if bound_right: + out_y.up[...] = max(y_points) + else: + x_pos_inf, b_pos_inf, eqs_pos_inf = _generate_ray(m, input_domain) + equations.extend(eqs_pos_inf) + + limit_b_pos_inf = m.addEquation(domain=b_pos_inf.domain) + last = str(len(J) - 1) + limit_b_pos_inf[...] = b_pos_inf <= lambda_var[[*input_domain, last]] + equations.append(limit_b_pos_inf) + + x_term += x_pos_inf + y_term += m_pos * x_pos_inf + + lambda_sum = m.addEquation(domain=input_x.domain) + lambda_sum[...] = gp.Sum(J, lambda_var) == 1 + equations.append(lambda_sum) + + set_x = m.addEquation(domain=input_x.domain) + set_x[...] = input_x == gp.Sum(J, x_par * lambda_var) + x_term + equations.append(set_x) + + set_y = m.addEquation(domain=input_x.domain) + set_y[...] = out_y == gp.Sum(J, y_par * lambda_var) + y_term + equations.append(set_y) + + if using == "binary": + extra_eqs = _enforce_sos2_with_binary(lambda_var) + equations.extend(extra_eqs) + + if len(combined_indices) > 0: + extra_eqs = _enforce_discontinuity(lambda_var, combined_indices) + equations.extend(extra_eqs) + + return out_y, equations diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index 5bd33e49..ac90a44f 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -37,7 +37,9 @@ def _flatten_dims_par( new_domain, _ = _get_new_domain(x, dims) new_shape = [len(d) for d in new_domain] - data = data.reshape(new_shape) + + if data is not None: + data = data.reshape(new_shape) out = m.addParameter(domain=new_domain, records=data) return out, [] @@ -51,8 +53,42 @@ def _generate_index_matching_statement( return base_txt.format(matching_set.name, domains_str, flattened.name) +def _propagate_bounds(x, out): + """Propagate bounds from the input to the output variable""" + + m = x.container + + # set domain for variable + x_domain = x.domain + x_domain = utils._next_domains(x_domain, []) + + bounds_set = m.addSet(records=["lb", "ub"]) + bounds = m.addParameter(domain=[bounds_set, *x_domain]) + + # capture original bounds + bounds[("lb",) + tuple(x_domain)] = x.lo[x_domain] + bounds[("ub",) + tuple(x_domain)] = x.up[x_domain] + + # reshape bounds based on the output variable's shape + # when bounds.records is None, it means the bounds are zeros + nb_data = ( + None + if bounds.records is None + else bounds.toDense().reshape((2,) + out.shape) + ) + + # set new domain for bounds + nb_dom = [bounds_set, *out.domain] + + new_bounds = m.addParameter(domain=nb_dom, records=nb_data) + + # assign new bounds to the output variable + out.lo[...] = new_bounds[("lb",) + tuple(out.domain)] + out.up[...] = new_bounds[("ub",) + tuple(out.domain)] + + def _flatten_dims_var( - x: gp.Variable, dims: list[int] + x: gp.Variable, dims: list[int], propagate_bounds: bool = True ) -> tuple[gp.Variable, list[gp.Equation]]: m = x.container new_domain, flattened = _get_new_domain(x, dims) @@ -60,6 +96,10 @@ def _flatten_dims_var( out = m.addVariable( domain=new_domain ) # outputs domain nearly matches the input domain + + if propagate_bounds and x.records is not None: + _propagate_bounds(x, out) + # match the flattened set to correct dims forwarded_domain = utils._next_domains([flattened, *x.domain], []) doms_to_flatten = [forwarded_domain[d + 1] for d in dims] @@ -85,10 +125,14 @@ def _flatten_dims_var( def flatten_dims( - x: gp.Variable | gp.Parameter, dims: list[int] + x: gp.Variable | gp.Parameter, + dims: list[int], + propagate_bounds: bool = True, ) -> tuple[gp.Parameter | gp.Variable, list[gp.Equation]]: """ Flatten domains indicated by `dims` into a single domain. + If `propagate_bounds` is True, and `x` is of type variable, + the bounds of the input variable are propagated to the output. Parameters ---------- @@ -97,6 +141,8 @@ def flatten_dims( dims: list[int] List of integers indicating indices of the domains to be flattened. Must be consecutive indices. + propagate_bounds: bool, optional + Propagate bounds from the input to the output variable. Default is True. Examples -------- @@ -114,6 +160,9 @@ def flatten_dims( if not isinstance(x, (gp.Parameter, gp.Variable)): raise ValidationError("Expected a parameter or a variable input") + if not isinstance(propagate_bounds, bool): + raise ValidationError("Expected a boolean for propagate_bounds") + if len(dims) < 2: raise ValidationError("Expected at least 2 items in the dim array") @@ -141,4 +190,4 @@ def flatten_dims( if isinstance(x, gp.Parameter): return _flatten_dims_par(x, dims) - return _flatten_dims_var(x, dims) + return _flatten_dims_var(x, dims, propagate_bounds) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py new file mode 100644 index 00000000..7dcdacf8 --- /dev/null +++ b/tests/integration/models/piecewiseLinear.py @@ -0,0 +1,478 @@ +""" +## LICENSETYPE: Requires license +## MODELTYPE: MIP +## KEYWORDS: piecewise linear function, binary, sos2 + + +Piecewise Linear +---------------- + +Description: A set of models for testing Piecewise Linear function implementation + +Usage: python piecewiseLinear.py +""" + +import math + +import numpy as np + +import gamspy as gp +import gamspy.formulations.piecewise as piecewise + + +def pwl_suite(fct, name): + print(f"PWL Suite function: {name}") + m = gp.Container() + x = gp.Variable(m, name="x") + + np.random.seed(1997) + x_points_1 = [ + int(x) + for x in sorted(np.random.randint(low=-1000, high=1000, size=(1000))) + ] + y_points_1 = [ + int(x) for x in (np.random.randint(low=-1000, high=1000, size=(1000))) + ] + xy = list(zip(x_points_1, y_points_1)) + max_pair = max(xy, key=lambda k: k[1]) + min_pair = min(xy, key=lambda k: k[1]) + + # A line segment between -1 and 1 + test_cases = [ + ([-1, 1], [-5, 5], -5, 5, -1, 1), + ([-1.1, 1, 100], [-5.2, 5, 20], -5.2, 20, -1.1, 100), + ([-1, 1, 1], [5, -5, -10], -10, 5, 1, -1), + ( + [-1, -1, 1], + [5, -5, 0], + -5, + 5, + -1, + -1, + ), + ( + x_points_1, + y_points_1, + min_pair[1], + max_pair[1], + min_pair[0], + max_pair[0], + ), + ] + + for case_i, ( + x_points, + y_points, + exp_min, + exp_max, + x_at_min, + x_at_max, + ) in enumerate(test_cases): + for sense, expected_y, expected_x in [ + ("min", exp_min, x_at_min), + ("max", exp_max, x_at_max), + ]: + if name == "convexity": + for using in ["sos2", "binary"]: + y, eqs = fct( + x, + x_points, + y_points, + using=using, + ) + model = gp.Model( + m, + equations=eqs, + objective=y, + sense=sense, + problem="mip", + ) + model.solve() + assert y.toDense() == expected_y, f"Case {case_i} failed !" + assert x.toDense() == expected_x, f"Case {case_i} failed !" + else: + y, eqs = fct( + x, + x_points, + y_points, + ) + model = gp.Model( + m, equations=eqs, objective=y, sense=sense, problem="mip" + ) + model.solve() + assert y.toDense() == expected_y, f"Case {case_i} failed !" + assert x.toDense() == expected_x, f"Case {case_i} failed !" + + print(f"Case {case_i} passed !") + + # test bound cases + # y is not bounded + if name == "convexity": + for using in ["sos2", "binary"]: + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 2] + y, eqs = fct( + x, + x_points, + y_points, + using=using, + bound_left=False, + bound_right=False, + ) + x.fx = -5 + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), -3), "Case 5 failed !" + print("Case 5 passed !") + x.fx = 100 + model.solve() + assert math.isclose(y.toDense(), 99), "Case 6 failed !" + print("Case 6 passed !") + + # y is upper bounded + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 0] + y, eqs = fct( + x, + x_points, + y_points, + using=using, + bound_left=False, + bound_right=False, + ) + model = gp.Model( + m, equations=eqs, objective=y, sense="max", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), 0), "Case 7 failed !" + print("Case 7 passed !") + x.fx = 100 + model.solve() + assert math.isclose(y.toDense(), 0), "Case 8 failed !" + print("Case 8 passed !") + # y is lower bounded + x_points = [-4, -2, 1, 3] + y_points = [-5, -5, 0, 2] + y, eqs = fct( + x, + x_points, + y_points, + using=using, + bound_left=False, + bound_right=False, + ) + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), -5), "Case 9 failed !" + print("Case 9 passed !") + x.fx = -100 + model.solve() + assert math.isclose(y.toDense(), -5), "Case 10 failed !" + print("Case 10 passed !") + else: + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 2] + y, eqs = fct( + x, x_points, y_points, bound_left=False, bound_right=False + ) + x.fx = -5 + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), -3), "Case 5 failed !" + print("Case 5 passed !") + x.fx = 100 + model.solve() + assert math.isclose(y.toDense(), 99), "Case 6 failed !" + print("Case 6 passed !") + + # y is upper bounded + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 0] + y, eqs = fct( + x, x_points, y_points, bound_left=False, bound_right=False + ) + model = gp.Model( + m, equations=eqs, objective=y, sense="max", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), 0), "Case 7 failed !" + print("Case 7 passed !") + x.fx = 100 + model.solve() + assert math.isclose(y.toDense(), 0), "Case 8 failed !" + print("Case 8 passed !") + # y is lower bounded + x_points = [-4, -2, 1, 3] + y_points = [-5, -5, 0, 2] + y, eqs = fct( + x, x_points, y_points, bound_left=False, bound_right=False + ) + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), -5), "Case 9 failed !" + print("Case 9 passed !") + x.fx = -100 + model.solve() + assert math.isclose(y.toDense(), -5), "Case 10 failed !" + print("Case 10 passed !") + + # test discontinuous function not allowing in between value + x_points = [1, 4, 4, 10] + y_points = [1, 4, 8, 25] + y, eqs = fct(x, x_points, y_points) + x.fx = 4 + y.fx = 6 # y can be either 4 or 8 but not their convex combination + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + res = model.solve() + assert ( + res["Model Status"].item() == "IntegerInfeasible" + ), "Case 11 failed !" + print("Case 11 passed !") + + # test None case + x_points = [1, 4, None, 6, 10] + y_points = [1, 4, None, 8, 25] + y, eqs = fct(x, x_points, y_points) + x.fx = 5 # should be IntegerInfeasible since 5 \in [4, 6] + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + res = model.solve() + assert ( + res["Model Status"].item() == "IntegerInfeasible" + ), "Case 12 failed !" + print("Case 12 passed !") + + # test None case + x_points = [1, 4, None, 6, 10] + y_points = [1, 4, None, 30, 25] + y, eqs = fct(x, x_points, y_points) + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + res = model.solve() + assert x.toDense() == 6, "Case 13 failed !" + assert y.toDense() == 30, "Case 13 failed !" + print("Case 13 passed !") + + # test None case + x_points = [1, 4, None, 6, 10] + y_points = [1, 45, None, 30, 25] + y, eqs = fct(x, x_points, y_points) + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + res = model.solve() + assert x.toDense() == 4, "Case 14 failed !" + assert y.toDense() == 45, "Case 14 failed !" + print("Case 14 passed !") + + # test with a non-scalar input + i = gp.Set(m, name="i", records=["1", "2", "3", "4", "5"]) + x2 = gp.Variable(m, name="x2", domain=[i]) + x_points = [1, 4, None, 6, 10, 10, 20] + y_points = [1, 45, None, 30, 25, 30, 12] + y, eqs = fct(x2, x_points, y_points) + x2.fx["1"] = 1 + x2.fx["2"] = 2.5 + x2.fx["3"] = 8 + x2.fx["4"] = 4 + x2.fx["5"] = 15 + model = gp.Model( + m, + equations=eqs, + objective=gp.Sum(y.domain, y), + sense="max", + problem="mip", + ) + model.solve() + assert np.allclose( + y.toDense(), np.array([1, 23, 27.5, 45, 21]) + ), "Case 14 failed !" + print("Case 14 passed !") + + # test unbounded when edges are discontinuous + if name == "convexity": + for using in ["binary", "sos2"]: + x_points = [-4, -4, -2, 1, 3, 3] + y_points = [20, -2, 0, 0, 2, 9] + y, eqs = fct( + x, + x_points, + y_points, + using=using, + bound_left=False, + bound_right=False, + ) + x.fx = -5 + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert y.toDense() == 20, "Case 15 failed !" + + print("Case 15 passed !") + else: + x_points = [-4, -4, -2, 1, 3, 3] + y_points = [20, -2, 0, 0, 2, 9] + y, eqs = fct( + x, x_points, y_points, bound_left=False, bound_right=False + ) + x.fx = -5 + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert y.toDense() == 20, "Case 15 failed !" + print("Case 15 passed !") + + # test single bound cases + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 2] + y, eqs = fct( + x, + x_points, + y_points, + bound_left=False, + bound_right=True, + ) + x.fx = -5 + model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") + model.solve() + + assert y.toDense() == -3, "Case 16 failed !" + print("Case 16 passed !") + + x.fx = 5 # bounded from right + res = model.solve() + assert ( + res["Model Status"].item() == "IntegerInfeasible" + ), "Case 17 failed !" + print("Case 17 passed !") + + y, eqs = fct( + x, + x_points, + y_points, + bound_left=True, + bound_right=False, + ) + x.fx = -5 + model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") + res = model.solve() + assert ( + res["Model Status"].item() == "IntegerInfeasible" + ), "Case 18 failed !" + print("Case 18 passed !") + + x.fx = 5 + model.solve() + + assert y.toDense() == 4, "Case 19 failed !" + print("Case 19 passed !") + + m.close() + + +def indicator_suite(): + print("Indicator Suite") + m = gp.Container() + x = gp.Variable(m, name="x") + b = gp.Variable(m, name="b", type="binary") + + eqs1 = piecewise._indicator(b, 1, x <= 50) + model = gp.Model( + m, + equations=eqs1, + objective=x, + sense="max", + problem="mip", + ) + b.fx = 1 + model.solve() + assert x.toDense() == 50, "Case 1 failed !" + print("Case 1 passed !") + + eqs2 = piecewise._indicator(b, 0, x <= 500) + b.lo = 0 + b.up = 1 + + model = gp.Model( + m, + equations=[*eqs1, *eqs2], + objective=x, + sense="max", + problem="mip", + ) + model.solve() + + assert x.toDense() == 500, "Case 2 failed !" + assert b.toDense() == 0, "Case 2 failed !" + print("Case 2 passed !") + + eqs3 = piecewise._indicator(b, 1, x <= 50) + model = gp.Model( + m, + equations=eqs3, + objective=x, + sense="max", + problem="mip", + ) + # b = 0 does not mean x cannot be less than 50 + b.fx = 0 + x.fx = 20 + model.solve() + assert x.toDense() == 20, "Case 3 failed !" + print("Case 3 passed !") + + eqs4 = piecewise._indicator(b, 1, x == 120) + model = gp.Model( + m, + equations=eqs4, + objective=x, + sense="min", + problem="mip", + ) + b.fx = 1 + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF + model.solve() + assert x.toDense() == 120, "Case 4 failed !" + print("Case 4 passed !") + + eqs5 = piecewise._indicator(b, 1, x >= 99) + model = gp.Model( + m, + equations=eqs5, + objective=x, + sense="min", + problem="mip", + ) + b.fx = 1 + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF + model.solve() + assert x.toDense() == 99, "Case 5 failed !" + print("Case 5 passed !") + m.close() + + +def main(): + print("Piecewise linear function test model") + pwl_suite(piecewise.pwl_convexity_formulation, "convexity") + pwl_suite(piecewise.pwl_interval_formulation, "interval") + indicator_suite() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/test_cmd_script.py b/tests/integration/test_cmd_script.py index fc6b9cff..d58a77b4 100644 --- a/tests/integration/test_cmd_script.py +++ b/tests/integration/test_cmd_script.py @@ -134,6 +134,44 @@ def test_install_license(teardown): assert process.returncode == 0, process.stderr + # Test checkout + process = subprocess.run( + [ + "gamspy", + "install", + "license", + os.environ["CHECKOUT_LICENSE"], + "-c", + "1", + "-o", + tmp_license_path, + ], + capture_output=True, + text=True, + ) + + assert process.returncode == 0, process.stderr + + # Test renew + process = subprocess.run( + [ + "gamspy", + "install", + "license", + os.environ["CHECKOUT_LICENSE"], + "-c", + "1", + "-r", + tmp_license_path, + "-o", + tmp_license_path, + ], + capture_output=True, + text=True, + ) + + assert process.returncode == 0, process.stderr + # Recover local license process = subprocess.run( ["gamspy", "install", "license", os.environ["LOCAL_LICENSE"]], diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index 8f048359..f863cbe5 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -3,9 +3,14 @@ import glob import math import os +import platform +import subprocess +import sys +import gamspy_base import pandas as pd import pytest +from gams import GamsExceptionExecution from gamspy import ( Container, @@ -18,6 +23,7 @@ Product, Sense, Set, + SolveStatus, Sum, Variable, ) @@ -25,11 +31,20 @@ pytestmark = pytest.mark.integration +try: + from dotenv import load_dotenv + + load_dotenv(os.getcwd() + os.sep + ".env") +except Exception: + pass + @pytest.fixture def data(): + ci_license_path = os.path.join(gamspy_base.directory, "ci_license.txt") + # Arrange - m = Container() + m = Container(debugging_level="keep") canning_plants = ["seattle", "san-diego"] markets = ["new-york", "chicago", "topeka"] distances = [ @@ -43,12 +58,47 @@ def data(): capacities = [["seattle", 350], ["san-diego", 600]] demands = [["new-york", 325], ["chicago", 300], ["topeka", 275]] + process = subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "install", + "license", + os.environ["MODEL_INSTANCE_LICENSE"], + "-o", + ci_license_path, + ], + check=True, + capture_output=True, + text=True, + ) + + assert process.returncode == 0, process.stderr + # Act and assert yield m, canning_plants, markets, capacities, demands, distances - # Cleanup m.close() + process = subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "install", + "license", + os.environ["LOCAL_LICENSE"], + "-o", + ci_license_path, + ], + check=True, + capture_output=True, + text=True, + ) + + assert process.returncode == 0, process.stderr + files = glob.glob("_*") for file in files: os.remove(file) @@ -60,6 +110,10 @@ def data(): os.remove("gams.gms") +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_parameter_change(data): m, canning_plants, markets, capacities, demands, distances = data i = Set(m, name="i", records=canning_plants) @@ -142,6 +196,10 @@ def test_parameter_change(data): assert not transport._is_frozen +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_variable_change(data): m, canning_plants, markets, capacities, demands, distances = data i = Set(m, name="i", records=canning_plants) @@ -185,6 +243,10 @@ def test_variable_change(data): transport.unfreeze() +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_fx(data): m, *_ = data INCOME0 = Parameter( @@ -230,6 +292,10 @@ def test_fx(data): mm.unfreeze() +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_validations(data): m, canning_plants, markets, capacities, demands, distances = data i = Set(m, name="i", records=canning_plants) @@ -282,10 +348,23 @@ def test_validations(data): assert os.path.exists("gams.gms") # Test solver options - transport.solve(solver="conopt", solver_options={"rtmaxv": "1.e12"}) - assert os.path.exists(os.path.join(m.working_directory, "conopt.opt")) + with open("_out.txt", "w") as file: + transport.solve( + solver="conopt", output=file, solver_options={"rtmaxv": "1.e12"} + ) + + with open("_out.txt") as file: + assert ">> rtmaxv 1.e12" in file.read() + options_path = os.path.join(m.working_directory, "conopt.opt") + assert os.path.exists(options_path) + os.remove(options_path) + +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_modifiable_in_condition(data): m, *_ = data td_data = pd.DataFrame( @@ -511,6 +590,10 @@ def test_modifiable_in_condition(data): war.freeze(modifiables=[x.l]) +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_modifiable_with_domain(data): m, *_ = data import gamspy as gp @@ -548,3 +631,74 @@ def test_modifiable_with_domain(data): assert math.isclose( mymodel.objective_value, 32.36124699832342, rel_tol=1e-6 ) + + +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) +def test_license(): + ci_license_path = os.path.join(gamspy_base.directory, "ci_license.txt") + subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "uninstall", + "license", + ], + check=True, + capture_output=True, + text=True, + ) + m = Container() + i = Set(m, "i", records=range(5000)) + p = Parameter(m, "p", domain=i) + p2 = Parameter(m, "p2", records=5) + p.generateRecords() + v1 = Variable(m, "v1", domain=i) + z = Variable(m, "z") + e1 = Equation(m, "e1", domain=i) + + e1[i] = p2 * v1[i] * p[i] >= z + model = Model( + m, name="my_model", equations=[e1], sense=Sense.MIN, objective=z + ) + with pytest.raises(GamsExceptionExecution): + model.freeze(modifiables=[p2]) + + subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "install", + "license", + os.environ["MODEL_INSTANCE_LICENSE"], + "-o", + ci_license_path, + ], + check=True, + capture_output=True, + text=True, + ) + + model.freeze(modifiables=[p2]) + model.solve() + assert model.solve_status == SolveStatus.NormalCompletion + + subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "install", + "license", + os.environ["LOCAL_LICENSE"], + "-o", + ci_license_path, + ], + check=True, + capture_output=True, + text=True, + ) diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index 6192b033..df993a75 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -12,6 +12,7 @@ import numpy as np import pytest +import gamspy as gp import gamspy._validation as validation import gamspy.math as gamspy_math from gamspy import ( @@ -156,7 +157,93 @@ def transport(f_value): transport.solve() m.close() + return transport.objective_value + + +def transport_with_ctx(f_value): + distances = [ + ["seattle", "new-york", 2.5], + ["seattle", "chicago", 1.7], + ["seattle", "topeka", 1.8], + ["san-diego", "new-york", 2.5], + ["san-diego", "chicago", 1.8], + ["san-diego", "topeka", 1.4], + ] + + capacities = [["seattle", 350], ["san-diego", 600]] + demands = [["new-york", 325], ["chicago", 300], ["topeka", 275]] + + m = Container() + with m: + i = Set( + name="i", + records=["seattle", "san-diego"], + description="canning plants", + ) + j = Set( + name="j", + records=["new-york", "chicago", "topeka"], + description="markets", + ) + # Data + a = Parameter( + name="a", + domain=i, + records=capacities, + description="capacity of plant i in cases", + ) + b = Parameter( + name="b", + domain=j, + records=demands, + description="demand at market j in cases", + ) + d = Parameter( + name="d", + domain=[i, j], + records=distances, + description="distance in thousands of miles", + ) + c = Parameter( + name="c", + domain=[i, j], + description="transport cost in thousands of dollars per case", + ) + f = Parameter(name="f", records=f_value) + c[i, j] = f * d[i, j] / 1000 + + # Variable + x = Variable( + name="x", + domain=[i, j], + type="Positive", + description="shipment quantities in cases", + ) + + # Equation + supply = Equation( + name="supply", + domain=i, + description="observe supply limit at plant i", + ) + demand = Equation( + name="demand", domain=j, description="satisfy demand at market j" + ) + + supply[i] = Sum(j, x[i, j]) <= a[i] + demand[j] = Sum(i, x[i, j]) >= b[j] + + transport = Model( + name="transport", + equations=m.getEquations(), + problem="LP", + sense=Sense.MIN, + objective=Sum((i, j), c[i, j] * x[i, j]), + ) + transport.solve() + + m.close() return transport.objective_value @@ -790,7 +877,7 @@ def test_interrupt(): m, name="dice", description="number of dice", - records=[f"dice{idx}" for idx in range(1, 10)], + records=[f"dice{idx}" for idx in range(1, 7)], ) flo = Parameter(m, name="flo", description="lowest face value", records=1) @@ -858,7 +945,7 @@ def interrupt_gams(model): thread = threading.Thread(target=interrupt_gams, args=(xdice,)) thread.start() - xdice.solve(output=sys.stdout) + xdice.solve(output=sys.stdout, options=Options(time_limit=60)) assert xdice.objective_value is not None assert xdice.solve_status == SolveStatus.UserInterrupt thread.join() @@ -1132,6 +1219,96 @@ def test_validation_2(): ) +def test_validation_3(): + m = Container() + + v = Set(m, "v") + k = Set(m, "k") + z = Variable(m, "z", domain=[v, k]) + vk = Set(m, "vk", domain=[v, k]) + n = Parameter(m, domain=k) + + z.up[vk[v, k]] = n[k] + + +def test_context_manager(data): + with Container(): + i = Set() + a = Alias(alias_with=i) + _ = Parameter() + _ = Variable() + _ = Equation() + + m, canning_plants, markets, capacities, demands, distances = data + with m: + i = Set(records=canning_plants) + j = Set(name="j", records=markets) + + a = Parameter(name="a", domain=i, records=capacities) + b = Parameter(name="b", domain=j, records=demands) + d = Parameter(name="d", domain=[i, j], records=distances) + c = Parameter( + name="c", + domain=[i, j], + description="transport cost in thousands of dollars per case", + ) + c[i, j] = 90 * d[i, j] / 1000 + + # Variable + x = Variable( + name="x", + domain=[i, j], + type="Positive", + description="shipment quantities in cases", + ) + + # Equation + supply = Equation( + name="supply", + domain=i, + description="observe supply limit at plant i", + ) + demand = Equation( + name="demand", domain=j, description="satisfy demand at market j" + ) + + supply[i] = Sum(j, x[i, j]) <= a[i] + demand[j] = Sum(i, x[i, j]) >= b[j] + + transport = Model( + name="transport", + equations=m.getEquations(), + problem="LP", + sense=Sense.MIN, + objective=Sum((i, j), c[i, j] * x[i, j]), + ) + transport.solve() + + import math + + assert math.isclose(transport.objective_value, 153.675000, rel_tol=0.001) + + assert i.container.working_directory is m.working_directory + + # We should still be able to access the symbols of m + c[i, j] = 90 * d[i, j] / 100 + transport.solve() + assert math.isclose(transport.objective_value, 1536.75000, rel_tol=0.001) + + m2 = Container() + i2 = Set(m2, "i2") + a2 = Parameter(m2, "a2", domain=i2) + assert i2.container.working_directory is m2.working_directory + assert a2.container.working_directory is m2.working_directory + + with m2: + i3 = Set(m2, "i3") + assert i3.container.working_directory is m2.working_directory + + # Make sure that the symbols of m is not affected by the new context manager + assert i.container.working_directory is m.working_directory + + def test_after_exception(data): m, *_ = data x = Variable(m, "x", type="positive") @@ -1352,6 +1529,30 @@ def test_multiprocessing(): assert math.isclose(expected, objective) +def test_multiprocessing_with_ctx(): + f_values = [90, 120, 150, 180] + expected_values = [153.675, 204.89999999999998, 256.125, 307.35] + with concurrent.futures.ProcessPoolExecutor() as executor: + for expected, objective in zip( + expected_values, executor.map(transport_with_ctx, f_values) + ): + assert math.isclose(expected, objective) + + assert gp._ctx_managers == {} + + +def test_threading_with_ctx(): + f_values = [90, 120, 150, 180] + expected_values = [153.675, 204.89999999999998, 256.125, 307.35] + with concurrent.futures.ThreadPoolExecutor() as executor: + for expected, objective in zip( + expected_values, executor.map(transport_with_ctx, f_values) + ): + assert math.isclose(expected, objective) + + assert gp._ctx_managers == {} + + def test_selective_loading(data): m, canning_plants, markets, capacities, demands, distances = data i = Set(m, name="i", records=canning_plants) diff --git a/tests/test_gamspy.py b/tests/test_gamspy.py index e3c2795b..13bb1f76 100644 --- a/tests/test_gamspy.py +++ b/tests/test_gamspy.py @@ -14,7 +14,7 @@ @pytest.mark.unit def test_version(): - assert gamspy.__version__ == "1.4.0" + assert gamspy.__version__ == "1.5.0" @pytest.mark.doc diff --git a/tests/unit/test_alias.py b/tests/unit/test_alias.py index 915ff1f0..f72b4ec5 100644 --- a/tests/unit/test_alias.py +++ b/tests/unit/test_alias.py @@ -125,7 +125,7 @@ def test_universe_alias_creation(m): _ = UniverseAlias(m, 5) # no container - pytest.raises(TypeError, UniverseAlias) + pytest.raises(ValidationError, UniverseAlias) # non-container type container pytest.raises(TypeError, UniverseAlias, 5, "j") diff --git a/tests/unit/test_container.py b/tests/unit/test_container.py index 7f206f6f..e4bc50d9 100644 --- a/tests/unit/test_container.py +++ b/tests/unit/test_container.py @@ -188,6 +188,35 @@ def test_read_write(data): assert list(m.data.keys()) == ["k", "i"] +def test_read_synch(): + m = Container() + + j = Set( + m, + name="j", + records=["new-york", "chicago", "topeka"], + description="markets", + ) + _ = Set( + m, + name="j_sub", + records=["new-york", "chicago"], + domain=j, + description="markets", + ) + + gdx_file = "test.gdx" + m.write(gdx_file) + m = Container() + m.read(gdx_file) + + assert m["j"].toList() == ["new-york", "chicago", "topeka"] + assert m["j_sub"].toList() == ["new-york", "chicago"] + m["j_sub"][m["j"]] = False + + os.remove("test.gdx") + + def test_loadRecordsFromGdx(data): m, *_ = data gdx_path = os.path.join("tmp", "test.gdx") diff --git a/tests/unit/test_equation.py b/tests/unit/test_equation.py index ee289df7..f0aa03f7 100644 --- a/tests/unit/test_equation.py +++ b/tests/unit/test_equation.py @@ -59,7 +59,7 @@ def test_equation_creation(data): pytest.raises(TypeError, Equation, m, 5) # no container - pytest.raises(TypeError, Equation) + pytest.raises(ValidationError, Equation) # non-container type container pytest.raises(TypeError, Equation, 5, "j") diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py new file mode 100644 index 00000000..97b37139 --- /dev/null +++ b/tests/unit/test_formulation.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import pytest + +import gamspy as gp +import gamspy.formulations.piecewise as piecewise +from gamspy.exceptions import ValidationError +from gamspy.formulations import ( + pwl_convexity_formulation, + pwl_interval_formulation, +) + +pytestmark = pytest.mark.unit + +fcts_to_test = [ + pwl_convexity_formulation, + pwl_interval_formulation, +] + + +@pytest.fixture +def data(): + m = gp.Container() + x = gp.Variable(m, "x") + x2 = gp.Variable(m, "x2", domain=gp.math.dim([2, 4, 3])) + x_points = [-10, 2.2, 5, 10] + y_points = [10, 20, -2, -5] + return { + "m": m, + "x": x, + "x2": x2, + "x_points": x_points, + "y_points": y_points, + } + + +def get_var_count_by_type(m: gp.Container) -> dict[str, int]: + count = {} + for k in m.data: + symbol = m.data[k] + if not isinstance(symbol, gp.Variable): + continue + + sym_type = symbol.type + if sym_type not in count: + count[sym_type] = 0 + + count[sym_type] += 1 + + return count + + +def test_pwl_enforce_sos2_log_binary(): + m = gp.Container() + i = gp.Set(m, name="i", records=["1", "2", "3"]) + lambda_var = gp.Variable(m, name="lambda", domain=[i]) + # this will create binary variables + eqs = piecewise._enforce_sos2_with_binary(lambda_var) + assert len(eqs) == 2 + var_count = get_var_count_by_type(m) + assert var_count["binary"] == 1 + + +def test_pwl_enforce_sos2_log_binary_2(): + m = gp.Container() + i = gp.Set(m, name="i", records=["1", "2"]) + lambda_var = gp.Variable(m, name="lambda", domain=[i]) + # this will create binary variables + eqs = piecewise._enforce_sos2_with_binary(lambda_var) + assert len(eqs) == 0 + var_count = get_var_count_by_type(m) + assert "binary" not in var_count + + +def test_pwl_indicator(): + m = gp.Container() + i = gp.Set(m, name="i", records=["1", "2"]) + j = gp.Set(m, name="j", records=["1", "2", "3"]) + k = gp.Set(m, name="k", records=["a", "b"]) + + b = gp.Variable(m, name="b", type="binary", domain=[i]) + b2 = gp.Variable(m, name="b2", type="free", domain=[j]) + x = gp.Variable(m, name="x", domain=[i]) + x2 = gp.Variable(m, name="x2", domain=[j]) + x3 = gp.Variable(m, name="x3", domain=[k]) + x4 = gp.Variable(m, name="x4", domain=[i, k]) + + b3 = gp.Variable(m, name="b3", type="binary") + x5 = gp.Variable(m, name="x5") + + pytest.raises( + ValidationError, piecewise._indicator, "indicator_var", 0, x <= 10 + ) + + pytest.raises(ValidationError, piecewise._indicator, b2, 0, x <= 10) + pytest.raises(ValidationError, piecewise._indicator, b, -1, x <= 10) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x + 10) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x3 >= 10) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x2 >= 10) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x4 >= 10) + + eqs1 = piecewise._indicator(b, 0, x >= 10) + eqs2 = piecewise._indicator(b, 0, x <= 10) + eqs3 = piecewise._indicator(b, 0, x == 10) + assert len(eqs1) == len(eqs2) + assert len(eqs3) == len(eqs1) * 2 + + eqs4 = piecewise._indicator(b3, 1, x5 >= 10) + assert len(eqs4) == len(eqs1) + + var_count = get_var_count_by_type(m) + assert "sos1" in var_count + + piecewise._indicator(b, 1, x >= 10) + piecewise._indicator(b, 1, x <= 10) + piecewise._indicator(b, 1, x == 10) + + +def test_pwl_enforce_sos2_log_binary_with_domain(): + m = gp.Container() + j = gp.Set(m, name="j", records=["1", "2"]) + i = gp.Set(m, name="i", records=["1", "2", "3"]) + lambda_var = gp.Variable(m, name="lambda", domain=[j, i]) + # this will create binary variables + eqs = piecewise._enforce_sos2_with_binary(lambda_var) + assert len(eqs) == 2 + var_count = get_var_count_by_type(m) + assert var_count["binary"] == 1 + + for k in m.data: + sym = m.data[k] + if isinstance(sym, gp.Equation): + assert len(sym.domain) == 2 + assert sym.domain[0] == j + + +def test_pwl_enforce_sos2_log_binary_with_domain_2(): + m = gp.Container() + lambda_var = gp.Variable(m, name="lambda", domain=gp.math.dim([3, 8])) + # this will create binary variables + eqs = piecewise._enforce_sos2_with_binary(lambda_var) + assert len(eqs) == 2 + var_count = get_var_count_by_type(m) + assert var_count["binary"] == 1 + + for k in m.data: + sym = m.data[k] + if isinstance(sym, gp.Equation): + assert len(sym.domain) == 2 + print(sym.getDefinition()) + + +def test_pwl_enforce_discontinuity(): + m = gp.Container() + lambda_var = gp.Variable(m, name="lambda", domain=gp.math.dim([5, 5])) + # this will create binary variables + eqs = piecewise._enforce_discontinuity(lambda_var, [1, 3]) + assert len(eqs) == 2 + assert len(eqs[0].domain) == 4 + assert len(eqs[1].domain) == 4 + + +def test_pwl_gray_code(): + for n, m in [(2, 1), (3, 2), (4, 2), (5, 3), (8, 3), (513, 10), (700, 10)]: + code = piecewise._generate_gray_code(n, m) + old = None + for row in code: + if old is None: + old = row + continue + + diff = old - row + count = 0 + for col in diff: + count += abs(col) + + # in gray code consecutive two rows differ by 1 bit + assert count == 1, "Gray code row had more than 1 change" + old = row + + +def test_pwl_with_sos2(data): + m = data["m"] + x = data["x"] + x_points = data["x_points"] + y_points = data["y_points"] + y, eqs = pwl_convexity_formulation(x, x_points, y_points, using="sos2") + y2, eqs2 = pwl_convexity_formulation( + x, + x_points, + y_points, + bound_left=False, + bound_right=False, + using="sos2", + ) + + # there should be no binary variables + var_count = get_var_count_by_type(m) + assert var_count["sos2"] == 2 # since we called it twice + assert y.type == "free" + assert y2.type == "free" + + +def test_pwl_with_binary(data): + m = data["m"] + x = data["x"] + x_points = data["x_points"] + y_points = data["y_points"] + y, eqs = pwl_convexity_formulation(x, x_points, y_points, using="binary") + y2, eqs2 = pwl_convexity_formulation(x, x_points, y_points, using="binary") + + # there should be no sos2 variables + var_count = get_var_count_by_type(m) + assert "sos2" not in var_count + assert var_count["binary"] == 2 # since we called it twice + assert y.type == "free" + assert y2.type == "free" + assert len(eqs) == len(eqs2) + + +def test_pwl_with_domain(data): + x2 = data["x2"] + x_points = data["x_points"] + y_points = data["y_points"] + y, eqs = pwl_convexity_formulation(x2, x_points, y_points, using="binary") + y2, eqs2 = pwl_convexity_formulation(x2, x_points, y_points, using="sos2") + y3, eqs3 = pwl_interval_formulation(x2, x_points, y_points) + + assert len(y.domain) == len(x2.domain) + assert len(y2.domain) == len(x2.domain) + assert len(y3.domain) == len(x2.domain) + + +def test_pwl_with_none(data): + x = data["x"] + x_points = [1, None, 2, 3] + y_points = [10, None, 20, 45] + y, eqs = pwl_convexity_formulation(x, x_points, y_points) + y2, eqs2 = pwl_interval_formulation(x, x_points, y_points) + + +def test_pwl_finished_start_with_disc(data): + x = data["x"] + x_points = [1, 1, None, 2, 3, 3] + y_points = [0, 10, None, 20, 45, 0] + y, eqs = pwl_convexity_formulation( + x, + x_points, + y_points, + bound_left=False, + bound_right=False, + ) + y2, eqs2 = pwl_interval_formulation( + x, + x_points, + y_points, + bound_left=False, + bound_right=False, + ) + + +@pytest.mark.parametrize("fct", fcts_to_test) +def test_pwl_bound_cases(data, fct): + x = data["x"] + x_points = data["x_points"] + y_points = data["y_points"] + + fct(x, x_points, y_points, bound_left=False, bound_right=False) + fct(x, x_points, y_points, bound_left=False, bound_right=True) + fct(x, x_points, y_points, bound_left=True, bound_right=True) + fct(x, x_points, y_points, bound_left=True, bound_right=False) + + +@pytest.mark.parametrize("fct", fcts_to_test) +def test_pwl_validation(data, fct): + x = data["x"] + x_points = data["x_points"] + y_points = data["y_points"] + + # incorrect using value + pytest.raises( + ValidationError, + fct, + x, + x_points, + y_points, + "hello", + ) + + # x not a variable + pytest.raises( + ValidationError, + fct, + 10, + x_points, + y_points, + ) + + # incorrect x_points, y_points + pytest.raises( + ValidationError, + fct, + x, + 10, + y_points, + ) + + pytest.raises( + ValidationError, + fct, + x, + x_points, + 10, + ) + + pytest.raises( + ValidationError, + fct, + x, + [1], + [10], + ) + + pytest.raises( + ValidationError, + fct, + x, + x_points, + [10], + ) + + pytest.raises( + ValidationError, + fct, + x, + [*x_points, "a"], + [*y_points, 5], + ) + + pytest.raises( + ValidationError, + fct, + x, + [*x_points, 16], + [*y_points, "a"], + ) + + pytest.raises( + ValidationError, + fct, + x, + [3, 2, 1], + [10, 20, 30], + ) + + pytest.raises( + ValidationError, + fct, + x, + [3, 1, 2], + [10, 20, 30], + ) + + pytest.raises( + ValidationError, + fct, + x, + [1, 3, 2], + [10, 20, 30], + ) + + pytest.raises( + ValidationError, + fct, + x, + [1], + [10], + ) + + pytest.raises( + ValidationError, + fct, + x, + [None, 2, 3], + [None, 20, 40], + ) + + pytest.raises( + ValidationError, + fct, + x, + [2, 3, None], + [20, 40, None], + ) + + pytest.raises( + ValidationError, + fct, + x, + [None, 2, 3, None], + [None, 20, 40, None], + ) + + pytest.raises( + ValidationError, + fct, + x, + [0, None, 2, 3], + [0, 10, 20, 40], + ) + + pytest.raises( + ValidationError, + fct, + x, + [0, 1, 2, 3], + [0, None, 20, 40], + ) + + pytest.raises( + ValidationError, + fct, + x, + [1, None, None, 2, 3], + [10, None, None, 20, 40], + ) + + pytest.raises( + ValidationError, + fct, + x, + [2, None, 2, 3], + [10, None, 20, 40], + ) + + pytest.raises( + ValidationError, + fct, + x, + [2, None, 4, 10], + [10, None, 20, 40], + bound_left="yes", + ) + + pytest.raises( + ValidationError, + fct, + x, + [2, None, 4, 10], + [10, None, 20, 40], + bound_right="yes", + ) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index be3fa075..514c7a05 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1766,6 +1766,13 @@ def test_flatten_bad(data): pytest.raises( ValidationError, flatten_dims, par_input, [1, 3] ) # non consecutive + pytest.raises( + ValidationError, + flatten_dims, + par_input, + [0, 1], + propagate_bounds="True", + ) # propagate_bounds not bool i = gp.Set(m, "i") j = gp.Set(m, "j") @@ -1804,6 +1811,19 @@ def test_flatten_par(data): assert eqs == [] # for parameters no equation needed +def test_flatten_par_with_no_records(data): + m, *_ = data + + par = gp.Parameter(m, "par", domain=dim([10, 5])) + + # 10x5 -> 50 + par_flattened, eqs = flatten_dims(par, [0, 1]) + + assert par_flattened.toDense() is None + assert par_flattened.shape == (50,) + assert eqs == [] # for parameters no equation needed + + def test_flatten_var_copied_domain(data): m, w1, b1, inp, par_input, ii = data @@ -1842,6 +1862,217 @@ def test_flatten_var_copied_domain(data): assert np.allclose(out_data_2, data.reshape(400 * 400)) +def test_flatten_2d_propagate_bounds(data): + m, *_ = data + i = gp.Set(m, name="i", records=[f"i{i}" for i in range(1, 41)]) + j = gp.Set(m, name="j", records=[f"j{j}" for j in range(1, 51)]) + var = gp.Variable(m, name="var", domain=[i, j]) + + # If the variable is unbounded, the bounds are not propagated even if propagate_bounds is True + var_1, eqs_1 = flatten_dims(var, [0, 1], propagate_bounds=True) + var_2, eqs_2 = flatten_dims(var, [0, 1], propagate_bounds=False) + assert var_1.records == var_2.records + + # If the variable is bounded, the bounds are propagated + bound_up = np.random.rand(40, 50) * 5 + bound_lo = np.random.rand(40, 50) * -5 + upper = gp.Parameter(m, name="upper", domain=[i, j], records=bound_up) + lower = gp.Parameter(m, name="lower", domain=[i, j], records=bound_lo) + var.up[...] = upper[...] + var.lo[...] = lower[...] + + var_3, eqs_3 = flatten_dims(var, [0, 1]) + + model = gp.Model( + m, + "flatten_everything", + equations=[*eqs_1, *eqs_2, *eqs_3], + problem="lp", + objective=var_3["240"] + 1, + sense="min", + ) + + model.solve() + + assert np.allclose( + np.array(var_3.records.lower.tolist()), bound_lo.reshape(2000) + ) + assert np.allclose( + np.array(var_3.records.upper.tolist()), bound_up.reshape(2000) + ) + + +def test_flatten_3d_propagate_bounds(data): + m, *_ = data + i = gp.Set(m, name="i", records=[f"i{i}" for i in range(1, 41)]) + j = gp.Set(m, name="j", records=[f"j{j}" for j in range(1, 51)]) + k = gp.Set(m, name="k", records=[f"k{k}" for k in range(1, 21)]) + var = gp.Variable(m, name="var", domain=[i, j, k]) + bounds_set = gp.Set(m, name="bounds_set", records=["lb", "ub"]) + + # If the variable is unbounded, the bounds are not propagated even if propagate_bounds is True + var_1, eqs_1 = flatten_dims(var, [0, 1], propagate_bounds=True) + var_2, eqs_2 = flatten_dims(var, [1, 2], propagate_bounds=True) + assert (var_1.records is None) and (var_2.records is None) + assert var_1.shape == (2000, 20) and var_2.shape == (40, 1000) + + # If the variable is bounded, the bounds are propagated + bound_up = np.random.rand(40, 50, 20) * 5 + bound_lo = np.random.rand(40, 50, 20) * -5 + all_bounds = np.stack([bound_lo, bound_up], axis=0) + + bounds = gp.Parameter( + m, name="bounds", domain=[bounds_set, i, j, k], records=all_bounds + ) + + var.up[...] = bounds[("ub", i, j, k)] + var.lo[...] = bounds[("lb", i, j, k)] + + var_3, eqs_3 = flatten_dims(var, [0, 1]) + var_4, eqs_4 = flatten_dims(var, [1, 2]) + var_5, eqs_5 = flatten_dims(var, [0, 1, 2]) + + model = gp.Model( + m, + "flatten_everything", + equations=[*eqs_1, *eqs_2, *eqs_3, *eqs_4, *eqs_5], + problem="lp", + objective=var_3["140", "k4"] + 1, + sense="min", + ) + + model.solve() + + var_3_bounds = gp.Parameter( + m, name="var_3_bounds", domain=[bounds_set, *var_3.domain] + ) + var_3_bounds[("lb",) + tuple(var_3.domain)] = var_3.lo[...] + var_3_bounds[("ub",) + tuple(var_3.domain)] = var_3.up[...] + + var_4_bounds = gp.Parameter( + m, name="var_4_bounds", domain=[bounds_set, *var_4.domain] + ) + var_4_bounds[("lb",) + tuple(var_4.domain)] = var_4.lo[...] + var_4_bounds[("ub",) + tuple(var_4.domain)] = var_4.up[...] + + var_5_bounds = gp.Parameter( + m, name="var_5_bounds", domain=[bounds_set, *var_5.domain] + ) + var_5_bounds[("lb",) + tuple(var_5.domain)] = var_5.lo[...] + var_5_bounds[("ub",) + tuple(var_5.domain)] = var_5.up[...] + + assert np.allclose(var_3_bounds.toDense(), all_bounds.reshape(2, 2000, 20)) + assert np.allclose(var_4_bounds.toDense(), all_bounds.reshape(2, 40, 1000)) + assert np.allclose(var_5_bounds.toDense(), all_bounds.reshape(2, 40000)) + + +def test_flatten_propagate_zero_bounds(data): + m, *_ = data + var = gp.Variable(m, name="var1", domain=dim([30, 40, 10, 5])) + + var.up[...] = 0 + var.lo[...] = 0 + + var_1, eqs_1 = flatten_dims(var, [0, 1]) + var_2, eqs_2 = flatten_dims(var, [0, 1, 2, 3]) + var_3, eqs_3 = flatten_dims(var_1, [1, 2]) + + model = gp.Model( + m, + "flatten_everything", + equations=[*eqs_1, *eqs_2, *eqs_3], + problem="lp", + objective=5 * var_1["0", "0", "0"] + 1, + sense="min", + ) + + model.solve() + + expected_bounds = np.zeros([30, 40, 10, 5]) + + # Because the bounds are zero, there are no way, currently, to represent them as an array + assert np.allclose( + np.array(var_1.records.upper).reshape(var_1.shape), + expected_bounds.reshape(var_1.shape), + ) + assert np.allclose( + np.array(var_1.records.lower).reshape(var_1.shape), + expected_bounds.reshape(var_1.shape), + ) + + assert np.allclose( + np.array(var_2.records.upper).reshape(var_2.shape), + expected_bounds.reshape(var_2.shape), + ) + assert np.allclose( + np.array(var_2.records.lower).reshape(var_2.shape), + expected_bounds.reshape(var_2.shape), + ) + + assert np.allclose( + np.array(var_3.records.upper).reshape(var_3.shape), + expected_bounds.reshape(var_3.shape), + ) + assert np.allclose( + np.array(var_3.records.lower).reshape(var_3.shape), + expected_bounds.reshape(var_3.shape), + ) + + +def test_flatten_more_complex_propagate_bounds(data): + m, *_ = data + var = gp.Variable(m, name="var", domain=dim([2, 4, 5])) + bounds_set = gp.Set(m, name="bounds_set", records=["lb", "ub"]) + + bound_up = np.array( + [ + [ + [1.6873254, np.inf, 4.64399079, np.inf, 0.85146007], + [4.31392932, 1.99165668, 4.19013802, 3.77449253, 0], + [np.inf, 4.13450595, 4.25880061, 1.529363, 2.54171194], + [1.79348688, 2.04002383, 0.19198094, 4.14445882, 4.72650868], + ], + [ + [1.54070398, np.inf, 0, 3.55077501, 2.12700496], + [0.13939228, 1.10668786, 0.23710837, 3.61857607, 1.64761417], + [1.80097419, 0.89434166, 1.46039526, 1.31960681, np.inf], + [2.50636193, 1.3920737, np.inf, 3.35616509, 4.98534911], + ], + ] + ) + + bound_lo = -bound_up + all_bounds = np.stack([bound_lo, bound_up], axis=0) + + bounds = gp.Parameter( + m, name="bounds", domain=[bounds_set, *var.domain], records=all_bounds + ) + + var.up[...] = bounds[("ub",) + tuple(var.domain)] + var.lo[...] = bounds[("lb",) + tuple(var.domain)] + + var_1, eqs_1 = flatten_dims(var, [0, 1]) + + model = gp.Model( + m, + "flatten_everything", + equations=[*eqs_1], + problem="lp", + objective=5 * var_1["5", "3"] + 1, + sense="min", + ) + + model.solve() + + var_1_bounds = gp.Parameter( + m, name="var_1_bounds", domain=[bounds_set, *var_1.domain] + ) + var_1_bounds[("lb",) + tuple(var_1.domain)] = var_1.lo[...] + var_1_bounds[("ub",) + tuple(var_1.domain)] = var_1.up[...] + + assert np.allclose(var_1_bounds.toDense(), all_bounds.reshape(2, 8, 5)) + + def test_linear_bad_init(data): m, *_ = data # in feature must be integer @@ -2033,7 +2264,7 @@ def test_linear_propagate_bounds_non_boolean(data): lin1.load_weights(w1, b1) par_input = gp.Parameter(m, domain=dim([30, 20, 30, 20])) - pytest.raises(TypeError, lin1, par_input, "True") + pytest.raises(ValidationError, lin1, par_input, "True") def test_linear_propagate_bounded_input(data): @@ -2139,3 +2370,41 @@ def test_linear_propagate_unbounded_input_with_zero_weight(data): # check if the bounds are zeros, since the weights are all zeros assert np.allclose(out1_ub, expected_bounds) assert np.allclose(out1_lb, expected_bounds) + + +def test_linear_propagate_zero_bounds(data): + m, *_ = data + lin1 = Linear(m, 4, 3, bias=False) + w1 = np.random.rand(3, 4) + lin1.load_weights(w1) + + x = gp.Variable(m, "x", domain=dim([2, 4])) + + x.up[...] = 0 + x.lo[...] = 0 + + out1, _ = lin1(x) + + expected_bounds = np.zeros((2, 3)) + + assert np.allclose( + np.array(out1.records.upper).reshape(out1.shape), expected_bounds + ) + assert np.allclose( + np.array(out1.records.lower).reshape(out1.shape), expected_bounds + ) + + lin2 = Linear(m, 4, 3, bias=True) + b1 = np.random.rand(3) + lin2.load_weights(w1, b1) + + out2, _ = lin2(x) + + expected_bounds = np.stack((b1, b1)) + + assert np.allclose( + np.array(out2.records.upper).reshape(out1.shape), expected_bounds + ) + assert np.allclose( + np.array(out2.records.lower).reshape(out1.shape), expected_bounds + ) diff --git a/tests/unit/test_variable.py b/tests/unit/test_variable.py index 3bba5078..2de1c663 100644 --- a/tests/unit/test_variable.py +++ b/tests/unit/test_variable.py @@ -51,7 +51,7 @@ def test_variable_creation(data): pytest.raises(TypeError, Variable, m, 5) # no container - pytest.raises(TypeError, Variable) + pytest.raises(ValidationError, Variable) # non-container type container pytest.raises(TypeError, Variable, 5, "j")