diff --git a/python-gams-cutstock/LICENSE b/python-gams-cutstock/LICENSE new file mode 100644 index 0000000..2c27ec7 --- /dev/null +++ b/python-gams-cutstock/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022-2024 nextmv.io inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/python-gams-cutstock/README.md b/python-gams-cutstock/README.md new file mode 100644 index 0000000..efd963f --- /dev/null +++ b/python-gams-cutstock/README.md @@ -0,0 +1,26 @@ +# Nextmv GAMS Cutstock Problem + +Example for running a Python application on the Nextmv Platform using the GAMS [control API](https://www.gams.com/latest/docs/API_PY_CONTROL.html). We solve a cutting stock problem that finds the minumum number of cuts required to satisfy the product demand. + +1. Install packages. + + ```bash + pip3 install -r requirements.txt + ``` + +1. Run the app. + + ```bash + python3 main.py -input data.json -output output.json \ + -raw_width 100 -max_pattern 35 + ``` + +## Next steps + +* Open `main.py` and modify the model. +* Visit our [docs][docs] and [blog][blog]. Need more assistance? + [Contact][contact] us! + +[docs]: https://docs.nextmv.io +[blog]: https://www.nextmv.io/blog +[contact]: https://www.nextmv.io/contact \ No newline at end of file diff --git a/python-gams-cutstock/app.yaml b/python-gams-cutstock/app.yaml new file mode 100644 index 0000000..7928930 --- /dev/null +++ b/python-gams-cutstock/app.yaml @@ -0,0 +1,11 @@ +# This manifest holds the information the app needs to run on the Nextmv Cloud. +type: python +runtime: ghcr.io/nextmv-io/runtime/python:3.11 +python: + # All listed packages will get bundled with the app. + pip-requirements: requirements.txt +# List all files/directories that should be included in the app. Globbing +# (e.g.: configs/*.json) is supported. +files: + - main.py + - cutstock.py diff --git a/python-gams-cutstock/cutstock.py b/python-gams-cutstock/cutstock.py new file mode 100644 index 0000000..6491eb4 --- /dev/null +++ b/python-gams-cutstock/cutstock.py @@ -0,0 +1,228 @@ +""" +@file +This example implements a column generation approach to solve the +cutting stock problem. In this example, the column generation approach +has been entirely implemented in the program using GamsJob for the +master and GamsModelInstance for the pricing problem. GAMS is used to +build the master and pricing problems. The logic of the column +generation method is in the application program. +""" + +import importlib.util +import os +from gams import GamsModifier, GamsWorkspace + +GAMS_MASTER_MODEL = """ +Set i 'widths'; + +Parameter + w(i) 'width' + d(i) 'demand'; + +Scalar + r 'raw width'; + +$gdxIn csdata +$load i w d r +$gdxIn + +$if not set pmax $set pmax 1000 +Set + p 'possible patterns' / 1*%pmax% / + pp(p) 'dynamic subset of p'; + +Parameter + aip(i,p) 'number of width i in pattern growing in p'; + +Variable + xp(p) 'patterns used' + z 'objective variable'; + +Integer Variable xp; +xp.up(p) = sum(i, d(i)); + +Equation + numpat + demand(i); + + numpat.. z =e= sum(pp, xp(pp)); + + demand(i).. sum(pp, aip(i,pp)*xp(pp)) =g= d(i); + +Model master / numpat, demand /; +""" + +GAMS_SUB_MODEL = """ +Set i 'widths'; + +Parameter w(i) 'width'; +Scalar r 'raw width'; + +$gdxIn csdata +$load i w r +$gdxIn + +Parameter + demdual(i) 'duals of master demand constraint' / #i eps /; + +Variable + z + y(i) 'new pattern'; +Integer Variable y; +y.up(i) = ceil(r/w(i)); + +Equation + defobj + knapsack; + +defobj.. z =e= 1 - sum(i, demdual(i)*y(i)); + +knapsack.. sum(i, w(i)*y(i)) =l= r; + +Model pricing / defobj, knapsack /; +""" + + +def cutStockModel( + d: dict, + w: dict, + r: int, + max_pattern: int, +): + """ + Args: + d: demand + w: width + r: raw width + max_pattern + """ + spec = importlib.util.find_spec("gamspy_base") + if spec and spec.origin: + sys_dir = os.path.dirname(spec.origin) + else: + raise Exception(">gamspy_base< not found! Quiting.") + + ws = GamsWorkspace(system_directory=sys_dir) + + opt = ws.add_options() + cutstock_data = ws.add_database("csdata") + opt.all_model_types = "Cplex" + opt.optcr = 0.0 # solve to optimality + + opt.defines["pmax"] = str(max_pattern) + opt.defines["solveMasterAs"] = "RMIP" + + raw_width = cutstock_data.add_parameter("r", 0, "raw width") + raw_width.add_record().value = int(r) + + demand = cutstock_data.add_parameter("d", 1, "demand") + widths = cutstock_data.add_set("i", 1, "widths") + for k, v in d.items(): + widths.add_record(k) + demand.add_record(k).value = v + + width = cutstock_data.add_parameter("w", 1, "width") + for k, v in w.items(): + width.add_record(k).value = v + + cp_master = ws.add_checkpoint() + job_master_init = ws.add_job_from_string(GAMS_MASTER_MODEL) + job_master_init.run(opt, cp_master, databases=cutstock_data) + job_master = ws.add_job_from_string( + "execute_load 'csdata', aip, pp; solve master min z using %solveMasterAs%;", + cp_master, + ) + + pattern = cutstock_data.add_set("pp", 1, "pattern index") + pattern_data = cutstock_data.add_parameter("aip", 2, "pattern data") + + if max_pattern < len(d): + raise Exception( + f"Maximum patterns ({max_pattern}) cannot be less than number of products ({len(d)})." + ) + + # initial pattern: pattern i hold width i + pattern_count = 0 + for k, v in w.items(): + pattern_count += 1 + pattern_data.add_record( + (k, pattern.add_record(str(pattern_count)).key(0)) + ).value = (int)(r / v) + + cp_sub = ws.add_checkpoint() + job_sub = ws.add_job_from_string(GAMS_SUB_MODEL) + job_sub.run(opt, cp_sub, databases=cutstock_data) + mi_sub = cp_sub.add_modelinstance() + + # define modifier demdual + demand_dual = mi_sub.sync_db.add_parameter( + "demdual", 1, "dual of demand from master" + ) + mi_sub.instantiate("pricing min z using mip", GamsModifier(demand_dual), opt) + + # find new pattern + list_of_new_patterns = [] + patternFlag = False + while True: + job_master.run(opt, cp_master, databases=cutstock_data) + # copy duals into mi_sub.sync_db DB + demand_dual.clear() + for dem in job_master.out_db["demand"]: + demand_dual.add_record(dem.key(0)).value = dem.marginal + mi_sub.solve() + if mi_sub.sync_db["z"].first_record().level < -0.00001: + if pattern_count == max_pattern: + patternFlag = True + print( + f"Out of pattern. Increase max_pattern (currently {max_pattern})." + ) + break + else: + new_pattern = mi_sub.sync_db["z"].first_record().level + list_of_new_patterns.append(new_pattern) + print(f"New pattern! Value: {new_pattern}") + pattern_count += 1 + s = pattern.add_record(str(pattern_count)) + for y in mi_sub.sync_db["y"]: + if y.level > 0.5: + pattern_data.add_record((y.key(0), s.key(0))).value = round( + y.level + ) + else: + break + + # solve final MIP + opt.defines["solveMasterAs"] = "MIP" + job_master.run(opt, databases=cutstock_data) + obj_val = job_master.out_db["z"].first_record().level + print(f"Objective Value: {obj_val}") + cuts = {} + for xp in job_master.out_db["xp"]: + if xp.level > 0.5: + print(f" pattern {xp.key(0)} {xp.level} times:") + aip = job_master.out_db["aip"].first_record((" ", xp.key(0))) + cuts[f"Pattern {xp.key(0)}"] = {"ncuts": xp.level, "itemCut": None} + icut = {} + while True: + icut[aip.key(0)] = aip.value + print(f" {aip.key(0)}: {aip.value}") + if not aip.move_next(): + break + cuts[f"Pattern {xp.key(0)}"]["itemCut"] = icut + + return ([patternFlag, list_of_new_patterns], int(obj_val), cuts) + + +if __name__ == "__main__": + demand = {"Kraft": 97, "Newsprint": 610, "Coated": 395, "Lightweight": 211} + width = {"Kraft": 47, "Newsprint": 36, "Coated": 31, "Lightweight": 14} + raw_width = 100 + max_pattern = 35 + + [_, list_of_new_patterns], obj_val, cut_info = cutStockModel( + d=demand, w=width, r=raw_width, max_pattern=max_pattern + ) + + print(f"New patterns: {list_of_new_patterns}") + print(f"Objective value: {obj_val}") + print(f"Cut info: {cut_info}") diff --git a/python-gams-cutstock/data.json b/python-gams-cutstock/data.json new file mode 100644 index 0000000..897aa1c --- /dev/null +++ b/python-gams-cutstock/data.json @@ -0,0 +1,20 @@ +{ + "ID": [ + "Kraft", + "Newsprint", + "Coated", + "Lightweight" + ], + "demand": [ + 97, + 610, + 395, + 211 + ], + "width": [ + 47, + 36, + 31, + 14 + ] +} diff --git a/python-gams-cutstock/main.py b/python-gams-cutstock/main.py new file mode 100644 index 0000000..af72f74 --- /dev/null +++ b/python-gams-cutstock/main.py @@ -0,0 +1,69 @@ +import time +import nextmv + +from cutstock import cutStockModel + + +def main() -> None: + """Entry point for the program.""" + + options = nextmv.Options( + nextmv.Option("input", str, "", "Path to input file. Default is stdin.", False), + nextmv.Option("raw_width", int, 100, "Total width of a pattern", False), + nextmv.Option("max_pattern", int, 35, "Maximum possible pattern", False), + nextmv.Option( + "output", str, "", "Path to output file. Default is stdout.", False + ), + ) + + input = nextmv.load(options=options, path=options.input) + + model = CutStockModel() + output = model.solve(input) + nextmv.write(output, path=options.output) + + +class CutStockModel(nextmv.Model): + def solve(self, input: nextmv.Input) -> nextmv.Output: + """Solves the given problem and returns the solution.""" + + max_pattern = input.options.max_pattern + raw_width = input.options.raw_width + start_time = time.time() + nextmv.redirect_stdout() # Solver chatter is logged to stderr. + + demand = {id: d for id, d in zip(input.data["ID"], input.data["demand"])} + width = {id: w for id, w in zip(input.data["ID"], input.data["width"])} + + nextmv.log("Solving Cutting Stock Problem:") + nextmv.log(f"- Number of Materials: {len(demand)}") + + start = time.time() + [patternFlag, list_of_new_patterns], obj_val, cuts = cutStockModel( + d=demand, w=width, r=raw_width, max_pattern=max_pattern + ) + tot_time = round(time.time() - start, 2) + + statistics = nextmv.Statistics( + run=nextmv.RunStatistics(duration=time.time() - start_time), + result=nextmv.ResultStatistics( + duration=tot_time, + value=f"{obj_val}", + custom={ + "New Patterns": f"{list_of_new_patterns}", + "Requires more Pattern": f"{patternFlag}", + }, + ), + ) + + return nextmv.Output( + options=input.options, + solution={ + "solution": cuts, + }, + statistics=statistics, + ) + + +if __name__ == "__main__": + main() diff --git a/python-gams-cutstock/requirements.txt b/python-gams-cutstock/requirements.txt new file mode 100644 index 0000000..e33edd3 --- /dev/null +++ b/python-gams-cutstock/requirements.txt @@ -0,0 +1,4 @@ +# Define the packages required by your project here. +gamsapi[control]==50.1.0 +gamspy_base==50.1.0 +nextmv==0.29.1 \ No newline at end of file diff --git a/python-gamspy-traveling-salesman/LICENSE b/python-gamspy-traveling-salesman/LICENSE new file mode 100644 index 0000000..2c27ec7 --- /dev/null +++ b/python-gamspy-traveling-salesman/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022-2024 nextmv.io inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/python-gamspy-traveling-salesman/README.md b/python-gamspy-traveling-salesman/README.md new file mode 100644 index 0000000..aad63df --- /dev/null +++ b/python-gamspy-traveling-salesman/README.md @@ -0,0 +1,26 @@ +# Nextmv GAMSPy Traveling Salesman Problem + +Example for running a Python application on the Nextmv Platform using [GAMSPy](https://gamspy.readthedocs.io/en/latest/) to model the problem. We solve the traveling salesman problem that minimizes the total distance travelled while visiting each city exactly once. + +1. Install packages. + + ```bash + pip3 install -r requirements.txt + ``` + +1. Run the app. + + ```bash + python3 main.py -input data.json -output output.json \ + -maxnodes 5 + ``` + +## Next steps + +* Open `main.py` and modify the model. +* Visit our [docs][docs] and [blog][blog]. Need more assistance? + [Contact][contact] us! + +[docs]: https://docs.nextmv.io +[blog]: https://www.nextmv.io/blog +[contact]: https://www.nextmv.io/contact \ No newline at end of file diff --git a/python-gamspy-traveling-salesman/app.yaml b/python-gamspy-traveling-salesman/app.yaml new file mode 100644 index 0000000..f9280ba --- /dev/null +++ b/python-gamspy-traveling-salesman/app.yaml @@ -0,0 +1,11 @@ +# This manifest holds the information the app needs to run on the Nextmv Cloud. +type: python +runtime: ghcr.io/nextmv-io/runtime/python:3.11 +python: + # All listed packages will get bundled with the app. + pip-requirements: requirements.txt +# List all files/directories that should be included in the app. Globbing +# (e.g.: configs/*.json) is supported. +files: + - main.py + - tsp.py \ No newline at end of file diff --git a/python-gamspy-traveling-salesman/data.json b/python-gamspy-traveling-salesman/data.json new file mode 100644 index 0000000..90d1551 --- /dev/null +++ b/python-gamspy-traveling-salesman/data.json @@ -0,0 +1,802 @@ +[ + { + "row_idx": 3354, + "row": { + "city": "Berlin", + "latitude": 52.5166667, + "longitude": 13.3999996 + } + }, + { + "row_idx": 3355, + "row": { + "city": "Hamburg", + "latitude": 53.55, + "longitude": 10.0 + } + }, + { + "row_idx": 3356, + "row": { + "city": "Muenchen", + "latitude": 48.1376831, + "longitude": 11.5743542 + } + }, + { + "row_idx": 3357, + "row": { + "city": "Koeln", + "latitude": 50.9333333, + "longitude": 6.9499998 + } + }, + { + "row_idx": 3358, + "row": { + "city": "Frankfurt am Main", + "latitude": 50.1166667, + "longitude": 8.6833334 + } + }, + { + "row_idx": 3359, + "row": { + "city": "Essen", + "latitude": 51.45, + "longitude": 7.0166669 + } + }, + { + "row_idx": 3360, + "row": { + "city": "Stuttgart", + "latitude": 48.7823243, + "longitude": 9.1770172 + } + }, + { + "row_idx": 3361, + "row": { + "city": "Dortmund", + "latitude": 51.5166667, + "longitude": 7.4499998 + } + }, + { + "row_idx": 3362, + "row": { + "city": "Dusseldorf", + "latitude": 51.2217226, + "longitude": 6.7761612 + } + }, + { + "row_idx": 3363, + "row": { + "city": "Bremen", + "latitude": 53.0751557, + "longitude": 8.8077736 + } + }, + { + "row_idx": 3364, + "row": { + "city": "Hannover", + "latitude": 52.3705163, + "longitude": 9.7332211 + } + }, + { + "row_idx": 3365, + "row": { + "city": "Leipzig", + "latitude": 51.33962, + "longitude": 12.3712921 + } + }, + { + "row_idx": 3366, + "row": { + "city": "Duisburg", + "latitude": 51.4333333, + "longitude": 6.75 + } + }, + { + "row_idx": 3367, + "row": { + "city": "Nuernberg", + "latitude": 49.4477778, + "longitude": 11.0683336 + } + }, + { + "row_idx": 3368, + "row": { + "city": "Dresden", + "latitude": 51.0508911, + "longitude": 13.7383175 + } + }, + { + "row_idx": 3369, + "row": { + "city": "Bochum", + "latitude": 51.4833333, + "longitude": 7.2166667 + } + }, + { + "row_idx": 3370, + "row": { + "city": "Wuppertal", + "latitude": 51.2666667, + "longitude": 7.1833334 + } + }, + { + "row_idx": 3371, + "row": { + "city": "Bielefeld", + "latitude": 52.0333333, + "longitude": 8.5333328 + } + }, + { + "row_idx": 3372, + "row": { + "city": "Bonn", + "latitude": 50.7333333, + "longitude": 7.0999999 + } + }, + { + "row_idx": 3373, + "row": { + "city": "Mannheim", + "latitude": 49.4883333, + "longitude": 8.4647226 + } + }, + { + "row_idx": 3374, + "row": { + "city": "Karlsruhe", + "latitude": 49.0047222, + "longitude": 8.3858337 + } + }, + { + "row_idx": 3375, + "row": { + "city": "Wiesbaden", + "latitude": 50.0833333, + "longitude": 8.25 + } + }, + { + "row_idx": 3376, + "row": { + "city": "Munster", + "latitude": 51.9623559, + "longitude": 7.6257133 + } + }, + { + "row_idx": 3377, + "row": { + "city": "Gelsenkirchen-Alt", + "latitude": 51.5166667, + "longitude": 7.1166668 + } + }, + { + "row_idx": 3378, + "row": { + "city": "Aachen", + "latitude": 50.7766356, + "longitude": 6.0834217 + } + }, + { + "row_idx": 3379, + "row": { + "city": "Monchengladbach", + "latitude": 51.2, + "longitude": 6.4333334 + } + }, + { + "row_idx": 3380, + "row": { + "city": "Augsburg", + "latitude": 48.3666667, + "longitude": 10.8833332 + } + }, + { + "row_idx": 3381, + "row": { + "city": "Chemnitz", + "latitude": 50.8333333, + "longitude": 12.916667 + } + }, + { + "row_idx": 3382, + "row": { + "city": "Braunschweig", + "latitude": 52.2666667, + "longitude": 10.5333328 + } + }, + { + "row_idx": 3383, + "row": { + "city": "Halle-Neustadt", + "latitude": 51.4833333, + "longitude": 11.9333334 + } + }, + { + "row_idx": 3384, + "row": { + "city": "Krefeld", + "latitude": 51.3333333, + "longitude": 6.5666666 + } + }, + { + "row_idx": 3385, + "row": { + "city": "Halle", + "latitude": 51.5, + "longitude": 12.0 + } + }, + { + "row_idx": 3386, + "row": { + "city": "Kiel", + "latitude": 54.3213293, + "longitude": 10.1348877 + } + }, + { + "row_idx": 3387, + "row": { + "city": "Magdeburg", + "latitude": 52.1666667, + "longitude": 11.666667 + } + }, + { + "row_idx": 3388, + "row": { + "city": "Neustadt", + "latitude": 52.15, + "longitude": 11.6333332 + } + }, + { + "row_idx": 3389, + "row": { + "city": "Oberhausen", + "latitude": 51.4666667, + "longitude": 6.8499999 + } + }, + { + "row_idx": 3390, + "row": { + "city": "Freiburg", + "latitude": 47.9958954, + "longitude": 7.8522205 + } + }, + { + "row_idx": 3391, + "row": { + "city": "Lubeck", + "latitude": 53.868927, + "longitude": 10.687294 + } + }, + { + "row_idx": 3392, + "row": { + "city": "Erfurt", + "latitude": 50.9833333, + "longitude": 11.0333328 + } + }, + { + "row_idx": 3393, + "row": { + "city": "Hagen", + "latitude": 51.35, + "longitude": 7.4666667 + } + }, + { + "row_idx": 3394, + "row": { + "city": "Rostock", + "latitude": 54.0886975, + "longitude": 12.1404934 + } + }, + { + "row_idx": 3395, + "row": { + "city": "Kassel", + "latitude": 51.3166667, + "longitude": 9.5 + } + }, + { + "row_idx": 3396, + "row": { + "city": "Hamm", + "latitude": 51.6803258, + "longitude": 7.8208923 + } + }, + { + "row_idx": 3397, + "row": { + "city": "Mainz", + "latitude": 50.0, + "longitude": 8.2711115 + } + }, + { + "row_idx": 3398, + "row": { + "city": "Saarbrucken", + "latitude": 49.2333333, + "longitude": 7.0 + } + }, + { + "row_idx": 3399, + "row": { + "city": "Herne", + "latitude": 51.55, + "longitude": 7.2166667 + } + }, + { + "row_idx": 3400, + "row": { + "city": "Mulheim an der Ruhr", + "latitude": 51.4333333, + "longitude": 6.8833332 + } + }, + { + "row_idx": 3401, + "row": { + "city": "Osnabruck", + "latitude": 52.2666667, + "longitude": 8.0500002 + } + }, + { + "row_idx": 3402, + "row": { + "city": "Solingen", + "latitude": 51.1833333, + "longitude": 7.0833335 + } + }, + { + "row_idx": 3403, + "row": { + "city": "Ludwigshafen am Rhein", + "latitude": 49.4811111, + "longitude": 8.4352779 + } + }, + { + "row_idx": 3404, + "row": { + "city": "Leverkusen", + "latitude": 51.0333333, + "longitude": 7.0 + } + }, + { + "row_idx": 3405, + "row": { + "city": "Oldenburg", + "latitude": 53.1666667, + "longitude": 8.1999998 + } + }, + { + "row_idx": 3406, + "row": { + "city": "Neuss", + "latitude": 51.2, + "longitude": 6.6833334 + } + }, + { + "row_idx": 3407, + "row": { + "city": "Potsdam", + "latitude": 52.3988578, + "longitude": 13.0656624 + } + }, + { + "row_idx": 3408, + "row": { + "city": "Heidelberg", + "latitude": 49.4076783, + "longitude": 8.6907864 + } + }, + { + "row_idx": 3409, + "row": { + "city": "Paderborn", + "latitude": 51.7190528, + "longitude": 8.7543869 + } + }, + { + "row_idx": 3410, + "row": { + "city": "Darmstadt", + "latitude": 49.8705556, + "longitude": 8.6494446 + } + }, + { + "row_idx": 3411, + "row": { + "city": "Wurzburg", + "latitude": 49.7877778, + "longitude": 9.9361115 + } + }, + { + "row_idx": 3412, + "row": { + "city": "Regensburg", + "latitude": 49.015, + "longitude": 12.0955553 + } + }, + { + "row_idx": 3413, + "row": { + "city": "Wolfsburg", + "latitude": 52.4333333, + "longitude": 10.8000002 + } + }, + { + "row_idx": 3414, + "row": { + "city": "Recklinghausen", + "latitude": 51.6166667, + "longitude": 7.1999998 + } + }, + { + "row_idx": 3415, + "row": { + "city": "Gottingen", + "latitude": 51.5333333, + "longitude": 9.9333334 + } + }, + { + "row_idx": 3416, + "row": { + "city": "Heilbronn", + "latitude": 49.1402778, + "longitude": 9.2200003 + } + }, + { + "row_idx": 3417, + "row": { + "city": "Ingolstadt", + "latitude": 48.7666667, + "longitude": 11.4333334 + } + }, + { + "row_idx": 3418, + "row": { + "city": "Ulm", + "latitude": 48.3984084, + "longitude": 9.9915504 + } + }, + { + "row_idx": 3419, + "row": { + "city": "Bottrop", + "latitude": 51.5166667, + "longitude": 6.9166665 + } + }, + { + "row_idx": 3420, + "row": { + "city": "Pforzheim", + "latitude": 48.8833333, + "longitude": 8.6999998 + } + }, + { + "row_idx": 3421, + "row": { + "city": "Offenbach", + "latitude": 50.1, + "longitude": 8.7666664 + } + }, + { + "row_idx": 3422, + "row": { + "city": "Bremerhaven", + "latitude": 53.55, + "longitude": 8.583333 + } + }, + { + "row_idx": 3423, + "row": { + "city": "Remscheid", + "latitude": 51.1833333, + "longitude": 7.1999998 + } + }, + { + "row_idx": 3424, + "row": { + "city": "Reutlingen", + "latitude": 48.49144, + "longitude": 9.2042685 + } + }, + { + "row_idx": 3425, + "row": { + "city": "Furth", + "latitude": 49.4759325, + "longitude": 10.9885597 + } + }, + { + "row_idx": 3426, + "row": { + "city": "Moers", + "latitude": 51.45, + "longitude": 6.6500001 + } + }, + { + "row_idx": 3427, + "row": { + "city": "Koblenz", + "latitude": 50.35, + "longitude": 7.5999999 + } + }, + { + "row_idx": 3428, + "row": { + "city": "Siegen", + "latitude": 50.8666667, + "longitude": 8.0333328 + } + }, + { + "row_idx": 3429, + "row": { + "city": "Bergisch Gladbach", + "latitude": 50.9833333, + "longitude": 7.1333332 + } + }, + { + "row_idx": 3430, + "row": { + "city": "Jena", + "latitude": 50.9333333, + "longitude": 11.583333 + } + }, + { + "row_idx": 3431, + "row": { + "city": "Gera", + "latitude": 50.8666667, + "longitude": 12.083333 + } + }, + { + "row_idx": 3432, + "row": { + "city": "Hildesheim", + "latitude": 52.15, + "longitude": 9.9666672 + } + }, + { + "row_idx": 3433, + "row": { + "city": "Erlangen", + "latitude": 49.5897222, + "longitude": 11.0038891 + } + }, + { + "row_idx": 3434, + "row": { + "city": "Witten", + "latitude": 51.4333333, + "longitude": 7.3333335 + } + }, + { + "row_idx": 3435, + "row": { + "city": "Trier", + "latitude": 49.7556526, + "longitude": 6.6393471 + } + }, + { + "row_idx": 3436, + "row": { + "city": "Zwickau", + "latitude": 50.7333333, + "longitude": 12.5 + } + }, + { + "row_idx": 3437, + "row": { + "city": "Kaiserslautern", + "latitude": 49.45, + "longitude": 7.75 + } + }, + { + "row_idx": 3438, + "row": { + "city": "Iserlohn", + "latitude": 51.3666667, + "longitude": 7.6999998 + } + }, + { + "row_idx": 3439, + "row": { + "city": "Schwerin", + "latitude": 53.6333333, + "longitude": 11.3833332 + } + }, + { + "row_idx": 3440, + "row": { + "city": "Gutersloh", + "latitude": 51.9, + "longitude": 8.3833332 + } + }, + { + "row_idx": 3441, + "row": { + "city": "Duren", + "latitude": 50.8, + "longitude": 6.4833331 + } + }, + { + "row_idx": 3442, + "row": { + "city": "Esslingen", + "latitude": 48.7396066, + "longitude": 9.3047333 + } + }, + { + "row_idx": 3443, + "row": { + "city": "Ratingen", + "latitude": 51.2972421, + "longitude": 6.8492889 + } + }, + { + "row_idx": 3444, + "row": { + "city": "Marl", + "latitude": 51.65, + "longitude": 7.0833335 + } + }, + { + "row_idx": 3445, + "row": { + "city": "Lunen", + "latitude": 51.6166667, + "longitude": 7.5166669 + } + }, + { + "row_idx": 3446, + "row": { + "city": "Hanau am Main", + "latitude": 50.1333333, + "longitude": 8.916667 + } + }, + { + "row_idx": 3447, + "row": { + "city": "Velbert", + "latitude": 51.3333333, + "longitude": 7.0500002 + } + }, + { + "row_idx": 3448, + "row": { + "city": "Ludwigsburg", + "latitude": 48.8973114, + "longitude": 9.1916084 + } + }, + { + "row_idx": 3449, + "row": { + "city": "Flensburg", + "latitude": 54.7833333, + "longitude": 9.4333334 + } + }, + { + "row_idx": 3450, + "row": { + "city": "Cottbus", + "latitude": 51.7666667, + "longitude": 14.333333 + } + }, + { + "row_idx": 3451, + "row": { + "city": "Wilhelmshaven", + "latitude": 53.5166667, + "longitude": 8.1333332 + } + }, + { + "row_idx": 3452, + "row": { + "city": "Tubingen", + "latitude": 48.522659, + "longitude": 9.0522194 + } + }, + { + "row_idx": 3453, + "row": { + "city": "Minden", + "latitude": 52.2833333, + "longitude": 8.916667 + } + } +] \ No newline at end of file diff --git a/python-gamspy-traveling-salesman/main.py b/python-gamspy-traveling-salesman/main.py new file mode 100644 index 0000000..107506a --- /dev/null +++ b/python-gamspy-traveling-salesman/main.py @@ -0,0 +1,91 @@ +import time + +import pandas as pd +import numpy as np +import nextmv + +from tsp import tspModel, getPath + + +def main() -> None: + """Entry point for the program.""" + + options = nextmv.Options( + nextmv.Option("input", str, "", "Path to input file. Default is stdin.", False), + nextmv.Option( + "maxnodes", int, 5, "Maximum number of nodes to solve the model with", False + ), + nextmv.Option( + "output", str, "", "Path to output file. Default is stdout.", False + ), + ) + + input = nextmv.load(options=options, path=options.input) + + model = TSPModel() + output = model.solve(input) + nextmv.write(output, path=options.output) + + +class TSPModel(nextmv.Model): + def solve(self, input: nextmv.Input) -> nextmv.Output: + """Solves the given problem and returns the solution.""" + + max_nodes = input.options.maxnodes + start_time = time.time() + nextmv.redirect_stdout() # Solver chatter is logged to stderr. + city_data = input.data + nextmv.log("Solving Traveling Salesman problem:") + nextmv.log(f" - Number of nodes: {len(city_data)}") + nextmv.log(f" - Solving for >{max_nodes}< nodes") + + city_df = pd.json_normalize(city_data) + city_df = city_df[["row.city", "row.latitude", "row.longitude"]] + + def euclidean_distance_matrix(coords): + diff = coords[:, np.newaxis, :] - coords[np.newaxis, :, :] + dist_matrix = np.sqrt(np.sum(diff**2, axis=-1)) + return dist_matrix + + dist_mat = euclidean_distance_matrix( + city_df[["row.latitude", "row.longitude"]].to_numpy() + ) + dist_df = pd.DataFrame( + dist_mat, index=city_df["row.city"], columns=city_df["row.city"] + ) + distance_df = dist_df.reset_index().melt( + id_vars="row.city", var_name="to_city", value_name="distance" + ) + + [sol, tot_time], model = tspModel( + nodes_recs=city_df, distance_recs=distance_df, maxnodes=max_nodes + ) + + path = getPath(sol) + + statistics = nextmv.Statistics( + run=nextmv.RunStatistics(duration=time.time() - start_time), + result=nextmv.ResultStatistics( + duration=tot_time, + value=model.objective_value, + custom={ + "status": model.status.name, + "variables": model.num_variables, + "constraints": model.num_equations, + "solution_path": " -> ".join(path), + }, + ), + ) + + return nextmv.Output( + options=input.options, + solution={ + "solution": sol.to_dict(), + "total_distance": model.objective_value, + }, + statistics=statistics, + ) + + +if __name__ == "__main__": + main() diff --git a/python-gamspy-traveling-salesman/requirements.txt b/python-gamspy-traveling-salesman/requirements.txt new file mode 100644 index 0000000..ab74296 --- /dev/null +++ b/python-gamspy-traveling-salesman/requirements.txt @@ -0,0 +1,4 @@ +# Define the packages required by your project here. +gamspy==1.12.1 +networkx==3.5 +nextmv==0.29.1 \ No newline at end of file diff --git a/python-gamspy-traveling-salesman/tsp.py b/python-gamspy-traveling-salesman/tsp.py new file mode 100644 index 0000000..e74d3f4 --- /dev/null +++ b/python-gamspy-traveling-salesman/tsp.py @@ -0,0 +1,174 @@ +import json +import time + +import gamspy as gp +from gamspy.exceptions import GamspyException + +import networkx as nx +import numpy as np +import pandas as pd + + +def find_subtours(sol: pd.DataFrame): + G = nx.Graph() + G.add_edges_from( + [(i, j) for i, j in sol[["n1", "n2"]].itertuples(index=False, name=None)] + ) + components = list(nx.connected_components(G)) + + return [list(comp) for comp in components] + +def getPath(sol: pd.DataFrame): + path = [sol.n1.iloc[0], sol.n2.iloc[0]] + + while path[-1] != path[0]: + current_node = path[-1] + previous_node = path[-2] + + connected_rows = sol[(sol.n1 == current_node) | (sol.n2 == current_node)] + + for _, row in connected_rows.iterrows(): + next_node_candidate = row.n1 if row.n2 == current_node else row.n2 + if next_node_candidate != previous_node: + path.append(next_node_candidate) + break + + return path + + +def tspModel( + nodes_recs: pd.DataFrame, distance_recs: pd.DataFrame, maxnodes: int = 10 +) -> tuple[list[pd.DataFrame, float], gp.Model]: + m = gp.Container() + + nodes = gp.Set(m, name="set_of_nodes", records=nodes_recs["row.city"]) + + n1 = gp.Alias(m, name="n1", alias_with=nodes) + n2 = gp.Alias(m, name="n2", alias_with=nodes) + + i = gp.Set(m, name="i", domain=[n1], description="dynamic subset of nodes") + j = gp.Alias(m, name="j", alias_with=i) + k = gp.Alias(m, name="k", alias_with=i) + + edges = gp.Set(m, name="allowed_arcs", domain=[n1, n2]) + distance = gp.Parameter(m, name="distance_matrix", domain=[n1, n2], records=distance_recs) + + i[n1].where[gp.Ord(n1) <= maxnodes] = True + edges[n1, n2].where[(gp.Ord(n1) > gp.Ord(n2)) & i[n1] & j[n2]] = True + + X = gp.Variable( + m, + name="x", + type="binary", + domain=[n1, n2], + description="decision variable - leg of trip", + ) + + objective_function = gp.Sum(edges[i, j], distance[i, j] * X[i, j]) + + eq_degree = gp.Equation(m, "eq_degree", domain=[n1]) + eq_degree[k] = gp.Sum(edges[i, k], X[i, k]) + gp.Sum(edges[k, j], X[k, j]) == 2 + + if not distance[i, j].records.equals(distance[j, i].records): + raise Exception("Distance matrix is not symmetric. Quitting!") + + s = gp.Set( + m, + "s", + description="Powerset", + records=range(1000), + ) + + active_cut = gp.Set(m, "active_cut", domain=[s]) + sn = gp.Set(m, "sn", domain=[s, n1], description="subset_membership") + + eq_dfj = gp.Equation(m, "eq_dfj", domain=[s]) + + eq_dfj[active_cut] = ( + gp.Sum( + gp.Domain(i, j).where[ + edges[i, j] & (sn[active_cut, i]) & (sn[active_cut, j]) + ], + X[i, j], + ) + <= gp.Sum(i.where[sn[active_cut, i]], 1) - 1 + ) + + tsp = gp.Model( + m, + name="tsp", + problem="MIP", + sense=gp.Sense.MIN, + objective=objective_function, + equations=[eq_degree, eq_dfj], + ) + + cnt = 0 + MAXCUTS = len(s) + time_limit = 180 + tot_time = 0 + current_tour = gp.Set(m, "current_tour", domain=[n1]) + + while True: + start = time.time() + tsp.solve(solver="CPLEX", options=gp.Options(time_limit=time_limit)) + sol = X[...].where[X.l > 0.5].records + subtours = find_subtours(sol) + + if len(subtours) == 1: + print("***All illegal subtours are removed. Solution found!***") + break + + if cnt + len(subtours) > MAXCUTS: + raise GamspyException( + f"Found {len(subtours)} illegal subtours, but adding them would" + f" exceed the cut limit of {MAXCUTS}." + ) + + for idx, tour in enumerate(subtours, start=cnt): + current_tour.setRecords(tour) + sn[idx, current_tour] = True + + cnt += len(subtours) + print(f"Subtours in current solution: {len(subtours)} | total subtours: {cnt}") + active_cut[s] = gp.Ord(s) <= cnt + tot_time += time.time() - start + + if tot_time > time_limit: + print("Total timelimit reached. Stopping!") + break + + return [sol, tot_time], tsp + + +def main(): + def euclidean_distance_matrix(coords): + diff = coords[:, np.newaxis, :] - coords[np.newaxis, :, :] + dist_matrix = np.sqrt(np.sum(diff**2, axis=-1)) + return dist_matrix + + with open(r"germany_cities.json", "r") as fp: + city_data = json.load(fp) + + city_df = pd.json_normalize(city_data["nodes"]) + dist_mat = euclidean_distance_matrix( + city_df[["row.latitude", "row.longitude"]].to_numpy() + ) + dist_df = pd.DataFrame( + dist_mat, index=city_df["row.city"], columns=city_df["row.city"] + ) + distance_df = dist_df.reset_index().melt( + id_vars="row.city", var_name="to_city", value_name="distance" + ) + + sol_list, tsp = tspModel(nodes_recs=city_df, distance_recs=distance_df, maxnodes=20) + sol, _ = sol_list + + path = getPath(sol=sol) + + print(f"Objective Value = {tsp.objective_value * 100: .2f} km") + print("Solution path:\n", " -> ".join(path)) + + +if __name__ == "__main__": + main()